1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: banners admin page (#5111)

https://linear.app/unleash/issue/2-1484/ui-create-an-admin-banners-configuration-page

Adds a new "Banners" page to the admin UI.
This first iteration allows admins to list and preview all configured
message banners, toggle them (whether they are currently visible to all
users or not), and remove them.

Next step will be creating the modal for "new" and "edit" operations.

### Admin menu

![image](https://github.com/Unleash/unleash/assets/14320932/39bcf575-b03a-481b-b19e-fc87697ed51c)

### Banners page

![image](https://github.com/Unleash/unleash/assets/14320932/39df6bc2-6949-4956-9dd0-0e5b1d2959f6)
This commit is contained in:
Nuno Góis 2023-10-20 11:14:48 +01:00 committed by GitHub
parent 433f3e2760
commit 667aed828b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 406 additions and 18 deletions

View File

@ -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 = () => {
<Route path='instance' element={<InstanceAdmin />} />
<Route path='network/*' element={<Network />} />
<Route path='maintenance' element={<MaintenanceAdmin />} />
<Route path='banners' element={<Banners />} />
<Route path='cors' element={<CorsAdmin />} />
<Route path='auth' element={<AuthSettings />} />
<Route

View File

@ -74,6 +74,13 @@ export const adminRoutes: INavigationMenuItem[] = [
menu: { adminSettings: true },
group: 'instance',
},
{
path: '/admin/banners',
title: 'Banners',
flag: 'banners',
menu: { adminSettings: true, mode: ['enterprise'] },
group: 'instance',
},
{
path: '/admin/instance',
title: 'Instance stats',

View File

@ -0,0 +1,21 @@
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import { BannersTable } from './BannersTable/BannersTable';
export const Banners = () => {
const { isEnterprise } = useUiConfig();
if (!isEnterprise()) {
return <PremiumFeature feature='banners' page />;
}
return (
<div>
<PermissionGuard permissions={ADMIN}>
<BannersTable />
</PermissionGuard>
</div>
);
};

View File

@ -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<React.SetStateAction<boolean>>;
onConfirm: (banner: IInternalBanner) => void;
}
export const BannerDeleteDialog = ({
banner,
open,
setOpen,
onConfirm,
}: IBannerDeleteDialogProps) => (
<Dialogue
title='Delete banner?'
open={open}
primaryButtonText='Delete banner'
secondaryButtonText='Cancel'
onClick={() => onConfirm(banner!)}
onClose={() => {
setOpen(false);
}}
>
<p>
You are about to delete banner: <strong>{banner?.message}</strong>
</p>
</Dialogue>
);

View File

@ -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 (
<StyledBox>
<PermissionIconButton
data-loading
onClick={onEdit}
permission={ADMIN}
tooltipProps={{
title: 'Edit banner',
}}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
onClick={onDelete}
permission={ADMIN}
tooltipProps={{
title: 'Remove banner',
}}
>
<Delete />
</PermissionIconButton>
</StyledBox>
);
};

View File

@ -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<IInternalBanner>();
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) => (
<Banner banner={{ ...banner, sticky: false }} inline />
),
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 } }) => (
<ToggleCell
checked={banner.enabled}
setChecked={(enabled) => toggleBanner(banner, enabled)}
/>
),
sortType: 'boolean',
width: 90,
maxWidth: 90,
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original: banner } }: any) => (
<BannersActionsCell
onEdit={() => {
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 (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Banners (${rows.length})`}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<>
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
<PageHeader.Divider />
</>
}
/>
<Button
variant='contained'
color='primary'
onClick={() => {
setSelectedBanner(undefined);
setModalOpen(true);
}}
>
New banner
</Button>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
</PageHeader>
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No banners found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No banners available. Get started by adding one.
</TablePlaceholder>
}
/>
}
/>
{/* <BannerModal
banner={selectedBanner}
open={modalOpen}
setOpen={setModalOpen}
/> */}
<BannerDeleteDialog
banner={selectedBanner}
open={deleteOpen}
setOpen={setDeleteOpen}
onConfirm={onDeleteConfirm}
/>
</PageContent>
);
};

View File

@ -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 = (
<StyledBar variant={variant}>
<StyledBar variant={variant} inline={inline}>
<StyledIcon variant={variant}>
<BannerIcon icon={icon} variant={variant} />
</StyledIcon>

View File

@ -6,9 +6,11 @@ export const InternalBanners = () => {
return (
<>
{banners.map((banner) => (
<Banner key={banner.id} banner={banner} />
))}
{banners
.filter(({ enabled }) => enabled)
.map((banner) => (
<Banner key={banner.id} banner={banner} />
))}
</>
);
};

View File

@ -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;

View File

@ -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) => (
<TextCell>
<Tooltip title={title ? title : checked ? 'Disable' : 'Enable'} arrow>
<Switch
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
</Tooltip>
</TextCell>
);