feat(system/upgrade): add get license dialog [EE-4743] (#8249)

pull/8311/head
Chaim Lev-Ari 2 years ago committed by GitHub
parent 5942f4ff58
commit 406ff8812c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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 }]

42
app/global.d.ts vendored

@ -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<HTMLFormElement>) => 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<HTMLFormElement>) => 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<HTMLFormElement>) => 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' {

@ -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 (
<Alert color={color} title={title}>
{text}
</Alert>
);
}
export const Success: Story<Args> = 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<Args> = Template.bind({});
Error.args = {
color: 'error',
title: 'Error',
text: 'This is an error alert',
};
export const Info: Story<Args> = Template.bind({});
Info.args = {
color: 'info',
title: 'Info',
text: 'This is an info alert',
};

@ -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 (
<AlertContainer className={container}>
<AlertHeader className={header}>
<Icon icon={icon} />
{title}
</AlertHeader>
<AlertBody className={body}>{children}</AlertBody>
</AlertContainer>
);
}
function AlertContainer({
className,
children,
}: PropsWithChildren<{ className?: string }>) {
return (
<div className={clsx('border-2 border-solid rounded-md', 'p-3', className)}>
{children}
</div>
);
}
function AlertHeader({
className,
children,
}: PropsWithChildren<{ className?: string }>) {
return (
<h4
className={clsx('text-base', 'flex gap-2 items-center !m-0', className)}
>
{children}
</h4>
);
}
function AlertBody({
className,
children,
}: PropsWithChildren<{ className?: string }>) {
return <div className={clsx('ml-6 mt-2 text-sm', className)}>{children}</div>;
}

@ -0,0 +1 @@
export { Alert } from './Alert';

