Skip to content
Snippets Groups Projects
ServicesPage.jsx 14.9 KiB
Newer Older
Bruno Boiget's avatar
Bruno Boiget committed
import React from 'react';
import PropTypes from 'prop-types';
import { makeStyles } from 'tss-react/mui';
import Container from '@mui/material/Container';
import FilterListIcon from '@mui/icons-material/FilterList';
import ClearIcon from '@mui/icons-material/Clear';
import ToggleButton from '@mui/material/ToggleButton';
import RadioButtonUncheckedRoundedIcon from '@mui/icons-material/RadioButtonUncheckedRounded';
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import CloseIcon from '@mui/icons-material/Close';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Grid from '@mui/material/Grid';
Lionel Morin's avatar
Lionel Morin committed
import i18n from 'meteor/universe:i18n';
import { withTracker } from 'meteor/react-meteor-data';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Fade from '@mui/material/Fade';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Slide from '@mui/material/Slide';
import AppBar from '@mui/material/AppBar';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import Toolbar from '@mui/material/Toolbar';
import Dialog from '@mui/material/Dialog';
import List from '@mui/material/List';
import Divider from '@mui/material/Divider';
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
import Tooltip from '@mui/material/Tooltip';
Francois AUBEUT's avatar
Francois AUBEUT committed
import ServiceDetails from '../../components/services/ServiceDetails';
import Services from '../../../api/services/services';
import Categories from '../../../api/categories/categories';
import Spinner from '../../components/system/Spinner';
Francois AUBEUT's avatar
Francois AUBEUT committed
import { useAppContext } from '../../contexts/context';
Francois AUBEUT's avatar
Francois AUBEUT committed
import ServiceDetailsList from '../../components/services/ServiceDetailsList';
import { useIconStyles, DetaiIconCustom, SimpleIconCustom } from '../../components/system/icons/icons';
import { useStructure } from '../../../api/structures/hooks';
const useStyles = makeStyles()((theme, isMobile) => ({
  flex: {
    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'center',
  },
  cardGrid: {
    marginBottom: '0px',
  },
  chip: {
    margin: theme.spacing(1),
    '&&:hover,&&:focus': {
      backgroundColor: theme.palette.backgroundFocus.main,
      color: theme.palette.primary.main,
    },
  },
  smallGrid: {
    height: 20,
  },
  filterTitle: {
    fontSize: '0.85rem',
    margin: theme.spacing(1),
  },
  badge: {
    height: 20,
    display: 'flex',
    padding: '0 6px',
    flexWrap: 'wrap',
    fontSize: '0.75rem !important',
    backgroundColor: theme.palette.primary.main,
    color: `${theme.palette.tertiary.main} !important`,
    minWidth: 20,
    borderRadius: 10,
    marginLeft: isMobile ? 10 : 0,
    alignItems: 'center',
    justifyContent: 'center',
  },
  invertedBadge: {
    height: 20,
    display: 'flex',
    padding: '0 6px',
    flexWrap: 'wrap',
    fontSize: '0.75rem !important',
    backgroundColor: theme.palette.tertiary.main,
    color: `${theme.palette.primary.main} !important`,
    minWidth: 20,
    borderRadius: 10,
    marginLeft: isMobile ? 10 : 0,
    alignItems: 'center',
    justifyContent: 'center',
  },
  gridItem: {
    display: 'flex',
    justifyContent: 'center',
  },
  small: {
    padding: '5px !important',
    transition: 'all 300ms ease-in-out',
  },
  spaceBetween: {
    display: 'flex',
    justifyContent: 'space-between',
  },
  mobileButtonContainer: {
    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'center',
    paddingTop: '0 !important',
  },
  categoryFilterMobile: {
    position: 'absolute',
    top: 0,
    bottom: 0,
    right: 0,
    left: 0,
    backgroundColor: theme.palette.tertiary.main,
    zIndex: theme.zIndex.modal,
  },
  categoriesList: {
    marginTop: 60,
  },
  appBarBottom: {
    bottom: 0,
    top: 'auto',
    backgroundColor: theme.palette.tertiary.main,
  },
  toolbarBottom: {
    justifyContent: 'space-between',
  },
  emptyMsg: {
    marginTop: 30,
    marginBottom: 15,
  },
}));
const Transition = React.forwardRef((props, ref) => <Slide direction="up" ref={ref} {...props} />);

