Introduce datagrid on staff members list view (#4044)

This commit is contained in:
Paweł Chyła 2023-08-03 09:52:39 +02:00 committed by GitHub
parent 4c43976270
commit f14ba5bcfd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 458 additions and 323 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-dashboard": minor
---
Introduce datagrid on staff members list view

View file

@ -12,11 +12,7 @@ import {
STAFF_MEMBERS_LIST_SELECTORS, STAFF_MEMBERS_LIST_SELECTORS,
} from "../elements/"; } from "../elements/";
import { LOGIN_SELECTORS } from "../elements/account/login-selectors"; import { LOGIN_SELECTORS } from "../elements/account/login-selectors";
import { import { MESSAGES, TEST_ADMIN_USER, urlList } from "../fixtures";
MESSAGES,
TEST_ADMIN_USER,
urlList,
} from "../fixtures";
import { userDetailsUrl } from "../fixtures/urlList"; import { userDetailsUrl } from "../fixtures/urlList";
import { import {
activatePlugin, activatePlugin,
@ -28,6 +24,7 @@ import {
getMailActivationLinkForUserAndSubject, getMailActivationLinkForUserAndSubject,
inviteStaffMemberWithFirstPermission, inviteStaffMemberWithFirstPermission,
} from "../support/api/utils/"; } from "../support/api/utils/";
import { ensureCanvasStatic } from "../support/customCommands/sharedElementsOperations/canvas";
import { import {
expectMainMenuAvailableSections, expectMainMenuAvailableSections,
expectWelcomeMessageIncludes, expectWelcomeMessageIncludes,
@ -72,10 +69,11 @@ describe("Staff members", () => {
const firstName = faker.name.firstName(); const firstName = faker.name.firstName();
const emailInvite = `${startsWith}${firstName}@example.com`; const emailInvite = `${startsWith}${firstName}@example.com`;
cy.visit(urlList.staffMembers) cy.visit(urlList.staffMembers);
.expectSkeletonIsVisible() ensureCanvasStatic(SHARED_ELEMENTS.dataGridTable);
.get(STAFF_MEMBERS_LIST_SELECTORS.inviteStaffMemberButton) cy.get(STAFF_MEMBERS_LIST_SELECTORS.inviteStaffMemberButton).click({
.click({ force: true }); force: true,
});
fillUpUserDetailsAndAddFirstPermission(firstName, lastName, emailInvite); fillUpUserDetailsAndAddFirstPermission(firstName, lastName, emailInvite);
getMailActivationLinkForUser(emailInvite).then(urlLink => { getMailActivationLinkForUser(emailInvite).then(urlLink => {
cy.clearSessionData().visit(urlLink); cy.clearSessionData().visit(urlLink);
@ -185,10 +183,11 @@ describe("Staff members", () => {
() => { () => {
const firstName = faker.name.firstName(); const firstName = faker.name.firstName();
const emailInvite = TEST_ADMIN_USER.email; const emailInvite = TEST_ADMIN_USER.email;
cy.visit(urlList.staffMembers) cy.visit(urlList.staffMembers);
.expectSkeletonIsVisible() ensureCanvasStatic(SHARED_ELEMENTS.dataGridTable);
.get(STAFF_MEMBERS_LIST_SELECTORS.inviteStaffMemberButton) cy.get(STAFF_MEMBERS_LIST_SELECTORS.inviteStaffMemberButton).click({
.click({ force: true }); force: true,
});
fillUpOnlyUserDetails(firstName, lastName, emailInvite); fillUpOnlyUserDetails(firstName, lastName, emailInvite);
cy.get(INVITE_STAFF_MEMBER_FORM_SELECTORS.emailValidationMessage).should( cy.get(INVITE_STAFF_MEMBER_FORM_SELECTORS.emailValidationMessage).should(
"be.visible", "be.visible",

View file

@ -3673,6 +3673,10 @@
"context": "header", "context": "header",
"string": "{webhookName} Details" "string": "{webhookName} Details"
}, },
"OTDo9I": {
"context": "tab name",
"string": "All staff members"
},
"OVOU1z": { "OVOU1z": {
"context": "section header", "context": "section header",
"string": "Metadata" "string": "Metadata"
@ -5133,10 +5137,6 @@
"context": "no address is set in draft order", "context": "no address is set in draft order",
"string": "Not set" "string": "Not set"
}, },
"YJ4TXc": {
"context": "tab name",
"string": "All Staff Members"
},
"YKyNm9": { "YKyNm9": {
"context": "label", "context": "label",
"string": "Gift Card" "string": "Gift Card"
@ -5386,9 +5386,6 @@
"context": "subheader", "context": "subheader",
"string": "Here is some information we gathered about your store" "string": "Here is some information we gathered about your store"
}, },
"aDbrOK": {
"string": "Search Staff Member"
},
"aEc9Ar": { "aEc9Ar": {
"context": "gift card history message", "context": "gift card history message",
"string": "Gift card balance was reset by {resetBy}" "string": "Gift card balance was reset by {resetBy}"
@ -7258,6 +7255,9 @@
"context": "attributes, section header", "context": "attributes, section header",
"string": "Variant Selection Attributes" "string": "Variant Selection Attributes"
}, },
"o68j+t": {
"string": "Search staff members..."
},
"o8S0Ac": { "o8S0Ac": {
"context": "usage limit uses left caption", "context": "usage limit uses left caption",
"string": "Uses left" "string": "Uses left"

View file

@ -121,6 +121,7 @@ export const defaultListSettings: AppListViewSettings = {
}, },
[ListViews.STAFF_MEMBERS_LIST]: { [ListViews.STAFF_MEMBERS_LIST]: {
rowNumber: PAGINATE_BY, rowNumber: PAGINATE_BY,
columns: ["name", "email", "status"],
}, },
[ListViews.PERMISSION_GROUP_LIST]: { [ListViews.PERMISSION_GROUP_LIST]: {
rowNumber: PAGINATE_BY, rowNumber: PAGINATE_BY,

View file

@ -31,7 +31,7 @@ export const useFilterPresets = <
getUrl, getUrl,
}: { }: {
params: T; params: T;
reset: () => void; reset?: () => void;
getUrl: () => string; getUrl: () => string;
storageUtils: StorageUtils<string>; storageUtils: StorageUtils<string>;
}): UseFilterPresets => { }): UseFilterPresets => {
@ -47,7 +47,7 @@ export const useFilterPresets = <
: undefined; : undefined;
const onPresetChange = (index: number) => { const onPresetChange = (index: number) => {
reset(); reset?.();
const currentPresets = storageUtils.getFilterTabs(); const currentPresets = storageUtils.getFilterTabs();
const qs = new URLSearchParams(currentPresets[index - 1]?.data ?? ""); const qs = new URLSearchParams(currentPresets[index - 1]?.data ?? "");
qs.append("activeTab", index.toString()); qs.append("activeTab", index.toString());
@ -65,7 +65,7 @@ export const useFilterPresets = <
} }
storageUtils.deleteFilterTab(presetIdToDelete); storageUtils.deleteFilterTab(presetIdToDelete);
reset(); reset?.();
// When deleting the current tab, navigate to the All products // When deleting the current tab, navigate to the All products
if (presetIdToDelete === selectedPreset || !selectedPreset) { if (presetIdToDelete === selectedPreset || !selectedPreset) {

View file

@ -1,169 +0,0 @@
// @ts-strict-ignore
import ResponsiveTable from "@dashboard/components/ResponsiveTable";
import Skeleton from "@dashboard/components/Skeleton";
import TableCellHeader from "@dashboard/components/TableCellHeader";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import TableRowLink from "@dashboard/components/TableRowLink";
import { UserAvatar } from "@dashboard/components/UserAvatar";
import { StaffListQuery } from "@dashboard/graphql";
import { commonStatusMessages } from "@dashboard/intl";
import {
getUserInitials,
getUserName,
renderCollection,
} from "@dashboard/misc";
import {
StaffListUrlSortField,
staffMemberDetailsUrl,
} from "@dashboard/staff/urls";
import { ListProps, RelayToFlat, SortPage } from "@dashboard/types";
import { getArrowDirection } from "@dashboard/utils/sort";
import {
TableBody,
TableCell,
TableFooter,
TableHead,
} from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import { Box, Text } from "@saleor/macaw-ui/next";
import clsx from "clsx";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
const useStyles = makeStyles(
{
colEmail: {
width: 400,
},
tableRow: {
cursor: "pointer",
},
wideColumn: {
width: "80%",
},
},
{ name: "StaffList" },
);
interface StaffListProps extends ListProps, SortPage<StaffListUrlSortField> {
staffMembers: RelayToFlat<StaffListQuery["staffUsers"]>;
}
const numberOfColumns = 2;
const StaffList: React.FC<StaffListProps> = props => {
const {
settings,
disabled,
onUpdateListSettings,
onSort,
sort,
staffMembers,
} = props;
const classes = useStyles(props);
const intl = useIntl();
return (
<ResponsiveTable>
<colgroup>
<col />
<col className={classes.colEmail} />
</colgroup>
<TableHead>
<TableRowLink>
<TableCellHeader
direction={
sort.sort === StaffListUrlSortField.name
? getArrowDirection(sort.asc)
: undefined
}
arrowPosition="right"
onClick={() => onSort(StaffListUrlSortField.name)}
className={classes.wideColumn}
>
<FormattedMessage
id="W32xfN"
defaultMessage="Name"
description="staff member full name"
/>
</TableCellHeader>
<TableCellHeader
direction={
sort.sort === StaffListUrlSortField.email
? getArrowDirection(sort.asc)
: undefined
}
onClick={() => onSort(StaffListUrlSortField.email)}
>
<FormattedMessage id="xxQxLE" defaultMessage="Email Address" />
</TableCellHeader>
</TableRowLink>
</TableHead>
<TableFooter>
<TableRowLink>
<TablePaginationWithContext
colSpan={numberOfColumns}
disabled={disabled}
settings={settings}
onUpdateListSettings={onUpdateListSettings}
/>
</TableRowLink>
</TableFooter>
<TableBody>
{renderCollection(
staffMembers,
staffMember => (
<TableRowLink
className={clsx({
[classes.tableRow]: !!staffMember,
})}
hover={!!staffMember}
href={staffMember && staffMemberDetailsUrl(staffMember.id)}
key={staffMember ? staffMember.id : "skeleton"}
>
<TableCell>
<Box display="flex" alignItems="center" gap={2}>
<UserAvatar
url={staffMember?.avatar?.url}
initials={getUserInitials(staffMember)}
/>
<Box display="flex" flexDirection="column">
<Text>{getUserName(staffMember) || <Skeleton />}</Text>
<Text
variant="caption"
data-test-id="staffStatusText"
color="textNeutralSubdued"
>
{staffMember?.isActive
? intl.formatMessage(commonStatusMessages.active)
: intl.formatMessage(commonStatusMessages.notActive)}
</Text>
</Box>
</Box>
</TableCell>
<TableCell>
<Text size="small" data-test-id="user-mail">
{staffMember?.email}
</Text>
</TableCell>
</TableRowLink>
),
() => (
<TableRowLink>
<TableCell colSpan={numberOfColumns}>
<FormattedMessage
id="xJQX5t"
defaultMessage="No staff members found"
/>
</TableCell>
</TableRowLink>
),
)}
</TableBody>
</ResponsiveTable>
);
};
StaffList.displayName = "StaffList";
export default StaffList;

View file

@ -1,2 +0,0 @@
export { default } from "./StaffList";
export * from "./StaffList";

View file

@ -0,0 +1,137 @@
import { useColumns } from "@dashboard/components/Datagrid/ColumnPicker/useColumns";
import Datagrid from "@dashboard/components/Datagrid/Datagrid";
import {
DatagridChangeStateContext,
useDatagridChangeState,
} from "@dashboard/components/Datagrid/hooks/useDatagridChange";
import { useEmptyColumn } from "@dashboard/components/Datagrid/hooks/useEmptyColumn";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import useNavigator from "@dashboard/hooks/useNavigator";
import { StaffMember, StaffMembers } from "@dashboard/staff/types";
import {
StaffListUrlSortField,
staffMemberDetailsUrl,
} from "@dashboard/staff/urls";
import { ListProps, SortPage } from "@dashboard/types";
import { Item } from "@glideapps/glide-data-grid";
import { Box, useTheme } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo } from "react";
import { useIntl } from "react-intl";
import {
createGetCellContent,
staffMemebersListStaticColumnsAdapter,
} from "./datagrid";
import { messages } from "./messages";
interface StaffListDatagridProps
extends ListProps,
SortPage<StaffListUrlSortField> {
staffMembers: StaffMembers;
}
export const StaffListDatagrid = ({
staffMembers,
settings,
sort,
disabled,
onSort,
onUpdateListSettings,
}: StaffListDatagridProps) => {
const datagridState = useDatagridChangeState();
const navigate = useNavigator();
const intl = useIntl();
const { themeValues } = useTheme();
const emptyColumn = useEmptyColumn();
const staffMemebersListStaticColumns = useMemo(
() => staffMemebersListStaticColumnsAdapter(intl, sort, emptyColumn),
[intl, sort, emptyColumn],
);
const onColumnChange = useCallback(
(picked: string[]) => {
if (onUpdateListSettings) {
onUpdateListSettings("columns", picked.filter(Boolean));
}
},
[onUpdateListSettings],
);
const { handlers, visibleColumns, recentlyAddedColumn } = useColumns({
selectedColumns: settings?.columns ?? [],
staticColumns: staffMemebersListStaticColumns,
onSave: onColumnChange,
});
const getCellContent = useCallback(
createGetCellContent({
staffMembers,
columns: visibleColumns,
intl,
currentTheme: themeValues,
}),
[staffMembers, intl, visibleColumns],
);
const handleRowClick = useCallback(
([_, row]: Item) => {
const rowData: StaffMember = staffMembers[row];
if (rowData) {
navigate(staffMemberDetailsUrl(rowData?.id));
}
},
[staffMembers],
);
const handleRowAnchor = useCallback(
([, row]: Item) => staffMemberDetailsUrl(staffMembers[row]?.id),
[staffMembers],
);
const handleHeaderClick = useCallback(
(col: number) => {
const columnName = visibleColumns[col].id as StaffListUrlSortField;
if (Object.values(StaffListUrlSortField).includes(columnName)) {
onSort(columnName);
}
},
[visibleColumns, onSort],
);
return (
<DatagridChangeStateContext.Provider value={datagridState}>
<Datagrid
readonly
loading={disabled}
rowMarkers="none"
columnSelect="single"
hasRowHover={true}
onColumnMoved={handlers.onMove}
onColumnResize={handlers.onResize}
verticalBorder={col => col > 1}
rows={staffMembers?.length ?? 0}
availableColumns={visibleColumns}
emptyText={intl.formatMessage(messages.empty)}
getCellContent={getCellContent}
getCellError={() => false}
selectionActions={() => null}
menuItems={() => []}
onRowClick={handleRowClick}
onHeaderClicked={handleHeaderClick}
rowAnchor={handleRowAnchor}
recentlyAddedColumn={recentlyAddedColumn}
/>
<Box paddingX={6}>
<TablePaginationWithContext
component="div"
settings={settings}
disabled={disabled}
onUpdateListSettings={onUpdateListSettings}
/>
</Box>
</DatagridChangeStateContext.Provider>
);
};

View file

@ -0,0 +1,104 @@
import { PLACEHOLDER } from "@dashboard/components/Datagrid/const";
import {
readonlyTextCell,
tagsCell,
thumbnailCell,
} from "@dashboard/components/Datagrid/customCells/cells";
import { AvailableColumn } from "@dashboard/components/Datagrid/types";
import { commonStatusMessages } from "@dashboard/intl";
import { getStatusColor, getUserName } from "@dashboard/misc";
import { StaffMember, StaffMembers } from "@dashboard/staff/types";
import { StaffListUrlSortField } from "@dashboard/staff/urls";
import { Sort } from "@dashboard/types";
import { getColumnSortDirectionIcon } from "@dashboard/utils/columns/getColumnSortDirectionIcon";
import { GridCell, Item } from "@glideapps/glide-data-grid";
import { ThemeTokensValues } from "@saleor/macaw-ui/next";
import { IntlShape } from "react-intl";
import { columnsMessages } from "./messages";
export const staffMemebersListStaticColumnsAdapter = (
intl: IntlShape,
sort: Sort<StaffListUrlSortField>,
emptyColumn: AvailableColumn,
) =>
[
emptyColumn,
{
id: "name",
title: intl.formatMessage(columnsMessages.name),
width: 400,
},
{
id: "status",
title: intl.formatMessage(columnsMessages.status),
width: 150,
},
{
id: "email",
title: intl.formatMessage(columnsMessages.email),
width: 400,
},
].map(column => ({
...column,
icon: getColumnSortDirectionIcon(sort, column.id),
}));
export const createGetCellContent =
({
staffMembers,
columns,
intl,
currentTheme,
}: {
staffMembers: StaffMembers;
columns: AvailableColumn[];
intl: IntlShape;
currentTheme: ThemeTokensValues;
}) =>
([column, row]: Item): GridCell => {
const rowData: StaffMember | undefined = staffMembers[row];
const columnId = columns[column]?.id;
if (!columnId || !rowData) {
return readonlyTextCell("");
}
switch (columnId) {
case "name":
return thumbnailCell(
getUserName(rowData) ?? "",
rowData?.avatar?.url ?? "",
{
cursor: "pointer",
},
);
case "status":
const isActive = rowData?.isActive;
const status = isActive
? intl.formatMessage(commonStatusMessages.active)
: intl.formatMessage(commonStatusMessages.notActive);
const statusColor = getStatusColor(isActive ? "success" : "error");
return tagsCell(
[
{
tag: status,
color:
currentTheme.colors.background[
statusColor as keyof typeof currentTheme.colors.background
],
},
],
[status],
{
readonly: true,
allowOverlay: false,
},
);
case "email":
return readonlyTextCell(rowData?.email ?? PLACEHOLDER);
default:
return readonlyTextCell("");
}
};

View file

@ -0,0 +1 @@
export * from "./StaffListDatagrid";

View file

@ -0,0 +1,24 @@
import { defineMessages } from "react-intl";
export const columnsMessages = defineMessages({
name: {
id: "W32xfN",
defaultMessage: "Name",
description: "staff member full name",
},
email: {
id: "xxQxLE",
defaultMessage: "Email Address",
},
status: {
id: "tzMNF3",
defaultMessage: "Status",
},
});
export const messages = defineMessages({
empty: {
id: "xJQX5t",
defaultMessage: "No staff members found",
},
});

View file

@ -1,12 +1,12 @@
// @ts-strict-ignore // @ts-strict-ignore
import { import {
filterPageProps, filterPageProps,
filterPresetsProps,
limits, limits,
limitsReached, limitsReached,
pageListProps, pageListProps,
searchPageProps, searchPageProps,
sortPageProps, sortPageProps,
tabPageProps,
} from "@dashboard/fixtures"; } from "@dashboard/fixtures";
import { StaffMemberStatus } from "@dashboard/graphql"; import { StaffMemberStatus } from "@dashboard/graphql";
import { staffMembers } from "@dashboard/staff/fixtures"; import { staffMembers } from "@dashboard/staff/fixtures";
@ -20,8 +20,8 @@ const props: StaffListPageProps = {
...pageListProps.default, ...pageListProps.default,
...searchPageProps, ...searchPageProps,
...sortPageProps, ...sortPageProps,
...tabPageProps,
...filterPageProps, ...filterPageProps,
...filterPresetsProps,
filterOpts: { filterOpts: {
status: { status: {
active: false, active: false,
@ -35,6 +35,10 @@ const props: StaffListPageProps = {
sort: StaffListUrlSortField.name, sort: StaffListUrlSortField.name,
}, },
staffMembers, staffMembers,
settings: {
rowNumber: 10,
columns: ["name", "email", "status"],
},
}; };
const meta: Meta<typeof StaffListPage> = { const meta: Meta<typeof StaffListPage> = {

View file

@ -1,27 +1,25 @@
// @ts-strict-ignore import { ListFilters } from "@dashboard/components/AppLayout/ListFilters";
import { LimitsInfo } from "@dashboard/components/AppLayout/LimitsInfo";
import { TopNav } from "@dashboard/components/AppLayout/TopNav"; import { TopNav } from "@dashboard/components/AppLayout/TopNav";
import { Button } from "@dashboard/components/Button"; import { FilterPresetsSelect } from "@dashboard/components/FilterPresetsSelect";
import FilterBar from "@dashboard/components/FilterBar";
import { ListPageLayout } from "@dashboard/components/Layouts"; import { ListPageLayout } from "@dashboard/components/Layouts";
import LimitReachedAlert from "@dashboard/components/LimitReachedAlert"; import LimitReachedAlert from "@dashboard/components/LimitReachedAlert";
import { configurationMenuUrl } from "@dashboard/configuration"; import { configurationMenuUrl } from "@dashboard/configuration";
import { RefreshLimitsQuery, StaffListQuery } from "@dashboard/graphql"; import { RefreshLimitsQuery } from "@dashboard/graphql";
import { sectionNames } from "@dashboard/intl"; import { sectionNames } from "@dashboard/intl";
import { StaffMembers } from "@dashboard/staff/types";
import { StaffListUrlSortField } from "@dashboard/staff/urls"; import { StaffListUrlSortField } from "@dashboard/staff/urls";
import { import {
FilterPageProps, FilterPagePropsWithPresets,
ListProps, ListProps,
RelayToFlat,
SortPage, SortPage,
TabPageProps,
} from "@dashboard/types"; } from "@dashboard/types";
import { hasLimits, isLimitReached } from "@dashboard/utils/limits"; import { hasLimits, isLimitReached } from "@dashboard/utils/limits";
import { Card } from "@material-ui/core"; import { Card } from "@material-ui/core";
import React from "react"; import { Box, Button, ChevronRightIcon } from "@saleor/macaw-ui/next";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import StaffList from "../StaffList/StaffList"; import { StaffListDatagrid } from "../StaffListDatagrid";
import { import {
createFilterStructure, createFilterStructure,
StaffFilterKeys, StaffFilterKeys,
@ -30,30 +28,33 @@ import {
export interface StaffListPageProps export interface StaffListPageProps
extends ListProps, extends ListProps,
FilterPageProps<StaffFilterKeys, StaffListFilterOpts>, FilterPagePropsWithPresets<StaffFilterKeys, StaffListFilterOpts>,
SortPage<StaffListUrlSortField>, SortPage<StaffListUrlSortField> {
TabPageProps { limits: RefreshLimitsQuery["shop"]["limits"] | undefined;
limits: RefreshLimitsQuery["shop"]["limits"]; staffMembers: StaffMembers;
staffMembers: RelayToFlat<StaffListQuery["staffUsers"]>;
onAdd: () => void; onAdd: () => void;
} }
const StaffListPage: React.FC<StaffListPageProps> = ({ const StaffListPage: React.FC<StaffListPageProps> = ({
currentTab,
filterOpts, filterOpts,
initialSearch, initialSearch,
limits, limits,
currencySymbol,
filterPresets,
selectedFilterPreset,
onAdd, onAdd,
onAll,
onFilterChange, onFilterChange,
onSearchChange, onSearchChange,
onTabChange, hasPresetsChanged,
onTabDelete, onFilterPresetChange,
onTabSave, onFilterPresetDelete,
tabs, onFilterPresetPresetSave,
onFilterPresetUpdate,
onFilterPresetsAll,
...listProps ...listProps
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const [isFilterPresetOpen, setFilterPresetOpen] = useState(false);
const structure = createFilterStructure(intl, filterOpts); const structure = createFilterStructure(intl, filterOpts);
const reachedLimit = isLimitReached(limits, "staffUsers"); const reachedLimit = isLimitReached(limits, "staffUsers");
@ -63,35 +64,69 @@ const StaffListPage: React.FC<StaffListPageProps> = ({
<TopNav <TopNav
href={configurationMenuUrl} href={configurationMenuUrl}
title={intl.formatMessage(sectionNames.staff)} title={intl.formatMessage(sectionNames.staff)}
isAlignToRight={false}
withoutBorder
> >
<Button <Box
data-test-id="invite-staff-member" __flex={1}
disabled={reachedLimit} display="flex"
variant="primary" justifyContent="space-between"
onClick={onAdd} alignItems="center"
> >
<FormattedMessage <Box display="flex">
id="4JcNaA" <Box marginX={3} display="flex" alignItems="center">
defaultMessage="Invite staff member" <ChevronRightIcon />
description="button" </Box>
/>
</Button> <FilterPresetsSelect
{hasLimits(limits, "staffUsers") && ( presetsChanged={hasPresetsChanged()}
<LimitsInfo onSelect={onFilterPresetChange}
text={intl.formatMessage( onRemove={onFilterPresetDelete}
{ onUpdate={onFilterPresetUpdate}
id: "9xlPgt", savedPresets={filterPresets}
defaultMessage: "{count}/{max} members", activePreset={selectedFilterPreset}
description: "used staff users counter", onSelectAll={onFilterPresetsAll}
}, onSave={onFilterPresetPresetSave}
{ isOpen={isFilterPresetOpen}
count: limits.currentUsage.staffUsers, onOpenChange={setFilterPresetOpen}
max: limits.allowedUsage.staffUsers, selectAllLabel={intl.formatMessage({
}, id: "OTDo9I",
)} defaultMessage: "All staff members",
/> description: "tab name",
)} })}
/>
</Box>
<Box>
<Button
data-test-id="invite-staff-member"
disabled={reachedLimit}
variant="primary"
onClick={onAdd}
>
<FormattedMessage
id="4JcNaA"
defaultMessage="Invite staff member"
description="button"
/>
</Button>
</Box>
</Box>
</TopNav> </TopNav>
{hasLimits(limits, "staffUsers") && (
<Box gridColumn="8" marginLeft={6} marginBottom={reachedLimit ? 0 : 3}>
{intl.formatMessage(
{
id: "9xlPgt",
defaultMessage: "{count}/{max} members",
description: "used staff users counter",
},
{
count: limits?.currentUsage?.staffUsers ?? 0,
max: limits?.allowedUsage?.staffUsers ?? 0,
},
)}
</Box>
)}
{reachedLimit && ( {reachedLimit && (
<LimitReachedAlert <LimitReachedAlert
title={intl.formatMessage({ title={intl.formatMessage({
@ -107,28 +142,19 @@ const StaffListPage: React.FC<StaffListPageProps> = ({
</LimitReachedAlert> </LimitReachedAlert>
)} )}
<Card> <Card>
<FilterBar <ListFilters<StaffFilterKeys>
allTabLabel={intl.formatMessage({ currencySymbol={currencySymbol}
id: "YJ4TXc",
defaultMessage: "All Staff Members",
description: "tab name",
})}
currentTab={currentTab}
filterStructure={structure}
initialSearch={initialSearch} initialSearch={initialSearch}
searchPlaceholder={intl.formatMessage({
id: "aDbrOK",
defaultMessage: "Search Staff Member",
})}
tabs={tabs}
onAll={onAll}
onFilterChange={onFilterChange} onFilterChange={onFilterChange}
onSearchChange={onSearchChange} onSearchChange={onSearchChange}
onTabChange={onTabChange} filterStructure={structure}
onTabDelete={onTabDelete} searchPlaceholder={intl.formatMessage({
onTabSave={onTabSave} id: "o68j+t",
defaultMessage: "Search staff members...",
})}
/> />
<StaffList {...listProps} />
<StaffListDatagrid {...listProps} />
</Card> </Card>
</ListPageLayout> </ListPageLayout>
); );

View file

@ -9,7 +9,7 @@ export enum StaffFilterKeys {
} }
export interface StaffListFilterOpts { export interface StaffListFilterOpts {
status: FilterOpts<StaffMemberStatus>; status: FilterOpts<StaffMemberStatus | null>;
} }
const messages = defineMessages({ const messages = defineMessages({
@ -39,7 +39,7 @@ export function createFilterStructure(
...createOptionsField( ...createOptionsField(
StaffFilterKeys.status, StaffFilterKeys.status,
intl.formatMessage(messages.status), intl.formatMessage(messages.status),
[opts.status.value], [opts.status.value ?? ""],
false, false,
[ [
{ {

7
src/staff/types.ts Normal file
View file

@ -0,0 +1,7 @@
import { StaffListQuery } from "@dashboard/graphql";
import { RelayToFlat } from "@dashboard/types";
export type StaffMembers = RelayToFlat<
NonNullable<StaffListQuery["staffUsers"]>
>;
export type StaffMember = StaffMembers[number];

View file

@ -1,15 +1,13 @@
// @ts-strict-ignore
import { newPasswordUrl } from "@dashboard/auth/urls"; import { newPasswordUrl } from "@dashboard/auth/urls";
import DeleteFilterTabDialog from "@dashboard/components/DeleteFilterTabDialog"; import DeleteFilterTabDialog from "@dashboard/components/DeleteFilterTabDialog";
import SaveFilterTabDialog, { import SaveFilterTabDialog from "@dashboard/components/SaveFilterTabDialog";
SaveFilterTabDialogFormData,
} from "@dashboard/components/SaveFilterTabDialog";
import { useShopLimitsQuery } from "@dashboard/components/Shop/queries"; import { useShopLimitsQuery } from "@dashboard/components/Shop/queries";
import { DEFAULT_INITIAL_SEARCH_DATA } from "@dashboard/config"; import { DEFAULT_INITIAL_SEARCH_DATA } from "@dashboard/config";
import { import {
useStaffListQuery, useStaffListQuery,
useStaffMemberAddMutation, useStaffMemberAddMutation,
} from "@dashboard/graphql"; } from "@dashboard/graphql";
import { useFilterPresets } from "@dashboard/hooks/useFilterPresets";
import useListSettings from "@dashboard/hooks/useListSettings"; import useListSettings from "@dashboard/hooks/useListSettings";
import useNavigator from "@dashboard/hooks/useNavigator"; import useNavigator from "@dashboard/hooks/useNavigator";
import useNotifier from "@dashboard/hooks/useNotifier"; import useNotifier from "@dashboard/hooks/useNotifier";
@ -19,7 +17,6 @@ import usePaginator, {
PaginatorContext, PaginatorContext,
} from "@dashboard/hooks/usePaginator"; } from "@dashboard/hooks/usePaginator";
import { commonMessages } from "@dashboard/intl"; import { commonMessages } from "@dashboard/intl";
import { getStringOrPlaceholder } from "@dashboard/misc";
import usePermissionGroupSearch from "@dashboard/searches/usePermissionGroupSearch"; import usePermissionGroupSearch from "@dashboard/searches/usePermissionGroupSearch";
import { ListViews } from "@dashboard/types"; import { ListViews } from "@dashboard/types";
import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers"; import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers";
@ -43,14 +40,10 @@ import {
staffMemberDetailsUrl, staffMemberDetailsUrl,
} from "../../urls"; } from "../../urls";
import { import {
deleteFilterTab,
getActiveFilters,
getFilterOpts, getFilterOpts,
getFilterQueryParam, getFilterQueryParam,
getFiltersCurrentTab,
getFilterTabs,
getFilterVariables, getFilterVariables,
saveFilterTab, storageUtils,
} from "./filters"; } from "./filters";
import { getSortQueryVariables } from "./sort"; import { getSortQueryVariables } from "./sort";
@ -89,27 +82,39 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
const [addStaffMember, addStaffMemberData] = useStaffMemberAddMutation({ const [addStaffMember, addStaffMemberData] = useStaffMemberAddMutation({
onCompleted: data => { onCompleted: data => {
if (data.staffCreate.errors.length === 0) { if (data?.staffCreate?.errors?.length === 0) {
notify({ notify({
status: "success", status: "success",
text: intl.formatMessage(commonMessages.savedChanges), text: intl.formatMessage(commonMessages.savedChanges),
}); });
navigate(staffMemberDetailsUrl(data.staffCreate.user.id)); navigate(staffMemberDetailsUrl(data?.staffCreate?.user?.id ?? ""));
} }
}, },
}); });
const paginationValues = usePaginator({ const paginationValues = usePaginator({
pageInfo: staffQueryData?.staffUsers.pageInfo, pageInfo: staffQueryData?.staffUsers?.pageInfo,
paginationState, paginationState,
queryString: params, queryString: params,
}); });
const handleSort = createSortHandler(navigate, staffListUrl, params); const handleSort = createSortHandler(navigate, staffListUrl, params);
const tabs = getFilterTabs(); const {
hasPresetsChanged,
const currentTab = getFiltersCurrentTab(params, tabs); onPresetChange,
onPresetDelete,
onPresetSave,
onPresetUpdate,
selectedPreset,
presets,
getPresetNameToDelete,
setPresetIdToDelete,
} = useFilterPresets({
getUrl: staffListUrl,
params,
storageUtils,
});
const [changeFilters, resetFilters, handleSearchChange] = const [changeFilters, resetFilters, handleSearchChange] =
createFilterHandlers({ createFilterHandlers({
@ -117,6 +122,7 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
getFilterQueryParam, getFilterQueryParam,
navigate, navigate,
params, params,
keepActiveTab: true,
}); });
const [openModal, closeModal] = createDialogActionHandlers< const [openModal, closeModal] = createDialogActionHandlers<
@ -124,25 +130,6 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
StaffListUrlQueryParams StaffListUrlQueryParams
>(navigate, staffListUrl, params); >(navigate, staffListUrl, params);
const handleTabChange = (tab: number) => {
navigate(
staffListUrl({
activeTab: tab.toString(),
...getFilterTabs()[tab - 1].data,
}),
);
};
const handleTabDelete = () => {
deleteFilterTab(currentTab);
navigate(staffListUrl());
};
const handleTabSave = (data: SaveFilterTabDialogFormData) => {
saveFilterTab(data.name, getActiveFilters(params));
handleTabChange(tabs.length + 1);
};
const { const {
loadMore: loadMorePermissionGroups, loadMore: loadMorePermissionGroups,
search: searchPermissionGroups, search: searchPermissionGroups,
@ -171,55 +158,65 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
return ( return (
<PaginatorContext.Provider value={paginationValues}> <PaginatorContext.Provider value={paginationValues}>
<StaffListPage <StaffListPage
currentTab={currentTab}
filterOpts={getFilterOpts(params)} filterOpts={getFilterOpts(params)}
initialSearch={params.query || ""} initialSearch={params.query || ""}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onFilterChange={changeFilters} onFilterChange={changeFilters}
onAll={resetFilters} onFilterPresetsAll={resetFilters}
onTabChange={handleTabChange} onFilterPresetDelete={id => {
onTabDelete={() => openModal("delete-search")} setPresetIdToDelete(id);
onTabSave={() => openModal("save-search")} openModal("delete-search");
tabs={tabs.map(tab => tab.name)} }}
selectedFilterPreset={selectedPreset}
onFilterPresetChange={onPresetChange}
onFilterPresetUpdate={onPresetUpdate}
hasPresetsChanged={hasPresetsChanged}
onFilterPresetPresetSave={() => openModal("save-search")}
filterPresets={presets.map(preset => preset.name)}
disabled={loading || addStaffMemberData.loading || limitOpts.loading} disabled={loading || addStaffMemberData.loading || limitOpts.loading}
limits={limitOpts.data?.shop.limits} limits={limitOpts.data?.shop?.limits}
settings={settings} settings={settings}
sort={getSortParams(params)} sort={getSortParams(params)}
staffMembers={mapEdgesToItems(staffQueryData?.staffUsers)} staffMembers={mapEdgesToItems(staffQueryData?.staffUsers) ?? []}
onAdd={() => openModal("add")} onAdd={() => openModal("add")}
onUpdateListSettings={updateListSettings} onUpdateListSettings={updateListSettings}
onSort={handleSort} onSort={handleSort}
/> />
<StaffAddMemberDialog <StaffAddMemberDialog
availablePermissionGroups={mapEdgesToItems( availablePermissionGroups={
searchPermissionGroupsOpts?.data?.search, mapEdgesToItems(searchPermissionGroupsOpts?.data?.search) ?? []
)} }
confirmButtonState={addStaffMemberData.status} confirmButtonState={addStaffMemberData.status}
initialSearch="" initialSearch=""
disabled={loading} disabled={loading}
errors={addStaffMemberData.data?.staffCreate.errors || []} errors={addStaffMemberData.data?.staffCreate?.errors || []}
open={params.action === "add"} open={params.action === "add"}
onClose={closeModal} onClose={closeModal}
onConfirm={handleStaffMemberAdd} onConfirm={handleStaffMemberAdd}
fetchMorePermissionGroups={{ fetchMorePermissionGroups={{
hasMore: searchPermissionGroupsOpts.data?.search.pageInfo.hasNextPage, hasMore:
searchPermissionGroupsOpts.data?.search?.pageInfo?.hasNextPage ??
false,
loading: searchPermissionGroupsOpts.loading, loading: searchPermissionGroupsOpts.loading,
onFetchMore: loadMorePermissionGroups, onFetchMore: loadMorePermissionGroups,
}} }}
onSearchChange={searchPermissionGroups} onSearchChange={searchPermissionGroups}
/> />
<SaveFilterTabDialog <SaveFilterTabDialog
open={params.action === "save-search"} open={params.action === "save-search"}
confirmButtonState="default" confirmButtonState="default"
onClose={closeModal} onClose={closeModal}
onSubmit={handleTabSave} onSubmit={onPresetSave}
/> />
<DeleteFilterTabDialog <DeleteFilterTabDialog
open={params.action === "delete-search"} open={params.action === "delete-search"}
confirmButtonState="default" confirmButtonState="default"
onClose={closeModal} onClose={closeModal}
onSubmit={handleTabDelete} onSubmit={onPresetDelete}
tabName={getStringOrPlaceholder(tabs[currentTab - 1]?.name)} tabName={getPresetNameToDelete()}
/> />
</PaginatorContext.Provider> </PaginatorContext.Provider>
); );

View file

@ -1,10 +1,9 @@
// @ts-strict-ignore
import { import {
FilterElement, FilterElement,
FilterElementRegular, FilterElementRegular,
} from "@dashboard/components/Filter"; } from "@dashboard/components/Filter";
import { StaffMemberStatus, StaffUserInput } from "@dashboard/graphql"; import { StaffMemberStatus, StaffUserInput } from "@dashboard/graphql";
import { findValueInEnum, maybe } from "@dashboard/misc"; import { findValueInEnum } from "@dashboard/misc";
import { import {
StaffFilterKeys, StaffFilterKeys,
StaffListFilterOpts, StaffListFilterOpts,
@ -28,8 +27,10 @@ export function getFilterOpts(
): StaffListFilterOpts { ): StaffListFilterOpts {
return { return {
status: { status: {
active: maybe(() => params.status !== undefined, false), active: params?.status !== undefined ?? false,
value: maybe(() => findValueInEnum(params.status, StaffMemberStatus)), value: params?.status
? findValueInEnum(params.status, StaffMemberStatus)
: null,
}, },
}; };
} }
@ -60,8 +61,7 @@ export function getFilterQueryParam(
} }
} }
export const { deleteFilterTab, getFilterTabs, saveFilterTab } = export const storageUtils = createFilterTabUtils<string>(STAFF_FILTERS_KEY);
createFilterTabUtils<StaffListUrlFilters>(STAFF_FILTERS_KEY);
export const { areFiltersApplied, getActiveFilters, getFiltersCurrentTab } = export const { areFiltersApplied, getActiveFilters, getFiltersCurrentTab } =
createFilterUtils<StaffListUrlQueryParams, StaffListUrlFilters>( createFilterUtils<StaffListUrlQueryParams, StaffListUrlFilters>(

View file

@ -1,9 +1,10 @@
// @ts-strict-ignore
import { UserSortField } from "@dashboard/graphql"; import { UserSortField } from "@dashboard/graphql";
import { StaffListUrlSortField } from "@dashboard/staff/urls"; import { StaffListUrlSortField } from "@dashboard/staff/urls";
import { createGetSortQueryVariables } from "@dashboard/utils/sort"; import { createGetSortQueryVariables } from "@dashboard/utils/sort";
export function getSortQueryField(sort: StaffListUrlSortField): UserSortField { export function getSortQueryField(
sort: StaffListUrlSortField,
): UserSortField | undefined {
switch (sort) { switch (sort) {
case StaffListUrlSortField.name: case StaffListUrlSortField.name:
return UserSortField.LAST_NAME; return UserSortField.LAST_NAME;

View file

@ -68,7 +68,7 @@ interface SortingInput<T extends string> {
} }
type GetSortQueryField<TUrlField extends string, TSortField extends string> = ( type GetSortQueryField<TUrlField extends string, TSortField extends string> = (
sort: TUrlField, sort: TUrlField,
) => TSortField; ) => TSortField | undefined;
type GetSortQueryVariables< type GetSortQueryVariables<
TSortField extends string, TSortField extends string,
TParams extends Record<any, any>, TParams extends Record<any, any>,