@ -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<HTMLDivElement>(null);
const id = useRef(`reactHubspotForm${globalId++}`);
const { isLoading } = useHubspotForm({
elId: id.current,
formId,
portalId,
region,
onSubmitted,
});
return (
<>
<div
ref={elRef}
id={id.current}
style={{ display: isLoading ? 'none' : 'block' }}
/>
{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<void>((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<HubSpotCreateFormOptions, 'target'>
) {
return new Promise<void>((resolve) => {
if (!window.hbspt) {
throw new Error('hbspt object is missing');
}
window.hbspt.forms.create({
...options,
target,
onFormReady(...rest) {
options.onFormReady?.(...rest);
resolve();
},
});
});
}

@ -12,7 +12,11 @@ export function CloseButton({
return (
<button
type="button"
className={clsx(styles.close, className, 'absolute top-4 right-5')}
className={clsx(
styles.close,
className,
'absolute top-4 right-5 close-button'
)}
onClick={() => onClose()}
>
×

@ -3,7 +3,6 @@
}
.modal-dialog {
width: 450px;
display: inline-block;
text-align: left;
vertical-align: middle;

@ -21,6 +21,8 @@ interface Props {
onDismiss?(): void;
'aria-label'?: string;
'aria-labelledby'?: string;
size?: 'md' | 'lg';
className?: string;
}
export function Modal({
@ -28,6 +30,8 @@ export function Modal({
onDismiss,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
size = 'md',
className,
}: PropsWithChildren<Props>) {
return (
<Context.Provider value>
@ -43,9 +47,12 @@ export function Modal({
<DialogContent
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={clsx(styles.modalDialog, 'p-0 bg-transparent')}
className={clsx(styles.modalDialog, 'p-0 bg-transparent', {
'w-[450px]': size === 'md',
'w-[700px]': size === 'lg',
})}
>
<div className={clsx(styles.modalContent, 'relative')}>
<div className={clsx(styles.modalContent, 'relative', className)}>
{children}
{onDismiss && <CloseButton onClose={onDismiss} />}
</div>

@ -0,0 +1,35 @@
import { HubspotForm } from '@@/HubspotForm';
import { Modal } from '@@/modals/Modal';
export function GetLicenseDialog({
onDismiss,
goToUploadLicense,
}: {
onDismiss: () => void;
goToUploadLicense: (isSubmitted: boolean) => void;
}) {
// form is loaded from hubspot, so it won't have the same styling as the rest of the app
// since it won't support darkmode, we enforce a white background and black text for the components we use
// (Modal, CloseButton, loading text)
return (
<Modal
onDismiss={onDismiss}
aria-label="Upgrade Portainer to Business Edition"
size="lg"
className="!bg-white [&>.close-button]:!text-black"
>
<Modal.Body>
<div className="max-h-[80vh] overflow-auto">
<HubspotForm
region="na1"
portalId="4731999"
formId="1ef8ea88-3e03-46c5-8aef-c1d9f48fd06b"
onSubmitted={() => goToUploadLicense(true)}
loading={<div className="text-black">Loading...</div>}
/>
</div>
</Modal.Body>
</Modal>
);
}

@ -5,13 +5,14 @@ import { useUser } from '@/react/hooks/useUser';
import { UploadLicenseDialog } from './UploadLicenseDialog';
import { LoadingDialog } from './LoadingDialog';
import { NonAdminUpgradeDialog } from './NonAdminUpgradeDialog';
import { GetLicenseDialog } from './GetLicenseDialog';
type Step = 'uploadLicense' | 'loading' | 'getLicense';
export function UpgradeDialog({ onDismiss }: { onDismiss: () => void }) {
const { isAdmin } = useUser();
const [currentStep, setCurrentStep] = useState<Step>('uploadLicense');
const [isGetLicenseSubmitted, setIsGetLicenseSubmitted] = useState(false);
const component = getDialog();
return component;
@ -23,13 +24,22 @@ export function UpgradeDialog({ onDismiss }: { onDismiss: () => void }) {
switch (currentStep) {
case 'getLicense':
throw new Error('Not implemented');
// return <GetLicense setCurrentStep={setCurrentStep} />;
return (
<GetLicenseDialog
goToUploadLicense={(isSubmitted) => {
setCurrentStep('uploadLicense');
setIsGetLicenseSubmitted(isSubmitted);
}}
onDismiss={onDismiss}
/>
);
case 'uploadLicense':
return (
<UploadLicenseDialog
goToLoading={() => setCurrentStep('loading')}
onDismiss={onDismiss}
goToGetLicense={() => setCurrentStep('getLicense')}
isGetLicenseSubmitted={isGetLicenseSubmitted}
/>
);
case 'loading':

@ -1,6 +1,5 @@
import { Field, Form, Formik } from 'formik';
import { object, SchemaOf, string } from 'yup';
import { ExternalLink } from 'lucide-react';
import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation';
import { notifySuccess } from '@/portainer/services/notifications';
@ -9,6 +8,7 @@ import { Button, LoadingButton } from '@@/buttons';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { Modal } from '@@/modals/Modal';
import { Alert } from '@@/Alert';
interface FormValues {
license: string;
@ -21,9 +21,13 @@ const initialValues: FormValues = {
export function UploadLicenseDialog({
onDismiss,
goToLoading,
goToGetLicense,
isGetLicenseSubmitted,
}: {
onDismiss: () => void;
goToLoading: () => void;
goToGetLicense: () => void;
isGetLicenseSubmitted: boolean;
}) {
const upgradeMutation = useUpgradeEditionMutation();
@ -44,9 +48,19 @@ export function UploadLicenseDialog({
{({ errors }) => (
<Form noValidate>
<Modal.Body>
<p className="font-semibold text-gray-7">
Please enter your Portainer License below
</p>
{!isGetLicenseSubmitted ? (
<p className="font-semibold text-gray-7">
Please enter your Portainer License below
</p>
) : (
<div className="mb-4">
<Alert color="success" title="License successfully sent">
Please check your email and copy your license into the field
below to upgrade Portainer.
</Alert>
</div>
)}
<FormControl
label="License"
errors={errors.license}
@ -58,21 +72,14 @@ export function UploadLicenseDialog({
</Modal.Body>
<Modal.Footer>
<div className="flex gap-2 [&>*]:w-1/2 w-full">
<a
href="https://www.portainer.io/pricing"
target="_blank"
rel="noreferrer"
className="no-link"
<Button
color="default"
size="medium"
className="w-full"
onClick={goToGetLicense}
>
<Button
color="default"
size="medium"
className="w-full"
icon={ExternalLink}
>
Get a license
</Button>
</a>
Get a license
</Button>
<LoadingButton
color="primary"
size="medium"

Loading…
Cancel
Save