VandyHacks/vaken

View on GitHub
src/client/routes/manage/HackerTable.tsx

Summary

Maintainability
D
2 days
Test Coverage
D
60%
import React, { useContext, useState, useEffect, useRef, FC } from 'react';
import { AutoSizer, SortDirection } from 'react-virtualized';
import 'react-virtualized/styles.css';
import styled from 'styled-components';
import Select from 'react-select';
import { ValueType } from 'react-select/src/types';
import { SelectableGroup, SelectAll, DeselectAll } from 'react-selectable-fast';
import { CSVLink } from 'react-csv';
import { useHistory } from 'react-router-dom';

import { use } from 'passport';
import { ToggleSwitch } from '../../components/Buttons/ToggleSwitch';
import { Button } from '../../components/Buttons/Button';
import { SearchBox } from '../../components/Input/SearchBox';
import { FlexRow, FlexColumn } from '../../components/Containers/FlexContainers';
import STRINGS from '../../assets/strings.json';
import { TableCtxI, TableContext, Option, SearchCriteria } from '../../contexts/TableContext';
import {
    Hacker,
    useHackerStatusMutation,
    useEventsQuery,
    ApplicationStatus,
    useHackerStatusesMutation,
    useResumeDumpUrlQuery,
} from '../../generated/graphql';
import RemoveButton from '../../assets/img/remove_button.svg';
import AddButton from '../../assets/img/add_button.svg';
import { Spinner } from '../../components/Loading/Spinner';

import { HackerTableRows } from './HackerTableRows';
import { DeselectElement, SliderInput } from './SliderInput';
import { QueriedHacker, SortFnProps } from './HackerTableTypes';

const Float = styled.div`
    position: fixed;
    bottom: 3.5rem;
    right: 16rem;
    margin-right: 1rem;
`;

const TableLayout = styled('div')`
    width: 100%;
    box-sizing: border-box;
    flex: 1;
    display: flex;
    flex-direction: column;
`;

const TableOptions = styled('div')`
    display: flex;
    flex-flow: row nowrap;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 1rem;
`;

const AddRemBtn = styled.img`
    display: inline-block;
    height: 2rem;
    margin-left: 10px;
`;

const ColumnSelect = styled(Select)`
    min-width: 15rem;
    display: inline-block;
    font-size: 1rem;
    margin-right: 1rem;
    box-shadow: none;
    .select__control,
    .basic-multi-select,
    select__control--menu-is-open {
        background-color: #ffffff;
        padding: 0.2rem;
        border: 0.0625rem solid #ecebed;
        border-radius: 0.375rem;
        box-shadow: none;
        outline: none;
        :focus,
        :active {
            border: 0.0625rem solid ${STRINGS.ACCENT_COLOR_DARK};
        }
        :hover:not(.select__control--is-focused) {
            border: 0.0625rem solid #ecebed;
        }
        :hover.select__control--is-focused {
            border: 0.0625rem solid ${STRINGS.ACCENT_COLOR_DARK};
        }
    }
    .select__control--is-focused,
    .select__control--is-selected {
        border: 0.0625rem solid ${STRINGS.ACCENT_COLOR_DARK};
    }
    .select__multi-value__label {
        font-size: 1rem;
    }
    .select__option {
        :active,
        :hover,
        :focus {
            background-color: #e5e7fa;
        }
    }
    .select__option--is-focused,
    .select__option--is-selected {
        background-color: #e5e7fa;
        color: #000000;
    }
`;

const Count = styled.div`
    h3 {
        font-weight: bold;
    }

    margin-left: 10px;
    text-align: right;
`;

const columnOptions: { label: string; value: keyof QueriedHacker }[] = [
    { label: 'First Name', value: 'firstName' },
    { label: 'Last Name', value: 'lastName' },
    { label: 'Email Address', value: 'email' },
    { label: 'School', value: 'school' },
    { label: 'Graduation Year', value: 'gradYear' },
    { label: 'Status', value: 'status' },
];

