Cal 262 refactor edit teams according to the design reference (#516)

* refactored settings/team landing page

* changed team edit flow, WIP

* merge conflict fix for teams.tsx

* minor fixes to edit team, WIP

* invite-member and disband team APIs attached inside edit-team page

* added remove-member API in edit-team page, minor fixes

* minor code fix, WIP

* WIP

* add logo, bio, branding to team schema

* bio, logo, branding, slug patch API and minor code fix-- WIP

* fn to Disband team directly from the dropdown menu in settings/teams page, removed debug remnants --WIP

* Pull latest data after an action in settings/teams-edit page

* added slug conflict check at Patch time

* code clean-up

* initial change request fixes --WIP

* prop type fix and add warn button color theme --WIP

* added warn Button to Dialog

* remaining change request fixes

* added noop from react-query

* updated invited team-list design

* prettier fix for api/teams/profile

* removed noop import and added custom noop

* minor Button fix

* requested changes addressed
This commit is contained in:
Syed Ali Shahbaz 2021-09-06 06:22:22 -07:00 committed by GitHub
parent 43b275bc30
commit fa35af7bd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 781 additions and 321 deletions

View file

@ -142,7 +142,7 @@ export default function ImageUploader({ target, id, buttonMsg, handleAvatarChang
<div className="sm:flex sm:items-start mb-4">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
Upload an avatar
Upload {target}
</h3>
</div>
</div>

View file

@ -0,0 +1,298 @@
import React, { useEffect, useState, useRef } from "react";
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
import ErrorAlert from "@components/ui/alerts/Error";
import { UsernameInput } from "@components/ui/UsernameInput";
import MemberList from "./MemberList";
import Avatar from "@components/Avatar";
import ImageUploader from "@components/ImageUploader";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Modal from "@components/Modal";
import MemberInvitationModal from "@components/team/MemberInvitationModal";
import Button from "@components/ui/Button";
import { Member } from "@lib/member";
import { Team } from "@lib/team";
export default function EditTeam(props: { team: Team | undefined | null; onCloseEdit: () => void }) {
const [members, setMembers] = useState([]);
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const [hasErrors, setHasErrors] = useState(false);
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
const [errorMessage, setErrorMessage] = useState("");
const [imageSrc, setImageSrc] = useState<string>("");
const loadMembers = () =>
fetch("/api/teams/" + props.team?.id + "/membership")
.then((res) => res.json())
.then((data) => setMembers(data.members));
useEffect(() => {
loadMembers();
}, []);
const deleteTeam = () => {
return fetch("/api/teams/" + props.team?.id, {
method: "DELETE",
}).then(props.onCloseEdit());
};
const onRemoveMember = (member: Member) => {
return fetch("/api/teams/" + props.team?.id + "/membership", {
method: "DELETE",
body: JSON.stringify({ userId: member.id }),
headers: {
"Content-Type": "application/json",
},
}).then(loadMembers);
};
const onInviteMember = (team: Team | null | undefined) => {
setShowMemberInvitationModal(true);
setInviteModalTeam(team);
};
const handleError = async (resp: Response) => {
if (!resp.ok) {
const error = await resp.json();
throw new Error(error.message);
}
};
async function updateTeamHandler(event) {
event.preventDefault();
const enteredUsername = teamUrlRef?.current?.value.toLowerCase();
const enteredName = nameRef?.current?.value;
const enteredDescription = descriptionRef?.current?.value;
const enteredLogo = logoRef?.current?.value;
const enteredHideBranding = hideBrandingRef?.current?.checked;
// TODO: Add validation
await fetch("/api/teams/" + props.team?.id + "/profile", {
method: "PATCH",
body: JSON.stringify({
username: enteredUsername,
name: enteredName,
description: enteredDescription,
logo: enteredLogo,
hideBranding: enteredHideBranding,
}),
headers: {
"Content-Type": "application/json",
},
})
.then(handleError)
.then(() => {
setSuccessModalOpen(true);
setHasErrors(false); // dismiss any open errors
})
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
}
const onMemberInvitationModalExit = () => {
loadMembers();
setShowMemberInvitationModal(false);
};
const closeSuccessModal = () => {
setSuccessModalOpen(false);
};
const handleLogoChange = (newLogo: string) => {
logoRef.current.value = newLogo;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement?.prototype, "value").set;
nativeInputValueSetter?.call(logoRef.current, newLogo);
const ev2 = new Event("input", { bubbles: true });
logoRef?.current?.dispatchEvent(ev2);
updateTeamHandler(ev2);
setImageSrc(newLogo);
};
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div className="mb-4">
<Button
type="button"
color="secondary"
size="sm"
StartIcon={ArrowLeftIcon}
onClick={() => props.onCloseEdit()}>
Back
</Button>
</div>
<div className="">
<div className="pb-5 pr-4 sm:pb-6">
<h3 className="text-lg font-bold leading-6 text-gray-900">{props.team?.name}</h3>
<div className="max-w-xl mt-2 text-sm text-gray-500">
<p>Manage your team</p>
</div>
</div>
</div>
<hr className="mt-2" />
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">Profile</h3>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateTeamHandler}>
{hasErrors && <ErrorAlert message={errorMessage} />}
<div className="py-6 lg:pb-8">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<div className="block sm:flex">
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
<UsernameInput ref={teamUrlRef} defaultValue={props.team?.slug} label={"My team URL"} />
</div>
<div className="w-full sm:w-1/2 sm:ml-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Team name
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="Your team name"
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={props.team?.name}
/>
</div>
</div>
<div>
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
About
</label>
<div className="mt-1">
<textarea
ref={descriptionRef}
id="about"
name="about"
rows={3}
defaultValue={props.team?.bio}
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
<p className="mt-2 text-sm text-gray-500">
A few sentences about your team. This will appear on your team&apos;s URL page.
</p>
</div>
</div>
<div>
<div className="flex mt-1">
<Avatar
className="relative w-10 h-10 rounded-full"
imageSrc={imageSrc ? imageSrc : props.team?.logo}
displayName="Logo"
/>
<input
ref={logoRef}
type="hidden"
name="avatar"
id="avatar"
placeholder="URL"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={imageSrc ? imageSrc : props.team?.logo}
/>
<ImageUploader
target="logo"
id="logo-upload"
buttonMsg={imageSrc !== "" ? "Edit logo" : "Upload a logo"}
handleAvatarChange={handleLogoChange}
imageRef={imageSrc ? imageSrc : props.team?.logo}
/>
</div>
<hr className="mt-6" />
</div>
<div className="flex justify-between mt-7">
<h3 className="font-bold leading-6 text-gray-900 text-md">Members</h3>
<div className="relative flex items-center">
<Button
type="button"
color="secondary"
StartIcon={PlusIcon}
onClick={() => onInviteMember(props.team)}>
New Member
</Button>
</div>
</div>
<div>
{!!members.length && (
<MemberList members={members} onRemoveMember={onRemoveMember} onChange={loadMembers} />
)}
<hr className="mt-6" />
</div>
<div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="hide-branding"
name="hide-branding"
type="checkbox"
ref={hideBrandingRef}
defaultChecked={props.team?.hideBranding}
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
Disable Calendso branding
</label>
<p className="text-gray-500">Hide all Calendso branding from your public pages.</p>
</div>
</div>
<hr className="mt-6" />
</div>
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">Danger Zone</h3>
<div>
<div className="relative flex items-start">
<Dialog>
<DialogTrigger
onClick={(e) => {
e.stopPropagation();
}}
className="btn-sm btn-white">
<TrashIcon className="group-hover:text-red text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
Disband Team
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title="Disband Team"
confirmBtnText="Yes, disband team"
cancelBtnText="Cancel"
onConfirm={() => deleteTeam()}>
Are you sure you want to disband this team? Anyone who you&apos;ve shared this team
link with will no longer be able to book using it.
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</div>
</div>
<hr className="mt-8" />
<div className="flex justify-end py-4">
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
</form>
<Modal
heading="Team updated successfully"
description="Your team has been updated successfully."
open={successModalOpen}
handleClose={closeSuccessModal}
/>
{showMemberInvitationModal && (
<MemberInvitationModal team={inviteModalTeam} onExit={onMemberInvitationModalExit} />
)}
</div>
</div>
);
}

View file

@ -1,131 +0,0 @@
import { useEffect, useState } from "react";
import { UserRemoveIcon, UsersIcon } from "@heroicons/react/outline";
import { useSession } from "next-auth/client";
export default function EditTeamModal(props) {
const [session] = useSession();
const [members, setMembers] = useState([]);
const [checkedDisbandTeam, setCheckedDisbandTeam] = useState(false);
const loadMembers = () =>
fetch("/api/teams/" + props.team.id + "/membership")
.then((res: any) => res.json())
.then((data) => setMembers(data.members));
useEffect(() => {
loadMembers();
}, []);
const deleteTeam = (e) => {
e.preventDefault();
return fetch("/api/teams/" + props.team.id, {
method: "DELETE",
}).then(props.onExit);
};
const removeMember = (member) => {
return fetch("/api/teams/" + props.team.id + "/membership", {
method: "DELETE",
body: JSON.stringify({ userId: member.id }),
headers: {
"Content-Type": "application/json",
},
}).then(loadMembers);
};
return (
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-black bg-opacity-10 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="h-6 w-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Edit the {props.team.name} team
</h3>
<div>
<p className="text-sm text-gray-400">Manage and delete your team.</p>
</div>
</div>
</div>
<form>
<div>
<div className="mb-4">
{members.length > 0 && (
<div>
<div className="flex justify-between mb-2">
<h2 className="text-lg font-medium text-gray-900">Members</h2>
</div>
<table className="table-auto mb-2 w-full text-sm">
<tbody>
{members.map((member) => (
<tr key={member.email}>
<td className="p-1">
{member.name} {member.name && "(" + member.email + ")"}
{!member.name && member.email}
</td>
<td className="capitalize">{member.role.toLowerCase()}</td>
<td className="text-right py-2 px-1">
{member.email !== session.user.email && (
<button
type="button"
onClick={() => removeMember(member)}
className="btn-sm text-xs bg-transparent px-3 py-1 rounded ml-2">
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mb-4 border border-red-400 rounded p-2 px-4">
<p className="block text-sm font-medium text-gray-700">Tick the box to disband this team.</p>
<label className="mt-1">
<input
type="checkbox"
onChange={(e) => setCheckedDisbandTeam(e.target.checked)}
className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md"
/>
Disband this team
</label>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
Update
</button>*/}
{checkedDisbandTeam && (
<button
onClick={deleteTeam}
className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
Disband Team
</button>
)}
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
Close
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View file

@ -1,10 +1,12 @@
import { UsersIcon } from "@heroicons/react/outline";
import { useState } from "react";
import Button from "@components/ui/Button";
import { Team } from "@lib/team";
export default function MemberInvitationModal(props) {
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
const [errorMessage, setErrorMessage] = useState("");
const handleError = async (res) => {
const handleError = async (res: Response) => {
const responseData = await res.json();
if (res.ok === false) {
@ -24,7 +26,7 @@ export default function MemberInvitationModal(props) {
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
};
return fetch("/api/teams/" + props.team.id + "/invite", {
return fetch("/api/teams/" + props?.team?.id + "/invite", {
method: "POST",
body: JSON.stringify(payload),
headers: {
@ -40,26 +42,26 @@ export default function MemberInvitationModal(props) {
return (
<div
className="fixed z-50 inset-0 overflow-y-auto"
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-black bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="h-6 w-6 text-black" />
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-black rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="w-6 h-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
Invite a new member
</h3>
<div>
@ -79,16 +81,16 @@ export default function MemberInvitationModal(props) {
id="inviteUser"
placeholder="email@example.com"
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
</div>
<div className="mb-4">
<label className="block tracking-wide text-gray-700 text-sm font-medium mb-2" htmlFor="role">
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
Role
</label>
<select
id="role"
className="shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md">
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm">
<option value="MEMBER">Member</option>
<option value="OWNER">Owner</option>
</select>
@ -100,7 +102,7 @@ export default function MemberInvitationModal(props) {
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
className="text-black shadow-sm focus:ring-black focus:border-black sm:text-sm border-gray-300 rounded-md"
className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm"
/>
</div>
<div className="ml-2 text-sm">
@ -111,18 +113,18 @@ export default function MemberInvitationModal(props) {
</div>
</div>
{errorMessage && (
<p className="text-red-700 text-sm">
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
<Button type="submit" color="primary" className="ml-2">
Invite
</button>
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
</Button>
<Button type="button" color="secondary" onClick={props.onExit}>
Cancel
</button>
</Button>
</div>
</form>
</div>

View file

@ -0,0 +1,31 @@
import MemberListItem from "./MemberListItem";
import { Member } from "@lib/member";
export default function MemberList(props: {
members: Member[];
onRemoveMember: (text: Member) => void;
onChange: (text: string) => void;
}) {
const selectAction = (action: string, member: Member) => {
switch (action) {
case "remove":
props.onRemoveMember(member);
break;
}
};
return (
<div>
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{props.members.map((member) => (
<MemberListItem
onChange={props.onChange}
key={member.id}
member={member}
onActionSelect={(action: string) => selectAction(action, member)}
/>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,94 @@
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
import Dropdown from "../ui/Dropdown";
import { useState } from "react";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/Avatar";
import { Member } from "@lib/member";
import Button from "@components/ui/Button";
export default function MemberListItem(props: {
member: Member;
onActionSelect: (text: string) => void;
onChange: (text: string) => void;
}) {
const [member] = useState(props.member);
return (
member && (
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex">
<Avatar
imageSrc={
props.member.avatar
? props.member.avatar
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
encodeURIComponent(props.member.name || "")
}
displayName={props.member.name || ""}
className="rounded-full w-9 h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{props.member.name}</span>
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
</div>
</div>
<div className="flex">
{props.member.role === "INVITEE" && (
<>
<span className="self-center h-6 px-3 py-1 mr-2 text-xs text-yellow-700 capitalize rounded-md bg-yellow-50">
Pending
</span>
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
Member
</span>
</>
)}
{props.member.role === "MEMBER" && (
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
Member
</span>
)}
{props.member.role === "OWNER" && (
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-blue-700 capitalize rounded-md bg-blue-50">
Owner
</span>
)}
<Dropdown className="relative flex text-left">
<Button type="button" color="minimal" className="ml-2">
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-black" />
</Button>
<ul
role="menu"
className="absolute right-0 z-10 origin-top-right bg-white rounded-sm shadow-lg top-10 w-44 ring-1 ring-black ring-opacity-5 focus:outline-none">
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
<Dialog>
<DialogTrigger
as={Button}
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={UserRemoveIcon}
className="w-full">
Remove User
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title="Remove member"
confirmBtnText="Yes, remove member"
cancelBtnText="Cancel"
onConfirm={() => props.onActionSelect("remove")}>
Are you sure you want to remove this member from the team?
</ConfirmationDialogContent>
</Dialog>
</li>
</ul>
</Dropdown>
</div>
</div>
</li>
)
);
}

View file

@ -1,29 +1,32 @@
import { useState } from "react";
import TeamListItem from "./TeamListItem";
import EditTeamModal from "./EditTeamModal";
import MemberInvitationModal from "./MemberInvitationModal";
import { Team } from "@lib/team";
export default function TeamList(props) {
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
const [team, setTeam] = useState(null);
const selectAction = (action: string, team: any) => {
setTeam(team);
export default function TeamList(props: {
teams: Team[];
onChange: () => void;
onEditTeam: (text: Team) => void;
}) {
const selectAction = (action: string, team: Team) => {
switch (action) {
case "edit":
setShowEditTeamModal(true);
props.onEditTeam(team);
break;
case "invite":
setShowMemberInvitationModal(true);
case "disband":
deleteTeam(team);
break;
}
};
const deleteTeam = (team: Team) => {
return fetch("/api/teams/" + team.id, {
method: "DELETE",
}).then(props.onChange());
};
return (
<div>
<ul className="bg-white border px-2 mb-2 rounded divide-y divide-gray-200">
{props.teams.map((team: any) => (
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{props.teams.map((team: Team) => (
<TeamListItem
onChange={props.onChange}
key={team.id}
@ -31,19 +34,6 @@ export default function TeamList(props) {
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
))}
</ul>
{showEditTeamModal && (
<EditTeamModal
team={team}
onExit={() => {
props.onChange();
setShowEditTeamModal(false);
}}></EditTeamModal>
)}
{showMemberInvitationModal && (
<MemberInvitationModal
team={team}
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
)}
</div>
);
}

View file

@ -1,9 +1,38 @@
import { CogIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline";
import {
TrashIcon,
DotsHorizontalIcon,
LinkIcon,
PencilAltIcon,
ExternalLinkIcon,
} from "@heroicons/react/outline";
import Dropdown from "../ui/Dropdown";
import { useState } from "react";
import { Tooltip } from "@components/Tooltip";
import Link from "next/link";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/Avatar";
import Button from "@components/ui/Button";
import showToast from "@lib/notification";
export default function TeamListItem(props) {
const [team, setTeam] = useState(props.team);
interface Team {
id: number;
name: string | null;
slug: string | null;
logo: string | null;
bio: string | null;
role: string | null;
hideBranding: boolean;
prevState: null;
}
export default function TeamListItem(props: {
onChange: () => void;
key: number;
team: Team;
onActionSelect: (text: string) => void;
}) {
const [team, setTeam] = useState<Team | null>(props.team);
const acceptInvite = () => invitationResponse(true);
const declineInvite = () => invitationResponse(false);
@ -23,84 +52,117 @@ export default function TeamListItem(props) {
return (
team && (
<li className="mb-2 mt-2 divide-y">
<div className="flex justify-between mb-2 mt-2">
<div>
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline" />
<div className="inline-block -mt-1">
<span className="font-bold text-neutral-700 text-sm">{props.team.name}</span>
<span className="text-xs text-gray-400 -mt-1 block capitalize">
{props.team.role.toLowerCase()}
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex">
<Avatar
imageSrc={
props.team.logo
? props.team.logo
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
encodeURIComponent(props.team.name || "")
}
displayName="Team Logo"
className="rounded-full w-9 h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{props.team.name}</span>
<span className="block -mt-1 text-xs text-gray-400">
{window.location.hostname}/{props.team.slug}
</span>
</div>
</div>
{props.team.role === "INVITEE" && (
<div>
<button
className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded-sm ml-2"
onClick={acceptInvite}>
Accept invitation
</button>
<button className="btn-sm bg-transparent px-2 py-1 ml-1">
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" onClick={declineInvite} />
</button>
<Button type="button" color="secondary" onClick={declineInvite}>
Reject
</Button>
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
Accept
</Button>
</div>
)}
{props.team.role === "MEMBER" && (
<div>
<button
onClick={declineInvite}
className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm ml-2">
<Button type="button" color="primary" onClick={declineInvite}>
Leave
</button>
</Button>
</div>
)}
{props.team.role === "OWNER" && (
<div>
<Dropdown className="relative inline-block text-left">
<button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded-sm ml-2">
<CogIcon className="h-6 w-6 inline text-gray-400" />
</button>
<div className="flex">
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
Owner
</span>
<Tooltip content="Copy link">
<Button
onClick={() => {
navigator.clipboard.writeText(window.location.hostname + "/team/" + props.team.slug);
showToast("Link copied!", "success");
}}
color="minimal"
className="w-full pl-5 ml-8"
StartIcon={LinkIcon}
type="button"></Button>
</Tooltip>
<Dropdown className="relative flex text-left">
<Button
color="minimal"
className="w-full pl-5 ml-2"
StartIcon={DotsHorizontalIcon}
type="button"></Button>
<ul
role="menu"
className="z-10 origin-top-right absolute right-0 w-36 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<li
className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
role="menuitem">
<button className="block px-4 py-2" onClick={() => props.onActionSelect("invite")}>
Invite members
</button>
className="absolute right-0 z-10 origin-top-right bg-white rounded-sm shadow-lg top-10 w-44 ring-1 ring-black ring-opacity-5 focus:outline-none">
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
<Button
type="button"
color="minimal"
className="w-full"
onClick={() => props.onActionSelect("edit")}
StartIcon={PencilAltIcon}>
{" "}
Edit team
</Button>
</li>
<li
className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
role="menuitem">
<button className="block px-4 py-2" onClick={() => props.onActionSelect("edit")}>
Manage team
</button>
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
<Link href={`/team/${props.team.slug}`} passHref={true}>
<a target="_blank">
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
{" "}
Preview team page
</Button>
</a>
</Link>
</li>
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
<Dialog>
<DialogTrigger
as={Button}
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={TrashIcon}
className="w-full">
Disband Team
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title="Disband Team"
confirmBtnText="Yes, disband team"
cancelBtnText="Cancel"
onConfirm={() => props.onActionSelect("disband")}>
Are you sure you want to disband this team? Anyone who you&apos;ve shared this team
link with will no longer be able to book using it.
</ConfirmationDialogContent>
</Dialog>
</li>
</ul>
</Dropdown>
</div>
)}
</div>
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
{props.team.members.length > 0 && <div>
<h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2>
<table className="table-auto mb-2 w-full">
<tbody>
{props.team.members.map( (member) => <tr key={member.email}>
<td className="py-1 pl-2">Alex van Andel ({ member.email })</td>
<td>Owner</td>
<td className="text-right p-1">
<button className="btn-sm text-xs bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2"><UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>Remove</button>
</td>
</tr>)}
</tbody>
</table>
</div>}
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded-sm"><UserAddIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 h-6 w-6 inline"/> Invite member</button>
<button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded-sm ml-2">Disband</button>
</div>}*/}
</li>
)
);

View file

@ -5,7 +5,7 @@ import React from "react";
type SVGComponent = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export type ButtonProps = {
color?: "primary" | "secondary" | "minimal";
color?: "primary" | "secondary" | "minimal" | "warn";
size?: "base" | "sm" | "lg" | "fab";
loading?: boolean;
disabled?: boolean;
@ -63,7 +63,10 @@ export const Button = function Button(props: ButtonProps) {
(disabled
? "text-gray-400 bg-transparent"
: "text-gray-700 bg-transparent hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-gray-100 focus:ring-neutral-500"),
color === "warn" &&
(disabled
? "text-gray-400 bg-transparent"
: "text-red-700 bg-transparent hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"),
// set not-allowed cursor if disabled
disabled && "cursor-not-allowed",
props.className

View file

@ -4,11 +4,11 @@ const UsernameInput = React.forwardRef((props, ref) => (
// todo, check if username is already taken here?
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
{props.label ? props.label : "Username"}
</label>
<div className="mt-1 rounded-md shadow-sm flex">
<span className="bg-gray-50 border border-r-0 border-gray-300 rounded-l-sm px-3 inline-flex items-center text-gray-500 sm:text-sm">
{typeof window !== "undefined" && window.location.hostname}/
<div className="flex mt-1 rounded-md shadow-sm">
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{typeof window !== "undefined" && window.location.hostname}/{props.label && "team/"}
</span>
<input
ref={ref}
@ -18,7 +18,7 @@ const UsernameInput = React.forwardRef((props, ref) => (
autoComplete="username"
required
{...props}
className="focus:ring-black focus:border-black flex-grow block w-full min-w-0 rounded-none rounded-r-sm sm:text-sm border-gray-300 lowercase"
className="flex-grow block w-full min-w-0 lowercase border-gray-300 rounded-none rounded-r-sm focus:ring-black focus:border-black sm:text-sm"
/>
</div>
</div>

8
lib/member.ts Normal file
View file

@ -0,0 +1,8 @@
export interface Member {
id: number;
avatar: string | null;
role: string;
email: string;
name: string | null;
username: string | null;
}

10
lib/team.ts Normal file
View file

@ -0,0 +1,10 @@
export interface Team {
id: number;
name: string | null;
slug: string | null;
logo: string | null;
bio: string | null;
role: string | null;
hideBranding: boolean;
prevState: null;
}

View file

@ -0,0 +1,68 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@lib/prisma";
import { getSession } from "next-auth/client";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
const isTeamOwner = !!(await prisma.membership.findFirst({
where: {
userId: session.user.id,
teamId: parseInt(req.query.team as string),
role: "OWNER",
},
}));
if (!isTeamOwner) {
res.status(403).json({ message: "You are not authorized to manage this team" });
return;
}
// PATCH /api/teams/profile/{team}
if (req.method === "PATCH") {
const team = await prisma.team.findFirst({
where: {
id: parseInt(req.query.team as string),
},
});
if (!team) {
return res.status(404).json({ message: "Invalid team" });
}
const username = req.body.username;
const userConflict = await prisma.team.findMany({
where: {
slug: username,
},
});
const teamId = Number(req.query.team);
if (userConflict.some((team) => team.id !== teamId)) {
return res.status(409).json({ message: "Team username already taken" });
}
const name = req.body.name;
const slug = req.body.username;
const bio = req.body.description;
const logo = req.body.logo;
const hideBranding = req.body.hideBranding;
await prisma.team.update({
where: {
id: team.id,
},
data: {
name,
slug,
logo,
bio,
hideBranding,
},
});
return res.status(200).json({ message: "Team updated successfully" });
}
}

View file

@ -1,7 +1,7 @@
import { GetServerSideProps } from "next";
import Shell from "@components/Shell";
import SettingsShell from "@components/Settings";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import type { Session } from "next-auth";
import { useSession } from "next-auth/client";
import { UsersIcon } from "@heroicons/react/outline";
@ -9,14 +9,23 @@ import TeamList from "@components/team/TeamList";
import TeamListItem from "@components/team/TeamListItem";
import Loader from "@components/Loader";
import { getSession } from "@lib/auth";
import EditTeam from "@components/team/EditTeam";
import Button from "@components/ui/Button";
import { Member } from "@lib/member";
import { Team } from "@lib/team";
import { PlusIcon } from "@heroicons/react/solid";
export default function Teams() {
const noop = () => undefined;
const [, loading] = useSession();
const [teams, setTeams] = useState([]);
const [invites, setInvites] = useState([]);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [editTeamEnabled, setEditTeamEnabled] = useState(false);
const [teamToEdit, setTeamToEdit] = useState<Team | null>();
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const handleErrors = async (resp) => {
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
@ -28,8 +37,8 @@ export default function Teams() {
fetch("/api/user/membership")
.then(handleErrors)
.then((data) => {
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
setTeams(data.membership.filter((m: Member) => m.role !== "INVITEE"));
setInvites(data.membership.filter((m: Member) => m.role === "INVITEE"));
})
.catch(console.log);
};
@ -42,12 +51,11 @@ export default function Teams() {
return <Loader />;
}
const createTeam = (e) => {
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
return fetch("/api/teams", {
method: "POST",
body: JSON.stringify({ name: e.target.elements["name"].value }),
body: JSON.stringify({ name: nameRef?.current?.value }),
headers: {
"Content-Type": "application/json",
},
@ -57,92 +65,93 @@ export default function Teams() {
});
};
const editTeam = (team: Team) => {
setEditTeamEnabled(true);
setTeamToEdit(team);
};
const onCloseEdit = () => {
loadData();
setEditTeamEnabled(false);
};
return (
<Shell heading="Teams" subtitle="Create and manage teams to use collaborative features.">
<SettingsShell>
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div className="flex justify-between">
<div>
{!(invites.length || teams.length) && (
<div className="bg-gray-50 sm:rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Create a team to get started
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>Create your first team and invite other users to work together with you.</p>
</div>
<div className="mt-5">
<button
type="button"
onClick={() => setShowCreateTeamModal(true)}
className="btn btn-primary">
Create new team
</button>
{!editTeamEnabled && (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div className="flex flex-col justify-between md:flex-row">
<div>
{!(invites.length || teams.length) && (
<div className="sm:rounded-sm">
<div className="pb-5 pr-4 sm:pb-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">
Create a team to get started
</h3>
<div className="max-w-xl mt-2 text-sm text-gray-500">
<p>Create your first team and invite other users to work together with you.</p>
</div>
</div>
</div>
)}
</div>
<div className="flex items-start mb-4">
<Button
type="button"
onClick={() => setShowCreateTeamModal(true)}
className="btn btn-white">
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
New Team
</Button>
</div>
</div>
<div>
{!!teams.length && (
<TeamList teams={teams} onChange={loadData} onEditTeam={editTeam}></TeamList>
)}
{!!invites.length && (
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900">Open Invitations</h2>
<ul className="px-4 mt-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{invites.map((team: Team) => (
<TeamListItem
onChange={loadData}
key={team.id}
team={team}
onActionSelect={noop}></TeamListItem>
))}
</ul>
</div>
)}
</div>
{!!(invites.length || teams.length) && (
<div>
<button className="btn-sm btn-primary mb-4" onClick={() => setShowCreateTeamModal(true)}>
Create new team
</button>
</div>
)}
</div>
<div>
{!!teams.length && <TeamList teams={teams} onChange={loadData}></TeamList>}
{!!invites.length && (
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
{invites.map((team) => (
<TeamListItem onChange={loadData} key={team.id} team={team}></TeamListItem>
))}
</ul>
</div>
)}
</div>
{/*{teamsLoaded && <div className="flex justify-between">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 mb-1">Transform account</h2>
<p className="text-sm text-gray-500 mb-1">
{membership.length !== 0 && "You cannot convert this account into a team until you leave all teams that youre a member of."}
{membership.length === 0 && "A user account can be turned into a team, as a team ...."}
</p>
</div>
<div>
<button className="mt-2 btn-sm btn-primary opacity-50 cursor-not-allowed" disabled>Convert {session.user.username} into a team</button>
</div>
</div>}*/}
</div>
</div>
)}
{!!editTeamEnabled && <EditTeam team={teamToEdit} onCloseEdit={onCloseEdit} />}
{showCreateTeamModal && (
<div
className="fixed z-50 inset-0 overflow-y-auto"
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="h-6 w-6 text-neutral-900" />
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-sm shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="w-6 h-6 text-neutral-900" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
Create a new team
</h3>
<div>
@ -156,12 +165,13 @@ export default function Teams() {
Name
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="Acme Inc."
required
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
@ -171,7 +181,7 @@ export default function Teams() {
<button
onClick={() => setShowCreateTeamModal(false)}
type="button"
className="btn btn-white mr-2">
className="mr-2 btn btn-white">
Cancel
</button>
</div>

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "bio" TEXT,
ADD COLUMN "hideBranding" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "logo" TEXT;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[slug]` on the table `Team` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Team.slug_unique" ON "Team"("slug");

View file

@ -73,10 +73,13 @@ model User {
}
model Team {
id Int @id @default(autoincrement())
name String?
slug String?
members Membership[]
id Int @default(autoincrement()) @id
name String?
slug String? @unique
logo String?
bio String?
hideBranding Boolean @default(false)
members Membership[]
}
enum MembershipRole {