Skip to content

Commit

Permalink
Merge pull request #276 from outgrow/outgrow-accounts-page-graphql
Browse files Browse the repository at this point in the history
GraphQL-powered Accounts page
  • Loading branch information
willopez authored Jun 10, 2020
2 parents 9630fe5 + c194a12 commit a83c97b
Show file tree
Hide file tree
Showing 44 changed files with 1,415 additions and 1,744 deletions.
43 changes: 43 additions & 0 deletions imports/plugins/core/accounts/client/components/Accounts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { Fragment, useState } from "react";
import i18next from "i18next";
import Divider from "@material-ui/core/Divider";
import Tab from "@material-ui/core/Tab";
import Tabs from "@material-ui/core/Tabs";
import Customers from "./Customers";
import GroupCards from "./GroupCards";
import Invitations from "./Invitations";

/**
* @summary Main accounts view
* @name Accounts
* @returns {React.Component} A React component
*/
function Accounts() {
const [currentTab, setCurrentTab] = useState(0);

return (
<Fragment>
<Tabs value={currentTab} onChange={(event, value) => setCurrentTab(value)}>
<Tab label={i18next.t("admin.accounts.tabs.staff")} />
<Tab label={i18next.t("admin.accounts.tabs.customers")} />
<Tab label={i18next.t("admin.accounts.tabs.invites")} />
</Tabs>

<Divider />

{currentTab === 0 &&
<GroupCards />
}

{currentTab === 1 &&
<Customers />
}

{currentTab === 2 &&
<Invitations />
}
</Fragment>
);
}

