🏡 index : ~doyle/chartered.git

import React = require("react");
import { useState } from "react";
import { PersonPlus, Trash, CheckLg, Save, PlusLg } from 'react-bootstrap-icons';
import { authenticatedEndpoint, useAuthenticatedRequest } from "../../util";
import { useAuth } from "../../useAuth";
import { Button, Modal } from "react-bootstrap";
import { AsyncTypeahead } from "react-bootstrap-typeahead";
import { debounce } from "lodash";
import _ = require("lodash");

interface CratesMembersResponse {
    allowed_permissions: string[],
    members: Member[],
}

interface Member {
    id: number,
    username: string,
    permissions: string[],
}

export default function Members({ crate }: { crate: string }) {
    const auth = useAuth();
    const [reload, setReload] = useState(0);
    const { response, error } = useAuthenticatedRequest<CratesMembersResponse>({
        auth,
        endpoint: `crates/${crate}/members`,
    }, [reload]);
    const [prospectiveMembers, setProspectiveMembers] = useState([]);

    React.useEffect(() => {
        if (response && response.members) {
            setProspectiveMembers(prospectiveMembers.filter((prospectiveMember) => {
                _.findIndex(response.members, (responseMember) => responseMember.id === prospectiveMember.id) === -1
            }));
        }
    }, [response])

    if (error) {
        return <>{error}</>;
    } else if (!response) {
        return <div className="d-flex justify-content-center align-items-center">
            <div className="spinner-border text-light" role="status">
                <span className="visually-hidden">Loading...</span>
            </div>
        </div>;
    }

    const allowedPermissions = response.allowed_permissions;

    return <div className="container-fluid g-0">
        <div className={/*"table-responsive"*/ ""}>
            <table className="table table-striped">
                <tbody>
                    {response.members.map((member, index) =>
                        <MemberListItem
                            key={index}
                            crate={crate}
                            member={member}
                            prospectiveMember={false}
                            allowedPermissions={allowedPermissions}
                            onUpdateComplete={() => setReload(reload + 1)}
                        />
                    )}

                    {prospectiveMembers.map((member, index) =>
                        <MemberListItem
                            key={index}
                            crate={crate}
                            member={member}
                            prospectiveMember={true}
                            allowedPermissions={allowedPermissions}
                            onUpdateComplete={() => setReload(reload + 1)}
                        />
                    )}

                    <MemberListInserter
                        onInsert={(username, userId) => setProspectiveMembers([
                            ...prospectiveMembers,
                            {
                                id: userId,
                                username,
                                permissions: ["VISIBLE"],
                            }
                        ])}
                        existingMembers={response.members}
                    />
                </tbody>
            </table>
        </div>
    </div>;
}

function MemberListItem({ crate, member, prospectiveMember, allowedPermissions, onUpdateComplete }: { crate: string, member: Member, prospectiveMember: boolean, allowedPermissions: string[], onUpdateComplete: () => any }) {
    const auth = useAuth();
    const [selectedPermissions, setSelectedPermissions] = useState(member.permissions);
    const [deleting, setDeleting] = useState(false);
    const [saving, setSaving] = useState(false);
    const [error, setError] = useState(null);

    let itemAction = <></>;

    const saveUserPermissions = async () => {
        setSaving(true);

        try {
            let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), {
                method: prospectiveMember ? 'PUT' : 'PATCH',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    user_id: member.id,
                    permissions: selectedPermissions,
                }),
            });
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            onUpdateComplete();
        } catch (e) {
            setError(error);
        } finally {
            setSaving(false);
        }
    };

    const doDelete = async () => {
        setSaving(true);

        try {
            let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), {
                method: 'DELETE',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    user_id: member.id,
                }),
            });
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            onUpdateComplete();
        } catch (e) {
            setError(error);
        } finally {
            setSaving(false);
        }
    };

    if (saving) {
        itemAction = <button type="button" className="btn">
            <div className="spinner-grow spinner-grow-sm text-primary" role="status">
                <span className="visually-hidden">Loading...</span>
            </div>
        </button>;
    } else if (!prospectiveMember && selectedPermissions.indexOf("VISIBLE") === -1) {
        itemAction = <button type="button" className="btn text-danger" onClick={() => setDeleting(true)}>
            <Trash />
        </button>;
    } else if (prospectiveMember || selectedPermissions.sort().join(',') != member.permissions.sort().join(',')) {
        itemAction = <button type="button" className="btn text-success" onClick={saveUserPermissions}>
            <CheckLg />
        </button>;
    }

    return <>
        <DeleteModal show={deleting === true}
            onCancel={() => setDeleting(false)}
            onConfirm={() => doDelete()}
            username={member.username} />

        <ErrorModal error={error} onClose={() => setError(null)} />

        <tr>
            <td className="align-middle fit">
                <img src="http://placekitten.com/48/48" className="rounded-circle" />
            </td>

            <td className="align-middle">
                <strong>{member.username}</strong><br />
                <em>(that's you!)</em>
            </td>

            <td className="align-middle">
                <RenderPermissions
                    allowedPermissions={allowedPermissions}
                    selectedPermissions={selectedPermissions}
                    userId={member.id}
                    onChange={setSelectedPermissions}
                />
            </td>

            <td className="align-middle fit">
                {itemAction}
            </td>
        </tr>
    </>;
}

