diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index b14d44d2bb42e47ddfc83f7d6d1214e866c74d5c..cf0ade3c0356370f0af31f6413c1ee79c510d97a 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -62,7 +62,7 @@ export const createMockSettings = (opts?: Partial<Settings>): Settings => ({ "application-font": "Lato", "application-font-files": [], "available-fonts": [], - "available-locales": [], + "available-locales": null, "enable-public-sharing": false, "enable-xrays": false, "email-configured?": false, diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index 0f639288d2194a80df14e3c0515bd979ad168bb5..39e65f649431a16f214a10179c6f23244dfd3e29 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -42,7 +42,7 @@ export interface Settings { "application-font": string; "application-font-files": FontFile[] | null; "available-fonts": string[]; - "available-locales": LocaleData[] | undefined; + "available-locales": LocaleData[] | null; "enable-public-sharing": boolean; "enable-xrays": boolean; "email-configured?": boolean; diff --git a/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.tsx b/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.tsx index 80763f476e4cf7207c330a46c55aefd485b260c0..4f855f9a01f5caf4d5dd7aad0730a9c94e8cfe88 100644 --- a/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.tsx +++ b/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.tsx @@ -35,11 +35,7 @@ const UserPasswordForm = ({ onSubmit, }: UserPasswordFormProps): JSX.Element => { const initialValues = useMemo( - () => ({ - old_password: "", - password: "", - password_confirm: "", - }), + () => ({ old_password: "", password: "", password_confirm: "" }), [], ); diff --git a/frontend/src/metabase/account/profile/actions.ts b/frontend/src/metabase/account/profile/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4756f45851fa2886c322ed06ff02cbcb80ad024 --- /dev/null +++ b/frontend/src/metabase/account/profile/actions.ts @@ -0,0 +1,17 @@ +import { createThunkAction } from "metabase/lib/redux"; +import Users from "metabase/entities/users"; +import { User } from "metabase-types/api"; +import { Dispatch } from "metabase-types/store"; +import { UserProfileData } from "./types"; + +export const UPDATE_USER = "metabase/account/profile/UPDATE_USER"; +export const updateUser = createThunkAction( + UPDATE_USER, + (user: User, data: UserProfileData) => async (dispatch: Dispatch) => { + await dispatch(Users.actions.update({ ...data, id: user.id })); + + if (user.locale !== data.locale) { + window.location.reload(); + } + }, +); diff --git a/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.jsx b/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.jsx deleted file mode 100644 index 4a31103534a7855e63a345eb3a337b3aefadf470..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { useCallback } from "react"; -import PropTypes from "prop-types"; - -import User from "metabase/entities/users"; - -const propTypes = { - user: PropTypes.object, -}; - -const UserProfileForm = ({ user }) => { - const handleSaved = useCallback( - values => { - if (user.locale !== values.locale) { - window.location.reload(); - } - }, - [user?.locale], - ); - - return ( - <User.Form - user={user} - form={User.forms.user(user)} - onSaved={handleSaved} - overwriteOnInitialValuesChange - /> - ); -}; - -UserProfileForm.propTypes = propTypes; - -export default UserProfileForm; diff --git a/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.tsx b/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b497a197af774b13b675347f2092e4b39e929ae0 --- /dev/null +++ b/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.tsx @@ -0,0 +1,118 @@ +import React, { useCallback, useMemo } from "react"; +import { t } from "ttag"; +import * as Yup from "yup"; +import _ from "underscore"; +import Form from "metabase/core/components/Form"; +import FormProvider from "metabase/core/components/FormProvider"; +import FormInput from "metabase/core/components/FormInput"; +import FormSelect from "metabase/core/components/FormSelect"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; +import FormErrorMessage from "metabase/core/components/FormErrorMessage"; +import { LocaleData, User } from "metabase-types/api"; +import { UserProfileData } from "../../types"; + +const SsoProfileSchema = Yup.object({ + locale: Yup.string().nullable(true), +}); + +const LocalProfileSchema = SsoProfileSchema.shape({ + first_name: Yup.string().max(100, t`must be 100 characters or less`), + last_name: Yup.string().max(100, t`must be 100 characters or less`), + email: Yup.string() + .required(t`required`) + .email(t`must be a valid email address`), +}); + +export interface UserProfileFormProps { + user: User; + locales: LocaleData[] | null; + isSsoUser: boolean; + onSubmit: (user: User, data: UserProfileData) => void; +} + +const UserProfileForm = ({ + user, + locales, + isSsoUser, + onSubmit, +}: UserProfileFormProps): JSX.Element => { + const initialValues = useMemo(() => getInitialValues(user), [user]); + const localeOptions = useMemo(() => getLocaleOptions(locales), [locales]); + + const handleSubmit = useCallback( + (data: UserProfileData) => onSubmit(user, getSubmitValues(data)), + [user, onSubmit], + ); + + return ( + <FormProvider + initialValues={initialValues} + validationSchema={isSsoUser ? SsoProfileSchema : LocalProfileSchema} + enableReinitialize + onSubmit={handleSubmit} + > + {({ dirty }) => ( + <Form disabled={!dirty}> + {!isSsoUser && ( + <> + <FormInput + name="first_name" + title={t`First name`} + placeholder={t`Johnny`} + fullWidth + /> + <FormInput + name="last_name" + title={t`Last name`} + placeholder={t`Appleseed`} + fullWidth + /> + <FormInput + name="email" + type="email" + title={t`Email`} + placeholder="nicetoseeyou@email.com" + fullWidth + /> + </> + )} + <FormSelect + name="locale" + title={t`Language`} + options={localeOptions} + /> + <FormSubmitButton title={t`Update`} disabled={!dirty} primary /> + <FormErrorMessage /> + </Form> + )} + </FormProvider> + ); +}; + +const getInitialValues = (user: User): UserProfileData => { + return { + first_name: user.first_name || "", + last_name: user.last_name || "", + email: user.email, + locale: user.locale, + }; +}; + +const getSubmitValues = (data: UserProfileData): UserProfileData => { + return { + ...data, + first_name: data.first_name || null, + last_name: data.last_name || null, + }; +}; + +const getLocaleOptions = (locales: LocaleData[] | null) => { + const options = _.chain(locales ?? [["en", "English"]]) + .map(([value, name]) => ({ name, value })) + .sortBy(({ name }) => name) + .value(); + + return [{ name: t`Use site default`, value: null }, ...options]; +}; + +export default UserProfileForm; diff --git a/frontend/src/metabase/account/profile/components/UserProfileForm/index.js b/frontend/src/metabase/account/profile/components/UserProfileForm/index.ts similarity index 100% rename from frontend/src/metabase/account/profile/components/UserProfileForm/index.js rename to frontend/src/metabase/account/profile/components/UserProfileForm/index.ts diff --git a/frontend/src/metabase/account/profile/containers/UserProfileApp/UserProfileApp.jsx b/frontend/src/metabase/account/profile/containers/UserProfileApp/UserProfileApp.jsx deleted file mode 100644 index 5b26c02d115d814afe9f7edc98f735eba15b0a5d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/account/profile/containers/UserProfileApp/UserProfileApp.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import { connect } from "react-redux"; -import { getUser } from "metabase/selectors/user"; -import UserProfileForm from "../../components/UserProfileForm"; - -const mapStateToProps = state => ({ - user: getUser(state), -}); - -export default connect(mapStateToProps)(UserProfileForm); diff --git a/frontend/src/metabase/account/profile/containers/UserProfileApp/UserProfileApp.tsx b/frontend/src/metabase/account/profile/containers/UserProfileApp/UserProfileApp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dbde8a6ed2619515230c607717aac85b67d4902e --- /dev/null +++ b/frontend/src/metabase/account/profile/containers/UserProfileApp/UserProfileApp.tsx @@ -0,0 +1,18 @@ +import { connect } from "react-redux"; +import { getUser } from "metabase/selectors/user"; +import { State } from "metabase-types/store"; +import UserProfileForm from "../../components/UserProfileForm"; +import { updateUser } from "../../actions"; +import { getIsSsoUser, getLocales } from "../../selectors"; + +const mapStateToProps = (state: State) => ({ + user: getUser(state), + locales: getLocales(state), + isSsoUser: getIsSsoUser(state), +}); + +const mapDispatchToProps = { + onSubmit: updateUser, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(UserProfileForm); diff --git a/frontend/src/metabase/account/profile/containers/UserProfileApp/index.js b/frontend/src/metabase/account/profile/containers/UserProfileApp/index.ts similarity index 100% rename from frontend/src/metabase/account/profile/containers/UserProfileApp/index.js rename to frontend/src/metabase/account/profile/containers/UserProfileApp/index.ts diff --git a/frontend/src/metabase/account/profile/selectors.ts b/frontend/src/metabase/account/profile/selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..46c12d573cc59c507f107bec8ceb202d59043438 --- /dev/null +++ b/frontend/src/metabase/account/profile/selectors.ts @@ -0,0 +1,12 @@ +import { createSelector } from "reselect"; +import { getUser } from "metabase/selectors/user"; +import { PLUGIN_IS_PASSWORD_USER } from "metabase/plugins"; +import { getSettings } from "metabase/selectors/settings"; + +export const getIsSsoUser = createSelector([getUser], user => { + return !PLUGIN_IS_PASSWORD_USER.every(predicate => predicate(user)); +}); + +export const getLocales = createSelector([getSettings], settings => { + return settings["available-locales"]; +}); diff --git a/frontend/src/metabase/account/profile/types.ts b/frontend/src/metabase/account/profile/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3518f64ee02c86587dd6e4f6d6bb50fe6aec4f65 --- /dev/null +++ b/frontend/src/metabase/account/profile/types.ts @@ -0,0 +1,6 @@ +export interface UserProfileData { + first_name: string | null; + last_name: string | null; + email: string; + locale: string | null; +} diff --git a/frontend/src/metabase/entities/users/forms.js b/frontend/src/metabase/entities/users/forms.js index b4ee16abd3d62221c4a2091a236f44c4ca68efa5..ae1282a6c39152f7490548bfecb7a8ceca74547e 100644 --- a/frontend/src/metabase/entities/users/forms.js +++ b/frontend/src/metabase/entities/users/forms.js @@ -1,141 +1,38 @@ -import _ from "underscore"; - import { t } from "ttag"; -import MetabaseSettings from "metabase/lib/settings"; -import MetabaseUtils from "metabase/lib/utils"; -import { - PLUGIN_ADMIN_USER_FORM_FIELDS, - PLUGIN_IS_PASSWORD_USER, -} from "metabase/plugins"; +import { PLUGIN_ADMIN_USER_FORM_FIELDS } from "metabase/plugins"; import validate from "metabase/lib/validate"; import FormGroupsWidget from "metabase/components/form/widgets/FormGroupsWidget"; -const getNameFields = () => [ - { - name: "first_name", - title: t`First name`, - placeholder: "Johnny", - autoFocus: true, - validate: validate.maxLength(100), - normalize: firstName => firstName || null, - }, - { - name: "last_name", - title: t`Last name`, - placeholder: "Appleseed", - validate: validate.maxLength(100), - normalize: lastName => lastName || null, - }, -]; - -const getEmailField = () => ({ - name: "email", - title: t`Email`, - placeholder: "nicetoseeyou@email.com", - validate: validate.required().email(), -}); - -const getLocaleField = () => ({ - name: "locale", - title: t`Language`, - type: "select", - options: [ - [null, t`Use site default`], - ..._.sortBy( - MetabaseSettings.get("available-locales") || [["en", "English"]], - ([code, name]) => name, - ), - ].map(([code, name]) => ({ name, value: code })), -}); - -const getPasswordFields = () => [ - { - name: "password", - title: t`Create a password`, - type: "password", - placeholder: t`Shhh...`, - validate: validate.required().passwordComplexity(), - }, - { - name: "password_confirm", - title: t`Confirm your password`, - type: "password", - placeholder: t`Shhh... but one more time so we get it right`, - validate: (password_confirm, { values: { password } = {} }) => { - if (!password_confirm) { - return t`required`; - } else if (password_confirm !== password) { - return t`passwords do not match`; - } - }, - }, -]; - export default { admin: { fields: [ - ...getNameFields(), - getEmailField(), { - name: "user_group_memberships", - title: t`Groups`, - type: FormGroupsWidget, - }, - ...PLUGIN_ADMIN_USER_FORM_FIELDS, - ], - }, - user: user => { - const isSsoUser = !PLUGIN_IS_PASSWORD_USER.every(predicate => - predicate(user), - ); - const fields = isSsoUser - ? [getLocaleField()] - : [...getNameFields(), getEmailField(), getLocaleField()]; - - return { - fields, - disablePristineSubmit: true, - }; - }, - setup_invite: user => ({ - fields: [ - ...getNameFields(), - { - name: "email", - title: t`Email`, - placeholder: "nicetoseeyou@email.com", - validate: email => { - if (!email) { - return t`required`; - } else if (!MetabaseUtils.isEmail(email)) { - return t`must be a valid email address`; - } else if (email === user.email) { - return t`must be different from the email address you used in setup`; - } - }, + name: "first_name", + title: t`First name`, + placeholder: "Johnny", + autoFocus: true, + validate: validate.maxLength(100), + normalize: firstName => firstName || null, }, - ], - }), - password: { - fields: [ { - name: "old_password", - type: "password", - title: t`Current password`, - placeholder: t`Shhh...`, - validate: validate.required(), + name: "last_name", + title: t`Last name`, + placeholder: "Appleseed", + validate: validate.maxLength(100), + normalize: lastName => lastName || null, }, - ...getPasswordFields(), - ], - }, - newsletter: { - fields: [ { name: "email", + title: t`Email`, placeholder: "nicetoseeyou@email.com", - autoFocus: true, validate: validate.required().email(), }, + { + name: "user_group_memberships", + title: t`Groups`, + type: FormGroupsWidget, + }, + ...PLUGIN_ADMIN_USER_FORM_FIELDS, ], }, };