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:
parent
43b275bc30
commit
fa35af7bd8
17 changed files with 781 additions and 321 deletions
|
@ -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>
|
||||
|
|
298
components/team/EditTeam.tsx
Normal file
298
components/team/EditTeam.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
|
@ -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">
|
||||
​
|
||||
</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>
|
||||
);
|
||||
}
|
|
@ -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">
|
||||
​
|
||||
</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>
|
||||
|
|
31
components/team/MemberList.tsx
Normal file
31
components/team/MemberList.tsx
Normal 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>
|
||||
);
|
||||
}
|
94
components/team/MemberListItem.tsx
Normal file
94
components/team/MemberListItem.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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'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>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
8
lib/member.ts
Normal 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
10
lib/team.ts
Normal 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;
|
||||
}
|
68
pages/api/teams/[team]/profile.ts
Normal file
68
pages/api/teams/[team]/profile.ts
Normal 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" });
|
||||
}
|
||||
}
|
|
@ -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 you’re 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">
|
||||
​
|
||||
</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>
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "bio" TEXT,
|
||||
ADD COLUMN "hideBranding" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "logo" TEXT;
|
|
@ -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");
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue