ComposedChart.tsx735 lines · main
1import { useTheme } from 'next-themes'
2import { ComponentProps, useEffect, useMemo, useState } from 'react'
3import {
4 Area,
5 Bar,
6 CartesianGrid,
7 Customized,
8 Label,
9 Line,
10 ComposedChart as RechartComposedChart,
11 ReferenceArea,
12 ReferenceLine,
13 Tooltip,
14 XAxis,
15 YAxis,
16} from 'recharts'
17import { CategoricalChartState } from 'recharts/types/chart/types'
18import { cn } from 'ui'
19
20import { ChartHeader } from './ChartHeader'
21import { ChartHighlightAction, ChartHighlightActions } from './ChartHighlightActions'
22import {
23 CHART_COLORS,
24 DateTimeFormats,
25 STACKED_CHART_COLORS,
26 STACKED_CHART_FILLS,
27 updateStackedChartColors,
28} from './Charts.constants'
29import { CommonChartProps, Datum } from './Charts.types'
30import {
31 computeYAxisDomain,
32 formatPercentage,
33 normalizeStackedSeriesData,
34 numberFormatter,
35 useChartSize,
36} from './Charts.utils'
37import {
38 calculateTotalChartAggregate,
39 CustomLabel,
40 CustomTooltip,
41 MultiAttribute,
42} from './ComposedChart.utils'
43import NoDataPlaceholder from './NoDataPlaceholder'
44import { ChartHighlight } from './useChartHighlight'
45import { useChartHoverState } from './useChartHoverState'
46import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
47import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
48
49export interface ComposedChartProps<D = Datum> extends CommonChartProps<D> {
50 chartId?: string
51 attributes: MultiAttribute[]
52 yAxisKey: string
53 xAxisKey: string
54 displayDateInUtc?: boolean
55 onBarClick?: (datum: Datum, tooltipData?: CategoricalChartState) => void
56 emptyStateMessage?: string
57 showLegend?: boolean
58 xAxisIsDate?: boolean
59 XAxisProps?: ComponentProps<typeof XAxis>
60 YAxisProps?: ComponentProps<typeof YAxis>
61 showGrid?: boolean
62 showTooltip?: boolean
63 showTotal?: boolean
64 showMaxValue?: boolean
65 chartHighlight?: ChartHighlight
66 hideChartType?: boolean
67 chartStyle?: string
68 onChartStyleChange?: (style: string) => void
69 updateDateRange: any
70 titleTooltip?: string
71 hideYAxis?: boolean
72 hideHighlightedValue?: boolean
73 hideHighlightedLabel?: boolean
74 hideHighlightArea?: boolean
75 syncId?: string
76 docsUrl?: string
77 sql?: string
78 highlightActions?: ChartHighlightAction[]
79 showNewBadge?: boolean
80 normalizeVisibleStackToPercent?: boolean
81}
82
83interface CustomizedDotProps {
84 formattedGraphicalItems?: Array<{
85 props?: {
86 points?: Array<{ x: number; y: number }>
87 dataKey?: string
88 }
89 item?: {
90 props?: {
91 points?: Array<{ x: number; y: number }>
92 dataKey?: string
93 }
94 }
95 points?: Array<{ x: number; y: number }>
96 }>
97}
98
99export function ComposedChart({
100 chartId,
101 data,
102 attributes,
103 yAxisKey,
104 xAxisKey,
105 format,
106 customDateFormat = DateTimeFormats.FULL,
107 title,
108 highlightedValue,
109 highlightedLabel,
110 displayDateInUtc,
111 minimalHeader,
112 valuePrecision,
113 className = '',
114 size = 'normal',
115 emptyStateMessage,
116 onBarClick,
117 showLegend = false,
118 xAxisIsDate = true,
119 XAxisProps,
120 YAxisProps,
121 showGrid = false,
122 showTooltip = false,
123 showTotal = true,
124 showMaxValue = false,
125 chartHighlight,
126 hideChartType,
127 chartStyle,
128 onChartStyleChange,
129 updateDateRange,
130 hideYAxis,
131 hideHighlightedValue,
132 hideHighlightedLabel = false,
133 hideHighlightArea = false,
134 syncId,
135 docsUrl,
136 sql,
137 highlightActions,
138 titleTooltip,
139 showNewBadge,
140 normalizeVisibleStackToPercent = false,
141}: ComposedChartProps) {
142 const { resolvedTheme } = useTheme()
143 const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState(
144 syncId || 'default'
145 )
146 const [_showMaxValue, setShowMaxValue] = useState(showMaxValue)
147 const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
148 const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false)
149 const [hiddenAttributes, setHiddenAttributes] = useState<Set<string>>(new Set())
150 const isDarkMode = resolvedTheme?.includes('dark')
151
152 useEffect(() => {
153 updateStackedChartColors(isDarkMode ?? false)
154 }, [isDarkMode])
155
156 const { Container } = useChartSize(size)
157
158 // When `displayDateInUtc` is set the chart explicitly wants UTC labels.
159 // Otherwise honour the user's selected timezone via the picker.
160 const formatPickerDate = useFormatDateTime()
161 const formatChartDate = (value: number | string) =>
162 displayDateInUtc
163 ? formatDateTime(value, { tz: 'UTC', format: customDateFormat })
164 : formatPickerDate(value, customDateFormat)
165
166 const formatTimestamp = (ts: unknown) => {
167 if (typeof ts !== 'number' && typeof ts !== 'string') {
168 return ''
169 }
170
171 if (typeof ts === 'number' && ts > 1e14) {
172 // Microsecond timestamp; convert to milliseconds before formatting.
173 return formatChartDate(ts / 1000)
174 }
175
176 return formatChartDate(ts)
177 }
178
179 const _XAxisProps = XAxisProps || {
180 interval: data.length - 2,
181 angle: 0,
182 tick: false,
183 }
184
185 const _YAxisProps = YAxisProps || {
186 tickFormatter: (value) => numberFormatter(value, valuePrecision),
187 tick: false,
188 width: 0,
189 }
190 const yAxisPadding = useMemo(() => {
191 const needsTopPadding = normalizeVisibleStackToPercent && chartStyle !== 'bar'
192 if (!needsTopPadding) return _YAxisProps.padding
193
194 return {
195 ..._YAxisProps.padding,
196 top: Math.max(8, _YAxisProps.padding?.top ?? 0),
197 }
198 }, [_YAxisProps.padding, chartStyle, normalizeVisibleStackToPercent])
199
200 function getHeaderLabel() {
201 if (!xAxisIsDate) {
202 if (!focusDataIndex) return highlightedLabel
203 return data[focusDataIndex]?.[xAxisKey]
204 }
205 return (
206 (focusDataIndex !== null &&
207 data &&
208 data[focusDataIndex] !== undefined &&
209 (() => {
210 const ts = data[focusDataIndex][xAxisKey]
211 return formatTimestamp(ts)
212 })()) ||
213 highlightedLabel
214 )
215 }
216
217 function formatHighlightedValue(value: any) {
218 if (typeof value !== 'number') {
219 return value
220 }
221
222 if (shouldFormatBytes) {
223 const bytesValue = isNetworkChart ? Math.abs(value) : value
224 const formatted = isMemoryChart
225 ? formatBytesMinMB(bytesValue, valuePrecision)
226 : formatBytes(bytesValue, valuePrecision)
227 return format === 'bytes-per-second' ? `${formatted}/s` : formatted
228 }
229
230 if (format === '%') {
231 return formatPercentage(value, valuePrecision)
232 }
233
234 if (valuePrecision === 0 && value > 0 && value < 1) {
235 return '<1'
236 }
237
238 const formatted = numberFormatter(value, valuePrecision)
239 if (typeof format === 'string' && format) {
240 return `${formatted}${format}`
241 }
242 return formatted
243 }
244
245 function computeHighlightedValue() {
246 const referenceLines = attributes.filter(
247 (attribute) => attribute?.provider === 'reference-line'
248 )
249
250 const attributesToIgnore =
251 attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? []
252 const attributesToIgnoreFromTotal = [
253 ...attributesToIgnore,
254 ...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []),
255 ...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []),
256 ...Array.from(hiddenAttributes),
257 ]
258
259 const lastDataPoint = data[data.length - 1]
260 ? Object.entries(data[data.length - 1])
261 .map(([key, value]) => ({
262 dataKey: key,
263 value: value as number,
264 }))
265 .filter(
266 (entry) =>
267 entry.dataKey !== 'timestamp' &&
268 entry.dataKey !== 'period_start' &&
269 attributes.some((attr) => attr.attribute === entry.dataKey && attr.enabled !== false)
270 )
271 : undefined
272
273 if (focusDataIndex !== null) {
274 const focusedDataPoint = data[focusDataIndex]
275 ? Object.entries(data[focusDataIndex])
276 .map(([key, value]) => ({
277 dataKey: key,
278 value: value as number,
279 }))
280 .filter(
281 (entry) =>
282 entry.dataKey !== 'timestamp' &&
283 entry.dataKey !== 'period_start' &&
284 attributes.some(
285 (attr) => attr.attribute === entry.dataKey && attr.enabled !== false
286 )
287 )
288 : undefined
289
290 return showTotal
291 ? calculateTotalChartAggregate(focusedDataPoint ?? [], attributesToIgnoreFromTotal)
292 : data[focusDataIndex]?.[yAxisKey]
293 }
294
295 if (showTotal && lastDataPoint) {
296 return calculateTotalChartAggregate(lastDataPoint, attributesToIgnoreFromTotal)
297 }
298
299 return highlightedValue
300 }
301
302 const maxAttribute = attributes.find((a) => a.isMaxValue)
303 const maxAttributeData = {
304 name: maxAttribute?.attribute,
305 color: CHART_COLORS.REFERENCE_LINE,
306 }
307
308 const referenceLines = attributes.filter((attribute) => {
309 return attribute?.provider === 'reference-line'
310 })
311
312 const resolvedHighlightedLabel = getHeaderLabel()
313
314 const resolvedHighlightedValue = computeHighlightedValue()
315
316 const showHighlightActions =
317 chartHighlight?.coordinates.left &&
318 chartHighlight?.coordinates.right &&
319 chartHighlight?.coordinates.left !== chartHighlight?.coordinates.right
320
321 const chartData =
322 data && !!data[0]
323 ? Object.entries(data[0])
324 ?.map(([key, value]) => ({
325 name: key,
326 value: value,
327 }))
328 .filter(
329 (att) =>
330 att.name !== 'timestamp' &&
331 att.name !== 'period_start' &&
332 att.name !== maxAttribute?.attribute &&
333 !referenceLines.map((a) => a.attribute).includes(att.name) &&
334 attributes.some((attr) => attr.attribute === att.name && attr.enabled !== false)
335 )
336 .map((att, index) => {
337 const attribute = attributes.find((attr) => attr.attribute === att.name)
338 return {
339 ...att,
340 color: attribute?.color
341 ? isDarkMode
342 ? attribute.color.dark
343 : attribute.color.light
344 : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length],
345 fill: attribute?.fill
346 ? isDarkMode
347 ? attribute.fill.dark
348 : attribute.fill.light
349 : STACKED_CHART_FILLS[index % STACKED_CHART_FILLS.length],
350 }
351 })
352 : []
353
354 const stackedAttributes = chartData.filter((att) => {
355 const attribute = attributes.find((attr) => attr.attribute === att.name)
356 return !attribute?.isMaxValue
357 })
358
359 const visibleAttributes = useMemo(
360 () => stackedAttributes.filter((att) => !hiddenAttributes.has(att.name)),
361 [stackedAttributes, hiddenAttributes]
362 )
363 const displayData = useMemo(
364 () =>
365 normalizeVisibleStackToPercent
366 ? normalizeStackedSeriesData({
367 data,
368 attributeNames: visibleAttributes.map((attribute) => attribute.name),
369 })
370 : data,
371 [data, normalizeVisibleStackToPercent, visibleAttributes]
372 )
373
374 const isPercentage = format === '%'
375 const isRamChart =
376 !chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') &&
377 chartData?.some((att: any) => att.name.toLowerCase().includes('ram_'))
378 const isSwapChart = chartData?.some((att: any) => att.name.toLowerCase().includes('swap_'))
379 const isMemoryChart = isRamChart || isSwapChart
380 const isDiskSpaceChart = chartData?.some((att: any) =>
381 att.name.toLowerCase().includes('disk_space_')
382 )
383 const isDBSizeChart = chartData?.some((att: any) =>
384 att.name.toLowerCase().includes('pg_database_size')
385 )
386 const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_'))
387 const isBytesFormat = format === 'bytes' || format === 'bytes-per-second'
388 const shouldFormatBytes =
389 isBytesFormat || isMemoryChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart
390 const yMaxFromVisible = Math.max(
391 0,
392 ...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0))
393 )
394
395 const yAxisDomain = useMemo(
396 () =>
397 computeYAxisDomain({
398 isPercentage,
399 showMaxValue,
400 yMaxFromVisible,
401 maxAttributeKey: maxAttribute?.attribute,
402 showMaxLine: _showMaxValue,
403 data,
404 visibleAttributeNames: visibleAttributes.map((a) => a.name),
405 }),
406 [
407 isPercentage,
408 showMaxValue,
409 yMaxFromVisible,
410 maxAttribute,
411 _showMaxValue,
412 data,
413 visibleAttributes,
414 ]
415 )
416
417 if (data.length === 0) {
418 return (
419 <NoDataPlaceholder
420 hideTotalPlaceholder={highlightedValue === undefined}
421 message={emptyStateMessage}
422 description="It may take up to 24 hours for data to refresh"
423 size={size}
424 className={className}
425 attribute={title}
426 format={format}
427 titleTooltip={titleTooltip}
428 />
429 )
430 }
431
432 return (
433 <div className={cn('flex flex-col gap-y-3', className)}>
434 <ChartHeader
435 hideHighlightedValue={hideHighlightedValue}
436 title={title}
437 showNewBadge={showNewBadge}
438 format={format}
439 hideHighlightedLabel={hideHighlightedLabel}
440 hideHighlightArea={hideHighlightArea}
441 titleTooltip={titleTooltip}
442 customDateFormat={customDateFormat}
443 highlightedValue={formatHighlightedValue(resolvedHighlightedValue)}
444 highlightedLabel={resolvedHighlightedLabel}
445 minimalHeader={minimalHeader}
446 hideChartType={hideChartType}
447 chartStyle={chartStyle}
448 onChartStyleChange={onChartStyleChange}
449 showMaxValue={_showMaxValue}
450 setShowMaxValue={maxAttribute ? setShowMaxValue : undefined}
451 docsUrl={docsUrl}
452 syncId={syncId}
453 data={data}
454 xAxisKey={xAxisKey}
455 yAxisKey={yAxisKey}
456 xAxisIsDate={xAxisIsDate}
457 displayDateInUtc={displayDateInUtc}
458 valuePrecision={valuePrecision}
459 shouldFormatBytes={shouldFormatBytes}
460 isNetworkChart={isNetworkChart}
461 isMemoryChart={isMemoryChart}
462 attributes={attributes}
463 sql={sql}
464 />
465 <Container className="relative z-10">
466 <RechartComposedChart
467 data={displayData}
468 syncId={syncId}
469 style={{ cursor: 'crosshair' }}
470 onMouseMove={({ activeLabel, activeTooltipIndex }) => {
471 if (activeTooltipIndex === undefined || activeTooltipIndex === null) return
472
473 setIsActiveHoveredChart(true)
474 if (activeTooltipIndex !== focusDataIndex) {
475 setFocusDataIndex(activeTooltipIndex)
476 }
477
478 setHover(activeTooltipIndex)
479
480 const activeTimestamp =
481 data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp
482 chartHighlight?.handleMouseMove({
483 activeLabel: activeTimestamp?.toString(),
484 coordinates: activeLabel,
485 })
486 }}
487 onMouseDown={({ activeLabel, activeTooltipIndex }) => {
488 if (activeTooltipIndex === undefined || activeTooltipIndex === null) return
489
490 const activeTimestamp =
491 data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp
492 chartHighlight?.handleMouseDown({
493 activeLabel: activeTimestamp?.toString(),
494 coordinates: activeLabel,
495 })
496 }}
497 onMouseUp={chartHighlight?.handleMouseUp}
498 onMouseLeave={() => {
499 setIsActiveHoveredChart(false)
500 setFocusDataIndex(null)
501
502 clearHover()
503 }}
504 onClick={(tooltipData) => {
505 const datum = tooltipData?.activePayload?.[0]?.payload
506 if (onBarClick) onBarClick(datum, tooltipData)
507 }}
508 >
509 {showGrid && <CartesianGrid stroke={CHART_COLORS.AXIS} />}
510 <YAxis
511 {..._YAxisProps}
512 hide={hideYAxis}
513 axisLine={{ stroke: CHART_COLORS.AXIS }}
514 tickLine={{ stroke: CHART_COLORS.AXIS }}
515 domain={_YAxisProps.domain ?? yAxisDomain}
516 padding={yAxisPadding}
517 key={yAxisKey}
518 />
519 <XAxis
520 {..._XAxisProps}
521 axisLine={{ stroke: CHART_COLORS.AXIS }}
522 tickLine={{ stroke: CHART_COLORS.AXIS }}
523 tickMargin={8}
524 minTickGap={3}
525 key={xAxisKey}
526 />
527
528 <defs>
529 {visibleAttributes.map((attribute) => (
530 <linearGradient
531 key={`gradient-${attribute.name}`}
532 id={`gradient-${attribute.name}`}
533 x1="0"
534 y1="0"
535 x2="0"
536 y2="1"
537 >
538 <stop offset="5%" stopColor={attribute.color} stopOpacity={0.15} />
539 <stop offset="95%" stopColor={isDarkMode ? '#131313' : '#FFFFFF'} stopOpacity={0} />
540 </linearGradient>
541 ))}
542 </defs>
543
544 {chartStyle === 'bar'
545 ? visibleAttributes.map((attribute) => (
546 <Bar
547 key={attribute.name}
548 dataKey={attribute.name}
549 stackId={attributes?.find((a) => a.attribute === attribute?.name)?.stackId ?? '1'}
550 fill={attribute.color}
551 radius={0.75}
552 opacity={1}
553 name={
554 attributes?.find((a) => a.attribute === attribute?.name)?.label ||
555 attribute?.name
556 }
557 maxBarSize={24}
558 />
559 ))
560 : visibleAttributes.map((attribute) => (
561 <Area
562 key={attribute.name}
563 type="linear"
564 dataKey={attribute.name}
565 stackId="1"
566 fill={`url(#gradient-${attribute.name})`}
567 fillOpacity={1}
568 stroke={attribute.color}
569 radius={20}
570 animationDuration={375}
571 name={
572 attributes?.find((a) => a.attribute === attribute.name)?.label || attribute.name
573 }
574 dot={false}
575 activeDot={false}
576 />
577 ))}
578 {/* Max value, if available */}
579 {maxAttribute && _showMaxValue && (
580 <Line
581 key={maxAttribute.attribute}
582 type="linear"
583 dataKey={maxAttribute.attribute}
584 stroke={CHART_COLORS.REFERENCE_LINE}
585 strokeWidth={2}
586 strokeDasharray={maxAttribute.strokeDasharray ?? '3 3'}
587 dot={false}
588 name={maxAttribute.label}
589 />
590 )}
591 {referenceLines
592 .filter((line) => {
593 return line.isReferenceLine
594 })
595 .map((line) => (
596 <ReferenceLine
597 key={line.attribute}
598 y={line.value}
599 strokeWidth={1}
600 stroke={isDarkMode ? line.color?.dark : line.color?.light}
601 strokeDasharray={line.strokeDasharray ?? '3 3'}
602 label={undefined}
603 >
604 <Label
605 value={line.label}
606 position="insideTopRight"
607 fill={CHART_COLORS.REFERENCE_LINE_TEXT}
608 className="text-xs"
609 style={{ fill: CHART_COLORS.REFERENCE_LINE_TEXT }}
610 />
611 </ReferenceLine>
612 ))}
613
614 {/* Selection highlight */}
615 {showHighlightActions && (
616 <ReferenceArea
617 x1={chartHighlight?.coordinates.left}
618 x2={chartHighlight?.coordinates.right}
619 strokeOpacity={0.5}
620 stroke={isDarkMode ? '#FFFFFF' : '#0C3925'}
621 fill={isDarkMode ? '#FFFFFF' : '#0C3925'}
622 fillOpacity={0.2}
623 />
624 )}
625 <Tooltip
626 content={(props) =>
627 showTooltip && !showHighlightActions ? (
628 <CustomTooltip
629 {...props}
630 data={data}
631 format={format}
632 isPercentage={isPercentage}
633 label={resolvedHighlightedLabel}
634 attributes={attributes}
635 xAxisKey={xAxisKey}
636 valuePrecision={valuePrecision}
637 showTotal={showTotal}
638 isActiveHoveredChart={
639 isActiveHoveredChart || (!!syncId && syncTooltip && hoveredIndex !== null)
640 }
641 />
642 ) : null
643 }
644 cursor={{
645 stroke: isDarkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)',
646 strokeWidth: 1,
647 }}
648 />
649 <Customized
650 component={(props: CustomizedDotProps) => {
651 const { formattedGraphicalItems } = props
652 if (!formattedGraphicalItems || focusDataIndex === null) return null
653
654 return (
655 <g>
656 {formattedGraphicalItems.map((item, index: number) => {
657 const points = item.props?.points || item.item?.props?.points || item.points
658 const dataKey = item.props?.dataKey || item.item?.props?.dataKey
659
660 if (!points || !points[focusDataIndex]) return null
661
662 const point = points[focusDataIndex]
663 const attribute = visibleAttributes.find((a) => a.name === dataKey)
664 if (!attribute) return null
665
666 return (
667 <circle
668 key={`custom-dot-${dataKey}-${index}`}
669 cx={point.x}
670 cy={point.y}
671 r={4}
672 fill={attribute.fill}
673 stroke={attribute.color}
674 strokeWidth={1}
675 />
676 )
677 })}
678 </g>
679 )
680 }}
681 />
682 </RechartComposedChart>
683 </Container>
684 <ChartHighlightActions
685 chartHighlight={chartHighlight}
686 updateDateRange={updateDateRange}
687 actions={highlightActions}
688 chartId={chartId}
689 />
690 {data && (
691 <div
692 className="text-foreground-lighter -mt-9 flex items-center justify-between text-xs"
693 style={{ marginLeft: YAxisProps?.width }}
694 >
695 <span>{xAxisIsDate ? formatTimestamp(data[0]?.[xAxisKey]) : data[0]?.[xAxisKey]}</span>
696 <span>
697 {xAxisIsDate
698 ? formatTimestamp(data[data.length - 1]?.[xAxisKey])
699 : data[data.length - 1]?.[xAxisKey]}
700 </span>
701 </div>
702 )}
703 {showLegend && (
704 <div className="relative z-0">
705 <CustomLabel
706 payload={[maxAttributeData, ...chartData]}
707 attributes={attributes}
708 showMaxValue={_showMaxValue}
709 onToggleAttribute={(attribute, options) => {
710 setHiddenAttributes((prev) => {
711 if (options?.exclusive) {
712 // Hide every attribute except the selected one. If all but one are hidden, clicking again will reset to all visible.
713 const allNames = chartData.map((c) => c.name)
714 const allHiddenExcept = allNames.filter((n) => n !== attribute)
715 const isAlreadyExclusive =
716 allHiddenExcept.every((n) => prev.has(n)) && !prev.has(attribute)
717 return isAlreadyExclusive ? new Set() : new Set(allHiddenExcept)
718 }
719
720 const next = new Set(prev)
721 if (next.has(attribute)) {
722 next.delete(attribute)
723 } else {
724 next.add(attribute)
725 }
726 return next
727 })
728 }}
729 hiddenAttributes={hiddenAttributes}
730 />
731 </div>
732 )}
733 </div>
734 )
735}