function MemberListInserter({ onInsert, existingMembers }: { existingMembers: Member[], onInsert: (username, user_id) => any }) {
    const auth = useAuth();
    const searchRef = React.useRef(null);
    const [loading, setLoading] = useState(false);
    const [options, setOptions] = useState([]);
    const [error, setError] = useState("");

    const handleSearch = async (query) => {
        setLoading(true);
        setError("");

        try {
            let res = await fetch(authenticatedEndpoint(auth, `users/search?q=` + encodeURIComponent(query)));
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            setOptions(json.users || []);
        } catch (e) {
            setError(e.message);
        } finally {
            setLoading(false);
        }
    };

    const handleChange = (selected) => {
        onInsert(selected[0].username, selected[0].user_id);
        searchRef.current.clear();
    }

    return <tr>
        <td className="align-middle fit">
            <div
                className="d-flex align-items-center justify-content-center rounded-circle"
                style={{ width: '48px', height: '48px', background: '#DEDEDE', fontSize: '1rem' }}
            >
                <PersonPlus />
            </div>
        </td>

        <td className="align-middle">
            <AsyncTypeahead
                id="search-new-user"
                onSearch={handleSearch}
                filterBy={(option) => _.findIndex(existingMembers, (existing) => option.user_id === existing.id) === -1}
                labelKey="username"
                options={options}
                isLoading={loading}
                placeholder="Search for User"
                onChange={handleChange}
                ref={searchRef}
                renderMenuItemChildren={(option, props) => <>
                    <img
                        alt={option.username}
                        src="http://placekitten.com/24/24"
                        className="rounded-circle me-2"
                    />
                    <span>{option.username}</span>
                </>}
            />

            <div className="text-danger">{error}</div>
        </td>

        <td className="align-middle">
        </td>

        <td className="align-middle">
            <button type="button" className="btn text-dark pe-none">
                <PlusLg />
            </button>
        </td>
    </tr>;
}

function RenderPermissions({ allowedPermissions, selectedPermissions, userId, onChange }: { allowedPermissions: string[], selectedPermissions: string[], userId: number, onChange: (permissions) => any }) {
    return (
        <div className="row ms-2">
            {allowedPermissions.map((permission) => (
                <div key={permission + userId} className="form-check col-12 col-md-6">
                    <input
                        className="form-check-input"
                        type="checkbox"
                        value="1"
                        id={`checkbox-${userId}-${permission}`}
                        checked={selectedPermissions.indexOf(permission) > -1}
                        onChange={(e) => {
                            let newUserPermissions = new Set(selectedPermissions);

                            if (e.target.checked) {
                                newUserPermissions.add(permission);
                            } else {
                                newUserPermissions.delete(permission);
                            }

                            onChange(Array.from(newUserPermissions));
                        }}
                    />
                    <label className="form-check-label" htmlFor={`checkbox-${userId}-${permission}`}>
                        {permission}
                    </label>
                </div>
            ))}
        </div>
    );
}

function DeleteModal(props: { show: boolean, onCancel: () => void, onConfirm: () => void, username: string }) {
    return (
        <Modal
            show={props.show}
            onHide={props.onCancel}
            size="lg"
            aria-labelledby="delete-modal-title"
            centered
        >
            <Modal.Header closeButton>
                <Modal.Title id="delete-modal-title">
                    Are you sure you wish to remove this member from the crate?
                </Modal.Title>
            </Modal.Header>
            <Modal.Body>
                <p>
                Are you sure you wish to remove <strong>{props.username}</strong> from the crate?
                </p>
            </Modal.Body>
            <Modal.Footer>
                <Button onClick={props.onCancel} variant="primary">Close</Button>
                <Button onClick={props.onConfirm} variant="danger">Delete</Button>
            </Modal.Footer>
        </Modal>
    );
}

function ErrorModal(props: { error?: string, onClose: () => void }) {
    return (
        <Modal
            show={props.error != null}
            onHide={props.onClose}
            size="lg"
            aria-labelledby="error-modal-title"
            centered
        >
            <Modal.Header closeButton>
                <Modal.Title id="error-modal-title">
                    Error
                </Modal.Title>
            </Modal.Header>
            <Modal.Body>
                <p>
                    {props.error}
                </p>
            </Modal.Body>
            <Modal.Footer>
                <Button onClick={props.onClose} variant="primary">Close</Button>
            </Modal.Footer>
        </Modal>
    );
}