diff --git a/frontend/src/component/admin/Admin.tsx b/frontend/src/component/admin/Admin.tsx index 2a7cdc8eec..be50b4963f 100644 --- a/frontend/src/component/admin/Admin.tsx +++ b/frontend/src/component/admin/Admin.tsx @@ -18,6 +18,7 @@ import UsersAdmin from './users/UsersAdmin'; import NotFound from 'component/common/NotFound/NotFound'; import { AdminIndex } from './AdminIndex'; import { AdminTabsMenu } from './menu/AdminTabsMenu'; +import { Banners } from './banners/Banners'; export const Admin = () => { return ( @@ -36,6 +37,7 @@ export const Admin = () => { } /> } /> } /> + } /> } /> } /> { + const { isEnterprise } = useUiConfig(); + + if (!isEnterprise()) { + return ; + } + + return ( +
+ + + +
+ ); +}; diff --git a/frontend/src/component/admin/banners/BannersTable/BannerDeleteDialog.tsx b/frontend/src/component/admin/banners/BannersTable/BannerDeleteDialog.tsx new file mode 100644 index 0000000000..e72e2fc958 --- /dev/null +++ b/frontend/src/component/admin/banners/BannersTable/BannerDeleteDialog.tsx @@ -0,0 +1,31 @@ +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { IInternalBanner } from 'interfaces/banner'; + +interface IBannerDeleteDialogProps { + banner?: IInternalBanner; + open: boolean; + setOpen: React.Dispatch>; + onConfirm: (banner: IInternalBanner) => void; +} + +export const BannerDeleteDialog = ({ + banner, + open, + setOpen, + onConfirm, +}: IBannerDeleteDialogProps) => ( + onConfirm(banner!)} + onClose={() => { + setOpen(false); + }} + > +

+ You are about to delete banner: {banner?.message} +

+
+); diff --git a/frontend/src/component/admin/banners/BannersTable/BannersActionsCell.tsx b/frontend/src/component/admin/banners/BannersTable/BannersActionsCell.tsx new file mode 100644 index 0000000000..cdc6f028c3 --- /dev/null +++ b/frontend/src/component/admin/banners/BannersTable/BannersActionsCell.tsx @@ -0,0 +1,44 @@ +import { Delete, Edit } from '@mui/icons-material'; +import { Box, styled } from '@mui/material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'center', +})); + +interface IBannersActionsCellProps { + onEdit: (event: React.SyntheticEvent) => void; + onDelete: (event: React.SyntheticEvent) => void; +} + +export const BannersActionsCell = ({ + onEdit, + onDelete, +}: IBannersActionsCellProps) => { + return ( + + + + + + + + + ); +}; diff --git a/frontend/src/component/admin/banners/BannersTable/BannersTable.tsx b/frontend/src/component/admin/banners/BannersTable/BannersTable.tsx new file mode 100644 index 0000000000..648066ff56 --- /dev/null +++ b/frontend/src/component/admin/banners/BannersTable/BannersTable.tsx @@ -0,0 +1,250 @@ +import { useMemo, useState } from 'react'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { Button, useMediaQuery } from '@mui/material'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { useFlexLayout, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import theme from 'themes/theme'; +import { Search } from 'component/common/Search/Search'; +import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; +import { useSearch } from 'hooks/useSearch'; +import { useBanners } from 'hooks/api/getters/useBanners/useBanners'; +import { useBannersApi } from 'hooks/api/actions/useMessageBannersApi/useMessageBannersApi'; +import { IInternalBanner } from 'interfaces/banner'; +import { Banner } from 'component/banners/Banner/Banner'; +import { BannersActionsCell } from './BannersActionsCell'; +import { BannerDeleteDialog } from './BannerDeleteDialog'; +import { ToggleCell } from 'component/common/Table/cells/ToggleCell/ToggleCell'; +import omit from 'lodash.omit'; + +export const BannersTable = () => { + const { setToastData, setToastApiError } = useToast(); + + const { banners, refetch, loading } = useBanners(); + const { updateBanner, removeBanner } = useBannersApi(); + + const [searchValue, setSearchValue] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [selectedBanner, setSelectedBanner] = useState(); + + const toggleBanner = async (banner: IInternalBanner, enabled: boolean) => { + try { + await updateBanner(banner.id, { + ...omit(banner, ['id', 'createdAt']), + enabled, + }); + setToastData({ + title: `"${banner.message}" has been ${ + enabled ? 'enabled' : 'disabled' + }`, + type: 'success', + }); + refetch(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onDeleteConfirm = async (banner: IInternalBanner) => { + try { + await removeBanner(banner.id); + setToastData({ + title: `"${banner.message}" has been deleted`, + type: 'success', + }); + refetch(); + setDeleteOpen(false); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const columns = useMemo( + () => [ + { + Header: 'Banner', + accessor: 'message', + Cell: ({ row: { original: banner } }: any) => ( + + ), + disableSortBy: true, + minWidth: 200, + searchable: true, + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + width: 120, + maxWidth: 120, + }, + { + Header: 'Enabled', + accessor: 'enabled', + Cell: ({ + row: { original: banner }, + }: { row: { original: IInternalBanner } }) => ( + toggleBanner(banner, enabled)} + /> + ), + sortType: 'boolean', + width: 90, + maxWidth: 90, + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ row: { original: banner } }: any) => ( + { + setSelectedBanner(banner); + setModalOpen(true); + }} + onDelete={() => { + setSelectedBanner(banner); + setDeleteOpen(true); + }} + /> + ), + width: 100, + disableSortBy: true, + }, + ], + [], + ); + + const [initialState] = useState({ + sortBy: [{ id: 'createdAt' }], + }); + + const { data, getSearchText } = useSearch(columns, searchValue, banners); + + const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable( + { + columns: columns as any, + data, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useSortBy, + useFlexLayout, + ); + + useConditionallyHiddenColumns( + [ + { + condition: isSmallScreen, + columns: ['createdAt'], + }, + ], + setHiddenColumns, + columns, + ); + + return ( + + + + + + } + /> + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No banners found matching “ + {searchValue} + ” + + } + elseShow={ + + No banners available. Get started by adding one. + + } + /> + } + /> + {/* */} + + + ); +}; diff --git a/frontend/src/component/banners/Banner/Banner.tsx b/frontend/src/component/banners/Banner/Banner.tsx index 4540859735..33b2ff76d9 100644 --- a/frontend/src/component/banners/Banner/Banner.tsx +++ b/frontend/src/component/banners/Banner/Banner.tsx @@ -14,19 +14,21 @@ import { BannerVariant, IBanner } from 'interfaces/banner'; import { Sticky } from 'component/common/Sticky/Sticky'; const StyledBar = styled('aside', { - shouldForwardProp: (prop) => prop !== 'variant', -})<{ variant: BannerVariant }>(({ theme, variant }) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: theme.spacing(1), - gap: theme.spacing(1), - borderBottom: '1px solid', - borderColor: theme.palette[variant].border, - background: theme.palette[variant].light, - color: theme.palette[variant].dark, - fontSize: theme.fontSizes.smallBody, -})); + shouldForwardProp: (prop) => prop !== 'variant' && prop !== 'inline', +})<{ variant: BannerVariant; inline?: boolean }>( + ({ theme, variant, inline }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(1), + gap: theme.spacing(1), + borderBottom: inline ? 'none' : '1px solid', + borderColor: theme.palette[variant].border, + background: theme.palette[variant].light, + color: theme.palette[variant].dark, + fontSize: theme.fontSizes.smallBody, + }), +); const StyledIcon = styled('div', { shouldForwardProp: (prop) => prop !== 'variant', @@ -38,9 +40,10 @@ const StyledIcon = styled('div', { interface IBannerProps { banner: IBanner; + inline?: boolean; } -export const Banner = ({ banner }: IBannerProps) => { +export const Banner = ({ banner, inline }: IBannerProps) => { const [open, setOpen] = useState(false); const { @@ -56,7 +59,7 @@ export const Banner = ({ banner }: IBannerProps) => { } = banner; const bannerBar = ( - + diff --git a/frontend/src/component/banners/internalBanners/InternalBanners.tsx b/frontend/src/component/banners/internalBanners/InternalBanners.tsx index 3db8dbd90a..1574cb2eef 100644 --- a/frontend/src/component/banners/internalBanners/InternalBanners.tsx +++ b/frontend/src/component/banners/internalBanners/InternalBanners.tsx @@ -6,9 +6,11 @@ export const InternalBanners = () => { return ( <> - {banners.map((banner) => ( - - ))} + {banners + .filter(({ enabled }) => enabled) + .map((banner) => ( + + ))} ); }; diff --git a/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx b/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx index bef791f492..dbdff39e36 100644 --- a/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx +++ b/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx @@ -103,6 +103,11 @@ const PremiumFeatures = { url: 'https://docs.getunleash.io/reference/projects', label: 'Project settings', }, + banners: { + plan: FeaturePlan.ENTERPRISE, + url: 'https://docs.getunleash.io/reference/banners', + label: 'Banners', + }, }; type PremiumFeatureType = keyof typeof PremiumFeatures; diff --git a/frontend/src/component/common/Table/cells/ToggleCell/ToggleCell.tsx b/frontend/src/component/common/Table/cells/ToggleCell/ToggleCell.tsx new file mode 100644 index 0000000000..7612a64aac --- /dev/null +++ b/frontend/src/component/common/Table/cells/ToggleCell/ToggleCell.tsx @@ -0,0 +1,23 @@ +import { Switch, Tooltip } from '@mui/material'; +import { TextCell } from '../TextCell/TextCell'; + +interface IToggleCellProps { + checked: boolean; + setChecked: (value: boolean) => void; + title?: string; +} + +export const ToggleCell = ({ + checked, + setChecked, + title, +}: IToggleCellProps) => ( + + + setChecked(e.target.checked)} + /> + + +);