// handles basic alphanumeric sorting
const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });

const onRemoveSearchCriterion = (ctx: TableCtxI, indexToRemove: number): (() => void) => {
    return () => {
        ctx.update(draft => {
            draft.searchCriteria = draft.searchCriteria.filter((_, index) => index !== indexToRemove);
        });
    };
};

const onAddSearchCriterion = (ctx: TableCtxI): (() => void) => {
    return () => {
        ctx.update(draft => {
            draft.searchCriteria.push(SearchCriteria.Create());
        });
    };
};

const onSearchBoxEntry = (
    ctx: TableCtxI,
    index: number
): ((e: React.ChangeEvent<HTMLInputElement>) => void) => {
    return e => {
        const { value } = e.target;
        ctx.update(draft => {
            draft.searchCriteria[index].searchValue = value;
        });
    };
};

const onToggleSelectAll = (ctx: TableCtxI): (() => void) => {
    return () => {
        ctx.update(draft => {
            draft.selectAll = !draft.selectAll;
        });
    };
};

// when draggable selection is cleared
const onSelectionClear = (ctx: TableCtxI): ((p: boolean) => void) => {
    return (p: boolean) =>
        ctx.update(draft => {
            draft.selectAll = false;
            draft.hasSelection = p;
            draft.selectedRowsIds = [];
        });
};

// when draggable selection is completed
const onSelectionFinish = (ctx: TableCtxI): ((keys: JSX.Element[]) => void) => {
    return (keys: JSX.Element[]): void => {
        if (keys.length > 0) {
            ctx.update(draft => {
                draft.hasSelection = true;
                draft.selectedRowsIds = keys.map((key: JSX.Element) => key.props.rowData.id);
            });
        }
    };
};

const onRegexToggle = (ctx: TableCtxI, index: number): ((p: boolean) => void) => {
    return (p: boolean) =>
        ctx.update(draft => {
            draft.searchCriteria[index].fuzzySearch = p;
        });
};

const onTableColumnSelect = (
    ctx: TableCtxI,
    index: number
): ((s: ValueType<Option, true>) => void) => {
    // Dependency injection
    return (s: ValueType<Option, true>) =>
        ctx.update(draft => {
            if (s == null) {
                draft.searchCriteria[index].selectedColumns = [];
            } else {
                // react-select is bad: https://github.com/JedWatson/react-select/issues/4252
                draft.searchCriteria[index].selectedColumns = (Array.isArray(s) ? s : [s]) as Option[];
            }
        });
};

const onSortColumnChange = (ctx: TableCtxI): ((p: SortFnProps) => void) => {
    return ({ sortBy, sortDirection }) => {
        const { sortBy: prevSortBy, sortDirection: prevSortDirection } = ctx.state;
        ctx.update(draft => {
            draft.sortBy =
                // Reset to unordered state if clicked when in decending order
                prevSortBy === sortBy && prevSortDirection === SortDirection.DESC ? undefined : sortBy;
            draft.sortDirection =
                prevSortBy === sortBy && prevSortDirection === SortDirection.DESC
                    ? undefined
                    : sortDirection;
        });
    };
};

export interface HackerTableProps {
    data: QueriedHacker[];
    isSponsor: boolean;
    viewResumes: boolean;
}

