[ShareableBrain] User email and role inputs form (#608)

* feat: add invitation emails form

* test(ShareBrain): add tests
This commit is contained in:
Mamadou DICKO 2023-07-12 14:56:25 +02:00 committed by GitHub
parent cef45ea712
commit 783f8dea76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 488 additions and 61 deletions

View File

@ -1,28 +1,16 @@
import { UUID } from "crypto";
import { MdDelete } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { ShareBrain } from "./ShareBrain";
import { DeleteBrain, ShareBrain } from "./components";
type BrainActionsProps = {
brainId: UUID;
};
export const BrainActions = ({ brainId }: BrainActionsProps): JSX.Element => {
const { deleteBrain } = useBrainContext();
return (
<div className="absolute right-0 flex flex-row">
<ShareBrain brainId={brainId} />
<Button
className="group-hover:visible invisible hover:text-red-500 transition-[colors,opacity] p-1"
onClick={() => void deleteBrain(brainId)}
variant={"tertiary"}
>
<MdDelete className="text-xl" />
</Button>
<DeleteBrain brainId={brainId} />
</div>
);
};

View File

@ -1,47 +0,0 @@
import { UUID } from "crypto";
import { MdContentPaste, MdShare } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import Modal from "@/lib/components/ui/Modal";
import { useToast } from "@/lib/hooks";
type ShareBrainModalProps = {
brainId: UUID;
};
export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
const { publish } = useToast();
const baseUrl = window.location.origin;
const brainShareLink = `${baseUrl}/brain_subscription_invitation=${brainId}`;
const handleCopyInvitationLink = async () => {
await navigator.clipboard.writeText(brainShareLink);
publish({
variant: "success",
text: "Copied to clipboard",
});
};
return (
<Modal
Trigger={
<Button
className="group-hover:visible invisible hover:text-red-500 transition-[colors,opacity] p-1"
onClick={() => void 0}
variant={"tertiary"}
>
<MdShare className="text-xl" />
</Button>
}
title="Share brain"
>
<div className="flex flex-row align-center my-5">
<p>{brainShareLink}</p>
<Button onClick={() => void handleCopyInvitationLink()}>
<MdContentPaste />
</Button>
</div>
</Modal>
);
};

View File

@ -0,0 +1,19 @@
import { UUID } from "crypto";
import { MdDelete } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
export const DeleteBrain = ({ brainId }: { brainId: UUID }): JSX.Element => {
const { deleteBrain } = useBrainContext();
return (
<Button
className="group-hover:visible invisible hover:text-red-500 transition-[colors,opacity] p-1"
onClick={() => void deleteBrain(brainId)}
variant={"tertiary"}
>
<MdDelete className="text-xl" />
</Button>
);
};

View File

@ -0,0 +1,97 @@
"use client";
import { UUID } from "crypto";
import { ImUserPlus } from "react-icons/im";
import { MdContentPaste, MdShare } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import Modal from "@/lib/components/ui/Modal";
import { InvitedUserRow } from "./components/InvitedUserRow";
import { useShareBrain } from "./hooks/useShareBrain";
type ShareBrainModalProps = {
brainId: UUID;
};
export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
const {
roleAssignations,
brainShareLink,
handleCopyInvitationLink,
updateRoleAssignation,
removeRoleAssignation,
inviteUsers,
addNewRoleAssignationRole,
} = useShareBrain(brainId);
const canAddNewRow =
roleAssignations.length === 0 ||
roleAssignations.filter((invitingUser) => invitingUser.email === "")
.length === 0;
return (
<Modal
Trigger={
<Button
className="group-hover:visible invisible hover:text-red-500 transition-[colors,opacity] p-1"
onClick={() => void 0}
variant={"tertiary"}
data-testId="share-brain-button"
>
<MdShare className="text-xl" />
</Button>
}
CloseTrigger={<div />}
title="Share brain"
>
<form
onSubmit={(event) => {
event.preventDefault();
void inviteUsers();
}}
>
<div>
<div className="flex flex-row align-center my-5">
<div className="flex bg-gray-100 p-3 rounded flex-1 flex-row border-b border-gray-200 dark:border-gray-700 justify-space-between align-center">
<div className="flex flex-1 overflow-hidden">
<p className="flex-1 color-gray-500">{brainShareLink}</p>
</div>
<Button
type="button"
onClick={() => void handleCopyInvitationLink()}
>
<MdContentPaste />
</Button>
</div>
</div>
<div className="bg-gray-100 h-0.5 mb-5 border-gray-200 dark:border-gray-700" />
{roleAssignations.map((roleAssignation, index) => (
<InvitedUserRow
key={roleAssignation.id}
onChange={updateRoleAssignation(index)}
removeCurrentInvitation={removeRoleAssignation(index)}
roleAssignation={roleAssignation}
/>
))}
<Button
className="my-5"
onClick={addNewRoleAssignationRole}
disabled={!canAddNewRow}
data-testid="add-new-row-role-button"
>
<ImUserPlus />
</Button>
</div>
<div className="mb-3 flex flex-row justify-end">
<Button disabled={roleAssignations.length === 0} type="submit">
Share
</Button>
</div>
</form>
</Modal>
);
};

