From 864f3f7f866713839af30d1506af3ba946c633c3 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Sat, 29 Oct 2022 01:16:18 +0700 Subject: [PATCH 001/269] docs - history (#26144) (#26157) Co-authored-by: Natalie --- docs/README.md | 4 +- .../collections.md | 8 -- .../exploration.md | 12 +++ docs/exploration-and-organization/history.md | 89 ++++++++++++++++++ .../images/verified-icon.png | Bin docs/exploration-and-organization/start.md | 4 + docs/paid-features/overview.md | 8 +- docs/questions/query-builder/introduction.md | 7 +- docs/questions/sharing/answers.md | 24 +---- docs/questions/sharing/public-links.md | 4 +- 10 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 docs/exploration-and-organization/history.md rename docs/{questions => exploration-and-organization}/images/verified-icon.png (100%) diff --git a/docs/README.md b/docs/README.md index 9b19ceb789e3..411682acd00c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,7 @@ Metabase's reference documentation. #### Query builder - [Asking questions](./questions/query-builder/introduction.md) +- [Visualizing data](./questions/sharing/visualizing-results.md) - [Custom expressions](./questions/query-builder/expressions.md) - [List of expressions](./questions/query-builder/expressions-list.md) - [Joining data](./questions/query-builder/join.md) @@ -77,7 +78,6 @@ Metabase's reference documentation. #### Sharing - [Sharing answers](./questions/sharing/answers.md) -- [Visualizing data](./questions/sharing/visualizing-results.md) - [Setting and getting alerts](./questions/sharing/alerts.md) - [Public links](./questions/sharing/public-links.md) @@ -104,7 +104,9 @@ Metabase's reference documentation. - [Organization overview](./exploration-and-organization/start.md) - [Basic exploration](./exploration-and-organization/exploration.md) - [Collections](./exploration-and-organization/collections.md) +- [History](./exploration-and-organization/history.md) - [Events and timelines](./exploration-and-organization/events-and-timelines.md) +- [X-rays](./exploration-and-organization/x-rays.md) ### People diff --git a/docs/exploration-and-organization/collections.md b/docs/exploration-and-organization/collections.md index b637dbed1603..4982fd09d2b3 100644 --- a/docs/exploration-and-organization/collections.md +++ b/docs/exploration-and-organization/collections.md @@ -58,14 +58,6 @@ To move a question, dashboard, or pulse into a collection, or from one collectio Note that you have to have Curate permission for the collection that you're moving a question into _and_ the collection you're moving the question out of. -## Archiving items - -Sometimes questions outlive their usefulness and need to be sent to Question Heaven. To archive a question or dashboard, just click on the `…` menu that appears on the far right when you hover over a question and pick the Archive action. You'll only see that option if you have "curate" permission for the current collection. You can also archive multiple items at once, the same way as you move multiple items. Note that archiving a question removes it from all dashboards or Pulses where it appears, so be careful! - -You can also archive _collections_ if you have curate permissions for the collection you're trying to archive, the collection _it's_ inside of, as well as any and all collections inside of _it_. Archiving a collection archives all of its contents as well. - -If you have second thoughts and want to bring an archived item back, you can see all your archived questions from the archive; click the menu icon in the top-right of any collection page to get to the archive. To unarchive a question, hover over it and click the unarchive icon that appears on the far right. - ## Events and timelines You can add events to collections, and organize those events into timelines. See [Events and timelines](events-and-timelines.md). diff --git a/docs/exploration-and-organization/exploration.md b/docs/exploration-and-organization/exploration.md index 79d5130a36a2..281b36ce0398 100644 --- a/docs/exploration-and-organization/exploration.md +++ b/docs/exploration-and-organization/exploration.md @@ -96,6 +96,18 @@ Some things to remember with bookmarks: - Items that you bookmark will get a boost in your search results (but not the search results of other people). - To reorder bookmarks, simply drag and drop them in the sidebar. +## Verified items + +{% include plans-blockquote.html feature="Verification" %} + +Verified questions and models are marked with a blue checkmark icon: + +![Verified icon](./images/verified-icon.png) + +Administrators can **Verify** a question or model from the three dot menu (`...`) to signal that they've reviewed the item and deemed it to be trustworthy. That is: the question or model is filtering the right columns, summarizing the right metrics, and querying records from the right tables. Verified items are more likely to show up higher in search suggestions and search results. + +If someone modifies a verified question, the question will lose its verified status, and an administrator will need to review and verify the question again to restore its verified status. + [collections]: ./collections.md [dashboards]: ../dashboards/start.md [models]: ../data-modeling/models.md diff --git a/docs/exploration-and-organization/history.md b/docs/exploration-and-organization/history.md new file mode 100644 index 000000000000..ac534fd279f3 --- /dev/null +++ b/docs/exploration-and-organization/history.md @@ -0,0 +1,89 @@ +--- +title: History +--- + +# History + +## Viewing tracked changes + +1. Go to your question, dashboard, or model. +2. Click the info icon. +3. A sidebar will pop up with a history of up to 15 versions. + +Metabase will keep track of a version each time you [save](../questions/sharing/answers.md#how-to-save-a-question), [move](../questions/sharing/answers.md#editing-your-question), [revert](#reverting-to-previous-versions), [archive](#archiving-items), or [verify](./exploration.md#verified-items) an item. + +## Reverting to previous versions + +1. Go to your question, dashboard, or model. +2. Click the info icon. +3. A sidebar will appear with up to 15 previous versions. +4. Click on the **back arrow** beside a version to revert your item to that point in time. + +## Archiving items + +Sometimes your questions, dashboards, or models outlive their usefulness. You can send outdated items to the **Archive** from the three dot menu `...` of your questions, dashboards, or models. + +Note that archiving an item will affect any [dashboards](../dashboards/introduction.md), [subscriptions](../dashboards/subscriptions.md), or [SQL questions](../questions/native-editor/referencing-saved-questions-in-queries.md) that depend on that item, so be careful! + +### Archiving multiple items + +You can batch archive multiple items from the same collection: + +1. Go to the collection. +2. Hover over the icon beside the name of the item. +3. Click the checkbox that appears. +4. When you're done selecting your items, click **Archive** at the bottom of the page. + +### Archiving a collection + +1. Go to the collection. +2. Click `...` > **Archive**. + +Archiving a collection archives all of the collection's contents as well. You can only archive a collection if you have permission to **Curate** a collection (as well as all of the collections inside that collection). If you don't see the **Archive** option, see your Metabase admin about your [collection permissions](../permissions/collections.md). + +### Viewing the archive + +1. Open the main Metabase sidebar. +2. Click the `...` beside the "Collections" header in the sidebar. +3. Click **View archive**. + +## Unarchiving items + +1. Open the main Metabase sidebar. +2. Click the `...` beside the "Collections" header in the sidebar. +3. Click **View archive**. +4. Hover over the item and click the **unarchive** icon (rectangle with a `^` symbol). + +The unarchived item should get restored to the parent collection that it was most recently saved in. If you unarchive an item, but you don't know where it's reappeared: + +- search for the item directly, or +- check if the item's parent collection is also in the archive. + +### Unarchiving multiple items + +You can unarchive multiple items at once from the same collection: + +1. Go to the collection. +2. Hover over the icon beside the name of the item and click the checkbox that appears. +3. When you're done selecting your items, click **Unarchive** at the bottom of the page. + +## Deleting items permanently + +1. Open the main Metabase sidebar. +2. Click the `...` beside the "Collections" header in the sidebar. +3. Click **View archive**. +4. Hover over the item and click the **trash bin** icon. + +The item will get permanently deleted from your application database. + +Remember that [archiving](#archiving-items) and deleting items can have unanticipated ripple effects on related [dashboards](../dashboards/introduction.md), [subscriptions](../dashboards/subscriptions.md), and [SQL questions](../questions/native-editor/referencing-saved-questions-in-queries.md). + +We recommend archiving because you can always unarchive if something breaks. If you delete an item and accidentally break something, you might have to recreate all of that work from scratch (unless you're prepared to revert to a backup of your application database). + +### Deleting multiple items permanently + +You can delete multiple items at once from the same collection: + +1. Go to the collection. +2. Hover over the icon beside the name of the item and click the checkbox that appears. +3. When you're done selecting your items, click **Delete** at the bottom of the page. diff --git a/docs/questions/images/verified-icon.png b/docs/exploration-and-organization/images/verified-icon.png similarity index 100% rename from docs/questions/images/verified-icon.png rename to docs/exploration-and-organization/images/verified-icon.png diff --git a/docs/exploration-and-organization/start.md b/docs/exploration-and-organization/start.md index 7c9d14f28d58..aba5c2d4512b 100644 --- a/docs/exploration-and-organization/start.md +++ b/docs/exploration-and-organization/start.md @@ -14,6 +14,10 @@ Find data, explore questions and dashboards, and bookmark your favorites. Organize questions, dashboards, and models with collections. +## [History](./history.md) + +View changes to a question or dashboard, revert to previous versions, and archive outdated items. + ## [Events and timelines](./events-and-timelines.md) Add events to timelines to annotate charts. diff --git a/docs/paid-features/overview.md b/docs/paid-features/overview.md index 05371cbf0599..340b0fc94de8 100644 --- a/docs/paid-features/overview.md +++ b/docs/paid-features/overview.md @@ -6,7 +6,7 @@ redirect_from: # Overview of premium features -Metabase's [Enterprise and Pro][pricing] plans provide additional features that help organizations scale Metabase and deliver self-service, embedded analytics. +Metabase's [Enterprise and Pro](https://www.metabase.com/pricing) plans provide additional features that help organizations scale Metabase and deliver self-service, embedded analytics. ## Setting up @@ -54,9 +54,9 @@ You can mark certain collections as [official](../exploration-and-organization/c ## Question moderation -People can ask administrators to verify their questions. +People can ask administrators to verify their questions and models. -- [Question moderation](../questions/sharing/answers.md#question-moderation) +- [Verified items](../exploration-and-organization/exploration.md#verified-items) ## Advanced caching controls @@ -81,5 +81,3 @@ See which queries are failing to help keep your Metabase tidy. You can export Metabase application data and use that to spin up new instances preloaded with questions, dashboards, and collections. - [Serialization](../installation-and-operation/serialization.md) - -[pricing]: https://www.metabase.com/pricing diff --git a/docs/questions/query-builder/introduction.md b/docs/questions/query-builder/introduction.md index edbbd0441bf9..5b2685f8c4ac 100644 --- a/docs/questions/query-builder/introduction.md +++ b/docs/questions/query-builder/introduction.md @@ -6,7 +6,7 @@ redirect_from: # Asking questions -Metabase's two core concepts are questions and their corresponding answers. Everything else is based around questions and answers. To ask a question in Metabase, click the **+ New** button in the upper right of the main navigation bar, and select either: +Metabase's two core concepts are questions and their corresponding answers. To ask a question in Metabase, click the **+ New** button in the upper right of the main navigation bar, and select either: - Question - [SQL query](../native-editor/writing-sql.md) @@ -19,7 +19,7 @@ From the **+ New** dropdown, select **Question**, then pick your starting data: You can start a question from: -- **A model**. A [model][model] is a special kind of saved question meant to be used as a good starting point for questions. Sometimes these are called derived tables, as they usually pull together data from multiple raw tables. +- **A model**. A [model](../../data-modeling/models.md) is a special kind of saved question meant to be used as a good starting point for questions. Sometimes these are called derived tables, as they usually pull together data from multiple raw tables. - **Raw data**. You'll need to specify the database and the table in that database as the starting point for your question. - A **saved question**. You can use the results of any question as the starting point for a new question. @@ -220,6 +220,5 @@ If you find yourself using the same saved question as a starting point for multi ## Further reading - [Visualize results](../sharing/visualizing-results.md). +- [Sharing answers](../sharing/answers.md). - [Asking questions](https://www.metabase.com/learn/questions) - -[model]: ../../data-modeling/models.md diff --git a/docs/questions/sharing/answers.md b/docs/questions/sharing/answers.md index 845aa8afc1f7..46b4f1dd2b27 100644 --- a/docs/questions/sharing/answers.md +++ b/docs/questions/sharing/answers.md @@ -36,7 +36,7 @@ Once you save your question, a down arrow will appear to the right of the questi - **Archive** (Folder with down arrow). See [Archiving items][archiving-items]. - **Bookmark** Save the question as a favorite, which will show up in the bookmarks section of your navigation sidebar. See [Bookmarks](../../exploration-and-organization/exploration.md#bookmarks). -### Caching results +## Caching results {% include plans-blockquote.html feature="Question-specific caching" %} @@ -46,26 +46,6 @@ Administrators can set global caching controls, but if you're using a paid versi Admins can still set global caching, but setting a cache duration on a specific question will override that global setting–useful for when a particular question has a different natural cadence. -### Question moderation - -{% include plans-blockquote.html feature="Question moderation" %} - -Administrators can **Verify** a question by clicking on the **Verify checkmark** in the **Moderation** section of the **Question detail sidebar**. Verifying a question is a simple way for an administrator to signal that they've reviewed the question and deemed it to be trustworthy. That is: the question is filtering the right columns, or summarizing the right metrics, and querying records from the right tables. - -Once verified, the question will have a verified icon next to the question's title. - -![Verified icon](../images/verified-icon.png) - -Verified questions are also more likely to show up higher in search suggestions and search results. - -If someone modifies a verified question, the question will lose its verified status, and an administrator will need to review and verify the question again to restore its verified status. - -### Question and model histories - -You can see the history of a question or [model][model], including edits and verifications, in the **History** section of the **Question detail sidebar**. - -Below each edit entry in the timeline, you can click on **Revert** to reinstate the question at the time of the edit. - ## Sharing questions with public links If your Metabase administrator has enabled [public sharing](../../questions/sharing/public-links.md) on a saved question or dashboard, you can go to that question or dashboard and click on the sharing icon to find its public links. Public links can be viewed by anyone, even if they don't have access to Metabase. You can also use the public embedding code to embed your question or dashboard in a simple web page or blog post. @@ -79,7 +59,7 @@ To share a question, click on the arrow pointing up and to the right in the bott You can set up questions to run periodically and notify you if the results are interesting. Check out [Alerts][alerts]. [alerts]: ./alerts.md -[archiving-items]: ../../exploration-and-organization/collections.md#archiving-items +[archiving-items]: ../../exploration-and-organization/history.md#archiving-items [caching]: ../../configuring-metabase/caching.md [collections]: ../../exploration-and-organization/collections.md [collection-permissions]: ../../permissions/collections.md diff --git a/docs/questions/sharing/public-links.md b/docs/questions/sharing/public-links.md index c815c80942ba..7b0462d4df51 100644 --- a/docs/questions/sharing/public-links.md +++ b/docs/questions/sharing/public-links.md @@ -26,7 +26,9 @@ In the case of a dashboard, the button is located on the top right of the page. Copy and share the public link URL with whomever you please. The public link URL will display static results of your question or dashboard, so visitors won't be able to drill-down into the underlying data on their own. -However, public URLs preserve [custom click behavior](../../dashboards/interactive.md). If you like, you can share specific drill-down views by linking to other questions or dashboards. +However, public URLs preserve [custom click behavior](../../dashboards/interactive.md) to custom URLs. If you like, you can share specific drill-down views by linking to the public links of other questions or dashboards. + +If you want to restrict what people can see in a public link based on a filter value, check out [signed embedding](../../embedding/signed-embedding.md). ## Public exports for question results in CSV, XLSX, JSON From 6e4a51f32c40f867d1ad2361a2c063d51358206b Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Mon, 31 Oct 2022 14:15:37 +0000 Subject: [PATCH 002/269] Fix form field API (#26160) (#26175) Co-authored-by: Alexander Polyankin --- .../auth/components/LoginForm/LoginForm.tsx | 43 +++++-------- .../core/components/CheckBox/CheckBox.tsx | 15 ++--- .../components/FormCheckBox/FormCheckBox.tsx | 39 ++++++++---- .../FormCheckBox/FormCheckBox.unit.spec.tsx | 16 ++--- .../FormField.styled.tsx} | 0 .../core/components/FormField/FormField.tsx | 60 ++++++++++++++----- .../core/components/FormField/index.ts | 1 + .../core/components/FormField/types.ts | 5 +- .../core/components/FormInput/FormInput.tsx | 38 ++++++++---- .../FormInput/FormInput.unit.spec.tsx | 16 ++--- .../FormNumericInput/FormNumericInput.tsx | 45 ++++++++++---- .../FormNumericInput.unit.spec.tsx | 16 ++--- .../core/components/FormRadio/FormRadio.tsx | 43 +++++++++---- .../FormRadio/FormRadio.unit.spec.tsx | 18 +++--- .../core/components/FormSelect/FormSelect.tsx | 53 +++++++++++----- .../FormSelect/FormSelect.unit.spec.tsx | 29 ++++----- .../FormSubmitButton/FormSubmitButton.tsx | 24 ++++---- .../core/components/FormToggle/FormToggle.tsx | 39 ++++++++---- .../FormToggle/FormToggle.unit.spec.tsx | 16 ++--- .../core/components/InputField/InputField.tsx | 60 ------------------- .../core/components/InputField/index.ts | 2 - .../core/components/InputField/types.ts | 3 - .../use-form-error-message.ts | 30 +++++++--- .../hooks/use-form-status/use-form-status.ts | 6 +- .../src/metabase/core/hooks/use-form/types.ts | 2 +- .../metabase/core/hooks/use-form/use-form.ts | 22 ++++--- frontend/src/metabase/hoc/Uncontrollable.jsx | 2 + 27 files changed, 365 insertions(+), 278 deletions(-) rename frontend/src/metabase/core/components/{InputField/InputField.styled.tsx => FormField/FormField.styled.tsx} (100%) delete mode 100644 frontend/src/metabase/core/components/InputField/InputField.tsx delete mode 100644 frontend/src/metabase/core/components/InputField/index.ts delete mode 100644 frontend/src/metabase/core/components/InputField/types.ts diff --git a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx index 7137907313fe..cfc5dd0464c0 100644 --- a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx +++ b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx @@ -5,7 +5,6 @@ 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"; @@ -48,39 +47,27 @@ const LoginForm = ({ onSubmit={handleSubmit} >
- - - - - - + type={isLdapEnabled ? "input" : "email"} + placeholder="nicetoseeyou@email.com" + autoFocus + fullWidth + /> + {!hasSessionCookies && ( - - - + )} - + diff --git a/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx b/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx index 7305a8cc7f88..aaebc21aab7f 100644 --- a/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx +++ b/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx @@ -43,9 +43,16 @@ export interface CheckBoxProps onBlur?: (event: FocusEvent) => void; } +interface CheckboxTooltipProps { + hasTooltip: boolean; + tooltipLabel: ReactNode; + children: ReactNode; +} + const CheckBox = forwardRef(function Checkbox( { name, + id, label, labelEllipsis = false, checked, @@ -75,7 +82,7 @@ const CheckBox = forwardRef(function Checkbox( tooltipLabel={label} > (function Checkbox( ); }); -interface CheckboxTooltipProps { - hasTooltip: boolean; - tooltipLabel: ReactNode; - children: ReactNode; -} - function CheckboxTooltip({ hasTooltip, tooltipLabel, diff --git a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx index fcfa83258c60..5f5715ed8864 100644 --- a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx +++ b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx @@ -1,27 +1,44 @@ -import React, { forwardRef, Ref } from "react"; +import React, { forwardRef, ReactNode, Ref } from "react"; import { useField } from "formik"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; import CheckBox, { CheckBoxProps } from "metabase/core/components/CheckBox"; +import FormField from "metabase/core/components/FormField"; export interface FormCheckBoxProps extends Omit { name: string; + title?: string; + description?: ReactNode; } const FormCheckBox = forwardRef(function FormCheckBox( - { name, ...props }: FormCheckBoxProps, - ref: Ref, + { name, className, style, title, description, ...props }: FormCheckBoxProps, + ref: Ref, ) { - const [{ value, onChange, onBlur }] = useField(name); + const id = useUniqueId(); + const [field, meta] = useField(name); return ( - + className={className} + style={style} + title={title} + description={description} + alignment="start" + orientation="horizontal" + htmlFor={id} + error={meta.touched ? meta.error : undefined} + > + + ); }); diff --git a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.unit.spec.tsx b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.unit.spec.tsx index f596c797f078..abd05ae842a6 100644 --- a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.unit.spec.tsx @@ -3,7 +3,6 @@ import { Form, Formik } from "formik"; import * as Yup from "yup"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import FormField from "metabase/core/components/FormField"; import FormCheckBox from "./FormCheckBox"; const TEST_SCHEMA = Yup.object().shape({ @@ -26,9 +25,7 @@ const TestFormCheckBox = ({ onSubmit={onSubmit} >
- - - + @@ -44,14 +41,17 @@ describe("FormCheckBox", () => { expect(screen.getByRole("checkbox")).toBeChecked(); }); - it("should propagate the changed value to the form", () => { + it("should propagate the changed value to the form", async () => { const onSubmit = jest.fn(); render(); userEvent.click(screen.getByRole("checkbox")); userEvent.click(screen.getByText("Submit")); - waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ value: true })); + await waitFor(() => { + const values = { value: true }; + expect(onSubmit).toHaveBeenCalledWith(values, expect.anything()); + }); }); it("should be referenced by the label", () => { @@ -62,13 +62,13 @@ describe("FormCheckBox", () => { expect(screen.getByLabelText("Label")).toBeInTheDocument(); }); - it("should be validated on blur", () => { + it("should be validated on blur", async () => { const onSubmit = jest.fn(); render(); userEvent.click(screen.getByRole("checkbox")); userEvent.tab(); - waitFor(() => expect(screen.getByText("Label: error")).toBeInTheDocument()); + expect(await screen.findByText(": error")).toBeInTheDocument(); }); }); diff --git a/frontend/src/metabase/core/components/InputField/InputField.styled.tsx b/frontend/src/metabase/core/components/FormField/FormField.styled.tsx similarity index 100% rename from frontend/src/metabase/core/components/InputField/InputField.styled.tsx rename to frontend/src/metabase/core/components/FormField/FormField.styled.tsx diff --git a/frontend/src/metabase/core/components/FormField/FormField.tsx b/frontend/src/metabase/core/components/FormField/FormField.tsx index 16288cfdc9f1..fd720f468896 100644 --- a/frontend/src/metabase/core/components/FormField/FormField.tsx +++ b/frontend/src/metabase/core/components/FormField/FormField.tsx @@ -1,28 +1,58 @@ -import React, { forwardRef, Ref } from "react"; -import { useField } from "formik"; -import InputField, { - InputFieldProps, -} from "metabase/core/components/InputField"; +import React, { forwardRef, HTMLAttributes, ReactNode, Ref } from "react"; +import { FieldAlignment, FieldOrientation } from "./types"; +import { + FieldCaption, + FieldDescription, + FieldLabel, + FieldLabelError, + FieldRoot, +} from "./FormField.styled"; -export interface FormFieldProps - extends Omit { - name: string; +export interface FormFieldProps extends HTMLAttributes { + title?: string; + description?: ReactNode; + alignment?: FieldAlignment; + orientation?: FieldOrientation; + error?: string; + htmlFor?: string; } const FormField = forwardRef(function FormField( - { name, ...props }: FormFieldProps, + { + title, + description, + error, + htmlFor, + alignment = "end", + orientation = "vertical", + children, + ...props + }: FormFieldProps, ref: Ref, ) { - const [, meta] = useField(name); - const { error, touched } = meta; + const hasError = Boolean(error); return ( - + orientation={orientation} + hasError={hasError} + > + {alignment === "start" && children} + {(title || description) && ( + + {title && ( + + {title} + {hasError && : {error}} + + )} + {description && {description}} + + )} + {alignment === "end" && children} + ); }); diff --git a/frontend/src/metabase/core/components/FormField/index.ts b/frontend/src/metabase/core/components/FormField/index.ts index f1d921fd9745..68c3d0de2c0e 100644 --- a/frontend/src/metabase/core/components/FormField/index.ts +++ b/frontend/src/metabase/core/components/FormField/index.ts @@ -1,2 +1,3 @@ export { default } from "./FormField"; export type { FormFieldProps } from "./FormField"; +export type { FieldAlignment, FieldOrientation } from "./types"; diff --git a/frontend/src/metabase/core/components/FormField/types.ts b/frontend/src/metabase/core/components/FormField/types.ts index ce136dd44bb8..c2dc2f099c1f 100644 --- a/frontend/src/metabase/core/components/FormField/types.ts +++ b/frontend/src/metabase/core/components/FormField/types.ts @@ -1,2 +1,3 @@ -export type FormFieldAlignment = "start" | "end"; -export type FormFieldOrientation = "horizontal" | "vertical"; +export type FieldAlignment = "start" | "end"; + +export type FieldOrientation = "horizontal" | "vertical"; diff --git a/frontend/src/metabase/core/components/FormInput/FormInput.tsx b/frontend/src/metabase/core/components/FormInput/FormInput.tsx index e09deb094296..43c062921d95 100644 --- a/frontend/src/metabase/core/components/FormInput/FormInput.tsx +++ b/frontend/src/metabase/core/components/FormInput/FormInput.tsx @@ -1,29 +1,43 @@ -import React, { forwardRef, Ref } from "react"; +import React, { forwardRef, ReactNode, Ref } from "react"; import { useField } from "formik"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; import Input, { InputProps } from "metabase/core/components/Input"; +import FormField from "metabase/core/components/FormField"; export interface FormInputProps extends Omit { name: string; + title?: string; + description?: ReactNode; } const FormInput = forwardRef(function FormInput( - { name, ...props }: FormInputProps, + { name, className, style, title, description, ...props }: FormInputProps, ref: Ref, ) { - const [{ value, onChange, onBlur }, { error, touched }] = useField(name); + const id = useUniqueId(); + const [field, meta] = useField(name); return ( - + className={className} + style={style} + title={title} + description={description} + htmlFor={id} + error={meta.touched ? meta.error : undefined} + > + + ); }); diff --git a/frontend/src/metabase/core/components/FormInput/FormInput.unit.spec.tsx b/frontend/src/metabase/core/components/FormInput/FormInput.unit.spec.tsx index 53eccacc52c0..8f455f7118cd 100644 --- a/frontend/src/metabase/core/components/FormInput/FormInput.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormInput/FormInput.unit.spec.tsx @@ -3,7 +3,6 @@ import { Form, Formik } from "formik"; import * as Yup from "yup"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import FormField from "metabase/core/components/FormField"; import FormInput from "./FormInput"; const TEST_SCHEMA = Yup.object().shape({ @@ -23,9 +22,7 @@ const TestFormInput = ({ initialValue = "", onSubmit }: TestFormInputProps) => { onSubmit={onSubmit} >
- - - + @@ -41,14 +38,17 @@ describe("FormInput", () => { expect(screen.getByRole("textbox")).toHaveValue("Text"); }); - it("should propagate the changed value to the form", () => { + it("should propagate the changed value to the form", async () => { const onSubmit = jest.fn(); render(); userEvent.type(screen.getByRole("textbox"), "Text"); userEvent.click(screen.getByText("Submit")); - waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ value: "Text" })); + await waitFor(() => { + const values = { value: "Text" }; + expect(onSubmit).toHaveBeenCalledWith(values, expect.anything()); + }); }); it("should be referenced by the label", () => { @@ -59,13 +59,13 @@ describe("FormInput", () => { expect(screen.getByLabelText("Label")).toBeInTheDocument(); }); - it("should be validated on blur", () => { + it("should be validated on blur", async () => { const onSubmit = jest.fn(); render(); userEvent.clear(screen.getByRole("textbox")); userEvent.tab(); - waitFor(() => expect(screen.getByText("Label: error")).toBeInTheDocument()); + expect(await screen.findByText(": error")).toBeInTheDocument(); }); }); diff --git a/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.tsx b/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.tsx index 0649432fa7e3..e9ad4572d48a 100644 --- a/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.tsx +++ b/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.tsx @@ -1,31 +1,52 @@ -import React, { forwardRef, Ref } from "react"; +import React, { forwardRef, ReactNode, Ref } from "react"; import { useField } from "formik"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; import NumericInput, { NumericInputProps, } from "metabase/core/components/NumericInput"; +import FormField from "metabase/core/components/FormField"; export interface FormNumericInputProps extends Omit { name: string; + title?: string; + description?: ReactNode; } const FormNumericInput = forwardRef(function FormNumericInput( - { name, ...props }: FormNumericInputProps, + { + name, + className, + style, + title, + description, + ...props + }: FormNumericInputProps, ref: Ref, ) { - const [{ value, onBlur }, { error, touched }, { setValue }] = useField(name); + const id = useUniqueId(); + const [field, meta, helpers] = useField(name); return ( - + className={className} + style={style} + title={title} + description={description} + htmlFor={id} + error={meta.touched ? meta.error : undefined} + > + + ); }); diff --git a/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.unit.spec.tsx b/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.unit.spec.tsx index 15ebfe2702af..f82aa06766a7 100644 --- a/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.unit.spec.tsx @@ -3,7 +3,6 @@ import { Form, Formik } from "formik"; import * as Yup from "yup"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import FormField from "metabase/core/components/FormField"; import FormNumericInput from "./FormNumericInput"; const TestSchema = Yup.object().shape({ @@ -26,9 +25,7 @@ const TestFormNumericInput = ({ onSubmit={onSubmit} >
- - - + @@ -44,14 +41,17 @@ describe("FormNumericInput", () => { expect(screen.getByRole("textbox")).toHaveValue("10"); }); - it("should propagate the changed value to the form", () => { + it("should propagate the changed value to the form", async () => { const onSubmit = jest.fn(); render(); userEvent.type(screen.getByRole("textbox"), "10"); userEvent.click(screen.getByText("Submit")); - waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ value: 10 })); + await waitFor(() => { + const values = { value: 10 }; + expect(onSubmit).toHaveBeenCalledWith(values, expect.anything()); + }); }); it("should be referenced by the label", () => { @@ -62,13 +62,13 @@ describe("FormNumericInput", () => { expect(screen.getByLabelText("Label")).toBeInTheDocument(); }); - it("should be validated on blur", () => { + it("should be validated on blur", async () => { const onSubmit = jest.fn(); render(); userEvent.clear(screen.getByRole("textbox")); userEvent.tab(); - waitFor(() => expect(screen.getByText("Label: error")).toBeInTheDocument()); + expect(await screen.findByText(": error")).toBeInTheDocument(); }); }); diff --git a/frontend/src/metabase/core/components/FormRadio/FormRadio.tsx b/frontend/src/metabase/core/components/FormRadio/FormRadio.tsx index 81ccfa138c60..92612762bedb 100644 --- a/frontend/src/metabase/core/components/FormRadio/FormRadio.tsx +++ b/frontend/src/metabase/core/components/FormRadio/FormRadio.tsx @@ -1,32 +1,53 @@ -import React, { forwardRef, Key, Ref } from "react"; +import React, { forwardRef, Key, ReactNode, Ref } from "react"; import { useField } from "formik"; import Radio, { RadioOption, RadioProps } from "metabase/core/components/Radio"; +import FormField from "metabase/core/components/FormField"; export interface FormRadioProps< TValue extends Key, TOption = RadioOption, -> extends Omit, "value" | "onChange" | "onBlur"> { +> extends Omit< + RadioProps, + "value" | "error" | "onChange" | "onBlur" + > { name: string; + title?: string; + description?: ReactNode; } const FormRadio = forwardRef(function FormRadio< TValue extends Key, TOption = RadioOption, >( - { name, ...props }: FormRadioProps, + { + name, + className, + style, + title, + description, + ...props + }: FormRadioProps, ref: Ref, ) { - const [{ value, onBlur }, , { setValue }] = useField(name); + const [field, meta, helpers] = useField(name); return ( - + className={className} + style={style} + title={title} + description={description} + error={meta.touched ? meta.error : undefined} + > + + ); }); diff --git a/frontend/src/metabase/core/components/FormRadio/FormRadio.unit.spec.tsx b/frontend/src/metabase/core/components/FormRadio/FormRadio.unit.spec.tsx index 2ec7da20957c..97bbc6c83cc1 100644 --- a/frontend/src/metabase/core/components/FormRadio/FormRadio.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormRadio/FormRadio.unit.spec.tsx @@ -3,11 +3,10 @@ import { Form, Formik } from "formik"; import * as Yup from "yup"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import FormField from "metabase/core/components/FormField"; import FormRadio from "./FormRadio"; const TEST_SCHEMA = Yup.object().shape({ - value: Yup.string().notOneOf(["Bar"]), + value: Yup.string().notOneOf(["bar"], "error"), }); const TEST_OPTIONS = [ @@ -29,9 +28,7 @@ const TestFormRadio = ({ initialValue, onSubmit }: TestFormRadioProps) => { onSubmit={onSubmit} >
- - - + @@ -47,23 +44,26 @@ describe("FormRadio", () => { expect(screen.getByRole("radio", { name: "Line" })).toBeChecked(); }); - it("should propagate the changed value to the form", () => { + it("should propagate the changed value to the form", async () => { const onSubmit = jest.fn(); render(); userEvent.click(screen.getByRole("radio", { name: "Line" })); userEvent.click(screen.getByText("Submit")); - waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ value: "line" })); + await waitFor(() => { + const values = { value: "line" }; + expect(onSubmit).toHaveBeenCalledWith(values, expect.anything()); + }); }); - it("should be validated on blur", () => { + it("should be validated on blur", async () => { const onSubmit = jest.fn(); render(); userEvent.click(screen.getByRole("radio", { name: "Bar" })); userEvent.tab(); - waitFor(() => expect(screen.getByText("Label: error")).toBeInTheDocument()); + expect(await screen.findByText(": error")).toBeInTheDocument(); }); }); diff --git a/frontend/src/metabase/core/components/FormSelect/FormSelect.tsx b/frontend/src/metabase/core/components/FormSelect/FormSelect.tsx index bd0d2763af34..974dc9cd636b 100644 --- a/frontend/src/metabase/core/components/FormSelect/FormSelect.tsx +++ b/frontend/src/metabase/core/components/FormSelect/FormSelect.tsx @@ -1,31 +1,54 @@ -import React, { useMemo } from "react"; +import React, { forwardRef, ReactNode, Ref, useMemo } from "react"; import { useField } from "formik"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; import Select, { SelectOption, SelectProps, } from "metabase/core/components/Select"; +import FormField from "metabase/core/components/FormField"; export interface FormSelectProps> extends Omit, "value" | "onChange"> { name: string; + title?: string; + description?: ReactNode; } -function FormSelect>({ - name, - ...props -}: FormSelectProps) { - const [{ value, onChange, onBlur }] = useField(name); - const buttonProps = useMemo(() => ({ id: name, onBlur }), [name, onBlur]); +const FormSelect = forwardRef(function FormSelect< + TValue, + TOption = SelectOption, +>( + { + name, + className, + title, + description, + ...props + }: FormSelectProps, + ref: Ref, +) { + const id = useUniqueId(); + const [{ value, onChange, onBlur }, meta] = useField(name); + const buttonProps = useMemo(() => ({ id, onBlur }), [id, onBlur]); return ( - + ); -} +}); export default FormSelect; diff --git a/frontend/src/metabase/core/components/FormSelect/FormSelect.unit.spec.tsx b/frontend/src/metabase/core/components/FormSelect/FormSelect.unit.spec.tsx index 68363fdbe359..4bfaed93589c 100644 --- a/frontend/src/metabase/core/components/FormSelect/FormSelect.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormSelect/FormSelect.unit.spec.tsx @@ -3,11 +3,10 @@ import { Form, Formik } from "formik"; import * as Yup from "yup"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import FormField from "metabase/core/components/FormField"; import FormSelect from "./FormSelect"; const TEST_SCHEMA = Yup.object().shape({ - value: Yup.string().notOneOf(["Bar"]), + value: Yup.string().notOneOf(["bar"], "error"), }); const TEST_OPTIONS = [ @@ -29,13 +28,12 @@ const TestFormSelect = ({ initialValue, onSubmit }: TestFormSelectProps) => { onSubmit={onSubmit} >
- - - + @@ -51,7 +49,7 @@ describe("FormSelect", () => { expect(screen.getByText("Line")).toBeInTheDocument(); }); - it("should propagate the changed value to the form", () => { + it("should propagate the changed value to the form", async () => { const onSubmit = jest.fn(); render(); @@ -59,7 +57,10 @@ describe("FormSelect", () => { userEvent.click(screen.getByText("Line")); userEvent.click(screen.getByText("Submit")); - waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ value: "line" })); + await waitFor(() => { + const values = { value: "line" }; + expect(onSubmit).toHaveBeenCalledWith(values, expect.anything()); + }); }); it("should be referenced by the label", () => { @@ -70,14 +71,14 @@ describe("FormSelect", () => { expect(screen.getByLabelText("Label")).toBeInTheDocument(); }); - it("should be validated on blur", () => { + it("should be validated on blur", async () => { const onSubmit = jest.fn(); render(); userEvent.click(screen.getByText("Line")); userEvent.click(screen.getByText("Bar")); - userEvent.tab(); + userEvent.click(screen.getByText("Submit")); - waitFor(() => expect(screen.getByText("Label: error")).toBeInTheDocument()); + expect(await screen.findByText(": error")).toBeInTheDocument(); }); }); diff --git a/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx b/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx index 6878df90e059..816a1e4f68f9 100644 --- a/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx +++ b/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx @@ -6,10 +6,10 @@ import { FormStatus } from "metabase/core/hooks/use-form-state"; import useFormStatus from "metabase/core/hooks/use-form-status"; export interface FormSubmitButtonProps extends Omit { - normalText?: string; - activeText?: string; - successText?: string; - failedText?: string; + title?: string; + activeTitle?: string; + successTitle?: string; + failedTitle?: string; } const FormSubmitButton = forwardRef(function FormSubmitButton( @@ -39,21 +39,21 @@ const FormSubmitButton = forwardRef(function FormSubmitButton( const getSubmitButtonText = ( status: FormStatus | undefined, { - normalText = t`Submit`, - activeText = normalText, - successText = t`Success`, - failedText = t`Failed`, + title = t`Submit`, + activeTitle = title, + successTitle = t`Success`, + failedTitle = t`Failed`, }: FormSubmitButtonProps, ) => { switch (status) { case "pending": - return activeText; + return activeTitle; case "fulfilled": - return successText; + return successTitle; case "rejected": - return failedText; + return failedTitle; default: - return normalText; + return title; } }; diff --git a/frontend/src/metabase/core/components/FormToggle/FormToggle.tsx b/frontend/src/metabase/core/components/FormToggle/FormToggle.tsx index 7233f23bfcd1..5ce8a3fc2874 100644 --- a/frontend/src/metabase/core/components/FormToggle/FormToggle.tsx +++ b/frontend/src/metabase/core/components/FormToggle/FormToggle.tsx @@ -1,28 +1,43 @@ -import React, { forwardRef, Ref } from "react"; +import React, { forwardRef, ReactNode, Ref } from "react"; import { useField } from "formik"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; import Toggle, { ToggleProps } from "metabase/core/components/Toggle"; +import FormField from "metabase/core/components/FormField"; export interface FormToggleProps extends Omit { name: string; + title?: string; + description?: ReactNode; } const FormToggle = forwardRef(function FormToggle( - { name, ...props }: FormToggleProps, - ref: Ref, + { name, className, style, title, description, ...props }: FormToggleProps, + ref: Ref, ) { - const [{ value, onBlur }, , { setValue }] = useField(name); + const id = useUniqueId(); + const [field, meta, helpers] = useField(name); return ( - + className={className} + style={style} + title={title} + description={description} + orientation="horizontal" + htmlFor={id} + error={meta.touched ? meta.error : undefined} + > + + ); }); diff --git a/frontend/src/metabase/core/components/FormToggle/FormToggle.unit.spec.tsx b/frontend/src/metabase/core/components/FormToggle/FormToggle.unit.spec.tsx index 45dda11bb7e4..4fc86c050f60 100644 --- a/frontend/src/metabase/core/components/FormToggle/FormToggle.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormToggle/FormToggle.unit.spec.tsx @@ -3,7 +3,6 @@ import { Form, Formik } from "formik"; import * as Yup from "yup"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import FormField from "metabase/core/components/FormField"; import FormToggle from "./FormToggle"; const TEST_SCHEMA = Yup.object().shape({ @@ -26,9 +25,7 @@ const TestFormToggle = ({ onSubmit={onSubmit} >
- - - + @@ -44,14 +41,17 @@ describe("FormToggle", () => { expect(screen.getByRole("switch")).toBeChecked(); }); - it("should propagate the changed value to the form", () => { + it("should propagate the changed value to the form", async () => { const onSubmit = jest.fn(); render(); userEvent.click(screen.getByRole("switch")); userEvent.click(screen.getByText("Submit")); - waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ value: true })); + await waitFor(() => { + const values = { value: true }; + expect(onSubmit).toHaveBeenCalledWith(values, expect.anything()); + }); }); it("should be referenced by the label", () => { @@ -62,13 +62,13 @@ describe("FormToggle", () => { expect(screen.getByLabelText("Label")).toBeInTheDocument(); }); - it("should be validated on blur", () => { + it("should be validated on blur", async () => { const onSubmit = jest.fn(); render(); userEvent.click(screen.getByRole("switch")); userEvent.tab(); - waitFor(() => expect(screen.getByText("Label: error")).toBeInTheDocument()); + expect(await screen.findByText(": error")).toBeInTheDocument(); }); }); diff --git a/frontend/src/metabase/core/components/InputField/InputField.tsx b/frontend/src/metabase/core/components/InputField/InputField.tsx deleted file mode 100644 index 9b4c5bb22842..000000000000 --- a/frontend/src/metabase/core/components/InputField/InputField.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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 { - 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, -) { - const hasError = Boolean(error); - - return ( - - {alignment === "start" && children} - {(title || description) && ( - - {title && ( - - {title} - {hasError && : {error}} - - )} - {description && {description}} - - )} - {alignment === "end" && children} - - ); -}); - -export default InputField; diff --git a/frontend/src/metabase/core/components/InputField/index.ts b/frontend/src/metabase/core/components/InputField/index.ts deleted file mode 100644 index 23ec5d21c757..000000000000 --- a/frontend/src/metabase/core/components/InputField/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index c2dc2f099c1f..000000000000 --- a/frontend/src/metabase/core/components/InputField/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type FieldAlignment = "start" | "end"; - -export type FieldOrientation = "horizontal" | "vertical"; 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 index 8442d393e94f..f77b2d6d6e76 100644 --- 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 @@ -1,21 +1,33 @@ import { useLayoutEffect, useState } from "react"; import { useFormikContext } from "formik"; -import useFormState from "../use-form-state"; +import type { FormikErrors } from "formik"; +import { t } from "ttag"; +import useFormState from "metabase/core/hooks/use-form-state"; -const useFormErrorMessage = () => { - const { values } = useFormikContext(); - const { message } = useFormState(); - const [errorMessage, setErrorMessage] = useState(message); +const useFormErrorMessage = (): string | undefined => { + const { values, errors } = useFormikContext(); + const { status, message } = useFormState(); + const [isVisible, setIsVisible] = useState(false); useLayoutEffect(() => { - setErrorMessage(undefined); + setIsVisible(false); }, [values]); useLayoutEffect(() => { - setErrorMessage(message); - }, [message]); + setIsVisible(status === "rejected"); + }, [status]); - return errorMessage; + return isVisible ? getFormErrorMessage(errors, message) : undefined; +}; + +const getFormErrorMessage = (errors: FormikErrors, message?: string) => { + const hasErrors = Object.keys(errors).length > 0; + + if (message) { + return message; + } else if (!hasErrors) { + return t`An error occurred`; + } }; export default useFormErrorMessage; 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 index f9893af13e39..0b0eff0edc12 100644 --- 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 @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useState } from "react"; -import useFormState, { FormStatus } from "../use-form-state"; +import useFormState, { FormStatus } from "metabase/core/hooks/use-form-state"; const STATUS_TIMEOUT = 5000; @@ -16,7 +16,7 @@ const useFormStatus = (): FormStatus | undefined => { } }; -function useIsRecent(value: unknown, timeout: number) { +const useIsRecent = (value: unknown, timeout: number) => { const [isRecent, setIsRecent] = useState(true); useEffect(() => { @@ -29,6 +29,6 @@ function useIsRecent(value: unknown, timeout: number) { }, [value]); return isRecent; -} +}; export default useFormStatus; diff --git a/frontend/src/metabase/core/hooks/use-form/types.ts b/frontend/src/metabase/core/hooks/use-form/types.ts index 1380e5611f86..35c22de02800 100644 --- a/frontend/src/metabase/core/hooks/use-form/types.ts +++ b/frontend/src/metabase/core/hooks/use-form/types.ts @@ -1,6 +1,6 @@ import type { FormikErrors } from "formik"; -export interface FormError { +export interface FormError extends FormErrorData { data?: FormErrorData; } diff --git a/frontend/src/metabase/core/hooks/use-form/use-form.ts b/frontend/src/metabase/core/hooks/use-form/use-form.ts index e281cafefeda..495ad8fe68e0 100644 --- a/frontend/src/metabase/core/hooks/use-form/use-form.ts +++ b/frontend/src/metabase/core/hooks/use-form/use-form.ts @@ -6,17 +6,15 @@ const useForm = (onSubmit: (data: T) => void) => { return useCallback( async (data: T, helpers: FormikHelpers) => { try { - helpers.setStatus({ status: "pending", message: undefined }); + helpers.setStatus({ status: "pending" }); 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 }); - } + helpers.setErrors(getFormErrors(error)); + helpers.setStatus({ + status: "rejected", + message: getFormMessage(error), + }); } }, [onSubmit], @@ -27,4 +25,12 @@ const isFormError = (error: unknown): error is FormError => { return error != null && typeof error === "object"; }; +const getFormErrors = (error: unknown) => { + return isFormError(error) ? error.data?.errors ?? error.errors ?? {} : {}; +}; + +const getFormMessage = (error: unknown) => { + return isFormError(error) ? error.data?.message ?? error.message : undefined; +}; + export default useForm; diff --git a/frontend/src/metabase/hoc/Uncontrollable.jsx b/frontend/src/metabase/hoc/Uncontrollable.jsx index 474687380ca6..e0f1a84aa923 100644 --- a/frontend/src/metabase/hoc/Uncontrollable.jsx +++ b/frontend/src/metabase/hoc/Uncontrollable.jsx @@ -28,7 +28,9 @@ const Uncontrollable = () => WrappedComponent => handleChange = e => { this.setState({ value: e.target.value }); + this.props.onChange?.(e); }; + render() { if (this.props.value !== undefined) { // controlled From 9e747c065cf0bd7f84645a5ca25c7318f142d6a9 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Mon, 31 Oct 2022 15:53:59 +0000 Subject: [PATCH 003/269] Serdes: Hide `entity_id` from Serdes v1; it was causing errors (#26141) (#26176) Fixes #26043. Co-authored-by: Braden Shepherdson --- .../backend/src/metabase_enterprise/serialization/serialize.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/backend/src/metabase_enterprise/serialization/serialize.clj b/enterprise/backend/src/metabase_enterprise/serialization/serialize.clj index 90335488ff8b..72f9846716d8 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/serialize.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/serialize.clj @@ -100,7 +100,7 @@ [entity] (cond-> (dissoc entity :id :creator_id :created_at :updated_at :db_id :location :dashboard_id :fields_hash :personal_owner_id :made_public_by_id :collection_id - :pulse_id :result_metadata) + :pulse_id :result_metadata :entity_id) (some #(instance? % entity) (map type [Metric Field Segment])) (dissoc :table_id))) (defmulti ^:private serialize-one From b587f4d92eb6b1e91202768f89b6d2daa59d3a41 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Mon, 31 Oct 2022 16:18:49 +0000 Subject: [PATCH 004/269] Cleanup SAML settings in Admin (#26064) (#26177) Co-authored-by: Alexander Polyankin --- .../SettingsJWTForm/SettingsJWTForm.jsx | 51 ++-- .../auth/components/SettingsSAMLForm.jsx | 189 --------------- .../SettingsSAMLForm/SettingsSAMLForm.jsx | 219 ++++++++++++++++++ .../SettingsSAMLForm.styled.tsx | 10 + .../auth/components/SettingsSAMLForm/index.js | 1 + .../src/metabase-enterprise/auth/index.js | 34 ++- .../settings/components/SettingsBatchForm.jsx | 5 +- .../SettingsGoogleForm/SettingsGoogleForm.tsx | 46 +--- .../settings/components/SettingsLdapForm.jsx | 53 ++--- frontend/src/metabase/admin/settings/utils.js | 1 + .../metabase/plugins/builtin/auth/google.js | 1 + .../src/metabase/plugins/builtin/auth/ldap.js | 1 + .../admin/settings/sso/google.cy.spec.js | 2 +- .../admin/settings/sso/jwt.cy.spec.js | 13 +- .../admin/settings/sso/ldap.cy.spec.js | 2 +- .../admin/settings/sso/saml.cy.spec.js | 75 ++++++ 16 files changed, 379 insertions(+), 324 deletions(-) delete mode 100644 enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm.jsx create mode 100644 enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/SettingsSAMLForm.jsx rename enterprise/frontend/src/metabase-enterprise/auth/components/{ => SettingsSAMLForm}/SettingsSAMLForm.styled.tsx (67%) create mode 100644 enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/index.js create mode 100644 frontend/test/metabase/scenarios/admin/settings/sso/saml.cy.spec.js diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsJWTForm/SettingsJWTForm.jsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsJWTForm/SettingsJWTForm.jsx index 13f20aaa5284..5dbcdbd3c3c9 100644 --- a/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsJWTForm/SettingsJWTForm.jsx +++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsJWTForm/SettingsJWTForm.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; import { t } from "ttag"; @@ -8,15 +8,18 @@ import { FormButton } from "./SettingsJWTForm.styled"; const propTypes = { settingValues: PropTypes.object.isRequired, - updateSettings: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, }; -const SettingsJWTForm = ({ settingValues, updateSettings, ...props }) => { +const SettingsJWTForm = ({ settingValues, onSubmit, ...props }) => { const isEnabled = settingValues["jwt-enabled"]; - const handleAutoEnableSubmit = formData => { - return updateSettings({ ...formData, "jwt-enabled": true }); - }; + const handleSubmit = useCallback( + values => { + return onSubmit({ ...values, "jwt-enabled": true }); + }, + [onSubmit], + ); return ( { layout={FORM_LAYOUT} breadcrumbs={BREADCRUMBS} settingValues={settingValues} - updateSettings={updateSettings} - renderSubmitButton={ - !isEnabled && - (({ disabled, pristine, onSubmit }) => ( - onSubmit(handleAutoEnableSubmit)} - normalText={t`Save and enable`} - successText={t`Changes saved!`} - /> - )) - } - renderExtraButtons={ - !isEnabled && - (({ disabled, pristine, onSubmit }) => ( - onSubmit(updateSettings)} - normalText={t`Save but don't enable`} - successText={t`Changes saved!`} - /> - )) - } + updateSettings={handleSubmit} + renderSubmitButton={({ disabled, pristine, onSubmit }) => ( + + )} /> ); }; @@ -80,7 +69,7 @@ const BREADCRUMBS = [ ]; const mapDispatchToProps = { - updateSettings, + onSubmit: updateSettings, }; export default connect(null, mapDispatchToProps)(SettingsJWTForm); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm.jsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm.jsx deleted file mode 100644 index 22110357f3b8..000000000000 --- a/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm.jsx +++ /dev/null @@ -1,189 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; -import { connect } from "react-redux"; -import { t } from "ttag"; -import _ from "underscore"; - -import { updateSettings } from "metabase/admin/settings/settings"; -import { settingToFormField } from "metabase/admin/settings/utils"; - -import Form, { - FormField, - FormSubmit, - FormMessage, - FormSection, -} from "metabase/containers/FormikForm"; - -import Breadcrumbs from "metabase/components/Breadcrumbs"; -import CopyWidget from "metabase/components/CopyWidget"; - -import GroupMappingsWidget from "metabase/admin/settings/components/widgets/GroupMappingsWidget"; - -import MetabaseSettings from "metabase/lib/settings"; -import { SAMLFormSection } from "./SettingsSAMLForm.styled"; - -class SettingsSAMLForm extends Component { - render() { - const { elements, settingValues, updateSettings } = this.props; - // TODO: move these to an outer component so we don't have to do it in every form page - const setting = name => - _.findWhere(elements, { key: name }) || { key: name }; - const settingField = name => settingToFormField(setting(name)); - - const initialValues = { ...settingValues }; - - // HACK: this is to make the default show up as selectable text instead of placeholder - const addDefaultAsInitialValue = name => { - if (initialValues[name] == null && setting(name).default != null) { - initialValues[name] = setting(name).default; - } - }; - addDefaultAsInitialValue("saml-attribute-email"); - addDefaultAsInitialValue("saml-attribute-firstname"); - addDefaultAsInitialValue("saml-attribute-lastname"); - - const acsConsumerUrl = MetabaseSettings.get("site-url") + "/auth/sso"; - - return ( -
- -

{t`Set up SAML-based SSO`}

- - -

{t`Configure your identity provider (IdP)`}

-

{t`Your identity provider will need the following info about Metabase.`}

- -
-
{t`URL the IdP should redirect back to`}
-
{t`This is called the Single Sign On URL in Okta, the Application Callback URL in Auth0, - and the ACS (Consumer) URL in OneLogin. `}
- -
- -

{t`SAML attributes`}

-

{t`In most IdPs, you'll need to put each of these in an input box labeled - "Name" in the attribute statements section.`}

- - } - /> - } - /> - } - /> -
- - -

{t`Tell Metabase about your identity provider`}

-

{t`Metabase will need the following info about your provider.`}

- - - - -
- - - - - - - - - - -

{t`Synchronize group membership with your SSO`}

-

- {t`To enable this, you'll need to create mappings to tell Metabase which group(s) your users should - be added to based on the SSO group they're in.`} -

- ( - - updateSettings({ [key]: value }) - } - mappingSetting="saml-group-mappings" - groupHeading={t`Group Name`} - groupPlaceholder={t`Group Name`} - /> - )} - /> - -
- -
- -
-
- {t`Save changes`} -
- - ); - } -} - -export default connect(null, { updateSettings })(SettingsSAMLForm); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/SettingsSAMLForm.jsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/SettingsSAMLForm.jsx new file mode 100644 index 000000000000..1125d1d67eaa --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/SettingsSAMLForm.jsx @@ -0,0 +1,219 @@ +import React, { useCallback, useMemo } from "react"; +import { connect } from "react-redux"; +import PropTypes from "prop-types"; +import { jt, t } from "ttag"; +import _ from "underscore"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import CopyWidget from "metabase/components/CopyWidget"; +import ExternalLink from "metabase/core/components/ExternalLink"; +import Form, { + FormField, + FormSubmit, + FormMessage, + FormSection, +} from "metabase/containers/FormikForm"; +import MetabaseSettings from "metabase/lib/settings"; +import GroupMappingsWidget from "metabase/admin/settings/components/widgets/GroupMappingsWidget"; +import { updateSettings } from "metabase/admin/settings/settings"; +import { settingToFormField } from "metabase/admin/settings/utils"; +import { + SAMLFormCaption, + SAMLFormFooter, + SAMLFormSection, +} from "./SettingsSAMLForm.styled"; + +const propTypes = { + elements: PropTypes.array, + settingValues: PropTypes.object, + onSubmit: PropTypes.func, +}; + +const SettingsSAMLForm = ({ elements = [], settingValues = {}, onSubmit }) => { + const isEnabled = Boolean(settingValues["saml-enabled"]); + + const settings = useMemo(() => { + return _.indexBy(elements, "key"); + }, [elements]); + + const fields = useMemo(() => { + return _.mapObject(settings, settingToFormField); + }, [settings]); + + const defaultValues = useMemo(() => { + return _.mapObject(settings, "default"); + }, [settings]); + + const attributeValues = useMemo(() => { + return getAttributeValues(settingValues, defaultValues); + }, [settingValues, defaultValues]); + + const handleSubmit = useCallback( + values => onSubmit({ ...values, "saml-enabled": true }), + [onSubmit], + ); + + return ( +
+ +

{t`Set up SAML-based SSO`}

+ + {jt`Use the settings below to configure your SSO via SAML. If you have any questions, check out our ${( + {t`documentation`} + )}.`} + + +

{t`Configure your identity provider (IdP)`}

+

{t`Your identity provider will need the following info about Metabase.`}

+ +
+
{t`URL the IdP should redirect back to`}
+
{t`This is called the Single Sign On URL in Okta, the Application Callback URL in Auth0, + and the ACS (Consumer) URL in OneLogin. `}
+ +
+ +

{t`SAML attributes`}

+

{t`In most IdPs, you'll need to put each of these in an input box labeled + "Name" in the attribute statements section.`}

+ + } + /> + } + /> + } + /> +
+ + +

{t`Tell Metabase about your identity provider`}

+

{t`Metabase will need the following info about your provider.`}

+ + + + +
+ + + + + + + + + + +

{t`Synchronize group membership with your SSO`}

+

+ {t`To enable this, you'll need to create mappings to tell Metabase which group(s) your users should + be added to based on the SSO group they're in.`} +

+ ( + onSubmit({ [key]: value })} + mappingSetting="saml-group-mappings" + groupHeading={t`Group Name`} + groupPlaceholder={t`Group Name`} + /> + )} + /> + +
+ +
+ +
+ + + {isEnabled ? t`Save changes` : t`Save and enable`} + + + + ); +}; + +const SAML_ATTRS = [ + "saml-attribute-email", + "saml-attribute-firstname", + "saml-attribute-lastname", +]; + +const getAttributeValues = (values, defaults) => { + return _.object(SAML_ATTRS.map(key => [key, values[key] ?? defaults[key]])); +}; + +const getAcsCustomerUrl = () => { + return `${MetabaseSettings.get("site-url")}/auth/sso`; +}; + +const getDocsUrl = () => { + return MetabaseSettings.docsUrl("people-and-groups/authenticating-with-saml"); +}; + +SettingsSAMLForm.propTypes = propTypes; + +const mapDispatchToProps = { + onSubmit: updateSettings, +}; + +export default connect(null, mapDispatchToProps)(SettingsSAMLForm); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm.styled.tsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/SettingsSAMLForm.styled.tsx similarity index 67% rename from enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm.styled.tsx rename to enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/SettingsSAMLForm.styled.tsx index 9d76d6a8163c..3e56ec53bff7 100644 --- a/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm.styled.tsx +++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/SettingsSAMLForm.styled.tsx @@ -11,3 +11,13 @@ export const SAMLFormSection = styled.div` border: 1px solid ${color("border")}; border-radius: 0.5rem; `; + +export const SAMLFormCaption = styled.div` + color: ${color("text-medium")}; + margin-bottom: 1rem; +`; + +export const SAMLFormFooter = styled.div` + display: flex; + gap: 0.5rem; +`; diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/index.js b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/index.js new file mode 100644 index 000000000000..1db0fcff3058 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SettingsSAMLForm/index.js @@ -0,0 +1 @@ +export { default } from "./SettingsSAMLForm"; diff --git a/enterprise/frontend/src/metabase-enterprise/auth/index.js b/enterprise/frontend/src/metabase-enterprise/auth/index.js index 13dd275fd198..27b39282f30e 100644 --- a/enterprise/frontend/src/metabase-enterprise/auth/index.js +++ b/enterprise/frontend/src/metabase-enterprise/auth/index.js @@ -1,9 +1,6 @@ -import React from "react"; -import { t, jt } from "ttag"; +import { t } from "ttag"; import { updateIn } from "icepick"; -import ExternalLink from "metabase/core/components/ExternalLink"; import { LOGIN, LOGIN_GOOGLE } from "metabase/auth/actions"; - import { hasPremiumFeature } from "metabase-enterprise/settings"; import MetabaseSettings from "metabase/lib/settings"; import { @@ -13,7 +10,6 @@ import { PLUGIN_REDUX_MIDDLEWARES, } from "metabase/plugins"; -import AuthenticationOption from "metabase/admin/settings/components/widgets/AuthenticationOption"; import AuthenticationWidget from "metabase/admin/settings/components/widgets/AuthenticationWidget"; import GroupMappingsWidget from "metabase/admin/settings/components/widgets/GroupMappingsWidget"; import SecretKeyWidget from "metabase/admin/settings/components/widgets/SecretKeyWidget"; @@ -30,16 +26,22 @@ PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => updateIn(sections, ["authentication", "settings"], settings => [ ...settings, { - authName: t`SAML`, - authDescription: t`Allows users to login via a SAML Identity Provider.`, - authType: "saml", - authEnabled: settings => settings["saml-enabled"], - widget: AuthenticationOption, + key: "saml-enabled", + description: null, + noHeader: true, + widget: AuthenticationWidget, + getProps: (setting, settings) => ({ + authName: t`SAML`, + authDescription: t`Allows users to login via a SAML Identity Provider.`, + authType: "saml", + authConfigured: settings => settings["saml-configured"], + }), getHidden: () => !hasPremiumFeature("sso"), }, { key: "jwt-enabled", description: null, + noHeader: true, widget: AuthenticationWidget, getProps: (setting, settings) => ({ authName: t`JWT`, @@ -87,17 +89,7 @@ PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => ({ settings: [ { key: "saml-enabled", - display_name: t`SAML Authentication`, - description: jt`Use the settings below to configure your SSO via SAML. If you have any questions, check out our ${( - - {t`documentation`} - - )}.`, - type: "boolean", + getHidden: () => true, }, { key: "saml-identity-provider-uri", diff --git a/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx index 97db374b4c46..b484e154c522 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx @@ -188,7 +188,8 @@ class SettingsBatchForm extends Component { return formErrors; } - handleSubmit = updateSettings => { + handleSubmit = () => { + const { updateSettings } = this.props; const { formData, valid } = this.state; if (valid) { @@ -217,7 +218,7 @@ class SettingsBatchForm extends Component { handleSubmitClick = event => { event.preventDefault(); - this.handleSubmit(this.props.updateSettings); + this.handleSubmit(); }; render() { diff --git a/frontend/src/metabase/admin/settings/components/SettingsGoogleForm/SettingsGoogleForm.tsx b/frontend/src/metabase/admin/settings/components/SettingsGoogleForm/SettingsGoogleForm.tsx index 6f4dbbb46020..7dfcd355b337 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsGoogleForm/SettingsGoogleForm.tsx +++ b/frontend/src/metabase/admin/settings/components/SettingsGoogleForm/SettingsGoogleForm.tsx @@ -1,8 +1,7 @@ -import React, { useRef } from "react"; +import React, { useCallback } from "react"; import { connect } from "react-redux"; import { jt, t } from "ttag"; import MetabaseSettings from "metabase/lib/settings"; -import ActionButton from "metabase/components/ActionButton"; import ExternalLink from "metabase/core/components/ExternalLink"; import Breadcrumbs from "metabase/components/Breadcrumbs"; import { @@ -35,44 +34,17 @@ const SettingsGoogleForm = ({ onSubmit, }: SettingsGoogleFormProps) => { const isEnabled = Boolean(settingValues["google-auth-enabled"]); - const isEnabledRef = useRef(isEnabled); - const handleSubmit = (values: Record) => { - return onSubmit({ ...values, "google-auth-enabled": isEnabledRef.current }); - }; - - const handleSaveAndEnableClick = (handleSubmit: () => void) => { - isEnabledRef.current = true; - return handleSubmit(); - }; - - const handleSaveAndNotEnableClick = (handleSubmit: () => void) => { - isEnabledRef.current = false; - return handleSubmit(); - }; + const handleSubmit = useCallback( + (values: Record) => { + return onSubmit({ ...values, "google-auth-enabled": true }); + }, + [onSubmit], + ); return ( ( - <> - handleSaveAndEnableClick(handleSubmit)} - primary={canSubmit} - disabled={!canSubmit} - normalText={isEnabled ? t`Save changes` : t`Save and enable`} - successText={t`Changes saved!`} - /> - {!isEnabled && ( - handleSaveAndNotEnableClick(handleSubmit)} - disabled={!canSubmit} - normalText={t`Save but don't enable`} - successText={t`Changes saved!`} - /> - )} - - )} disablePristineSubmit overwriteOnInitialValuesChange onSubmit={handleSubmit} @@ -108,7 +80,9 @@ const SettingsGoogleForm = ({ - {t`Save changes`} + + {isEnabled ? t`Save changes` : t`Save and enable`} + ); diff --git a/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx index 96482187ed84..031f96520b13 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsLdapForm.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } from "react"; import PropTypes from "prop-types"; import { t } from "ttag"; import { connect } from "react-redux"; @@ -8,17 +8,20 @@ import { FormButton } from "./SettingsLdapForm.styled"; const propTypes = { settingValues: PropTypes.object.isRequired, - updateLdapSettings: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, }; -const SettingsLdapForm = ({ settingValues, updateLdapSettings, ...props }) => { +const SettingsLdapForm = ({ settingValues, onSubmit, ...props }) => { const isEnabled = settingValues["ldap-enabled"]; const layout = getLayout(settingValues); const breadcrumbs = getBreadcrumbs(); - const handleAutoEnableSubmit = formData => { - return updateLdapSettings({ ...formData, "ldap-enabled": true }); - }; + const handleSubmit = useCallback( + values => { + return onSubmit({ ...values, "ldap-enabled": true }); + }, + [onSubmit], + ); return ( { layout={layout} breadcrumbs={breadcrumbs} settingValues={settingValues} - updateSettings={updateLdapSettings} - renderSubmitButton={ - !isEnabled && - (({ disabled, pristine, onSubmit }) => ( - onSubmit(handleAutoEnableSubmit)} - normalText={t`Save and enable`} - successText={t`Changes saved!`} - /> - )) - } - renderExtraButtons={ - !isEnabled && - (({ disabled, pristine, onSubmit }) => ( - onSubmit(updateLdapSettings)} - normalText={t`Save but don't enable`} - successText={t`Changes saved!`} - /> - )) - } + updateSettings={handleSubmit} + renderSubmitButton={({ disabled, pristine, onSubmit }) => ( + + )} /> ); }; @@ -101,6 +90,8 @@ const getBreadcrumbs = () => { return [[t`Authentication`, "/admin/settings/authentication"], [t`LDAP`]]; }; -const mapDispatchToProps = { updateLdapSettings }; +const mapDispatchToProps = { + onSubmit: updateLdapSettings, +}; export default connect(null, mapDispatchToProps)(SettingsLdapForm); diff --git a/frontend/src/metabase/admin/settings/utils.js b/frontend/src/metabase/admin/settings/utils.js index cc30202dc3f9..a74c20710fa0 100644 --- a/frontend/src/metabase/admin/settings/utils.js +++ b/frontend/src/metabase/admin/settings/utils.js @@ -14,6 +14,7 @@ export const settingToFormField = setting => ({ ? t`Using ${setting.env_name}` : setting.placeholder || setting.default, validate: setting.required ? value => !value && "required" : undefined, + autoFocus: setting.autoFocus, }); export const settingToFormFieldId = setting => `setting-${setting.key}`; diff --git a/frontend/src/metabase/plugins/builtin/auth/google.js b/frontend/src/metabase/plugins/builtin/auth/google.js index 8d31ca830f71..b176aee89dd6 100644 --- a/frontend/src/metabase/plugins/builtin/auth/google.js +++ b/frontend/src/metabase/plugins/builtin/auth/google.js @@ -30,6 +30,7 @@ PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => { key: "google-auth-enabled", description: null, + noHeader: true, widget: AuthenticationWidget, getProps: (setting, settings) => ({ authType: "google", diff --git a/frontend/src/metabase/plugins/builtin/auth/ldap.js b/frontend/src/metabase/plugins/builtin/auth/ldap.js index 3910672a4d9b..f7885c7cea13 100644 --- a/frontend/src/metabase/plugins/builtin/auth/ldap.js +++ b/frontend/src/metabase/plugins/builtin/auth/ldap.js @@ -17,6 +17,7 @@ PLUGIN_ADMIN_SETTINGS_UPDATES.push( { key: "ldap-enabled", description: null, + noHeader: true, widget: AuthenticationWidget, getProps: (setting, settings) => ({ authType: "ldap", diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/google.cy.spec.js b/frontend/test/metabase/scenarios/admin/settings/sso/google.cy.spec.js index 2694045c846a..525fb30a210a 100644 --- a/frontend/test/metabase/scenarios/admin/settings/sso/google.cy.spec.js +++ b/frontend/test/metabase/scenarios/admin/settings/sso/google.cy.spec.js @@ -22,7 +22,7 @@ describe("scenarios > admin > settings > SSO > Google", () => { typeAndBlurUsingLabel("Client ID", `example2.${CLIENT_ID_SUFFIX}`); cy.button("Save changes").click(); cy.wait("@updateGoogleSettings"); - cy.findByText("Changes saved!").should("be.visible"); + cy.findByText("Success").should("be.visible"); }); it("should disable google auth (metabase#20442)", () => { diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/jwt.cy.spec.js b/frontend/test/metabase/scenarios/admin/settings/sso/jwt.cy.spec.js index 7a96456c456f..bb757ab4cf03 100644 --- a/frontend/test/metabase/scenarios/admin/settings/sso/jwt.cy.spec.js +++ b/frontend/test/metabase/scenarios/admin/settings/sso/jwt.cy.spec.js @@ -23,17 +23,6 @@ describeEE("scenarios > admin > settings > SSO > JWT", () => { cy.findByRole("switch", { name: "JWT" }).should("be.checked"); }); - it("should allow to save but not enable jwt", () => { - cy.visit("/admin/settings/authentication/jwt"); - - enterJWTSettings(); - cy.button("Save but don't enable").click(); - cy.wait("@updateSettings"); - cy.findAllByRole("link", { name: "Authentication" }).first().click(); - - cy.findByRole("switch", { name: "JWT" }).should("not.be.checked"); - }); - it("should allow to regenerate the jwt key and save the settings", () => { setupJWT(); cy.visit("/admin/settings/authentication/jwt"); @@ -43,7 +32,7 @@ describeEE("scenarios > admin > settings > SSO > JWT", () => { cy.button("Save changes").click(); cy.wait("@updateSettings"); - cy.findByText("Changes saved!").should("exist"); + cy.findByText("Success").should("exist"); }); }); diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js b/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js index 6ac688d23270..d367875b9c5f 100644 --- a/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js +++ b/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js @@ -22,7 +22,7 @@ describe( cy.button("Save and enable").click(); cy.wait("@updateLdapSettings"); - cy.findByText("Changes saved!").should("exist"); + cy.findByText("Success").should("exist"); }); it("should update ldap settings", () => { diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/saml.cy.spec.js b/frontend/test/metabase/scenarios/admin/settings/sso/saml.cy.spec.js new file mode 100644 index 000000000000..c2a07db62e4e --- /dev/null +++ b/frontend/test/metabase/scenarios/admin/settings/sso/saml.cy.spec.js @@ -0,0 +1,75 @@ +import { + restore, + describeEE, + typeAndBlurUsingLabel, +} from "__support__/e2e/helpers"; + +describeEE("scenarios > admin > settings > SSO > SAML", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + cy.intercept("PUT", "/api/setting/*").as("updateSetting"); + cy.intercept("PUT", "/api/setting").as("updateSettings"); + }); + + it("should allow to save and enable saml", () => { + cy.visit("/admin/settings/authentication/saml"); + + enterSAMLSettings(); + cy.button("Save and enable").click(); + cy.wait("@updateSettings"); + cy.findByText("Success").should("exist"); + + cy.findAllByRole("link", { name: "Authentication" }).first().click(); + cy.findByRole("switch", { name: "SAML" }).should("be.checked"); + }); + + it("should allow to update saml settings", () => { + setupSAML(); + cy.visit("/admin/settings/authentication/saml"); + + typeAndBlurUsingLabel("SAML Identity Provider URL", "https://other.test"); + cy.button("Save changes").click(); + cy.wait("@updateSettings"); + cy.findByText("Success").should("exist"); + + cy.findAllByRole("link", { name: "Authentication" }).first().click(); + cy.findByRole("switch", { name: "SAML" }).should("be.checked"); + }); + + it("should allow to enable and disable saml via the toggle", () => { + setupSAML(); + cy.visit("/admin/settings/authentication"); + + cy.findByRole("switch", { name: "SAML" }).click(); + cy.wait("@updateSetting"); + cy.findByText("Saved").should("exist"); + cy.findByRole("switch", { name: "SAML" }).should("not.be.checked"); + + cy.findByRole("switch", { name: "SAML" }).click(); + cy.wait("@updateSetting"); + cy.findByText("Saved").should("exist"); + cy.findByRole("switch", { name: "SAML" }).should("be.checked"); + }); +}); + +const getSAMLCertificate = () => { + return cy.readFile("test_resources/sso/auth0-public-idp.cert", "utf8"); +}; + +const setupSAML = () => { + getSAMLCertificate().then(certificate => { + cy.request("PUT", "/api/setting", { + "saml-enabled": true, + "saml-identity-provider-uri": "https://example.test", + "saml-identity-provider-certificate": certificate, + }); + }); +}; + +const enterSAMLSettings = () => { + getSAMLCertificate().then(certificate => { + typeAndBlurUsingLabel("SAML Identity Provider URL", "https://example.test"); + typeAndBlurUsingLabel("SAML Identity Provider Certificate", certificate); + }); +}; From f82d507df60fed5c736ab98dc1b1ffbcc899c8e7 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Tue, 1 Nov 2022 04:56:37 +0000 Subject: [PATCH 005/269] Fix typo (#26173) (#26188) Co-authored-by: Mahatthana (Kelvin) Nomsawadi --- .../metabase/visualizations/components/ChartSettings.jsx | 4 ++-- .../metabase/visualizations/components/ColumnSettings.jsx | 4 ++-- frontend/src/metabase/visualizations/lib/settings/column.js | 4 ++-- frontend/src/metabase/visualizations/lib/settings/nested.js | 6 +++--- frontend/src/metabase/visualizations/lib/settings/series.js | 4 ++-- .../visualizations/lib/settings/nested.unit.spec.js | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.jsx b/frontend/src/metabase/visualizations/components/ChartSettings.jsx index 70a7e51ab62c..66d8c2f6e692 100644 --- a/frontend/src/metabase/visualizations/components/ChartSettings.jsx +++ b/frontend/src/metabase/visualizations/components/ChartSettings.jsx @@ -24,7 +24,7 @@ import { } from "metabase/visualizations/lib/settings"; import { keyForSingleSeries } from "metabase/visualizations/lib/settings/series"; -import { getSettingDefintionsForColumn } from "metabase/visualizations/lib/settings/column"; +import { getSettingDefinitionsForColumn } from "metabase/visualizations/lib/settings/column"; import { getColumnKey } from "metabase-lib/queries/utils/get-column-key"; import ChartSettingsWidgetList from "./ChartSettingsWidgetList"; @@ -164,7 +164,7 @@ class ChartSettings extends Component { columnHasSettings(col) { const { series } = this.props; const settings = this._getSettings() || {}; - const settingsDefs = getSettingDefintionsForColumn(series, col); + const settingsDefs = getSettingDefinitionsForColumn(series, col); const computedSettings = getComputedSettings(settingsDefs, col, settings); return getSettingsWidgets( diff --git a/frontend/src/metabase/visualizations/components/ColumnSettings.jsx b/frontend/src/metabase/visualizations/components/ColumnSettings.jsx index 23ae3b3a0d84..eaf27b4e75ea 100644 --- a/frontend/src/metabase/visualizations/components/ColumnSettings.jsx +++ b/frontend/src/metabase/visualizations/components/ColumnSettings.jsx @@ -5,7 +5,7 @@ import { t } from "ttag"; import EmptyState from "metabase/components/EmptyState"; -import { getSettingDefintionsForColumn } from "metabase/visualizations/lib/settings/column"; +import { getSettingDefinitionsForColumn } from "metabase/visualizations/lib/settings/column"; import { getSettingsWidgets, getComputedSettings, @@ -31,7 +31,7 @@ function getWidgets({ column = { ...column, unit: "default" }; } - const settingsDefs = getSettingDefintionsForColumn(series, column); + const settingsDefs = getSettingDefinitionsForColumn(series, column); const computedSettings = getComputedSettings( settingsDefs, diff --git a/frontend/src/metabase/visualizations/lib/settings/column.js b/frontend/src/metabase/visualizations/lib/settings/column.js index f995c94c5513..8e14c568061e 100644 --- a/frontend/src/metabase/visualizations/lib/settings/column.js +++ b/frontend/src/metabase/visualizations/lib/settings/column.js @@ -34,7 +34,7 @@ export function columnSettings({ objectName: "column", getObjects: getColumns, getObjectKey: getColumnKey, - getSettingDefintionsForObject: getSettingDefintionsForColumn, + getSettingDefinitionsForObject: getSettingDefinitionsForColumn, component: ChartNestedSettingColumns, getInheritedSettingsForObject: getInhertiedSettingsForColumn, useRawSeries: true, @@ -476,7 +476,7 @@ const COMMON_COLUMN_SETTINGS = { }, }; -export function getSettingDefintionsForColumn(series, column) { +export function getSettingDefinitionsForColumn(series, column) { const { visualization } = getVisualizationRaw(series); const extraColumnSettings = typeof visualization.columnSettings === "function" diff --git a/frontend/src/metabase/visualizations/lib/settings/nested.js b/frontend/src/metabase/visualizations/lib/settings/nested.js index df4986999aff..3cc2774cc473 100644 --- a/frontend/src/metabase/visualizations/lib/settings/nested.js +++ b/frontend/src/metabase/visualizations/lib/settings/nested.js @@ -11,14 +11,14 @@ export function nestedSettings( objectName = "object", getObjects, getObjectKey, - getSettingDefintionsForObject, + getSettingDefinitionsForObject, getInheritedSettingsForObject = () => ({}), component, ...def } = {}, ) { function getComputedSettingsForObject(series, object, storedSettings, extra) { - const settingsDefs = getSettingDefintionsForObject(series, object); + const settingsDefs = getSettingDefinitionsForObject(series, object); const inheritedSettings = getInheritedSettingsForObject(object); const computedSettings = getComputedSettings( settingsDefs, @@ -56,7 +56,7 @@ export function nestedSettings( onChangeSettings, extra, ) { - const settingsDefs = getSettingDefintionsForObject(series, object); + const settingsDefs = getSettingDefinitionsForObject(series, object); const computedSettings = getComputedSettingsForObject( series, object, diff --git a/frontend/src/metabase/visualizations/lib/settings/series.js b/frontend/src/metabase/visualizations/lib/settings/series.js index 7841f0ff5b0a..4a49f0a624e6 100644 --- a/frontend/src/metabase/visualizations/lib/settings/series.js +++ b/frontend/src/metabase/visualizations/lib/settings/series.js @@ -151,7 +151,7 @@ export function seriesSetting({ }, }; - function getSettingDefintionsForSingleSeries(series, object, settings) { + function getSettingDefinitionsForSingleSeries(series, object, settings) { return COMMON_SETTINGS; } @@ -164,7 +164,7 @@ export function seriesSetting({ objectName: "series", getObjects: (series, settings) => series, getObjectKey: keyForSingleSeries, - getSettingDefintionsForObject: getSettingDefintionsForSingleSeries, + getSettingDefinitionsForObject: getSettingDefinitionsForSingleSeries, component: ChartNestedSettingSeries, readDependencies: [colorSettingId, ...readDependencies], noPadding: true, diff --git a/frontend/test/metabase/visualizations/lib/settings/nested.unit.spec.js b/frontend/test/metabase/visualizations/lib/settings/nested.unit.spec.js index fc8782bdcc40..5e515ae04be1 100644 --- a/frontend/test/metabase/visualizations/lib/settings/nested.unit.spec.js +++ b/frontend/test/metabase/visualizations/lib/settings/nested.unit.spec.js @@ -8,7 +8,7 @@ describe("nestedSettings", () => { objectName: "nested", getObjects: () => [1, 2, 3], getObjectKey: object => String(object), - getSettingDefintionsForObject: () => ({ + getSettingDefinitionsForObject: () => ({ foo: { getDefault: object => `foo${object}` }, }), }), From e04c9f53661fc4691c21bea352c99e7012ef3924 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Tue, 1 Nov 2022 10:27:26 +0000 Subject: [PATCH 006/269] Fix filtering by join dimensions (#26156) (#26189) Co-authored-by: Alexander Polyankin --- frontend/src/metabase-lib/Dimension.ts | 36 +++++++++++++ .../metabase-lib/queries/structured/Join.ts | 2 +- .../25990-filter-nested-join.cy.spec.js | 52 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 frontend/test/metabase/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js diff --git a/frontend/src/metabase-lib/Dimension.ts b/frontend/src/metabase-lib/Dimension.ts index bd1e24bd66a7..d481a677cef1 100644 --- a/frontend/src/metabase-lib/Dimension.ts +++ b/frontend/src/metabase-lib/Dimension.ts @@ -410,6 +410,10 @@ export default class Dimension { return this._query; } + setQuery(query: StructuredQuery): Dimension { + return this; + } + sourceDimension() { return this._query && this._query.dimensionForSourceQuery(this); } @@ -731,6 +735,20 @@ export class FieldDimension extends Dimension { Object.freeze(this); } + setQuery(query: StructuredQuery): FieldDimension { + return new FieldDimension( + this._fieldIdOrName, + this._options, + this._metadata, + query, + { + _fieldInstance: this._fieldInstance, + _subDisplayName: this._subDisplayName, + _subTriggerDisplayName: this._subTriggerDisplayName, + }, + ); + } + isEqual(somethingElse) { if (isFieldDimension(somethingElse)) { return ( @@ -1154,6 +1172,15 @@ export class ExpressionDimension extends Dimension { Object.freeze(this); } + setQuery(query: StructuredQuery): ExpressionDimension { + return new ExpressionDimension( + this._expressionName, + this._options, + this._metadata, + query, + ); + } + isEqual(somethingElse) { if (isExpressionDimension(somethingElse)) { return ( @@ -1414,6 +1441,15 @@ export class AggregationDimension extends Dimension { Object.freeze(this); } + setQuery(query: StructuredQuery): AggregationDimension { + return new AggregationDimension( + this._aggregationIndex, + this._options, + this._metadata, + query, + ); + } + aggregationIndex(): number { return this._aggregationIndex; } diff --git a/frontend/src/metabase-lib/queries/structured/Join.ts b/frontend/src/metabase-lib/queries/structured/Join.ts index a87ecefe92e8..ef97f69eb74f 100644 --- a/frontend/src/metabase-lib/queries/structured/Join.ts +++ b/frontend/src/metabase-lib/queries/structured/Join.ts @@ -625,7 +625,7 @@ export default class Join extends MBQLObjectClause { joinedDimension(dimension: Dimension) { if (dimension instanceof FieldDimension) { - return dimension.withJoinAlias(this.alias); + return dimension.withJoinAlias(this.alias).setQuery(this.query()); } console.warn("Don't know how to create joined dimension with:", dimension); diff --git a/frontend/test/metabase/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js b/frontend/test/metabase/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js new file mode 100644 index 000000000000..e311897587a7 --- /dev/null +++ b/frontend/test/metabase/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js @@ -0,0 +1,52 @@ +import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; +import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; + +const { ORDERS, ORDERS_ID, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; + +const questionDetails = { + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-query": { + "source-table": ORDERS_ID, + joins: [ + { + fields: "all", + "source-table": PEOPLE_ID, + condition: [ + "=", + ["field", ORDERS.USER_ID, null], + ["field", PEOPLE.ID, { "join-alias": "People - User" }], + ], + alias: "People - User", + }, + ], + aggregation: [["count"]], + breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }]], + }, + filter: [">", ["field", "count", { "base-type": "type/Integer" }], 0], + }, + }, +}; + +describe("issue 25990", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + cy.intercept("POST", `/api/dataset`).as("dataset"); + }); + + it("should allow to filter by a column in a joined table (metabase#25990)", () => { + visitQuestionAdhoc(questionDetails); + + cy.findByText("Filter").click(); + cy.findByText("People - User").click(); + cy.findByPlaceholderText("Enter an ID").type("10"); + cy.button("Apply Filters").click(); + cy.wait("@dataset"); + + cy.findByText("ID is 10").should("be.visible"); + }); +}); From 4d942ab3298a9d001020951c2e5fd0a3717a36da Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Tue, 1 Nov 2022 12:35:35 +0000 Subject: [PATCH 007/269] Improve forms API (#26181) (#26195) Co-authored-by: Alexander Polyankin --- .../auth/components/LoginForm/LoginForm.tsx | 14 +++---- .../metabase/core/components/Form/Form.tsx | 35 +++++++++++++++++ .../metabase/core/components/Form/index.ts | 2 + .../components/FormProvider/FormProvider.tsx | 11 ++++++ .../core/components/FormProvider/index.ts | 1 + .../FormSubmitButton/FormSubmitButton.tsx | 19 ++++------ .../use-form-error-message.ts | 12 ++---- .../core/hooks/use-form-state/types.ts | 4 +- .../hooks/use-form-state/use-form-state.ts | 6 ++- .../core/hooks/use-form-status/index.ts | 1 - .../hooks/use-form-submit-button/index.ts | 1 + .../use-form-submit-button.ts} | 38 ++++++++++++++----- .../core/hooks/use-form-submit/index.ts | 1 + .../{use-form => use-form-submit}/types.ts | 0 .../use-form-submit.ts} | 8 ++-- .../src/metabase/core/hooks/use-form/index.ts | 1 - 16 files changed, 110 insertions(+), 44 deletions(-) create mode 100644 frontend/src/metabase/core/components/Form/Form.tsx create mode 100644 frontend/src/metabase/core/components/Form/index.ts create mode 100644 frontend/src/metabase/core/components/FormProvider/FormProvider.tsx create mode 100644 frontend/src/metabase/core/components/FormProvider/index.ts delete mode 100644 frontend/src/metabase/core/hooks/use-form-status/index.ts create mode 100644 frontend/src/metabase/core/hooks/use-form-submit-button/index.ts rename frontend/src/metabase/core/hooks/{use-form-status/use-form-status.ts => use-form-submit-button/use-form-submit-button.ts} (51%) create mode 100644 frontend/src/metabase/core/hooks/use-form-submit/index.ts rename frontend/src/metabase/core/hooks/{use-form => use-form-submit}/types.ts (100%) rename frontend/src/metabase/core/hooks/{use-form/use-form.ts => use-form-submit/use-form-submit.ts} (84%) delete mode 100644 frontend/src/metabase/core/hooks/use-form/index.ts diff --git a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx index cfc5dd0464c0..dadff5759427 100644 --- a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx +++ b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx @@ -1,8 +1,8 @@ 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 Form from "metabase/core/components/Form"; +import FormProvider from "metabase/core/components/FormProvider"; import FormCheckBox from "metabase/core/components/FormCheckBox"; import FormErrorMessage from "metabase/core/components/FormErrorMessage"; import FormInput from "metabase/core/components/FormInput"; @@ -37,14 +37,12 @@ const LoginForm = ({ password: "", remember: !hasSessionCookies, }; - const handleSubmit = useForm(onSubmit); - return ( -
)} - + -
+ ); }; diff --git a/frontend/src/metabase/core/components/Form/Form.tsx b/frontend/src/metabase/core/components/Form/Form.tsx new file mode 100644 index 000000000000..00e2d2bbc3a4 --- /dev/null +++ b/frontend/src/metabase/core/components/Form/Form.tsx @@ -0,0 +1,35 @@ +import React, { + FormHTMLAttributes, + forwardRef, + Ref, + SyntheticEvent, +} from "react"; +import { useFormikContext } from "formik"; + +export interface FormProps + extends Omit, "onSubmit" | "onReset"> { + disabled?: boolean; +} + +const Form = forwardRef(function Form( + { disabled, ...props }: FormProps, + ref: Ref, +) { + const { handleSubmit, handleReset } = useFormikContext(); + + return ( +
+ ); +}); + +const handleDisabledEvent = (event: SyntheticEvent) => { + event.preventDefault(); + event.stopPropagation(); +}; + +export default Form; diff --git a/frontend/src/metabase/core/components/Form/index.ts b/frontend/src/metabase/core/components/Form/index.ts new file mode 100644 index 000000000000..d0066d51caf9 --- /dev/null +++ b/frontend/src/metabase/core/components/Form/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Form"; +export type { FormProps } from "./Form"; diff --git a/frontend/src/metabase/core/components/FormProvider/FormProvider.tsx b/frontend/src/metabase/core/components/FormProvider/FormProvider.tsx new file mode 100644 index 000000000000..361469619fff --- /dev/null +++ b/frontend/src/metabase/core/components/FormProvider/FormProvider.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Formik } from "formik"; +import type { FormikConfig } from "formik"; +import useFormSubmit from "metabase/core/hooks/use-form-submit"; + +function FormProvider({ onSubmit, ...props }: FormikConfig): JSX.Element { + const handleSubmit = useFormSubmit(onSubmit); + return ; +} + +export default FormProvider; diff --git a/frontend/src/metabase/core/components/FormProvider/index.ts b/frontend/src/metabase/core/components/FormProvider/index.ts new file mode 100644 index 000000000000..8f9d44981a9d --- /dev/null +++ b/frontend/src/metabase/core/components/FormProvider/index.ts @@ -0,0 +1 @@ +export { default } from "./FormProvider"; diff --git a/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx b/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx index 816a1e4f68f9..f0db016acc67 100644 --- a/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx +++ b/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx @@ -1,9 +1,8 @@ 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"; +import useFormSubmitButton from "metabase/core/hooks/use-form-submit-button"; export interface FormSubmitButtonProps extends Omit { title?: string; @@ -13,30 +12,28 @@ export interface FormSubmitButtonProps extends Omit { } const FormSubmitButton = forwardRef(function FormSubmitButton( - { disabled, ...props }: FormSubmitButtonProps, + { primary, disabled, ...props }: FormSubmitButtonProps, ref: Ref, ) { - const { isValid, isSubmitting } = useFormikContext(); - const status = useFormStatus(); - const submitText = getSubmitButtonText(status, props); - const isEnabled = isValid && !isSubmitting && !disabled; + const { status, isDisabled } = useFormSubmitButton({ isDisabled: disabled }); + const submitTitle = getSubmitButtonTitle(status, props); return ( ); }); -const getSubmitButtonText = ( +const getSubmitButtonTitle = ( status: FormStatus | undefined, { title = t`Submit`, 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 index f77b2d6d6e76..2c913214e507 100644 --- 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 @@ -1,6 +1,5 @@ import { useLayoutEffect, useState } from "react"; import { useFormikContext } from "formik"; -import type { FormikErrors } from "formik"; import { t } from "ttag"; import useFormState from "metabase/core/hooks/use-form-state"; @@ -8,6 +7,7 @@ const useFormErrorMessage = (): string | undefined => { const { values, errors } = useFormikContext(); const { status, message } = useFormState(); const [isVisible, setIsVisible] = useState(false); + const hasErrors = Object.keys(errors).length > 0; useLayoutEffect(() => { setIsVisible(false); @@ -17,13 +17,9 @@ const useFormErrorMessage = (): string | undefined => { setIsVisible(status === "rejected"); }, [status]); - return isVisible ? getFormErrorMessage(errors, message) : undefined; -}; - -const getFormErrorMessage = (errors: FormikErrors, message?: string) => { - const hasErrors = Object.keys(errors).length > 0; - - if (message) { + if (!isVisible) { + return undefined; + } else if (message) { return message; } else if (!hasErrors) { return t`An error occurred`; diff --git a/frontend/src/metabase/core/hooks/use-form-state/types.ts b/frontend/src/metabase/core/hooks/use-form-state/types.ts index f8cd0fb25c90..97088e2d977f 100644 --- a/frontend/src/metabase/core/hooks/use-form-state/types.ts +++ b/frontend/src/metabase/core/hooks/use-form-state/types.ts @@ -1,6 +1,6 @@ -export type FormStatus = "pending" | "fulfilled" | "rejected"; +export type FormStatus = "idle" | "pending" | "fulfilled" | "rejected"; export interface FormState { - status?: FormStatus; + 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 index 46e50c29be40..14bca0a64e3f 100644 --- 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 @@ -1,9 +1,13 @@ import { useFormikContext } from "formik"; import { FormState } from "./types"; +const DEFAULT_STATE: FormState = { + status: "idle", +}; + const useFormState = (): FormState => { const { status } = useFormikContext(); - return status ?? {}; + return status ?? DEFAULT_STATE; }; 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 deleted file mode 100644 index aaf24961a285..000000000000 --- a/frontend/src/metabase/core/hooks/use-form-status/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./use-form-status"; diff --git a/frontend/src/metabase/core/hooks/use-form-submit-button/index.ts b/frontend/src/metabase/core/hooks/use-form-submit-button/index.ts new file mode 100644 index 000000000000..bb4454175944 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-submit-button/index.ts @@ -0,0 +1 @@ +export { default } from "./use-form-submit-button"; diff --git a/frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts b/frontend/src/metabase/core/hooks/use-form-submit-button/use-form-submit-button.ts similarity index 51% rename from frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts rename to frontend/src/metabase/core/hooks/use-form-submit-button/use-form-submit-button.ts index 0b0eff0edc12..00661433ab14 100644 --- a/frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts +++ b/frontend/src/metabase/core/hooks/use-form-submit-button/use-form-submit-button.ts @@ -1,19 +1,29 @@ import { useEffect, useLayoutEffect, useState } from "react"; +import { useFormikContext } from "formik"; import useFormState, { FormStatus } from "metabase/core/hooks/use-form-state"; const STATUS_TIMEOUT = 5000; -const useFormStatus = (): FormStatus | undefined => { +export interface UseFormSubmitButtonProps { + isDisabled?: boolean; +} + +export interface UseFormSubmitButtonResult { + status: FormStatus; + isDisabled: boolean; +} + +const useFormSubmitButton = ({ + isDisabled = false, +}: UseFormSubmitButtonProps): UseFormSubmitButtonResult => { + const { isValid, isSubmitting } = useFormikContext(); const { status } = useFormState(); const isRecent = useIsRecent(status, STATUS_TIMEOUT); - switch (status) { - case "pending": - return status; - case "fulfilled": - case "rejected": - return isRecent ? status : undefined; - } + return { + status: getFormStatus(status, isRecent), + isDisabled: !isValid || isSubmitting || isDisabled, + }; }; const useIsRecent = (value: unknown, timeout: number) => { @@ -31,4 +41,14 @@ const useIsRecent = (value: unknown, timeout: number) => { return isRecent; }; -export default useFormStatus; +const getFormStatus = (status: FormStatus, isRecent: boolean): FormStatus => { + switch (status) { + case "fulfilled": + case "rejected": + return isRecent ? status : "idle"; + default: + return status; + } +}; + +export default useFormSubmitButton; diff --git a/frontend/src/metabase/core/hooks/use-form-submit/index.ts b/frontend/src/metabase/core/hooks/use-form-submit/index.ts new file mode 100644 index 000000000000..874d748ee2d8 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-submit/index.ts @@ -0,0 +1 @@ +export { default } from "./use-form-submit"; diff --git a/frontend/src/metabase/core/hooks/use-form/types.ts b/frontend/src/metabase/core/hooks/use-form-submit/types.ts similarity index 100% rename from frontend/src/metabase/core/hooks/use-form/types.ts rename to frontend/src/metabase/core/hooks/use-form-submit/types.ts diff --git a/frontend/src/metabase/core/hooks/use-form/use-form.ts b/frontend/src/metabase/core/hooks/use-form-submit/use-form-submit.ts similarity index 84% rename from frontend/src/metabase/core/hooks/use-form/use-form.ts rename to frontend/src/metabase/core/hooks/use-form-submit/use-form-submit.ts index 495ad8fe68e0..8d66d8e97e1a 100644 --- a/frontend/src/metabase/core/hooks/use-form/use-form.ts +++ b/frontend/src/metabase/core/hooks/use-form-submit/use-form-submit.ts @@ -2,12 +2,14 @@ import { useCallback } from "react"; import type { FormikHelpers } from "formik"; import { FormError } from "./types"; -const useForm = (onSubmit: (data: T) => void) => { +const useFormSubmit = ( + onSubmit: (data: T, helpers: FormikHelpers) => void, +) => { return useCallback( async (data: T, helpers: FormikHelpers) => { try { helpers.setStatus({ status: "pending" }); - await onSubmit(data); + await onSubmit(data, helpers); helpers.setStatus({ status: "fulfilled" }); } catch (error) { helpers.setErrors(getFormErrors(error)); @@ -33,4 +35,4 @@ const getFormMessage = (error: unknown) => { return isFormError(error) ? error.data?.message ?? error.message : undefined; }; -export default useForm; +export default useFormSubmit; diff --git a/frontend/src/metabase/core/hooks/use-form/index.ts b/frontend/src/metabase/core/hooks/use-form/index.ts deleted file mode 100644 index fc6c71c16f1a..000000000000 --- a/frontend/src/metabase/core/hooks/use-form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./use-form"; From 8ce907c9ca0ca41f01f0ff99a7a1780cd9124580 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Tue, 1 Nov 2022 16:04:26 +0000 Subject: [PATCH 008/269] Fix google sign in (#26197) (#26199) Co-authored-by: Alexander Polyankin --- .../components/GoogleButton/GoogleButton.tsx | 2 +- frontend/src/metabase/oauth/GoogleLogin.tsx | 106 ++++++ .../metabase/oauth/GoogleOAuthProvider.tsx | 54 +++ .../metabase/oauth/google-auth-window.d.ts | 45 +++ frontend/src/metabase/oauth/googleLogout.ts | 3 + .../oauth/hasGrantedAllScopesGoogle.ts | 20 ++ .../oauth/hasGrantedAnyScopeGoogle.ts | 20 ++ .../metabase/oauth/hooks/useGoogleLogin.ts | 115 ++++++ .../oauth/hooks/useGoogleOneTapLogin.ts | 70 ++++ .../metabase/oauth/hooks/useLoadGsiScript.ts | 50 +++ frontend/src/metabase/oauth/index.ts | 14 + frontend/src/metabase/oauth/types/index.ts | 335 ++++++++++++++++++ frontend/src/metabase/oauth/utils/index.ts | 24 ++ package.json | 1 - yarn.lock | 5 - 15 files changed, 857 insertions(+), 7 deletions(-) create mode 100644 frontend/src/metabase/oauth/GoogleLogin.tsx create mode 100644 frontend/src/metabase/oauth/GoogleOAuthProvider.tsx create mode 100644 frontend/src/metabase/oauth/google-auth-window.d.ts create mode 100644 frontend/src/metabase/oauth/googleLogout.ts create mode 100644 frontend/src/metabase/oauth/hasGrantedAllScopesGoogle.ts create mode 100644 frontend/src/metabase/oauth/hasGrantedAnyScopeGoogle.ts create mode 100644 frontend/src/metabase/oauth/hooks/useGoogleLogin.ts create mode 100644 frontend/src/metabase/oauth/hooks/useGoogleOneTapLogin.ts create mode 100644 frontend/src/metabase/oauth/hooks/useLoadGsiScript.ts create mode 100644 frontend/src/metabase/oauth/index.ts create mode 100644 frontend/src/metabase/oauth/types/index.ts create mode 100644 frontend/src/metabase/oauth/utils/index.ts diff --git a/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx b/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx index 41be42914f42..d02d3edd50ac 100644 --- a/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx +++ b/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useState } from "react"; -import { GoogleOAuthProvider, GoogleLogin } from "@react-oauth/google"; import { t } from "ttag"; import { getIn } from "icepick"; import * as Urls from "metabase/lib/urls"; +import { GoogleOAuthProvider, GoogleLogin } from "metabase/oauth"; import { AuthError, AuthErrorContainer, TextLink } from "./GoogleButton.styled"; export interface GoogleButtonProps { diff --git a/frontend/src/metabase/oauth/GoogleLogin.tsx b/frontend/src/metabase/oauth/GoogleLogin.tsx new file mode 100644 index 000000000000..78737e178460 --- /dev/null +++ b/frontend/src/metabase/oauth/GoogleLogin.tsx @@ -0,0 +1,106 @@ +/* eslint-disable */ +import React, { useEffect, useRef } from "react"; + +import { useGoogleOAuth } from "./GoogleOAuthProvider"; +import { extractClientId } from "./utils"; +import { + IdConfiguration, + CredentialResponse, + GoogleCredentialResponse, + MomenListener, + GsiButtonConfiguration, +} from "./types"; + +const containerHeightMap = { large: 40, medium: 32, small: 20 }; + +export type GoogleLoginProps = { + onSuccess: (credentialResponse: CredentialResponse) => void; + onError?: () => void; + promptMomentNotification?: MomenListener; + useOneTap?: boolean; +} & Omit & + GsiButtonConfiguration; + +export default function GoogleLogin({ + onSuccess, + onError, + useOneTap, + promptMomentNotification, + type = "standard", + theme = "outline", + size = "large", + text, + shape, + logo_alignment, + width, + locale, + ...props +}: GoogleLoginProps) { + const btnContainerRef = useRef(null); + const { clientId, scriptLoadedSuccessfully } = useGoogleOAuth(); + + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + + const promptMomentNotificationRef = useRef(promptMomentNotification); + promptMomentNotificationRef.current = promptMomentNotification; + + useEffect(() => { + if (!scriptLoadedSuccessfully) return; + + window.google?.accounts.id.initialize({ + client_id: clientId, + callback: (credentialResponse: GoogleCredentialResponse) => { + if (!credentialResponse?.credential) { + return onErrorRef.current?.(); + } + + const { credential, select_by } = credentialResponse; + onSuccessRef.current({ + credential, + clientId: extractClientId(credentialResponse), + select_by, + }); + }, + ...props, + }); + + window.google?.accounts.id.renderButton(btnContainerRef.current!, { + type, + theme, + size, + text, + shape, + logo_alignment, + width, + locale, + }); + + if (useOneTap) + window.google?.accounts.id.prompt(promptMomentNotificationRef.current); + + return () => { + if (useOneTap) window.google?.accounts.id.cancel(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + clientId, + scriptLoadedSuccessfully, + useOneTap, + type, + theme, + size, + text, + shape, + logo_alignment, + width, + locale, + ]); + + return ( +
+ ); +} diff --git a/frontend/src/metabase/oauth/GoogleOAuthProvider.tsx b/frontend/src/metabase/oauth/GoogleOAuthProvider.tsx new file mode 100644 index 000000000000..009811cfbb68 --- /dev/null +++ b/frontend/src/metabase/oauth/GoogleOAuthProvider.tsx @@ -0,0 +1,54 @@ +/* eslint-disable */ +import React, { useContext, createContext, useMemo, ReactNode } from "react"; + +import useLoadGsiScript, { + UseLoadGsiScriptOptions, +} from "./hooks/useLoadGsiScript"; + +interface GoogleOAuthContextProps { + clientId: string; + scriptLoadedSuccessfully: boolean; +} + +const GoogleOAuthContext = createContext(null!); + +interface GoogleOAuthProviderProps extends UseLoadGsiScriptOptions { + clientId: string; + children: ReactNode; +} + +export default function GoogleOAuthProvider({ + clientId, + onScriptLoadSuccess, + onScriptLoadError, + children, +}: GoogleOAuthProviderProps) { + const scriptLoadedSuccessfully = useLoadGsiScript({ + onScriptLoadSuccess, + onScriptLoadError, + }); + + const contextValue = useMemo( + () => ({ + clientId, + scriptLoadedSuccessfully, + }), + [clientId, scriptLoadedSuccessfully], + ); + + return ( + + {children} + + ); +} + +export function useGoogleOAuth() { + const context = useContext(GoogleOAuthContext); + if (!context) { + throw new Error( + "Google OAuth components must be used within GoogleOAuthProvider", + ); + } + return context; +} diff --git a/frontend/src/metabase/oauth/google-auth-window.d.ts b/frontend/src/metabase/oauth/google-auth-window.d.ts new file mode 100644 index 000000000000..59521a101674 --- /dev/null +++ b/frontend/src/metabase/oauth/google-auth-window.d.ts @@ -0,0 +1,45 @@ +/* eslint-disable */ +interface Window { + google?: { + accounts: { + id: { + initialize: (input: IdConfiguration) => void; + prompt: (momentListener?: MomenListener) => void; + renderButton: ( + parent: HTMLElement, + options: GsiButtonConfiguration, + clickHandler?: () => void, + ) => void; + disableAutoSelect: () => void; + storeCredential: ( + credential: { id: string; password: string }, + callback?: () => void, + ) => void; + cancel: () => void; + onGoogleLibraryLoad: Function; + revoke: (accessToken: string, done: () => void) => void; + }; + oauth2: { + initTokenClient: (config: TokenClientConfig) => { + requestAccessToken: ( + overridableClientConfig?: OverridableTokenClientConfig, + ) => void; + }; + initCodeClient: (config: CodeClientConfig) => { + requestCode: () => void; + }; + hasGrantedAnyScope: ( + tokenRsponse: TokenResponse, + firstScope: string, + ...restScopes: string[] + ) => boolean; + hasGrantedAllScopes: ( + tokenRsponse: TokenResponse, + firstScope: string, + ...restScopes: string[] + ) => boolean; + revoke: (accessToken: string, done?: () => void) => void; + }; + }; + }; +} diff --git a/frontend/src/metabase/oauth/googleLogout.ts b/frontend/src/metabase/oauth/googleLogout.ts new file mode 100644 index 000000000000..68f14167568e --- /dev/null +++ b/frontend/src/metabase/oauth/googleLogout.ts @@ -0,0 +1,3 @@ +export default function googleLogout() { + window.google?.accounts.id.disableAutoSelect(); +} diff --git a/frontend/src/metabase/oauth/hasGrantedAllScopesGoogle.ts b/frontend/src/metabase/oauth/hasGrantedAllScopesGoogle.ts new file mode 100644 index 000000000000..fa2209888119 --- /dev/null +++ b/frontend/src/metabase/oauth/hasGrantedAllScopesGoogle.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +import { TokenResponse } from "./types"; + +/** + * Checks if the user granted all the specified scope or scopes + * @returns True if all the scopes are granted + */ +export default function hasGrantedAllScopesGoogle( + tokenResponse: TokenResponse, + firstScope: string, + ...restScopes: string[] +): boolean { + if (!window.google) return false; + + return window.google.accounts.oauth2.hasGrantedAllScopes( + tokenResponse, + firstScope, + ...restScopes, + ); +} diff --git a/frontend/src/metabase/oauth/hasGrantedAnyScopeGoogle.ts b/frontend/src/metabase/oauth/hasGrantedAnyScopeGoogle.ts new file mode 100644 index 000000000000..1a18c790a773 --- /dev/null +++ b/frontend/src/metabase/oauth/hasGrantedAnyScopeGoogle.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +import { TokenResponse } from "./types"; + +/** + * Checks if the user granted any of the specified scope or scopes. + * @returns True if any of the scopes are granted + */ +export default function hasGrantedAnyScopeGoogle( + tokenResponse: TokenResponse, + firstScope: string, + ...restScopes: string[] +): boolean { + if (!window.google) return false; + + return window.google.accounts.oauth2.hasGrantedAnyScope( + tokenResponse, + firstScope, + ...restScopes, + ); +} diff --git a/frontend/src/metabase/oauth/hooks/useGoogleLogin.ts b/frontend/src/metabase/oauth/hooks/useGoogleLogin.ts new file mode 100644 index 000000000000..80c61450f5a3 --- /dev/null +++ b/frontend/src/metabase/oauth/hooks/useGoogleLogin.ts @@ -0,0 +1,115 @@ +/* eslint-disable */ +import { useCallback, useEffect, useRef } from "react"; + +import { useGoogleOAuth } from "../GoogleOAuthProvider"; +import { + TokenClientConfig, + TokenResponse, + CodeClientConfig, + CodeResponse, + OverridableTokenClientConfig, +} from "../types"; + +interface ImplicitFlowOptions + extends Omit { + onSuccess?: ( + tokenResponse: Omit< + TokenResponse, + "error" | "error_description" | "error_uri" + >, + ) => void; + onError?: ( + errorResponse: Pick< + TokenResponse, + "error" | "error_description" | "error_uri" + >, + ) => void; + scope?: TokenClientConfig["scope"]; +} + +interface AuthCodeFlowOptions + extends Omit { + onSuccess?: ( + codeResponse: Omit< + CodeResponse, + "error" | "error_description" | "error_uri" + >, + ) => void; + onError?: ( + errorResponse: Pick< + CodeResponse, + "error" | "error_description" | "error_uri" + >, + ) => void; + scope?: CodeResponse["scope"]; +} + +export type UseGoogleLoginOptionsImplicitFlow = { + flow?: "implicit"; +} & ImplicitFlowOptions; + +export type UseGoogleLoginOptionsAuthCodeFlow = { + flow?: "auth-code"; +} & AuthCodeFlowOptions; + +export type UseGoogleLoginOptions = + | UseGoogleLoginOptionsImplicitFlow + | UseGoogleLoginOptionsAuthCodeFlow; + +export default function useGoogleLogin( + options: UseGoogleLoginOptionsImplicitFlow, +): (overrideConfig?: OverridableTokenClientConfig) => void; +export default function useGoogleLogin( + options: UseGoogleLoginOptionsAuthCodeFlow, +): () => void; + +export default function useGoogleLogin({ + flow = "implicit", + scope = "", + onSuccess, + onError, + ...props +}: UseGoogleLoginOptions): unknown { + const { clientId, scriptLoadedSuccessfully } = useGoogleOAuth(); + const clientRef = useRef(); + + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + + useEffect(() => { + if (!scriptLoadedSuccessfully) return; + + const clientMethod = + flow === "implicit" ? "initTokenClient" : "initCodeClient"; + + const client = window.google?.accounts.oauth2[clientMethod]({ + client_id: clientId, + scope: `openid profile email ${scope}`, + callback: (response: TokenResponse | CodeResponse) => { + if (response.error) return onErrorRef.current?.(response); + + onSuccessRef.current?.(response as any); + }, + ...props, + }); + + clientRef.current = client; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clientId, scriptLoadedSuccessfully, flow, scope]); + + const loginImplicitFlow = useCallback( + (overrideConfig?: OverridableTokenClientConfig) => + clientRef.current.requestAccessToken(overrideConfig), + [], + ); + + const loginAuthCodeFlow = useCallback( + () => clientRef.current.requestCode(), + [], + ); + + return flow === "implicit" ? loginImplicitFlow : loginAuthCodeFlow; +} diff --git a/frontend/src/metabase/oauth/hooks/useGoogleOneTapLogin.ts b/frontend/src/metabase/oauth/hooks/useGoogleOneTapLogin.ts new file mode 100644 index 000000000000..eaf5722dbf50 --- /dev/null +++ b/frontend/src/metabase/oauth/hooks/useGoogleOneTapLogin.ts @@ -0,0 +1,70 @@ +/* eslint-disable */ +import { useEffect, useRef } from "react"; + +import { useGoogleOAuth } from "../GoogleOAuthProvider"; +import { extractClientId } from "../utils"; +import { + CredentialResponse, + GoogleCredentialResponse, + MomenListener, +} from "../types"; + +interface UseGoogleOneTapLoginOptions { + onSuccess: (credentialResponse: CredentialResponse) => void; + onError?: () => void; + promptMomentNotification?: MomenListener; + cancel_on_tap_outside?: boolean; + hosted_domain?: string; +} + +export default function useGoogleOneTapLogin({ + onSuccess, + onError, + promptMomentNotification, + cancel_on_tap_outside, + hosted_domain, +}: UseGoogleOneTapLoginOptions): void { + const { clientId, scriptLoadedSuccessfully } = useGoogleOAuth(); + + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + + const promptMomentNotificationRef = useRef(promptMomentNotification); + promptMomentNotificationRef.current = promptMomentNotification; + + useEffect(() => { + if (!scriptLoadedSuccessfully) return; + + window.google?.accounts.id.initialize({ + client_id: clientId, + callback: (credentialResponse: GoogleCredentialResponse) => { + if (!credentialResponse?.credential) { + return onErrorRef.current?.(); + } + + const { credential, select_by } = credentialResponse; + onSuccessRef.current({ + credential, + clientId: extractClientId(credentialResponse), + select_by, + }); + }, + hosted_domain, + cancel_on_tap_outside, + }); + + window.google?.accounts.id.prompt(promptMomentNotificationRef.current); + + return () => { + window.google?.accounts.id.cancel(); + }; + }, [ + clientId, + scriptLoadedSuccessfully, + cancel_on_tap_outside, + hosted_domain, + ]); +} diff --git a/frontend/src/metabase/oauth/hooks/useLoadGsiScript.ts b/frontend/src/metabase/oauth/hooks/useLoadGsiScript.ts new file mode 100644 index 000000000000..6f8e271a5f67 --- /dev/null +++ b/frontend/src/metabase/oauth/hooks/useLoadGsiScript.ts @@ -0,0 +1,50 @@ +import { useState, useEffect, useRef } from "react"; + +export interface UseLoadGsiScriptOptions { + /** + * Callback fires on load [gsi](https://accounts.google.com/gsi/client) script success + */ + onScriptLoadSuccess?: () => void; + /** + * Callback fires on load [gsi](https://accounts.google.com/gsi/client) script failure + */ + onScriptLoadError?: () => void; +} + +export default function useLoadGsiScript( + options: UseLoadGsiScriptOptions = {}, +): boolean { + const { onScriptLoadSuccess, onScriptLoadError } = options; + + const [scriptLoadedSuccessfully, setScriptLoadedSuccessfully] = + useState(false); + + const onScriptLoadSuccessRef = useRef(onScriptLoadSuccess); + onScriptLoadSuccessRef.current = onScriptLoadSuccess; + + const onScriptLoadErrorRef = useRef(onScriptLoadError); + onScriptLoadErrorRef.current = onScriptLoadError; + + useEffect(() => { + const scriptTag = document.createElement("script"); + scriptTag.src = "https://accounts.google.com/gsi/client"; + scriptTag.async = true; + scriptTag.defer = true; + scriptTag.onload = () => { + setScriptLoadedSuccessfully(true); + onScriptLoadSuccessRef.current?.(); + }; + scriptTag.onerror = () => { + setScriptLoadedSuccessfully(false); + onScriptLoadErrorRef.current?.(); + }; + + document.body.appendChild(scriptTag); + + return () => { + document.body.removeChild(scriptTag); + }; + }, []); + + return scriptLoadedSuccessfully; +} diff --git a/frontend/src/metabase/oauth/index.ts b/frontend/src/metabase/oauth/index.ts new file mode 100644 index 000000000000..48ecf8faafe1 --- /dev/null +++ b/frontend/src/metabase/oauth/index.ts @@ -0,0 +1,14 @@ +export { default as GoogleOAuthProvider } from "./GoogleOAuthProvider"; +export { default as GoogleLogin } from "./GoogleLogin"; +export type { GoogleLoginProps } from "./GoogleLogin"; +export { default as googleLogout } from "./googleLogout"; +export { default as useGoogleLogin } from "./hooks/useGoogleLogin"; +export type { + UseGoogleLoginOptions, + UseGoogleLoginOptionsAuthCodeFlow, + UseGoogleLoginOptionsImplicitFlow, +} from "./hooks/useGoogleLogin"; +export { default as useGoogleOneTapLogin } from "./hooks/useGoogleOneTapLogin"; +export { default as hasGrantedAllScopesGoogle } from "./hasGrantedAllScopesGoogle"; +export { default as hasGrantedAnyScopeGoogle } from "./hasGrantedAnyScopeGoogle"; +export * from "./types"; diff --git a/frontend/src/metabase/oauth/types/index.ts b/frontend/src/metabase/oauth/types/index.ts new file mode 100644 index 000000000000..697adbc1f433 --- /dev/null +++ b/frontend/src/metabase/oauth/types/index.ts @@ -0,0 +1,335 @@ +type Context = "signin" | "signup" | "use"; + +type UxMode = "popup" | "redirect"; + +type ErrorCode = + | "invalid_request" + | "access_denied" + | "unauthorized_client" + | "unsupported_response_type" + | "invalid_scope" + | "server_error" + | "temporarily_unavailable"; + +export interface IdConfiguration { + /** Your application's client ID */ + client_id?: string; + /** Enables automatic selection on Google One Tap */ + auto_select?: boolean; + /** ID token callback handler */ + callback?: (credentialResponse: CredentialResponse) => void; + /** The Sign In With Google button UX flow */ + ux_mode?: UxMode; + /** The URL of your login endpoint */ + login_uri?: string; + /** The URL of your password credential handler endpoint */ + native_login_uri?: string; + /** The JavaScript password credential handler function name */ + native_callback?: (response: { id: string; password: string }) => void; + /** Controls whether to cancel the prompt if the user clicks outside of the prompt */ + cancel_on_tap_outside?: boolean; + /** The DOM ID of the One Tap prompt container element */ + prompt_parent_id?: string; + /** A random string for ID tokens */ + nonce?: string; + /** The title and words in the One Tap prompt */ + context?: Context; + /** If you need to call One Tap in the parent domain and its subdomains, pass the parent domain to this attribute so that a single shared cookie is used. */ + state_cookie_domain?: string; + /** The origins that are allowed to embed the intermediate iframe. One Tap will run in the intermediate iframe mode if this attribute presents */ + allowed_parent_origin?: string | string[]; + /** Overrides the default intermediate iframe behavior when users manually close One Tap */ + intermediate_iframe_close_callback?: () => void; + /** Enables upgraded One Tap UX on ITP browsers */ + itp_support?: boolean; + /** + * If your application knows the Workspace domain the user belongs to, + * use this to provide a hint to Google. For more information, + * see the [hd](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) + * field in the OpenID Connect docs. + */ + hosted_domain?: string; +} + +export interface CredentialResponse { + /** This field is the returned ID token */ + credential?: string; + /** This field sets how the credential is selected */ + select_by?: + | "auto" + | "user" + | "user_1tap" + | "user_2tap" + | "btn" + | "btn_confirm" + | "brn_add_session" + | "btn_confirm_add_session"; + clientId?: string; +} + +export interface GoogleCredentialResponse extends CredentialResponse { + client_id?: string; +} + +export interface GsiButtonConfiguration { + /** The button [type](https://developers.google.com/identity/gsi/web/reference/js-reference#type): icon, or standard button */ + type?: "standard" | "icon"; + /** The button [theme](https://developers.google.com/identity/gsi/web/reference/js-reference#theme). For example, filled_blue or filled_black */ + theme?: "outline" | "filled_blue" | "filled_black"; + /** The button [size](https://developers.google.com/identity/gsi/web/reference/js-reference#size). For example, small or large */ + size?: "large" | "medium" | "small"; + /** The button [text](https://developers.google.com/identity/gsi/web/reference/js-reference#text). For example, "Sign in with Google" or "Sign up with Google" */ + text?: "signin_with" | "signup_with" | "continue_with" | "signin"; + /** The button [shape](https://developers.google.com/identity/gsi/web/reference/js-reference#shape). For example, rectangular or circular */ + shape?: "rectangular" | "pill" | "circle" | "square"; + /** The Google [logo alignment](https://developers.google.com/identity/gsi/web/reference/js-reference#logo_alignment): left or center */ + logo_alignment?: "left" | "center"; + /** The button [width](https://developers.google.com/identity/gsi/web/reference/js-reference#width), in pixels */ + width?: string; + /** If set, then the button [language](https://developers.google.com/identity/gsi/web/reference/js-reference#locale) is rendered */ + locale?: string; +} + +export interface PromptMomentNotification { + /** Is this notification for a display moment? */ + isDisplayMoment: () => boolean; + /** Is this notification for a display moment, and the UI is displayed? */ + isDisplayed: () => boolean; + /** Is this notification for a display moment, and the UI isn't displayed? */ + isNotDisplayed: () => boolean; + /** The detailed reason why the UI isn't displayed */ + getNotDisplayedReason: () => + | "browser_not_supported" + | "invalid_client" + | "missing_client_id" + | "opt_out_or_no_session" + | "secure_http_required" + | "suppressed_by_user" + | "unregistered_origin" + | "unknown_reason"; + /** Is this notification for a skipped moment? */ + isSkippedMoment: () => boolean; + /** The detailed reason for the skipped moment */ + getSkippedReason: () => + | "auto_cancel" + | "user_cancel" + | "tap_outside" + | "issuing_failed"; + /** Is this notification for a dismissed moment? */ + isDismissedMoment: () => boolean; + /** The detailed reason for the dismissa */ + getDismissedReason: () => + | "credential_returned" + | "cancel_called" + | "flow_restarted"; + /** Return a string for the moment type */ + getMomentType: () => "display" | "skipped" | "dismissed"; +} + +export interface TokenResponse { + /** The access token of a successful token response. */ + access_token: string; + + /** The lifetime in seconds of the access token. */ + expires_in: number; + + /** The hosted domain the signed-in user belongs to. */ + hd?: string; + + /** The prompt value that was used from the possible list of values specified by TokenClientConfig or OverridableTokenClientConfig */ + prompt: string; + + /** The type of the token issued. */ + token_type: string; + + /** A space-delimited list of scopes that are approved by the user. */ + scope: string; + + /** The string value that your application uses to maintain state between your authorization request and the response. */ + state?: string; + + /** A single ASCII error code. */ + error?: ErrorCode; + + /** Human-readable ASCII text providing additional information, used to assist the client developer in understanding the error that occurred. */ + error_description?: string; + + /** A URI identifying a human-readable web page with information about the error, used to provide the client developer with additional information about the error. */ + error_uri?: string; +} + +export interface TokenClientConfig { + /** + * The client ID for your application. You can find this value in the + * [API Console](https://console.cloud.google.com/apis/dashboard) + */ + client_id: string; + + /** + * A space-delimited list of scopes that identify the resources + * that your application could access on the user's behalf. + * These values inform the consent screen that Google displays to the user + */ + scope: string; + + /** + * Required for popup UX. The JavaScript function name that handles returned code response + * The property will be ignored by the redirect UX + */ + callback?: (response: TokenResponse) => void; + + /** + * Optional, defaults to 'select_account'. A space-delimited, case-sensitive list of prompts to present the user + */ + prompt?: "" | "none" | "consent" | "select_account"; + + /** + * Optional, defaults to true. If set to false, + * [more granular Google Account permissions](https://developers.googleblog.com/2018/10/more-granular-google-account.html) + * will be disabled for clients created before 2019. No effect for newer clients, + * since more granular permissions is always enabled for them. + */ + enable_serial_consent?: boolean; + + /** + * Optional. If your application knows which user should authorize the request, + * it can use this property to provide a hint to Google. + * The email address for the target user. For more information, + * see the [login_hint](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) field in the OpenID Connect docs. + */ + hint?: string; + + /** + * Optional. If your application knows the Workspace domain the user belongs to, + * use this to provide a hint to Google. For more information, + * see the [hd](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) + * field in the OpenID Connect docs. + */ + hosted_domain?: string; + + /** + * Optional. Not recommended. Specifies any string value that + * your application uses to maintain state between your authorization + * request and the authorization server's response. + */ + state?: string; +} + +export interface OverridableTokenClientConfig { + /** + * Optional. A space-delimited, case-sensitive list of prompts to present the user. + */ + prompt?: string; + + /** + * Optional. If set to false, + * [more granular Google Account permissions](https://developers.googleblog.com/2018/10/more-granular-google-account.html) + * will be disabled for clients created before 2019. + * No effect for newer clients, since more granular permissions is always enabled for them. + */ + enable_serial_consent?: boolean; + + /** + * Optional. If your application knows which user should authorize the request, + * it can use this property to provide a hint to Google. + * The email address for the target user. For more information, + * see the [login_hint](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) field in the OpenID Connect docs. + */ + hint?: string; + + /** + * Optional. Not recommended. Specifies any string value that your + * application uses to maintain state between your authorization request + * and the authorization server's response. + */ + state?: string; +} + +export interface CodeResponse { + /** The authorization code of a successful token response */ + code: string; + /** A space-delimited list of scopes that are approved by the user */ + scope: string; + /** The string value that your application uses to maintain state between your authorization request and the response */ + state?: string; + /** A single ASCII error code */ + error?: ErrorCode; + /** Human-readable ASCII text providing additional information, used to assist the client developer in understanding the error that occurred */ + error_description?: string; + /** A URI identifying a human-readable web page with information about the error, used to provide the client developer with additional information about the error */ + error_uri?: string; +} + +export interface CodeClientConfig { + /** + * Required. The client ID for your application. You can find this value in the + * [API Console](https://console.developers.google.com/) + */ + client_id: string; + + /** + * Required. A space-delimited list of scopes that identify + * the resources that your application could access on the user's behalf. + * These values inform the consent screen that Google displays to the user + */ + scope: string; + + /** + * Required for redirect UX. Determines where the API server redirects + * the user after the user completes the authorization flow. + * The value must exactly match one of the authorized redirect URIs for the OAuth 2.0 client, + * which you configured in the API Console and must conform to our + * [Redirect URI validation](https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation) rules. The property will be ignored by the popup UX + */ + redirect_uri?: string; + + /** + * Required for popup UX. The JavaScript function name that handles + * returned code response. The property will be ignored by the redirect UX + */ + callback?: (codeResponse: CodeResponse) => void; + + /** + * Optional. Recommended for redirect UX. Specifies any string value that + * your application uses to maintain state between your authorization request and the authorization server's response + */ + state?: string; + + /** + * Optional, defaults to true. If set to false, + * [more granular Google Account permissions](https://developers.googleblog.com/2018/10/more-granular-google-account.html) + * will be disabled for clients created before 2019. No effect for newer clients, since + * more granular permissions is always enabled for them + */ + enable_serial_consent?: boolean; + + /** + * Optional. If your application knows which user should authorize the request, + * it can use this property to provide a hint to Google. + * The email address for the target user. For more information, + * see the [login_hint](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) field in the OpenID Connect docs + */ + hint?: string; + + /** + * Optional. If your application knows the Workspace domain + * the user belongs to, use this to provide a hint to Google. + * For more information, see the [hd](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) field in the OpenID Connect docs + */ + hosted_domain?: string; + + /** + * Optional. The UX mode to use for the authorization flow. + * By default, it will open the consent flow in a popup. Valid values are popup and redirect + */ + ux_mode?: "popup" | "redirect"; + + /** + * Optional, defaults to 'false'. Boolean value to prompt the user to select an account + */ + select_account?: boolean; +} + +export type MomenListener = ( + promptMomentNotification: PromptMomentNotification, +) => void; diff --git a/frontend/src/metabase/oauth/utils/index.ts b/frontend/src/metabase/oauth/utils/index.ts new file mode 100644 index 000000000000..9100d424df8c --- /dev/null +++ b/frontend/src/metabase/oauth/utils/index.ts @@ -0,0 +1,24 @@ +import { GoogleCredentialResponse } from "../types"; + +export function extractClientId( + credentialResponse: GoogleCredentialResponse, +): string | undefined { + try { + const clientId = + credentialResponse?.clientId ?? credentialResponse?.client_id; + if (clientId) { + return clientId; + } + + if (!credentialResponse?.credential) { + return undefined; + } + + const payload = JSON.parse( + atob(credentialResponse.credential.split(".")[1]), + ); + return payload?.aud; + } catch { + return undefined; + } +} diff --git a/package.json b/package.json index 167d1052a91d..e2e86a669edf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "dependencies": { "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", - "@react-oauth/google": "^0.2.1", "@snowplow/browser-tracker": "^3.1.6", "@tippyjs/react": "^4.2.6", "@visx/axis": "1.8.0", diff --git a/yarn.lock b/yarn.lock index b121a0478c99..05aa72b768ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3707,11 +3707,6 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ== -"@react-oauth/google@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@react-oauth/google/-/google-0.2.1.tgz#a0bdacf5374b5a11d31ee9afd6b5d2f519fa7c1d" - integrity sha512-ZG20jrOaBuVt6kIv7acE4JHjIQ8rYwGNsA7MdBvjxmxyU7l8vRR/FV6XvneDWXE+AGc8YxqSI6U+cvJLJ3YQ9w== - "@sinclair/typebox@^0.24.1": version "0.24.44" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.44.tgz#0a0aa3bf4a155a678418527342a3ee84bd8caa5c" From 7db8c68f1fb312d0414b06ebc85bebedcda121d0 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Tue, 1 Nov 2022 12:09:47 -0600 Subject: [PATCH 009/269] Fix google-auth-enabled when upgrading from pre x.45 (#26200) (#26203) Co-authored-by: Alexander Polyankin --- src/metabase/integrations/google.clj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/metabase/integrations/google.clj b/src/metabase/integrations/google.clj index 9277fdac43f3..316419df14fd 100644 --- a/src/metabase/integrations/google.clj +++ b/src/metabase/integrations/google.clj @@ -44,6 +44,10 @@ (deferred-tru "Is Google Sign-in currently enabled?") :visibility :public :type :boolean + :getter (fn [] + (if-some [value (setting/get-value-of-type :boolean :google-auth-enabled)] + value + (boolean (google-auth-client-id)))) :setter (fn [new-value] (if-let [new-value (boolean new-value)] (if-not (google-auth-client-id) From dfd2d8c25afa651fe5854cf43933e7b5d006237e Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Wed, 2 Nov 2022 11:13:13 +0000 Subject: [PATCH 010/269] Remove react-oauth fork and use the new lib version (#26196) (#26208) Co-authored-by: Alexander Polyankin --- .../components/GoogleButton/GoogleButton.tsx | 2 +- frontend/src/metabase/oauth/GoogleLogin.tsx | 106 ------ .../metabase/oauth/GoogleOAuthProvider.tsx | 54 --- .../metabase/oauth/google-auth-window.d.ts | 45 --- frontend/src/metabase/oauth/googleLogout.ts | 3 - .../oauth/hasGrantedAllScopesGoogle.ts | 20 -- .../oauth/hasGrantedAnyScopeGoogle.ts | 20 -- .../metabase/oauth/hooks/useGoogleLogin.ts | 115 ------ .../oauth/hooks/useGoogleOneTapLogin.ts | 70 ---- .../metabase/oauth/hooks/useLoadGsiScript.ts | 50 --- frontend/src/metabase/oauth/index.ts | 14 - frontend/src/metabase/oauth/types/index.ts | 335 ------------------ frontend/src/metabase/oauth/utils/index.ts | 24 -- package.json | 1 + yarn.lock | 5 + 15 files changed, 7 insertions(+), 857 deletions(-) delete mode 100644 frontend/src/metabase/oauth/GoogleLogin.tsx delete mode 100644 frontend/src/metabase/oauth/GoogleOAuthProvider.tsx delete mode 100644 frontend/src/metabase/oauth/google-auth-window.d.ts delete mode 100644 frontend/src/metabase/oauth/googleLogout.ts delete mode 100644 frontend/src/metabase/oauth/hasGrantedAllScopesGoogle.ts delete mode 100644 frontend/src/metabase/oauth/hasGrantedAnyScopeGoogle.ts delete mode 100644 frontend/src/metabase/oauth/hooks/useGoogleLogin.ts delete mode 100644 frontend/src/metabase/oauth/hooks/useGoogleOneTapLogin.ts delete mode 100644 frontend/src/metabase/oauth/hooks/useLoadGsiScript.ts delete mode 100644 frontend/src/metabase/oauth/index.ts delete mode 100644 frontend/src/metabase/oauth/types/index.ts delete mode 100644 frontend/src/metabase/oauth/utils/index.ts diff --git a/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx b/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx index d02d3edd50ac..f5b5e4275724 100644 --- a/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx +++ b/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useState } from "react"; import { t } from "ttag"; import { getIn } from "icepick"; +import { GoogleOAuthProvider, GoogleLogin } from "@react-oauth/google"; import * as Urls from "metabase/lib/urls"; -import { GoogleOAuthProvider, GoogleLogin } from "metabase/oauth"; import { AuthError, AuthErrorContainer, TextLink } from "./GoogleButton.styled"; export interface GoogleButtonProps { diff --git a/frontend/src/metabase/oauth/GoogleLogin.tsx b/frontend/src/metabase/oauth/GoogleLogin.tsx deleted file mode 100644 index 78737e178460..000000000000 --- a/frontend/src/metabase/oauth/GoogleLogin.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint-disable */ -import React, { useEffect, useRef } from "react"; - -import { useGoogleOAuth } from "./GoogleOAuthProvider"; -import { extractClientId } from "./utils"; -import { - IdConfiguration, - CredentialResponse, - GoogleCredentialResponse, - MomenListener, - GsiButtonConfiguration, -} from "./types"; - -const containerHeightMap = { large: 40, medium: 32, small: 20 }; - -export type GoogleLoginProps = { - onSuccess: (credentialResponse: CredentialResponse) => void; - onError?: () => void; - promptMomentNotification?: MomenListener; - useOneTap?: boolean; -} & Omit & - GsiButtonConfiguration; - -export default function GoogleLogin({ - onSuccess, - onError, - useOneTap, - promptMomentNotification, - type = "standard", - theme = "outline", - size = "large", - text, - shape, - logo_alignment, - width, - locale, - ...props -}: GoogleLoginProps) { - const btnContainerRef = useRef(null); - const { clientId, scriptLoadedSuccessfully } = useGoogleOAuth(); - - const onSuccessRef = useRef(onSuccess); - onSuccessRef.current = onSuccess; - - const onErrorRef = useRef(onError); - onErrorRef.current = onError; - - const promptMomentNotificationRef = useRef(promptMomentNotification); - promptMomentNotificationRef.current = promptMomentNotification; - - useEffect(() => { - if (!scriptLoadedSuccessfully) return; - - window.google?.accounts.id.initialize({ - client_id: clientId, - callback: (credentialResponse: GoogleCredentialResponse) => { - if (!credentialResponse?.credential) { - return onErrorRef.current?.(); - } - - const { credential, select_by } = credentialResponse; - onSuccessRef.current({ - credential, - clientId: extractClientId(credentialResponse), - select_by, - }); - }, - ...props, - }); - - window.google?.accounts.id.renderButton(btnContainerRef.current!, { - type, - theme, - size, - text, - shape, - logo_alignment, - width, - locale, - }); - - if (useOneTap) - window.google?.accounts.id.prompt(promptMomentNotificationRef.current); - - return () => { - if (useOneTap) window.google?.accounts.id.cancel(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - clientId, - scriptLoadedSuccessfully, - useOneTap, - type, - theme, - size, - text, - shape, - logo_alignment, - width, - locale, - ]); - - return ( -
- ); -} diff --git a/frontend/src/metabase/oauth/GoogleOAuthProvider.tsx b/frontend/src/metabase/oauth/GoogleOAuthProvider.tsx deleted file mode 100644 index 009811cfbb68..000000000000 --- a/frontend/src/metabase/oauth/GoogleOAuthProvider.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable */ -import React, { useContext, createContext, useMemo, ReactNode } from "react"; - -import useLoadGsiScript, { - UseLoadGsiScriptOptions, -} from "./hooks/useLoadGsiScript"; - -interface GoogleOAuthContextProps { - clientId: string; - scriptLoadedSuccessfully: boolean; -} - -const GoogleOAuthContext = createContext(null!); - -interface GoogleOAuthProviderProps extends UseLoadGsiScriptOptions { - clientId: string; - children: ReactNode; -} - -export default function GoogleOAuthProvider({ - clientId, - onScriptLoadSuccess, - onScriptLoadError, - children, -}: GoogleOAuthProviderProps) { - const scriptLoadedSuccessfully = useLoadGsiScript({ - onScriptLoadSuccess, - onScriptLoadError, - }); - - const contextValue = useMemo( - () => ({ - clientId, - scriptLoadedSuccessfully, - }), - [clientId, scriptLoadedSuccessfully], - ); - - return ( - - {children} - - ); -} - -export function useGoogleOAuth() { - const context = useContext(GoogleOAuthContext); - if (!context) { - throw new Error( - "Google OAuth components must be used within GoogleOAuthProvider", - ); - } - return context; -} diff --git a/frontend/src/metabase/oauth/google-auth-window.d.ts b/frontend/src/metabase/oauth/google-auth-window.d.ts deleted file mode 100644 index 59521a101674..000000000000 --- a/frontend/src/metabase/oauth/google-auth-window.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable */ -interface Window { - google?: { - accounts: { - id: { - initialize: (input: IdConfiguration) => void; - prompt: (momentListener?: MomenListener) => void; - renderButton: ( - parent: HTMLElement, - options: GsiButtonConfiguration, - clickHandler?: () => void, - ) => void; - disableAutoSelect: () => void; - storeCredential: ( - credential: { id: string; password: string }, - callback?: () => void, - ) => void; - cancel: () => void; - onGoogleLibraryLoad: Function; - revoke: (accessToken: string, done: () => void) => void; - }; - oauth2: { - initTokenClient: (config: TokenClientConfig) => { - requestAccessToken: ( - overridableClientConfig?: OverridableTokenClientConfig, - ) => void; - }; - initCodeClient: (config: CodeClientConfig) => { - requestCode: () => void; - }; - hasGrantedAnyScope: ( - tokenRsponse: TokenResponse, - firstScope: string, - ...restScopes: string[] - ) => boolean; - hasGrantedAllScopes: ( - tokenRsponse: TokenResponse, - firstScope: string, - ...restScopes: string[] - ) => boolean; - revoke: (accessToken: string, done?: () => void) => void; - }; - }; - }; -} diff --git a/frontend/src/metabase/oauth/googleLogout.ts b/frontend/src/metabase/oauth/googleLogout.ts deleted file mode 100644 index 68f14167568e..000000000000 --- a/frontend/src/metabase/oauth/googleLogout.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function googleLogout() { - window.google?.accounts.id.disableAutoSelect(); -} diff --git a/frontend/src/metabase/oauth/hasGrantedAllScopesGoogle.ts b/frontend/src/metabase/oauth/hasGrantedAllScopesGoogle.ts deleted file mode 100644 index fa2209888119..000000000000 --- a/frontend/src/metabase/oauth/hasGrantedAllScopesGoogle.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable */ -import { TokenResponse } from "./types"; - -/** - * Checks if the user granted all the specified scope or scopes - * @returns True if all the scopes are granted - */ -export default function hasGrantedAllScopesGoogle( - tokenResponse: TokenResponse, - firstScope: string, - ...restScopes: string[] -): boolean { - if (!window.google) return false; - - return window.google.accounts.oauth2.hasGrantedAllScopes( - tokenResponse, - firstScope, - ...restScopes, - ); -} diff --git a/frontend/src/metabase/oauth/hasGrantedAnyScopeGoogle.ts b/frontend/src/metabase/oauth/hasGrantedAnyScopeGoogle.ts deleted file mode 100644 index 1a18c790a773..000000000000 --- a/frontend/src/metabase/oauth/hasGrantedAnyScopeGoogle.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable */ -import { TokenResponse } from "./types"; - -/** - * Checks if the user granted any of the specified scope or scopes. - * @returns True if any of the scopes are granted - */ -export default function hasGrantedAnyScopeGoogle( - tokenResponse: TokenResponse, - firstScope: string, - ...restScopes: string[] -): boolean { - if (!window.google) return false; - - return window.google.accounts.oauth2.hasGrantedAnyScope( - tokenResponse, - firstScope, - ...restScopes, - ); -} diff --git a/frontend/src/metabase/oauth/hooks/useGoogleLogin.ts b/frontend/src/metabase/oauth/hooks/useGoogleLogin.ts deleted file mode 100644 index 80c61450f5a3..000000000000 --- a/frontend/src/metabase/oauth/hooks/useGoogleLogin.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* eslint-disable */ -import { useCallback, useEffect, useRef } from "react"; - -import { useGoogleOAuth } from "../GoogleOAuthProvider"; -import { - TokenClientConfig, - TokenResponse, - CodeClientConfig, - CodeResponse, - OverridableTokenClientConfig, -} from "../types"; - -interface ImplicitFlowOptions - extends Omit { - onSuccess?: ( - tokenResponse: Omit< - TokenResponse, - "error" | "error_description" | "error_uri" - >, - ) => void; - onError?: ( - errorResponse: Pick< - TokenResponse, - "error" | "error_description" | "error_uri" - >, - ) => void; - scope?: TokenClientConfig["scope"]; -} - -interface AuthCodeFlowOptions - extends Omit { - onSuccess?: ( - codeResponse: Omit< - CodeResponse, - "error" | "error_description" | "error_uri" - >, - ) => void; - onError?: ( - errorResponse: Pick< - CodeResponse, - "error" | "error_description" | "error_uri" - >, - ) => void; - scope?: CodeResponse["scope"]; -} - -export type UseGoogleLoginOptionsImplicitFlow = { - flow?: "implicit"; -} & ImplicitFlowOptions; - -export type UseGoogleLoginOptionsAuthCodeFlow = { - flow?: "auth-code"; -} & AuthCodeFlowOptions; - -export type UseGoogleLoginOptions = - | UseGoogleLoginOptionsImplicitFlow - | UseGoogleLoginOptionsAuthCodeFlow; - -export default function useGoogleLogin( - options: UseGoogleLoginOptionsImplicitFlow, -): (overrideConfig?: OverridableTokenClientConfig) => void; -export default function useGoogleLogin( - options: UseGoogleLoginOptionsAuthCodeFlow, -): () => void; - -export default function useGoogleLogin({ - flow = "implicit", - scope = "", - onSuccess, - onError, - ...props -}: UseGoogleLoginOptions): unknown { - const { clientId, scriptLoadedSuccessfully } = useGoogleOAuth(); - const clientRef = useRef(); - - const onSuccessRef = useRef(onSuccess); - onSuccessRef.current = onSuccess; - - const onErrorRef = useRef(onError); - onErrorRef.current = onError; - - useEffect(() => { - if (!scriptLoadedSuccessfully) return; - - const clientMethod = - flow === "implicit" ? "initTokenClient" : "initCodeClient"; - - const client = window.google?.accounts.oauth2[clientMethod]({ - client_id: clientId, - scope: `openid profile email ${scope}`, - callback: (response: TokenResponse | CodeResponse) => { - if (response.error) return onErrorRef.current?.(response); - - onSuccessRef.current?.(response as any); - }, - ...props, - }); - - clientRef.current = client; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clientId, scriptLoadedSuccessfully, flow, scope]); - - const loginImplicitFlow = useCallback( - (overrideConfig?: OverridableTokenClientConfig) => - clientRef.current.requestAccessToken(overrideConfig), - [], - ); - - const loginAuthCodeFlow = useCallback( - () => clientRef.current.requestCode(), - [], - ); - - return flow === "implicit" ? loginImplicitFlow : loginAuthCodeFlow; -} diff --git a/frontend/src/metabase/oauth/hooks/useGoogleOneTapLogin.ts b/frontend/src/metabase/oauth/hooks/useGoogleOneTapLogin.ts deleted file mode 100644 index eaf5722dbf50..000000000000 --- a/frontend/src/metabase/oauth/hooks/useGoogleOneTapLogin.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* eslint-disable */ -import { useEffect, useRef } from "react"; - -import { useGoogleOAuth } from "../GoogleOAuthProvider"; -import { extractClientId } from "../utils"; -import { - CredentialResponse, - GoogleCredentialResponse, - MomenListener, -} from "../types"; - -interface UseGoogleOneTapLoginOptions { - onSuccess: (credentialResponse: CredentialResponse) => void; - onError?: () => void; - promptMomentNotification?: MomenListener; - cancel_on_tap_outside?: boolean; - hosted_domain?: string; -} - -export default function useGoogleOneTapLogin({ - onSuccess, - onError, - promptMomentNotification, - cancel_on_tap_outside, - hosted_domain, -}: UseGoogleOneTapLoginOptions): void { - const { clientId, scriptLoadedSuccessfully } = useGoogleOAuth(); - - const onSuccessRef = useRef(onSuccess); - onSuccessRef.current = onSuccess; - - const onErrorRef = useRef(onError); - onErrorRef.current = onError; - - const promptMomentNotificationRef = useRef(promptMomentNotification); - promptMomentNotificationRef.current = promptMomentNotification; - - useEffect(() => { - if (!scriptLoadedSuccessfully) return; - - window.google?.accounts.id.initialize({ - client_id: clientId, - callback: (credentialResponse: GoogleCredentialResponse) => { - if (!credentialResponse?.credential) { - return onErrorRef.current?.(); - } - - const { credential, select_by } = credentialResponse; - onSuccessRef.current({ - credential, - clientId: extractClientId(credentialResponse), - select_by, - }); - }, - hosted_domain, - cancel_on_tap_outside, - }); - - window.google?.accounts.id.prompt(promptMomentNotificationRef.current); - - return () => { - window.google?.accounts.id.cancel(); - }; - }, [ - clientId, - scriptLoadedSuccessfully, - cancel_on_tap_outside, - hosted_domain, - ]); -} diff --git a/frontend/src/metabase/oauth/hooks/useLoadGsiScript.ts b/frontend/src/metabase/oauth/hooks/useLoadGsiScript.ts deleted file mode 100644 index 6f8e271a5f67..000000000000 --- a/frontend/src/metabase/oauth/hooks/useLoadGsiScript.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useState, useEffect, useRef } from "react"; - -export interface UseLoadGsiScriptOptions { - /** - * Callback fires on load [gsi](https://accounts.google.com/gsi/client) script success - */ - onScriptLoadSuccess?: () => void; - /** - * Callback fires on load [gsi](https://accounts.google.com/gsi/client) script failure - */ - onScriptLoadError?: () => void; -} - -export default function useLoadGsiScript( - options: UseLoadGsiScriptOptions = {}, -): boolean { - const { onScriptLoadSuccess, onScriptLoadError } = options; - - const [scriptLoadedSuccessfully, setScriptLoadedSuccessfully] = - useState(false); - - const onScriptLoadSuccessRef = useRef(onScriptLoadSuccess); - onScriptLoadSuccessRef.current = onScriptLoadSuccess; - - const onScriptLoadErrorRef = useRef(onScriptLoadError); - onScriptLoadErrorRef.current = onScriptLoadError; - - useEffect(() => { - const scriptTag = document.createElement("script"); - scriptTag.src = "https://accounts.google.com/gsi/client"; - scriptTag.async = true; - scriptTag.defer = true; - scriptTag.onload = () => { - setScriptLoadedSuccessfully(true); - onScriptLoadSuccessRef.current?.(); - }; - scriptTag.onerror = () => { - setScriptLoadedSuccessfully(false); - onScriptLoadErrorRef.current?.(); - }; - - document.body.appendChild(scriptTag); - - return () => { - document.body.removeChild(scriptTag); - }; - }, []); - - return scriptLoadedSuccessfully; -} diff --git a/frontend/src/metabase/oauth/index.ts b/frontend/src/metabase/oauth/index.ts deleted file mode 100644 index 48ecf8faafe1..000000000000 --- a/frontend/src/metabase/oauth/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { default as GoogleOAuthProvider } from "./GoogleOAuthProvider"; -export { default as GoogleLogin } from "./GoogleLogin"; -export type { GoogleLoginProps } from "./GoogleLogin"; -export { default as googleLogout } from "./googleLogout"; -export { default as useGoogleLogin } from "./hooks/useGoogleLogin"; -export type { - UseGoogleLoginOptions, - UseGoogleLoginOptionsAuthCodeFlow, - UseGoogleLoginOptionsImplicitFlow, -} from "./hooks/useGoogleLogin"; -export { default as useGoogleOneTapLogin } from "./hooks/useGoogleOneTapLogin"; -export { default as hasGrantedAllScopesGoogle } from "./hasGrantedAllScopesGoogle"; -export { default as hasGrantedAnyScopeGoogle } from "./hasGrantedAnyScopeGoogle"; -export * from "./types"; diff --git a/frontend/src/metabase/oauth/types/index.ts b/frontend/src/metabase/oauth/types/index.ts deleted file mode 100644 index 697adbc1f433..000000000000 --- a/frontend/src/metabase/oauth/types/index.ts +++ /dev/null @@ -1,335 +0,0 @@ -type Context = "signin" | "signup" | "use"; - -type UxMode = "popup" | "redirect"; - -type ErrorCode = - | "invalid_request" - | "access_denied" - | "unauthorized_client" - | "unsupported_response_type" - | "invalid_scope" - | "server_error" - | "temporarily_unavailable"; - -export interface IdConfiguration { - /** Your application's client ID */ - client_id?: string; - /** Enables automatic selection on Google One Tap */ - auto_select?: boolean; - /** ID token callback handler */ - callback?: (credentialResponse: CredentialResponse) => void; - /** The Sign In With Google button UX flow */ - ux_mode?: UxMode; - /** The URL of your login endpoint */ - login_uri?: string; - /** The URL of your password credential handler endpoint */ - native_login_uri?: string; - /** The JavaScript password credential handler function name */ - native_callback?: (response: { id: string; password: string }) => void; - /** Controls whether to cancel the prompt if the user clicks outside of the prompt */ - cancel_on_tap_outside?: boolean; - /** The DOM ID of the One Tap prompt container element */ - prompt_parent_id?: string; - /** A random string for ID tokens */ - nonce?: string; - /** The title and words in the One Tap prompt */ - context?: Context; - /** If you need to call One Tap in the parent domain and its subdomains, pass the parent domain to this attribute so that a single shared cookie is used. */ - state_cookie_domain?: string; - /** The origins that are allowed to embed the intermediate iframe. One Tap will run in the intermediate iframe mode if this attribute presents */ - allowed_parent_origin?: string | string[]; - /** Overrides the default intermediate iframe behavior when users manually close One Tap */ - intermediate_iframe_close_callback?: () => void; - /** Enables upgraded One Tap UX on ITP browsers */ - itp_support?: boolean; - /** - * If your application knows the Workspace domain the user belongs to, - * use this to provide a hint to Google. For more information, - * see the [hd](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) - * field in the OpenID Connect docs. - */ - hosted_domain?: string; -} - -export interface CredentialResponse { - /** This field is the returned ID token */ - credential?: string; - /** This field sets how the credential is selected */ - select_by?: - | "auto" - | "user" - | "user_1tap" - | "user_2tap" - | "btn" - | "btn_confirm" - | "brn_add_session" - | "btn_confirm_add_session"; - clientId?: string; -} - -export interface GoogleCredentialResponse extends CredentialResponse { - client_id?: string; -} - -export interface GsiButtonConfiguration { - /** The button [type](https://developers.google.com/identity/gsi/web/reference/js-reference#type): icon, or standard button */ - type?: "standard" | "icon"; - /** The button [theme](https://developers.google.com/identity/gsi/web/reference/js-reference#theme). For example, filled_blue or filled_black */ - theme?: "outline" | "filled_blue" | "filled_black"; - /** The button [size](https://developers.google.com/identity/gsi/web/reference/js-reference#size). For example, small or large */ - size?: "large" | "medium" | "small"; - /** The button [text](https://developers.google.com/identity/gsi/web/reference/js-reference#text). For example, "Sign in with Google" or "Sign up with Google" */ - text?: "signin_with" | "signup_with" | "continue_with" | "signin"; - /** The button [shape](https://developers.google.com/identity/gsi/web/reference/js-reference#shape). For example, rectangular or circular */ - shape?: "rectangular" | "pill" | "circle" | "square"; - /** The Google [logo alignment](https://developers.google.com/identity/gsi/web/reference/js-reference#logo_alignment): left or center */ - logo_alignment?: "left" | "center"; - /** The button [width](https://developers.google.com/identity/gsi/web/reference/js-reference#width), in pixels */ - width?: string; - /** If set, then the button [language](https://developers.google.com/identity/gsi/web/reference/js-reference#locale) is rendered */ - locale?: string; -} - -export interface PromptMomentNotification { - /** Is this notification for a display moment? */ - isDisplayMoment: () => boolean; - /** Is this notification for a display moment, and the UI is displayed? */ - isDisplayed: () => boolean; - /** Is this notification for a display moment, and the UI isn't displayed? */ - isNotDisplayed: () => boolean; - /** The detailed reason why the UI isn't displayed */ - getNotDisplayedReason: () => - | "browser_not_supported" - | "invalid_client" - | "missing_client_id" - | "opt_out_or_no_session" - | "secure_http_required" - | "suppressed_by_user" - | "unregistered_origin" - | "unknown_reason"; - /** Is this notification for a skipped moment? */ - isSkippedMoment: () => boolean; - /** The detailed reason for the skipped moment */ - getSkippedReason: () => - | "auto_cancel" - | "user_cancel" - | "tap_outside" - | "issuing_failed"; - /** Is this notification for a dismissed moment? */ - isDismissedMoment: () => boolean; - /** The detailed reason for the dismissa */ - getDismissedReason: () => - | "credential_returned" - | "cancel_called" - | "flow_restarted"; - /** Return a string for the moment type */ - getMomentType: () => "display" | "skipped" | "dismissed"; -} - -export interface TokenResponse { - /** The access token of a successful token response. */ - access_token: string; - - /** The lifetime in seconds of the access token. */ - expires_in: number; - - /** The hosted domain the signed-in user belongs to. */ - hd?: string; - - /** The prompt value that was used from the possible list of values specified by TokenClientConfig or OverridableTokenClientConfig */ - prompt: string; - - /** The type of the token issued. */ - token_type: string; - - /** A space-delimited list of scopes that are approved by the user. */ - scope: string; - - /** The string value that your application uses to maintain state between your authorization request and the response. */ - state?: string; - - /** A single ASCII error code. */ - error?: ErrorCode; - - /** Human-readable ASCII text providing additional information, used to assist the client developer in understanding the error that occurred. */ - error_description?: string; - - /** A URI identifying a human-readable web page with information about the error, used to provide the client developer with additional information about the error. */ - error_uri?: string; -} - -export interface TokenClientConfig { - /** - * The client ID for your application. You can find this value in the - * [API Console](https://console.cloud.google.com/apis/dashboard) - */ - client_id: string; - - /** - * A space-delimited list of scopes that identify the resources - * that your application could access on the user's behalf. - * These values inform the consent screen that Google displays to the user - */ - scope: string; - - /** - * Required for popup UX. The JavaScript function name that handles returned code response - * The property will be ignored by the redirect UX - */ - callback?: (response: TokenResponse) => void; - - /** - * Optional, defaults to 'select_account'. A space-delimited, case-sensitive list of prompts to present the user - */ - prompt?: "" | "none" | "consent" | "select_account"; - - /** - * Optional, defaults to true. If set to false, - * [more granular Google Account permissions](https://developers.googleblog.com/2018/10/more-granular-google-account.html) - * will be disabled for clients created before 2019. No effect for newer clients, - * since more granular permissions is always enabled for them. - */ - enable_serial_consent?: boolean; - - /** - * Optional. If your application knows which user should authorize the request, - * it can use this property to provide a hint to Google. - * The email address for the target user. For more information, - * see the [login_hint](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) field in the OpenID Connect docs. - */ - hint?: string; - - /** - * Optional. If your application knows the Workspace domain the user belongs to, - * use this to provide a hint to Google. For more information, - * see the [hd](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) - * field in the OpenID Connect docs. - */ - hosted_domain?: string; - - /** - * Optional. Not recommended. Specifies any string value that - * your application uses to maintain state between your authorization - * request and the authorization server's response. - */ - state?: string; -} - -export interface OverridableTokenClientConfig { - /** - * Optional. A space-delimited, case-sensitive list of prompts to present the user. - */ - prompt?: string; - - /** - * Optional. If set to false, - * [more granular Google Account permissions](https://developers.googleblog.com/2018/10/more-granular-google-account.html) - * will be disabled for clients created before 2019. - * No effect for newer clients, since more granular permissions is always enabled for them. - */ - enable_serial_consent?: boolean; - - /** - * Optional. If your application knows which user should authorize the request, - * it can use this property to provide a hint to Google. - * The email address for the target user. For more information, - * see the [login_hint](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) field in the OpenID Connect docs. - */ - hint?: string; - - /** - * Optional. Not recommended. Specifies any string value that your - * application uses to maintain state between your authorization request - * and the authorization server's response. - */ - state?: string; -} - -export interface CodeResponse { - /** The authorization code of a successful token response */ - code: string; - /** A space-delimited list of scopes that are approved by the user */ - scope: string; - /** The string value that your application uses to maintain state between your authorization request and the response */ - state?: string; - /** A single ASCII error code */ - error?: ErrorCode; - /** Human-readable ASCII text providing additional information, used to assist the client developer in understanding the error that occurred */ - error_description?: string; - /** A URI identifying a human-readable web page with information about the error, used to provide the client developer with additional information about the error */ - error_uri?: string; -} - -export interface CodeClientConfig { - /** - * Required. The client ID for your application. You can find this value in the - * [API Console](https://console.developers.google.com/) - */ - client_id: string; - - /** - * Required. A space-delimited list of scopes that identify - * the resources that your application could access on the user's behalf. - * These values inform the consent screen that Google displays to the user - */ - scope: string; - - /** - * Required for redirect UX. Determines where the API server redirects - * the user after the user completes the authorization flow. - * The value must exactly match one of the authorized redirect URIs for the OAuth 2.0 client, - * which you configured in the API Console and must conform to our - * [Redirect URI validation](https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation) rules. The property will be ignored by the popup UX - */ - redirect_uri?: string; - - /** - * Required for popup UX. The JavaScript function name that handles - * returned code response. The property will be ignored by the redirect UX - */ - callback?: (codeResponse: CodeResponse) => void; - - /** - * Optional. Recommended for redirect UX. Specifies any string value that - * your application uses to maintain state between your authorization request and the authorization server's response - */ - state?: string; - - /** - * Optional, defaults to true. If set to false, - * [more granular Google Account permissions](https://developers.googleblog.com/2018/10/more-granular-google-account.html) - * will be disabled for clients created before 2019. No effect for newer clients, since - * more granular permissions is always enabled for them - */ - enable_serial_consent?: boolean; - - /** - * Optional. If your application knows which user should authorize the request, - * it can use this property to provide a hint to Google. - * The email address for the target user. For more information, - * see the [login_hint](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) field in the OpenID Connect docs - */ - hint?: string; - - /** - * Optional. If your application knows the Workspace domain - * the user belongs to, use this to provide a hint to Google. - * For more information, see the [hd](https://developers.google.com/identity/protocols/oauth2/openid-connect#authenticationuriparameters) field in the OpenID Connect docs - */ - hosted_domain?: string; - - /** - * Optional. The UX mode to use for the authorization flow. - * By default, it will open the consent flow in a popup. Valid values are popup and redirect - */ - ux_mode?: "popup" | "redirect"; - - /** - * Optional, defaults to 'false'. Boolean value to prompt the user to select an account - */ - select_account?: boolean; -} - -export type MomenListener = ( - promptMomentNotification: PromptMomentNotification, -) => void; diff --git a/frontend/src/metabase/oauth/utils/index.ts b/frontend/src/metabase/oauth/utils/index.ts deleted file mode 100644 index 9100d424df8c..000000000000 --- a/frontend/src/metabase/oauth/utils/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { GoogleCredentialResponse } from "../types"; - -export function extractClientId( - credentialResponse: GoogleCredentialResponse, -): string | undefined { - try { - const clientId = - credentialResponse?.clientId ?? credentialResponse?.client_id; - if (clientId) { - return clientId; - } - - if (!credentialResponse?.credential) { - return undefined; - } - - const payload = JSON.parse( - atob(credentialResponse.credential.split(".")[1]), - ); - return payload?.aud; - } catch { - return undefined; - } -} diff --git a/package.json b/package.json index e2e86a669edf..782b845c5c95 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", + "@react-oauth/google": "^0.4.0", "@snowplow/browser-tracker": "^3.1.6", "@tippyjs/react": "^4.2.6", "@visx/axis": "1.8.0", diff --git a/yarn.lock b/yarn.lock index 05aa72b768ca..0c45a59a1d6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3707,6 +3707,11 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ== +"@react-oauth/google@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@react-oauth/google/-/google-0.4.0.tgz#ae4fe2724040bd11facdc53aad43a21e9f34b2c9" + integrity sha512-2QxxrKbXXH8bwHSefB56sBgsKs7Bq3Pvv8tVmGJuINGefECsssIUKidTDm5P55T4CV99sCX/GUfxs3l2Ntxo8Q== + "@sinclair/typebox@^0.24.1": version "0.24.44" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.44.tgz#0a0aa3bf4a155a678418527342a3ee84bd8caa5c" From 3775a69e6c9fc3e4dacd9c7f8378fc5025bdb0cb Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 03:13:47 +0000 Subject: [PATCH 011/269] Rename `dateAdd` to `datetimeAdd` - the missing file (#26191) (#26224) * date-add => datetime-ad Co-authored-by: Ngoc Khuat --- .../expressions/typeinferencer.js | 4 +-- .../expressions/typeinferencer.unit.spec.js | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/frontend/src/metabase-lib/expressions/typeinferencer.js b/frontend/src/metabase-lib/expressions/typeinferencer.js index 3dc1d5c1fdc0..ea530d10ee09 100644 --- a/frontend/src/metabase-lib/expressions/typeinferencer.js +++ b/frontend/src/metabase-lib/expressions/typeinferencer.js @@ -37,8 +37,8 @@ export function infer(mbql, env) { case "case": return infer(mbql[1][0][1], env); case "coalesce": - case "date-add": - case "date-subtract": + case "datetime-add": + case "datetime-subtract": return infer(mbql[1], env); } diff --git a/frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js b/frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js index 89bdc2b352fd..ba145846e9c1 100644 --- a/frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js +++ b/frontend/test/metabase/lib/expressions/typeinferencer.unit.spec.js @@ -36,6 +36,8 @@ describe("metabase-lib/expressions/typeinferencer", () => { case "Location": case "Place": return "type/Coordinate"; + case "CreatedAt": + return "type/Datetime"; } } @@ -112,4 +114,29 @@ describe("metabase-lib/expressions/typeinferencer", () => { expect(type("COALESCE([BirthDate], [MiscDate])")).toEqual("type/Temporal"); expect(type("COALESCE([Place], [Location])")).toEqual("type/Coordinate"); }); + + it("should infer the result of datetimeAdd, datetimeSubtract", () => { + expect(type('datetimeAdd([CreatedAt], 2, "month")')).toEqual( + "type/Datetime", + ); + expect(type('datetimeSubtract([CreatedAt], 2, "month")')).toEqual( + "type/Datetime", + ); + }); + + it("should infer the result of datetimeExtract functions", () => { + const ops = [ + "year", + "month", + "quarter", + "month", + "week", + "hour", + "minute", + "second", + ]; + ops.forEach(op => { + expect(type(`${op}([Created At])`)).toEqual("number"); + }); + }); }); From cb415bab590099d969c9006ba1b587574932344d Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 10:57:32 +0000 Subject: [PATCH 012/269] Add form validation to formik forms (#26219) (#26226) Co-authored-by: Alexander Polyankin --- .../auth/components/LoginForm/LoginForm.tsx | 42 ++++++---- .../FormCheckBox/FormCheckBox.unit.spec.tsx | 4 +- .../FormInput/FormInput.unit.spec.tsx | 4 +- .../components/FormProvider/FormProvider.tsx | 33 +++++++- .../FormRadio/FormRadio.unit.spec.tsx | 4 +- .../FormSelect/FormSelect.unit.spec.tsx | 4 +- .../FormToggle/FormToggle.unit.spec.tsx | 4 +- .../hooks/use-form-submit/use-form-submit.ts | 20 ++++- .../core/hooks/use-form-validation/index.ts | 1 + .../use-form-validation.ts | 76 +++++++++++++++++++ 10 files changed, 159 insertions(+), 33 deletions(-) create mode 100644 frontend/src/metabase/core/hooks/use-form-validation/index.ts create mode 100644 frontend/src/metabase/core/hooks/use-form-validation/use-form-validation.ts diff --git a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx index dadff5759427..404a0ec889ee 100644 --- a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx +++ b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { t } from "ttag"; import * as Yup from "yup"; import Form from "metabase/core/components/Form"; @@ -9,16 +9,15 @@ import FormInput from "metabase/core/components/FormInput"; import FormSubmitButton from "metabase/core/components/FormSubmitButton"; import { LoginData } from "../../types"; -const LDAP_SCHEMA = Yup.object().shape({ - username: Yup.string().required(t`required`), - password: Yup.string().required(t`required`), - remember: Yup.boolean(), -}); - -const PASSWORD_SCHEMA = LDAP_SCHEMA.shape({ +const LoginSchema = Yup.object().shape({ username: Yup.string() .required(t`required`) - .email(t`must be a valid email address`), + .when("$isLdapEnabled", { + is: false, + then: schema => schema.email(t`must be a valid email address`), + }), + password: Yup.string().required(t`required`), + remember: Yup.boolean(), }); export interface LoginFormProps { @@ -32,16 +31,27 @@ const LoginForm = ({ hasSessionCookies, onSubmit, }: LoginFormProps): JSX.Element => { - const initialValues: LoginData = { - username: "", - password: "", - remember: !hasSessionCookies, - }; + const initialValues = useMemo( + () => ({ + username: "", + password: "", + remember: !hasSessionCookies, + }), + [hasSessionCookies], + ); + + const validationContext = useMemo( + () => ({ + isLdapEnabled, + }), + [isLdapEnabled], + ); + return ( diff --git a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.unit.spec.tsx b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.unit.spec.tsx index abd05ae842a6..4ab202074b96 100644 --- a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.unit.spec.tsx @@ -5,7 +5,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import FormCheckBox from "./FormCheckBox"; -const TEST_SCHEMA = Yup.object().shape({ +const TestSchema = Yup.object().shape({ value: Yup.boolean().isTrue("error"), }); @@ -21,7 +21,7 @@ const TestFormCheckBox = ({ return ( diff --git a/frontend/src/metabase/core/components/FormInput/FormInput.unit.spec.tsx b/frontend/src/metabase/core/components/FormInput/FormInput.unit.spec.tsx index 8f455f7118cd..f053d714386e 100644 --- a/frontend/src/metabase/core/components/FormInput/FormInput.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormInput/FormInput.unit.spec.tsx @@ -5,7 +5,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import FormInput from "./FormInput"; -const TEST_SCHEMA = Yup.object().shape({ +const TestSchema = Yup.object().shape({ value: Yup.string().required("error"), }); @@ -18,7 +18,7 @@ const TestFormInput = ({ initialValue = "", onSubmit }: TestFormInputProps) => { return ( diff --git a/frontend/src/metabase/core/components/FormProvider/FormProvider.tsx b/frontend/src/metabase/core/components/FormProvider/FormProvider.tsx index 361469619fff..8da14f4c29bc 100644 --- a/frontend/src/metabase/core/components/FormProvider/FormProvider.tsx +++ b/frontend/src/metabase/core/components/FormProvider/FormProvider.tsx @@ -1,11 +1,38 @@ import React from "react"; import { Formik } from "formik"; import type { FormikConfig } from "formik"; +import type { AnySchema } from "yup"; import useFormSubmit from "metabase/core/hooks/use-form-submit"; +import useFormValidation from "metabase/core/hooks/use-form-validation"; -function FormProvider({ onSubmit, ...props }: FormikConfig): JSX.Element { - const handleSubmit = useFormSubmit(onSubmit); - return ; +export interface FormProviderProps extends FormikConfig { + validationSchema?: AnySchema; + validationContext?: C; +} + +function FormProvider({ + initialValues, + validationSchema, + validationContext, + onSubmit, + ...props +}: FormProviderProps): JSX.Element { + const { handleSubmit } = useFormSubmit({ onSubmit }); + const { initialErrors, handleValidate } = useFormValidation({ + initialValues, + validationSchema, + validationContext, + }); + + return ( + + ); } export default FormProvider; diff --git a/frontend/src/metabase/core/components/FormRadio/FormRadio.unit.spec.tsx b/frontend/src/metabase/core/components/FormRadio/FormRadio.unit.spec.tsx index 97bbc6c83cc1..ffce9299987e 100644 --- a/frontend/src/metabase/core/components/FormRadio/FormRadio.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormRadio/FormRadio.unit.spec.tsx @@ -5,7 +5,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import FormRadio from "./FormRadio"; -const TEST_SCHEMA = Yup.object().shape({ +const TestSchema = Yup.object().shape({ value: Yup.string().notOneOf(["bar"], "error"), }); @@ -24,7 +24,7 @@ const TestFormRadio = ({ initialValue, onSubmit }: TestFormRadioProps) => { return ( diff --git a/frontend/src/metabase/core/components/FormSelect/FormSelect.unit.spec.tsx b/frontend/src/metabase/core/components/FormSelect/FormSelect.unit.spec.tsx index 4bfaed93589c..882807999956 100644 --- a/frontend/src/metabase/core/components/FormSelect/FormSelect.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormSelect/FormSelect.unit.spec.tsx @@ -5,7 +5,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import FormSelect from "./FormSelect"; -const TEST_SCHEMA = Yup.object().shape({ +const TestSchema = Yup.object().shape({ value: Yup.string().notOneOf(["bar"], "error"), }); @@ -24,7 +24,7 @@ const TestFormSelect = ({ initialValue, onSubmit }: TestFormSelectProps) => { return ( diff --git a/frontend/src/metabase/core/components/FormToggle/FormToggle.unit.spec.tsx b/frontend/src/metabase/core/components/FormToggle/FormToggle.unit.spec.tsx index 4fc86c050f60..dcb58a7ff67a 100644 --- a/frontend/src/metabase/core/components/FormToggle/FormToggle.unit.spec.tsx +++ b/frontend/src/metabase/core/components/FormToggle/FormToggle.unit.spec.tsx @@ -5,7 +5,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import FormToggle from "./FormToggle"; -const TEST_SCHEMA = Yup.object().shape({ +const TestSchema = Yup.object().shape({ value: Yup.boolean().isTrue("error"), }); @@ -21,7 +21,7 @@ const TestFormToggle = ({ return ( diff --git a/frontend/src/metabase/core/hooks/use-form-submit/use-form-submit.ts b/frontend/src/metabase/core/hooks/use-form-submit/use-form-submit.ts index 8d66d8e97e1a..3d5650f66934 100644 --- a/frontend/src/metabase/core/hooks/use-form-submit/use-form-submit.ts +++ b/frontend/src/metabase/core/hooks/use-form-submit/use-form-submit.ts @@ -2,10 +2,18 @@ import { useCallback } from "react"; import type { FormikHelpers } from "formik"; import { FormError } from "./types"; -const useFormSubmit = ( - onSubmit: (data: T, helpers: FormikHelpers) => void, -) => { - return useCallback( +export interface UseFormSubmitProps { + onSubmit: (values: T, helpers: FormikHelpers) => void; +} + +export interface UseFormSubmitResult { + handleSubmit: (values: T, helpers: FormikHelpers) => void; +} + +const useFormSubmit = ({ + onSubmit, +}: UseFormSubmitProps): UseFormSubmitResult => { + const handleSubmit = useCallback( async (data: T, helpers: FormikHelpers) => { try { helpers.setStatus({ status: "pending" }); @@ -21,6 +29,10 @@ const useFormSubmit = ( }, [onSubmit], ); + + return { + handleSubmit, + }; }; const isFormError = (error: unknown): error is FormError => { diff --git a/frontend/src/metabase/core/hooks/use-form-validation/index.ts b/frontend/src/metabase/core/hooks/use-form-validation/index.ts new file mode 100644 index 000000000000..f763e79632c4 --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-validation/index.ts @@ -0,0 +1 @@ +export { default } from "./use-form-validation"; diff --git a/frontend/src/metabase/core/hooks/use-form-validation/use-form-validation.ts b/frontend/src/metabase/core/hooks/use-form-validation/use-form-validation.ts new file mode 100644 index 000000000000..9f9caef9e58e --- /dev/null +++ b/frontend/src/metabase/core/hooks/use-form-validation/use-form-validation.ts @@ -0,0 +1,76 @@ +import { useCallback, useMemo } from "react"; +import { prepareDataForValidation, yupToFormErrors } from "formik"; +import type { FormikErrors, FormikValues } from "formik"; +import type { AnySchema } from "yup"; + +export interface UseFormValidationProps { + initialValues: T; + validationSchema?: AnySchema; + validationContext?: C; +} + +export interface UseFormValidationResult { + initialErrors: FormikErrors | undefined; + handleValidate: (values: T) => void | object | FormikErrors; +} + +const useFormValidation = ({ + initialValues, + validationSchema, + validationContext, +}: UseFormValidationProps): UseFormValidationResult => { + const initialErrors = useMemo(() => { + if (validationSchema) { + return validateSchemaInitial( + initialValues, + validationSchema, + validationContext, + ); + } + }, [initialValues, validationSchema, validationContext]); + + const handleValidate = useCallback( + (values: FormikValues) => { + if (validationSchema) { + return validateSchema(values, validationSchema, validationContext); + } + }, + [validationSchema, validationContext], + ); + + return { + initialErrors, + handleValidate, + }; +}; + +const validateSchema = async ( + values: T, + validationSchema: AnySchema, + validationContext?: C, +) => { + try { + const data = prepareDataForValidation(values); + await validationSchema.validate(data, { + context: validationContext, + abortEarly: false, + }); + } catch (error) { + return yupToFormErrors(error); + } +}; + +const validateSchemaInitial = ( + values: T, + validationSchema: AnySchema, + validationContext?: C, +) => { + try { + const data = prepareDataForValidation(values); + validationSchema.validateSync(data, { context: validationContext }); + } catch (error) { + return yupToFormErrors(error); + } +}; + +export default useFormValidation; From a006b24a0511ff77a3088c81203d2d93c6b8641c Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 11:15:24 +0000 Subject: [PATCH 013/269] Migrate NewsletterForm to formik (#26192) (#26227) Co-authored-by: Alexander Polyankin --- .../NewsletterForm/NewsletterForm.styled.tsx | 27 ++--- .../NewsletterForm/NewsletterForm.tsx | 106 ++++++++++-------- .../NewsletterForm.unit.spec.tsx | 28 ++--- .../setup/components/NewsletterForm/types.ts | 20 ---- 4 files changed, 81 insertions(+), 100 deletions(-) delete mode 100644 frontend/src/metabase/setup/components/NewsletterForm/types.ts diff --git a/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.styled.tsx b/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.styled.tsx index 241aa2917337..e322cc86cbac 100644 --- a/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.styled.tsx +++ b/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.styled.tsx @@ -1,16 +1,17 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; import Icon from "metabase/components/Icon"; -import Button from "metabase/core/components/Button"; +import Form from "metabase/core/components/Form"; +import FormInput from "metabase/core/components/FormInput"; -export const FormRoot = styled.div` +export const EmailFormRoot = styled.div` position: relative; padding: 2rem; border: 1px solid ${color("border")}; border-radius: 0.5rem; `; -export const FormLabel = styled.div` +export const EmailFormLabel = styled.div` position: absolute; top: 0; left: 0; @@ -20,57 +21,57 @@ export const FormLabel = styled.div` transform: translateY(-50%); `; -export const FormLabelCard = styled.div` +export const EmailFormLabelCard = styled.div` display: flex; padding: 0 1.5rem; color: ${color("text-medium")}; background-color: ${color("white")}; `; -export const FormLabelIcon = styled(Icon)` +export const EmailFormLabelIcon = styled(Icon)` width: 1rem; height: 1rem; margin-right: 0.5rem; `; -export const FormLabelText = styled.div` +export const EmailFormLabelText = styled.div` font-size: 0.75rem; font-weight: 700; text-transform: uppercase; `; -export const FormHeader = styled.div` +export const EmailFormHeader = styled.div` color: ${color("text-medium")}; font-size: 1rem; font-weight: 700; margin-bottom: 1.5rem; `; -export const FormContainer = styled.div` +export const EmailForm = styled(Form)` display: flex; `; -export const FormFieldContainer = styled.div` +export const EmailFormInput = styled(FormInput)` flex: 1 0 auto; margin-right: 1rem; - margin-bottom: -1.5em; + margin-bottom: 0; `; -export const FormSuccessContainer = styled.div` +export const EmailFormSuccessContainer = styled.div` display: flex; justify-content: center; align-items: center; padding: 0.5rem; `; -export const FormSuccessIcon = styled(Icon)` +export const EmailFormSuccessIcon = styled(Icon)` color: ${color("success")}; width: 1rem; height: 1rem; margin-right: 1rem; `; -export const FormSuccessText = styled.div` +export const EmailFormSuccessText = styled.div` color: ${color("success")}; font-size: 1rem; font-weight: bold; diff --git a/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.tsx b/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.tsx index 6a8b52c8fb09..b5eb945e204d 100644 --- a/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.tsx +++ b/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.tsx @@ -1,22 +1,28 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { t } from "ttag"; -import Users from "metabase/entities/users"; -import Form from "metabase/containers/FormikForm"; +import * as Yup from "yup"; +import FormProvider from "metabase/core/components/FormProvider"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; import { SubscribeInfo } from "metabase-types/store"; import { - FormContainer, - FormFieldContainer, - FormHeader, - FormLabel, - FormLabelCard, - FormLabelIcon, - FormLabelText, - FormRoot, - FormSuccessContainer, - FormSuccessIcon, - FormSuccessText, + EmailForm, + EmailFormHeader, + EmailFormLabel, + EmailFormLabelCard, + EmailFormLabelIcon, + EmailFormLabelText, + EmailFormRoot, + EmailFormSuccessContainer, + EmailFormSuccessIcon, + EmailFormSuccessText, + EmailFormInput, } from "./NewsletterForm.styled"; -import { FormProps } from "./types"; + +const NewsletterSchema = Yup.object({ + email: Yup.string() + .required(t`required`) + .email(t`must be a valid email address`), +}); export interface NewsletterFormProps { initialEmail?: string; @@ -30,50 +36,52 @@ const NewsletterForm = ({ const initialValues = { email: initialEmail }; const [isSubscribed, setIsSubscribed] = useState(false); - const onSubmit = async ({ email }: SubscribeInfo) => { - await onSubscribe(email); - setIsSubscribed(true); - }; + const handleSubmit = useCallback( + async ({ email }: SubscribeInfo) => { + await onSubscribe(email); + setIsSubscribed(true); + }, + [onSubscribe], + ); return ( - - - - - {t`Metabase Newsletter`} - - - + + + + + {t`Metabase Newsletter`} + + + {t`Get infrequent emails about new releases and feature updates.`} - + {!isSubscribed && ( - - form={Users.forms.newsletter} + - {({ Form, FormField, FormSubmit }: FormProps) => ( - - - - - - - - - )} - + + + + + )} {isSubscribed && ( - - - + + + {t`You're subscribed. Thanks for using Metabase!`} - - + + )} - + ); }; diff --git a/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.unit.spec.tsx b/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.unit.spec.tsx index 72e3d88017e5..c1b016e11482 100644 --- a/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.unit.spec.tsx +++ b/frontend/src/metabase/setup/components/NewsletterForm/NewsletterForm.unit.spec.tsx @@ -1,27 +1,19 @@ -import React, { FormHTMLAttributes } from "react"; -import { render, screen } from "@testing-library/react"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import NewsletterForm from "./NewsletterForm"; -const FormMock = (props: FormHTMLAttributes) => ( -
- -
-); - -jest.mock("metabase/containers/FormikForm", () => FormMock); - -jest.mock("metabase/entities/users", () => ({ - forms: { newsletter: jest.fn() }, -})); - describe("NewsletterForm", () => { - it("allows to submit the form with an email", async () => { - const onSubscribe = jest.fn(); + it("should allow to submit the form with the provided email", async () => { + const email = "user@metabase.test"; + const onSubscribe = jest.fn().mockResolvedValue({}); - render(); + render(); userEvent.click(screen.getByText("Subscribe")); - expect(await screen.findByText(/You're subscribed/)).toBeInTheDocument(); + await waitFor(() => { + expect(onSubscribe).toHaveBeenCalledWith(email); + expect(screen.getByText(/You're subscribed/)).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/metabase/setup/components/NewsletterForm/types.ts b/frontend/src/metabase/setup/components/NewsletterForm/types.ts deleted file mode 100644 index 01b1823831ae..000000000000 --- a/frontend/src/metabase/setup/components/NewsletterForm/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ComponentType } from "react"; - -export interface FormField { - name: string; -} - -export interface FormProps { - Form: ComponentType; - FormField: ComponentType; - FormSubmit: ComponentType; -} - -export interface FormFieldProps { - name: string; -} - -export interface FormSubmitProps { - primary?: boolean; - submitTitle?: string; -} From 8e40e1f1571158467e21ad776559f1f19d018ba8 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 16:48:45 +0000 Subject: [PATCH 014/269] Migrate UserPasswordForm to formik (#26228) (#26232) Co-authored-by: Alexander Polyankin --- .../src/metabase/account/password/actions.js | 32 ------ .../src/metabase/account/password/actions.ts | 26 +++++ .../UserPasswordForm/UserPasswordForm.jsx | 44 --------- .../UserPasswordForm/UserPasswordForm.tsx | 97 +++++++++++++++++++ .../UserPasswordForm/{index.js => index.ts} | 0 ...serPasswordApp.jsx => UserPasswordApp.tsx} | 12 +-- .../UserPasswordApp/{index.js => index.ts} | 0 .../src/metabase/account/password/types.ts | 5 + 8 files changed, 133 insertions(+), 83 deletions(-) delete mode 100644 frontend/src/metabase/account/password/actions.js create mode 100644 frontend/src/metabase/account/password/actions.ts delete mode 100644 frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.jsx create mode 100644 frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.tsx rename frontend/src/metabase/account/password/components/UserPasswordForm/{index.js => index.ts} (100%) rename frontend/src/metabase/account/password/containers/UserPasswordApp/{UserPasswordApp.jsx => UserPasswordApp.tsx} (53%) rename frontend/src/metabase/account/password/containers/UserPasswordApp/{index.js => index.ts} (100%) create mode 100644 frontend/src/metabase/account/password/types.ts diff --git a/frontend/src/metabase/account/password/actions.js b/frontend/src/metabase/account/password/actions.js deleted file mode 100644 index 4041329d863e..000000000000 --- a/frontend/src/metabase/account/password/actions.js +++ /dev/null @@ -1,32 +0,0 @@ -import { t } from "ttag"; -import { UserApi, UtilApi } from "metabase/services"; -import { createThunkAction } from "metabase/lib/redux"; - -export const UPDATE_PASSWORD = "UPDATE_PASSWORD"; -export const VALIDATE_PASSWORD = "VALIDATE_PASSWORD"; - -export const validatePassword = createThunkAction( - VALIDATE_PASSWORD, - password => async () => - UtilApi.password_check({ - password, - }), -); - -export const updatePassword = createThunkAction( - UPDATE_PASSWORD, - (user_id, password, old_password) => async () => { - await UserApi.update_password({ - id: user_id, - password, - old_password, - }); - - return { - success: true, - data: { - message: t`Password updated successfully!`, - }, - }; - }, -); diff --git a/frontend/src/metabase/account/password/actions.ts b/frontend/src/metabase/account/password/actions.ts new file mode 100644 index 000000000000..0ec7da41d473 --- /dev/null +++ b/frontend/src/metabase/account/password/actions.ts @@ -0,0 +1,26 @@ +import { getIn } from "icepick"; +import { UserApi, UtilApi } from "metabase/services"; +import MetabaseSettings from "metabase/lib/settings"; +import { User } from "metabase-types/api"; +import { UserPasswordData } from "./types"; + +export const validatePassword = async (password: string) => { + const error = MetabaseSettings.passwordComplexityDescription(password); + if (error) { + return error; + } + + try { + await UtilApi.password_check({ password }); + } catch (error) { + return getIn(error, ["data", "errors", "password"]); + } +}; + +export const updatePassword = async (user: User, data: UserPasswordData) => { + await UserApi.update_password({ + id: user.id, + password: data.password, + old_password: data.old_password, + }); +}; diff --git a/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.jsx b/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.jsx deleted file mode 100644 index 08d78013c73a..000000000000 --- a/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { useCallback } from "react"; -import PropTypes from "prop-types"; -import { t } from "ttag"; -import User from "metabase/entities/users"; - -const propTypes = { - user: PropTypes.object, - validatePassword: PropTypes.func, - updatePassword: PropTypes.func, -}; - -const UserPasswordForm = ({ user, validatePassword, updatePassword }) => { - const handleAsyncValidate = useCallback( - async ({ password }) => { - try { - await validatePassword(password); - } catch (error) { - return error.data.errors; - } - }, - [validatePassword], - ); - - const handleSubmit = useCallback( - async ({ password, old_password }) => { - await updatePassword(user.id, password, old_password); - }, - [user, updatePassword], - ); - - return ( - - ); -}; - -UserPasswordForm.propTypes = propTypes; - -export default UserPasswordForm; diff --git a/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.tsx b/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.tsx new file mode 100644 index 000000000000..80763f476e4c --- /dev/null +++ b/frontend/src/metabase/account/password/components/UserPasswordForm/UserPasswordForm.tsx @@ -0,0 +1,97 @@ +import React, { useCallback, useMemo } from "react"; +import { t } from "ttag"; +import _ from "underscore"; +import * as Yup from "yup"; +import Form from "metabase/core/components/Form"; +import FormProvider from "metabase/core/components/FormProvider"; +import FormInput from "metabase/core/components/FormInput"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; +import FormErrorMessage from "metabase/core/components/FormErrorMessage"; +import { User } from "metabase-types/api"; +import { UserPasswordData } from "../../types"; + +const UserPasswordSchema = Yup.object({ + old_password: Yup.string().required(t`required`), + password: Yup.string() + .required(t`required`) + .test(async (value = "", context) => { + const error = await context.options.context?.onValidatePassword(value); + return error ? context.createError({ message: error }) : true; + }), + password_confirm: Yup.string() + .required(t`required`) + .oneOf([Yup.ref("password")], t`passwords do not match`), +}); + +export interface UserPasswordFormProps { + user: User; + onValidatePassword: (password: string) => Promise; + onSubmit: (user: User, data: UserPasswordData) => void; +} + +const UserPasswordForm = ({ + user, + onValidatePassword, + onSubmit, +}: UserPasswordFormProps): JSX.Element => { + const initialValues = useMemo( + () => ({ + old_password: "", + password: "", + password_confirm: "", + }), + [], + ); + + const validationContext = useMemo( + () => ({ onValidatePassword: _.memoize(onValidatePassword) }), + [onValidatePassword], + ); + + const handleSubmit = useCallback( + (data: UserPasswordData) => { + return onSubmit(user, data); + }, + [user, onSubmit], + ); + + return ( + +
+ + + + + + +
+ ); +}; + +export default UserPasswordForm; diff --git a/frontend/src/metabase/account/password/components/UserPasswordForm/index.js b/frontend/src/metabase/account/password/components/UserPasswordForm/index.ts similarity index 100% rename from frontend/src/metabase/account/password/components/UserPasswordForm/index.js rename to frontend/src/metabase/account/password/components/UserPasswordForm/index.ts diff --git a/frontend/src/metabase/account/password/containers/UserPasswordApp/UserPasswordApp.jsx b/frontend/src/metabase/account/password/containers/UserPasswordApp/UserPasswordApp.tsx similarity index 53% rename from frontend/src/metabase/account/password/containers/UserPasswordApp/UserPasswordApp.jsx rename to frontend/src/metabase/account/password/containers/UserPasswordApp/UserPasswordApp.tsx index 723ff7997ef2..4e5f026b8159 100644 --- a/frontend/src/metabase/account/password/containers/UserPasswordApp/UserPasswordApp.jsx +++ b/frontend/src/metabase/account/password/containers/UserPasswordApp/UserPasswordApp.tsx @@ -1,15 +1,13 @@ import { connect } from "react-redux"; import { getUser } from "metabase/selectors/user"; +import { State } from "metabase-types/store"; import { updatePassword, validatePassword } from "../../actions"; import UserPasswordForm from "../../components/UserPasswordForm"; -const mapStateToProps = state => ({ +const mapStateToProps = (state: State) => ({ user: getUser(state), + onValidatePassword: validatePassword, + onSubmit: updatePassword, }); -const mapDispatchToProps = { - validatePassword, - updatePassword, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(UserPasswordForm); +export default connect(mapStateToProps)(UserPasswordForm); diff --git a/frontend/src/metabase/account/password/containers/UserPasswordApp/index.js b/frontend/src/metabase/account/password/containers/UserPasswordApp/index.ts similarity index 100% rename from frontend/src/metabase/account/password/containers/UserPasswordApp/index.js rename to frontend/src/metabase/account/password/containers/UserPasswordApp/index.ts diff --git a/frontend/src/metabase/account/password/types.ts b/frontend/src/metabase/account/password/types.ts new file mode 100644 index 000000000000..c48379e2a840 --- /dev/null +++ b/frontend/src/metabase/account/password/types.ts @@ -0,0 +1,5 @@ +export interface UserPasswordData { + old_password: string; + password: string; + password_confirm: string; +} From cc31e088fa21850a39fe4b7e5c4454ab5ecaf902 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 16:59:29 +0000 Subject: [PATCH 015/269] Migrate UserStep form to formik (#26215) (#26233) Co-authored-by: Alexander Polyankin --- frontend/src/metabase-types/store/setup.ts | 8 +- frontend/src/metabase/entities/users/forms.js | 13 -- frontend/src/metabase/setup/actions.ts | 32 +++-- .../components/UserForm/UserForm.styled.tsx | 15 ++ .../setup/components/UserForm/UserForm.tsx | 128 ++++++++++++++++++ .../setup/components/UserForm/index.ts | 1 + .../components/UserStep/UserStep.styled.tsx | 14 -- .../setup/components/UserStep/UserStep.tsx | 61 +-------- .../UserStep/UserStep.unit.spec.tsx | 9 +- .../setup/containers/UserStep/UserStep.tsx | 4 +- 10 files changed, 174 insertions(+), 111 deletions(-) create mode 100644 frontend/src/metabase/setup/components/UserForm/UserForm.styled.tsx create mode 100644 frontend/src/metabase/setup/components/UserForm/UserForm.tsx create mode 100644 frontend/src/metabase/setup/components/UserForm/index.ts diff --git a/frontend/src/metabase-types/store/setup.ts b/frontend/src/metabase-types/store/setup.ts index be113f982a3a..e015cf665d84 100644 --- a/frontend/src/metabase-types/store/setup.ts +++ b/frontend/src/metabase-types/store/setup.ts @@ -4,8 +4,8 @@ export interface Locale { } export interface UserInfo { - first_name: string; - last_name: string; + first_name: string | null; + last_name: string | null; email: string; site_name: string; password: string; @@ -13,8 +13,8 @@ export interface UserInfo { } export interface InviteInfo { - first_name: string; - last_name: string; + first_name: string | null; + last_name: string | null; email: string; } diff --git a/frontend/src/metabase/entities/users/forms.js b/frontend/src/metabase/entities/users/forms.js index 52dfb1ff5c6e..b848f82906a4 100644 --- a/frontend/src/metabase/entities/users/forms.js +++ b/frontend/src/metabase/entities/users/forms.js @@ -97,19 +97,6 @@ export default { disablePristineSubmit: true, }; }, - setup: () => ({ - fields: [ - ...getNameFields(), - getEmailField(), - { - name: "site_name", - title: t`Company or team name`, - placeholder: t`Department of Awesome`, - validate: validate.required(), - }, - ...getPasswordFields(), - ], - }), setup_invite: user => ({ fields: [ ...getNameFields(), diff --git a/frontend/src/metabase/setup/actions.ts b/frontend/src/metabase/setup/actions.ts index 8491dc0dfe6b..315ef0ef13f0 100644 --- a/frontend/src/metabase/setup/actions.ts +++ b/frontend/src/metabase/setup/actions.ts @@ -1,9 +1,10 @@ import { createAction } from "redux-actions"; +import { getIn } from "icepick"; import { SetupApi, UtilApi } from "metabase/services"; import { createThunkAction } from "metabase/lib/redux"; import { loadLocalization } from "metabase/lib/i18n"; -import Settings from "metabase/lib/settings"; -import { UserInfo, DatabaseInfo, Locale } from "metabase-types/store"; +import MetabaseSettings from "metabase/lib/settings"; +import { DatabaseInfo, Locale } from "metabase-types/store"; import { getUserToken, getDefaultLocale, getLocales } from "./utils"; export const SET_STEP = "metabase/setup/SET_STEP"; @@ -50,26 +51,31 @@ export const LOAD_LOCALE_DEFAULTS = "metabase/setup/LOAD_LOCALE_DEFAULTS"; export const loadLocaleDefaults = createThunkAction( LOAD_LOCALE_DEFAULTS, () => async (dispatch: any) => { - const data = Settings.get("available-locales"); + const data = MetabaseSettings.get("available-locales"); const locale = getDefaultLocale(getLocales(data)); await dispatch(setLocale(locale)); }, ); -export const VALIDATE_PASSWORD = "metabase/setup/VALIDATE_PASSWORD"; -export const validatePassword = createThunkAction( - VALIDATE_PASSWORD, - (user: UserInfo) => async () => { - await UtilApi.password_check({ password: user.password }); - }, -); +export const validatePassword = async (password: string) => { + const error = MetabaseSettings.passwordComplexityDescription(password); + if (error) { + return error; + } + + try { + await UtilApi.password_check({ password }); + } catch (error) { + return getIn(error, ["data", "errors", "password"]); + } +}; export const VALIDATE_DATABASE = "metabase/setup/VALIDATE_DATABASE"; export const validateDatabase = createThunkAction( VALIDATE_DATABASE, (database: DatabaseInfo) => async () => { await SetupApi.validate_db({ - token: Settings.get("setup-token"), + token: MetabaseSettings.get("setup-token"), details: database, }); }, @@ -102,7 +108,7 @@ export const submitSetup = createThunkAction( const { locale, user, database, invite, isTrackingAllowed } = setup; await SetupApi.create({ - token: Settings.get("setup-token"), + token: MetabaseSettings.get("setup-token"), user, database, invite, @@ -113,6 +119,6 @@ export const submitSetup = createThunkAction( }, }); - Settings.set("setup-token", null); + MetabaseSettings.set("setup-token", null); }, ); diff --git a/frontend/src/metabase/setup/components/UserForm/UserForm.styled.tsx b/frontend/src/metabase/setup/components/UserForm/UserForm.styled.tsx new file mode 100644 index 000000000000..7c2bfe6a83e8 --- /dev/null +++ b/frontend/src/metabase/setup/components/UserForm/UserForm.styled.tsx @@ -0,0 +1,15 @@ +import styled from "@emotion/styled"; +import { breakpointMinSmall } from "metabase/styled-components/theme"; +import Form from "metabase/core/components/Form"; + +export const UserFormRoot = styled(Form)` + margin-top: 1rem; +`; + +export const UserFieldGroup = styled.div` + ${breakpointMinSmall} { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } +`; diff --git a/frontend/src/metabase/setup/components/UserForm/UserForm.tsx b/frontend/src/metabase/setup/components/UserForm/UserForm.tsx new file mode 100644 index 000000000000..6e4b6d433d8b --- /dev/null +++ b/frontend/src/metabase/setup/components/UserForm/UserForm.tsx @@ -0,0 +1,128 @@ +import React, { useCallback, useMemo } from "react"; +import { t } from "ttag"; +import * as Yup from "yup"; +import _ from "underscore"; +import FormProvider from "metabase/core/components/FormProvider"; +import FormInput from "metabase/core/components/FormInput"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; +import { UserInfo } from "metabase-types/store"; +import { UserFieldGroup, UserFormRoot } from "./UserForm.styled"; + +const UserSchema = Yup.object({ + 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`), + site_name: Yup.string().required(t`required`), + password: Yup.string() + .required(t`required`) + .test(async (value = "", context) => { + const error = await context.options.context?.onValidatePassword(value); + return error ? context.createError({ message: error }) : true; + }), + password_confirm: Yup.string() + .required(t`required`) + .oneOf([Yup.ref("password")], t`passwords do not match`), +}); + +interface UserFormProps { + user?: UserInfo; + onValidatePassword: (password: string) => Promise; + onSubmit: (user: UserInfo) => void; +} + +const UserForm = ({ user, onValidatePassword, onSubmit }: UserFormProps) => { + const initialValues = useMemo(() => { + return getInitialValues(user); + }, [user]); + + const validationContext = useMemo( + () => ({ + onValidatePassword: _.memoize(onValidatePassword), + }), + [onValidatePassword], + ); + + const handleSubmit = useCallback( + (values: UserInfo) => onSubmit(getSubmitValues(values)), + [onSubmit], + ); + + return ( + + + + + + + + + + + + + + ); +}; + +const getInitialValues = (user?: UserInfo): UserInfo => { + return { + email: "", + site_name: "", + password: "", + password_confirm: "", + ...user, + first_name: user?.first_name || "", + last_name: user?.last_name || "", + }; +}; + +const getSubmitValues = (user: UserInfo): UserInfo => { + return { + ...user, + first_name: user.first_name || null, + last_name: user.last_name || null, + }; +}; + +export default UserForm; diff --git a/frontend/src/metabase/setup/components/UserForm/index.ts b/frontend/src/metabase/setup/components/UserForm/index.ts new file mode 100644 index 000000000000..0aed9c2f9244 --- /dev/null +++ b/frontend/src/metabase/setup/components/UserForm/index.ts @@ -0,0 +1 @@ +export { default } from "./UserForm"; diff --git a/frontend/src/metabase/setup/components/UserStep/UserStep.styled.tsx b/frontend/src/metabase/setup/components/UserStep/UserStep.styled.tsx index e0f2995c8fb4..8971d97c581f 100644 --- a/frontend/src/metabase/setup/components/UserStep/UserStep.styled.tsx +++ b/frontend/src/metabase/setup/components/UserStep/UserStep.styled.tsx @@ -1,21 +1,7 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; -import { breakpointMinSmall } from "metabase/styled-components/theme"; -import User from "metabase/entities/users"; export const StepDescription = styled.div` color: ${color("text-medium")}; margin-top: 0.875rem; `; - -export const UserFormRoot = styled(User.Form)` - margin-top: 1rem; -`; - -export const UserFormGroup = styled.div` - ${breakpointMinSmall} { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; - } -`; diff --git a/frontend/src/metabase/setup/components/UserStep/UserStep.tsx b/frontend/src/metabase/setup/components/UserStep/UserStep.tsx index e088ce89ef11..841c1e366828 100644 --- a/frontend/src/metabase/setup/components/UserStep/UserStep.tsx +++ b/frontend/src/metabase/setup/components/UserStep/UserStep.tsx @@ -1,16 +1,10 @@ import React from "react"; import { t } from "ttag"; -import { getIn } from "icepick"; -import Users from "metabase/entities/users"; import { UserInfo } from "metabase-types/store"; import ActiveStep from "../ActiveStep"; import InactiveStep from "../InvactiveStep"; -import { - UserFormRoot, - UserFormGroup, - StepDescription, -} from "./UserStep.styled"; -import { FormProps } from "./types"; +import UserForm from "../UserForm"; +import { StepDescription } from "./UserStep.styled"; export interface UserStepProps { user?: UserInfo; @@ -18,7 +12,7 @@ export interface UserStepProps { isStepActive: boolean; isStepCompleted: boolean; isSetupCompleted: boolean; - onPasswordChange: (user: UserInfo) => void; + onValidatePassword: (password: string) => Promise; onStepSelect: () => void; onStepSubmit: (user: UserInfo) => void; } @@ -29,7 +23,7 @@ const UserStep = ({ isStepActive, isStepCompleted, isSetupCompleted, - onPasswordChange, + onValidatePassword, onStepSelect, onStepSubmit, }: UserStepProps): JSX.Element => { @@ -55,54 +49,13 @@ const UserStep = ({ )} ); }; -interface UserFormProps { - user?: UserInfo; - onSubmit: (user: UserInfo) => void; - onPasswordChange: (user: UserInfo) => void; -} - -const UserForm = ({ user, onSubmit, onPasswordChange }: UserFormProps) => { - const handleAsyncValidate = async (user: UserInfo) => { - try { - await onPasswordChange(user); - return {}; - } catch (error) { - return getSubmitError(error); - } - }; - - return ( - - {({ Form, FormField, FormFooter }: FormProps) => ( -
- - - - - - - - - - - )} -
- ); -}; - const getStepTitle = (user: UserInfo | undefined, isStepCompleted: boolean) => { const namePart = user?.first_name ? `, ${user.first_name}` : ""; return isStepCompleted @@ -110,8 +63,4 @@ const getStepTitle = (user: UserInfo | undefined, isStepCompleted: boolean) => { : t`What should we call you?`; }; -const getSubmitError = (error: unknown) => { - return getIn(error, ["data", "errors"]); -}; - export default UserStep; diff --git a/frontend/src/metabase/setup/components/UserStep/UserStep.unit.spec.tsx b/frontend/src/metabase/setup/components/UserStep/UserStep.unit.spec.tsx index 34a27acd5bd3..b7022fe5de00 100644 --- a/frontend/src/metabase/setup/components/UserStep/UserStep.unit.spec.tsx +++ b/frontend/src/metabase/setup/components/UserStep/UserStep.unit.spec.tsx @@ -3,13 +3,6 @@ import { render, screen } from "@testing-library/react"; import { UserInfo } from "metabase-types/store"; import UserStep, { UserStepProps } from "./UserStep"; -const FormMock = () =>
; - -jest.mock("metabase/entities/users", () => ({ - forms: { setup: jest.fn() }, - Form: FormMock, -})); - describe("UserStep", () => { it("should render in active state", () => { const props = getProps({ @@ -40,7 +33,7 @@ const getProps = (opts?: Partial): UserStepProps => ({ isStepActive: false, isStepCompleted: false, isSetupCompleted: false, - onPasswordChange: jest.fn(), + onValidatePassword: jest.fn(), onStepSelect: jest.fn(), onStepSubmit: jest.fn(), ...opts, diff --git a/frontend/src/metabase/setup/containers/UserStep/UserStep.tsx b/frontend/src/metabase/setup/containers/UserStep/UserStep.tsx index 8281ee09fbab..809669bb1be1 100644 --- a/frontend/src/metabase/setup/containers/UserStep/UserStep.tsx +++ b/frontend/src/metabase/setup/containers/UserStep/UserStep.tsx @@ -20,12 +20,10 @@ const mapStateToProps = (state: State) => ({ isStepCompleted: isStepCompleted(state, USER_STEP), isSetupCompleted: isSetupCompleted(state), isLocaleLoaded: isLocaleLoaded(state), + onValidatePassword: validatePassword, }); const mapDispatchToProps = (dispatch: any) => ({ - onPasswordChange: async (user: UserInfo) => { - await dispatch(validatePassword(user)); - }, onStepSelect: () => { dispatch(setStep(USER_STEP)); }, From 4f035aeecac7e356ce068d737aa44867c897e96b Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 17:01:05 +0000 Subject: [PATCH 016/269] Migrate ResetPassword form to formik (#26214) (#26234) Co-authored-by: Alexander Polyankin --- frontend/src/metabase/auth/actions.ts | 17 ++-- .../ResetPassword/ResetPassword.styled.tsx | 16 ---- .../ResetPassword/ResetPassword.tsx | 72 ++------------- .../ResetPassword/ResetPassword.unit.spec.tsx | 60 ++++++------ .../ResetPasswordForm.styled.tsx | 17 ++++ .../ResetPasswordForm/ResetPasswordForm.tsx | 91 +++++++++++++++++++ .../components/ResetPasswordForm/index.ts | 1 + .../ResetPasswordApp/ResetPasswordApp.tsx | 2 +- frontend/src/metabase/entities/users/forms.js | 3 - 9 files changed, 156 insertions(+), 123 deletions(-) create mode 100644 frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.styled.tsx create mode 100644 frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.tsx create mode 100644 frontend/src/metabase/auth/components/ResetPasswordForm/index.ts diff --git a/frontend/src/metabase/auth/actions.ts b/frontend/src/metabase/auth/actions.ts index 58678fd19e83..a8c99466b36e 100644 --- a/frontend/src/metabase/auth/actions.ts +++ b/frontend/src/metabase/auth/actions.ts @@ -97,13 +97,18 @@ export const resetPassword = createThunkAction( }, ); -export const VALIDATE_PASSWORD = "metabase/auth/VALIDATE_PASSWORD"; -export const validatePassword = createThunkAction( - VALIDATE_PASSWORD, - (password: string) => async () => { +export const validatePassword = async (password: string) => { + const error = MetabaseSettings.passwordComplexityDescription(password); + if (error) { + return error; + } + + try { await UtilApi.password_check({ password }); - }, -); + } catch (error) { + return getIn(error, ["data", "errors", "password"]); + } +}; export const VALIDATE_PASSWORD_TOKEN = "metabase/auth/VALIDATE_TOKEN"; export const validatePasswordToken = createThunkAction( diff --git a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.styled.tsx b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.styled.tsx index 53c4971e33ff..c036766b4a8f 100644 --- a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.styled.tsx +++ b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.styled.tsx @@ -1,21 +1,5 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; -import Icon from "metabase/components/Icon"; - -export const FormTitle = styled.div` - color: ${color("text-dark")}; - font-size: 1.25rem; - font-weight: 700; - line-height: 1.5rem; - text-align: center; - margin-bottom: 1rem; -`; - -export const FormMessage = styled.div` - color: ${color("text-dark")}; - text-align: center; - margin-bottom: 1.5rem; -`; export const InfoBody = styled.div` display: flex; diff --git a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.tsx b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.tsx index 5fb0b8786abd..1b0623507136 100644 --- a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.tsx +++ b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.tsx @@ -1,25 +1,18 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { t } from "ttag"; -import { getIn } from "icepick"; -import Settings from "metabase/lib/settings"; -import Users from "metabase/entities/users"; +import Button from "metabase/core/components/Button"; import Link from "metabase/core/components/Link"; import AuthLayout from "../../containers/AuthLayout"; +import ResetPasswordForm from "../ResetPasswordForm"; import { ResetPasswordData } from "../../types"; -import { - FormMessage, - FormTitle, - InfoBody, - InfoMessage, - InfoTitle, -} from "./ResetPassword.styled"; +import { InfoBody, InfoMessage, InfoTitle } from "./ResetPassword.styled"; type ViewType = "none" | "form" | "success" | "expired"; export interface ResetPasswordProps { token: string; onResetPassword: (token: string, password: string) => void; - onValidatePassword: (password: string) => void; + onValidatePassword: (password: string) => Promise; onValidatePasswordToken: (token: string) => void; onShowToast: (toast: { message: string }) => void; onRedirect: (url: string) => void; @@ -44,18 +37,6 @@ const ResetPassword = ({ } }, [token, onValidatePasswordToken]); - const handlePasswordChange = useCallback( - async ({ password }: ResetPasswordData) => { - try { - await onValidatePassword(password); - return {}; - } catch (error) { - return getPasswordError(error); - } - }, - [onValidatePassword], - ); - const handlePasswordSubmit = useCallback( async ({ password }: ResetPasswordData) => { await onResetPassword(token, password); @@ -73,7 +54,7 @@ const ResetPassword = ({ {view === "form" && ( )} @@ -82,36 +63,6 @@ const ResetPassword = ({ ); }; -interface ResetPasswordFormProps { - onPasswordChange: (data: ResetPasswordData) => void; - onSubmit: (data: ResetPasswordData) => void; -} - -const ResetPasswordForm = ({ - onPasswordChange, - onSubmit, -}: ResetPasswordFormProps): JSX.Element => { - const passwordDescription = useMemo( - () => Settings.passwordComplexityDescription(), - [], - ); - - return ( -
- {t`New password`} - {t`To keep your data secure, passwords ${passwordDescription}`} - -
- ); -}; - const ResetPasswordExpired = (): JSX.Element => { return ( @@ -119,16 +70,11 @@ const ResetPasswordExpired = (): JSX.Element => { {t`For security reasons, password reset links expire after a little while. If you still need to reset your password, you can request a new reset email.`} - {t`Request a new reset email`} + ); }; -const getPasswordError = (error: unknown) => { - return getIn(error, ["data", "errors"]); -}; - export default ResetPassword; diff --git a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.unit.spec.tsx b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.unit.spec.tsx index 833d2257ef5b..040aaeb92943 100644 --- a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.unit.spec.tsx @@ -11,8 +11,10 @@ describe("ResetPassword", () => { render(); - const message = await screen.findByText("New password"); - expect(message).toBeInTheDocument(); + await waitFor(() => { + expect(props.onValidatePasswordToken).toHaveBeenCalledWith(props.token); + expect(screen.getByText("New password")).toBeInTheDocument(); + }); }); it("should show an error message when token validation fails", async () => { @@ -22,36 +24,40 @@ describe("ResetPassword", () => { render(); - const message = await screen.findByText("Whoops, that's an expired link"); - expect(message).toBeInTheDocument(); + await waitFor(() => { + expect(props.onValidatePasswordToken).toHaveBeenCalledWith(props.token); + expect(screen.getByText(/that's an expired link/)).toBeInTheDocument(); + }); }); it("should show a success message when the form is submitted", async () => { - const onShowToast = jest.fn(); - const onRedirect = jest.fn(); - const props = getProps({ onResetPassword: jest.fn().mockResolvedValue({}), + onValidatePassword: jest.fn().mockResolvedValue(undefined), onValidatePasswordToken: jest.fn().mockResolvedValue({}), - onShowToast, - onRedirect, }); - render( - , - ); + render(); - const button = await screen.findByText("Save new password"); + await waitFor(() => { + expect(props.onValidatePasswordToken).toHaveBeenCalledWith(props.token); + expect(screen.getByText("New password")).toBeInTheDocument(); + }); - userEvent.click(button); + userEvent.type(screen.getByLabelText("Create a password"), "test"); + userEvent.type(screen.getByLabelText("Confirm your password"), "test"); await waitFor(() => { - expect(onRedirect).toHaveBeenCalledWith("/"); - expect(onShowToast).toHaveBeenCalledWith({ + expect(props.onValidatePassword).toHaveBeenCalledWith("test"); + expect(screen.getByText("Save new password")).toBeEnabled(); + }); + + userEvent.click(screen.getByText("Save new password")); + + await waitFor(() => { + expect(props.onResetPassword).toHaveBeenCalledWith(props.token, "test"); + expect(props.onRedirect).toHaveBeenCalledWith("/"); + expect(props.onShowToast).toHaveBeenCalledWith({ message: "You've updated your password.", }); }); @@ -70,20 +76,6 @@ const getProps = (opts?: Partial): ResetPasswordProps => { }; }; -interface FormMockProps { - submitTitle: string; - onSubmit: () => void; -} - -const FormMock = ({ submitTitle, onSubmit }: FormMockProps) => { - return ; -}; - -jest.mock("metabase/entities/users", () => ({ - forms: { password_reset: jest.fn() }, - Form: FormMock, -})); - interface AuthLayoutMockProps { children?: ReactNode; } diff --git a/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.styled.tsx b/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.styled.tsx new file mode 100644 index 000000000000..7f150701de95 --- /dev/null +++ b/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.styled.tsx @@ -0,0 +1,17 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; + +export const PasswordFormTitle = styled.div` + color: ${color("text-dark")}; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.5rem; + text-align: center; + margin-bottom: 1rem; +`; + +export const PasswordFormMessage = styled.div` + color: ${color("text-dark")}; + text-align: center; + margin-bottom: 1.5rem; +`; diff --git a/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.tsx b/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.tsx new file mode 100644 index 000000000000..8c23566064e7 --- /dev/null +++ b/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.tsx @@ -0,0 +1,91 @@ +import React, { useMemo } from "react"; +import { t } from "ttag"; +import * as Yup from "yup"; +import _ from "underscore"; +import MetabaseSettings from "metabase/lib/settings"; +import Form from "metabase/core/components/Form"; +import FormProvider from "metabase/core/components/FormProvider"; +import FormInput from "metabase/core/components/FormInput"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; +import FormErrorMessage from "metabase/core/components/FormErrorMessage"; +import { ResetPasswordData } from "metabase/auth/types"; +import { + PasswordFormMessage, + PasswordFormTitle, +} from "./ResetPasswordForm.styled"; + +const ResetPasswordSchema = Yup.object({ + password: Yup.string() + .required(t`required`) + .test(async (value = "", context) => { + const error = await context.options.context?.onValidatePassword(value); + return error ? context.createError({ message: error }) : true; + }), + password_confirm: Yup.string() + .required(t`required`) + .oneOf([Yup.ref("password")], t`passwords do not match`), +}); + +interface ResetPasswordFormProps { + onValidatePassword: (password: string) => Promise; + onSubmit: (data: ResetPasswordData) => void; +} + +const ResetPasswordForm = ({ + onValidatePassword, + onSubmit, +}: ResetPasswordFormProps): JSX.Element => { + const initialValues = useMemo( + () => ({ password: "", password_confirm: "" }), + [], + ); + + const passwordDescription = useMemo( + () => MetabaseSettings.passwordComplexityDescription(), + [], + ); + + const validationContext = useMemo( + () => ({ onValidatePassword: _.memoize(onValidatePassword) }), + [onValidatePassword], + ); + + return ( +
+ {t`New password`} + + {t`To keep your data secure, passwords ${passwordDescription}`} + + +
+ + + + + +
+
+ ); +}; + +export default ResetPasswordForm; diff --git a/frontend/src/metabase/auth/components/ResetPasswordForm/index.ts b/frontend/src/metabase/auth/components/ResetPasswordForm/index.ts new file mode 100644 index 000000000000..21ff6c5a72af --- /dev/null +++ b/frontend/src/metabase/auth/components/ResetPasswordForm/index.ts @@ -0,0 +1 @@ +export { default } from "./ResetPasswordForm"; diff --git a/frontend/src/metabase/auth/containers/ResetPasswordApp/ResetPasswordApp.tsx b/frontend/src/metabase/auth/containers/ResetPasswordApp/ResetPasswordApp.tsx index 5ee05f253a42..7b721650d14a 100644 --- a/frontend/src/metabase/auth/containers/ResetPasswordApp/ResetPasswordApp.tsx +++ b/frontend/src/metabase/auth/containers/ResetPasswordApp/ResetPasswordApp.tsx @@ -10,11 +10,11 @@ import { const mapStateToProps = (state: any, props: any) => ({ token: props.params.token, + onValidatePassword: validatePassword, }); const mapDispatchToProps = { onResetPassword: resetPassword, - onValidatePassword: validatePassword, onValidatePasswordToken: validatePasswordToken, onShowToast: addUndo, onRedirect: replace, diff --git a/frontend/src/metabase/entities/users/forms.js b/frontend/src/metabase/entities/users/forms.js index b848f82906a4..70cbea361761 100644 --- a/frontend/src/metabase/entities/users/forms.js +++ b/frontend/src/metabase/entities/users/forms.js @@ -138,9 +138,6 @@ export default { }, ], }, - password_reset: { - fields: [...getPasswordFields()], - }, newsletter: { fields: [ { From 723ffda0c0f2c3e9ad9c4ae2548303ceb21dd8fa Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 17:18:02 +0000 Subject: [PATCH 017/269] Migrate ForgotPassword form to formik (#26204) (#26236) Co-authored-by: Alexander Polyankin --- .../ForgotPassword/ForgotPassword.styled.tsx | 24 ------- .../ForgotPassword/ForgotPassword.tsx | 43 +---------- .../ForgotPassword.unit.spec.tsx | 26 +++---- .../ForgotPasswordForm.styled.tsx | 27 +++++++ .../ForgotPasswordForm/ForgotPasswordForm.tsx | 72 +++++++++++++++++++ .../components/ForgotPasswordForm/index.ts | 1 + frontend/src/metabase/entities/users/forms.js | 10 --- 7 files changed, 109 insertions(+), 94 deletions(-) create mode 100644 frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.styled.tsx create mode 100644 frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.tsx create mode 100644 frontend/src/metabase/auth/components/ForgotPasswordForm/index.ts diff --git a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.styled.tsx b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.styled.tsx index e488b261ab4b..2765084457a1 100644 --- a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.styled.tsx +++ b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.styled.tsx @@ -3,30 +3,6 @@ import { color } from "metabase/lib/colors"; import Icon from "metabase/components/Icon"; import Link from "metabase/core/components/Link"; -export const FormTitle = styled.div` - color: ${color("text-dark")}; - font-size: 1.25rem; - font-weight: 700; - line-height: 1.5rem; - text-align: center; - margin-bottom: 1.5rem; -`; - -export const FormFooter = styled.div` - display: flex; - flex-direction: column; - align-items: center; - margin-top: 1.5rem; -`; - -export const FormLink = styled(Link)` - color: ${color("text-dark")}; - - &:hover { - color: ${color("brand")}; - } -`; - export const InfoBody = styled.div` display: flex; flex-direction: column; diff --git a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.tsx b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.tsx index e0a700cb405a..ca88e16bbfbd 100644 --- a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.tsx +++ b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.tsx @@ -1,13 +1,9 @@ import React, { useCallback, useMemo, useState } from "react"; import { t } from "ttag"; -import Users from "metabase/entities/users"; import Button from "metabase/core/components/Button"; import AuthLayout from "../../containers/AuthLayout"; -import { ForgotPasswordData } from "../../types"; +import ForgotPasswordForm from "../ForgotPasswordForm"; import { - FormFooter, - FormLink, - FormTitle, InfoBody, InfoIcon, InfoIconContainer, @@ -54,43 +50,6 @@ const ForgotPassword = ({ ); }; -interface ForgotPasswordFormProps { - initialEmail?: string; - onSubmit: (email: string) => void; -} - -const ForgotPasswordForm = ({ - initialEmail, - onSubmit, -}: ForgotPasswordFormProps): JSX.Element => { - const initialValues = useMemo(() => { - return { email: initialEmail }; - }, [initialEmail]); - - const handleSubmit = useCallback( - async ({ email }: ForgotPasswordData) => { - await onSubmit(email); - }, - [onSubmit], - ); - - return ( -
- {t`Forgot password`} - - - {t`Back to sign in`} - -
- ); -}; - const ForgotPasswordSuccess = (): JSX.Element => { return ( diff --git a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx index 4a96363e57a6..d78c422571aa 100644 --- a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx @@ -1,5 +1,5 @@ import React, { ReactNode } 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 ForgotPassword, { ForgotPasswordProps } from "./ForgotPassword"; @@ -13,16 +13,20 @@ describe("ForgotPassword", () => { }); it("should show a success message when the form is submitted", async () => { + const email = "user@metabase.test"; const props = getProps({ canResetPassword: true, onResetPassword: jest.fn().mockResolvedValue({}), }); render(); - userEvent.click(screen.getByText("Send password reset email")); + userEvent.type(screen.getByLabelText("Email address"), email); + userEvent.click(await screen.findByText("Send password reset email")); - const message = await screen.findByText(/Check your email/); - expect(message).toBeInTheDocument(); + await waitFor(() => { + expect(props.onResetPassword).toHaveBeenCalledWith(email); + expect(screen.getByText(/Check your email/)).toBeInTheDocument(); + }); }); it("should show an error message when the user cannot reset their password", () => { @@ -42,20 +46,6 @@ const getProps = ( ...opts, }); -interface FormMockProps { - submitTitle: string; - onSubmit: () => void; -} - -const FormMock = ({ submitTitle, onSubmit }: FormMockProps) => { - return ; -}; - -jest.mock("metabase/entities/users", () => ({ - forms: { password_reset: jest.fn() }, - Form: FormMock, -})); - interface AuthLayoutMockProps { children?: ReactNode; } diff --git a/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.styled.tsx b/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.styled.tsx new file mode 100644 index 000000000000..ffc6e887ac5d --- /dev/null +++ b/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.styled.tsx @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import Link from "metabase/core/components/Link/Link"; + +export const PasswordFormTitle = styled.div` + color: ${color("text-dark")}; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.5rem; + text-align: center; + margin-bottom: 1.5rem; +`; + +export const PasswordFormFooter = styled.div` + display: flex; + flex-direction: column; + align-items: center; + margin-top: 1.5rem; +`; + +export const PasswordFormLink = styled(Link)` + color: ${color("text-dark")}; + + &:hover { + color: ${color("brand")}; + } +`; diff --git a/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.tsx b/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.tsx new file mode 100644 index 000000000000..1fac8067095d --- /dev/null +++ b/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.tsx @@ -0,0 +1,72 @@ +import React, { useCallback, useMemo } from "react"; +import { t } from "ttag"; +import * as Yup from "yup"; +import FormProvider from "metabase/core/components/FormProvider"; +import Form from "metabase/core/components/Form"; +import FormInput from "metabase/core/components/FormInput"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; +import FormErrorMessage from "metabase/core/components/FormErrorMessage"; +import { ForgotPasswordData } from "../../types"; +import { + PasswordFormFooter, + PasswordFormLink, + PasswordFormTitle, +} from "./ForgotPasswordForm.styled"; + +const ForgotPasswordSchema = Yup.object({ + email: Yup.string() + .required(t`required`) + .email(t`must be a valid email address`), +}); + +export interface ForgotPasswordFormProps { + initialEmail?: string; + onSubmit: (email: string) => void; +} + +const ForgotPasswordForm = ({ + initialEmail = "", + onSubmit, +}: ForgotPasswordFormProps): JSX.Element => { + const initialValues = useMemo( + () => ({ email: initialEmail }), + [initialEmail], + ); + + const handleSubmit = useCallback( + ({ email }: ForgotPasswordData) => onSubmit(email), + [onSubmit], + ); + + return ( +
+ {t`Forgot password`} + +
+ + + + +
+ + {t`Back to sign in`} + +
+ ); +}; + +export default ForgotPasswordForm; diff --git a/frontend/src/metabase/auth/components/ForgotPasswordForm/index.ts b/frontend/src/metabase/auth/components/ForgotPasswordForm/index.ts new file mode 100644 index 000000000000..f56004f14883 --- /dev/null +++ b/frontend/src/metabase/auth/components/ForgotPasswordForm/index.ts @@ -0,0 +1 @@ +export { default } from "./ForgotPasswordForm"; diff --git a/frontend/src/metabase/entities/users/forms.js b/frontend/src/metabase/entities/users/forms.js index 70cbea361761..b4ee16abd3d6 100644 --- a/frontend/src/metabase/entities/users/forms.js +++ b/frontend/src/metabase/entities/users/forms.js @@ -128,16 +128,6 @@ export default { ...getPasswordFields(), ], }, - password_forgot: { - fields: [ - { - name: "email", - title: t`Email address`, - placeholder: t`The email you use for your Metabase account`, - validate: validate.required().email(), - }, - ], - }, newsletter: { fields: [ { From 6f05114431e45205ecd4bd96c12cfecb9437c6d0 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 22:28:33 +0100 Subject: [PATCH 018/269] docs - update full-app embedding (#26239) (#26240) Co-authored-by: Natalie --- docs/embedding/full-app-embedding.md | 111 ++++++++++----------------- 1 file changed, 40 insertions(+), 71 deletions(-) diff --git a/docs/embedding/full-app-embedding.md b/docs/embedding/full-app-embedding.md index 8dd7c1ef1e78..d4720da32223 100644 --- a/docs/embedding/full-app-embedding.md +++ b/docs/embedding/full-app-embedding.md @@ -28,7 +28,7 @@ If you're dealing with a [multi-tenant](https://www.metabase.com/learn/customer- 1. Go to **Settings** > **Admin settings** > **Embedding**. 2. Click **Enable**. 3. Click **Full-app embedding**. -4. Under **Authorized origins**, add the URL of the website or web app where you want to embed Metabase (e.g., `https://*.example.com`). +4. Under **Authorized origins**, add the URL of the website or web app where you want to embed Metabase (such as `https://*.example.com`). ## Setting up embedding on your website @@ -39,10 +39,9 @@ If you're dealing with a [multi-tenant](https://www.metabase.com/learn/customer- - [Add your license token](../configuring-metabase/environment-variables.md#mb_premium_embedding_token). - [Embed Metabase in a different domain](#embedding-metabase-in-a-different-domain). - [Secure your full-app embed](#securing-full-app-embeds). -3. Optional: Enable communication to and from the embedded Metabase using [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage): - - [Fill an entire iframe with an embedded Metabase page](#filling-an-entire-iframe-with-an-embedded-metabase-page). - - [Fit an iframe to a Metabase page with a fixed size](#fitting-an-iframe-to-a-metabase-page-with-a-fixed-size). - - [Pass an embedding URL between Metabase and your app](#passing-an-embedding-url-between-metabase-and-your-app). +3. Optional: Enable communication to and from the embedded Metabase using supported [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) messages: + - [From Metabase](#supported-postmessage-messages-from-embedded-metabase) + - [To Metabase](#supported-postmessage-messages-to-embedded-metabase) 4. Optional: Set parameters to [show or hide Metabase UI components](#showing-or-hiding-metabase-ui-components). Once you're ready to roll out your full-app embed, make sure that people **allow** browser cookies from Metabase, otherwise they won't be able to log in. @@ -51,17 +50,13 @@ Once you're ready to roll out your full-app embed, make sure that people **allow Go to your Metabase instance and find the page that you want to embed. -For example, to embed your Metabase home page, set the `src` attribute to your [site URL](../configuring-metabase/settings.md#site-url). +For example, to embed your Metabase home page, set the `src` attribute to your [site URL](../configuring-metabase/settings.md#site-url), such as: -``` -http://metabase.yourcompany.com/ -``` +`http://metabase.yourcompany.com/` -To embed a specific Metabase dashboard, use the dashboard's URL: +To embed a specific Metabase dashboard, use the dashboard's URL, such as: -``` -http://metabase.yourcompany.com/dashboard/1 -``` +`http://metabase.yourcompany.com/dashboard/1` ### Pointing an iframe to an authentication endpoint @@ -87,13 +82,11 @@ https://metabase.example.com/auth/sso?jwt=&redirect=%2Fdashboard%2F1%3Ffi ## Embedding Metabase in a different domain -If you want to embed Metabase in another domain (e.g., Metabase is hosted at `metabase.yourcompany.com`, but you want to embed Metabase at `yourcompany.github.io`), set the following [environment variable](../configuring-metabase/environment-variables.md): +If you want to embed Metabase in another domain (say, if Metabase is hosted at `metabase.yourcompany.com`, but you want to embed Metabase at `yourcompany.github.io`), set the following [environment variable](../configuring-metabase/environment-variables.md): -``` -MB_SESSION_COOKIE_SAMESITE=None -``` +`MB_SESSION_COOKIE_SAMESITE=None` -If you set this environment variable to `None`, you must use HTTPS in Metabase to prevent browsers from rejecting the request. For more information, see MDN's documentation on [SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite). +If you set this environment variable to "None", you must use HTTPS in Metabase to prevent browsers from rejecting the request. For more information, see MDN's documentation on [SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite). ## Securing full-app embeds @@ -103,49 +96,41 @@ To limit the amount of time that a person stays logged in, set [`MAX_SESSION_AGE For example, to keep people signed in for 24 hours at most: -``` -MAX_SESSION_AGE=1440 -``` +`MAX_SESSION_AGE=1440` To automatically clear a person's login cookies when they end a browser session: -``` -MB_SESSION_COOKIES=true -``` +`MB_SESSION_COOKIES=true` To manually log someone out of Metabase, load the following URL (for example, in a hidden iframe on the logout page of your application): -``` -https://metabase.yourcompany.com/auth/logout -``` +`https://metabase.yourcompany.com/auth/logout` If you're using [JWT](../people-and-groups/authenticating-with-jwt.md) for SSO, we recommend setting the `exp` (expiration time) property to a short duration (e.g., 1 minute). -## Filling an entire iframe with an embedded Metabase page +## Supported postMessage messages _from_ embedded Metabase -To make an embedded Metabase page fill up the entire iframe (e.g., a question page), use [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) to send a "frame" message _from_ Metabase to your app: +To keep up with changes to an embedded Metabase URL (for example, when a filter is applied), set up your app to listen for "location" messages from the embedded Metabase. If you want to use this message for deep-linking, note that "location" mirrors "window.location". ``` -{ “metabase”: { “type”: “frame”, “frame”: { “mode”: “normal” }}} +{ “metabase”: { “type”: “location”, “location”: LOCATION_OBJECT_OR_URL }} ``` -## Fitting an iframe to a Metabase page with a fixed size - -To specify the size of an iframe so that it matches an embedded Metabase page (e.g., a dashboard page), use [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) to send a "frame" message _from_ Metabase to your app: +To make an embedded Metabase page (like a question) fill up the entire iframe in your app, set up your app to listen for a "frame" message with "normal" mode from Metabase: ``` -{ “metabase”: { “type”: “frame”, “frame”: { “mode”: “fit”, height: HEIGHT_IN_PIXELS }}} +{ “metabase”: { “type”: “frame”, “frame”: { “mode”: “normal” }}} ``` -## Passing an embedding URL between Metabase and your app - -To make a request for a particular embedding URL (e.g., for deep linking), you can use [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) to send a "location" message _from_ your embedded Metabase to your app: +To specify the size of an iframe in your app so that it matches an embedded Metabase page (such as a dashboard), set up your app to listen for a "frame" message with "fit" mode from Metabase: ``` -{ “metabase”: { “type”: “location”, “location”: LOCATION_OBJECT }} +{ “metabase”: { “type”: “frame”, “frame”: { “mode”: “fit”, height: HEIGHT_IN_PIXELS }}} ``` -Or, send a "location" message _to_ your embedded Metabase from your app: +## Supported postMessage messages _to_ embedded Metabase + +To change an embedding URL, send a "location" message from your app to Metabase: ``` { “metabase”: { “type”: “location”, “location”: LOCATION_OBJECT_OR_URL }} @@ -157,81 +142,65 @@ To change the interface of your full-app embed, you can add parameters to the en For example, you can disable Metabase's [top nav bar](#top_nav) and [side nav menu](#side_nav) like this: -``` -your_embedding_url?top_nav=false&side_nav=false -``` +`your_embedding_url?top_nav=false&side_nav=false` ![Top nav and side nav disabled](./images/no-top-no-side.png) -### `top_nav` +### top_nav Hidden by default. To show the top navigation bar: -``` -top_nav=true -``` +`top_nav=true` ![Top nav bar](./images/top-nav.png) -### `search` +### search Hidden by default. To show the search box in the top nav: -``` -top_nav=true&search=true -``` +`top_nav=true&search=true` -### `new_button` +### new_button Hidden by default. To show the **+ New** button used to create queries or dashboards: -``` -top_nav=true&new_button=true -``` +`top_nav=true&new_button=true` -### `side_nav` +### side_nav The navigation sidebar is shown on `/collection` and home page routes, and hidden everywhere else by default. To allow people to minimize the sidebar: -``` -top_nav=true&side_nav=true -``` +`top_nav=true&side_nav=true` ![Side nav](./images/side-nav.png) -### `header` +### header Visible by default on question and dashboard pages. To hide a question or dashboard's title, [additional info](#additional_info), and [action buttons](#action_buttons): -``` -header=false -``` +`header=false` -### `additional_info` +### additional_info Visible by default on question and dashboard pages, when the [header](#header) is enabled. To hide the gray text “Edited X days ago by FirstName LastName”, as well as the breadcrumbs with collection, database, and table names: -``` -header=false&additional_info=false -``` +`header=false&additional_info=false` ![Additional info](./images/additional-info.png) -### `action_buttons` +### action_buttons Visible by default on question pages when the [header](#header) is enabled. -To hide the action buttons such as **Save**, **Summarize**, **Filter**, or the query builder icon: +To hide the action buttons such as **Filter**, **Summarize**, the query builder button, and so on: -``` -header=false&action_buttons=false -``` +`header=false&action_buttons=false` ![Action buttons](./images/action-buttons.png) From a2f1ceb588e7646320254123e7f274258e132358 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 12:57:29 +0000 Subject: [PATCH 019/269] Upgrade moment-timezone to latest version (#26158) (#26245) Co-authored-by: Luis Paolini --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 782b845c5c95..9b9cdea78d74 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "leaflet": "^1.2.0", "leaflet-draw": "^0.4.9", "leaflet.heat": "^0.2.0", - "moment-timezone": "^0.5.26", + "moment-timezone": "^0.5.38", "mustache": "^2.3.2", "normalizr": "^3.0.2", "number-to-locale-string": "^1.0.1", diff --git a/yarn.lock b/yarn.lock index 0c45a59a1d6a..538f4c65b4de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16497,10 +16497,10 @@ moment-timezone@*: dependencies: moment ">= 2.9.0" -moment-timezone@^0.5.26: - version "0.5.31" - resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.31.tgz#9c40d8c5026f0c7ab46eda3d63e49c155148de05" - integrity sha512-+GgHNg8xRhMXfEbv81iDtrVeTcWt0kWmTEY1XQK14dICTXnWJnT0dxdlPspwqF3keKMVPXwayEsk1DI0AA/jdA== +moment-timezone@^0.5.38: + version "0.5.38" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.38.tgz#9674a5397b8be7c13de820fd387d8afa0f725aad" + integrity sha512-nMIrzGah4+oYZPflDvLZUgoVUO4fvAqHstvG3xAUnMolWncuAiLDWNnJZj6EwJGMGfb1ZcuTFE6GI3hNOVWI/Q== dependencies: moment ">= 2.9.0" From 9fd09a0f16a259ef7b19bfe31f2fde83458735c9 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 20:49:41 +0000 Subject: [PATCH 020/269] Migrate UserInviteForm to formik (#26216) (#26235) Co-authored-by: Alexander Polyankin --- .../DatabaseStep/DatabaseStep.styled.tsx | 6 -- .../components/DatabaseStep/DatabaseStep.tsx | 40 ++------ .../DatabaseStep/DatabaseStep.unit.spec.tsx | 5 - .../InviteUserForm/InviteUserForm.styled.tsx | 10 ++ .../InviteUserForm/InviteUserForm.tsx | 95 +++++++++++++++++++ .../setup/components/InviteUserForm/index.ts | 1 + 6 files changed, 112 insertions(+), 45 deletions(-) create mode 100644 frontend/src/metabase/setup/components/InviteUserForm/InviteUserForm.styled.tsx create mode 100644 frontend/src/metabase/setup/components/InviteUserForm/InviteUserForm.tsx create mode 100644 frontend/src/metabase/setup/components/InviteUserForm/index.ts diff --git a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.styled.tsx b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.styled.tsx index bcc4259d6174..07a668389249 100644 --- a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.styled.tsx +++ b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.styled.tsx @@ -24,12 +24,6 @@ export const StepDescription = styled.div` color: ${color("text-medium")}; `; -export const StepFormGroup = styled.div` - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; -`; - export const FormActions = styled.div` display: flex; align-items: center; diff --git a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.tsx b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.tsx index 9d5d7363c523..f15df33bcb23 100644 --- a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.tsx +++ b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.tsx @@ -3,17 +3,16 @@ import { t } from "ttag"; import _ from "underscore"; import { updateIn } from "icepick"; import Button from "metabase/core/components/Button"; -import Users from "metabase/entities/users"; import Databases from "metabase/entities/databases"; import DriverWarning from "metabase/containers/DriverWarning"; import { DatabaseInfo, InviteInfo, UserInfo } from "metabase-types/store"; import ActiveStep from "../ActiveStep"; import InactiveStep from "../InvactiveStep"; +import InviteUserForm from "../InviteUserForm"; import SetupSection from "../SetupSection"; import { StepActions, StepDescription, - StepFormGroup, StepButton, FormActions, } from "./DatabaseStep.styled"; @@ -87,7 +86,11 @@ const DatabaseStep = ({ title={t`Need help connecting to your data?`} description={t`Invite a teammate. We’ll make them an admin so they can configure your database. You can always change this later on.`} > - + )} @@ -168,37 +171,6 @@ const DatabaseForm = ({ ); }; -interface InviteFormProps { - user?: UserInfo; - invite?: InviteInfo; - onSubmit: (invite: InviteInfo) => void; -} - -const InviteForm = ({ - user, - invite, - onSubmit, -}: InviteFormProps): JSX.Element => { - return ( - - {({ Form, FormField, FormFooter }: FormProps) => ( -
- - - - - - - - )} -
- ); -}; - const getStepTitle = ( database: DatabaseInfo | undefined, invite: InviteInfo | undefined, diff --git a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.unit.spec.tsx b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.unit.spec.tsx index d1286bb37ef8..0d7e64341d34 100644 --- a/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.unit.spec.tsx +++ b/frontend/src/metabase/setup/components/DatabaseStep/DatabaseStep.unit.spec.tsx @@ -10,11 +10,6 @@ jest.mock("metabase/entities/databases", () => ({ Form: ComponentMock, })); -jest.mock("metabase/entities/users", () => ({ - forms: { setup_invite: jest.fn() }, - Form: ComponentMock, -})); - jest.mock("metabase/containers/DriverWarning", () => ComponentMock); describe("DatabaseStep", () => { diff --git a/frontend/src/metabase/setup/components/InviteUserForm/InviteUserForm.styled.tsx b/frontend/src/metabase/setup/components/InviteUserForm/InviteUserForm.styled.tsx new file mode 100644 index 000000000000..0847108feb7d --- /dev/null +++ b/frontend/src/metabase/setup/components/InviteUserForm/InviteUserForm.styled.tsx @@ -0,0 +1,10 @@ +import styled from "@emotion/styled"; +import { breakpointMinSmall } from "metabase/styled-components/theme"; + +export const UserFieldGroup = styled.div` + ${breakpointMinSmall} { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } +`; diff --git a/frontend/src/metabase/setup/components/InviteUserForm/InviteUserForm.tsx b/frontend/src/metabase/setup/components/InviteUserForm/InviteUserForm.tsx new file mode 100644 index 000000000000..d337c197ed52 --- /dev/null +++ b/frontend/src/metabase/setup/components/InviteUserForm/InviteUserForm.tsx @@ -0,0 +1,95 @@ +import React, { useCallback, useMemo } from "react"; +import { t } from "ttag"; +import * as Yup from "yup"; +import Form from "metabase/core/components/Form"; +import FormProvider from "metabase/core/components/FormProvider"; +import FormInput from "metabase/core/components/FormInput"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; +import { InviteInfo, UserInfo } from "metabase-types/store"; +import { UserFieldGroup } from "./InviteUserForm.styled"; + +const InviteUserSchema = Yup.object({ + 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`) + .notOneOf( + [Yup.ref("$email")], + t`must be different from the email address you used in setup`, + ), +}); + +interface InviteUserFormProps { + user?: UserInfo; + invite?: InviteInfo; + onSubmit: (invite: InviteInfo) => void; +} + +const InviteUserForm = ({ + user, + invite, + onSubmit, +}: InviteUserFormProps): JSX.Element => { + const initialValues = useMemo(() => { + return getInitialValues(invite); + }, [invite]); + + const handleSubmit = useCallback( + (values: InviteInfo) => onSubmit(getSubmitValues(values)), + [onSubmit], + ); + + return ( + +
+ + + + + + + +
+ ); +}; + +const getInitialValues = (invite?: InviteInfo): InviteInfo => { + return { + email: "", + ...invite, + first_name: invite?.first_name || "", + last_name: invite?.last_name || "", + }; +}; + +const getSubmitValues = (invite: InviteInfo): InviteInfo => { + return { + ...invite, + first_name: invite.first_name || null, + last_name: invite.last_name || null, + }; +}; + +export default InviteUserForm; diff --git a/frontend/src/metabase/setup/components/InviteUserForm/index.ts b/frontend/src/metabase/setup/components/InviteUserForm/index.ts new file mode 100644 index 000000000000..acf49331c96f --- /dev/null +++ b/frontend/src/metabase/setup/components/InviteUserForm/index.ts @@ -0,0 +1 @@ +export { default } from "./InviteUserForm"; From 8432de5c2356f3e8b2797184ee335ce5e10d421e Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 20:52:17 +0000 Subject: [PATCH 021/269] Migrate UserProfileForm to formik (#26231) (#26238) Co-authored-by: Alexander Polyankin --- .../src/metabase-types/api/mocks/settings.ts | 2 +- frontend/src/metabase-types/api/settings.ts | 2 +- .../UserPasswordForm/UserPasswordForm.tsx | 6 +- .../src/metabase/account/profile/actions.ts | 17 +++ .../UserProfileForm/UserProfileForm.jsx | 32 ---- .../UserProfileForm/UserProfileForm.tsx | 118 +++++++++++++++ .../UserProfileForm/{index.js => index.ts} | 0 .../UserProfileApp/UserProfileApp.jsx | 9 -- .../UserProfileApp/UserProfileApp.tsx | 18 +++ .../UserProfileApp/{index.js => index.ts} | 0 .../src/metabase/account/profile/selectors.ts | 12 ++ .../src/metabase/account/profile/types.ts | 6 + frontend/src/metabase/entities/users/forms.js | 141 +++--------------- 13 files changed, 193 insertions(+), 170 deletions(-) create mode 100644 frontend/src/metabase/account/profile/actions.ts delete mode 100644 frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.jsx create mode 100644 frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.tsx rename frontend/src/metabase/account/profile/components/UserProfileForm/{index.js => index.ts} (100%) delete mode 100644 frontend/src/metabase/account/profile/containers/UserProfileApp/UserProfileApp.jsx create mode 100644 frontend/src/metabase/account/profile/containers/UserProfileApp/UserProfileApp.tsx rename frontend/src/metabase/account/profile/containers/UserProfileApp/{index.js => index.ts} (100%) create mode 100644 frontend/src/metabase/account/profile/selectors.ts create mode 100644 frontend/src/metabase/account/profile/types.ts diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index b14d44d2bb42..cf0ade3c0356 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 => ({ "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 0f639288d219..39e65f649431 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 80763f476e4c..4f855f9a01f5 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 000000000000..b4756f45851f --- /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 4a31103534a7..000000000000 --- 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 ( - - ); -}; - -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 000000000000..b497a197af77 --- /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 ( + + {({ dirty }) => ( +
+ {!isSsoUser && ( + <> + + + + + )} + + + + + )} +
+ ); +}; + +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 5b26c02d115d..000000000000 --- 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 000000000000..dbde8a6ed261 --- /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 000000000000..46c12d573cc5 --- /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 000000000000..3518f64ee02c --- /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 b4ee16abd3d6..ae1282a6c391 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, ], }, }; From 809a6444e9f78d01cb78404797f4b331b2ea8e42 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 21:11:05 +0000 Subject: [PATCH 022/269] Add FormTextArea (#26257) (#26259) Co-authored-by: Alexander Polyankin --- .../UserProfileForm/UserProfileForm.tsx | 10 ++++- .../ForgotPassword.unit.spec.tsx | 5 ++- .../FormErrorMessage.styled.tsx | 8 +++- .../FormErrorMessage/FormErrorMessage.tsx | 12 ++--- .../core/components/FormInput/FormInput.tsx | 2 +- .../FormNumericInput/FormNumericInput.tsx | 2 +- .../components/FormTextArea/FormTextArea.tsx | 44 +++++++++++++++++++ .../core/components/FormTextArea/index.ts | 2 + .../core/components/Input/Input.styled.tsx | 4 +- .../metabase/core/components/Input/Input.tsx | 3 +- .../components/TextArea/TextArea.styled.tsx | 40 +++++++++++++++++ .../core/components/TextArea/TextArea.tsx | 19 ++++++++ .../core/components/TextArea/index.ts | 2 + .../InviteUserForm/InviteUserForm.tsx | 10 ++++- .../setup/components/UserForm/UserForm.tsx | 10 ++++- 15 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx create mode 100644 frontend/src/metabase/core/components/FormTextArea/index.ts create mode 100644 frontend/src/metabase/core/components/TextArea/TextArea.styled.tsx create mode 100644 frontend/src/metabase/core/components/TextArea/TextArea.tsx create mode 100644 frontend/src/metabase/core/components/TextArea/index.ts diff --git a/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.tsx b/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.tsx index b497a197af77..0893ec8728c2 100644 --- a/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.tsx +++ b/frontend/src/metabase/account/profile/components/UserProfileForm/UserProfileForm.tsx @@ -16,8 +16,14 @@ const SsoProfileSchema = Yup.object({ }); 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`), + first_name: Yup.string().max( + 100, + ({ max }) => t`must be ${max} characters or less`, + ), + last_name: Yup.string().max( + 100, + ({ max }) => t`must be ${max} characters or less`, + ), email: Yup.string() .required(t`required`) .email(t`must be a valid email address`), diff --git a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx index d78c422571aa..685dd6431c91 100644 --- a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx @@ -21,8 +21,11 @@ describe("ForgotPassword", () => { render(); userEvent.type(screen.getByLabelText("Email address"), email); - userEvent.click(await screen.findByText("Send password reset email")); + await waitFor(() => { + expect(screen.getByText("Send password reset email")).toBeEnabled(); + }); + userEvent.click(screen.getByText("Send password reset email")); await waitFor(() => { expect(props.onResetPassword).toHaveBeenCalledWith(email); expect(screen.getByText(/Check your email/)).toBeInTheDocument(); diff --git a/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx index b192d4034703..ce5debaeb693 100644 --- a/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx +++ b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx @@ -1,7 +1,11 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; -export const ErrorMessageRoot = styled.div` +export interface ErrorMessageRootProps { + inline?: boolean; +} + +export const ErrorMessageRoot = styled.div` color: ${color("error")}; - margin-top: 1em; + margin-top: ${props => !props.inline && "1rem"}; `; diff --git a/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx index 3d1b04c34dd5..d3bffcfa0898 100644 --- a/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx +++ b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx @@ -2,13 +2,13 @@ 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, - "children" ->; +export interface FormErrorMessageProps + extends Omit, "children"> { + inline?: boolean; +} const FormErrorMessage = forwardRef(function FormErrorMessage( - props: FormErrorMessageProps, + { inline, ...props }: FormErrorMessageProps, ref: Ref, ) { const message = useFormErrorMessage(); @@ -17,7 +17,7 @@ const FormErrorMessage = forwardRef(function FormErrorMessage( } return ( - + {message} ); diff --git a/frontend/src/metabase/core/components/FormInput/FormInput.tsx b/frontend/src/metabase/core/components/FormInput/FormInput.tsx index 43c062921d95..d7039d68fb46 100644 --- a/frontend/src/metabase/core/components/FormInput/FormInput.tsx +++ b/frontend/src/metabase/core/components/FormInput/FormInput.tsx @@ -13,7 +13,7 @@ export interface FormInputProps const FormInput = forwardRef(function FormInput( { name, className, style, title, description, ...props }: FormInputProps, - ref: Ref, + ref: Ref, ) { const id = useUniqueId(); const [field, meta] = useField(name); diff --git a/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.tsx b/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.tsx index e9ad4572d48a..0bdccbb46ece 100644 --- a/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.tsx +++ b/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.tsx @@ -22,7 +22,7 @@ const FormNumericInput = forwardRef(function FormNumericInput( description, ...props }: FormNumericInputProps, - ref: Ref, + ref: Ref, ) { const id = useUniqueId(); const [field, meta, helpers] = useField(name); diff --git a/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx b/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx new file mode 100644 index 000000000000..dd68224e739c --- /dev/null +++ b/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef, ReactNode, Ref } from "react"; +import { useField } from "formik"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; +import TextArea, { TextAreaProps } from "metabase/core/components/TextArea"; +import FormField from "metabase/core/components/FormField"; + +export interface FormTextAreaProps + extends Omit { + name: string; + title?: string; + description?: ReactNode; +} + +const FormTextArea = forwardRef(function FormTextArea( + { name, className, style, title, description, ...props }: FormTextAreaProps, + ref: Ref, +) { + const id = useUniqueId(); + const [field, meta] = useField(name); + + return ( + +