diff --git a/app/client/jest.config.js b/app/client/jest.config.js index a3a4bef0d9..3fd4b33709 100644 --- a/app/client/jest.config.js +++ b/app/client/jest.config.js @@ -31,6 +31,9 @@ module.exports = { "design-system-old": "/node_modules/design-system-old/build", "@design-system/widgets-old": "/node_modules/@design-system/widgets-old", + "@design-system/widgets": "/node_modules/@design-system/widgets", + "@design-system/headless": "/node_modules/@design-system/headless", + "@design-system/theming": "/node_modules/@design-system/theming", "design-system": "/node_modules/design-system/build", "^proxy-memoize$": "/node_modules/proxy-memoize/dist/wrapper.cjs", // @blueprintjs packages need to be resolved to the `esnext` directory. The default `esm` directory diff --git a/app/client/packages/design-system/headless/src/components/Tooltip/Tooltip.stories.mdx b/app/client/packages/design-system/headless/src/components/Tooltip/Tooltip.stories.mdx index 07dd38b6d2..2ab14d4d05 100644 --- a/app/client/packages/design-system/headless/src/components/Tooltip/Tooltip.stories.mdx +++ b/app/client/packages/design-system/headless/src/components/Tooltip/Tooltip.stories.mdx @@ -1,11 +1,11 @@ import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs"; -import { Tooltip, TooltipTrigger, TooltipContent } from "./"; +import { TooltipRoot, TooltipTrigger, TooltipContent } from "./"; import { Button } from "../Button"; { return ( - + My tooltip - + ); }; @@ -37,30 +37,30 @@ The placement of the tooltip can be changed by passing the `placement` prop. - + My tooltip - - + + My tooltip - - + + My tooltip - - + + My tooltip - + @@ -70,12 +70,12 @@ If the trigger is disabled, the tooltip will not be displayed. - + My tooltip - + diff --git a/app/client/packages/design-system/headless/src/components/Tooltip/Tooltip.tsx b/app/client/packages/design-system/headless/src/components/Tooltip/TooltipRoot.tsx similarity index 72% rename from app/client/packages/design-system/headless/src/components/Tooltip/Tooltip.tsx rename to app/client/packages/design-system/headless/src/components/Tooltip/TooltipRoot.tsx index 2f60e59b15..8f91843382 100644 --- a/app/client/packages/design-system/headless/src/components/Tooltip/Tooltip.tsx +++ b/app/client/packages/design-system/headless/src/components/Tooltip/TooltipRoot.tsx @@ -5,9 +5,9 @@ import { useTooltip } from "./useTooltip"; import { TooltipContext } from "./TooltipContext"; import type { TooltipOptions } from "./useTooltip"; -type TooltipProps = { children: ReactNode } & TooltipOptions; +type TooltipRootProps = { children: ReactNode } & TooltipOptions; -export function Tooltip({ children, ...options }: TooltipProps) { +export function TooltipRoot({ children, ...options }: TooltipRootProps) { const tooltip = useTooltip(options); return ( diff --git a/app/client/packages/design-system/headless/src/components/Tooltip/index.tsx b/app/client/packages/design-system/headless/src/components/Tooltip/index.tsx index a5fbc67a04..bc7a77fab9 100644 --- a/app/client/packages/design-system/headless/src/components/Tooltip/index.tsx +++ b/app/client/packages/design-system/headless/src/components/Tooltip/index.tsx @@ -1,4 +1,4 @@ -export { Tooltip } from "./Tooltip"; +export { TooltipRoot } from "./TooltipRoot"; export { TooltipTrigger } from "./TooltipTrigger"; export { TooltipContent } from "./TooltipContent"; diff --git a/app/client/packages/design-system/theming/src/theme/index.ts b/app/client/packages/design-system/theming/src/theme/index.ts index 9d681df8bc..594fd26808 100644 --- a/app/client/packages/design-system/theming/src/theme/index.ts +++ b/app/client/packages/design-system/theming/src/theme/index.ts @@ -1,3 +1,4 @@ export * from "./ThemeContext"; export * from "./ThemeProvider"; export * from "./types"; +export * from "./useTheme"; diff --git a/app/client/packages/design-system/theming/src/theme/types.ts b/app/client/packages/design-system/theming/src/theme/types.ts index 5854763026..c01b5e00c5 100644 --- a/app/client/packages/design-system/theming/src/theme/types.ts +++ b/app/client/packages/design-system/theming/src/theme/types.ts @@ -1,4 +1,7 @@ import type { ReactNode } from "react"; + +import type { ColorMode } from "../color"; +import type { FontFamily } from "../typography"; import type { RootUnit, ThemeToken } from "../token"; export type Theme = ThemeToken & { @@ -15,3 +18,11 @@ export interface ThemeProviderProps { children: ReactNode; className?: string; } + +export type UseThemeProps = { + seedColor?: string; + colorMode?: ColorMode; + borderRadius?: string; + fontFamily?: FontFamily; + rootUnitRatio?: number; +}; diff --git a/app/client/packages/design-system/theming/src/theme/useTheme.tsx b/app/client/packages/design-system/theming/src/theme/useTheme.tsx new file mode 100644 index 0000000000..095043e042 --- /dev/null +++ b/app/client/packages/design-system/theming/src/theme/useTheme.tsx @@ -0,0 +1,137 @@ +import Color from "colorjs.io"; +import { useEffect, useState } from "react"; +import { + TokensAccessor, + defaultTokens, + useFluidTokens, +} from "@design-system/theming"; +import type { ColorMode, FontFamily } from "@design-system/theming"; + +import type { UseThemeProps } from "./types"; + +export function useTheme(props: UseThemeProps) { + const { + borderRadius, + colorMode = "light", + fontFamily, + rootUnitRatio: rootUnitRatioProp, + seedColor, + } = props; + + const [rootUnitRatio, setRootUnitRatio] = useState(1); + const { fluid, ...restDefaultTokens } = defaultTokens; + + const { rootUnit, sizing, spacing, typography } = useFluidTokens( + fluid, + rootUnitRatio, + ); + + const tokensAccessor = new TokensAccessor({ + ...restDefaultTokens, + rootUnit, + spacing, + sizing, + typography, + colorMode: colorMode as ColorMode, + }); + + const [theme, setTheme] = useState(tokensAccessor.getAllTokens()); + + const updateFontFamily = (fontFamily: FontFamily) => { + tokensAccessor.updateFontFamily(fontFamily); + + setTheme((prevState) => { + return { + ...prevState, + typography: tokensAccessor.getTypography(), + }; + }); + }; + + useEffect(() => { + if (colorMode) { + tokensAccessor.updateColorMode(colorMode); + + setTheme((prevState) => { + return { + ...prevState, + ...tokensAccessor.getColors(), + }; + }); + } + }, [colorMode]); + + useEffect(() => { + if (borderRadius) { + tokensAccessor.updateBorderRadius({ + 1: borderRadius, + }); + + setTheme((prevState) => { + return { + ...prevState, + ...tokensAccessor.getBorderRadius(), + }; + }); + } + }, [borderRadius]); + + useEffect(() => { + if (seedColor) { + let color; + + try { + color = Color.parse(seedColor); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } + + if (color) { + tokensAccessor.updateSeedColor(seedColor); + + setTheme((prevState) => { + return { + ...prevState, + ...tokensAccessor.getColors(), + }; + }); + } + } + }, [seedColor]); + + useEffect(() => { + if (fontFamily) { + updateFontFamily(fontFamily); + } + }, [fontFamily]); + + useEffect(() => { + if (rootUnitRatioProp) { + setRootUnitRatio(rootUnitRatioProp); + tokensAccessor.updateRootUnit(rootUnit); + tokensAccessor.updateSpacing(spacing); + + setTheme((prevState) => { + return { + ...prevState, + rootUnit: tokensAccessor.getRootUnit(), + ...tokensAccessor.getSpacing(), + }; + }); + } + }, [rootUnitRatioProp]); + + useEffect(() => { + tokensAccessor.updateTypography(typography); + + setTheme((prevState) => { + return { + ...prevState, + typography: tokensAccessor.getTypography(), + }; + }); + }, [typography]); + + return { theme, setTheme }; +} diff --git a/app/client/packages/design-system/theming/src/token/TokensAccessor.ts b/app/client/packages/design-system/theming/src/token/TokensAccessor.ts index 1877acf615..1f2d388f8c 100644 --- a/app/client/packages/design-system/theming/src/token/TokensAccessor.ts +++ b/app/client/packages/design-system/theming/src/token/TokensAccessor.ts @@ -24,6 +24,7 @@ export class TokensAccessor { private fontFamily?: FontFamily; private spacing?: TokenObj; private sizing?: TokenObj; + private zIndex?: TokenObj; constructor({ borderRadius, @@ -37,6 +38,7 @@ export class TokensAccessor { sizing, spacing, typography, + zIndex, }: TokenSource) { this.seedColor = seedColor; this.colorMode = colorMode; @@ -49,6 +51,7 @@ export class TokensAccessor { this.sizing = sizing; this.spacing = spacing; this.typography = typography; + this.zIndex = zIndex; } updateRootUnit = (rootUnit: RootUnit) => { @@ -87,6 +90,10 @@ export class TokensAccessor { this.opacity = opacity; }; + updateZIndex = (zIndex: TokenObj) => { + this.zIndex = zIndex; + }; + updateSpacing = (spacing: TokenObj) => { this.spacing = spacing; }; @@ -107,6 +114,7 @@ export class TokensAccessor { ...this.getBoxShadow(), ...this.getBorderWidth(), ...this.getOpacity(), + ...this.getZIndex(), }; }; @@ -178,6 +186,12 @@ export class TokensAccessor { return this.createTokenObject(this.opacity, "opacity"); }; + getZIndex = () => { + if (this.zIndex == null) return {} as ThemeToken; + + return this.createTokenObject(this.zIndex, "zIndex"); + }; + private get isLightMode() { return this.colorMode === "light"; } diff --git a/app/client/packages/design-system/theming/src/token/defaultTokens.json b/app/client/packages/design-system/theming/src/token/defaultTokens.json index 271a0c0a37..8c5300e140 100644 --- a/app/client/packages/design-system/theming/src/token/defaultTokens.json +++ b/app/client/packages/design-system/theming/src/token/defaultTokens.json @@ -47,5 +47,11 @@ }, "opacity": { "disabled": 0.3 + }, + "zIndex": { + "1": 3, + "2": 4, + "3": 10, + "99": 9999 } } diff --git a/app/client/packages/design-system/theming/src/token/types.ts b/app/client/packages/design-system/theming/src/token/types.ts index 4cf8812682..59878c60e1 100644 --- a/app/client/packages/design-system/theming/src/token/types.ts +++ b/app/client/packages/design-system/theming/src/token/types.ts @@ -12,7 +12,8 @@ export type TokenType = | "borderRadius" | "boxShadow" | "borderWidth" - | "opacity"; + | "opacity" + | "zIndex"; export interface Token { value: string | number; @@ -31,6 +32,7 @@ export interface TokenSource { borderWidth?: TokenObj; opacity?: TokenObj; fontFamily?: FontFamily; + zIndex?: TokenObj; sizing?: TokenObj; spacing?: TokenObj; } diff --git a/app/client/packages/design-system/widgets/src/components/Button/Button.tsx b/app/client/packages/design-system/widgets/src/components/Button/Button.tsx index 1d1eb92b01..b3cd8e7db4 100644 --- a/app/client/packages/design-system/widgets/src/components/Button/Button.tsx +++ b/app/client/packages/design-system/widgets/src/components/Button/Button.tsx @@ -8,6 +8,7 @@ import { useVisuallyHidden } from "@react-aria/visually-hidden"; import { Text } from "../Text"; import { Spinner } from "../Spinner"; +import type { ButtonColor, ButtonIconPosition, ButtonVariant } from "./types"; import { DragContainer, StyledButton } from "./index.styled"; export interface ButtonProps extends Omit { @@ -15,9 +16,9 @@ export interface ButtonProps extends Omit { * * @default "filled" */ - variant?: "filled" | "outlined" | "ghost"; + variant?: ButtonVariant; /** Color tone of the button */ - color?: "accent" | "neutral" | "positive" | "negative" | "warning"; + color?: ButtonColor; /** When true, makes the button occupy all the space available */ isFitContainer?: boolean; /** Indicates the loading state of the button */ @@ -25,8 +26,8 @@ export interface ButtonProps extends Omit { /** Icon to be used in the button of the button */ icon?: React.ReactNode; /** Indicates the position of icon of the button */ - iconPosition?: "start" | "end"; - /** Makes the button visually and functionally disabled but focusable */ + iconPosition?: ButtonIconPosition; + /** Makes the button visually and functionaly disabled but focusable */ visuallyDisabled?: boolean; } @@ -64,7 +65,9 @@ export const Button = forwardRef( return ( <> {icon} - {children} + + {children} + ); }; @@ -80,7 +83,7 @@ export const Button = forwardRef( data-button="" data-color={color} data-fit-container={isFitContainer ? "" : undefined} - data-icon-position={iconPosition === "start" ? undefined : "end"} + data-icon-position={iconPosition === "start" ? "start" : "end"} data-loading={isLoading ? "" : undefined} data-variant={variant} draggable diff --git a/app/client/packages/design-system/widgets/src/components/Button/index.styled.tsx b/app/client/packages/design-system/widgets/src/components/Button/index.styled.tsx index 0ea2c7d251..2bafbd3821 100644 --- a/app/client/packages/design-system/widgets/src/components/Button/index.styled.tsx +++ b/app/client/packages/design-system/widgets/src/components/Button/index.styled.tsx @@ -1,8 +1,8 @@ import styled, { css } from "styled-components"; import { Button as HeadlessButton } from "@design-system/headless"; -import type { PickRename } from "../../utils"; import type { ButtonProps } from "./Button"; +import type { PickRename } from "../../utils"; type StyledButtonProps = PickRename< ButtonProps, @@ -73,16 +73,19 @@ export const StyledButton = styled(HeadlessButton)` cursor: pointer; outline: 0; padding: var(--spacing-2) var(--spacing-4); + block-size: var(--sizing-8); border-radius: var(--border-radius-1); user-select: none; - height: var(--sizing-8); - min-width: var(--sizing-8); - text-align: center; + min-inline-size: var(--sizing-8); position: relative; font-weight: 600; - & *:not([data-hidden]) + *:not([data-hidden]) { - margin: 0 var(--spacing-1); + &[data-icon-position="start"] *:not([data-hidden]) + *:not([data-hidden]) { + margin-inline-start: var(--spacing-1); + } + + &[data-icon-position="end"] *:not([data-hidden]) + *:not([data-hidden]) { + margin-inline-end: var(--spacing-1); } ${buttonStyles} @@ -96,8 +99,14 @@ export const StyledButton = styled(HeadlessButton)` display: flex; justify-content: center; align-items: center; - height: var(--sizing-5); - width: var(--sizing-5); + height: var(--sizing-4); + width: var(--sizing-4); + } + + // Note: adding important here as ADS is overriding the color of blueprint icon globally + // TODO(pawan): Remove this once ADS team removes the global override + &[data-button] .bp3-icon { + color: currentColor !important; } /** diff --git a/app/client/packages/design-system/widgets/src/components/Button/index.tsx b/app/client/packages/design-system/widgets/src/components/Button/index.tsx index 1c3bdccf40..7a23efc0f3 100644 --- a/app/client/packages/design-system/widgets/src/components/Button/index.tsx +++ b/app/client/packages/design-system/widgets/src/components/Button/index.tsx @@ -1,2 +1,3 @@ export { Button } from "./Button"; export type { ButtonProps } from "./Button"; +export * from "./types"; diff --git a/app/client/packages/design-system/widgets/src/components/Button/types.ts b/app/client/packages/design-system/widgets/src/components/Button/types.ts new file mode 100644 index 0000000000..8d4f898ff4 --- /dev/null +++ b/app/client/packages/design-system/widgets/src/components/Button/types.ts @@ -0,0 +1,26 @@ +export const BUTTON_VARIANTS = { + FILLED: "filled", + OUTLINED: "outlined", + GHOST: "ghost", +} as const; + +export type ButtonVariant = + (typeof BUTTON_VARIANTS)[keyof typeof BUTTON_VARIANTS]; + +export const BUTTON_ICON_POSITIONS = { + START: "start", + END: "end", +} as const; + +export type ButtonIconPosition = + (typeof BUTTON_ICON_POSITIONS)[keyof typeof BUTTON_ICON_POSITIONS]; + +export const BUTTON_COLORS = { + ACCENT: "accent", + NEUTRAL: "neutral", + POSITIVE: "positive", + NEGATIVE: "negative", + WARNING: "warning", +} as const; + +export type ButtonColor = (typeof BUTTON_COLORS)[keyof typeof BUTTON_COLORS]; diff --git a/app/client/packages/design-system/widgets/src/components/Text/index.styled.tsx b/app/client/packages/design-system/widgets/src/components/Text/index.styled.tsx index 2342d03e16..995f1a277f 100644 --- a/app/client/packages/design-system/widgets/src/components/Text/index.styled.tsx +++ b/app/client/packages/design-system/widgets/src/components/Text/index.styled.tsx @@ -33,7 +33,7 @@ export const StyledText = styled.div` font-weight: ${({ $isBold }) => ($isBold ? "bold" : "normal")}; font-style: ${({ $isItalic }) => ($isItalic ? "italic" : "normal")}; text-align: ${({ $textAlign }) => $textAlign}; - max-width: 100%; + width: 100%; color: ${({ color }) => { switch (true) { diff --git a/app/client/packages/design-system/widgets/src/components/Tooltip/Tooltip.stories.mdx b/app/client/packages/design-system/widgets/src/components/Tooltip/Tooltip.stories.mdx index 4d01f2d448..fc505ae720 100644 --- a/app/client/packages/design-system/widgets/src/components/Tooltip/Tooltip.stories.mdx +++ b/app/client/packages/design-system/widgets/src/components/Tooltip/Tooltip.stories.mdx @@ -2,11 +2,11 @@ import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs"; import { Button } from "../Button"; import { ButtonGroup } from "../ButtonGroup"; -import { Tooltip, TooltipTrigger, TooltipContent } from "./"; +import { TooltipRoot, TooltipTrigger, TooltipContent } from "./"; { return ( - + My tooltip - + ); }; @@ -41,30 +41,30 @@ The placement of the tooltip can be changed by passing the `placement` prop. - + My tooltip - - + + My tooltip - - + + My tooltip - - + + My tooltip - + @@ -75,7 +75,7 @@ If the trigger is disabled, the tooltip will still be displayed. - + diff --git a/app/client/packages/design-system/widgets/src/components/Tooltip/Tooltip.tsx b/app/client/packages/design-system/widgets/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000000..599050b918 --- /dev/null +++ b/app/client/packages/design-system/widgets/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +import { TooltipRoot, TooltipContent, TooltipTrigger } from "./"; + +export type TooltipProps = { + tooltip?: React.ReactNode; + children: React.ReactElement; +}; + +export function Tooltip(props: TooltipProps) { + const { children, tooltip } = props; + + if (!tooltip) return children; + + return ( + + {children} + {tooltip} + + ); +} diff --git a/app/client/packages/design-system/widgets/src/components/Tooltip/index.styled.tsx b/app/client/packages/design-system/widgets/src/components/Tooltip/index.styled.tsx index 70780dac6c..07083630d6 100644 --- a/app/client/packages/design-system/widgets/src/components/Tooltip/index.styled.tsx +++ b/app/client/packages/design-system/widgets/src/components/Tooltip/index.styled.tsx @@ -15,6 +15,7 @@ export const StyledTooltipContent = styled( color: var(--color-fg-on-assistive); padding: var(--spacing-2) var(--spacing-3); border-radius: var(--border-radius-1); + z-index: var(--z-index-99); [data-tooltip-trigger-arrow] { fill: var(--color-bg-assistive); diff --git a/app/client/packages/design-system/widgets/src/components/Tooltip/index.tsx b/app/client/packages/design-system/widgets/src/components/Tooltip/index.tsx index dd99c34245..dacc28afd3 100644 --- a/app/client/packages/design-system/widgets/src/components/Tooltip/index.tsx +++ b/app/client/packages/design-system/widgets/src/components/Tooltip/index.tsx @@ -1,3 +1,4 @@ -export { Tooltip } from "@design-system/headless"; +export { Tooltip } from "./Tooltip"; +export { TooltipRoot } from "@design-system/headless"; export { TooltipTrigger } from "./TooltipTrigger"; export { TooltipContent } from "./TooltipContent"; diff --git a/app/client/packages/design-system/widgets/src/index.ts b/app/client/packages/design-system/widgets/src/index.ts index cc5101a387..e0d58b8781 100644 --- a/app/client/packages/design-system/widgets/src/index.ts +++ b/app/client/packages/design-system/widgets/src/index.ts @@ -1,4 +1,6 @@ // components +export { Icon } from "@design-system/headless"; + export * from "./components/Button"; export * from "./components/Checkbox"; export * from "./components/Text"; diff --git a/app/client/packages/design-system/widgets/src/testComponents/ComplexForm.tsx b/app/client/packages/design-system/widgets/src/testComponents/ComplexForm.tsx index 6361a5c544..d65799e854 100644 --- a/app/client/packages/design-system/widgets/src/testComponents/ComplexForm.tsx +++ b/app/client/packages/design-system/widgets/src/testComponents/ComplexForm.tsx @@ -4,7 +4,7 @@ import { Text, CheckboxGroup, Checkbox, - Tooltip, + TooltipRoot, TooltipTrigger, TooltipContent, } from "../"; @@ -34,14 +34,14 @@ export const ComplexForm = () => { gap: "var(--spacing-2)", }} > - + If you cancel, you will lose your order - + diff --git a/app/client/packages/design-system/widgets/src/utils/OmitRename.ts b/app/client/packages/design-system/widgets/src/utils/OmitRename.ts index 1448af6789..95dd166032 100644 --- a/app/client/packages/design-system/widgets/src/utils/OmitRename.ts +++ b/app/client/packages/design-system/widgets/src/utils/OmitRename.ts @@ -3,7 +3,5 @@ export type OmitRename< TOmitKeys extends keyof TObj, TSymbol extends string = "$", > = { - [K in keyof TObj as K extends TOmitKeys - ? never - : `${TSymbol}${string & K}`]: TObj[K]; -} & Omit; + [K in keyof Omit as `${TSymbol}${string & K}`]: TObj[K]; +} & Pick; diff --git a/app/client/packages/storybook/.storybook/decorators/theming.tsx b/app/client/packages/storybook/.storybook/decorators/theming.tsx index 498cd84aa6..cc1b35c791 100644 --- a/app/client/packages/storybook/.storybook/decorators/theming.tsx +++ b/app/client/packages/storybook/.storybook/decorators/theming.tsx @@ -7,6 +7,7 @@ import { TokensAccessor, defaultTokens, useFluidTokens, + useTheme, } from "@design-system/theming"; import Color from "colorjs.io"; @@ -22,132 +23,14 @@ const StyledThemeProvider = styled(ThemeProvider)` `; export const theming = (Story, args) => { - const [rootUnitRatio, setRootUnitRatio] = useState(1); - const { fluid, ...restDefaultTokens } = defaultTokens; - - const { typography, rootUnit, spacing, sizing } = useFluidTokens( - fluid, - rootUnitRatio, - ); - - const tokensAccessor = new TokensAccessor({ - ...restDefaultTokens, - rootUnit, - spacing, - sizing, - typography, + const { theme } = useTheme({ + seedColor: args.globals.accentColor, + colorMode: args.globals.colorMode, + borderRadius: args.globals.borderRadius, + fontFamily: args.globals.fontFamily, + rootUnitRatio: args.globals.rootUnitRatio, }); - const [theme, setTheme] = useState(tokensAccessor.getAllTokens()); - - const updateFontFamily = (fontFamily) => { - tokensAccessor.updateFontFamily(fontFamily); - - setTheme((prevState) => { - return { - ...prevState, - typography: tokensAccessor.getTypography(), - }; - }); - }; - - useEffect(() => { - if (args.globals.colorMode) { - tokensAccessor.updateColorMode(args.globals.colorMode); - - setTheme((prevState) => { - return { - ...prevState, - ...tokensAccessor.getColors(), - }; - }); - } - }, [args.globals.colorMode]); - - useEffect(() => { - if (args.globals.borderRadius) { - tokensAccessor.updateBorderRadius({ - 1: args.globals.borderRadius, - }); - - setTheme((prevState) => { - return { - ...prevState, - ...tokensAccessor.getBorderRadius(), - }; - }); - } - }, [args.globals.borderRadius]); - - useEffect(() => { - if (args.globals.accentColor) { - let color; - - try { - color = Color.parse(args.globals.accentColor); - } catch (error) { - console.error(error); - } - - if (color) { - tokensAccessor.updateSeedColor(args.globals.accentColor); - - setTheme((prevState) => { - return { - ...prevState, - ...tokensAccessor.getColors(), - }; - }); - } - } - }, [args.globals.accentColor]); - - useEffect(() => { - if ( - args.globals.fontFamily && - args.globals.fontFamily !== "Arial" && - args.globals.fontFamily !== "System Default" - ) { - webfontloader.load({ - google: { - families: [`${args.globals.fontFamily}:300,400,500,700`], - }, - active: () => { - updateFontFamily(args.globals.fontFamily); - }, - }); - } else { - updateFontFamily(args.globals.fontFamily); - } - }, [args.globals.fontFamily]); - - useEffect(() => { - if (args.globals.rootUnitRatio) { - setRootUnitRatio(args.globals.rootUnitRatio); - tokensAccessor.updateRootUnit(rootUnit); - tokensAccessor.updateSpacing(spacing); - - setTheme((prevState) => { - return { - ...prevState, - rootUnit: tokensAccessor.getRootUnit(), - ...tokensAccessor.getSpacing(), - }; - }); - } - }, [args.globals.rootUnitRatio]); - - useEffect(() => { - tokensAccessor.updateTypography(typography); - - setTheme((prevState) => { - return { - ...prevState, - typography: tokensAccessor.getTypography(), - }; - }); - }, [typography]); - return ( diff --git a/app/client/packages/storybook/.storybook/styles.css b/app/client/packages/storybook/.storybook/styles.css index 7633354920..3859a2fdf7 100644 --- a/app/client/packages/storybook/.storybook/styles.css +++ b/app/client/packages/storybook/.storybook/styles.css @@ -15,3 +15,14 @@ body, .innerZoomElementWrapper > * { overflow: hidden; } + +/* fonts */ +@import url("https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap"); diff --git a/app/client/src/ce/entities/FeatureFlag.ts b/app/client/src/ce/entities/FeatureFlag.ts index f42f174173..8d167a7841 100644 --- a/app/client/src/ce/entities/FeatureFlag.ts +++ b/app/client/src/ce/entities/FeatureFlag.ts @@ -13,6 +13,7 @@ export const FEATURE_FLAG = { ab_ds_schema_enabled: "ab_ds_schema_enabled", ab_ds_binding_enabled: "ab_ds_binding_enabled", release_scim_provisioning_enabled: "release_scim_provisioning_enabled", + ab_wds_enabled: "ab_wds_enabled", release_widgetdiscovery_enabled: "release_widgetdiscovery_enabled", } as const; @@ -31,6 +32,7 @@ export const DEFAULT_FEATURE_FLAG_VALUE: FeatureFlags = { ab_ds_schema_enabled: false, ab_ds_binding_enabled: false, release_scim_provisioning_enabled: false, + ab_wds_enabled: false, release_widgetdiscovery_enabled: false, }; diff --git a/app/client/src/ce/selectors/featureFlagsSelectors.ts b/app/client/src/ce/selectors/featureFlagsSelectors.ts index e534163711..a9eaf123ab 100644 --- a/app/client/src/ce/selectors/featureFlagsSelectors.ts +++ b/app/client/src/ce/selectors/featureFlagsSelectors.ts @@ -1,6 +1,6 @@ +import { createSelector } from "reselect"; import type { AppState } from "@appsmith/reducers"; import type { FeatureFlag } from "@appsmith/entities/FeatureFlag"; -import { createSelector } from "reselect"; export const selectFeatureFlags = (state: AppState) => state.ui.users.featureFlag.data; diff --git a/app/client/src/components/wds/constants.ts b/app/client/src/components/wds/constants.ts new file mode 100644 index 0000000000..c795f1b80f --- /dev/null +++ b/app/client/src/components/wds/constants.ts @@ -0,0 +1,3 @@ +export const WDS_V2_WIDGET_MAP = { + BUTTON_WIDGET: "BUTTON_WIDGET_V2", +}; diff --git a/app/client/src/pages/AppViewer/index.tsx b/app/client/src/pages/AppViewer/index.tsx index 25d7d66101..24f98b6174 100644 --- a/app/client/src/pages/AppViewer/index.tsx +++ b/app/client/src/pages/AppViewer/index.tsx @@ -37,13 +37,18 @@ import { WidgetGlobaStyles } from "globalStyles/WidgetGlobalStyles"; import { getAppsmithConfigs } from "@appsmith/configs"; import useWidgetFocus from "utils/hooks/useWidgetFocus/useWidgetFocus"; import HtmlTitle from "./AppViewerHtmlTitle"; +import BottomBar from "components/BottomBar"; import type { ApplicationPayload } from "@appsmith/constants/ReduxActionConstants"; import { getCurrentApplication } from "@appsmith/selectors/applicationSelectors"; import { editorInitializer } from "../../utils/editor/EditorUtils"; import { widgetInitialisationSuccess } from "../../actions/widgetActions"; -import BottomBar from "components/BottomBar"; import { areEnvironmentsFetched } from "@appsmith/selectors/environmentSelectors"; import { datasourceEnvEnabled } from "@appsmith/selectors/featureFlagsSelectors"; +import { + ThemeProvider as WDSThemeProvider, + useTheme, +} from "@design-system/theming"; +import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; const AppViewerBody = styled.section<{ hasPages: boolean; @@ -98,7 +103,10 @@ function AppViewer(props: Props) { const currentApplicationDetails: ApplicationPayload | undefined = useSelector( getCurrentApplication, ); - + const { theme } = useTheme({ + borderRadius: selectedTheme.properties.borderRadius.appBorderRadius, + seedColor: selectedTheme.properties.colors.primaryColor, + }); const focusRef = useWidgetFocus(); const workspaceId = currentApplicationDetails?.workspaceId || ""; @@ -176,46 +184,53 @@ function AppViewer(props: Props) { }; }, [selectedTheme.properties.fontFamily.appFont]); + const isWDSV2Enabled = useFeatureFlag("ab_wds_enabled"); + const backgroundForBody = isWDSV2Enabled + ? "var(--color-bg)" + : selectedTheme.properties.colors.backgroundColor; + return ( - - - - - - 1} - headerHeight={headerHeight} - ref={focusRef} - showBottomBar={showBottomBar} - showGuidedTourMessage={showGuidedTourMessage} - > - {isInitialized && } - - {showBottomBar && } - {!hideWatermark && ( - + + + + {!isWDSV2Enabled && ( + )} - - - + + + 1} + headerHeight={headerHeight} + ref={focusRef} + showBottomBar={showBottomBar} + showGuidedTourMessage={showGuidedTourMessage} + > + {isInitialized && } + + {showBottomBar && } + {!hideWatermark && ( + + )} + + + + ); } diff --git a/app/client/src/pages/Editor/Canvas.tsx b/app/client/src/pages/Editor/Canvas.tsx index 8cf25da713..bdce771fba 100644 --- a/app/client/src/pages/Editor/Canvas.tsx +++ b/app/client/src/pages/Editor/Canvas.tsx @@ -1,16 +1,21 @@ -import * as Sentry from "@sentry/react"; import log from "loglevel"; import React from "react"; import styled from "styled-components"; +import * as Sentry from "@sentry/react"; +import { useSelector } from "react-redux"; import WidgetFactory from "utils/WidgetFactory"; import type { CanvasWidgetStructure } from "widgets/constants"; import { RenderModes } from "constants/WidgetConstants"; -import { useSelector } from "react-redux"; -import { getSelectedAppTheme } from "selectors/appThemingSelectors"; -import { previewModeSelector } from "selectors/editorSelectors"; import useWidgetFocus from "utils/hooks/useWidgetFocus"; +import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; +import { previewModeSelector } from "selectors/editorSelectors"; +import { getSelectedAppTheme } from "selectors/appThemingSelectors"; import { getViewportClassName } from "utils/autoLayout/AutoLayoutUtils"; +import { + ThemeProvider as WDSThemeProvider, + useTheme, +} from "@design-system/theming"; import { getIsAppSettingsPaneWithNavigationTabOpen } from "selectors/appSettingsPaneSelectors"; interface CanvasProps { @@ -36,6 +41,11 @@ const Canvas = (props: CanvasProps) => { getIsAppSettingsPaneWithNavigationTabOpen, ); const selectedTheme = useSelector(getSelectedAppTheme); + const isWDSV2Enabled = useFeatureFlag("ab_wds_enabled"); + const { theme } = useTheme({ + borderRadius: selectedTheme.properties.borderRadius.appBorderRadius, + seedColor: selectedTheme.properties.colors.primaryColor, + }); /** * background for canvas @@ -43,9 +53,17 @@ const Canvas = (props: CanvasProps) => { let backgroundForCanvas; if (isPreviewMode || isAppSettingsPaneWithNavigationTabOpen) { - backgroundForCanvas = "initial"; + if (isWDSV2Enabled) { + backgroundForCanvas = "var(--color-bg)"; + } else { + backgroundForCanvas = "initial"; + } } else { - backgroundForCanvas = selectedTheme.properties.colors.backgroundColor; + if (isWDSV2Enabled) { + backgroundForCanvas = "var(--color-bg)"; + } else { + backgroundForCanvas = selectedTheme.properties.colors.backgroundColor; + } } const focusRef = useWidgetFocus(); @@ -54,23 +72,25 @@ const Canvas = (props: CanvasProps) => { const paddingBottomClass = props.isAutoLayout ? "" : "pb-52"; try { return ( - - {props.widgetsStructure.widgetId && - WidgetFactory.createWidget( - props.widgetsStructure, - RenderModes.CANVAS, - )} - + + + {props.widgetsStructure.widgetId && + WidgetFactory.createWidget( + props.widgetsStructure, + RenderModes.CANVAS, + )} + + ); } catch (error) { log.error("Error rendering DSL", error); diff --git a/app/client/src/pages/Editor/PropertyPane/PropertyPaneView.tsx b/app/client/src/pages/Editor/PropertyPane/PropertyPaneView.tsx index 5441a70c3a..0e2f8c01e0 100644 --- a/app/client/src/pages/Editor/PropertyPane/PropertyPaneView.tsx +++ b/app/client/src/pages/Editor/PropertyPane/PropertyPaneView.tsx @@ -40,6 +40,7 @@ export const excludeList: WidgetType[] = [ "FILE_PICKER_WIDGET", "FILE_PICKER_WIDGET_V2", "TABLE_WIDGET_V2", + "BUTTON_WIDGET_V2", ]; function PropertyPaneView( @@ -184,7 +185,7 @@ function PropertyPaneView( return (
diff --git a/app/client/src/pages/Editor/WidgetsEditor/CanvasContainer.tsx b/app/client/src/pages/Editor/WidgetsEditor/CanvasContainer.tsx index f326aaca07..7db7ec79ce 100644 --- a/app/client/src/pages/Editor/WidgetsEditor/CanvasContainer.tsx +++ b/app/client/src/pages/Editor/WidgetsEditor/CanvasContainer.tsx @@ -25,15 +25,16 @@ import { getSelectedAppTheme, } from "selectors/appThemingSelectors"; import { getIsAutoLayout } from "selectors/canvasSelectors"; -import { getCanvasWidgetsStructure } from "selectors/entitiesSelector"; import { getCurrentThemeDetails } from "selectors/themeSelectors"; +import { getCanvasWidgetsStructure } from "selectors/entitiesSelector"; import { AUTOLAYOUT_RESIZER_WIDTH_BUFFER, useDynamicAppLayout, } from "utils/hooks/useDynamicAppLayout"; import Canvas from "../Canvas"; -import { CanvasResizer } from "widgets/CanvasResizer"; import type { AppState } from "@appsmith/reducers"; +import { CanvasResizer } from "widgets/CanvasResizer"; +import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; import { getIsAnonymousDataPopupVisible } from "selectors/onboardingSelectors"; type CanvasContainerProps = { @@ -119,9 +120,9 @@ function CanvasContainer(props: CanvasContainerProps) { const isAppThemeChanging = useSelector(getAppThemeIsChanging); const showCanvasTopSection = useSelector(showCanvasTopSectionSelector); const showAnonymousDataPopup = useSelector(getIsAnonymousDataPopupVisible); - const isLayoutingInitialized = useDynamicAppLayout(); const isPageInitializing = isFetchingPage || !isLayoutingInitialized; + const isWDSV2Enabled = useFeatureFlag("ab_wds_enabled"); useEffect(() => { return () => { @@ -184,7 +185,9 @@ function CanvasContainer(props: CanvasContainerProps) { $isAutoLayout={isAutoLayout} background={ isPreviewMode || isAppSettingsPaneWithNavigationTabOpen - ? selectedTheme.properties.colors.backgroundColor + ? isWDSV2Enabled + ? "var(--bg-color)" + : selectedTheme.properties.colors.backgroundColor : "initial" } className={classNames({ @@ -214,10 +217,12 @@ function CanvasContainer(props: CanvasContainerProps) { pointerEvents: isAutoCanvasResizing ? "none" : "auto", }} > - + {!isWDSV2Enabled && ( + + )} {isAppThemeChanging && (
diff --git a/app/client/src/reducers/uiReducers/appThemingReducer.ts b/app/client/src/reducers/uiReducers/appThemingReducer.ts index e1579ad5bf..d4e2c5614e 100644 --- a/app/client/src/reducers/uiReducers/appThemingReducer.ts +++ b/app/client/src/reducers/uiReducers/appThemingReducer.ts @@ -42,7 +42,7 @@ const initialState: AppThemingState = { properties: { colors: { backgroundColor: "#F8FAFC", - primaryColor: "", + primaryColor: "#000", secondaryColor: "", }, borderRadius: {}, diff --git a/app/client/src/selectors/editorSelectors.tsx b/app/client/src/selectors/editorSelectors.tsx index 2b75733d34..c936780c95 100644 --- a/app/client/src/selectors/editorSelectors.tsx +++ b/app/client/src/selectors/editorSelectors.tsx @@ -9,7 +9,6 @@ import type { AppLayoutConfig, PageListReduxState, } from "reducers/entityReducers/pageListReducer"; -import type { WidgetConfigReducerState } from "reducers/entityReducers/widgetConfigReducer"; import type { WidgetCardProps, WidgetProps } from "widgets/BaseWidget"; import type { Page } from "@appsmith/constants/ReduxActionConstants"; @@ -61,6 +60,9 @@ import WidgetFactory from "utils/WidgetFactory"; import { isAirgapped } from "@appsmith/utils/airgapHelpers"; import { nestDSL } from "@shared/dsl"; import { getIsAnonymousDataPopupVisible } from "./onboardingSelectors"; +import { WDS_V2_WIDGET_MAP } from "components/wds/constants"; +import { selectFeatureFlagCheck } from "@appsmith/selectors/featureFlagsSelectors"; +import { FEATURE_FLAG } from "@appsmith/entities/FeatureFlag"; const getIsDraggingOrResizing = (state: AppState) => state.ui.widgetDragResize.isResizing || state.ui.widgetDragResize.isDragging; @@ -336,12 +338,29 @@ export const getCurrentPageName = createSelector( export const getWidgetCards = createSelector( getWidgetConfigs, getIsAutoLayout, - (widgetConfigs: WidgetConfigReducerState, isAutoLayout: boolean) => { + (_state) => selectFeatureFlagCheck(_state, FEATURE_FLAG.ab_wds_enabled), + (widgetConfigs, isAutoLayout, isWDSEnabled) => { const cards = Object.values(widgetConfigs.config).filter((config) => { - return isAirgapped() - ? config.widgetName !== "Map" && !config.hideCard - : !config.hideCard; + if (isAirgapped()) { + return config.widgetName !== "Map" && !config.hideCard; + } + + // if wds_vs is not enabled, hide all wds_v2 widgets + if ( + Object.values(WDS_V2_WIDGET_MAP).includes(config.type) && + isWDSEnabled === false + ) { + return false; + } + + // if wds is enabled, only show the wds_v2 widgets + if (isWDSEnabled === true) { + return Object.values(WDS_V2_WIDGET_MAP).includes(config.type); + } + + return !config.hideCard; }); + const _cards: WidgetCardProps[] = cards.map((config) => { const { detachFromLayout = false, @@ -374,6 +393,7 @@ export const getWidgetCards = createSelector( }; }); const sortedCards = sortBy(_cards, ["displayName"]); + return sortedCards; }, ); diff --git a/app/client/src/utils/WidgetRegistry.tsx b/app/client/src/utils/WidgetRegistry.tsx index 96766a4cd1..ae05ea1970 100644 --- a/app/client/src/utils/WidgetRegistry.tsx +++ b/app/client/src/utils/WidgetRegistry.tsx @@ -164,6 +164,10 @@ import CodeScannerWidget, { import ListWidgetV2, { CONFIG as LIST_WIDGET_CONFIG_V2, } from "widgets/ListWidgetV2"; +import { + ButtonWidget as ButtonWidgetV2, + CONFIG as BUTTON_WIDGET_CONFIG_V2, +} from "widgets/ButtonWidgetV2"; export const ALL_WIDGETS_AND_CONFIG: [any, WidgetConfiguration][] = [ [CanvasWidget, CANVAS_WIDGET_CONFIG], @@ -215,6 +219,7 @@ export const ALL_WIDGETS_AND_CONFIG: [any, WidgetConfiguration][] = [ [CategorySliderWidget, CATEGORY_SLIDER_WIDGET_CONFIG], [CodeScannerWidget, CODE_SCANNER_WIDGET_CONFIG], [ListWidgetV2, LIST_WIDGET_CONFIG_V2], + [ButtonWidgetV2, BUTTON_WIDGET_CONFIG_V2], //Deprecated Widgets [InputWidget, INPUT_WIDGET_CONFIG], diff --git a/app/client/src/utils/hooks/useFeatureFlag.ts b/app/client/src/utils/hooks/useFeatureFlag.ts new file mode 100644 index 0000000000..32a6bcd5f5 --- /dev/null +++ b/app/client/src/utils/hooks/useFeatureFlag.ts @@ -0,0 +1,11 @@ +import { useSelector } from "react-redux"; +import type { FeatureFlag } from "@appsmith/entities/FeatureFlag"; +import { selectFeatureFlags } from "@appsmith/selectors/featureFlagsSelectors"; + +export function useFeatureFlag(flagName: FeatureFlag): boolean { + const flagValues = useSelector(selectFeatureFlags); + if (flagName in flagValues) { + return flagValues[flagName]; + } + return false; +} diff --git a/app/client/src/widgets/ButtonWidgetV2/component/Container.tsx b/app/client/src/widgets/ButtonWidgetV2/component/Container.tsx new file mode 100644 index 0000000000..a09eb4e7dc --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/component/Container.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import type { RenderMode } from "constants/WidgetConstants"; + +const StyledContainer = styled.div` + height: 100%; + position: relative; + + ${({ maxWidth, minHeight, minWidth }) => + css` + & [data-button] { + display: flex; + width: auto; + ${minWidth ? `min-width: ${minWidth}px;` : ""} + ${minHeight ? `min-height: ${minHeight}px;` : ""} + ${maxWidth ? `max-width: ${maxWidth}px;` : ""} + } + `} + + .grecaptcha-badge { + visibility: hidden; + } +`; + +type ContainerProps = { + children?: React.ReactNode; + renderMode?: RenderMode; + showInAllModes?: boolean; + minWidth?: number; + maxWidth?: number; + minHeight?: number; +}; + +export function Container(props: ContainerProps) { + const { children, ...rest } = props; + + return {children}; +} diff --git a/app/client/src/widgets/ButtonWidgetV2/component/RecaptchaV2.tsx b/app/client/src/widgets/ButtonWidgetV2/component/RecaptchaV2.tsx new file mode 100644 index 0000000000..fab1348d9c --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/component/RecaptchaV2.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { noop } from "lodash"; +import { useRef, useState } from "react"; +import ReCAPTCHA from "react-google-recaptcha"; + +import { + GOOGLE_RECAPTCHA_KEY_ERROR, + GOOGLE_RECAPTCHA_DOMAIN_ERROR, + createMessage, +} from "@appsmith/constants/messages"; +import type { RecaptchaProps } from "./useRecaptcha"; + +export type RecaptchaV2Props = RecaptchaProps; + +export function RecaptchaV2(props: RecaptchaV2Props) { + const recaptchaRef = useRef(null); + const [isInvalidKey, setInvalidKey] = useState(false); + const handleRecaptchaLoading = (isloading: boolean) => { + props.handleRecaptchaV2Loading && props.handleRecaptchaV2Loading(isloading); + }; + const { + isLoading, + isDisabled, + recaptchaKey, + onRecaptchaSubmitSuccess, + onRecaptchaSubmitError = noop, + onPress: onClickProp, + } = props; + const onClick = () => { + if (isDisabled) return onClickProp; + if (isLoading) return onClickProp; + + if (isInvalidKey) { + // Handle incorrent google recaptcha site key + onRecaptchaSubmitError(createMessage(GOOGLE_RECAPTCHA_KEY_ERROR)); + } else { + handleRecaptchaLoading(true); + recaptchaRef?.current?.reset(); + recaptchaRef?.current + ?.executeAsync() + .then((token: any) => { + if (token) { + if (typeof onRecaptchaSubmitSuccess === "function") { + onRecaptchaSubmitSuccess(token); + } + } else { + // Handle incorrent google recaptcha site key + onRecaptchaSubmitError(createMessage(GOOGLE_RECAPTCHA_KEY_ERROR)); + } + + handleRecaptchaLoading(false); + }) + .catch(() => { + handleRecaptchaLoading(false); + // Handle error due to google recaptcha key of different domain + onRecaptchaSubmitError(createMessage(GOOGLE_RECAPTCHA_DOMAIN_ERROR)); + }); + } + }; + + const recaptcha = ( + setInvalidKey(true)} + ref={recaptchaRef} + sitekey={recaptchaKey || ""} + size="invisible" + /> + ); + + return { onClick, recaptcha }; +} diff --git a/app/client/src/widgets/ButtonWidgetV2/component/RecaptchaV3.tsx b/app/client/src/widgets/ButtonWidgetV2/component/RecaptchaV3.tsx new file mode 100644 index 0000000000..b37f11ae67 --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/component/RecaptchaV3.tsx @@ -0,0 +1,64 @@ +import { noop } from "lodash"; + +import { + GOOGLE_RECAPTCHA_KEY_ERROR, + GOOGLE_RECAPTCHA_DOMAIN_ERROR, + createMessage, +} from "@appsmith/constants/messages"; +import type { ButtonComponentProps } from "."; +import type { RecaptchaProps } from "./useRecaptcha"; +import { useScript, ScriptStatus, AddScriptTo } from "utils/hooks/useScript"; + +type RecaptchaV3Props = RecaptchaProps; + +export function RecaptchaV3(props: RecaptchaV3Props) { + const checkValidJson = (inputString: string): boolean => { + return !inputString.includes('"'); + }; + + const { + recaptchaKey, + onPress: onClickProp, + onRecaptchaSubmitSuccess, + onRecaptchaSubmitError = noop, + } = props; + + const onClick: ButtonComponentProps["onPress"] = () => { + if (props.isDisabled) return onClickProp; + if (props.isLoading) return onClickProp; + + if (status === ScriptStatus.READY) { + (window as any).grecaptcha.ready(() => { + try { + (window as any).grecaptcha + .execute(recaptchaKey, { + action: "submit", + }) + .then((token: any) => { + if (typeof onRecaptchaSubmitSuccess === "function") { + onRecaptchaSubmitSuccess(token); + } + }) + .catch(() => { + // Handle incorrent google recaptcha site key + onRecaptchaSubmitError(createMessage(GOOGLE_RECAPTCHA_KEY_ERROR)); + }); + } catch (err) { + // Handle error due to google recaptcha key of different domain + onRecaptchaSubmitError(createMessage(GOOGLE_RECAPTCHA_DOMAIN_ERROR)); + } + }); + } + }; + + let validGoogleRecaptchaKey = recaptchaKey; + if (validGoogleRecaptchaKey && !checkValidJson(validGoogleRecaptchaKey)) { + validGoogleRecaptchaKey = undefined; + } + const status = useScript( + `https://www.google.com/recaptcha/api.js?render=${validGoogleRecaptchaKey}`, + AddScriptTo.HEAD, + ); + + return { onClick }; +} diff --git a/app/client/src/widgets/ButtonWidgetV2/component/index.tsx b/app/client/src/widgets/ButtonWidgetV2/component/index.tsx new file mode 100644 index 0000000000..b81b37026d --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/component/index.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Icon as BIcon } from "@blueprintjs/core"; +import type { IconName } from "@blueprintjs/icons"; + +import { Container } from "./Container"; +import { useRecaptcha } from "./useRecaptcha"; +import type { UseRecaptchaProps } from "./useRecaptcha"; +import type { ButtonProps } from "@design-system/widgets"; +import { Button, Icon, Tooltip } from "@design-system/widgets"; + +export type ButtonComponentProps = { + text?: string; + tooltip?: string; + minWidth?: number; + maxWidth?: number; + minHeight?: number; + isVisible?: boolean; + isLoading: boolean; + iconName?: IconName; + isDisabled?: boolean; + variant?: ButtonProps["variant"]; + color?: ButtonProps["color"]; + type: ButtonProps["type"]; + onPress?: ButtonProps["onPress"]; + iconPosition?: ButtonProps["iconPosition"]; +}; + +function ButtonComponent(props: ButtonComponentProps & UseRecaptchaProps) { + const { iconName, maxWidth, minHeight, minWidth, text, tooltip, ...rest } = + props; + const containerProps = { maxWidth, minHeight, minWidth }; + + const icon = iconName && ( + + + + ); + + const { onClick, recpatcha } = useRecaptcha(props); + + return ( + + + + + {recpatcha} + + ); +} + +export default ButtonComponent; diff --git a/app/client/src/widgets/ButtonWidgetV2/component/useRecaptcha.tsx b/app/client/src/widgets/ButtonWidgetV2/component/useRecaptcha.tsx new file mode 100644 index 0000000000..197f4c4030 --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/component/useRecaptcha.tsx @@ -0,0 +1,37 @@ +import { RecaptchaV2 } from "./RecaptchaV2"; +import { RecaptchaV3 } from "./RecaptchaV3"; +import type { ButtonComponentProps } from "."; +import type { RecaptchaType } from "components/constants"; + +export type UseRecaptchaProps = { + recaptchaKey?: string; + recaptchaType?: RecaptchaType; + onRecaptchaSubmitError?: (error: string) => void; + onRecaptchaSubmitSuccess?: (token: string) => void; + handleRecaptchaV2Loading?: (isLoading: boolean) => void; +}; + +export type RecaptchaProps = ButtonComponentProps & UseRecaptchaProps; + +type UseRecaptchaReturn = { + onClick?: (...args: any[]) => void; + recpatcha?: React.ReactElement; +}; + +export const useRecaptcha = (props: RecaptchaProps): UseRecaptchaReturn => { + const { onPress: onClickProp, recaptchaKey } = props; + + if (!recaptchaKey) { + return { onClick: onClickProp }; + } + + if (props.recaptchaType === "V2") { + return RecaptchaV2(props); + } + + if (props.recaptchaType === "V3") { + return RecaptchaV3(props); + } + + return { onClick: onClickProp }; +}; diff --git a/app/client/src/widgets/ButtonWidgetV2/icon.svg b/app/client/src/widgets/ButtonWidgetV2/icon.svg new file mode 100644 index 0000000000..9693e6f256 --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/client/src/widgets/ButtonWidgetV2/index.tsx b/app/client/src/widgets/ButtonWidgetV2/index.tsx new file mode 100644 index 0000000000..cd6dd6a9d8 --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/index.tsx @@ -0,0 +1,76 @@ +import { ButtonWidget } from "./widget"; +import IconSVG from "./icon.svg"; +import { WIDGET_TAGS } from "constants/WidgetConstants"; +import { ButtonPlacementTypes, RecaptchaTypes } from "components/constants"; +import { BUTTON_MIN_WIDTH } from "constants/minWidthConstants"; +import { ResponsiveBehavior } from "utils/autoLayout/constants"; +import { BUTTON_COLORS, BUTTON_VARIANTS } from "@design-system/widgets"; + +export const CONFIG = { + type: ButtonWidget.getWidgetType(), + name: "Button", + iconSVG: IconSVG, + needsMeta: false, + isCanvas: false, + tags: [WIDGET_TAGS.BUTTONS], + searchTags: ["click", "submit"], + features: { + dynamicHeight: { + sectionIndex: 0, + active: false, + }, + }, + defaults: { + animateLoading: true, + text: "Submit", + buttonVariant: BUTTON_VARIANTS.FILLED, + buttonColor: BUTTON_COLORS.ACCENT, + placement: ButtonPlacementTypes.CENTER, + rows: 4, + columns: 16, + widgetName: "Button", + isDisabled: false, + isVisible: true, + isDefaultClickDisabled: true, + disabledWhenInvalid: false, + resetFormOnClick: false, + recaptchaType: RecaptchaTypes.V3, + version: 1, + responsiveBehavior: ResponsiveBehavior.Hug, + minWidth: BUTTON_MIN_WIDTH, + }, + properties: { + derived: ButtonWidget.getDerivedPropertiesMap(), + default: ButtonWidget.getDefaultPropertiesMap(), + meta: ButtonWidget.getMetaPropertiesMap(), + contentConfig: ButtonWidget.getPropertyPaneContentConfig(), + styleConfig: ButtonWidget.getPropertyPaneStyleConfig(), + }, + autoLayout: { + defaults: { + rows: 4, + columns: 6.453, + }, + autoDimension: { + width: true, + }, + widgetSize: [ + { + viewportMinWidth: 0, + configuration: () => { + return { + minWidth: "120px", + maxWidth: "360px", + minHeight: "40px", + }; + }, + }, + ], + disableResizeHandles: { + horizontal: true, + vertical: true, + }, + }, +}; + +export { ButtonWidget }; diff --git a/app/client/src/widgets/ButtonWidgetV2/widget/contentConfig.tsx b/app/client/src/widgets/ButtonWidgetV2/widget/contentConfig.tsx new file mode 100644 index 0000000000..4e9f411cef --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/widget/contentConfig.tsx @@ -0,0 +1,145 @@ +import { RecaptchaTypes } from "components/constants"; +import { isAirgapped } from "@appsmith/utils/airgapHelpers"; +import { ValidationTypes } from "constants/WidgetValidation"; + +export const propertyPaneContentConfig = [ + { + sectionName: "Basic", + children: [ + { + propertyName: "text", + label: "Label", + helpText: "Sets the label of the button", + controlType: "INPUT_TEXT", + placeholderText: "Submit", + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + { + helpText: "when the button is clicked", + propertyName: "onClick", + label: "onClick", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: true, + }, + ], + }, + { + sectionName: "General", + children: [ + { + helpText: "Show helper text with button on hover", + propertyName: "tooltip", + label: "Tooltip", + controlType: "INPUT_TEXT", + placeholderText: "Submits Form", + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + { + propertyName: "isVisible", + label: "Visible", + helpText: "Controls the visibility of the widget", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + { + propertyName: "isDisabled", + label: "Disabled", + controlType: "SWITCH", + helpText: "Disables clicks to this widget", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + { + propertyName: "animateLoading", + label: "Animate loading", + controlType: "SWITCH", + helpText: "Controls the loading of the widget", + defaultValue: true, + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + ], + }, + { + sectionName: "Validation", + hidden: isAirgapped, + children: [ + { + propertyName: "googleRecaptchaKey", + label: "Google reCAPTCHA key", + helpText: "Sets Google reCAPTCHA site key for the button", + controlType: "INPUT_TEXT", + placeholderText: "reCAPTCHA Key", + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + { + propertyName: "recaptchaType", + label: "Google reCAPTCHA version", + controlType: "DROP_DOWN", + helpText: "Select reCAPTCHA version", + options: [ + { + label: "reCAPTCHA v3", + value: RecaptchaTypes.V3, + }, + { + label: "reCAPTCHA v2", + value: RecaptchaTypes.V2, + }, + ], + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.TEXT, + params: { + allowedValues: [RecaptchaTypes.V3, RecaptchaTypes.V2], + default: RecaptchaTypes.V3, + }, + }, + }, + ], + }, + // TODO: refactor widgetParentProps implementation when we address #10659 + { + sectionName: "Form settings", + children: [ + { + helpText: + "Disabled if the form is invalid, if this widget exists directly within a Form widget.", + propertyName: "disabledWhenInvalid", + label: "Disabled invalid forms", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + { + helpText: + "Resets the fields of the form, on click, if this widget exists directly within a Form widget.", + propertyName: "resetFormOnClick", + label: "Reset form on success", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + ], + }, +]; diff --git a/app/client/src/widgets/ButtonWidgetV2/widget/index.tsx b/app/client/src/widgets/ButtonWidgetV2/widget/index.tsx new file mode 100644 index 0000000000..819d4709c1 --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/widget/index.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { toast } from "design-system"; + +import BaseWidget from "widgets/BaseWidget"; +import ButtonComponent from "../component"; +import { propertyPaneStyleConfig } from "./styleConfig"; +import type { ButtonComponentProps } from "../component"; +import type { RecaptchaType } from "components/constants"; +import type { WidgetType } from "constants/WidgetConstants"; +import { propertyPaneContentConfig } from "./contentConfig"; +import type { DerivedPropertiesMap } from "utils/WidgetFactory"; +import type { WidgetProps, WidgetState } from "widgets/BaseWidget"; +import type { AutocompletionDefinitions } from "widgets/constants"; +import { DefaultAutocompleteDefinitions } from "widgets/WidgetUtils"; +import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; +import type { ExecutionResult } from "constants/AppsmithActionConstants/ActionConstants"; + +class ButtonWidget extends BaseWidget { + onButtonClickBound: () => void; + + constructor(props: ButtonWidgetProps) { + super(props); + this.onButtonClickBound = this.onButtonClick.bind(this); + this.onRecaptchaSubmitError = this.onRecaptchaSubmitError.bind(this); + this.onRecaptchaSubmitSuccess = this.onRecaptchaSubmitSuccess.bind(this); + this.state = { + isLoading: false, + }; + } + + static getAutocompleteDefinitions(): AutocompletionDefinitions { + return { + "!doc": + "Buttons are used to capture user intent and trigger actions based on that intent", + "!url": "https://docs.appsmith.com/widget-reference/button", + isVisible: DefaultAutocompleteDefinitions.isVisible, + text: "string", + isDisabled: "bool", + recaptchaToken: "string", + }; + } + + static getPropertyPaneContentConfig() { + return propertyPaneContentConfig; + } + + static getPropertyPaneStyleConfig() { + return propertyPaneStyleConfig; + } + + static getMetaPropertiesMap(): Record { + return { + recaptchaToken: undefined, + }; + } + + static getDerivedPropertiesMap(): DerivedPropertiesMap { + return {}; + } + + onButtonClick() { + if (this.props.onClick) { + this.setState({ isLoading: true }); + + super.executeAction({ + triggerPropertyName: "onClick", + dynamicString: this.props.onClick, + event: { + type: EventType.ON_CLICK, + callback: this.handleActionComplete, + }, + }); + + return; + } + + if (this.props.resetFormOnClick && this.props.onReset) { + this.props.onReset(); + + return; + } + } + + hasOnClickAction = () => { + const { isDisabled, onClick, onReset, resetFormOnClick } = this.props; + + return Boolean((onClick || onReset || resetFormOnClick) && !isDisabled); + }; + + onRecaptchaSubmitSuccess(token: string) { + this.props.updateWidgetMetaProperty("recaptchaToken", token, { + triggerPropertyName: "onClick", + dynamicString: this.props.onClick, + event: { + type: EventType.ON_CLICK, + callback: this.handleActionComplete, + }, + }); + } + + onRecaptchaSubmitError = (error: string) => { + toast.show(error, { kind: "error" }); + + if (this.hasOnClickAction()) { + this.onButtonClickBound(); + } + }; + + handleRecaptchaV2Loading = (isLoading: boolean) => { + if (this.props.onClick) { + this.setState({ isLoading }); + } + }; + + handleActionComplete = (result: ExecutionResult) => { + this.setState({ + isLoading: false, + }); + + if (result.success) { + if (this.props.resetFormOnClick && this.props.onReset) + this.props.onReset(); + } + }; + + getPageView() { + const disabled = + this.props.disabledWhenInvalid && + "isFormValid" in this.props && + !this.props.isFormValid; + const isDisabled = this.props.isDisabled || disabled; + + return ( + + ); + } + + static getWidgetType(): WidgetType { + return "BUTTON_WIDGET_V2"; + } +} + +export interface ButtonWidgetProps extends WidgetProps { + text?: string; + isVisible?: boolean; + isDisabled?: boolean; + resetFormOnClick?: boolean; + googleRecaptchaKey?: string; + recaptchaType?: RecaptchaType; + disabledWhenInvalid?: boolean; + buttonType?: ButtonComponentProps["type"]; + iconName?: ButtonComponentProps["iconName"]; + buttonVariant?: ButtonComponentProps["variant"]; + iconAlign?: ButtonComponentProps["iconPosition"]; + buttonColor?: ButtonComponentProps["color"]; +} + +interface ButtonWidgetState extends WidgetState { + isLoading: boolean; +} + +export { ButtonWidget }; diff --git a/app/client/src/widgets/ButtonWidgetV2/widget/styleConfig.tsx b/app/client/src/widgets/ButtonWidgetV2/widget/styleConfig.tsx new file mode 100644 index 0000000000..104e27aaf0 --- /dev/null +++ b/app/client/src/widgets/ButtonWidgetV2/widget/styleConfig.tsx @@ -0,0 +1,96 @@ +import { capitalize } from "lodash"; +import { BUTTON_COLORS, BUTTON_VARIANTS } from "@design-system/widgets"; + +import { ValidationTypes } from "constants/WidgetValidation"; + +export const propertyPaneStyleConfig = [ + { + sectionName: "General", + children: [ + { + propertyName: "buttonVariant", + label: "Button variant", + controlType: "ICON_TABS", + fullWidth: true, + helpText: "Sets the variant of the button", + options: Object.values(BUTTON_VARIANTS).map((variant) => ({ + label: capitalize(variant), + value: variant, + })), + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.TEXT, + params: { + allowedValues: Object.values(BUTTON_VARIANTS), + default: BUTTON_VARIANTS.FILLED, + }, + }, + }, + { + propertyName: "buttonColor", + label: "Button color", + controlType: "DROP_DOWN", + fullWidth: true, + helpText: "Sets the semantic color of the button", + options: Object.values(BUTTON_COLORS).map((semantic) => ({ + label: capitalize(semantic), + value: semantic, + })), + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.TEXT, + params: { + allowedValues: Object.values(BUTTON_COLORS), + default: BUTTON_COLORS.ACCENT, + }, + }, + }, + ], + }, + { + sectionName: "Icon", + children: [ + { + propertyName: "iconName", + label: "Select icon", + helpText: "Sets the icon to be used for the button", + controlType: "ICON_SELECT", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.TEXT, + }, + }, + { + propertyName: "iconAlign", + label: "Position", + helpText: "Sets the icon alignment of the button", + controlType: "ICON_TABS", + fullWidth: false, + options: [ + { + startIcon: "skip-left-line", + value: "start", + }, + { + startIcon: "skip-right-line", + value: "end", + }, + ], + isBindProperty: false, + isTriggerProperty: false, + validation: { + type: ValidationTypes.TEXT, + params: { + allowedValues: ["start", "end"], + }, + }, + }, + ], + }, +];