View File

@ -0,0 +1,50 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ShareBrain } from "../ShareBrain";
describe("ShareBrain", () => {
it("should render ShareBrain component properly", () => {
const { getByTestId } = render(
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
);
const shareButton = getByTestId("share-brain-button");
expect(shareButton).toBeDefined();
});
it("should render open share modal when share button is clicked", () => {
const { getByText, getByTestId } = render(
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
);
const shareButton = getByTestId("share-brain-button");
fireEvent.click(shareButton);
expect(getByText("Share brain")).toBeDefined();
});
it('shoud add new user row when "Add new user" button is clicked and only where there is no empty field', async () => {
const { getByTestId, findAllByTestId } = render(
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
);
const shareButton = getByTestId("share-brain-button");
fireEvent.click(shareButton);
let assignationRows = await findAllByTestId("assignation-row");
expect(assignationRows.length).toBe(1);
const firstAssignationRowEmailInput = (
await findAllByTestId("role-assignation-email-input")
)[0];
fireEvent.change(firstAssignationRowEmailInput, {
target: { value: "user@quivr.app" },
});
const addNewRoleAssignationButton = getByTestId("add-new-row-role-button");
fireEvent.click(addNewRoleAssignationButton);
assignationRows = await findAllByTestId("assignation-row");
expect(assignationRows.length).toBe(2);
});
});

View File

@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { MdOutlineRemoveCircle } from "react-icons/md";
import Field from "@/lib/components/ui/Field";
import { Select } from "@/lib/components/ui/Select";
import { BrainRoleAssignation, BrainRoleType } from "../../../types";
type AddUserRowProps = {
onChange: (newRole: BrainRoleAssignation) => void;
removeCurrentInvitation?: () => void;
roleAssignation: BrainRoleAssignation;
};
type SelectOptionsProps = {
label: string;
value: BrainRoleType;
};
const SelectOptions: SelectOptionsProps[] = [
{ label: "Viewer", value: "viewer" },
{ label: "Editor", value: "editor" },
];
export const InvitedUserRow = ({
onChange,
removeCurrentInvitation,
roleAssignation,
}: AddUserRowProps): JSX.Element => {
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(
roleAssignation.role
);
const [email, setEmail] = useState(roleAssignation.email);
useEffect(() => {
onChange({
...roleAssignation,
email,
role: selectedRole,
});
}, [email, selectedRole]);
return (
<div
data-testid="assignation-row"
className="flex flex-row align-center my-2 gap-3 items-center"
>
<div className="cursor-pointer" onClick={removeCurrentInvitation}>
<MdOutlineRemoveCircle />
</div>
<div className="flex flex-1">
<Field
name="email"
required
type="email"
placeholder="Email"
onChange={(e) => setEmail(e.target.value)}
value={email}
onBlur={() => email === "" && removeCurrentInvitation?.()}
data-testid="role-assignation-email-input"
/>
</div>
<Select
onChange={setSelectedRole}
value={selectedRole}
options={SelectOptions}
/>
</div>
);
};

View File

