diff --git a/changelog/dashboard-card.yaml b/changelog/dashboard-card.yaml new file mode 100644 index 00000000000..f3a2ab9f4f4 --- /dev/null +++ b/changelog/dashboard-card.yaml @@ -0,0 +1,4 @@ +type: Added +description: Create Card and Statistic HOCs, demonstrate in StatCard +pr: 7477 +labels: [] diff --git a/clients/fidesui/src/components/charts/RadarChart.tsx b/clients/fidesui/src/components/charts/RadarChart.tsx index 615bad449bd..2dfa761b5d7 100644 --- a/clients/fidesui/src/components/charts/RadarChart.tsx +++ b/clients/fidesui/src/components/charts/RadarChart.tsx @@ -132,7 +132,7 @@ export const RadarChart = ({ data={empty ? EMPTY_PLACEHOLDER_DATA : data} cx="50%" cy="50%" - outerRadius="70%" + outerRadius="80%" > - {!empty && ( - - } - /> - )} - + + {!empty && ( + + } + /> + )} diff --git a/clients/fidesui/src/components/dashboard/StatCard.stories.tsx b/clients/fidesui/src/components/dashboard/StatCard.stories.tsx new file mode 100644 index 00000000000..a31a13d411f --- /dev/null +++ b/clients/fidesui/src/components/dashboard/StatCard.stories.tsx @@ -0,0 +1,293 @@ +import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Flex, theme } from "antd"; +import { Card, Statistic } from "fidesui"; +import { useState } from "react"; + +import { RadarChart } from "../charts/RadarChart"; +import { Sparkline } from "../charts/Sparkline"; + +const upwardTrendData = [12, 18, 15, 22, 28, 25, 34, 30, 38, 42, 39, 47]; + +const downwardTrendData = [47, 42, 45, 38, 34, 36, 28, 24, 26, 18, 15, 12]; + +const neutralTrendData = [28, 32, 25, 30, 27, 33, 26, 31, 28, 34, 29, 30]; + +const meta = { + title: "Dashboard/Card", + component: Card, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + coverPosition: { + description: + "Position of the cover relative to the card body. Use `bottom` to place a sparkline below the stat.", + control: "radio", + options: ["top", "bottom"], + }, + cover: { + description: + 'Cover content (e.g. a sparkline). Set `coverPosition="bottom"` to display it below the card body.', + control: false, + }, + children: { + description: "Card body content, e.g. `` component.", + control: false, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: "borderless", + coverPosition: "bottom", + title: "Default Card", + }, + render: (args) => { + const { token } = theme.useToken(); + return ( + + + } + valueStyle={{ fontSize: token.fontSize }} + /> + + ); + }, +}; + +export const NoTitle: Story = { + args: { + variant: "borderless", + coverPosition: "bottom", + }, + render: (args) => { + const { token } = theme.useToken(); + return ( + + + } + valueStyle={{ fontSize: token.fontSize }} + /> + + ); + }, +}; + +export const WithSparkline: Story = { + args: { + variant: "borderless", + cover: ( +
+ +
+ ), + title: "Active Users", + coverPosition: "bottom", + }, + render: (args) => { + const { token } = theme.useToken(); + return ( + + + } + valueStyle={{ fontSize: token.fontSize }} + /> + + ); + }, +}; + +export const WithRadarChart: Story = { + args: { + variant: "borderless", + }, + render: (args) => { + const { token } = theme.useToken(); + return ( + + <> + + } + valueStyle={{ fontSize: token.fontSize }} + /> +
+ +
+ +
+ ); + }, +}; + +export const Loading: Story = { + args: { + variant: "borderless", + loading: true, + children: , + }, +}; + +const TAB_CONTENT: Record = { + a: "Content A", + b: "Content B", + c: "Content C", +}; + +export const WithTabs: Story = { + args: { + variant: "borderless", + title: "Overview", + }, + render: (args) => { + const [activeTab, setActiveTab] = useState("a"); + return ( + + + {TAB_CONTENT[activeTab]} + + + ); + }, +}; + +export const WithInlineTabs: Story = { + args: { + variant: "borderless", + title: "Overview", + headerLayout: "inline", + }, + render: (args) => { + const [activeTab, setActiveTab] = useState("a"); + return ( +
+ +
+ {TAB_CONTENT[activeTab]} +
+
+
+ ); + }, +}; + +export const DashboardRow: Story = { + args: {}, + decorators: [ + () => { + const { token } = theme.useToken(); + return ( +
+ + +
+ } + coverPosition="bottom" + > + + } + valueStyle={{ fontSize: token.fontSize }} + /> + + + + + } + coverPosition="bottom" + > + + } + valueStyle={{ fontSize: token.fontSize }} + /> + + + + + } + coverPosition="bottom" + > + + + + ); + }, + ], +}; diff --git a/clients/fidesui/src/hoc/CustomCard.module.scss b/clients/fidesui/src/hoc/CustomCard.module.scss new file mode 100644 index 00000000000..a3b46d4bfbc --- /dev/null +++ b/clients/fidesui/src/hoc/CustomCard.module.scss @@ -0,0 +1,54 @@ +.inlineHeader { + :global(.ant-card-head) { + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: nowrap; + padding-bottom: 0; + } + + :global(.ant-card-head-wrapper) { + width: auto; + flex: 0 0 auto; + margin-right: var(--ant-card-padding-lg, 24px); + margin-bottom: 0; + padding-bottom: 0; + } + + :global(.ant-card-head-tabs) { + flex: 0 0 auto; + margin-left: auto; + margin-top: 0; + margin-bottom: 0; + min-width: 0; + } + + // Ant adds padding-top to title/extra when tabs are present via: + // .ant-card-contain-tabs > div.ant-card-head .ant-card-head-title { padding-top: var(--ant-padding) } + // The `div` element selector gives that rule specificity (0,3,1) vs our (0,3,0), so we need !important. + :global(.ant-card-head .ant-card-head-title), + :global(.ant-card-head .ant-card-extra) { + padding-top: 0 !important; + } +} + +.bottomCover { + display: flex; + flex-direction: column; + + :global(.ant-card-head) { + order: 0; + } + + :global(.ant-card-body) { + order: 1; + } + + :global(.ant-card-cover) { + order: 2; + } + + :global(.ant-card-actions) { + order: 3; + } +} diff --git a/clients/fidesui/src/hoc/CustomCard.tsx b/clients/fidesui/src/hoc/CustomCard.tsx new file mode 100644 index 00000000000..a64b4aed806 --- /dev/null +++ b/clients/fidesui/src/hoc/CustomCard.tsx @@ -0,0 +1,84 @@ +import { Card, CardProps } from "antd/lib"; +import classNames from "classnames"; +import React from "react"; + +import styles from "./CustomCard.module.scss"; + +export interface CustomCardProps extends CardProps { + /** + * Position of the cover image/content. + * - `top`: default Ant Design behaviour (cover above body) + * - `bottom`: cover rendered below body via CSS order + * @default "top" + */ + coverPosition?: "top" | "bottom"; + /** + * Layout of the card header when both a `title` and `tabList` are provided. + * - `"stacked"`: default Ant Design behaviour (title above tabs) + * - `"inline"`: title and tabs rendered on the same row, with tabs right-aligned + * @default "stacked" + */ + headerLayout?: "stacked" | "inline"; +} + +const withCustomProps = (WrappedComponent: typeof Card) => { + const WrappedCard = React.forwardRef( + ( + { coverPosition = "top", headerLayout = "stacked", className, ...props }, + ref, + ) => ( + + ), + ); + + WrappedCard.displayName = "CustomCard"; + return WrappedCard; +}; + +/** + * Higher-order component that extends Ant Design's Card with additional layout options. + * + * Additional props: + * @param {"top" | "bottom"} [coverPosition="top"] - Controls where the `cover` content is + * displayed relative to the card body. Use `"bottom"` to place a sparkline or chart + * below the card content. + * @param {"stacked" | "inline"} [headerLayout="stacked"] - Controls how the card header is + * laid out when both a `title` and `tabList` are provided. Use `"inline"` to place the + * title and tab bar on the same row, with the tabs right-aligned. + * + * @example + * // Card with a sparkline at the bottom + * } + * coverPosition="bottom" + * > + * + * + * + * @example + * // Card with title left and tabs right-aligned on the same row + * const [activeTab, setActiveTab] = useState("a"); + * + *
Tab content here
+ *
+ */ +export const CustomCard = Object.assign(withCustomProps(Card), { + Meta: Card.Meta, + Grid: Card.Grid, +}); diff --git a/clients/fidesui/src/hoc/CustomStatistic.tsx b/clients/fidesui/src/hoc/CustomStatistic.tsx new file mode 100644 index 00000000000..d5c75989adf --- /dev/null +++ b/clients/fidesui/src/hoc/CustomStatistic.tsx @@ -0,0 +1,72 @@ +import type { GlobalToken } from "antd"; +import { Statistic, StatisticProps, theme } from "antd/lib"; +import React from "react"; + +type AntColorTokenKey = Extract; + +export type StatisticTrend = "up" | "down" | "neutral"; + +export interface CustomStatisticProps extends StatisticProps { + /** + * Trend direction — controls the value colour. + * - `up`: maps to `token.colorSuccess` + * - `down`: maps to `token.colorError` + * - `neutral`: maps to `token.colorText` + * @default "neutral" + */ + trend?: StatisticTrend; +} + +/** Maps a trend direction to the corresponding Ant Design color-token key. */ +const TREND_TOKEN_MAP: Record = { + up: "colorSuccess", + down: "colorError", + neutral: "colorText", +}; + +const withCustomProps = (WrappedComponent: typeof Statistic) => { + const WrappedStatistic = React.forwardRef< + React.ComponentRef, + CustomStatisticProps + >(({ trend = "neutral", valueStyle, ...props }, ref) => { + const { token } = theme.useToken(); + const trendColor = token[TREND_TOKEN_MAP[trend]]; + return ( + + ); + }); + + WrappedStatistic.displayName = "CustomStatistic"; + return WrappedStatistic; +}; + +/** + * Higher-order component that extends Ant Design's Statistic with trend-aware + * colour and a semibold font-weight default. + * + * Additional props: + * @param {"up" | "down" | "neutral"} [trend="neutral"] - Controls the colour of + * the statistic value. `"up"` uses the success colour, `"down"` the error colour, + * and `"neutral"` the default text colour. + * + * @example + * + * + * @example + * // Trend indicator below the main stat + * } + * valueStyle={{ fontSize: token.fontSize }} + * /> + */ +export const CustomStatistic = withCustomProps(Statistic); diff --git a/clients/fidesui/src/hoc/index.tsx b/clients/fidesui/src/hoc/index.tsx index eb55abf4de4..7370a6e1b6d 100644 --- a/clients/fidesui/src/hoc/index.tsx +++ b/clients/fidesui/src/hoc/index.tsx @@ -1,9 +1,11 @@ export * from "./CopyTooltip"; export * from "./CustomAvatar"; +export * from "./CustomCard"; export * from "./CustomDateRangePicker"; export * from "./CustomInput"; export * from "./CustomList"; export * from "./CustomSelect"; +export * from "./CustomStatistic"; export * from "./CustomTable"; export * from "./CustomTag"; export * from "./CustomTooltip"; diff --git a/clients/fidesui/src/index.ts b/clients/fidesui/src/index.ts index 1f2846518a3..f2a9b72c921 100644 --- a/clients/fidesui/src/index.ts +++ b/clients/fidesui/src/index.ts @@ -239,7 +239,6 @@ export { Badge, Breadcrumb, Button, - Card, Cascader, Checkbox, Col, @@ -290,17 +289,22 @@ export type { DisplayValueType } from "rc-select/lib/BaseSelect"; // Higher-order components export type { CustomAvatarProps as AvatarProps, + CustomCardProps as CardProps, ICustomMultiSelectProps, ICustomSelectProps, CustomInputProps as InputProps, + CustomStatisticProps as StatisticProps, + StatisticTrend, } from "./hoc"; export { CustomAvatar as Avatar, + CustomCard as Card, CopyTooltip, CustomDateRangePicker as DateRangePicker, CustomInput as Input, CustomList as List, CustomSelect as Select, + CustomStatistic as Statistic, CustomTable as Table, CustomTag as Tag, CustomTooltip as Tooltip,