Skip to content

Commit

Permalink
feat: banners admin page (#5111)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
nunogois authored Oct 20, 2023
1 parent 433f3e2 commit 667aed8
Show file tree
Hide file tree
Showing 10 changed files with 406 additions and 18 deletions.
2 changes: 2 additions & 0 deletions frontend/src/component/admin/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/component/admin/adminRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/component/admin/banners/Banners.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
Original file line number Diff line number Diff line change
@@ -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>
);
};
250 changes: 250 additions & 0 deletions frontend/src/component/admin/banners/BannersTable/BannersTable.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading

0 comments on commit 667aed8

Please sign in to comment.