diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index d2be5ea0e811104afe9732043909d8056b74fa13..b14d44d2bb42e47ddfc83f7d6d1214e866c74d5c 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -72,6 +72,7 @@ export const createMockSettings = (opts?: Partial<Settings>): Settings => ({ "ldap-enabled": false, "loading-message": "doing-science", "deprecation-notice-version": undefined, + "session-cookies": null, "site-locale": "en", "show-database-syncing-modal": false, "show-homepage-data": false, diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index 5491c5b8f896f04471ff786b54528891c69da66b..0f639288d2194a80df14e3c0515bd979ad168bb5 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -52,6 +52,7 @@ export interface Settings { "deprecation-notice-version": string | undefined; "ldap-enabled": boolean; "loading-message": LoadingMessage; + "session-cookies": boolean | null; "site-locale": string; "show-database-syncing-modal": boolean; "show-homepage-data": boolean; @@ -59,10 +60,10 @@ export interface Settings { "show-homepage-pin-message": boolean; "show-lighthouse-illustration": boolean; "show-metabot": boolean; - "token-status": TokenStatus | undefined; "slack-token": string | undefined; "slack-token-valid?": boolean; "slack-app-token": string | undefined; "slack-files-channel": string | undefined; + "token-status": TokenStatus | undefined; version: Version; } diff --git a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e73298da5148d3e66d84d9307c9bbf696f7d3e1d --- /dev/null +++ b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { t } from "ttag"; +import { Form, Formik } from "formik"; +import * as Yup from "yup"; +import useForm from "metabase/core/hooks/use-form"; +import FormCheckBox from "metabase/core/components/FormCheckBox"; +import FormErrorMessage from "metabase/core/components/FormErrorMessage"; +import FormField from "metabase/core/components/FormField"; +import FormInput from "metabase/core/components/FormInput"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; +import { LoginData } from "../../types"; + +const LdapSchema = Yup.object().shape({ + username: Yup.string().required(t`required`), + password: Yup.string().required(t`required`), + remember: Yup.boolean(), +}); + +const PasswordSchema = LdapSchema.shape({ + username: Yup.string() + .required(t`required`) + .email(t`must be a valid email address`), +}); + +export interface LoginFormProps { + isLdapEnabled: boolean; + hasSessionCookies: boolean; + onSubmit: (data: LoginData) => void; +} + +const LoginForm = ({ + isLdapEnabled, + hasSessionCookies, + onSubmit, +}: LoginFormProps): JSX.Element => { + const initialValues: LoginData = { + username: "", + password: "", + remember: !hasSessionCookies, + }; + const handleSubmit = useForm(onSubmit); + + return ( + <Formik + initialValues={initialValues} + validationSchema={isLdapEnabled ? LdapSchema : PasswordSchema} + isInitialValid={false} + onSubmit={handleSubmit} + > + <Form> + <FormField + name="username" + title={ + isLdapEnabled ? t`Username or email address` : t`Email address` + } + > + <FormInput + name="username" + type={isLdapEnabled ? "input" : "email"} + placeholder="nicetoseeyou@email.com" + autoFocus + fullWidth + /> + </FormField> + <FormField name="password" title={t`Password`}> + <FormInput + name="password" + type="password" + placeholder={t`Shhh...`} + fullWidth + /> + </FormField> + {!hasSessionCookies && ( + <FormField + name="remember" + title={t`Remember me`} + alignment="start" + orientation="horizontal" + > + <FormCheckBox name="remember" /> + </FormField> + )} + <FormSubmitButton normalText={t`Sign in`} fullWidth /> + <FormErrorMessage /> + </Form> + </Formik> + ); +}; + +export default LoginForm; diff --git a/frontend/src/metabase/auth/components/LoginForm/index.ts b/frontend/src/metabase/auth/components/LoginForm/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8059f00fdfa0d892bc831b8f5f38d0d9c6a30fb6 --- /dev/null +++ b/frontend/src/metabase/auth/components/LoginForm/index.ts @@ -0,0 +1 @@ +export { default } from "./LoginForm"; diff --git a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx index 388d9a35176aa40f72220b6bef90b4ae7575d14c..aee68252153a47852a969d57d226f5d94ee82211 100644 --- a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx +++ b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx @@ -1,19 +1,23 @@ import React, { useCallback } from "react"; import { t } from "ttag"; -import Users from "metabase/entities/users"; import AuthButton from "../AuthButton"; +import LoginForm from "../LoginForm"; import { AuthProvider, LoginData } from "../../types"; import { ActionListItem, ActionList } from "./PasswordPanel.styled"; export interface PasswordPanelProps { providers?: AuthProvider[]; redirectUrl?: string; + isLdapEnabled: boolean; + hasSessionCookies: boolean; onLogin: (data: LoginData, redirectUrl?: string) => void; } const PasswordPanel = ({ providers = [], redirectUrl, + isLdapEnabled, + hasSessionCookies, onLogin, }: PasswordPanelProps) => { const handleSubmit = useCallback( @@ -25,10 +29,9 @@ const PasswordPanel = ({ return ( <div> - <Users.Form - form={Users.forms.login()} - submitTitle={t`Sign in`} - submitFullWidth + <LoginForm + isLdapEnabled={isLdapEnabled} + hasSessionCookies={hasSessionCookies} onSubmit={handleSubmit} /> <ActionList> diff --git a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx index 1e7bfced023d693f814740ac464918227119c83d..96382fe6881816c87161b542a78a364a4e514547 100644 --- a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx @@ -1,43 +1,39 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AuthProvider } from "metabase/auth/types"; -import PasswordPanel from "./PasswordPanel"; +import PasswordPanel, { PasswordPanelProps } from "./PasswordPanel"; describe("PasswordPanel", () => { it("should login successfully", () => { - const onLogin = jest.fn().mockResolvedValue({}); + const props = getProps(); + const data = { username: "user@example.test", password: "password" }; - render(<PasswordPanel onLogin={onLogin} />); + render(<PasswordPanel {...props} />); + userEvent.type(screen.getByLabelText("Email address"), data.username); + userEvent.type(screen.getByLabelText("Password"), data.password); userEvent.click(screen.getByText("Sign in")); - expect(onLogin).toHaveBeenCalled(); + waitFor(() => expect(props.onLogin).toHaveBeenCalledWith(data)); }); it("should render a link to reset the password and a list of auth providers", () => { - const providers = [getAuthProvider()]; - const onLogin = jest.fn(); + const props = getProps({ providers: [getAuthProvider()] }); - render(<PasswordPanel providers={providers} onLogin={onLogin} />); + render(<PasswordPanel {...props} />); expect(screen.getByText(/forgotten my password/)).toBeInTheDocument(); expect(screen.getByText("Sign in with Google")).toBeInTheDocument(); }); }); -interface FormMockProps { - submitTitle: string; - onSubmit: () => void; -} - -const FormMock = ({ submitTitle, onSubmit }: FormMockProps) => { - return <button onClick={onSubmit}>{submitTitle}</button>; -}; - -jest.mock("metabase/entities/users", () => ({ - forms: { login: jest.fn() }, - Form: FormMock, -})); +const getProps = (opts?: Partial<PasswordPanelProps>): PasswordPanelProps => ({ + providers: [], + isLdapEnabled: false, + hasSessionCookies: false, + onLogin: jest.fn(), + ...opts, +}); const getAuthProvider = (opts?: Partial<AuthProvider>): AuthProvider => ({ name: "google", diff --git a/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx b/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx index 77ff39f3ed5e5c5a00ec0c48e09f9a3371ee70eb..b1012d34584e4c22e7da8775a8e0a77f4f15c049 100644 --- a/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx +++ b/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx @@ -1,10 +1,13 @@ import { connect } from "react-redux"; import { getExternalAuthProviders } from "metabase/auth/selectors"; +import { State } from "metabase-types/store"; import { login } from "../../actions"; import PasswordPanel from "../../components/PasswordPanel"; -const mapStateToProps = (state: any) => ({ +const mapStateToProps = (state: State) => ({ providers: getExternalAuthProviders(state), + isLdapEnabled: state.settings.values["ldap-enabled"], + hasSessionCookies: state.settings.values["session-cookies"] ?? false, }); const mapDispatchToProps = { diff --git a/frontend/src/metabase/core/components/Button/Button.tsx b/frontend/src/metabase/core/components/Button/Button.tsx index 006969988836a8604a7cced1dae45745f9f80d8d..c908fe78b5252d431fdf4a629ff04d2cc2840ac4 100644 --- a/frontend/src/metabase/core/components/Button/Button.tsx +++ b/frontend/src/metabase/core/components/Button/Button.tsx @@ -33,7 +33,7 @@ const BUTTON_VARIANTS = [ "fullWidth", ] as const; -interface Props extends ButtonHTMLAttributes<HTMLButtonElement> { +export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { as?: ElementType; className?: string; to?: string; @@ -78,7 +78,7 @@ const BaseButton = forwardRef(function BaseButton( labelBreakpoint, children, ...props - }: Props, + }: ButtonProps, ref: Ref<HTMLButtonElement>, ) { const variantClasses = BUTTON_VARIANTS.filter(variant => props[variant]).map( diff --git a/frontend/src/metabase/core/components/Button/index.ts b/frontend/src/metabase/core/components/Button/index.ts index c4719be7c09748c989aaf48649c32718ada90465..c82601dda43909c4b003d437582d7b7386d23ba8 100644 --- a/frontend/src/metabase/core/components/Button/index.ts +++ b/frontend/src/metabase/core/components/Button/index.ts @@ -1 +1,2 @@ export { default } from "./Button"; +export type { ButtonProps } from "./Button"; diff --git a/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx b/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx index ddf2381bb79d459413c641f823da4fd477fca2c0..7305a8cc7f8812e7b40d852dc9dd0b7c64cc1fb7 100644 --- a/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx +++ b/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx @@ -1,11 +1,22 @@ import React, { + ChangeEvent, + FocusEvent, forwardRef, + HTMLAttributes, isValidElement, ReactElement, + ReactNode, Ref, useRef, } from "react"; import Tooltip from "metabase/components/Tooltip"; +import { + DEFAULT_CHECKED_COLOR, + DEFAULT_ICON_PADDING, + DEFAULT_SIZE, + DEFAULT_UNCHECKED_COLOR, +} from "./constants"; +import { isEllipsisActive } from "./utils"; import { CheckBoxContainer, CheckBoxIcon, @@ -14,25 +25,22 @@ import { CheckBoxLabel, CheckBoxRoot, } from "./CheckBox.styled"; -import { CheckBoxProps, CheckboxTooltipProps } from "./types"; -import { isEllipsisActive } from "./utils"; -import { - DEFAULT_CHECKED_COLOR, - DEFAULT_ICON_PADDING, - DEFAULT_SIZE, - DEFAULT_UNCHECKED_COLOR, -} from "./constants"; -function CheckboxTooltip({ - hasTooltip, - tooltipLabel, - children, -}: CheckboxTooltipProps): ReactElement { - return hasTooltip ? ( - <Tooltip tooltip={tooltipLabel}>{children}</Tooltip> - ) : ( - <>{children}</> - ); +export interface CheckBoxProps + extends Omit<HTMLAttributes<HTMLElement>, "onChange" | "onFocus" | "onBlur"> { + name?: string; + label?: ReactNode; + labelEllipsis?: boolean; + checked?: boolean; + indeterminate?: boolean; + disabled?: boolean; + size?: number; + checkedColor?: string; + uncheckedColor?: string; + autoFocus?: boolean; + onChange?: (event: ChangeEvent<HTMLInputElement>) => void; + onFocus?: (event: FocusEvent<HTMLInputElement>) => void; + onBlur?: (event: FocusEvent<HTMLInputElement>) => void; } const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox( @@ -67,6 +75,8 @@ const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox( tooltipLabel={label} > <CheckBoxInput + id={name} + name={name} type="checkbox" checked={isControlledCheckBoxInput ? !!checked : undefined} defaultChecked={isControlledCheckBoxInput ? undefined : !!checked} @@ -77,7 +87,6 @@ const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox( onChange={isControlledCheckBoxInput ? onChange : undefined} onFocus={onFocus} onBlur={onBlur} - id={name} /> <CheckBoxContainer disabled={disabled}> <CheckBoxIconContainer @@ -109,6 +118,24 @@ const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox( ); }); +interface CheckboxTooltipProps { + hasTooltip: boolean; + tooltipLabel: ReactNode; + children: ReactNode; +} + +function CheckboxTooltip({ + hasTooltip, + tooltipLabel, + children, +}: CheckboxTooltipProps): ReactElement { + return hasTooltip ? ( + <Tooltip tooltip={tooltipLabel}>{children}</Tooltip> + ) : ( + <>{children}</> + ); +} + export default Object.assign(CheckBox, { Label: CheckBoxLabel, }); diff --git a/frontend/src/metabase/core/components/CheckBox/index.ts b/frontend/src/metabase/core/components/CheckBox/index.ts index 551e172305f8dc40181c4e1228d76521fc24fed7..fa1802a7294774ec9c5b800ef69373cb8382d9f7 100644 --- a/frontend/src/metabase/core/components/CheckBox/index.ts +++ b/frontend/src/metabase/core/components/CheckBox/index.ts @@ -1 +1,2 @@ export { default } from "./CheckBox"; +export type { CheckBoxProps } from "./CheckBox"; diff --git a/frontend/src/metabase/core/components/CheckBox/types.ts b/frontend/src/metabase/core/components/CheckBox/types.ts index 64b216856345bf4836b8a8a872857efe81b0505b..edbd6ca824b3a05ec083d82a405675b646a8a252 100644 --- a/frontend/src/metabase/core/components/CheckBox/types.ts +++ b/frontend/src/metabase/core/components/CheckBox/types.ts @@ -1,5 +1,3 @@ -import { ChangeEvent, FocusEvent, HTMLAttributes, ReactNode } from "react"; - export interface CheckBoxInputProps { size: number; } @@ -23,26 +21,3 @@ export interface CheckBoxIconContainerProps { export interface CheckBoxLabelProps { labelEllipsis: boolean; } - -export interface CheckBoxProps - extends Omit<HTMLAttributes<HTMLElement>, "onChange" | "onFocus" | "onBlur"> { - name?: string; - label?: ReactNode; - labelEllipsis?: boolean; - checked?: boolean; - indeterminate?: boolean; - disabled?: boolean; - size?: number; - checkedColor?: string; - uncheckedColor?: string; - autoFocus?: boolean; - onChange?: (event: ChangeEvent<HTMLInputElement>) => void; - onFocus?: (event: FocusEvent<HTMLInputElement>) => void; - onBlur?: (event: FocusEvent<HTMLInputElement>) => void; -} - -export interface CheckboxTooltipProps { - hasTooltip: boolean; - tooltipLabel: ReactNode; - children: ReactNode; -} diff --git a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f60717543ad651c26b15a4e0d3806db2e6fdf1eb --- /dev/null +++ b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx @@ -0,0 +1,28 @@ +import React, { forwardRef, Ref } from "react"; +import { useField } from "formik"; +import CheckBox, { CheckBoxProps } from "metabase/core/components/CheckBox"; + +export interface FormCheckBoxProps + extends Omit<CheckBoxProps, "checked" | "onChange" | "onBlur"> { + name: string; +} + +const FormCheckBox = forwardRef(function FormCheckBox( + { name, ...props }: FormCheckBoxProps, + ref: Ref<HTMLLabelElement>, +) { + const [field] = useField(name); + + return ( + <CheckBox + {...props} + ref={ref} + name={name} + checked={field.value} + onChange={field.onChange} + onBlur={field.onBlur} + /> + ); +}); + +export default FormCheckBox; diff --git a/frontend/src/metabase/core/components/FormCheckBox/index.ts b/frontend/src/metabase/core/components/FormCheckBox/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d93b810dfd96a0ff5da5faaa39d0bd23b9e42fa9 --- /dev/null +++ b/frontend/src/metabase/core/components/FormCheckBox/index.ts @@ -0,0 +1,2 @@ +export { default } from "./FormCheckBox"; +export type { FormCheckBoxProps } from "./FormCheckBox"; diff --git a/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b192d40347033983dc0780b06d355d5934700e04 --- /dev/null +++ b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx @@ -0,0 +1,7 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; + +export const ErrorMessageRoot = styled.div` + color: ${color("error")}; + margin-top: 1em; +`; diff --git a/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3d1b04c34dd5cb56c8d1c152f468f932b4b93d74 --- /dev/null +++ b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx @@ -0,0 +1,26 @@ +import React, { forwardRef, HTMLAttributes, Ref } from "react"; +import useFormErrorMessage from "metabase/core/hooks/use-form-error-message"; +import { ErrorMessageRoot } from "./FormErrorMessage.styled"; + +export type FormErrorMessageProps = Omit< + HTMLAttributes<HTMLDivElement>, + "children" +>; + +const FormErrorMessage = forwardRef(function FormErrorMessage( + props: FormErrorMessageProps, + ref: Ref<HTMLDivElement>, +) { + const message = useFormErrorMessage(); + if (!message) { + return null; + } + + return ( + <ErrorMessageRoot {...props} ref={ref}> + {message} + </ErrorMessageRoot> + ); +}); + +export default FormErrorMessage; diff --git a/frontend/src/metabase/core/components/FormErrorMessage/index.ts b/frontend/src/metabase/core/components/FormErrorMessage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f29055dbeaffc942df8b5c19d370ee2f4988e7e1 --- /dev/null +++ b/frontend/src/metabase/core/components/FormErrorMessage/index.ts @@ -0,0 +1 @@ +export { default } from "./FormErrorMessage"; diff --git a/frontend/src/metabase/core/components/FormField/FormField.tsx b/frontend/src/metabase/core/components/FormField/FormField.tsx new file mode 100644 index 0000000000000000000000000000000000000000..16288cfdc9f1e6fb6ccb22a6f88aa506e859c4d7 --- /dev/null +++ b/frontend/src/metabase/core/components/FormField/FormField.tsx @@ -0,0 +1,29 @@ +import React, { forwardRef, Ref } from "react"; +import { useField } from "formik"; +import InputField, { + InputFieldProps, +} from "metabase/core/components/InputField"; + +export interface FormFieldProps + extends Omit<InputFieldProps, "error" | "htmlFor"> { + name: string; +} + +const FormField = forwardRef(function FormField( + { name, ...props }: FormFieldProps, + ref: Ref<HTMLDivElement>, +) { + const [, meta] = useField(name); + const { error, touched } = meta; + + return ( + <InputField + {...props} + ref={ref} + htmlFor={name} + error={touched ? error : undefined} + /> + ); +}); + +export default FormField; diff --git a/frontend/src/metabase/core/components/FormField/index.ts b/frontend/src/metabase/core/components/FormField/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1d921fd9745f2d4a6a72e7bb9cb123aa54cc40c --- /dev/null +++ b/frontend/src/metabase/core/components/FormField/index.ts @@ -0,0 +1,2 @@ +export { default } from "./FormField"; +export type { FormFieldProps } from "./FormField"; diff --git a/frontend/src/metabase/core/components/FormField/types.ts b/frontend/src/metabase/core/components/FormField/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce136dd44bb8a1c8baca9f159706968e0fa8d16f --- /dev/null +++ b/frontend/src/metabase/core/components/FormField/types.ts @@ -0,0 +1,2 @@ +export type FormFieldAlignment = "start" | "end"; +export type FormFieldOrientation = "horizontal" | "vertical"; diff --git a/frontend/src/metabase/core/components/FormInput/FormInput.tsx b/frontend/src/metabase/core/components/FormInput/FormInput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d63dce5068a4ca9b1c692ecea156206771ca743c --- /dev/null +++ b/frontend/src/metabase/core/components/FormInput/FormInput.tsx @@ -0,0 +1,30 @@ +import React, { forwardRef, Ref } from "react"; +import { useField } from "formik"; +import Input, { InputProps } from "metabase/core/components/Input"; + +export interface FormInputProps + extends Omit<InputProps, "value" | "error" | "onChange" | "onBlur"> { + name: string; +} + +const FormInput = forwardRef(function FormInput( + { name, ...props }: FormInputProps, + ref: Ref<HTMLInputElement>, +) { + const [field, meta] = useField(name); + + return ( + <Input + {...props} + ref={ref} + id={name} + name={name} + value={field.value} + error={meta.touched && meta.error != null} + onChange={field.onChange} + onBlur={field.onBlur} + /> + ); +}); + +export default FormInput; diff --git a/frontend/src/metabase/core/components/FormInput/index.ts b/frontend/src/metabase/core/components/FormInput/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ec828874a8550f716ba8dcace8a59e8125fbf35 --- /dev/null +++ b/frontend/src/metabase/core/components/FormInput/index.ts @@ -0,0 +1,2 @@ +export { default } from "./FormInput"; +export type { FormInputProps } from "./FormInput"; diff --git a/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx b/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6878df90e0596eb472e6876eac340933521575a7 --- /dev/null +++ b/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx @@ -0,0 +1,60 @@ +import React, { forwardRef, Ref } from "react"; +import { useFormikContext } from "formik"; +import { t } from "ttag"; +import Button, { ButtonProps } from "metabase/core/components/Button"; +import { FormStatus } from "metabase/core/hooks/use-form-state"; +import useFormStatus from "metabase/core/hooks/use-form-status"; + +export interface FormSubmitButtonProps extends Omit<ButtonProps, "children"> { + normalText?: string; + activeText?: string; + successText?: string; + failedText?: string; +} + +const FormSubmitButton = forwardRef(function FormSubmitButton( + { disabled, ...props }: FormSubmitButtonProps, + ref: Ref<HTMLButtonElement>, +) { + const { isValid, isSubmitting } = useFormikContext(); + const status = useFormStatus(); + const submitText = getSubmitButtonText(status, props); + const isEnabled = isValid && !isSubmitting && !disabled; + + return ( + <Button + {...props} + ref={ref} + type="submit" + primary={isEnabled} + success={status === "fulfilled"} + danger={status === "rejected"} + disabled={!isEnabled} + > + {submitText} + </Button> + ); +}); + +const getSubmitButtonText = ( + status: FormStatus | undefined, + { + normalText = t`Submit`, + activeText = normalText, + successText = t`Success`, + failedText = t`Failed`, + }: FormSubmitButtonProps, +) => { + switch (status) { + case "pending": + return activeText; + case "fulfilled": + return successText; + case "rejected": + return failedText; + default: + return normalText; + } +}; + +export default FormSubmitButton; diff --git a/frontend/src/metabase/core/components/FormSubmitButton/index.ts b/frontend/src/metabase/core/components/FormSubmitButton/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b16428394522d040fc2160b40c2e37f939c372ef --- /dev/null +++ b/frontend/src/metabase/core/components/FormSubmitButton/index.ts @@ -0,0 +1,2 @@ +export { default } from "./FormSubmitButton"; +export type { FormSubmitButtonProps } from "./FormSubmitButton"; diff --git a/frontend/src/metabase/core/components/Input/index.ts b/frontend/src/metabase/core/components/Input/index.ts index a50d7d110ed8ac8c5c1c547c7a754c48a513bac4..52609a2e9dcca57c0fff6e8802a4714c55bab8ae 100644 --- a/frontend/src/metabase/core/components/Input/index.ts +++ b/frontend/src/metabase/core/components/Input/index.ts @@ -1 +1,2 @@ export { default } from "./Input"; +export type { InputProps } from "./Input"; diff --git a/frontend/src/metabase/core/components/InputField/InputField.styled.tsx b/frontend/src/metabase/core/components/InputField/InputField.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be942547e399d77ee91a225e199f7b6fa616d852 --- /dev/null +++ b/frontend/src/metabase/core/components/InputField/InputField.styled.tsx @@ -0,0 +1,55 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import { FieldAlignment, FieldOrientation } from "./types"; + +export const FieldLabelError = styled.span` + color: ${color("error")}; +`; + +export interface FieldRootProps { + orientation: FieldOrientation; + hasError: boolean; +} + +export const FieldRoot = styled.div<FieldRootProps>` + display: ${props => props.orientation === "horizontal" && "flex"}; + color: ${props => (props.hasError ? color("error") : color("text-medium"))}; + margin-bottom: 1.25rem; + + &:focus-within { + color: ${color("text-medium")}; + + ${FieldLabelError} { + display: none; + } + } +`; + +export interface FormCaptionProps { + alignment: FieldAlignment; + orientation: FieldOrientation; +} + +export const FieldCaption = styled.div<FormCaptionProps>` + display: flex; + align-items: center; + margin-left: ${props => + props.orientation === "horizontal" && + props.alignment === "start" && + "0.5rem"}; + margin-right: ${props => + props.orientation === "horizontal" && + props.alignment === "end" && + "0.5rem"}; + margin-bottom: 0.5rem; +`; + +export const FieldLabel = styled.label` + display: block; + font-size: 0.77rem; + font-weight: 900; +`; + +export const FieldDescription = styled.div` + margin-bottom: 0.5rem; +`; diff --git a/frontend/src/metabase/core/components/InputField/InputField.tsx b/frontend/src/metabase/core/components/InputField/InputField.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b4c5bb22842b7ae30cd258fa9a3204b9a743a84 --- /dev/null +++ b/frontend/src/metabase/core/components/InputField/InputField.tsx @@ -0,0 +1,60 @@ +import React, { forwardRef, HTMLAttributes, ReactNode, Ref } from "react"; +import { FieldAlignment, FieldOrientation } from "./types"; +import { + FieldCaption, + FieldDescription, + FieldLabel, + FieldLabelError, + FieldRoot, +} from "./InputField.styled"; + +export interface InputFieldProps extends HTMLAttributes<HTMLDivElement> { + title?: string; + description?: ReactNode; + error?: string; + htmlFor?: string; + alignment?: FieldAlignment; + orientation?: FieldOrientation; + children?: ReactNode; +} + +const InputField = forwardRef(function InputField( + { + title, + description, + error, + htmlFor, + alignment = "end", + orientation = "vertical", + children, + ...props + }: InputFieldProps, + ref: Ref<HTMLDivElement>, +) { + const hasError = Boolean(error); + + return ( + <FieldRoot + {...props} + ref={ref} + orientation={orientation} + hasError={hasError} + > + {alignment === "start" && children} + {(title || description) && ( + <FieldCaption alignment={alignment} orientation={orientation}> + {title && ( + <FieldLabel htmlFor={htmlFor}> + {title} + {hasError && <FieldLabelError>: {error}</FieldLabelError>} + </FieldLabel> + )} + {description && <FieldDescription>{description}</FieldDescription>} + </FieldCaption> + )} + {alignment === "end" && children} + </FieldRoot> + ); +}); + +export default InputField; diff --git a/frontend/src/metabase/core/components/InputField/index.ts b/frontend/src/metabase/core/components/InputField/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..23ec5d21c7573aacdcea368712a75b57c9367f64 --- /dev/null +++ b/frontend/src/metabase/core/components/InputField/index.ts @@ -0,0 +1,2 @@ +export { default } from "./InputField"; +export type { InputFieldProps } from "./InputField"; diff --git a/frontend/src/metabase/core/components/InputField/types.ts b/frontend/src/metabase/core/components/InputField/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2dc2f099c1f14cf66d0031f2ea6ba8500a64924 --- /dev/null +++ b/frontend/src/metabase/core/components/InputField/types.ts @@ -0,0 +1,3 @@ +export type FieldAlignment = "start" | "end"; + +export type FieldOrientation = "horizontal" | "vertical"; diff --git a/frontend/src/metabase/core/hooks/use-form-error-message/index.ts b/frontend/src/metabase/core/hooks/use-form-error-message/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7f88391af89e99d697fcd4c5de34e37580fbcc7 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-error-message/index.ts @@ -0,0 +1 @@ +export { default } from "./use-form-error-message"; diff --git a/frontend/src/metabase/core/hooks/use-form-error-message/use-form-error-message.ts b/frontend/src/metabase/core/hooks/use-form-error-message/use-form-error-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..8442d393e94fbab10574b6fb1fb4d8932e5482d8 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-error-message/use-form-error-message.ts @@ -0,0 +1,21 @@ +import { useLayoutEffect, useState } from "react"; +import { useFormikContext } from "formik"; +import useFormState from "../use-form-state"; + +const useFormErrorMessage = () => { + const { values } = useFormikContext(); + const { message } = useFormState(); + const [errorMessage, setErrorMessage] = useState(message); + + useLayoutEffect(() => { + setErrorMessage(undefined); + }, [values]); + + useLayoutEffect(() => { + setErrorMessage(message); + }, [message]); + + return errorMessage; +}; + +export default useFormErrorMessage; diff --git a/frontend/src/metabase/core/hooks/use-form-state/index.ts b/frontend/src/metabase/core/hooks/use-form-state/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1acef3bdec7d548ce1afe8f733c52d6979c9fb74 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-state/index.ts @@ -0,0 +1,2 @@ +export { default } from "./use-form-state"; +export type { FormState, FormStatus } from "./types"; diff --git a/frontend/src/metabase/core/hooks/use-form-state/types.ts b/frontend/src/metabase/core/hooks/use-form-state/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8cd0fb25c90e739319175e98957dadd0c2e63ec --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-state/types.ts @@ -0,0 +1,6 @@ +export type FormStatus = "pending" | "fulfilled" | "rejected"; + +export interface FormState { + status?: FormStatus; + message?: string; +} diff --git a/frontend/src/metabase/core/hooks/use-form-state/use-form-state.ts b/frontend/src/metabase/core/hooks/use-form-state/use-form-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..46e50c29be4092db3e5d955fb4222aaad3ea5623 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-state/use-form-state.ts @@ -0,0 +1,9 @@ +import { useFormikContext } from "formik"; +import { FormState } from "./types"; + +const useFormState = (): FormState => { + const { status } = useFormikContext(); + return status ?? {}; +}; + +export default useFormState; diff --git a/frontend/src/metabase/core/hooks/use-form-status/index.ts b/frontend/src/metabase/core/hooks/use-form-status/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..aaf24961a2856a051fdf2ddff695496291b7ff7e --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-status/index.ts @@ -0,0 +1 @@ +export { default } from "./use-form-status"; diff --git a/frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts b/frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9893af13e396c9d57a5beb1941631748c7741a4 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts @@ -0,0 +1,34 @@ +import { useEffect, useLayoutEffect, useState } from "react"; +import useFormState, { FormStatus } from "../use-form-state"; + +const STATUS_TIMEOUT = 5000; + +const useFormStatus = (): FormStatus | undefined => { + const { status } = useFormState(); + const isRecent = useIsRecent(status, STATUS_TIMEOUT); + + switch (status) { + case "pending": + return status; + case "fulfilled": + case "rejected": + return isRecent ? status : undefined; + } +}; + +function useIsRecent(value: unknown, timeout: number) { + const [isRecent, setIsRecent] = useState(true); + + useEffect(() => { + const timerId = setTimeout(() => setIsRecent(false), timeout); + return () => clearTimeout(timerId); + }, [value, timeout]); + + useLayoutEffect(() => { + setIsRecent(true); + }, [value]); + + return isRecent; +} + +export default useFormStatus; diff --git a/frontend/src/metabase/core/hooks/use-form/index.ts b/frontend/src/metabase/core/hooks/use-form/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc6c71c16f1a9b734c8aa592397828def6d4ff22 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form/index.ts @@ -0,0 +1 @@ +export { default } from "./use-form"; diff --git a/frontend/src/metabase/core/hooks/use-form/types.ts b/frontend/src/metabase/core/hooks/use-form/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..1380e5611f86421ae6aaeac5d31940c4dcdba388 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form/types.ts @@ -0,0 +1,10 @@ +import type { FormikErrors } from "formik"; + +export interface FormError<T> { + data?: FormErrorData<T>; +} + +export interface FormErrorData<T> { + errors?: FormikErrors<T>; + message?: string; +} diff --git a/frontend/src/metabase/core/hooks/use-form/use-form.ts b/frontend/src/metabase/core/hooks/use-form/use-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..e281cafefedafce380a5470c9fd2a4914a31dbc1 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form/use-form.ts @@ -0,0 +1,30 @@ +import { useCallback } from "react"; +import type { FormikHelpers } from "formik"; +import { FormError } from "./types"; + +const useForm = <T>(onSubmit: (data: T) => void) => { + return useCallback( + async (data: T, helpers: FormikHelpers<T>) => { + try { + helpers.setStatus({ status: "pending", message: undefined }); + await onSubmit(data); + helpers.setStatus({ status: "fulfilled" }); + } catch (error) { + if (isFormError(error)) { + const { data } = error; + helpers.setErrors(data?.errors ?? {}); + helpers.setStatus({ status: "rejected", message: data?.message }); + } else { + helpers.setStatus({ status: "rejected", message: undefined }); + } + } + }, + [onSubmit], + ); +}; + +const isFormError = <T>(error: unknown): error is FormError<T> => { + return error != null && typeof error === "object"; +}; + +export default useForm; diff --git a/frontend/src/metabase/entities/users/forms.js b/frontend/src/metabase/entities/users/forms.js index 4eabad82aef4deb0d6313157557106363866698a..52dfb1ff5c6e92c0c9cedede73e9cd133c37a419 100644 --- a/frontend/src/metabase/entities/users/forms.js +++ b/frontend/src/metabase/entities/users/forms.js @@ -129,39 +129,6 @@ export default { }, ], }), - login: () => { - const ldap = MetabaseSettings.isLdapEnabled(); - const cookies = MetabaseSettings.get("session-cookies"); - - return { - fields: [ - { - name: "username", - type: ldap ? "input" : "email", - title: ldap ? t`Username or email address` : t`Email address`, - placeholder: "nicetoseeyou@email.com", - validate: ldap ? validate.required() : validate.required().email(), - autoFocus: true, - }, - { - name: "password", - type: "password", - title: t`Password`, - placeholder: t`Shhh...`, - validate: validate.required(), - }, - { - name: "remember", - type: "checkbox", - title: t`Remember me`, - initial: true, - hidden: cookies, - horizontal: true, - align: "left", - }, - ], - }; - }, password: { fields: [ { diff --git a/package.json b/package.json index 17e3913fa54f1082fe194ac47f58c13ef155e0a5..83f4abab081cf69a6e4917ed9ce03affc7cd955d 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "ttag": "1.7.15", "underscore": "~1.13.3", "yarn.lock": "^0.0.1-security", + "yup": "^0.32.11", "z-index": "0.0.1" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index ae6524e84de4d2a52c852d8d6ef7554bd850a8a0..4b2c102c90dc74c07eacb12e4996b6a3eb335949 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5334,6 +5334,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.185.tgz#c9843f5a40703a8f5edfd53358a58ae729816908" integrity sha512-evMDG1bC4rgQg4ku9tKpuMh5iBNEwNa3tf9zRHdP1qlv+1WUg44xat4IxCE14gIpZRGUUWAx2VhItCZc25NfMA== +"@types/lodash@^4.14.175": + version "4.14.186" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.186.tgz#862e5514dd7bd66ada6c70ee5fce844b06c8ee97" + integrity sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw== + "@types/mdast@^3.0.0": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.4.tgz#8ee6b5200751b6cadb9a043ca39612693ad6cb9e" @@ -16566,6 +16571,11 @@ nan@^2.12.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== +nanoclone@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" + integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== + nanoid@3.1.31, nanoid@^3.1.23, nanoid@^3.1.30, nanoid@^3.3.3: version "3.1.31" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.31.tgz#f5b58a1ce1b7604da5f0605757840598d8974dc6" @@ -18676,6 +18686,11 @@ prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.4, pr object-assign "^4.1.1" react-is "^16.8.1" +property-expr@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + property-information@^5.0.0, property-information@^5.3.0: version "5.6.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69" @@ -21797,6 +21812,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -23365,6 +23385,19 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +yup@^0.32.11: + version "0.32.11" + resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" + integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/lodash" "^4.14.175" + lodash "^4.17.21" + lodash-es "^4.17.21" + nanoclone "^0.2.1" + property-expr "^2.0.4" + toposort "^2.0.2" + z-index@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/z-index/-/z-index-0.0.1.tgz#4f3d257a36869dabd990572b70494291cb3eab8f"