diff --git a/imports/plugins/core/accounts/client/components/Accounts.js b/imports/plugins/core/accounts/client/components/Accounts.js new file mode 100644 index 0000000000..78a7340f39 --- /dev/null +++ b/imports/plugins/core/accounts/client/components/Accounts.js @@ -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 ( + + setCurrentTab(value)}> + + + + + + + + {currentTab === 0 && + + } + + {currentTab === 1 && + + } + + {currentTab === 2 && + + } + + ); +} + +export default Accounts; diff --git a/imports/plugins/core/accounts/client/components/AccountsTable.js b/imports/plugins/core/accounts/client/components/AccountsTable.js new file mode 100644 index 0000000000..3bb26b6a64 --- /dev/null +++ b/imports/plugins/core/accounts/client/components/AccountsTable.js @@ -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 ( + + {selectedRows && !props.isLoadingGroups && + null} + onClose={() => setGroupSelectorVisibility(false)} + accounts={tableData.filter((account) => selectedRows.includes(account._id))} + groups={props.groups} + /> + } + + + ); +} + +AccountsTable.propTypes = { + group: PropTypes.shape({ + _id: PropTypes.string.isRequired + }), + groups: PropTypes.arrayOf(PropTypes.object), + isLoadingGroups: PropTypes.bool +}; + +export default AccountsTable; diff --git a/imports/plugins/core/accounts/client/components/CreateOrEditGroupDialog.js b/imports/plugins/core/accounts/client/components/CreateOrEditGroupDialog.js new file mode 100644 index 0000000000..0813bcd87a --- /dev/null +++ b/imports/plugins/core/accounts/client/components/CreateOrEditGroupDialog.js @@ -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 ( + + + + + } + title={i18next.t("admin.groupCards.createOrUpdateGroupDialog.title")} + /> + + + + + + + + + + + + + setSelectedGroups(tags)} + placeholder={i18next.t("admin.accountsTable.groupSelectorDialog.inputPlaceholder")} + value={selectedGroups} + /> + + + + + + + + + + + ); +} + +GroupSelector.propTypes = { + accounts: PropTypes.arrayOf(PropTypes.object).isRequired, + groups: PropTypes.arrayOf(PropTypes.object).isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSuccess: PropTypes.func +}; + +export default GroupSelector; diff --git a/imports/plugins/core/accounts/client/components/Invitations.js b/imports/plugins/core/accounts/client/components/Invitations.js new file mode 100644 index 0000000000..1be2fce74d --- /dev/null +++ b/imports/plugins/core/accounts/client/components/Invitations.js @@ -0,0 +1,104 @@ +import React, { useState, useMemo, useCallback } from "react"; +import { useApolloClient } from "@apollo/react-hooks"; +import i18next from "i18next"; +import { Card, CardContent, makeStyles } from "@material-ui/core"; +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 invitationsQuery from "../graphql/queries/invitations"; + +const useStyles = makeStyles(() => ({ + card: { + marginTop: "1rem" + } +})); + + +/** + * @summary Main invitations view + * @name Invitations + * @returns {React.Component} A React component + */ +function Invitations() { + const apolloClient = useApolloClient(); + const [shopId] = useCurrentShopId(); + + // React-Table state + const [isLoading, setIsLoading] = useState(false); + const [pageCount, setPageCount] = useState(1); + const [tableData, setTableData] = useState([]); + + const classes = useStyles(); + + // Create and memoize the column data + const columns = useMemo(() => [ + { + Header: "", + accessor: (account) => getAccountAvatar(account), + id: "profilePicture" + }, { + Header: i18next.t("admin.invitationsTable.header.email"), + accessor: (row) => row.email, + id: "email" + }, { + Header: i18next.t("admin.invitationsTable.header.invitedBy"), + accessor: (row) => row.invitedBy.emailRecords[0].address, + id: "invitedBy" + }, { + Header: i18next.t("admin.invitationsTable.header.shop"), + accessor: (row) => row.shop.name, + id: "shop" + }, { + Header: i18next.t("admin.invitationsTable.header.groups"), + accessor: (row) => row.groups.reduce((allGroups, group) => `${allGroups}, ${group.name}`, "").substring(2), + id: "groups" + } + ], []); + + 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: invitationsQuery, + variables: { + shopIds: [shopId], + 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.invitations.nodes); + setPageCount(Math.ceil(data.invitations.totalCount / pageSize)); + + setIsLoading(false); + }, [apolloClient, shopId]); + + const dataTableProps = useDataTable({ + columns, + data: tableData, + isFilterable: false, + pageCount, + onFetchData, + getRowId: (row) => row._id + }); + + return ( + + + + + + ); +} + +export default Invitations; diff --git a/imports/plugins/core/accounts/client/components/InviteShopMemberDialog.js b/imports/plugins/core/accounts/client/components/InviteShopMemberDialog.js new file mode 100644 index 0000000000..9b032c91b0 --- /dev/null +++ b/imports/plugins/core/accounts/client/components/InviteShopMemberDialog.js @@ -0,0 +1,191 @@ +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 inviteShopMemberMutation from "../graphql/mutations/inviteShopMember"; + +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 + }, + email: { + type: String, + min: 3 + } +}); +const validator = formSchema.getFormValidator(); + +/** + * InviteShopMember component + * @param {Object} props Component props + * @returns {React.Component} A React component + */ +function InviteShopMember({ isOpen, onClose, onSuccess, groups, shopId }) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedGroups, setSelectedGroups] = useState([]); + const classes = useStyles(); + const { enqueueSnackbar } = useSnackbar(); + + const [inviteShopMember] = useMutation(inviteShopMemberMutation, { + ignoreResults: true, + onCompleted() { + setIsSubmitting(false); + onSuccess(); + }, + onError() { + setIsSubmitting(false); + enqueueSnackbar(i18next.t("admin.groupCards.inviteStaffMemberDialog.saveFailed"), { variant: "error" }); + } + }); + + const { + getFirstErrorMessage, + getInputProps, + hasErrors, + isDirty, + submitForm + } = useReactoForm({ + async onSubmit(formData) { + setIsSubmitting(true); + + await inviteShopMember({ + variables: { + input: { + email: formData.email, + groupIds: selectedGroups.map((role) => role.value), + name: formData.name, + shopId + } + } + }); + + setIsSubmitting(false); + }, + validator(formData) { + return validator(formSchema.clean(formData)); + } + }); + + const handleSubmit = (event) => { + event.preventDefault(); + submitForm(); + }; + + const groupsForSelect = groups.map((group) => ({ value: group._id, label: group.name })); + + return ( + + + + + } + title={i18next.t("admin.groupCards.inviteStaffMemberDialog.title")} + /> + + + + + + + + + +