const HackerTable: FC<HackerTableProps> = ({
    data,
    isSponsor = false,
    viewResumes = false,
}: HackerTableProps): JSX.Element => {
    const table = useContext(TableContext);
    const [eventIds, setEventIds] = useState<string[]>([]);
    const [sortedData, setSortedData] = useState(data);
    const deselect = useRef<DeselectElement>(null);
    const [updateStatus] = useHackerStatusMutation();
    const [updateStatuses] = useHackerStatusesMutation();
    const resumeDumpUrlQuery = useResumeDumpUrlQuery();
    const { data: { resumeDumpUrl = '' } = {} } = resumeDumpUrlQuery || {};

    const {
        selectAll,
        hasSelection,
        searchCriteria,
        sortBy,
        sortDirection,
        selectedRowsIds,
    } = table.state;

    const { data: eventData, loading: eventLoading, error: eventError } = useEventsQuery();
    if (eventError) console.error(eventError);

    let options: Record<string, string>[] = [];
    if (eventData && eventData.events) {
        options = eventData.events.map(e => ({ label: e.name, value: e.id.toString() }));
    }

    useEffect(() => {
        // Only search one column in regex mode
        table.update(draft => {
            draft.searchCriteria.forEach((searchCriterion, index) => {
                if (!searchCriterion.fuzzySearch && searchCriterion.selectedColumns.length > 1) {
                    draft.searchCriteria[index].selectedColumns = [
                        draft.searchCriteria[index].selectedColumns[0],
                    ];
                }
            });
        });
        // eslint wants to auto-fix this to include table and selectedColumns, but this breaks the toggle
        // eslint-disable-next-line
    }, [searchCriteria]);

    useEffect(() => {
        // filter and sort data
        const newData = searchCriteria.reduce(SearchCriteria.filter, [...data]);
        let filteredData = newData;
        if (eventIds.length > 0) {
            filteredData = newData.filter(
                (hacker: Partial<Hacker>) =>
                    hacker &&
                    hacker.eventsAttended &&
                    hacker.eventsAttended.some(eventId => eventIds.includes(eventId))
            );
        }

        // Sort data based on props and context
        if (sortBy && sortDirection) {
            filteredData.sort((a, b) =>
                sortDirection === SortDirection.DESC
                    ? collator.compare(`${b[sortBy]}`, `${a[sortBy]}`)
                    : collator.compare(`${a[sortBy]}`, `${b[sortBy]}`)
            );
        }

        setSortedData(filteredData);
    }, [data, sortBy, sortDirection, searchCriteria, eventIds]);

    const [isResumeDumpReady, setIsResumeDumpReady] = useState(false);
    useEffect(() => {
        setIsResumeDumpReady(resumeDumpUrl !== '');
    }, [resumeDumpUrl]);

    // handles the text or regex search and sets the sortedData state with the updated row list
    // floating button that onClick toggles between selecting all or none of the rows
    const SelectAllButton = (
        <div
            style={{
                position: 'fixed',
                bottom: '3.25rem',
                right: '3.75rem',
            }}>
            <Button large onClick={onToggleSelectAll(table)}>
                {selectAll || hasSelection ? 'Deselect All' : 'Select All'}
            </Button>
        </div>
    );

    // prevents hackers with certain statuses from being selected
    const isSelectable = (status: ApplicationStatus): boolean => {
        return (
            status === ApplicationStatus.Submitted ||
            status === ApplicationStatus.Accepted ||
            status === ApplicationStatus.Rejected
        );
    };

    // assigns the row names for styling and to prevent selection
    const generateRowClassName = ({ index }: { index: number }): string => {
        let className;
        if (index < 0) className = 'headerRow';
        else {
            className = index % 2 === 0 ? 'evenRow' : 'oddRow';
            const { status, id } = sortedData[index];
            if (!isSelectable(status)) {
                className = className.concat(' ignore-select');
            } else if (selectAll || selectedRowsIds.includes(id)) {
                className = className.concat(' selected');
            }
        }
        return className;
    };

    return (
        <TableLayout>
            <TableOptions>
                <FlexColumn>
                    {!eventLoading && (
                        <div style={{ paddingBottom: '5px' }}>
                            <span style={{ paddingRight: '5px' }}>Filter By Events Attended: </span>
                            <ColumnSelect
                                isMulti
                                options={options}
                                onChange={(selected: Record<string, string>[]) => {
                                    if (!selected) setEventIds([]);
                                    else setEventIds(selected.map(s => s.value));
                                }}
                            />
                        </div>
                    )}
                    {searchCriteria.map((criterion, index) => (
                        // eslint-disable-next-line
                        <FlexRow key={index}>
                            <ColumnSelect
                                isMulti={criterion.fuzzySearch}
                                name="colors"
                                defaultValue={[columnOptions[0]]}
                                value={criterion.selectedColumns}
                                options={columnOptions}
                                className="basic-multi-select"
                                classNamePrefix="select"
                                onChange={(value: ValueType<Option, true>) =>
                                    onTableColumnSelect(table, index)(value)
                                }
                            />
                            <SearchBox
                                width="100%"
                                value={criterion.searchValue}
                                placeholder={
                                    criterion.fuzzySearch
                                        ? 'Search by text'
                                        : "Search by regex string, e.g. '^[a-b].*'"
                                }
                                onChange={onSearchBoxEntry(table, index)}
                                minWidth="15rem"
                                hasIcon
                                flex
                            />
                            <ToggleSwitch
                                label="Fuzzy Search: "
                                checked={criterion.fuzzySearch}
                                onChange={onRegexToggle(table, index)}
                            />
                            <AddRemBtn src={AddButton} alt="add" onClick={onAddSearchCriterion(table)} />
                            {searchCriteria.length > 1 ? (
                                <AddRemBtn
                                    src={RemoveButton}
                                    alt="remove"
                                    onClick={onRemoveSearchCriterion(table, index)}
                                />
                            ) : (
                                <div style={{ width: 'calc(10px + 2rem)' }} />
                            )}
                        </FlexRow>
                    ))}
                </FlexColumn>
                <Count style={{ margin: '20px' }}>
                    <h3>Num Shown:</h3>
                    <p>{sortedData.length}</p>
                    {selectedRowsIds.length > 0 ? (
                        <>
                            <h3>Num Selected:</h3>
                            <p>{selectedRowsIds.length}</p>
                        </>
                    ) : null}
                </Count>
                {viewResumes &&
                    (isResumeDumpReady ? (
                        <Button linkTo={resumeDumpUrl}>Download Resumes</Button>
                    ) : (
                        <Spinner />
                    ))}
                <CSVLink style={{ margin: '20px' }} data={sortedData} filename="exportedData.csv">
                    Export
                </CSVLink>
            </TableOptions>
            <AutoSizer>
                {({ height, width }) => (
                    <SelectableGroup
                        clickClassName="selected"
                        enableDeselect
                        deselectOnEsc
                        tolerance={0}
                        allowClickWithoutSelected={false}
                        onSelectionClear={onSelectionClear(table)}
                        onSelectionFinish={onSelectionFinish(table)}
                        ignoreList={['.ignore-select']}
                        resetOnStart>
                        <HackerTableRows
                            width={width}
                            height={height}
                            updateStatus={updateStatus}
                            sortedData={sortedData}
                            onSortColumnChange={onSortColumnChange}
                            generateRowClassName={generateRowClassName}
                            table={table}
                            isSponsor={isSponsor}
                            viewResumes={viewResumes}
                        />
                        {selectAll || hasSelection ? (
                            <DeselectAll ref={deselect}>{SelectAllButton}</DeselectAll>
                        ) : (
                            <SelectAll
                                onClick={() =>
                                    table.update(draft => {
                                        draft.hasSelection = true;
                                        draft.selectedRowsIds = sortedData
                                            .filter(row => isSelectable(row.status))
                                            .map(row => row.id);
                                        console.log(draft.selectedRowsIds);
                                    })
                                }>
                                {SelectAllButton}
                            </SelectAll>
                        )}
                        {hasSelection && (
                            <Float className="ignore-select">
                                <SliderInput
                                    updateStatuses={updateStatuses}
                                    deselect={deselect}
                                    selectedRowsIds={selectedRowsIds}
                                    sortBy={sortBy}
                                />
                            </Float>
                        )}
                    </SelectableGroup>
                )}
            </AutoSizer>
        </TableLayout>
    );
};

export default HackerTable;