1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-04 00:18:40 +01:00

feat: banner modal (#5132)

https://linear.app/unleash/issue/2-1548/ui-create-banner-newedit-modal

Adds a new banner modal (and form) that allows admins to create and edit
banners.
Also adds a new `FormSwitch` common component that may be helpful in
different situations where we need a switch on a form.

<img width="1263" alt="image"
src="https://github.com/Unleash/unleash/assets/14320932/1b89db9b-9003-413c-8829-c37d245e2487">
This commit is contained in:
Nuno Góis 2023-10-24 16:26:44 +01:00 committed by GitHub
parent 898c1b4bc7
commit 3ca22c7c5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 574 additions and 6 deletions

View File

@ -0,0 +1,343 @@
import { styled } from '@mui/material';
import { Banner } from 'component/banners/Banner/Banner';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FormSwitch } from 'component/common/FormSwitch/FormSwitch';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import Input from 'component/common/Input/Input';
import { BannerVariant } from 'interfaces/banner';
import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
const StyledForm = styled('form')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(4),
}));
const StyledFieldGroup = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}));
const StyledInputDescription = styled('p')(({ theme }) => ({
display: 'flex',
color: theme.palette.text.primary,
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
maxWidth: theme.spacing(50),
}));
const StyledTooltip = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(0.5),
gap: theme.spacing(0.5),
}));
const StyledSelect = styled(GeneralSelect)(({ theme }) => ({
width: '100%',
maxWidth: theme.spacing(50),
}));
const VARIANT_OPTIONS = [
{ key: 'info', label: 'Information' },
{ key: 'warning', label: 'Warning' },
{ key: 'error', label: 'Error' },
{ key: 'success', label: 'Success' },
];
type IconOption = 'Default' | 'Custom' | 'None';
type LinkOption = 'Link' | 'Dialog' | 'None';
interface IBannerFormProps {
enabled: boolean;
message: string;
variant: BannerVariant;
sticky: boolean;
icon: string;
link: string;
linkText: string;
dialogTitle: string;
dialog: string;
setEnabled: Dispatch<SetStateAction<boolean>>;
setMessage: Dispatch<SetStateAction<string>>;
setVariant: Dispatch<SetStateAction<BannerVariant>>;
setSticky: Dispatch<SetStateAction<boolean>>;
setIcon: Dispatch<SetStateAction<string>>;
setLink: Dispatch<SetStateAction<string>>;
setLinkText: Dispatch<SetStateAction<string>>;
setDialogTitle: Dispatch<SetStateAction<string>>;
setDialog: Dispatch<SetStateAction<string>>;
}
export const BannerForm = ({
enabled,
message,
variant,
sticky,
icon,
link,
linkText,
dialogTitle,
dialog,
setEnabled,
setMessage,
setVariant,
setSticky,
setIcon,
setLink,
setLinkText,
setDialogTitle,
setDialog,
}: IBannerFormProps) => {
const [iconOption, setIconOption] = useState<IconOption>(
icon === '' ? 'Default' : icon === 'none' ? 'None' : 'Custom',
);
const [linkOption, setLinkOption] = useState<LinkOption>(
link === '' ? 'None' : link === 'dialog' ? 'Dialog' : 'Link',
);
return (
<StyledForm>
<StyledFieldGroup>
<StyledInputDescription>Preview:</StyledInputDescription>
<Banner
banner={{
message:
message ||
'*No message set. Please enter a message below.*',
variant,
sticky: false,
icon,
link,
linkText,
dialogTitle,
dialog,
}}
inline
/>
</StyledFieldGroup>
<StyledFieldGroup>
<StyledInputDescription>
What is your banner message?
<HelpIcon
tooltip={
<StyledTooltip>
<p>Markdown is supported.</p>
</StyledTooltip>
}
/>
</StyledInputDescription>
<StyledInput
autoFocus
label='Banner message'
value={message}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setMessage(e.target.value)
}
autoComplete='off'
required
/>
</StyledFieldGroup>
<StyledFieldGroup>
<StyledInputDescription>
What type of banner is it?
</StyledInputDescription>
<StyledSelect
size='small'
value={variant}
onChange={(variant) => setVariant(variant as BannerVariant)}
options={VARIANT_OPTIONS}
/>
</StyledFieldGroup>
<StyledFieldGroup>
<StyledInputDescription>
What icon should be displayed on the banner?
</StyledInputDescription>
<StyledSelect
size='small'
value={iconOption}
onChange={(iconOption) => {
setIconOption(iconOption as IconOption);
if (iconOption === 'None') {
setIcon('none');
} else {
setIcon('');
}
}}
options={['Default', 'Custom', 'None'].map((option) => ({
key: option,
label: option,
}))}
/>
<ConditionallyRender
condition={iconOption === 'Custom'}
show={
<>
<StyledInputDescription>
What custom icon should be displayed?
<HelpIcon
tooltip={
<StyledTooltip>
<p>
Choose an icon from{' '}
<a
href='https://fonts.google.com/icons'
target='_blank'
rel='noreferrer'
>
Material Symbols
</a>
.
</p>
<p>
For example, if you want to
display the "Rocket Launch"
icon, you can enter
"rocket_launch" in the field
below.
</p>
</StyledTooltip>
}
/>
</StyledInputDescription>
<StyledInput
label='Banner icon'
value={icon}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setIcon(e.target.value)
}
autoComplete='off'
/>
</>
}
/>
</StyledFieldGroup>
<StyledFieldGroup>
<StyledInputDescription>
What action should be available in the banner?
</StyledInputDescription>
<StyledSelect
size='small'
value={linkOption}
onChange={(linkOption) => {
setLinkOption(linkOption as LinkOption);
if (linkOption === 'Dialog') {
setLink('dialog');
} else {
setLink('');
}
}}
options={['None', 'Link', 'Dialog'].map((option) => ({
key: option,
label: option,
}))}
/>
<ConditionallyRender
condition={linkOption === 'Link'}
show={
<>
<StyledInputDescription>
What URL should be opened?
</StyledInputDescription>
<StyledInput
label='URL'
value={link}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setLink(e.target.value)
}
onBlur={() => {
if (!linkText) setLinkText(link);
}}
autoComplete='off'
/>
</>
}
/>
<ConditionallyRender
condition={linkOption !== 'None'}
show={
<>
<StyledInputDescription>
What is the action text?
</StyledInputDescription>
<StyledInput
label='Action text'
value={linkText}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setLinkText(e.target.value)
}
autoComplete='off'
/>
</>
}
/>
<ConditionallyRender
condition={linkOption === 'Dialog'}
show={
<>
<StyledInputDescription>
What is the dialog title?
</StyledInputDescription>
<StyledInput
label='Dialog title'
value={dialogTitle}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setDialogTitle(e.target.value)
}
autoComplete='off'
/>
<StyledInputDescription>
What is the dialog content?
<HelpIcon
tooltip={
<StyledTooltip>
<p>Markdown is supported.</p>
</StyledTooltip>
}
/>
</StyledInputDescription>
<StyledInput
label='Dialog content'
multiline
minRows={4}
value={dialog}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setDialog(e.target.value)
}
autoComplete='off'
/>
</>
}
/>
</StyledFieldGroup>
<StyledFieldGroup>
<StyledInputDescription>
Is the banner sticky on the screen when scrolling?
</StyledInputDescription>
<FormSwitch
checked={sticky}
setChecked={setSticky}
sx={{
justifyContent: 'start',
}}
/>
</StyledFieldGroup>
<StyledFieldGroup>
<StyledInputDescription>
Is the banner currently visible to all users?
</StyledInputDescription>
<FormSwitch
checked={enabled}
setChecked={setEnabled}
sx={{
justifyContent: 'start',
}}
/>
</StyledFieldGroup>
</StyledForm>
);
};

