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:
parent
898c1b4bc7
commit
3ca22c7c5c
343
frontend/src/component/admin/banners/BannerModal/BannerForm.tsx
Normal file
343
frontend/src/component/admin/banners/BannerModal/BannerForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
173
frontend/src/component/admin/banners/BannerModal/BannerModal.tsx
Normal file
173
frontend/src/component/admin/banners/BannerModal/BannerModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
|
@ -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,
|
||||
|
45
frontend/src/component/common/FormSwitch/FormSwitch.tsx
Normal file
45
frontend/src/component/common/FormSwitch/FormSwitch.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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({
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user