Skip to content
Snippets Groups Projects
Unverified Commit 7341044e authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

[RFC] Add first-class formik support (#26100)

parent b73f7b32
No related branches found
No related tags found
No related merge requests found
Showing
with 270 additions and 74 deletions
......@@ -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,
......
......@@ -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;
}
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;
export { default } from "./LoginForm";
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>
......
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",
......
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 = {
......
......@@ -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(
......
export { default } from "./Button";
export type { ButtonProps } from "./Button";
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,
});
export { default } from "./CheckBox";
export type { CheckBoxProps } from "./CheckBox";
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;
}
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;
export { default } from "./FormCheckBox";
export type { FormCheckBoxProps } from "./FormCheckBox";
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
export const ErrorMessageRoot = styled.div`
color: ${color("error")};
margin-top: 1em;
`;
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;
export { default } from "./FormErrorMessage";
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;
export { default } from "./FormField";
export type { FormFieldProps } from "./FormField";
export type FormFieldAlignment = "start" | "end";
export type FormFieldOrientation = "horizontal" | "vertical";
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment