From 406ff8812c6ec7ceec8346b22eb1a6ca012ab5e3 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 19 Jan 2023 15:31:49 +0200 Subject: [PATCH] feat(system/upgrade): add get license dialog [EE-4743] (#8249) --- .eslintrc.yml | 1 + app/global.d.ts | 42 +++++++ app/react/components/Alert/Alert.stories.tsx | 43 +++++++ app/react/components/Alert/Alert.tsx | 83 +++++++++++++ app/react/components/Alert/index.ts | 1 + app/react/components/HubspotForm.tsx | 112 ++++++++++++++++++ .../components/modals/Modal/CloseButton.tsx | 6 +- .../components/modals/Modal/Modal.module.css | 1 - app/react/components/modals/Modal/Modal.tsx | 11 +- .../UpgradeBEBanner/GetLicenseDialog.tsx | 35 ++++++ .../sidebar/UpgradeBEBanner/UpgradeDialog.tsx | 16 ++- .../UpgradeBEBanner/UploadLicenseDialog.tsx | 43 ++++--- 12 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 app/react/components/Alert/Alert.stories.tsx create mode 100644 app/react/components/Alert/Alert.tsx create mode 100644 app/react/components/Alert/index.ts create mode 100644 app/react/components/HubspotForm.tsx create mode 100644 app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx diff --git a/.eslintrc.yml b/.eslintrc.yml index 5612e359c..7d167bda5 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -83,6 +83,7 @@ overrides: 'newlines-between': 'always', }, ] + no-plusplus: off func-style: [error, 'declaration'] import/prefer-default-export: off no-use-before-define: ['error', { functions: false }] diff --git a/app/global.d.ts b/app/global.d.ts index a19a91e56..471a29ca6 100644 --- a/app/global.d.ts +++ b/app/global.d.ts @@ -30,11 +30,53 @@ declare module 'axios-progress-bar' { ): void; } +interface HubSpotCreateFormOptions { + /** User's portal ID */ + portalId: string; + /** Unique ID of the form you wish to build */ + formId: string; + + region: string; + /** + * jQuery style selector specifying an existing element on the page into which the form will be placed once built. + * + * NOTE: If you're including multiple forms on the page, it is strongly recommended that you include a separate, specific target for each form. + */ + target: string; + /** + * Callback that executes after form is validated, just before the data is actually sent. + * This is for any logic that needs to execute during the submit. + * Any changes will not be validated. + * Takes the jQuery form object as the argument: onFormSubmit($form). + * + * Note: Performing a browser redirect in this callback is not recommended and could prevent the form submission + */ + onFormSubmit?: (form: JQuery) => void; + /** + * Callback when the data is actually sent. + * This allows you to perform an action when the submission is fully complete, + * such as displaying a confirmation or thank you message. + */ + onFormSubmitted?: (form: JQuery) => void; + /** + * Callback that executes after form is built, placed in the DOM, and validation has been initialized. + * This is perfect for any logic that needs to execute when the form is on the page. + * + * Takes the jQuery form object as the argument: onFormReady($form) + */ + onFormReady?: (form: JQuery) => void; +} + interface Window { /** * will be true if portainer is run as a Docker Desktop Extension */ ddExtension?: boolean; + hbspt?: { + forms: { + create: (options: HubSpotCreateFormOptions) => void; + }; + }; } declare module 'process' { diff --git a/app/react/components/Alert/Alert.stories.tsx b/app/react/components/Alert/Alert.stories.tsx new file mode 100644 index 000000000..8d1472bd5 --- /dev/null +++ b/app/react/components/Alert/Alert.stories.tsx @@ -0,0 +1,43 @@ +import { Meta, Story } from '@storybook/react'; + +import { Alert } from './Alert'; + +export default { + component: Alert, + title: 'Components/Alert', +} as Meta; + +interface Args { + color: 'success' | 'error' | 'info'; + title: string; + text: string; +} + +function Template({ text, color, title }: Args) { + return ( + + {text} + + ); +} + +export const Success: Story = Template.bind({}); +Success.args = { + color: 'success', + title: 'Success', + text: 'This is a success alert. Very long text, Very long text,Very long text ,Very long text ,Very long text, Very long text', +}; + +export const Error: Story = Template.bind({}); +Error.args = { + color: 'error', + title: 'Error', + text: 'This is an error alert', +}; + +export const Info: Story = Template.bind({}); +Info.args = { + color: 'info', + title: 'Info', + text: 'This is an info alert', +}; diff --git a/app/react/components/Alert/Alert.tsx b/app/react/components/Alert/Alert.tsx new file mode 100644 index 000000000..e8993d973 --- /dev/null +++ b/app/react/components/Alert/Alert.tsx @@ -0,0 +1,83 @@ +import clsx from 'clsx'; +import { AlertCircle, CheckCircle, XCircle } from 'lucide-react'; +import { PropsWithChildren, ReactNode } from 'react'; + +import { Icon } from '@@/Icon'; + +type AlertType = 'success' | 'error' | 'info'; + +const alertSettings: Record< + AlertType, + { container: string; header: string; body: string; icon: ReactNode } +> = { + success: { + container: + 'border-green-4 bg-green-2 th-dark:bg-green-3 th-dark:border-green-5', + header: 'text-green-8', + body: 'text-green-7', + icon: CheckCircle, + }, + error: { + container: + 'border-error-4 bg-error-2 th-dark:bg-error-3 th-dark:border-error-5', + header: 'text-error-8', + body: 'text-error-7', + icon: XCircle, + }, + info: { + container: + 'border-blue-4 bg-blue-2 th-dark:bg-blue-3 th-dark:border-blue-5', + header: 'text-blue-8', + body: 'text-blue-7', + icon: AlertCircle, + }, +}; + +export function Alert({ + color, + title, + children, +}: PropsWithChildren<{ color: AlertType; title: string }>) { + const { container, header, body, icon } = alertSettings[color]; + + return ( + + + + {title} + + {children} + + ); +} + +function AlertContainer({ + className, + children, +}: PropsWithChildren<{ className?: string }>) { + return ( +
+ {children} +
+ ); +} + +function AlertHeader({ + className, + children, +}: PropsWithChildren<{ className?: string }>) { + return ( +

+ {children} +

+ ); +} + +function AlertBody({ + className, + children, +}: PropsWithChildren<{ className?: string }>) { + return
{children}
; +} diff --git a/app/react/components/Alert/index.ts b/app/react/components/Alert/index.ts new file mode 100644 index 000000000..5adeb5a0c --- /dev/null +++ b/app/react/components/Alert/index.ts @@ -0,0 +1 @@ +export { Alert } from './Alert'; diff --git a/app/react/components/HubspotForm.tsx b/app/react/components/HubspotForm.tsx new file mode 100644 index 000000000..3c1225df7 --- /dev/null +++ b/app/react/components/HubspotForm.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useRef } from 'react'; +import { useQuery } from 'react-query'; + +let globalId = 0; + +interface Props { + portalId: HubSpotCreateFormOptions['portalId']; + formId: HubSpotCreateFormOptions['formId']; + region: HubSpotCreateFormOptions['region']; + + onSubmitted: () => void; + + loading?: ReactNode; +} + +export function HubspotForm({ + loading, + portalId, + region, + formId, + onSubmitted, +}: Props) { + const elRef = useRef(null); + const id = useRef(`reactHubspotForm${globalId++}`); + const { isLoading } = useHubspotForm({ + elId: id.current, + formId, + portalId, + region, + onSubmitted, + }); + + return ( + <> +
+ {isLoading && loading} + + ); +} + +function useHubspotForm({ + elId, + formId, + portalId, + region, + onSubmitted, +}: { + elId: string; + portalId: HubSpotCreateFormOptions['portalId']; + formId: HubSpotCreateFormOptions['formId']; + region: HubSpotCreateFormOptions['region']; + + onSubmitted: () => void; +}) { + return useQuery( + ['hubspot', { elId, formId, portalId, region }], + async () => { + await loadHubspot(); + await createForm(`#${elId}`, { + formId, + portalId, + region, + onFormSubmit: onSubmitted, + }); + }, + { + refetchOnWindowFocus: false, + } + ); +} + +async function loadHubspot() { + return new Promise((resolve) => { + if (window.hbspt) { + resolve(); + return; + } + + const script = document.createElement(`script`); + + script.defer = true; + script.onload = () => { + resolve(); + }; + script.src = `//js.hsforms.net/forms/v2.js`; + document.head.appendChild(script); + }); +} + +async function createForm( + target: string, + options: Omit +) { + return new Promise((resolve) => { + if (!window.hbspt) { + throw new Error('hbspt object is missing'); + } + + window.hbspt.forms.create({ + ...options, + target, + onFormReady(...rest) { + options.onFormReady?.(...rest); + resolve(); + }, + }); + }); +} diff --git a/app/react/components/modals/Modal/CloseButton.tsx b/app/react/components/modals/Modal/CloseButton.tsx index 22ccbddfd..7b47cc2f2 100644 --- a/app/react/components/modals/Modal/CloseButton.tsx +++ b/app/react/components/modals/Modal/CloseButton.tsx @@ -12,7 +12,11 @@ export function CloseButton({ return ( - + Get a license +