index.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import {Children, type ReactNode, useRef, useState} from 'react';
  2. import React from 'react';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
  6. import {AnnotatedText} from 'sentry/components/events/meta/annotatedText';
  7. import {AnnotatedTextErrors} from 'sentry/components/events/meta/annotatedText/annotatedTextErrors';
  8. import Link from 'sentry/components/links/link';
  9. import Panel from 'sentry/components/panels/panel';
  10. import {StructuredData} from 'sentry/components/structuredEventData';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {KeyValueListDataItem, MetaError} from 'sentry/types/group';
  14. import {defined} from 'sentry/utils';
  15. export interface KeyValueDataContentProps {
  16. /**
  17. * Specifies the item to display.
  18. * - If set, item.subjectNode will override displaying item.subject.
  19. * - If item.subjectNode is null, the value section will span the whole card.
  20. * - If item.action.link is specified, the value will appear as a link.
  21. * - If item.actionButton is specified, the button will be rendered inline with the value.
  22. */
  23. item: KeyValueListDataItem;
  24. /**
  25. * If enabled, renders raw value instead of formatted structured data
  26. */
  27. disableFormattedData?: boolean;
  28. /**
  29. * If enabled, avoids rendering links, even if provided via `item.action.link`.
  30. */
  31. disableLink?: boolean;
  32. /**
  33. * Errors pertaining to content item
  34. */
  35. errors?: MetaError[];
  36. /**
  37. * Metadata pertaining to content item
  38. */
  39. meta?: Record<string, any>;
  40. }
  41. export function Content({
  42. item,
  43. meta,
  44. errors = [],
  45. disableLink = false,
  46. disableFormattedData = false,
  47. ...props
  48. }: KeyValueDataContentProps) {
  49. const {
  50. subject,
  51. subjectNode,
  52. value: contextValue,
  53. action = {},
  54. actionButton,
  55. actionButtonAlwaysVisible,
  56. } = item;
  57. const hasErrors = errors.length > 0;
  58. const hasSuffix = !!(hasErrors || actionButton);
  59. const dataComponent = disableFormattedData ? (
  60. React.isValidElement(contextValue) ? (
  61. contextValue
  62. ) : (
  63. <AnnotatedText value={contextValue as string} meta={meta} />
  64. )
  65. ) : (
  66. <StructuredData
  67. value={contextValue}
  68. maxDefaultDepth={0}
  69. meta={meta}
  70. withAnnotatedText
  71. withOnlyFormattedText
  72. />
  73. );
  74. return (
  75. <ContentWrapper hasErrors={hasErrors} {...props}>
  76. {subjectNode !== undefined ? subjectNode : <Subject>{subject}</Subject>}
  77. <ValueSection hasErrors={hasErrors} hasEmptySubject={subjectNode === null}>
  78. <ValueWrapper hasSuffix={hasSuffix}>
  79. {!disableLink && defined(action?.link) ? (
  80. <ValueLink to={action.link}>{dataComponent}</ValueLink>
  81. ) : (
  82. dataComponent
  83. )}
  84. </ValueWrapper>
  85. {hasSuffix && (
  86. <div>
  87. {hasErrors && <AnnotatedTextErrors errors={errors} />}
  88. {actionButton && (
  89. <ActionButtonWrapper actionButtonAlwaysVisible={actionButtonAlwaysVisible}>
  90. {actionButton}
  91. </ActionButtonWrapper>
  92. )}
  93. </div>
  94. )}
  95. </ValueSection>
  96. </ContentWrapper>
  97. );
  98. }
  99. export interface KeyValueDataCardProps {
  100. /**
  101. * ContentProps items to be rendered in this card.
  102. */
  103. contentItems: KeyValueDataContentProps[];
  104. /**
  105. * Flag to enable alphabetical sorting by item subject. Uses given item ordering if false.
  106. */
  107. sortAlphabetically?: boolean;
  108. /**
  109. * Title of the key value data grouping
  110. */
  111. title?: React.ReactNode;
  112. /**
  113. * Content item length which, when exceeded, displays a 'Show more' option
  114. */
  115. truncateLength?: number;
  116. }
  117. export function Card({
  118. contentItems,
  119. title,
  120. truncateLength = Infinity,
  121. sortAlphabetically = false,
  122. }: KeyValueDataCardProps) {
  123. const [isTruncated, setIsTruncated] = useState(contentItems.length > truncateLength);
  124. if (contentItems.length === 0) {
  125. return null;
  126. }
  127. const truncatedItems = isTruncated
  128. ? contentItems.slice(0, truncateLength)
  129. : [...contentItems];
  130. const orderedItems = sortAlphabetically
  131. ? truncatedItems.sort((a, b) => a.item.subject.localeCompare(b.item.subject))
  132. : truncatedItems;
  133. const componentItems = orderedItems.map((itemProps, i) => (
  134. <Content key={`content-card-${title}-${i}`} {...itemProps} />
  135. ));
  136. return (
  137. <CardPanel>
  138. {title && <Title>{title}</Title>}
  139. {componentItems}
  140. {contentItems.length > truncateLength && (
  141. <TruncateWrapper onClick={() => setIsTruncated(!isTruncated)}>
  142. {isTruncated ? t('Show more...') : t('Show less')}
  143. </TruncateWrapper>
  144. )}
  145. </CardPanel>
  146. );
  147. }
  148. // Returns an array of children where null/undefined children are filtered out.
  149. // For example:
  150. // <Component1/> --> returns a <Card/>
  151. // {null}
  152. // <Component2/> --> returns a <Card/>
  153. // Gives us back [<Component1/>, <Component2/>]
  154. const filterChildren = (children: ReactNode): ReactNode[] => {
  155. return Children.toArray(children).filter(
  156. (child: ReactNode) => child !== null && child !== undefined
  157. );
  158. };
  159. // Note: When conditionally rendering children, instead of returning
  160. // if(!condition) return null inside Component, we should render {condition ? <Component/> : null}
  161. // where Component returns a <Card/>. {null} is ignored when distributing cards into columns.
  162. export function Container({children}: {children: React.ReactNode}) {
  163. const containerRef = useRef<HTMLDivElement>(null);
  164. const columnCount = useIssueDetailsColumnCount(containerRef);
  165. const columns: React.ReactNode[] = [];
  166. // Filter out null/undefined children, so that we don't count them
  167. // when determining column size.
  168. const cards = filterChildren(children);
  169. // Evenly distributing the cards into columns.
  170. const columnSize = Math.ceil(cards.length / columnCount);
  171. for (let i = 0; i < cards.length; i += columnSize) {
  172. columns.push(<CardColumn key={i}>{cards.slice(i, i + columnSize)}</CardColumn>);
  173. }
  174. return (
  175. <CardWrapper columnCount={columnCount} ref={containerRef}>
  176. {columns}
  177. </CardWrapper>
  178. );
  179. }
  180. export const CardPanel = styled(Panel)`
  181. padding: ${space(0.75)};
  182. display: grid;
  183. column-gap: ${space(1.5)};
  184. grid-template-columns: fit-content(50%) 1fr;
  185. font-size: ${p => p.theme.fontSizeSmall};
  186. `;
  187. const Title = styled('div')`
  188. grid-column: span 2;
  189. padding: ${space(0.25)} ${space(0.75)};
  190. color: ${p => p.theme.headingColor};
  191. font-weight: ${p => p.theme.fontWeightBold};
  192. `;
  193. const ContentWrapper = styled('div')<{hasErrors: boolean}>`
  194. display: grid;
  195. grid-template-columns: subgrid;
  196. grid-column: span 2;
  197. column-gap: ${space(1.5)};
  198. padding: ${space(0.25)} ${space(0.75)};
  199. border-radius: 4px;
  200. color: ${p => (p.hasErrors ? p.theme.alert.error.color : p.theme.subText)};
  201. box-shadow: inset 0 0 0 1px
  202. ${p => (p.hasErrors ? p.theme.alert.error.border : 'transparent')};
  203. background-color: ${p =>
  204. p.hasErrors ? p.theme.alert.error.backgroundLight : p.theme.background};
  205. &:nth-child(odd) {
  206. background-color: ${p =>
  207. p.hasErrors ? p.theme.alert.error.backgroundLight : p.theme.backgroundSecondary};
  208. }
  209. `;
  210. export const Subject = styled('div')`
  211. grid-column: span 1;
  212. font-family: ${p => p.theme.text.familyMono};
  213. word-break: break-word;
  214. min-width: 100px;
  215. `;
  216. const ValueSection = styled('div')<{hasEmptySubject: boolean; hasErrors: boolean}>`
  217. font-family: ${p => p.theme.text.familyMono};
  218. word-break: break-word;
  219. color: ${p => (p.hasErrors ? 'inherit' : p.theme.textColor)};
  220. grid-column: ${p => (p.hasEmptySubject ? '1 / -1' : 'span 1')};
  221. display: grid;
  222. grid-template-columns: 1fr auto;
  223. grid-column-gap: ${space(0.5)};
  224. `;
  225. const ValueWrapper = styled('div')<{hasSuffix: boolean}>`
  226. word-break: break-word;
  227. grid-column: ${p => (p.hasSuffix ? 'span 1' : '1 / -1')};
  228. `;
  229. const TruncateWrapper = styled('a')`
  230. display: flex;
  231. grid-column: 1 / -1;
  232. margin: ${space(0.5)} 0;
  233. justify-content: center;
  234. font-family: ${p => p.theme.text.family};
  235. `;
  236. const CardWrapper = styled('div')<{columnCount: number}>`
  237. display: grid;
  238. align-items: start;
  239. grid-template-columns: repeat(${p => p.columnCount}, 1fr);
  240. gap: 10px;
  241. `;
  242. const CardColumn = styled('div')`
  243. grid-column: span 1;
  244. `;
  245. export const ValueLink = styled(Link)`
  246. text-decoration: ${p => p.theme.linkUnderline} underline dotted;
  247. `;
  248. const ActionButtonWrapper = styled('div')<{actionButtonAlwaysVisible?: boolean}>`
  249. ${p =>
  250. !p.actionButtonAlwaysVisible &&
  251. css`
  252. visibility: hidden;
  253. ${ContentWrapper}:hover & {
  254. visibility: visible;
  255. }
  256. `}
  257. `;
  258. export const KeyValueData = {
  259. Content,
  260. Card,
  261. Container,
  262. };
  263. export default KeyValueData;