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:
parent
433f3e2760
commit
667aed828b
@ -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
|
||||
|
@ -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',
|
||||
|
21
frontend/src/component/admin/banners/Banners.tsx
Normal file
21
frontend/src/component/admin/banners/Banners.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 “
|
||||
{searchValue}
|
||||
”
|
||||
</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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
Loading…
Reference in New Issue
Block a user