diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..5e0ee7a9e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ + + +Closes # + + + +This PR... + + + +## How to test + +1. Go to... + + + +--- + +- [ ] Add a CHANGELOG entry diff --git a/CHANGELOG.md b/CHANGELOG.md index 028126f91..256872b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,16 @@ You can also check the # Unreleased - Features - - Implemented Content Security Policy (CSP) + - Added a new chart type - bar - It's now possible to export charts as images - - Added support for custom colors in single and multi-colored charts, enabling enhanced visual customization. - - Introduced a new Color Picker, offering greater flexibility and precision in chart color selection. - - Added Footer to the Profile Page + - Added footer to the profile page + - Added support for custom colors in single and multi-colored charts, enabling + enhanced visual customization + - Introduced a new color picker, offering greater flexibility and precision in + chart color selection - Fixes - - Addressed security flaw allowing the injection of arbitrary URLs - in the `sourceUrl` parameter in the GraphQL API + - Addressed security flaw allowing the injection of arbitrary URLs in the + `sourceUrl` parameter in the GraphQL API - Color mapping is now correctly kept up to date in case of editing an old chart and the cube has been updated in the meantime and contains new values in the color dimension @@ -34,6 +36,9 @@ You can also check the inputs - Opening a temporal dimension with timezone in table chart configurator doesn't crash the application anymore + - Themes fetching is now done by using standard SPARQL iris (starting with + https://), so that it behaves consistently across different SPARQL database + engines - Styles - Updated dataset result borders to match the design - Maintenance @@ -49,6 +54,7 @@ You can also check the - Removed unused dependencies and dead code - Updated several outdated packages - Added knip as a new CI task + - Implemented Content Security Policy (CSP) - Performance - Introduced sharding to improve performance of basic CI checks (unit tests, type checks, linting, knip) @@ -56,7 +62,6 @@ You can also check the - Added auto-generated JSON Schema files for configurator state and chart config and improved preview charts via API documentation - # [5.0.2] - 2024-11-28 - Features diff --git a/app/charts/index.ts b/app/charts/index.ts index 61038221d..dd4af7dd8 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -92,7 +92,7 @@ import { import { theme } from "@/themes/federal"; import { bfs } from "@/utils/bfs"; import { CHART_CONFIG_VERSION } from "@/utils/chart-config/constants"; -import { createChartId } from "@/utils/create-chart-id"; +import { createId } from "@/utils/create-id"; import { isMultiHierarchyNode } from "@/utils/hierarchy"; import { unreachableError } from "@/utils/unreachable"; @@ -374,7 +374,7 @@ export const getInitialConfig = ( activeField: string | undefined; } => { return { - key: key ?? createChartId(), + key: key ?? createId(), version: CHART_CONFIG_VERSION, meta: meta ?? META, // Technically, we should scope filters per cube; but as we only set initial diff --git a/app/components/action-elements-container.tsx b/app/components/action-elements-container.tsx new file mode 100644 index 000000000..80820b8c1 --- /dev/null +++ b/app/components/action-elements-container.tsx @@ -0,0 +1,23 @@ +import { Theme } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import { ReactNode } from "react"; + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + height: "fit-content", + marginTop: "-0.33rem", + }, +})); + +export const ActionElementsContainer = ({ + children, +}: { + children: ReactNode; +}) => { + const classes = useStyles(); + + return
{children}
; +}; diff --git a/app/components/add-button.tsx b/app/components/add-button.tsx new file mode 100644 index 000000000..4867ad86d --- /dev/null +++ b/app/components/add-button.tsx @@ -0,0 +1,22 @@ +import { Button, ButtonProps } from "@mui/material"; + +import { Icon } from "@/icons"; + +export const AddButton = (props: ButtonProps) => { + const { sx, ...rest } = props; + + return ( + + )} ); diff --git a/app/components/chart-shared.tsx b/app/components/chart-shared.tsx index f67d102d2..266e099fc 100644 --- a/app/components/chart-shared.tsx +++ b/app/components/chart-shared.tsx @@ -56,13 +56,15 @@ import { getChartIcon } from "@/icons"; import SvgIcMore from "@/icons/components/IcMore"; import { useLocale } from "@/src"; import { animationFrame } from "@/utils/animation-frame"; -import { createChartId } from "@/utils/create-chart-id"; +import { createId } from "@/utils/create-id"; import { DISABLE_SCREENSHOT_ATTR, useScreenshot, UseScreenshotProps, } from "@/utils/use-screenshot"; +export const CHART_GRID_ROW_COUNT = 7; + /** Generic styles shared between `ChartPreview` and `ChartPublished`. */ export const useChartStyles = makeStyles( (theme) => ({ @@ -71,7 +73,7 @@ export const useChartStyles = makeStyles( display: "grid", gridTemplateRows: "subgrid", /** Should stay in sync with the number of rows contained in a chart */ - gridRow: "span 7", + gridRow: `span ${CHART_GRID_ROW_COUNT}`, padding: theme.spacing(6), backgroundColor: theme.palette.background.paper, border: ({ disableBorder }) => @@ -347,7 +349,7 @@ export const DuplicateChartMenuActionItem = ({ dispatch({ type: "CHART_CONFIG_ADD", value: { - chartConfig: { ...chartConfig, key: createChartId() }, + chartConfig: { ...chartConfig, key: createId() }, locale, }, }); @@ -420,7 +422,7 @@ const DownloadPNGImageMenuActionItem = ({ onClick={screenshot} disabled={loading} leadingIconName="download" - label={`${t({ id: "chart-controls.export", message: "Export" })} PNG`} + label={t({ id: "chart-controls.export-png", message: "Export PNG" })} /> ); }; diff --git a/app/components/dashboard-shared.tsx b/app/components/dashboard-shared.tsx new file mode 100644 index 000000000..047b869b4 --- /dev/null +++ b/app/components/dashboard-shared.tsx @@ -0,0 +1,125 @@ +import { t } from "@lingui/macro"; +import { IconButton, useEventCallback } from "@mui/material"; +import { useState } from "react"; + +import { ArrowMenuTopBottom } from "@/components/arrow-menu"; +import { MenuActionItem } from "@/components/menu-action-item"; +import { + isLayouting, + useConfiguratorState, +} from "@/configurator/configurator-state"; +import SvgIcMore from "@/icons/components/IcMore"; +import { createId } from "@/utils/create-id"; +import { DISABLE_SCREENSHOT_ATTR } from "@/utils/use-screenshot"; + +export const BlockMoreButton = ({ blockKey }: { blockKey: string }) => { + const [state, dispatch] = useConfiguratorState(isLayouting); + const { layout } = state; + const [anchor, setAnchor] = useState(null); + const handleClose = useEventCallback(() => setAnchor(null)); + const handleDuplicate = useEventCallback(() => { + const key = createId(); + const block = layout.blocks.find((b) => b.key === blockKey); + + if (!block) { + return; + } + + dispatch({ + type: "LAYOUT_CHANGED", + value: { + ...layout, + blocks: [ + ...layout.blocks, + { + ...block, + key, + }, + ], + }, + }); + dispatch({ + type: "LAYOUT_ACTIVE_FIELD_CHANGED", + value: key, + }); + }); + const handleRemove = useEventCallback(() => { + dispatch({ + type: "LAYOUT_CHANGED", + value: { + ...layout, + blocks: layout.blocks.filter((b) => b.key !== blockKey), + }, + }); + + if (layout.activeField === blockKey) { + dispatch({ + type: "LAYOUT_ACTIVE_FIELD_CHANGED", + value: undefined, + }); + } + }); + + return ( + <> + { + e.stopPropagation(); + setAnchor(e.currentTarget); + }} + sx={{ height: "fit-content" }} + {...DISABLE_SCREENSHOT_ATTR} + data-testid="block-more-button" + > + + + { + // @ts-ignore this is correct + e.stopPropagation(); + handleClose(); + }} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + transformOrigin={{ horizontal: "right", vertical: "top" }} + > +
+ { + e.stopPropagation(); + handleDuplicate(); + handleClose(); + }} + leadingIconName="duplicate" + label={t({ id: "block-controls.duplicate", message: "Duplicate" })} + /> + { + e.stopPropagation(); + handleRemove(); + handleClose(); + }} + leadingIconName="trash" + label={t({ id: "block-controls.delete", message: "Delete" })} + /> +
+
+ + ); +}; diff --git a/app/components/drag-handle.tsx b/app/components/drag-handle.tsx index b9b1ef433..a669fb171 100644 --- a/app/components/drag-handle.tsx +++ b/app/components/drag-handle.tsx @@ -2,6 +2,7 @@ import { Box, BoxProps } from "@mui/material"; import clsx from "clsx"; import { forwardRef, Ref } from "react"; +import { chartPanelLayoutGridClasses } from "@/components/chart-panel-layout-grid"; import { useIconStyles } from "@/components/chart-selection-tabs"; import { Icon } from "@/icons"; import { DISABLE_SCREENSHOT_ATTR } from "@/utils/use-screenshot"; @@ -21,7 +22,11 @@ export const DragHandle = forwardRef( {...DISABLE_SCREENSHOT_ATTR} ref={ref} {...rest} - className={clsx(classes.dragIconWrapper, props.className)} + className={clsx( + classes.dragIconWrapper, + props.className, + chartPanelLayoutGridClasses.dragHandle + )} > diff --git a/app/components/form.tsx b/app/components/form.tsx index 140c84439..a71edad81 100644 --- a/app/components/form.tsx +++ b/app/components/form.tsx @@ -1,4 +1,14 @@ import { Trans } from "@lingui/macro"; +import { + headingsPlugin, + linkPlugin, + listsPlugin, + markdownShortcutPlugin, + MDXEditor, + quotePlugin, + thematicBreakPlugin, + toolbarPlugin, +} from "@mdxeditor/editor"; import { Box, BoxProps, @@ -24,10 +34,12 @@ import { styled, Switch as MUISwitch, SxProps, + Theme, Tooltip, Typography, TypographyProps, } from "@mui/material"; +import { makeStyles } from "@mui/styles"; import { useId } from "@reach/auto-id"; import flatten from "lodash/flatten"; import React, { @@ -45,6 +57,9 @@ import React, { import { useBrowseContext } from "@/browser/context"; import { MaybeTooltip } from "@/components/maybe-tooltip"; +import { BlockTypeMenu } from "@/components/mdx-editor/block-type-menu"; +import { BoldItalicUnderlineToggles } from "@/components/mdx-editor/bold-italic-underline-toggles"; +import { ListToggles } from "@/components/mdx-editor/list-toggles"; import { BANNER_MARGIN_TOP } from "@/components/presence"; import { TooltipTitle } from "@/components/tooltip-utils"; import VisuallyHidden from "@/components/visually-hidden"; @@ -60,6 +75,8 @@ import { useLocale } from "@/locales/use-locale"; import { valueComparator } from "@/utils/sorting-values"; import useEvent from "@/utils/use-event"; +import "@mdxeditor/editor/style.css"; + export const Label = ({ htmlFor, smaller = false, @@ -610,29 +627,119 @@ export const Input = ({ }: { label?: string | ReactNode; disabled?: boolean; -} & FieldProps) => ( - - {label && name && ( - - )} - - -); +} & FieldProps) => { + const inputRef = useRef(null); + + return ( + + {label && name ? ( + + ) : null} + + + ); +}; + +export const MarkdownInput = ({ + label, + name, + value, + onChange, +}: { + label?: string | ReactNode; +} & FieldProps) => { + const classes = useMarkdownInputStyles(); + + return ( + + ( +
+ + + + + + {label && name ? ( + + ) : null} +
+ ), + }), + headingsPlugin(), + listsPlugin(), + linkPlugin(), + quotePlugin(), + markdownShortcutPlugin(), + thematicBreakPlugin(), + ]} + onChange={(newValue) => { + onChange?.({ + currentTarget: { + value: newValue + // is not supported in react-markdown we use for rendering. + .replaceAll("", "") + .replace("", ""), + }, + } as any); + }} + /> +
+ ); +}; + +const useMarkdownInputStyles = makeStyles((theme) => ({ + root: { + "& [data-lexical-editor='true']": { + padding: "0.5rem 0.75rem", + border: `1px solid ${theme.palette.grey[300]}`, + borderRadius: 3, + backgroundColor: theme.palette.grey[100], + "&:focus": { + border: `1px solid ${theme.palette.secondary.main}`, + }, + "& *": { + margin: "1em 0", + lineHeight: 1.2, + }, + "& :first-child": { + marginTop: 0, + }, + "& :last-child": { + marginBottom: 0, + }, + }, + }, + toolbar: { + borderRadius: 0, + backgroundColor: theme.palette.background.paper, + }, +})); export const SearchField = ({ id, @@ -726,7 +833,7 @@ export const FieldSetLegend = ({ component="legend" sx={{ lineHeight: ["1rem", "1.125rem", "1.125rem"], - fontWeight: "regular", + fontWeight: "normal", fontSize: ["0.625rem", "0.75rem", "0.75rem"], pl: 0, mb: 1, diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx new file mode 100644 index 000000000..c52664a77 --- /dev/null +++ b/app/components/markdown.tsx @@ -0,0 +1,62 @@ +import { ComponentProps } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; + +const components: ComponentProps["components"] = { + // TODO: Maybe can be handled by Title and Description components? + h1: ({ children, style, ...props }) => ( +

+ {children} +

+ ), + h2: ({ children, style, ...props }) => ( +

+ {children} +

+ ), + h3: ({ children, style, ...props }) => ( +

+ {children} +

+ ), + h4: ({ children, style, ...props }) => ( +

+ {children} +

+ ), + h5: ({ children, style, ...props }) => ( +
+ {children} +
+ ), + h6: ({ children, style, ...props }) => ( +
+ {children} +
+ ), + p: ({ children, style, ...props }) => ( +

+ {children} +

+ ), + a: ({ children, style, ...props }) => ( + + {children} + + ), +}; + +export const Markdown = ( + props: Omit, "components"> +) => { + return ( + + ); +}; diff --git a/app/components/mdx-editor/block-type-menu.tsx b/app/components/mdx-editor/block-type-menu.tsx new file mode 100644 index 000000000..d8d6ba6fe --- /dev/null +++ b/app/components/mdx-editor/block-type-menu.tsx @@ -0,0 +1,123 @@ +import { $createHeadingNode } from "@lexical/rich-text"; +import { + activePlugins$, + allowedHeadingLevels$, + BlockType, + convertSelectionToNode$, + currentBlockType$, + useCellValue, + usePublisher, +} from "@mdxeditor/editor"; +import { + ListItemText, + Menu, + MenuItem, + Typography, + useEventCallback, +} from "@mui/material"; +import { $createParagraphNode } from "lexical"; +import { useState } from "react"; + +import { ToolbarIconButton } from "@/components/mdx-editor/common"; +import { Icon } from "@/icons"; + +/** + * Based on https://github.com/mdx-editor/editor/blob/main/src/plugins/toolbar/components/BlockTypeSelect.tsx + * */ +export const BlockTypeMenu = () => { + const convertSelectionToNode = usePublisher(convertSelectionToNode$); + const currentBlockType = useCellValue(currentBlockType$); + const activePlugins = useCellValue(activePlugins$); + const hasHeadings = activePlugins.includes("headings"); + + const handleChange = useEventCallback((blockType: BlockType) => { + switch (blockType) { + case "paragraph": + convertSelectionToNode(() => $createParagraphNode()); + break; + case "quote": + case "": + break; + default: + if (blockType.startsWith("h")) { + convertSelectionToNode(() => $createHeadingNode(blockType)); + } else { + throw new Error(`Unknown block type: ${blockType}`); + } + } + }); + + const [anchor, setAnchor] = useState(null); + const handleClose = useEventCallback(() => setAnchor(null)); + + if (!hasHeadings) { + return null; + } + + type Item = { label: string | JSX.Element; value: BlockType }; + const items: Item[] = [ + { + label: "Paragraph", + value: "paragraph", + }, + ]; + + if (hasHeadings) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const allowedHeadingLevels = useCellValue(allowedHeadingLevels$); + items.push( + ...allowedHeadingLevels.map( + (n) => + ({ + label: `Heading ${n}`, + value: `h${n}`, + }) as const + ) + ); + } + + return ( + <> + { + setAnchor(e.currentTarget); + }} + sx={{ backgroundColor: anchor ? "grey.300" : "transparent" }} + > + + + { + // @ts-ignore this is correct + e.stopPropagation(); + handleClose(); + }} + anchorOrigin={{ horizontal: "center", vertical: "bottom" }} + transformOrigin={{ horizontal: "left", vertical: "top" }} + style={{ transform: "translate(-12px, 8px)" }} + MenuListProps={{ style: { padding: 0 } }} + > + {items.map((item) => ( + { + handleChange(item.value); + handleClose(); + }} + sx={{ py: 3 }} + > + + + {item.label} + + + + ))} + + + ); +}; diff --git a/app/components/mdx-editor/bold-italic-underline-toggles.tsx b/app/components/mdx-editor/bold-italic-underline-toggles.tsx new file mode 100644 index 000000000..d7405811f --- /dev/null +++ b/app/components/mdx-editor/bold-italic-underline-toggles.tsx @@ -0,0 +1,61 @@ +import { + applyFormat$, + currentFormat$, + FORMAT, + iconComponentFor$, + IS_BOLD, + IS_ITALIC, + IS_UNDERLINE, +} from "@mdxeditor/editor"; +import { useCellValues, usePublisher } from "@mdxeditor/gurx"; +import { TextFormatType } from "lexical"; + +import { ToolbarIconButton } from "@/components/mdx-editor/common"; +import { Icon, IconName } from "@/icons"; + +const FormatButton = ({ + format, + iconName, + formatName, +}: { + format: FORMAT; + iconName: IconName; + formatName: TextFormatType; +}) => { + const [currentFormat] = useCellValues(currentFormat$, iconComponentFor$); + const applyFormat = usePublisher(applyFormat$); + const active = (currentFormat & format) !== 0; + + return ( + { + applyFormat(formatName); + }} + sx={{ + backgroundColor: active ? "grey.300" : "transparent", + "&:hover": { + backgroundColor: active ? "grey.300" : "grey.200", + }, + }} + > + + + ); +}; + +/** + * Based on https://github.com/mdx-editor/editor/blob/main/src/plugins/toolbar/components/BoldItalicUnderlineToggles.tsx + */ +export const BoldItalicUnderlineToggles = () => { + return ( + <> + + + + + ); +}; diff --git a/app/components/mdx-editor/common.tsx b/app/components/mdx-editor/common.tsx new file mode 100644 index 000000000..5cbd1fff6 --- /dev/null +++ b/app/components/mdx-editor/common.tsx @@ -0,0 +1,10 @@ +import { IconButton, IconButtonProps } from "@mui/material"; + +export const ToolbarIconButton = ({ style, ...rest }: IconButtonProps) => { + return ( + + ); +}; diff --git a/app/components/mdx-editor/list-toggles.tsx b/app/components/mdx-editor/list-toggles.tsx new file mode 100644 index 000000000..c0a95c42e --- /dev/null +++ b/app/components/mdx-editor/list-toggles.tsx @@ -0,0 +1,42 @@ +import { applyListType$, currentListType$ } from "@mdxeditor/editor"; +import { useCellValues, usePublisher } from "@mdxeditor/gurx"; + +import { ToolbarIconButton } from "@/components/mdx-editor/common"; +import { Icon, IconName } from "@/icons"; + +const ListTypeButton = ({ + listType, + iconName, +}: { + listType: "bullet" | "number"; + iconName: IconName; +}) => { + const [currentListType] = useCellValues(currentListType$); + const applyListType = usePublisher(applyListType$); + const active = currentListType === listType; + + return ( + { + applyListType(active ? "" : listType); + }} + sx={{ + backgroundColor: active ? "grey.300" : "transparent", + "&:hover": { + backgroundColor: active ? "grey.300" : "grey.200", + }, + }} + > + + + ); +}; + +export const ListToggles = () => { + return ( + <> + + + + ); +}; diff --git a/app/components/metadata-panel.tsx b/app/components/metadata-panel.tsx index a0fa0be3d..4f7dc50c8 100644 --- a/app/components/metadata-panel.tsx +++ b/app/components/metadata-panel.tsx @@ -806,7 +806,6 @@ const ComponentTabPanel = ({ {isJoinByComponent(component) ? ( diff --git a/app/components/react-grid.stories.tsx b/app/components/react-grid.stories.tsx index b6e97937f..f52d4177e 100644 --- a/app/components/react-grid.stories.tsx +++ b/app/components/react-grid.stories.tsx @@ -9,7 +9,7 @@ import { useMemo, useState } from "react"; import { Layouts } from "react-grid-layout"; import { - availableHandles, + availableHandlesByBlockType, ChartGridLayout, generateLayout, GridLayout, @@ -32,7 +32,7 @@ export const Example = () => { const [allowResize, setAllowResize] = useState(false); const resizeHandles = useMemo( - () => (allowResize ? availableHandles : []), + () => (allowResize ? availableHandlesByBlockType.chart : []), [allowResize] ); const [layouts, setLayouts] = useLocalState( diff --git a/app/components/react-grid.tsx b/app/components/react-grid.tsx index b40a812c0..a3cc0704d 100644 --- a/app/components/react-grid.tsx +++ b/app/components/react-grid.tsx @@ -14,11 +14,11 @@ import { getChartWrapperId } from "@/components/chart-panel"; import { hasChartConfigs, isLayouting, - LayoutDashboardFreeCanvas, + LayoutBlock, ReactGridLayoutType, + useConfiguratorState, } from "@/configurator"; import { useTimeout } from "@/hooks/use-timeout"; -import { useConfiguratorState } from "@/src"; import { theme } from "@/themes/federal"; import { assert } from "@/utils/assert"; @@ -27,22 +27,19 @@ const ResponsiveReactGridLayout = WidthProvider(Responsive); type ResizeHandle = NonNullable[number]; export type GridLayout = "horizontal" | "vertical" | "wide" | "tall"; -export const availableHandles: ResizeHandle[] = [ - "s", - "w", - "e", - "n", - "sw", - "nw", - "se", - "ne", -]; +export const availableHandlesByBlockType: Record< + LayoutBlock["type"], + ResizeHandle[] +> = { + chart: ["s", "w", "e", "n", "sw", "nw", "se", "ne"], + text: ["w", "e"], +}; /** In grid unit */ const MAX_H = 10; const INITIAL_H = 7; -const MIN_H = 1; +export const MIN_H = 1; /** In grid unit */ const MAX_W = 4; @@ -54,7 +51,7 @@ export const FREE_CANVAS_BREAKPOINTS = { md: 480, sm: 0, }; -const ROW_HEIGHT = 100; +export const ROW_HEIGHT = 100; const useStyles = makeStyles((theme: Theme) => ({ root: { @@ -70,7 +67,7 @@ const useStyles = makeStyles((theme: Theme) => ({ }, "& .react-grid-item": { transition: "all 200ms ease", - transitionProperty: "left, top, width, height", + transitionProperty: "none", // Customization boxSizing: "border-box", @@ -80,7 +77,7 @@ const useStyles = makeStyles((theme: Theme) => ({ userSelect: "none", }, "& .react-grid-item.cssTransforms": { - transitionProperty: "transform, width, height", + transitionProperty: "none", }, "& .react-grid-item.resizing": { transition: "none", @@ -182,7 +179,6 @@ const useStyles = makeStyles((theme: Theme) => ({ transform: "rotate(45deg)", }, "& .react-grid-item:not(.react-grid-placeholder)": { - background: "#eee", border: theme.palette.divider, boxShadow: theme.shadows[1], }, @@ -222,16 +218,16 @@ export const ChartGridLayout = ({ ...rest }: { className: string; - onLayoutChange: Function; resize?: boolean; } & ComponentProps) => { const classes = useStyles(); + const [state, dispatch] = useConfiguratorState(hasChartConfigs); + const layout = state.layout; assert( - state.layout.type === "dashboard" && state.layout.layout === "canvas", + layout.type === "dashboard" && layout.layout === "canvas", "ChartGridLayout can only be used in a canvas layout!" ); - const configLayout = state.layout as LayoutDashboardFreeCanvas; const allowHeightInitialization = isLayouting(state); const [mounted, setMounted] = useState(false); const mountedForSomeTime = useTimeout(500, mounted); @@ -243,11 +239,16 @@ export const ChartGridLayout = ({ return mapValues(layouts, (chartLayouts) => { return chartLayouts.map((chartLayout) => { + const block = layout.blocks.find( + (block) => block.key === chartLayout.i + ); + return { ...chartLayout, maxW: MAX_W, w: Math.min(MAX_W, chartLayout.w), - resizeHandles: resize ? availableHandles : [], + resizeHandles: + resize && block ? availableHandlesByBlockType[block.type] : [], minH: chartLayout.minH ?? MIN_H, h: Math.max(MIN_H, chartLayout.h), }; @@ -269,7 +270,11 @@ export const ChartGridLayout = ({ return [ breakpoint, chartLayouts.map((chartLayout) => { - if (configLayout.layoutsMetadata[chartLayout.i]?.initialized) { + const block = layout.blocks.find( + (block) => block.key === chartLayout.i + ); + + if (block?.initialized) { return chartLayout; } @@ -302,7 +307,8 @@ export const ChartGridLayout = ({ ...chartLayout, maxW: MAX_W, w: Math.min(MAX_W, chartLayout.w), - resizeHandles: resize ? availableHandles : [], + resizeHandles: + resize && block ? availableHandlesByBlockType[block.type] : [], minH, h: minH, }; @@ -316,14 +322,14 @@ export const ChartGridLayout = ({ dispatch({ type: "LAYOUT_CHANGED", value: { - ...configLayout, + ...layout, layouts: newLayouts, - layoutsMetadata: Object.fromEntries( - state.chartConfigs.map(({ key }) => { - const layoutMetadata = configLayout.layoutsMetadata[key]; - return [key, { ...layoutMetadata, initialized: true }]; - }) - ), + blocks: layout.blocks.map((block) => { + return { + ...block, + initialized: true, + }; + }), }, }); } @@ -334,19 +340,18 @@ export const ChartGridLayout = ({ enhancedLayouts, mountedForSomeTime, resize, - configLayout, + layout, state.chartConfigs, ]); return ( @@ -380,6 +385,7 @@ export const generateLayout = function ({ y: i * h, w: maxWidth, h, + minH: MIN_H, i: i.toString(), resizeHandles, }; @@ -391,6 +397,7 @@ export const generateLayout = function ({ y: 0, w: w, h: maxHeight, + minH: MIN_H, i: i.toString(), resizeHandles, }; @@ -402,6 +409,7 @@ export const generateLayout = function ({ y: i === 0 ? 0 : h * (i - 1), w: maxWidth / 2, h: i === 0 ? maxHeight : h, + minH: MIN_H, i: i.toString(), resizeHandles, }; @@ -412,6 +420,7 @@ export const generateLayout = function ({ y: Math.floor(i / 2) * INITIAL_H, w: getInitialTileWidth(), h: getInitialTileHeight(), + minH: MIN_H, i: i.toString(), resizeHandles: [], }; @@ -423,6 +432,7 @@ export const generateLayout = function ({ y: i === 0 ? 0 : maxHeight / 2, w: i === 0 ? maxWidth : w, h: maxHeight / 2, + minH: MIN_H, i: i.toString(), resizeHandles, }; diff --git a/app/config-types.ts b/app/config-types.ts index a3975baf9..98b27f3de 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1064,6 +1064,7 @@ const ResizeHandle = t.keyof({ const ReactGridLayoutType = t.type({ w: t.number, h: t.number, + minH: t.union([t.number, t.undefined]), x: t.number, y: t.number, i: t.string, @@ -1077,23 +1078,35 @@ export const ReactGridLayoutsType = t.record( ); export type ReactGridLayoutsType = t.TypeOf; -const ReactGridLayoutMetadata = t.type({ - initialized: t.boolean, +const LayoutChartBlock = t.type({ + type: t.literal("chart"), + key: t.string, }); -export type ReactGridLayoutMetadata = t.TypeOf; +export type LayoutChartBlock = t.TypeOf; -const ReactGridLayoutsMetadataType = t.record( - t.string, - ReactGridLayoutMetadata -); -export type ReactGridLayoutsMetadataType = t.TypeOf< - typeof ReactGridLayoutsMetadataType ->; +const LayoutTextBlock = t.type({ + type: t.literal("text"), + key: t.string, + text: t.type({ + de: t.string, + fr: t.string, + it: t.string, + en: t.string, + }), +}); +export type LayoutTextBlock = t.TypeOf; + +const LayoutBlock = t.intersection([ + t.union([LayoutChartBlock, LayoutTextBlock]), + t.type({ initialized: t.boolean }), +]); +export type LayoutBlock = t.TypeOf; const Layout = t.intersection([ t.type({ activeField: t.union([t.undefined, t.string]), meta: Meta, + blocks: t.array(LayoutBlock), }), t.union([ t.type({ @@ -1107,7 +1120,6 @@ const Layout = t.intersection([ type: t.literal("dashboard"), layout: t.literal("canvas"), layouts: ReactGridLayoutsType, - layoutsMetadata: ReactGridLayoutsMetadataType, }), t.type({ type: t.literal("singleURLs"), diff --git a/app/configurator/components/add-dataset-dialog.mock.ts b/app/configurator/components/add-dataset-dialog.mock.ts index e13d00b51..05ce24f78 100644 --- a/app/configurator/components/add-dataset-dialog.mock.ts +++ b/app/configurator/components/add-dataset-dialog.mock.ts @@ -10,6 +10,7 @@ export const photovoltaikChartStateMock: ConfiguratorStateConfiguringChart = { layout: { activeField: "y", type: "tab", + blocks: [{ type: "chart", key: "8-5RW138pTDA", initialized: true }], meta: { title: { de: "", diff --git a/app/configurator/components/annotation-options.tsx b/app/configurator/components/annotation-options.tsx index 9a31f5a27..f526bfa8a 100644 --- a/app/configurator/components/annotation-options.tsx +++ b/app/configurator/components/annotation-options.tsx @@ -16,8 +16,7 @@ import { } from "@/configurator/components/chart-controls/section"; import { MetaInputField } from "@/configurator/components/field"; import { getFieldLabel } from "@/configurator/components/field-i18n"; -import { locales } from "@/locales/locales"; -import { useLocale } from "@/locales/use-locale"; +import { useOrderedLocales } from "@/locales/use-locale"; import useEvent from "@/utils/use-event"; export const ChartAnnotationsSelector = () => { @@ -54,10 +53,7 @@ type AnnotationOptionsProps = { const AnnotationOptions = (props: AnnotationOptionsProps) => { const { type, activeField, meta } = props; const [_, dispatch] = useConfiguratorState(); - const locale = useLocale(); - // Reorder locales so the input field for - // the current locale is on top - const orderedLocales = [locale, ...locales.filter((l) => l !== locale)]; + const orderedLocales = useOrderedLocales(); const panelRef = useRef(null); const handleClosePanel = useEvent(() => { dispatch({ diff --git a/app/configurator/components/annotators.tsx b/app/configurator/components/annotators.tsx index dfa871019..71756da33 100644 --- a/app/configurator/components/annotators.tsx +++ b/app/configurator/components/annotators.tsx @@ -3,6 +3,7 @@ import { Theme, Typography, TypographyProps } from "@mui/material"; import { makeStyles } from "@mui/styles"; import clsx from "clsx"; +import { Markdown } from "@/components/markdown"; import { getChartConfig } from "@/config-utils"; import { ControlSection, @@ -21,18 +22,27 @@ import { } from "@/configurator/configurator-state"; import { useLocale } from "@/locales/use-locale"; -const useStyles = makeStyles({ +const useStyles = makeStyles< + Theme, + { interactive?: boolean; empty: boolean; lighterColor?: boolean } +>((theme) => ({ text: { + wordBreak: "break-word", + color: ({ empty, lighterColor }) => + !empty + ? theme.palette.text.primary + : lighterColor + ? theme.palette.grey[500] + : theme.palette.secondary.main, cursor: ({ interactive }) => (interactive ? "pointer" : "text"), "&:hover": { textDecoration: ({ interactive }) => (interactive ? "underline" : "none"), }, + "& > :last-child": { + marginBottom: 0, + }, }, -}); - -const getEmptyColor = (lighterColor?: boolean) => { - return lighterColor ? "grey.500" : "secondary.main"; -}; +})); type Props = TypographyProps & { text: string; @@ -40,46 +50,64 @@ type Props = TypographyProps & { smaller?: boolean; }; -export const Title = (props: Props) => { - const { text, lighterColor, smaller, onClick, className, sx, ...rest } = - props; - const classes = useStyles({ interactive: !!onClick }); +export const Title = ({ + text, + lighterColor, + smaller, + onClick, + className, + sx, + ...rest +}: Props) => { + const classes = useStyles({ + interactive: !!onClick, + empty: !text, + lighterColor, + }); + return ( - {text ? text : [ Title ]} + {text ? ( + {text} + ) : ( + [ Title ] + )} ); }; -export const Description = (props: Props) => { - const { text, lighterColor, smaller, onClick, className, sx, ...rest } = - props; - const classes = useStyles({ interactive: !!onClick }); +export const Description = ({ + text, + lighterColor, + smaller, + onClick, + className, + sx, + ...rest +}: Props) => { + const classes = useStyles({ + interactive: !!onClick, + empty: !text, + lighterColor, + }); + return ( {text ? ( - text + {text} ) : ( [ Description ] )} diff --git a/app/configurator/components/block-options-selector.tsx b/app/configurator/components/block-options-selector.tsx new file mode 100644 index 000000000..120bdb164 --- /dev/null +++ b/app/configurator/components/block-options-selector.tsx @@ -0,0 +1,54 @@ +import { Trans } from "@lingui/macro"; +import { useMemo } from "react"; + +import { + ControlSection, + ControlSectionContent, + SectionTitle, +} from "@/configurator/components/chart-controls/section"; +import { TextBlockInputField } from "@/configurator/components/field"; +import { isLayouting } from "@/configurator/configurator-state"; +import { useOrderedLocales } from "@/locales/use-locale"; +import { useConfiguratorState } from "@/src"; +import { assert } from "@/utils/assert"; + +export const LayoutBlocksSelector = () => { + const orderedLocales = useOrderedLocales(); + const [state] = useConfiguratorState(isLayouting); + const { layout } = state; + const { blocks } = layout; + const activeBlock = useMemo(() => { + const activeBlock = blocks.find( + (block) => block.key === layout.activeField + ); + + if (activeBlock) { + // For now we only support text blocks + assert(activeBlock.type === "text", "Active block must be a text block"); + } + + return activeBlock; + }, [blocks, layout.activeField]); + + return activeBlock ? ( +
+ + + + Text object + + + + {orderedLocales.map((locale) => ( + + ))} + + +
+ ) : null; +}; diff --git a/app/configurator/components/chart-controls/control-tab.tsx b/app/configurator/components/chart-controls/control-tab.tsx index 02f6f76e9..72afbfa64 100644 --- a/app/configurator/components/chart-controls/control-tab.tsx +++ b/app/configurator/components/chart-controls/control-tab.tsx @@ -23,7 +23,16 @@ import SvgIcEdit from "@/icons/components/IcEdit"; import SvgIcExclamation from "@/icons/components/IcExclamation"; import useEvent from "@/utils/use-event"; -type ControlTabProps = { +export const ControlTabFieldInner = ({ + chartConfig, + fieldComponents, + value, + onClick, + checked, + labelId, + disabled, + warnMessage, +}: { chartConfig: ChartConfig; fieldComponents?: Component[]; value: string; @@ -31,28 +40,14 @@ type ControlTabProps = { labelId: string | null; disabled?: boolean; warnMessage?: string; -} & FieldProps; - -export const ControlTab = (props: ControlTabProps) => { - const { - chartConfig, - fieldComponents, - value, - onClick, - checked, - labelId, - disabled, - warnMessage, - } = props; +} & FieldProps) => { const handleClick = useEvent(() => onClick(value)); const components = fieldComponents; const firstComponent = components?.[0]; - const isActive = overrideChecked(chartConfig, value) - ? true - : !!firstComponent; + const isActive = overrideChecked(chartConfig, value) || !!firstComponent; - const labels = components?.map((x) => getComponentLabel(x)); + const labels = components?.map((c) => getComponentLabel(c)); const { upperLabel, mainLabel } = getLabels( chartConfig, @@ -193,7 +188,7 @@ export const OnOffControlTab = ({ ); }; -export type AnnotatorTabProps = { +export type ControlTabProps = { disabled?: boolean; onClick: (x: string) => void; value: string; @@ -204,7 +199,7 @@ export type AnnotatorTabProps = { rightIcon?: ReactNode; } & FieldProps; -export const AnnotatorTab = ({ +export const ControlTab = ({ value, checked, onClick, @@ -213,7 +208,7 @@ export const AnnotatorTab = ({ mainLabel, lowerLabel, rightIcon, -}: AnnotatorTabProps) => { +}: ControlTabProps) => { return ( ({ minWidth: 160, padding: `${theme.spacing(3)} ${theme.spacing(2)}`, fontWeight: "normal", - fontSize: "0.875rem", transition: "background-color .2s", cursor: "pointer", @@ -309,16 +303,16 @@ const useStyles = makeStyles((theme: Theme) => ({ }, }, controlTabButtonInnerIcon: { + justifyContent: "center", + alignItems: "center", width: 32, - height: 32, minWidth: 32, + height: 32, borderRadius: 2, - justifyContent: "center", - alignItems: "center", + color: theme.palette.grey[700], }, })); -// Generic component const ControlTabButton = ({ disabled, checked, @@ -329,20 +323,19 @@ const ControlTabButton = ({ disabled?: boolean; checked?: boolean; value: string; - onClick: (x: string) => void; + onClick: (value: string) => void; children: ReactNode; }) => { const classes = useStyles(); return (