@ -0,0 +1,83 @@
import { useState } from "react";
import { useToast } from "@/lib/hooks";
import { BrainRoleAssignation } from "../../../types";
import { generateBrainAssignation } from "../utils/generateBrainAssignation";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useShareBrain = (brainId: string) => {
const baseUrl = window.location.origin;
const { publish } = useToast();
const brainShareLink = `${baseUrl}/brain_subscription_invitation=${brainId}`;
const [roleAssignations, setRoleAssignation] = useState<
BrainRoleAssignation[]
>([generateBrainAssignation()]);
const handleCopyInvitationLink = async () => {
await navigator.clipboard.writeText(brainShareLink);
publish({
variant: "success",
text: "Copied to clipboard",
});
};
const removeRoleAssignation = (assignationIndex: number) => () => {
if (roleAssignations.length === 1) {
return;
}
setRoleAssignation(
roleAssignations.filter((_, index) => index !== assignationIndex)
);
};
const updateRoleAssignation =
(rowIndex: number) => (data: BrainRoleAssignation) => {
const concernedRow = roleAssignations[rowIndex];
if (concernedRow !== undefined) {
setRoleAssignation(
roleAssignations.map((row, index) => {
if (index === rowIndex) {
return data;
}
return row;
})
);
} else {
setRoleAssignation([...roleAssignations, data]);
}
};
const inviteUsers = (): void => {
const inviteUsersPayload = roleAssignations
.filter(({ email }) => email !== "")
.map((assignation) => ({
email: assignation.email,
role: assignation.role,
}));
alert(
`You will soon be able to invite ${JSON.stringify(
inviteUsersPayload
)}. Wait a bit`
);
};
const addNewRoleAssignationRole = () => {
setRoleAssignation([...roleAssignations, generateBrainAssignation()]);
};
return {
roleAssignations,
brainShareLink,
handleCopyInvitationLink,
updateRoleAssignation,
removeRoleAssignation,
inviteUsers,
addNewRoleAssignationRole,
};
};

View File

@ -0,0 +1,9 @@
import { BrainRoleAssignation } from "../../../types";
export const generateBrainAssignation = (): BrainRoleAssignation => {
return {
email: "",
role: "viewer",
id: Math.random().toString(),
};
};

View File

@ -0,0 +1,2 @@
export * from "./DeleteBrain";
export * from "./ShareBrain";

View File

@ -0,0 +1,9 @@
export const roles = ["viewer", "editor"];
export type BrainRoleType = (typeof roles)[number];
export type BrainRoleAssignation = {
email: string;
role: BrainRoleType;
id: string;
};

View File

@ -0,0 +1,96 @@
/* eslint-disable max-lines */
import { BsCheckCircleFill } from "react-icons/bs";
import Popover from "@/lib/components/ui/Popover";
type SelectOptionProps = {
label: string;
value: string;
};
type SelectProps = {
options: SelectOptionProps[];
value?: SelectOptionProps["value"];
onChange: (option: SelectOptionProps["value"]) => void;
label?: string;
};
const selectedStyle = "rounded-lg bg-black text-white";
export const Select = ({
onChange,
options,
value,
label,
}: SelectProps): JSX.Element => {
const selectedValueLabel = options.find(
(option) => option.value === value
)?.label;
return (
<div>
{label !== undefined && (
<label
id="listbox-label"
className="block text-sm font-medium leading-6 text-gray-900 mb-2"
>
{label}
</label>
)}
<div className="relative">
<Popover
Trigger={
<button
type="button"
className="relative w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 sm:text-sm sm:leading-6"
aria-haspopup="listbox"
>
<span className="flex items-center">
<span className="ml-3 block truncate">
{selectedValueLabel ?? label ?? "Select"}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2">
<svg
className="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
clip-rule="evenodd"
/>
</svg>
</span>
</button>
}
CloseTrigger={<div />}
>
<ul role="listbox">
{options.map((option) => (
<li
className="text-gray-900 relative cursor-pointer select-none py-2"
id="listbox-option-0"
key={option.value}
onClick={() => onChange(option.value)}
>
<div
className={`flex items-center px-3 py-2 ${
value === option.value && selectedStyle
}`}
>
<span className="font-bold block truncate mr-2">
{option.label}
</span>
{value === option.value && <BsCheckCircleFill />}
</div>
</li>
))}
</ul>
</Popover>
</div>
</div>
);
};

View File

@ -22,6 +22,7 @@
"@june-so/analytics-next": "^2.0.0",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-select": "^1.2.2",
"@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-tooltip": "^1.0.6",
"@sentry/nextjs": "^7.57.0",

View File

@ -742,6 +742,13 @@
picocolors "^1.0.0"
tslib "^2.5.0"
"@radix-ui/number@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.0.1.tgz#644161a3557f46ed38a042acf4a770e826021674"
integrity sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd"
@ -803,6 +810,13 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-direction@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b"
integrity sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dismissable-layer@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
@ -904,6 +918,34 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-select@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-1.2.2.tgz#caa981fa0d672cf3c1b2a5240135524e69b32181"
integrity sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/number" "1.0.1"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-direction" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.4"
"@radix-ui/react-focus-guards" "1.0.1"
"@radix-ui/react-focus-scope" "1.0.3"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-popper" "1.1.2"
"@radix-ui/react-portal" "1.0.3"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-visually-hidden" "1.0.3"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-slot@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
@ -980,6 +1022,13 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-previous@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz#b595c087b07317a4f143696c6a01de43b0d0ec66"
integrity sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-rect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2"