export default Accounts;
122 changes: 122 additions & 0 deletions imports/plugins/core/accounts/client/components/AccountsTable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React, { Fragment, useState, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import { useApolloClient } from "@apollo/react-hooks";
import i18next from "i18next";
import DataTable, { useDataTable } from "@reactioncommerce/catalyst/DataTable";
import useCurrentShopId from "/imports/client/ui/hooks/useCurrentShopId";
import { getAccountAvatar } from "/imports/plugins/core/accounts/client/helpers/helpers";
import accountsQuery from "../graphql/queries/accounts";
import GroupSelectorDialog from "./GroupSelectorDialog";

/**
* @summary Main products view
* @name ProductsTable
* @param {Object} props - the component's props
* @returns {React.Component} A React component
*/
function AccountsTable(props) {
const apolloClient = useApolloClient();
const [shopId] = useCurrentShopId();

// React-Table state
const [isLoading, setIsLoading] = useState(false);
const [pageCount, setPageCount] = useState(1);
const [selectedRows, setSelectedRows] = useState([]);
const [tableData, setTableData] = useState([]);

// Tag selector state
const [isGroupSelectorVisible, setGroupSelectorVisibility] = useState(false);

// Create and memoize the column data
const columns = useMemo(() => [
{
Header: "",
accessor: (account) => getAccountAvatar(account),
id: "profilePicture"
}, {
Header: i18next.t("admin.accountsTable.header.email"),
accessor: (row) => row.emailRecords[0].address,
id: "email"
}, {
Header: i18next.t("admin.accountsTable.header.name"),
accessor: "name"
}
], []);

const onFetchData = useCallback(async ({ pageIndex, pageSize }) => {
// Wait for shop id to be available before fetching products.
setIsLoading(true);
if (!shopId) {
return;
}

const { data } = await apolloClient.query({
query: accountsQuery,
variables: {
groupIds: [props.group._id],
first: pageSize,
limit: (pageIndex + 1) * pageSize,
offset: pageIndex * pageSize
},
fetchPolicy: "network-only"
});

// Update the state with the fetched data as an array of objects and the calculated page count
setTableData(data.accounts.nodes);
setPageCount(Math.ceil(data.accounts.totalCount / pageSize));

setIsLoading(false);
}, [apolloClient, shopId, props.group._id]);

const onRowSelect = useCallback(async ({ selectedRows: rows }) => {
setSelectedRows(rows || []);
}, []);

const dataTableProps = useDataTable({
columns,
data: tableData,
isFilterable: false,
pageCount,
onFetchData,
onRowSelect,
getRowId: (row) => row._id
});

// Create options for the built-in ActionMenu in the DataTable
const options = useMemo(() => [{
label: i18next.t("admin.accountsTable.bulkActions.addRemoveGroupsFromAccount"),
isDisabled: selectedRows.length === 0,
onClick: () => {
setGroupSelectorVisibility(true);
}
}], [selectedRows]);

return (
<Fragment>
{selectedRows && !props.isLoadingGroups &&
<GroupSelectorDialog
isOpen={isGroupSelectorVisible}
onSuccess={() => null}
onClose={() => setGroupSelectorVisibility(false)}
accounts={tableData.filter((account) => selectedRows.includes(account._id))}
groups={props.groups}
/>
}
<DataTable
{...dataTableProps}
actionMenuProps={{ options }}
isLoading={isLoading}
/>
</Fragment>
);
}

AccountsTable.propTypes = {
group: PropTypes.shape({
_id: PropTypes.string.isRequired
}),
groups: PropTypes.arrayOf(PropTypes.object),
isLoadingGroups: PropTypes.bool
};

export default AccountsTable;
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import i18next from "i18next";
import SimpleSchema from "simpl-schema";
import CloseIcon from "mdi-material-ui/Close";
import Button from "@reactioncommerce/catalyst/Button";
import TextField from "@reactioncommerce/catalyst/TextField";
import Select from "@reactioncommerce/catalyst/Select";
import useReactoForm from "reacto-form/cjs/useReactoForm";
import muiOptions from "reacto-form/cjs/muiOptions";
import { useMutation } from "@apollo/react-hooks";
import { useSnackbar } from "notistack";
import {
Box,
Grid,
CardActions,
CardHeader,
CardContent,
Dialog,
IconButton,
makeStyles
} from "@material-ui/core";
import useRoles from "../hooks/useRoles";
import createGroupMutation from "../graphql/mutations/createGroup";
import updateGroupMutation from "../graphql/mutations/updateGroup";

const useStyles = makeStyles((theme) => ({
dialogPaper: {
overflow: "visible",
padding: theme.spacing(2)
},
cardContainer: {
alignItems: "center"
},
cardActions: {
padding: theme.spacing(2),
justifyContent: "flex-end"
},
hidden: {
display: "none"
},
visible: {
display: "block"
}
}));

const formSchema = new SimpleSchema({
name: {
type: String
}
});
const validator = formSchema.getFormValidator();

/**
* CreateOrEditGroup component
* @param {Object} props Component props
* @returns {React.Component} A React component
*/
function CreateOrEditGroup({ isOpen, onClose, onSuccess, group, shopId }) {
const [isSubmitting, setIsSubmitting] = useState(false);
const classes = useStyles();
const { enqueueSnackbar } = useSnackbar();

const { roles } = useRoles(shopId);

const [selectedRoles, setSelectedRoles] = useState(group ? group.permissions.map((role) => ({ label: role, value: role })) : []);

let mutation = createGroupMutation;

if (group) {
mutation = updateGroupMutation;
}

const [createOrUpdateGroup] = useMutation(mutation, {
ignoreResults: true,
onCompleted() {
setIsSubmitting(false);
onSuccess();
onClose();
},
onError() {
setIsSubmitting(false);
enqueueSnackbar(i18next.t("admin.groupCards.createOrUpdateGroupDialog.saveFailed"), { variant: "error" });
}
});

const initialValues = {};
const groupIdVariable = {};

if (group) {
initialValues.value = group;
groupIdVariable.groupId = group._id;
}

const {
getFirstErrorMessage,
getInputProps,
hasErrors,
isDirty,
submitForm
} = useReactoForm({
async onSubmit(formData) {
setIsSubmitting(true);

await createOrUpdateGroup({
variables: {
input: {
group: {
name: formData.name,
description: formData.description,
slug: formData.slug,
permissions: selectedRoles.map((role) => role.value)
},
shopId,
...groupIdVariable
}
}
});

setIsSubmitting(false);
},
validator(formData) {
return validator(formSchema.clean(formData));
},
...initialValues
});

const handleSubmit = (event) => {
event.preventDefault();
submitForm();
};

const rolesForSelect = roles.map((role) => ({ value: role.name, label: role.name }));

return (
<Dialog
classes={{ paper: classes.dialogPaper }}
open={isOpen}
onClose={onClose}
fullWidth
maxWidth="sm"
>
<CardHeader
action={
<IconButton aria-label="close" onClick={onClose}>
<CloseIcon />
</IconButton>
}
title={i18next.t("admin.groupCards.createOrUpdateGroupDialog.title")}
/>
<CardContent>
<Grid container spacing={1} className={classes.cardContainer}>
<Grid item sm={12}>
<TextField
error={hasErrors(["name"])}
fullWidth
helperText={getFirstErrorMessage(["name"])}
label={i18next.t("admin.groupCards.createOrUpdateGroupDialog.name")}
{...getInputProps("name", muiOptions)}
/>
</Grid>
<Grid item sm={12}>
<TextField
error={hasErrors(["slug"])}
fullWidth
helperText={getFirstErrorMessage(["slug"])}
label={i18next.t("admin.groupCards.createOrUpdateGroupDialog.slug")}
{...getInputProps("slug", muiOptions)}
/>
</Grid>
<Grid item sm={12}>
<TextField
error={hasErrors(["description"])}
fullWidth
helperText={getFirstErrorMessage(["description"])}
label={i18next.t("admin.groupCards.createOrUpdateGroupDialog.description")}
{...getInputProps("description", muiOptions)}
/>
</Grid>
<Grid item sm={12}>
<Select
fullWidth
isMulti
options={rolesForSelect}
onSelection={setSelectedRoles}
placeholder={i18next.t("admin.groupCards.createOrUpdateGroupDialog.selectRoles")}
value={selectedRoles}
/>
</Grid>
</Grid>
</CardContent>
<CardActions className={classes.cardActions}>
<Box>
<Button
onClick={onClose}
>
{i18next.t("app.cancel")}
</Button>
</Box>
<Button
color="primary"
disabled={isSubmitting || !isDirty}
variant="contained"
onClick={handleSubmit}
type="submit"
>
{isSubmitting ? i18next.t("app.settings.saveProcessing") : i18next.t("app.saveChanges")}
</Button>
</CardActions>
</Dialog>
);
}

CreateOrEditGroup.propTypes = {
group: PropTypes.object,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onSuccess: PropTypes.func,
shopId: PropTypes.string
};

export default CreateOrEditGroup;
Loading

0 comments on commit a83c97b

Please sign in to comment.