export function ServicesPage({ services, categories, ready, structureMode, offline }) {
  const [{ user, loadingUser, isMobile, servicePage }, dispatch] = useAppContext();
  const structure = offline ? null : useStructure();
  const { classes } = useStyles(isMobile);
  const { classes: classesIcons } = useIconStyles();
  const {
    catList = [],
    search = '',
    filterToggle = false,
    viewMode = 'card', // Possible values : "card" or "list"
  const favs = loadingUser || offline ? [] : user.favServices;
  const updateGlobalState = (key, value) =>
    dispatch({
      type: 'servicePage',
      data: {
        ...servicePage,
        [key]: value,
      },
    });
  const toggleFilter = () => updateGlobalState('filterToggle', !filterToggle);
  const resetCatList = () => updateGlobalState('catList', []);
  const changeViewMode = (_, value) => updateGlobalState('viewMode', value);
Lionel Morin's avatar
Lionel Morin committed
  const updateCatList = (catId) => {
    // Call by click on categories of services
    if (catList.includes(catId)) {
      // catId already in list so remove it
      updateGlobalState(
        'catList',
        catList.filter((id) => id !== catId),
      );
Lionel Morin's avatar
Lionel Morin committed
    } else {
      // add new catId to list
      updateGlobalState('catList', [...catList, catId]);
Lionel Morin's avatar
Lionel Morin committed
    }
Lionel Morin's avatar
Lionel Morin committed
  const filterServices = (service) => {
    let filterSearch = true;
    let filterCat = true;
    if (search) {
      let searchText = service.title + service.description;
      searchText = searchText.toLowerCase();
      filterSearch = searchText.indexOf(search.toLowerCase()) > -1;
    }
    if (catList.length > 0) {
      const intersection = catList.filter((value) => service.categories.includes(value));
      filterCat = intersection.length > 0;
    }
    return filterSearch && filterCat;
  };
Lionel Morin's avatar
Lionel Morin committed

  const mapList = (func) => services.filter((service) => filterServices(service)).map(func);
  const favAction = (id) => (favs.indexOf(id) === -1 ? 'fav' : 'unfav');

  const toggleButtons = (
    <ToggleButtonGroup value={viewMode} exclusive aria-label={i18n.__('pages.ServicesPage.viewMode')}>
      <ToggleButton value="card" onClick={changeViewMode} aria-label={i18n.__('pages.ServicesPage.viewDetail')}>
        <Tooltip title={i18n.__('pages.ServicesPage.viewDetail')} aria-label={i18n.__('pages.ServicesPage.viewDetail')}>
          <span className={classesIcons.size}>
            <DetaiIconCustom />
          </span>
      <ToggleButton value="list" onClick={changeViewMode} aria-label={i18n.__('pages.ServicesPage.viewSimple')}>
        <Tooltip title={i18n.__('pages.ServicesPage.viewSimple')} aria-label={i18n.__('pages.ServicesPage.viewSimple')}>
          <span className={classesIcons.size}>
            <SimpleIconCustom />
          </span>
    </ToggleButtonGroup>
  );

  const mobileFilterButton = (
    <Button
      style={{ textTransform: 'none' }}
      color="primary"
      variant="outlined"
      size="large"
      onClick={toggleFilter}
      startIcon={<FilterListIcon />}
    >
      {i18n.__('pages.ServicesPage.filter')}{' '}
      {catList.length ? (
        <span className={classes.badge}>{catList.length}</span>
      ) : (
        i18n.__('pages.ServicesPage.emptyFilter')
      )}
Lionel Morin's avatar
Lionel Morin committed
      {!ready ? (
        <Spinner />
          <Container>
            <Grid container spacing={4}>
              <Grid item xs={12} className={isMobile ? null : classes.flex}>
                <Typography variant={isMobile ? 'h6' : 'h4'} className={classes.flex}>
                  {i18n.__(structureMode ? structure && structure.name : 'pages.ServicesPage.titleServices')}
                <div className={classes.spaceBetween}>{!isMobile && toggleButtons}</div>
            </Grid>
            <Grid container spacing={4}>
Bruno Boiget's avatar
Bruno Boiget committed
              {isMobile ? (
                <Grid item xs={12} sm={12} className={classes.mobileButtonContainer}>
                  {mobileFilterButton}
                  {toggleButtons}
                </Grid>
Bruno Boiget's avatar
Bruno Boiget committed
              ) : (
                <Grid item xs={12} sm={12} md={12}>
Bruno Boiget's avatar
Bruno Boiget committed
                    <Typography variant="h6" display="inline" className={classes.filterTitle}>
                      {i18n.__('pages.ServicesPage.categories')}
                    </Typography>
                    {categories.map((cat) => (
                      <Chip
                        className={classes.chip}
                        key={cat._id}
                        label={cat.name}
                          <span className={catList.includes(cat._id) ? classes.invertedBadge : classes.badge}>
                            {cat.count}
                          </span>
                        onDelete={() => updateCatList(cat._id)}
                        variant={catList.includes(cat._id) ? 'default' : 'outlined'}
                        color={catList.includes(cat._id) ? 'primary' : 'default'}
                        onClick={() => updateCatList(cat._id)}
                      />
                    ))}
Bruno Boiget's avatar
Bruno Boiget committed
                    {catList.length > 0 ? (
                      <Button color="primary" onClick={resetCatList} startIcon={<ClearIcon />}>
                        {i18n.__('pages.ServicesPage.reset')}
                      </Button>
                    ) : null}
Bruno Boiget's avatar
Bruno Boiget committed
                </Grid>
              )}
Bruno Boiget's avatar
Bruno Boiget committed

            <Grid container className={classes.cardGrid} spacing={isMobile ? 2 : 4}>
              {services.length === 0 ? (
                <Typography className={classes.emptyMsg}>
                  {i18n.__(`pages.ServicesPage.${structureMode ? 'NoStructureServices' : 'NoServices'}`)}
                </Typography>
              ) : viewMode === 'list' && isMobile ? (
                mapList((service) => (
                  <Grid className={classes.gridItem} item xs={4} md={2} key={service._id}>
                    <ServiceDetailsList service={service} favAction={favAction(service._id)} />
                  </Grid>
                ))
              ) : (
                mapList((service) => (
                  <Grid className={classes.gridItem} item key={service._id} xs={12} sm={12} md={6} lg={4}>
                    <ServiceDetails
                      service={service}
                      favAction={favAction(service._id)}
                      updateCategories={updateCatList}
                      catList={catList}
                      categories={categories}
                      isShort={!isMobile && viewMode === 'list'}
                    />
                  </Grid>
                ))
              )}
Lionel Morin's avatar
Lionel Morin committed
            </Grid>
            <Dialog fullScreen open={filterToggle && isMobile} TransitionComponent={Transition}>
              <AppBar className={classes.appBar}>
                <Toolbar>
                  <IconButton edge="start" color="inherit" onClick={toggleFilter} aria-label="close" size="large">
                    <CloseIcon />
                  </IconButton>
                  <Typography variant="h6">{i18n.__('pages.ServicesPage.categories')}</Typography>
                  {!!catList.length && <span className={classes.invertedBadge}>{catList.length}</span>}
                </Toolbar>
              </AppBar>
              <List className={classes.categoriesList}>
                {categories.map((cat) => [
                  <ListItem button key={cat._id}>
                    <ListItemText
                      primary={cat.name}
                      onClick={() => updateCatList(cat._id)}
                      secondary={`${cat.count} applications`}
                    />
                    <ListItemSecondaryAction>
                      <IconButton edge="end" aria-label="add" onClick={() => updateCatList(cat._id)} size="large">
                        {catList.includes(cat._id) ? (
                          <CheckCircleRoundedIcon fontSize="large" color="primary" />
                        ) : (
                          <RadioButtonUncheckedRoundedIcon fontSize="large" color="primary" />
                        )}
                      </IconButton>
                    </ListItemSecondaryAction>
                  </ListItem>,
                  <Divider key={`${cat._id}divider`} />,
                ])}
              </List>
              <AppBar className={classes.appBarBottom}>
                <Toolbar className={classes.toolbarBottom}>
                  <Button
                    color="primary"
                    disabled={catList.length === 0}
                    variant="outlined"
                    onClick={resetCatList}
                    startIcon={<ClearIcon />}
                  >
                    {i18n.__('pages.ServicesPage.reset')}
                  </Button>
                  <Button
                    color="primary"
                    variant="contained"
                    onClick={toggleFilter}
                    startIcon={<CheckCircleRoundedIcon />}
                  >
                    {i18n.__('pages.ServicesPage.validate')}
                  </Button>
                </Toolbar>
              </AppBar>
            </Dialog>
          </Container>
        </Fade>

ServicesPage.propTypes = {
  services: PropTypes.arrayOf(PropTypes.object).isRequired,
Lionel Morin's avatar
Lionel Morin committed
  categories: PropTypes.arrayOf(PropTypes.object).isRequired,
Lionel Morin's avatar
Lionel Morin committed
  ready: PropTypes.bool.isRequired,
  structureMode: PropTypes.bool.isRequired,
  offline: PropTypes.bool,
};

ServicesPage.defaultProps = {
  offline: false,
Bruno Boiget's avatar
Bruno Boiget committed
export default withTracker(({ match: { path } }) => {
  const structureMode = path === '/structure';
  const [{ structure }] = useAppContext();
  /**
   * - Grab current user structure with the ancestors
   *
   * - This is used to get all services from top level to current one
   */
  const currentStructureWithAncestors = [];
  if (structure && structure._id) {
    currentStructureWithAncestors.push(structure._id, ...structure.ancestorsIds);
  }
  const subName = structureMode ? 'services.structure.ids' : 'services.all';
  const servicesHandle = Meteor.subscribe(subName, structureMode && { structureIds: currentStructureWithAncestors });
Bruno Boiget's avatar
Bruno Boiget committed
  let services;
  if (structureMode) {
    services = Services.findFromPublication(subName, { state: { $ne: 10 } }, { sort: { title: 1 } }).fetch();
Bruno Boiget's avatar
Bruno Boiget committed
  } else {
    services = Services.find({ state: { $ne: 10 } }, { sort: { title: 1 } }).fetch();
  }
Lionel Morin's avatar
Lionel Morin committed
  const categoriesHandle = Meteor.subscribe('categories.all');
  const cats = Categories.find({}, { sort: { name: 1 } }).fetch();
  const categories = cats.map((cat) => ({
    ...cat,
    count: Services.find({ state: { $ne: 10 }, categories: { $in: [cat._id] } }).count(),
  }));
Lionel Morin's avatar
Lionel Morin committed
  const ready = servicesHandle.ready() && categoriesHandle.ready();
  return {
    services,
Lionel Morin's avatar
Lionel Morin committed
    categories,
Lionel Morin's avatar
Lionel Morin committed
    ready,
})(ServicesPage);