diff --git a/frontend/src/component/admin/banners/BannerModal/BannerForm.tsx b/frontend/src/component/admin/banners/BannerModal/BannerForm.tsx new file mode 100644 index 000000000000..64762e7696b5 --- /dev/null +++ b/frontend/src/component/admin/banners/BannerModal/BannerForm.tsx @@ -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>; + setMessage: Dispatch>; + setVariant: Dispatch>; + setSticky: Dispatch>; + setIcon: Dispatch>; + setLink: Dispatch>; + setLinkText: Dispatch>; + setDialogTitle: Dispatch>; + setDialog: Dispatch>; +} + +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( + icon === '' ? 'Default' : icon === 'none' ? 'None' : 'Custom', + ); + const [linkOption, setLinkOption] = useState( + link === '' ? 'None' : link === 'dialog' ? 'Dialog' : 'Link', + ); + + return ( + + + Preview: + + + + + What is your banner message? + +

Markdown is supported.

+ + } + /> +
+ ) => + setMessage(e.target.value) + } + autoComplete='off' + required + /> +
+ + + What type of banner is it? + + setVariant(variant as BannerVariant)} + options={VARIANT_OPTIONS} + /> + + + + What icon should be displayed on the banner? + + { + setIconOption(iconOption as IconOption); + if (iconOption === 'None') { + setIcon('none'); + } else { + setIcon(''); + } + }} + options={['Default', 'Custom', 'None'].map((option) => ({ + key: option, + label: option, + }))} + /> + + + What custom icon should be displayed? + +

+ Choose an icon from{' '} + + Material Symbols + + . +

+

+ For example, if you want to + display the "Rocket Launch" + icon, you can enter + "rocket_launch" in the field + below. +

+ + } + /> +
+ ) => + setIcon(e.target.value) + } + autoComplete='off' + /> + + } + /> +
+ + + What action should be available in the banner? + + { + setLinkOption(linkOption as LinkOption); + if (linkOption === 'Dialog') { + setLink('dialog'); + } else { + setLink(''); + } + }} + options={['None', 'Link', 'Dialog'].map((option) => ({ + key: option, + label: option, + }))} + /> + + + What URL should be opened? + + ) => + setLink(e.target.value) + } + onBlur={() => { + if (!linkText) setLinkText(link); + }} + autoComplete='off' + /> + + } + /> + + + What is the action text? + + ) => + setLinkText(e.target.value) + } + autoComplete='off' + /> + + } + /> + + + What is the dialog title? + + ) => + setDialogTitle(e.target.value) + } + autoComplete='off' + /> + + What is the dialog content? + +

Markdown is supported.

+ + } + /> +
+ ) => + setDialog(e.target.value) + } + autoComplete='off' + /> + + } + /> +
+ + + Is the banner sticky on the screen when scrolling? + + + + + + Is the banner currently visible to all users? + + + +
+ ); +}; diff --git a/frontend/src/component/admin/banners/BannerModal/BannerModal.tsx b/frontend/src/component/admin/banners/BannerModal/BannerModal.tsx new file mode 100644 index 000000000000..f93b6c0f3425 --- /dev/null +++ b/frontend/src/component/admin/banners/BannerModal/BannerModal.tsx @@ -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>; +} + +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('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) => { + 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 ( + { + setOpen(false); + }} + label={title} + > + + + + + + { + setOpen(false); + }} + > + Cancel + + + + + + ); +}; diff --git a/frontend/src/component/admin/banners/BannersTable/BannersTable.tsx b/frontend/src/component/admin/banners/BannersTable/BannersTable.tsx index 648066ff56b6..c6e5dba68aac 100644 --- a/frontend/src/component/admin/banners/BannersTable/BannersTable.tsx +++ b/frontend/src/component/admin/banners/BannersTable/BannersTable.tsx @@ -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 = () => { /> } /> - {/* */} + /> ({ + marginLeft: theme.spacing(0.5), +})); + +interface IFormSwitchProps extends BoxProps { + checked: boolean; + setChecked: Dispatch>; + children?: ReactNode; +} + +export const FormSwitch = ({ + checked, + setChecked, + children, + ...props +}: IFormSwitchProps) => { + return ( + + {children} + setChecked(e.target.checked)} + /> + } + label={ + + {checked ? 'Enabled' : 'Disabled'} + + } + /> + + ); +}; diff --git a/frontend/src/hooks/api/actions/useMessageBannersApi/useMessageBannersApi.ts b/frontend/src/hooks/api/actions/useMessageBannersApi/useMessageBannersApi.ts index c65e93cbb998..18d724868674 100644 --- a/frontend/src/hooks/api/actions/useMessageBannersApi/useMessageBannersApi.ts +++ b/frontend/src/hooks/api/actions/useMessageBannersApi/useMessageBannersApi.ts @@ -3,7 +3,7 @@ import useAPI from '../useApi/useApi'; const ENDPOINT = 'api/admin/banners'; -type AddOrUpdateBanner = Omit; +export type AddOrUpdateBanner = Omit; export const useBannersApi = () => { const { loading, makeRequest, createRequest, errors } = useAPI({ diff --git a/frontend/src/interfaces/banner.ts b/frontend/src/interfaces/banner.ts index c7bbb8a38f6e..90206cac127d 100644 --- a/frontend/src/interfaces/banner.ts +++ b/frontend/src/interfaces/banner.ts @@ -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 { id: number; enabled: boolean; createdAt: string;