View File

@ -0,0 +1,173 @@
import { Button, styled } from '@mui/material';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { FormEvent, useEffect, useState } from 'react';
import { BannerVariant, IInternalBanner } from 'interfaces/banner';
import { useBanners } from 'hooks/api/getters/useBanners/useBanners';
import {
AddOrUpdateBanner,
useBannersApi,
} from 'hooks/api/actions/useMessageBannersApi/useMessageBannersApi';
import { BannerForm } from './BannerForm';
const StyledForm = styled('form')(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
paddingTop: theme.spacing(4),
}));
const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
interface IBannerModalProps {
banner?: IInternalBanner;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const BannerModal = ({ banner, open, setOpen }: IBannerModalProps) => {
const { refetch } = useBanners();
const { addBanner, updateBanner, loading } = useBannersApi();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const [enabled, setEnabled] = useState(true);
const [message, setMessage] = useState('');
const [variant, setVariant] = useState<BannerVariant>('info');
const [sticky, setSticky] = useState(false);
const [icon, setIcon] = useState('');
const [link, setLink] = useState('');
const [linkText, setLinkText] = useState('');
const [dialogTitle, setDialogTitle] = useState('');
const [dialog, setDialog] = useState('');
useEffect(() => {
setEnabled(banner?.enabled ?? true);
setMessage(banner?.message || '');
setVariant(banner?.variant || 'info');
setSticky(banner?.sticky || false);
setIcon(banner?.icon || '');
setLink(banner?.link || '');
setLinkText(banner?.linkText || '');
setDialogTitle(banner?.dialogTitle || '');
setDialog(banner?.dialog || '');
}, [open, banner]);
const editing = banner !== undefined;
const title = editing ? 'Edit banner' : 'New banner';
const isValid = message.length;
const payload: AddOrUpdateBanner = {
message,
variant,
icon,
link,
linkText,
dialogTitle,
dialog,
sticky,
enabled,
};
const formatApiCode = () => {
return `curl --location --request ${editing ? 'PUT' : 'POST'} '${
uiConfig.unleashUrl
}/api/admin/banners${editing ? `/${banner.id}` : ''}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
};
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isValid) return;
try {
if (editing) {
await updateBanner(banner.id, payload);
} else {
await addBanner(payload);
}
setToastData({
title: `Banner ${editing ? 'updated' : 'added'} successfully`,
type: 'success',
});
refetch();
setOpen(false);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<SidebarModal
open={open}
onClose={() => {
setOpen(false);
}}
label={title}
>
<FormTemplate
loading={loading}
modal
title={title}
description='Banners allow you to display messages to other users inside your Unleash instance.'
documentationLink='https://docs.getunleash.io/reference/banners'
documentationLinkLabel='Banners documentation'
formatApiCode={formatApiCode}
>
<StyledForm onSubmit={onSubmit}>
<BannerForm
enabled={enabled}
message={message}
variant={variant}
sticky={sticky}
icon={icon}
link={link}
linkText={linkText}
dialogTitle={dialogTitle}
dialog={dialog}
setEnabled={setEnabled}
setMessage={setMessage}
setVariant={setVariant}
setSticky={setSticky}
setIcon={setIcon}
setLink={setLink}
setLinkText={setLinkText}
setDialogTitle={setDialogTitle}
setDialog={setDialog}
/>
<StyledButtonContainer>
<Button
type='submit'
variant='contained'
color='primary'
disabled={!isValid}
>
{editing ? 'Save' : 'Add'} banner
</Button>
<StyledCancelButton
onClick={() => {
setOpen(false);
}}
>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</SidebarModal>
);
};

View File

@ -23,6 +23,7 @@ import { BannersActionsCell } from './BannersActionsCell';
import { BannerDeleteDialog } from './BannerDeleteDialog';
import { ToggleCell } from 'component/common/Table/cells/ToggleCell/ToggleCell';
import omit from 'lodash.omit';
import { BannerModal } from '../BannerModal/BannerModal';
export const BannersTable = () => {
const { setToastData, setToastApiError } = useToast();
@ -234,11 +235,11 @@ export const BannersTable = () => {
/>
}
/>
{/* <BannerModal
<BannerModal
banner={selectedBanner}
open={modalOpen}
setOpen={setModalOpen}
/> */}
/>
<BannerDeleteDialog
banner={selectedBanner}
open={deleteOpen}

View File

@ -22,7 +22,13 @@ const StyledBar = styled('aside', {
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: inline ? 'none' : '1px solid',
...(inline
? {
border: '1px solid',
}
: {
borderBottom: '1px solid',
}),
borderColor: theme.palette[variant].border,
background: theme.palette[variant].light,
color: theme.palette[variant].dark,

View File

@ -0,0 +1,45 @@
import { Box, BoxProps, FormControlLabel, Switch, styled } from '@mui/material';
import { Dispatch, ReactNode, SetStateAction } from 'react';
const StyledContainer = styled(Box)({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
});
const StyledSwitchSpan = styled('span')(({ theme }) => ({
marginLeft: theme.spacing(0.5),
}));
interface IFormSwitchProps extends BoxProps {
checked: boolean;
setChecked: Dispatch<SetStateAction<boolean>>;
children?: ReactNode;
}
export const FormSwitch = ({
checked,
setChecked,
children,
...props
}: IFormSwitchProps) => {
return (
<StyledContainer {...props}>
{children}
<FormControlLabel
control={
<Switch
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
}
label={
<StyledSwitchSpan>
{checked ? 'Enabled' : 'Disabled'}
</StyledSwitchSpan>
}
/>
</StyledContainer>
);
};

View File

@ -3,7 +3,7 @@ import useAPI from '../useApi/useApi';
const ENDPOINT = 'api/admin/banners';
type AddOrUpdateBanner = Omit<IInternalBanner, 'id' | 'createdAt'>;
export type AddOrUpdateBanner = Omit<IInternalBanner, 'id' | 'createdAt'>;
export const useBannersApi = () => {
const { loading, makeRequest, createRequest, errors } = useAPI({

View File

@ -1,4 +1,4 @@
export type BannerVariant = 'warning' | 'info' | 'error' | 'success';
export type BannerVariant = 'info' | 'warning' | 'error' | 'success';
export interface IBanner {
message: string;
@ -12,7 +12,7 @@ export interface IBanner {
dialog?: string;
}
export interface IInternalBanner extends IBanner {
export interface IInternalBanner extends Omit<IBanner, 'plausibleEvent'> {
id: number;
enabled: boolean;
createdAt: string;