Improvement/teams (#1285)
* [WIP] checkpoint before pull & merge - Added teams to sidebar - Refactored team settings - Improved team list UI This code will be partly reverted next commit. * [WIP] - Moved team code back to components - Removed team link from sidebar - Built new team manager screen based on Event Type designs - Component-ized frequently reused code (SettingInputContainer, FlatIconButton) * [WIP] - Created LinkIconButton as standalone component - Added functionality to sidebar of team settings - Fixed type bug on public team page induced by my normalization of members array in team query - Removed teams-old which was kept as refrence - Cleaned up loose ends * [WIP] - added create team model - fixed profile missing label due to my removal of default label from component * [WIP] - Fixed TeamCreateModal trigger - removed TeamShell, it didn't make the cut - added getPlaceHolderAvatar - renamed TeamCreate to TeamCreateModal - removed deprecated UsernameInput and replaced uses with suggested TextField * fix save button * [WIP] - Fixed drop down actions on team list - Cleaned up state updates * [WIP] converting teams to tRPC * [WIP] Finished refactor to tRPC * [WIP] Finishing touches * [WIP] Team availability beginning * team availability mvp * - added validation to change role - modified layout of team availability - corrected types * fix ui issue on team availability screen * - added virtualization to team availability - added flexChildrenContainer boolean to Shell to allow for flex on children * availability style fix * removed hard coded team type as teams now use inferred type from tRPC * Removed unneeded vscode settings * Reverted prisma schema * Fixed migrations * Removes unused dayjs plugins * Reverts type regression * Type fix * Type fixes * Type fixes * Moves team availability code to ee Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
parent
5902f78fb2
commit
c1d90eb438
49 changed files with 2295 additions and 998 deletions
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
@ -5,9 +5,5 @@
|
|||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"eslint.run": "onSave",
|
||||
"workbench.colorCustomizations": {
|
||||
"titleBar.activeBackground": "#292929",
|
||||
"titleBar.inactiveBackground": "#888888"
|
||||
}
|
||||
"eslint.run": "onSave"
|
||||
}
|
||||
|
|
|
@ -110,7 +110,7 @@ export default function ImageUploader({
|
|||
(opened) => !opened && setFile(null) // unset file on close
|
||||
}>
|
||||
<DialogTrigger asChild>
|
||||
<div className="flex items-center px-3">
|
||||
<div className="flex items-center">
|
||||
<Button color="secondary" type="button" className="py-1 text-xs">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ArrowLeftIcon,
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExternalLinkIcon,
|
||||
|
@ -36,6 +37,7 @@ import Dropdown, {
|
|||
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
import Logo from "./Logo";
|
||||
import Button from "./ui/Button";
|
||||
|
||||
function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
|
@ -118,6 +120,10 @@ export default function Shell(props: {
|
|||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
CTA?: ReactNode;
|
||||
HeadingLeftIcon?: ReactNode;
|
||||
showBackButton?: boolean;
|
||||
// use when content needs to expand with flex
|
||||
flexChildrenContainer?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
@ -249,7 +255,11 @@ export default function Shell(props: {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 w-0 overflow-hidden">
|
||||
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]">
|
||||
<main
|
||||
className={classNames(
|
||||
"flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]",
|
||||
props.flexChildrenContainer && "flex flex-col"
|
||||
)}>
|
||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 md:hidden">
|
||||
<Link href="/event-types">
|
||||
|
@ -269,8 +279,21 @@ export default function Shell(props: {
|
|||
<UserDropdown small />
|
||||
</div>
|
||||
</nav>
|
||||
<div className={classNames(props.centered && "md:max-w-5xl mx-auto", "py-8")}>
|
||||
<div
|
||||
className={classNames(
|
||||
props.centered && "md:max-w-5xl mx-auto",
|
||||
props.flexChildrenContainer && "flex flex-col flex-1",
|
||||
"py-8"
|
||||
)}>
|
||||
{props.showBackButton && (
|
||||
<div className="mx-3 mb-8 sm:mx-8">
|
||||
<Button onClick={() => router.back()} StartIcon={ArrowLeftIcon} color="secondary">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8 min-h-[80px]">
|
||||
{props.HeadingLeftIcon && <div className="mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="w-full mb-8">
|
||||
<h1 className="mb-1 text-xl font-bold tracking-wide text-gray-900 font-cal">
|
||||
{props.heading}
|
||||
|
@ -279,7 +302,13 @@ export default function Shell(props: {
|
|||
</div>
|
||||
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
|
||||
</div>
|
||||
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"px-4 sm:px-6 md:px-8",
|
||||
props.flexChildrenContainer && "flex flex-col flex-1"
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="fixed bottom-0 flex w-full bg-white shadow bottom-nav md:hidden">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
|
|
|
@ -16,7 +16,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
|
|||
{...props}
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"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",
|
||||
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-1 focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
|
@ -54,9 +54,11 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
|
|||
} = props;
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
{!!props.name && (
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
{addOnLeading ? (
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
{addOnLeading}
|
||||
|
|
|
@ -1,289 +0,0 @@
|
|||
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
import showToast from "@lib/notification";
|
||||
import { Team } from "@lib/team";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import { UsernameInput } from "@components/ui/UsernameInput";
|
||||
import ErrorAlert from "@components/ui/alerts/Error";
|
||||
|
||||
import MemberList from "./MemberList";
|
||||
|
||||
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 [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
|
||||
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(() => {
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
setHasErrors(false); // dismiss any open errors
|
||||
})
|
||||
.catch((err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
const onMemberInvitationModalExit = () => {
|
||||
loadMembers();
|
||||
setShowMemberInvitationModal(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()}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<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>{t("manage_your_team")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-2" />
|
||||
<h3 className="font-bold leading-6 text-gray-900 font-cal mt-7 text-md">{t("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={t("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">
|
||||
{t("team_name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder={t("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">
|
||||
{t("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">{t("team_description")}</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 ?? props.team?.logo}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="logo"
|
||||
id="logo-upload"
|
||||
buttonMsg={imageSrc !== "" ? t("edit_logo") : t("upload_a_logo")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
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 font-cal text-md">{t("members")}</h3>
|
||||
<div className="relative flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={PlusIcon}
|
||||
onClick={() => onInviteMember(props.team)}>
|
||||
{t("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">
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">{t("danger_zone")}</h3>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" color="secondary" StartIcon={TrashIcon}>
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => deleteTeam()}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-8" />
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit" color="primary">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{showMemberInvitationModal && (
|
||||
<MemberInvitationModal team={inviteModalTeam} onExit={onMemberInvitationModalExit} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
86
components/team/MemberChangeRoleModal.tsx
Normal file
86
components/team/MemberChangeRoleModal.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [role, setRole] = useState(props.initialRole || MembershipRole.MEMBER);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function changeRole(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
changeRoleMutation.mutate({
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContainer>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("change_member_role")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={changeRole}>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as MembershipRole)}
|
||||
id="role"
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
<option value="OWNER">{t("owner")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<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" color="primary" className="ml-2">
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</ModalContainer>
|
||||
);
|
||||
}
|
|
@ -1,58 +1,49 @@
|
|||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { UserIcon } from "@heroicons/react/outline";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Team } from "@lib/team";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
|
||||
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const handleError = async (res: Response) => {
|
||||
const responseData = await res.json();
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok === false) {
|
||||
setErrorMessage(responseData.message);
|
||||
throw new Error(responseData.message);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
};
|
||||
|
||||
const inviteMember = (e: SyntheticEvent) => {
|
||||
function inviteMember(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!props.team) return;
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
elements: {
|
||||
role: { value: string };
|
||||
role: { value: MembershipRole };
|
||||
inviteUser: { value: string };
|
||||
sendInviteEmail: { checked: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
const payload = {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
language: i18n.language,
|
||||
role: target.elements["role"].value,
|
||||
usernameOrEmail: target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||
};
|
||||
|
||||
return fetch("/api/teams/" + props?.team?.id + "/invite", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleError)
|
||||
.then(props.onExit)
|
||||
.catch(() => {
|
||||
// do nothing.
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -71,8 +62,8 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
|||
|
||||
<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-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="w-6 h-6 text-black" />
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-brand bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UserIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
|
@ -106,6 +97,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
|||
id="role"
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
<option value="OWNER">{t("owner")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
@ -1,30 +1,20 @@
|
|||
import { Member } from "@lib/member";
|
||||
import { inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import MemberListItem from "./MemberListItem";
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
members: inferQueryOutput<"viewer.teams.get">["members"];
|
||||
}
|
||||
|
||||
export default function MemberList(props: Props) {
|
||||
if (!props.members.length) return <></>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="px-6 mb-2 -mx-6 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
|
||||
{props.members.map((member) => (
|
||||
<MemberListItem
|
||||
onChange={props.onChange}
|
||||
key={member.id}
|
||||
member={member}
|
||||
onActionSelect={(action: string) => selectAction(action, member)}
|
||||
/>
|
||||
{props.members?.map((member) => (
|
||||
<MemberListItem key={member.id} member={member} team={props.team} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -1,104 +1,162 @@
|
|||
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamRole from "./TeamRole";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
export default function MemberListItem(props: {
|
||||
member: Member;
|
||||
onActionSelect: (text: string) => void;
|
||||
onChange: (text: string) => void;
|
||||
}) {
|
||||
const [member] = useState(props.member);
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
export default function MemberListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
|
||||
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const name =
|
||||
props.member.name ||
|
||||
(() => {
|
||||
const emailName = props.member.email.split("@")[0];
|
||||
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
|
||||
})();
|
||||
|
||||
const removeMember = () =>
|
||||
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
|
||||
|
||||
return (
|
||||
member && (
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex flex-col justify-between w-full sm:flex-row">
|
||||
<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 || "")
|
||||
}
|
||||
alt={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>
|
||||
{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">
|
||||
{t("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">
|
||||
{t("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">
|
||||
{t("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">
|
||||
{t("owner")}
|
||||
</span>
|
||||
)}
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex flex-col justify-between w-full sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, name)}
|
||||
alt={name || ""}
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{/* <div className="flex flex-col-reverse"> */}
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger>
|
||||
<DotsHorizontalIcon className="w-5 h-5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={UserRemoveIcon}
|
||||
className="w-full">
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={() => props.onActionSelect("remove")}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{/* </div> */}
|
||||
<div className="flex justify-center mr-2">
|
||||
{!props.member.accepted && <TeamRole invitePending />}
|
||||
<TeamRole role={props.member.role} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
<div className="flex">
|
||||
<Tooltip content={t("View user availability")}>
|
||||
<Button
|
||||
onClick={() => setShowTeamAvailabilityModal(true)}
|
||||
color="minimal"
|
||||
className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
|
||||
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
|
||||
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/" + props.member.username}>
|
||||
<a target="_blank">
|
||||
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full">
|
||||
{t("view_public_page")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{(props.team.membership.role === MembershipRole.OWNER ||
|
||||
props.team.membership.role === MembershipRole.ADMIN) && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
color="minimal"
|
||||
StartIcon={PencilIcon}
|
||||
className="flex-shrink-0 w-full">
|
||||
{t("edit_role")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={UserRemoveIcon}
|
||||
className="w-full">
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={removeMember}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
onExit={() => setShowChangeMemberRoleModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<ModalContainer wide noPadding>
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<div className="p-5 space-x-2 border-t">
|
||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<Button color="secondary">{t("Open Team Availability")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</ModalContainer>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
86
components/team/TeamCreateModal.tsx
Normal file
86
components/team/TeamCreateModal.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TeamCreate(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
props.onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
createTeamMutation.mutate({ name: nameRef?.current?.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
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 px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
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 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 font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("create_new_team")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={createTeam}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("create_team")}
|
||||
</button>
|
||||
<button onClick={props.onClose} type="button" className="mr-2 btn btn-white">
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,39 +1,44 @@
|
|||
import { Team } from "@lib/team";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import TeamListItem from "./TeamListItem";
|
||||
|
||||
export default function TeamList(props: {
|
||||
teams: Team[];
|
||||
onChange: () => void;
|
||||
onEditTeam: (text: Team) => void;
|
||||
}) {
|
||||
const selectAction = (action: string, team: Team) => {
|
||||
interface Props {
|
||||
teams: inferQueryOutput<"viewer.teams.list">;
|
||||
}
|
||||
|
||||
export default function TeamList(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
function selectAction(action: string, teamId: number) {
|
||||
switch (action) {
|
||||
case "edit":
|
||||
props.onEditTeam(team);
|
||||
break;
|
||||
case "disband":
|
||||
deleteTeam(team);
|
||||
deleteTeam(teamId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const deleteTeam = async (team: Team) => {
|
||||
await fetch("/api/teams/" + team.id, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return props.onChange();
|
||||
};
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam(teamId: number) {
|
||||
deleteTeamMutation.mutate({ teamId });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
|
||||
{props.teams.map((team: Team) => (
|
||||
<ul className="mb-2 bg-white border divide-y rounded divide-neutral-200">
|
||||
{props.teams.map((team) => (
|
||||
<TeamListItem
|
||||
onChange={props.onChange}
|
||||
key={team.id}
|
||||
key={team?.id as number}
|
||||
team={team}
|
||||
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
|
||||
onActionSelect={(action: string) => selectAction(action, team?.id as number)}></TeamListItem>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -1,137 +1,137 @@
|
|||
import {
|
||||
DotsHorizontalIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
PencilAltIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/ui/Dropdown";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
import TeamRole from "./TeamRole";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
interface Team {
|
||||
id: number;
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
logo: string | null;
|
||||
bio: string | null;
|
||||
role: string | null;
|
||||
hideBranding: boolean;
|
||||
prevState: null;
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.list">[number];
|
||||
key: number;
|
||||
onActionSelect: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function TeamListItem(props: {
|
||||
onChange: () => void;
|
||||
key: number;
|
||||
team: Team;
|
||||
onActionSelect: (text: string) => void;
|
||||
}) {
|
||||
const [team, setTeam] = useState<Team | null>(props.team);
|
||||
export default function TeamListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const team = props.team;
|
||||
|
||||
const acceptInvite = () => invitationResponse(true);
|
||||
const declineInvite = () => invitationResponse(false);
|
||||
|
||||
const invitationResponse = (accept: boolean) =>
|
||||
fetch("/api/user/membership", {
|
||||
method: accept ? "PATCH" : "DELETE",
|
||||
body: JSON.stringify({ teamId: props.team.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(() => {
|
||||
// success
|
||||
setTeam(null);
|
||||
props.onChange();
|
||||
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
});
|
||||
function acceptOrLeave(accept: boolean) {
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: team?.id as number,
|
||||
accept,
|
||||
});
|
||||
}
|
||||
const acceptInvite = () => acceptOrLeave(true);
|
||||
const declineInvite = () => acceptOrLeave(false);
|
||||
|
||||
const isOwner = props.team.role === MembershipRole.OWNER;
|
||||
const isInvitee = !props.team.accepted;
|
||||
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
|
||||
|
||||
if (!team) return <></>;
|
||||
|
||||
const teamInfo = (
|
||||
<div className="flex px-5 py-5">
|
||||
<Avatar
|
||||
size={9}
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="rounded-full w-9 h-9 min-w-9 min-h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
team && (
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
size={9}
|
||||
imageSrc={
|
||||
props.team.logo
|
||||
? props.team.logo
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||
encodeURIComponent(props.team.name || "")
|
||||
}
|
||||
alt="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">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{props.team.role === "INVITEE" && (
|
||||
<div>
|
||||
<li className="divide-y">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex justify-between items-center",
|
||||
!isInvitee && "group hover:bg-neutral-50"
|
||||
)}>
|
||||
{!isInvitee ? (
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<a className="flex-grow text-sm truncate cursor-pointer" title={`${team.name}`}>
|
||||
{teamInfo}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
teamInfo
|
||||
)}
|
||||
<div className="px-5 py-5">
|
||||
{isInvitee && (
|
||||
<>
|
||||
<Button type="button" color="secondary" onClick={declineInvite}>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
|
||||
<Button type="button" color="primary" className="ml-2" onClick={acceptInvite}>
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{props.team.role === "MEMBER" && (
|
||||
<div>
|
||||
<Button type="button" color="primary" onClick={declineInvite}>
|
||||
{t("leave")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{props.team.role === "OWNER" && (
|
||||
<div className="flex space-x-4">
|
||||
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
|
||||
{t("owner")}
|
||||
</span>
|
||||
<Tooltip content={t("copy_link")}>
|
||||
{!isInvitee && (
|
||||
<div className="flex space-x-2">
|
||||
<TeamRole role={team.role as MembershipRole} />
|
||||
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
|
||||
);
|
||||
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
className="w-10 h-10 transition-none"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={LinkIcon}
|
||||
type="button"
|
||||
/>
|
||||
type="button">
|
||||
<LinkIcon className="w-5 h-5 group-hover:text-gray-600" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="group w-10 h-10 p-0 border border-transparent text-neutral-400 hover:border-gray-200">
|
||||
<DotsHorizontalIcon className="w-5 h-5" />
|
||||
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 ">
|
||||
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<a>
|
||||
<Button type="button" color="minimal" className="w-full" StartIcon={PencilIcon}>
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
onClick={() => props.onActionSelect("edit")}
|
||||
StartIcon={PencilAltIcon}>
|
||||
{" "}
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team.slug}`} passHref={true}>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
|
||||
<a target="_blank">
|
||||
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
|
||||
{" "}
|
||||
|
@ -140,34 +140,61 @@ export default function TeamListItem(props: {
|
|||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={TrashIcon}
|
||||
className="w-full">
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => props.onActionSelect("disband")}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
{isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={TrashIcon}
|
||||
className="w-full">
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => props.onActionSelect("disband")}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="warn"
|
||||
StartIcon={LogoutIcon}
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={declineInvite}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
37
components/team/TeamRole.tsx
Normal file
37
components/team/TeamRole.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
interface Props {
|
||||
role?: MembershipRole;
|
||||
invitePending?: boolean;
|
||||
}
|
||||
|
||||
export default function TeamRole(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames("self-center px-3 py-1 mr-2 text-xs capitalize border rounded-md", {
|
||||
"bg-blue-50 border-blue-200 text-blue-700": props.role === "MEMBER",
|
||||
"bg-gray-50 border-gray-200 text-gray-700": props.role === "OWNER",
|
||||
"bg-red-50 border-red-200 text-red-700": props.role === "ADMIN",
|
||||
"bg-yellow-50 border-yellow-200 text-yellow-700": props.invitePending,
|
||||
})}>
|
||||
{(() => {
|
||||
if (props.invitePending) return t("invitee");
|
||||
switch (props.role) {
|
||||
case "OWNER":
|
||||
return t("owner");
|
||||
case "ADMIN":
|
||||
return t("admin");
|
||||
case "MEMBER":
|
||||
return t("member");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
);
|
||||
}
|
210
components/team/TeamSettings.tsx
Normal file
210
components/team/TeamSettings.tsx
Normal file
|
@ -0,0 +1,210 @@
|
|||
import { HashtagIcon, InformationCircleIcon, LinkIcon, PhotographIcon } from "@heroicons/react/solid";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import { TextField } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
import SettingInputContainer from "@components/ui/SettingInputContainer";
|
||||
|
||||
interface Props {
|
||||
team: TeamWithMembers | null | undefined;
|
||||
}
|
||||
|
||||
export default function TeamSettings(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const team = props.team;
|
||||
const hasLogo = !!team?.logo;
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const mutation = trpc.useMutation("viewer.teams.update", {
|
||||
onError: (err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
setHasErrors(false);
|
||||
},
|
||||
});
|
||||
|
||||
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>;
|
||||
|
||||
function updateTeamData() {
|
||||
if (!team) return;
|
||||
const variables = {
|
||||
name: nameRef.current?.value,
|
||||
slug: teamUrlRef.current?.value,
|
||||
bio: descriptionRef.current?.value,
|
||||
hideBranding: hideBrandingRef.current?.checked,
|
||||
};
|
||||
// remove unchanged variables
|
||||
for (const key in variables) {
|
||||
//@ts-expect-error will fix types
|
||||
if (variables[key] === team?.[key]) delete variables[key];
|
||||
}
|
||||
mutation.mutate({ id: team.id, ...variables });
|
||||
}
|
||||
|
||||
function updateLogo(newLogo: string) {
|
||||
if (!team) return;
|
||||
logoRef.current.value = newLogo;
|
||||
mutation.mutate({ id: team.id, logo: newLogo });
|
||||
}
|
||||
|
||||
const removeLogo = () => updateLogo("");
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
||||
<div className="">
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
<form
|
||||
className="divide-y divide-gray-200 lg:col-span-9"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
updateTeamData();
|
||||
}}>
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<SettingInputContainer
|
||||
Icon={LinkIcon}
|
||||
label="Team URL"
|
||||
htmlFor="team-url"
|
||||
Input={
|
||||
<TextField
|
||||
name="team-url"
|
||||
id="team-url"
|
||||
addOnLeading={
|
||||
<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">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
|
||||
</span>
|
||||
}
|
||||
ref={teamUrlRef}
|
||||
defaultValue={team?.slug as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingInputContainer
|
||||
Icon={HashtagIcon}
|
||||
label="Team Name"
|
||||
htmlFor="name"
|
||||
Input={
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder={t("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-800 focus:border-neutral-800 sm:text-sm"
|
||||
defaultValue={team?.name as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<hr />
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={InformationCircleIcon}
|
||||
label={t("about")}
|
||||
htmlFor="about"
|
||||
Input={
|
||||
<>
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
id="about"
|
||||
name="about"
|
||||
rows={3}
|
||||
defaultValue={team?.bio as string}
|
||||
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"></textarea>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={PhotographIcon}
|
||||
label={"Logo"}
|
||||
htmlFor="avatar"
|
||||
Input={
|
||||
<>
|
||||
<div className="flex mt-1">
|
||||
<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-800 focus:border-neutral-800 sm:text-sm"
|
||||
defaultValue={team?.logo ?? undefined}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="logo"
|
||||
id="logo-upload"
|
||||
buttonMsg={hasLogo ? t("edit_logo") : t("upload_a_logo")}
|
||||
handleAvatarChange={updateLogo}
|
||||
imageSrc={team?.logo ?? undefined}
|
||||
/>
|
||||
{hasLogo && (
|
||||
<Button
|
||||
onClick={removeLogo}
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="py-1 ml-1 text-xs">
|
||||
{t("remove_logo")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="mt-6" />
|
||||
</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={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">
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit" color="primary">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
123
components/team/TeamSettingsRightSidebar.tsx
Normal file
123
components/team/TeamSettingsRightSidebar.tsx
Normal file
|
@ -0,0 +1,123 @@
|
|||
import { ClockIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, TrashIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import LinkIconButton from "@components/ui/LinkIconButton";
|
||||
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
// import Switch from "@components/ui/Switch";
|
||||
|
||||
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
|
||||
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`;
|
||||
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
router.push(`/settings/teams`);
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam() {
|
||||
if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id });
|
||||
}
|
||||
function leaveTeam() {
|
||||
if (props.team?.id)
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
accept: false,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 space-y-6">
|
||||
{/* <Switch
|
||||
name="isHidden"
|
||||
defaultChecked={hidden}
|
||||
onCheckedChange={setHidden}
|
||||
label={"Hide team from view"}
|
||||
/> */}
|
||||
<div className="space-y-1">
|
||||
<Link href={permalink} passHref={true}>
|
||||
<a target="_blank">
|
||||
<LinkIconButton Icon={ExternalLinkIcon}>{t("preview")}</LinkIconButton>
|
||||
</a>
|
||||
</Link>
|
||||
<LinkIconButton
|
||||
Icon={LinkIcon}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_link_team")}
|
||||
</LinkIconButton>
|
||||
{props.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
Icon={TrashIcon}>
|
||||
{t("disband_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
Icon={LogoutIcon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={leaveTeam}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
{/* TODO: Team availability */}
|
||||
{props.team?.id && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<div className="mt-5 space-y-1">
|
||||
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
|
||||
<p className="mt-2 text-sm text-gray-500">See your team members availability at a glance.</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -2,6 +2,7 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
|
|||
import { ArrowLeftIcon } from "@heroicons/react/solid";
|
||||
import classnames from "classnames";
|
||||
import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
@ -10,10 +11,14 @@ import Avatar from "@components/ui/Avatar";
|
|||
import Button from "@components/ui/Button";
|
||||
import Text from "@components/ui/Text";
|
||||
|
||||
const Team = ({ team }) => {
|
||||
type TeamType = TeamPageProps["team"];
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number];
|
||||
|
||||
const Team = ({ team }: TeamPageProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const Member = ({ member }) => {
|
||||
const Member = ({ member }: { member: MemberType }) => {
|
||||
const classes = classnames(
|
||||
"group",
|
||||
"relative",
|
||||
|
@ -29,7 +34,7 @@ const Team = ({ team }) => {
|
|||
);
|
||||
|
||||
return (
|
||||
<Link key={member.id} href={`/${member.user.username}`}>
|
||||
<Link key={member.id} href={`/${member.username}`}>
|
||||
<div className={classes}>
|
||||
<ArrowRightIcon
|
||||
className={classnames(
|
||||
|
@ -42,11 +47,11 @@ const Team = ({ team }) => {
|
|||
/>
|
||||
|
||||
<div>
|
||||
<Avatar displayName={member.user.name} imageSrc={member.user.avatar} className="w-12 h-12" />
|
||||
<Avatar alt={member.name || ""} imageSrc={member.avatar} className="w-12 h-12" />
|
||||
<section className="space-y-2">
|
||||
<Text variant="title">{member.user.name}</Text>
|
||||
<Text variant="subtitle" className="w-6/8 max-w-md">
|
||||
{member.user.bio}
|
||||
<Text variant="title">{member.name}</Text>
|
||||
<Text variant="subtitle" className="w-6/8">
|
||||
{member.bio}
|
||||
</Text>
|
||||
</section>
|
||||
</div>
|
||||
|
@ -55,15 +60,15 @@ const Team = ({ team }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const Members = ({ members }) => {
|
||||
const Members = ({ members }: { members: MembersType }) => {
|
||||
if (!members || members.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto min-w-full lg:min-w-lg max-w-5xl flex flex-wrap gap-x-12 gap-y-6 justify-center">
|
||||
<section className="flex flex-wrap justify-center max-w-5xl min-w-full mx-auto lg:min-w-lg gap-x-12 gap-y-6">
|
||||
{members.map((member) => {
|
||||
return member.user.username !== null && <Member key={member.id} member={member} />;
|
||||
return member.username !== null && <Member key={member.id} member={member} />;
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
|
@ -73,7 +78,7 @@ const Team = ({ team }) => {
|
|||
<div>
|
||||
<Members members={team.members} />
|
||||
{team.eventTypes.length > 0 && (
|
||||
<aside className="text-center dark:text-white mt-8">
|
||||
<aside className="mt-8 text-center dark:text-white">
|
||||
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
|
|
|
@ -90,12 +90,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
|||
},
|
||||
<>
|
||||
{StartIcon && (
|
||||
<StartIcon
|
||||
className={classNames(
|
||||
"inline",
|
||||
size === "icon" ? "w-5 h-5 group-hover:text-black" : "w-5 h-5 mr-2 -ml-1"
|
||||
)}
|
||||
/>
|
||||
<StartIcon className={classNames("inline", size === "icon" ? "w-5 h-5 " : "w-5 h-5 mr-2 -ml-1")} />
|
||||
)}
|
||||
{props.children}
|
||||
{loading && (
|
||||
|
|
21
components/ui/LinkIconButton.tsx
Normal file
21
components/ui/LinkIconButton.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React from "react";
|
||||
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
Icon: SVGComponent;
|
||||
}
|
||||
|
||||
export default function LinkIconButton(props: LinkIconButtonProps) {
|
||||
return (
|
||||
<div className="-ml-2">
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
className="flex items-center px-2 py-1 text-sm font-medium text-gray-700 rounded-sm text-md hover:text-gray-900 hover:bg-gray-200">
|
||||
<props.Icon className="w-4 h-4 mr-2 text-neutral-500" />
|
||||
{props.children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
39
components/ui/ModalContainer.tsx
Normal file
39
components/ui/ModalContainer.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
interface Props extends React.PropsWithChildren<any> {
|
||||
wide?: boolean;
|
||||
scroll?: boolean;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
export default function ModalContainer(props: Props) {
|
||||
return (
|
||||
<div
|
||||
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 px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
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={classNames(
|
||||
"inline-block min-w-96 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:p-6",
|
||||
{
|
||||
"sm:max-w-lg sm:w-full ": !props.wide,
|
||||
"sm:max-w-4xl sm:w-4xl": props.wide,
|
||||
"overflow-scroll": props.scroll,
|
||||
"!p-0": props.noPadding,
|
||||
}
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
25
components/ui/SettingInputContainer.tsx
Normal file
25
components/ui/SettingInputContainer.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
export default function SettingInputContainer({
|
||||
Input,
|
||||
Icon,
|
||||
label,
|
||||
htmlFor,
|
||||
}: {
|
||||
Input: React.ReactNode;
|
||||
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element;
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="block sm:flex">
|
||||
<div className="mb-4 min-w-48 sm:mb-0">
|
||||
<label htmlFor={htmlFor} className="flex mt-1 text-sm font-medium text-neutral-700">
|
||||
<Icon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-grow w-full">{Input}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use <TextField addOnLeading={}> to achieve the same effect.
|
||||
*/
|
||||
const UsernameInput = React.forwardRef<HTMLInputElement, UsernameInputProps>((props, ref) => (
|
||||
// todo, check if username is already taken here?
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
{props.label ? props.label : "Username"}
|
||||
</label>
|
||||
<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">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{props.label && "team/"}
|
||||
</span>
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
{...props}
|
||||
className="flex-grow block w-full min-w-0 lowercase border-gray-300 rounded-none rounded-r-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
UsernameInput.displayName = "UsernameInput";
|
||||
|
||||
export { UsernameInput };
|
28
components/ui/form/DatePicker.tsx
Normal file
28
components/ui/form/DatePicker.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { CalendarIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
import "react-calendar/dist/Calendar.css";
|
||||
import "react-date-picker/dist/DatePicker.css";
|
||||
import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
type Props = {
|
||||
date: Date;
|
||||
onDatesChange?: ((date: Date) => void) | undefined;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
||||
return (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
"p-1 pl-2 border border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
|
||||
className
|
||||
)}
|
||||
clearIcon={null}
|
||||
calendarIcon={<CalendarIcon className="w-5 h-5 text-gray-500" />}
|
||||
value={date}
|
||||
onChange={onDatesChange}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,24 +1,30 @@
|
|||
import classNames from "classnames";
|
||||
import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: ReactNode;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
|
||||
return (
|
||||
<div className="block sm:flex">
|
||||
<div className="mb-4 min-w-48 sm:mb-0">
|
||||
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
{!!label && (
|
||||
<div className="mb-4 min-w-48 sm:mb-0">
|
||||
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="relative rounded-sm shadow-sm">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="number"
|
||||
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className={classNames(
|
||||
"block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
|
||||
rest.className
|
||||
)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm" id="duration">
|
||||
|
|
92
ee/components/team/availability/TeamAvailabilityModal.tsx
Normal file
92
ee/components/team/availability/TeamAvailabilityModal.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||
import MinutesField from "@components/ui/form/MinutesField";
|
||||
|
||||
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
interface Props {
|
||||
team?: inferQueryOutput<"viewer.teams.get">;
|
||||
member?: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
export default function TeamAvailabilityModal(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess);
|
||||
const [frequency, setFrequency] = useState<number>(30);
|
||||
|
||||
useEffect(() => {
|
||||
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
||||
}, [utils, selectedTimeZone, selectedDate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row max-h-[500px] min-h-[500px] space-x-8">
|
||||
<div className="w-64 p-5 pr-0 space-y-5 min-w-64">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, props.member?.name as string)}
|
||||
alt={props.member?.name || ""}
|
||||
className="rounded-full w-14 h-14"
|
||||
/>
|
||||
<div className="inline-block pt-1 ml-3">
|
||||
<span className="text-lg font-bold text-neutral-700">{props.member?.name}</span>
|
||||
<span className="block -mt-1 text-sm text-gray-400">{props.member?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-gray-600">Date</span>
|
||||
<DatePicker
|
||||
date={selectedDate.toDate()}
|
||||
onDatesChange={(newDate) => {
|
||||
setSelectedDate(dayjs(newDate));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-gray-600">Timezone</span>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
|
||||
classNamePrefix="react-select"
|
||||
className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-gray-600">Slot Length</span>
|
||||
<MinutesField
|
||||
id="length"
|
||||
label=""
|
||||
required
|
||||
min="10"
|
||||
placeholder="15"
|
||||
defaultValue={frequency}
|
||||
onChange={(e) => {
|
||||
setFrequency(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.team && props.member && (
|
||||
<TeamAvailabilityTimes
|
||||
className="overflow-scroll"
|
||||
team={props.team}
|
||||
member={props.member}
|
||||
frequency={frequency}
|
||||
selectedDate={selectedDate}
|
||||
selectedTimeZone={selectedTimeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
120
ee/components/team/availability/TeamAvailabilityScreen.tsx
Normal file
120
ee/components/team/availability/TeamAvailabilityScreen.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, { useState, useEffect, CSSProperties } from "react";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||
import MinutesField from "@components/ui/form/MinutesField";
|
||||
|
||||
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
||||
|
||||
interface Props {
|
||||
team?: inferQueryOutput<"viewer.teams.get">;
|
||||
members?: inferQueryOutput<"viewer.teams.get">["members"];
|
||||
}
|
||||
|
||||
// type Member = inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
|
||||
export default function TeamAvailabilityScreen(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess());
|
||||
const [frequency, setFrequency] = useState<number>(30);
|
||||
|
||||
useEffect(() => {
|
||||
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTimeZone, selectedDate]);
|
||||
|
||||
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
|
||||
const member = props.members?.[index];
|
||||
if (!member) return <></>;
|
||||
|
||||
return (
|
||||
<div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 ">
|
||||
<TeamAvailabilityTimes
|
||||
team={props.team as inferQueryOutput<"viewer.teams.get">}
|
||||
member={member}
|
||||
frequency={frequency}
|
||||
selectedDate={selectedDate}
|
||||
selectedTimeZone={selectedTimeZone}
|
||||
HeaderComponent={
|
||||
<div className="flex items-center mb-6">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(member?.avatar, member?.name as string)}
|
||||
alt={member?.name || ""}
|
||||
className="w-10 h-10 mt-1 rounded-full min-w-10 min-h-10"
|
||||
/>
|
||||
<div className="inline-block pt-1 ml-3 overflow-hidden">
|
||||
<span className="text-lg font-bold truncate text-neutral-700">{member?.name}</span>
|
||||
<span className="block -mt-1 text-sm text-gray-400 truncate">{member?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 bg-white border rounded-sm border-neutral-200">
|
||||
<div className="flex w-full p-5 pr-0 space-x-5 border-b border-gray-200">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-gray-600">Date</span>
|
||||
<DatePicker
|
||||
date={selectedDate.toDate()}
|
||||
className="p-1.5"
|
||||
onDatesChange={(newDate) => {
|
||||
setSelectedDate(dayjs(newDate));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-gray-600">Timezone</span>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
|
||||
classNamePrefix="react-select"
|
||||
className="w-full border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-gray-600">Slot Length</span>
|
||||
<MinutesField
|
||||
id="length"
|
||||
label=""
|
||||
required
|
||||
min="10"
|
||||
className="p-2.5"
|
||||
placeholder="15"
|
||||
defaultValue={frequency}
|
||||
onChange={(e) => {
|
||||
setFrequency(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 h-full">
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
itemSize={240}
|
||||
itemCount={props.members?.length || 0}
|
||||
className="List"
|
||||
height={height}
|
||||
direction="horizontal"
|
||||
width={width}>
|
||||
{Item}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
70
ee/components/team/availability/TeamAvailabilityTimes.tsx
Normal file
70
ee/components/team/availability/TeamAvailabilityTimes.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import classNames from "classnames";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React from "react";
|
||||
import { ITimezone } from "react-timezone-select";
|
||||
|
||||
import getSlots from "@lib/slots";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
selectedDate: Dayjs;
|
||||
selectedTimeZone: ITimezone;
|
||||
frequency: number;
|
||||
HeaderComponent?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export default function TeamAvailabilityTimes(props: Props) {
|
||||
const { data, isLoading } = trpc.useQuery(
|
||||
[
|
||||
"viewer.teams.getMemberAvailability",
|
||||
{
|
||||
teamId: props.team.id,
|
||||
memberId: props.member.id,
|
||||
dateFrom: props.selectedDate.toString(),
|
||||
dateTo: props.selectedDate.add(1, "day").toString(),
|
||||
timezone: `${props.selectedTimeZone.toString()}`,
|
||||
},
|
||||
],
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const times = !isLoading
|
||||
? getSlots({
|
||||
frequency: props.frequency,
|
||||
inviteeDate: props.selectedDate,
|
||||
workingHours: data?.workingHours || [],
|
||||
minimumBookingNotice: 0,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={classNames("flex-grow p-5 pl-0 min-w-60", props.className)}>
|
||||
{props.HeaderComponent}
|
||||
{isLoading && times.length === 0 && <Loader />}
|
||||
{!isLoading && times.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center pt-4">
|
||||
<span className="text-sm text-gray-500">No Available Slots</span>
|
||||
</div>
|
||||
)}
|
||||
{times.map((time) => (
|
||||
<div key={time.format()} className="flex flex-row items-center">
|
||||
<a
|
||||
className="flex-grow block py-2 mb-2 mr-3 font-medium text-center bg-white border rounded-sm min-w-48 dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border-brand dark:border-transparent hover:text-white hover:bg-brand dark:hover:border-black dark:hover:bg-black"
|
||||
data-testid="time">
|
||||
{time.format("HH:mm")}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
49
ee/pages/settings/teams/[id]/availability.tsx
Normal file
49
ee/pages/settings/teams/[id]/availability.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
export function TeamSettingsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||
refetchOnWindowFocus: false,
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Shell
|
||||
showBackButton={!errorMessage}
|
||||
heading={team?.name}
|
||||
flexChildrenContainer
|
||||
subtitle={team && "Your team's availability at a glance"}
|
||||
HeadingLeftIcon={
|
||||
team && (
|
||||
<Avatar
|
||||
size={12}
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="mt-1"
|
||||
/>
|
||||
)
|
||||
}>
|
||||
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
|
||||
{isLoading && <Loader />}
|
||||
{team && <TeamAvailabilityScreen team={team} members={team.members} />}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamSettingsPage;
|
6
lib/getPlaceholderAvatar.tsx
Normal file
6
lib/getPlaceholderAvatar.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
export function getPlaceholderAvatar(avatar: string | null | undefined, name: string | null) {
|
||||
return avatar
|
||||
? avatar
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=f9f9f9&bold=true&background=000000&name=" +
|
||||
encodeURIComponent(name || "");
|
||||
}
|
88
lib/queries/availability/index.ts
Normal file
88
lib/queries/availability/index.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
// import { getBusyVideoTimes } from "@lib/videoClient";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
export async function getUserAvailability(query: {
|
||||
username: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
timezone?: string;
|
||||
}) {
|
||||
const username = asStringOrNull(query.username);
|
||||
const dateFrom = dayjs(asStringOrNull(query.dateFrom));
|
||||
const dateTo = dayjs(asStringOrNull(query.dateTo));
|
||||
|
||||
if (!username) throw new Error("Missing username");
|
||||
if (!dateFrom.isValid() || !dateTo.isValid()) throw new Error("Invalid time range given.");
|
||||
|
||||
const rawUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: username,
|
||||
},
|
||||
select: {
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
bufferTime: true,
|
||||
availability: true,
|
||||
id: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
selectedCalendars: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getEventType = (id: number) =>
|
||||
prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
timeZone: true,
|
||||
availability: {
|
||||
select: {
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
days: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type EventType = Prisma.PromiseReturnType<typeof getEventType>;
|
||||
let eventType: EventType | null = null;
|
||||
if (query.eventTypeId) eventType = await getEventType(query.eventTypeId);
|
||||
|
||||
if (!rawUser) throw new Error("No user found");
|
||||
|
||||
const { selectedCalendars, ...currentUser } = rawUser;
|
||||
|
||||
const busyTimes = await getBusyCalendarTimes(
|
||||
currentUser.credentials,
|
||||
dateFrom.format(),
|
||||
dateTo.format(),
|
||||
selectedCalendars
|
||||
);
|
||||
|
||||
// busyTimes.push(...await getBusyVideoTimes(currentUser.credentials, dateFrom.format(), dateTo.format()));
|
||||
|
||||
const bufferedBusyTimes = busyTimes.map((a) => ({
|
||||
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
||||
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
||||
}));
|
||||
|
||||
const timeZone = query.timezone || eventType?.timeZone || currentUser.timeZone;
|
||||
const workingHours = getWorkingHours(
|
||||
{ timeZone },
|
||||
eventType?.availability.length ? eventType.availability : currentUser.availability
|
||||
);
|
||||
|
||||
return {
|
||||
busy: bufferedBusyTimes,
|
||||
timeZone,
|
||||
workingHours,
|
||||
};
|
||||
}
|
99
lib/queries/teams/index.ts
Normal file
99
lib/queries/teams/index.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (...args: any) => Promise<infer R>
|
||||
? R
|
||||
: any;
|
||||
|
||||
export type TeamWithMembers = AsyncReturnType<typeof getTeamWithMembers>;
|
||||
|
||||
export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
username: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
name: true,
|
||||
id: true,
|
||||
bio: true,
|
||||
});
|
||||
|
||||
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
logo: true,
|
||||
bio: true,
|
||||
hideBranding: true,
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
eventTypes: {
|
||||
where: {
|
||||
hidden: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
length: true,
|
||||
slug: true,
|
||||
schedulingType: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
users: {
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: id ? { id } : { slug },
|
||||
select: teamSelect,
|
||||
});
|
||||
|
||||
if (!team) return null;
|
||||
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const members = team.members.map((obj) => {
|
||||
const membership = memberships.find((membership) => obj.user.id === membership.userId);
|
||||
return {
|
||||
...obj.user,
|
||||
role: membership?.role,
|
||||
accepted: membership?.role === "OWNER" ? true : membership?.accepted,
|
||||
};
|
||||
});
|
||||
|
||||
return { ...team, members };
|
||||
}
|
||||
// also returns team
|
||||
export async function isTeamAdmin(userId: number, teamId: number) {
|
||||
return (
|
||||
(await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
|
||||
},
|
||||
})) || false
|
||||
);
|
||||
}
|
||||
export async function isTeamOwner(userId: number, teamId: number) {
|
||||
return !!(await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: "OWNER",
|
||||
},
|
||||
}));
|
||||
}
|
10
lib/team.ts
10
lib/team.ts
|
@ -1,10 +0,0 @@
|
|||
export interface Team {
|
||||
id: number;
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
logo: string | null;
|
||||
bio: string | null;
|
||||
role: string | null;
|
||||
hideBranding: boolean;
|
||||
prevState: null;
|
||||
}
|
|
@ -81,6 +81,7 @@
|
|||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.0",
|
||||
"react": "^17.0.2",
|
||||
"react-date-picker": "^8.3.6",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-easy-crop": "^3.5.2",
|
||||
"react-hook-form": "^7.20.2",
|
||||
|
@ -93,6 +94,8 @@
|
|||
"react-select": "^5.2.1",
|
||||
"react-timezone-select": "^1.1.15",
|
||||
"react-use-intercom": "1.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.6",
|
||||
"short-uuid": "^4.2.0",
|
||||
"stripe": "^8.191.0",
|
||||
"superjson": "1.8.0",
|
||||
|
@ -115,6 +118,8 @@
|
|||
"@types/qrcode": "^1.4.1",
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react-phone-number-input": "^3.0.13",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/stripe": "^8.0.417",
|
||||
"@types/uuid": "8.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
import prisma from "../../lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session) {
|
||||
if (!session?.user?.id) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
@ -23,9 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
});
|
||||
|
||||
if (nameCollisions > 0) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ errorCode: "TeamNameCollision", message: "Team username already taken." });
|
||||
return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already taken." });
|
||||
}
|
||||
|
||||
const createTeam = await prisma.team.create({
|
||||
|
|
|
@ -2,24 +2,35 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
import { getTeamWithMembers } from "@lib/queries/teams";
|
||||
|
||||
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" });
|
||||
}
|
||||
if (!session.user?.id) {
|
||||
console.log("Received session token without a user id.");
|
||||
return res.status(500).json({ message: "Something went wrong." });
|
||||
}
|
||||
if (!req.query.team) {
|
||||
console.log("Missing team query param.");
|
||||
return res.status(500).json({ message: "Something went wrong." });
|
||||
}
|
||||
|
||||
const teamId = parseInt(req.query.team as string);
|
||||
|
||||
// GET /api/teams/{team}
|
||||
if (req.method === "GET") {
|
||||
const team = await getTeamWithMembers(teamId);
|
||||
return res.status(200).json({ team });
|
||||
}
|
||||
// DELETE /api/teams/{team}
|
||||
if (req.method === "DELETE") {
|
||||
if (!session.user?.id) {
|
||||
console.log("Received session token without a user id.");
|
||||
return res.status(500).json({ message: "Something went wrong." });
|
||||
}
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
teamId: parseInt(req.query.team as string),
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -30,12 +41,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: session.user.id, teamId: parseInt(req.query.team) },
|
||||
userId_teamId: { userId: session.user.id, teamId },
|
||||
},
|
||||
});
|
||||
await prisma.team.delete({
|
||||
where: {
|
||||
id: parseInt(req.query.team),
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
return res.status(204).send(null);
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import prisma from "../../../../lib/prisma";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
|
@ -14,7 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
const isTeamOwner = !!(await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
userId: session.user?.id,
|
||||
teamId: parseInt(req.query.team as string),
|
||||
role: "OWNER",
|
||||
},
|
||||
|
@ -54,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
const membership = memberships.find((membership) => member.id === membership.userId);
|
||||
return {
|
||||
...member,
|
||||
role: membership.accepted ? membership.role : "INVITEE",
|
||||
role: membership?.accepted ? membership?.role : "INVITEE",
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -65,7 +64,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
if (req.method === "DELETE") {
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team) },
|
||||
userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team as string) },
|
||||
},
|
||||
});
|
||||
return res.status(204).send(null);
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/client";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
// @deprecated - USE TRPC
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
if (!session) {
|
||||
const session = await getSession({ req });
|
||||
if (!session?.user?.id) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import prisma from "../../../lib/prisma";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
if (!session) {
|
||||
if (!session || !session.user?.id) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
|
|||
import crypto from "crypto";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { ComponentProps, FormEvent, RefObject, useEffect, useRef, useState, useMemo } from "react";
|
||||
import Select, { OptionTypeBase } from "react-select";
|
||||
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Select from "react-select";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
@ -21,11 +21,11 @@ import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
|
|||
import ImageUploader from "@components/ImageUploader";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell from "@components/Shell";
|
||||
import { TextField } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Badge from "@components/ui/Badge";
|
||||
import Button from "@components/ui/Button";
|
||||
import { UsernameInput } from "@components/ui/UsernameInput";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
|
@ -42,7 +42,7 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
|
|||
ref={props.hideBrandingRef}
|
||||
defaultChecked={isBrandingHidden(props.user)}
|
||||
className={
|
||||
"focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm disabled:opacity-50"
|
||||
"focus:ring-neutral-800 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm disabled:opacity-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (!e.currentTarget.checked || props.user.plan !== "FREE") {
|
||||
|
@ -125,37 +125,32 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
{ value: "light", label: t("light") },
|
||||
{ value: "dark", label: t("dark") },
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const usernameRef = useRef<HTMLInputElement>(null!);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const nameRef = useRef<HTMLInputElement>(null!);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const brandColorRef = useRef<HTMLInputElement>(null!);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const hideBrandingRef = useRef<HTMLInputElement>(null!);
|
||||
const [selectedTheme, setSelectedTheme] = useState<OptionTypeBase>();
|
||||
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(props.user.timeZone);
|
||||
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState<OptionTypeBase>({
|
||||
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({
|
||||
value: props.user.weekStart,
|
||||
label: nameOfDay(props.localeProp, props.user.weekStart === "Sunday" ? 0 : 1),
|
||||
});
|
||||
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<OptionTypeBase>({
|
||||
value: props.localeProp,
|
||||
label: localeOptions.find((option) => option.value === props.localeProp)?.label,
|
||||
const [selectedLanguage, setSelectedLanguage] = useState({
|
||||
value: props.localeProp || "",
|
||||
label: localeOptions.find((option) => option.value === props.localeProp)?.label || "",
|
||||
});
|
||||
const [imageSrc, setImageSrc] = useState<string>(props.user.avatar || "");
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTheme(
|
||||
props.user.theme ? themeOptions.find((theme) => theme.value === props.user.theme) : undefined
|
||||
);
|
||||
if (!props.user.theme) return;
|
||||
const userTheme = themeOptions.find((theme) => theme.value === props.user.theme);
|
||||
if (!userTheme) return;
|
||||
setSelectedTheme(userTheme);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
@ -196,7 +191,16 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
<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={usernameRef} defaultValue={props.user.username || undefined} />
|
||||
<TextField
|
||||
name="username"
|
||||
addOnLeading={
|
||||
<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">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
|
||||
</span>
|
||||
}
|
||||
ref={usernameRef}
|
||||
defaultValue={props.user.username || undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2 sm:ml-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
|
@ -210,7 +214,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
autoComplete="given-name"
|
||||
placeholder={t("your_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"
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
defaultValue={props.user.name || undefined}
|
||||
/>
|
||||
</div>
|
||||
|
@ -251,7 +255,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
placeholder={t("little_something_about")}
|
||||
rows={3}
|
||||
defaultValue={props.user.bio || undefined}
|
||||
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>
|
||||
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -268,27 +272,29 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
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"
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("change_avatar")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
avatarRef.current.value = newAvatar;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current.dispatchEvent(ev2);
|
||||
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
|
||||
setImageSrc(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
<div className="flex items-center px-5">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("change_avatar")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
avatarRef.current.value = newAvatar;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current.dispatchEvent(ev2);
|
||||
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
|
||||
setImageSrc(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
|
@ -300,9 +306,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
<Select
|
||||
id="languageSelect"
|
||||
value={selectedLanguage || props.localeProp}
|
||||
onChange={setSelectedLanguage}
|
||||
onChange={(v) => v && setSelectedLanguage(v)}
|
||||
classNamePrefix="react-select"
|
||||
className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
options={localeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
@ -315,9 +321,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={setSelectedTimeZone}
|
||||
onChange={(v) => v && setSelectedTimeZone(v)}
|
||||
classNamePrefix="react-select"
|
||||
className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -329,9 +335,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
<Select
|
||||
id="weekStart"
|
||||
value={selectedWeekStartDay}
|
||||
onChange={setSelectedWeekStartDay}
|
||||
onChange={(v) => v && setSelectedWeekStartDay(v)}
|
||||
classNamePrefix="react-select"
|
||||
className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
options={[
|
||||
{ value: "Sunday", label: nameOfDay(props.localeProp, 0) },
|
||||
{ value: "Monday", label: nameOfDay(props.localeProp, 1) },
|
||||
|
@ -349,8 +355,8 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
isDisabled={!selectedTheme}
|
||||
defaultValue={selectedTheme || themeOptions[0]}
|
||||
value={selectedTheme || themeOptions[0]}
|
||||
onChange={setSelectedTheme}
|
||||
className="shadow-sm | { value: string } focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
onChange={(v) => v && setSelectedTheme(v)}
|
||||
className="shadow-sm | { value: string } focus:ring-neutral-800 focus:border-neutral-800 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
options={themeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
@ -362,7 +368,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
type="checkbox"
|
||||
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
|
||||
checked={!selectedTheme}
|
||||
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
|
||||
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-800 text-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
|
@ -383,7 +389,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
name="brandColor"
|
||||
id="brandColor"
|
||||
placeholder="#hex-code"
|
||||
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"
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
defaultValue={props.user.brandColor}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,207 +1,54 @@
|
|||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
import { useSession } from "next-auth/client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
import { Team } from "@lib/team";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell from "@components/Shell";
|
||||
import EditTeam from "@components/team/EditTeam";
|
||||
import TeamCreateModal from "@components/team/TeamCreateModal";
|
||||
import TeamList from "@components/team/TeamList";
|
||||
import TeamListItem from "@components/team/TeamListItem";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function Teams() {
|
||||
const { t } = useLocale();
|
||||
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 [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
const handleErrors = async (resp: Response) => {
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.message);
|
||||
}
|
||||
return resp.json();
|
||||
};
|
||||
const { data } = trpc.useQuery(["viewer.teams.list"], {
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
const loadData = () => {
|
||||
fetch("/api/user/membership")
|
||||
.then(handleErrors)
|
||||
.then((data) => {
|
||||
setTeams(data.membership.filter((m: Member) => m.role !== "INVITEE"));
|
||||
setInvites(data.membership.filter((m: Member) => m.role === "INVITEE"));
|
||||
})
|
||||
.catch(console.log);
|
||||
};
|
||||
if (loading) return <Loader />;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHasErrors(false);
|
||||
setErrorMessage("");
|
||||
}, [showCreateTeamModal]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
return fetch("/api/teams", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: nameRef?.current?.value }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleErrors)
|
||||
.then(() => {
|
||||
loadData();
|
||||
setShowCreateTeamModal(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const editTeam = (team: Team) => {
|
||||
setEditTeamEnabled(true);
|
||||
setTeamToEdit(team);
|
||||
};
|
||||
|
||||
const onCloseEdit = () => {
|
||||
loadData();
|
||||
setEditTeamEnabled(false);
|
||||
};
|
||||
const teams = data?.filter((m) => m.accepted) || [];
|
||||
const invites = data?.filter((m) => !m.accepted) || [];
|
||||
|
||||
return (
|
||||
<Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
||||
<SettingsShell>
|
||||
{!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">
|
||||
{t("create_team_to_get_started")}
|
||||
</h3>
|
||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
||||
<p>{t("create_first_team_and_invite_others")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start mb-4">
|
||||
<Button type="button" onClick={() => setShowCreateTeamModal(true)} color="secondary">
|
||||
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
|
||||
{t("new_team")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{!!teams.length && (
|
||||
<TeamList teams={teams} onChange={loadData} onEditTeam={editTeam}></TeamList>
|
||||
)}
|
||||
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
|
||||
{!!invites.length && (
|
||||
<div>
|
||||
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!editTeamEnabled && <EditTeam team={teamToEdit} onCloseEdit={onCloseEdit} />}
|
||||
{showCreateTeamModal && (
|
||||
<div
|
||||
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 px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
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 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 font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("create_new_team")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={createTeam}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
</label>
|
||||
{hasErrors && <Alert className="mt-1 mb-2" severity="error" message={errorMessage} />}
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit">{t("create_team")}</Button>
|
||||
<Button
|
||||
onClick={() => setShowCreateTeamModal(false)}
|
||||
type="button"
|
||||
className="mr-2"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{showCreateTeamModal && <TeamCreateModal onClose={() => setShowCreateTeamModal(false)} />}
|
||||
<div className="flex justify-end my-4">
|
||||
<Button type="button" className="btn btn-white" onClick={() => setShowCreateTeamModal(true)}>
|
||||
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
|
||||
{t("new_team")}
|
||||
</Button>
|
||||
</div>
|
||||
{invites.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||
<TeamList teams={invites}></TeamList>
|
||||
</div>
|
||||
)}
|
||||
{teams.length > 0 && <TeamList teams={teams}></TeamList>}
|
||||
</SettingsShell>
|
||||
</Shell>
|
||||
);
|
||||
|
|
94
pages/settings/teams/[id].tsx
Normal file
94
pages/settings/teams/[id].tsx
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||
import MemberList from "@components/team/MemberList";
|
||||
import TeamSettings from "@components/team/TeamSettings";
|
||||
import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { Button } from "@components/ui/Button";
|
||||
|
||||
export function TeamSettingsPage() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
const isAdmin = team && (team.membership.role === "OWNER" || team.membership.role === "ADMIN");
|
||||
|
||||
return (
|
||||
<Shell
|
||||
showBackButton={!errorMessage}
|
||||
heading={team?.name}
|
||||
subtitle={team && "Manage this team"}
|
||||
HeadingLeftIcon={
|
||||
team && (
|
||||
<Avatar
|
||||
size={12}
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="mt-1"
|
||||
/>
|
||||
)
|
||||
}>
|
||||
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
|
||||
{isLoading && <Loader />}
|
||||
{team && (
|
||||
<>
|
||||
<div className="block sm:flex md:max-w-5xl">
|
||||
<div className="w-full mr-2 sm:w-9/12">
|
||||
<div className="px-4 -mx-0 bg-white border rounded-sm border-neutral-200 sm:px-6">
|
||||
{isAdmin ? (
|
||||
<TeamSettings team={team} />
|
||||
) : (
|
||||
<div className="py-5">
|
||||
<span className="mb-1 font-bold">Team Info</span>
|
||||
<p className="text-sm text-gray-700">{team.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-3 mt-7">
|
||||
<h3 className="text-xl font-bold leading-6 text-gray-900 font-cal">{t("members")}</h3>
|
||||
{isAdmin && (
|
||||
<div className="relative flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={PlusIcon}
|
||||
onClick={() => setShowMemberInvitationModal(true)}>
|
||||
{t("new_member")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MemberList team={team} members={team.members || []} />
|
||||
</div>
|
||||
<div className="w-full px-2 mt-8 ml-2 md:w-3/12 sm:mt-0 min-w-32">
|
||||
<TeamSettingsRightSidebar role={team.membership.role} team={team} />
|
||||
</div>
|
||||
</div>
|
||||
{showMemberInvitationModal && (
|
||||
<MemberInvitationModal team={team} onExit={() => setShowMemberInvitationModal(false)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamSettingsPage;
|
1
pages/settings/teams/[id]/availability.tsx
Normal file
1
pages/settings/teams/[id]/availability.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from "@ee/pages/settings/teams/[id]/availability";
|
|
@ -1,5 +1,4 @@
|
|||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
@ -7,8 +6,8 @@ import React from "react";
|
|||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import prisma from "@lib/prisma";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
import { getTeamWithMembers } from "@lib/queries/teams";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||
|
@ -17,8 +16,11 @@ import Team from "@components/team/screens/Team";
|
|||
import Avatar from "@components/ui/Avatar";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import Button from "@components/ui/Button";
|
||||
import Text from "@components/ui/Text";
|
||||
|
||||
function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
||||
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
function TeamPage({ team }: TeamPageProps) {
|
||||
const { isReady } = useTheme();
|
||||
const showMembers = useToggleQuery("members");
|
||||
const { t } = useLocale();
|
||||
|
@ -28,12 +30,12 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
|||
{team.eventTypes.map((type) => (
|
||||
<li
|
||||
key={type.id}
|
||||
className="group relative dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 bg-white hover:bg-gray-50 border border-neutral-200 hover:border-brand rounded-sm">
|
||||
<ArrowRightIcon className="absolute transition-opacity h-4 w-4 right-3 top-3 text-black dark:text-white opacity-0 group-hover:opacity-100" />
|
||||
className="relative bg-white border rounded-sm group dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 hover:bg-gray-50 border-neutral-200 hover:border-brand">
|
||||
<ArrowRightIcon className="absolute w-4 h-4 text-black transition-opacity opacity-0 right-3 top-3 dark:text-white group-hover:opacity-100" />
|
||||
<Link href={`${team.slug}/${type.slug}`}>
|
||||
<a className="px-6 py-4 flex justify-between">
|
||||
<a className="flex justify-between px-6 py-4">
|
||||
<div className="flex-shrink">
|
||||
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
|
||||
<h2 className="font-semibold font-cal text-neutral-900 dark:text-white">{type.title}</h2>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
|
@ -60,43 +62,38 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
|||
isReady && (
|
||||
<div>
|
||||
<HeadSeo title={teamName} description={teamName} />
|
||||
<div className="h-screen bg-neutral-50 dark:bg-black">
|
||||
<main className="max-w-3xl px-4 py-24 mx-auto">
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar alt={teamName} imageSrc={team.logo} className="mx-auto w-20 h-20 rounded-full mb-4" />
|
||||
<h1 className="mb-1 text-3xl font-bold font-cal text-neutral-900 dark:text-white">
|
||||
{teamName}
|
||||
</h1>
|
||||
<p className="text-neutral-500 dark:text-white">{team.bio}</p>
|
||||
</div>
|
||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{eventTypes}
|
||||
<div className="px-4 pt-24 pb-12">
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar alt={teamName} imageSrc={team.logo} className="w-20 h-20 mx-auto mb-4 rounded-full" />
|
||||
<Text variant="headline">{teamName}</Text>
|
||||
</div>
|
||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{eventTypes}
|
||||
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200 dark:border-gray-900" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-2 bg-gray-100 text-sm text-gray-500 dark:bg-brand dark:text-gray-500">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200 dark:border-gray-900" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-2 text-sm text-gray-500 bg-gray-100 dark:bg-brand dark:text-gray-500">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<aside className="text-center dark:text-white mt-8">
|
||||
<Button
|
||||
color="secondary"
|
||||
EndIcon={ArrowRightIcon}
|
||||
href={`/team/${team.slug}?members=1`}
|
||||
shallow={true}>
|
||||
{t("book_a_team_member")}
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="mt-8 text-center dark:text-white">
|
||||
<Button
|
||||
color="secondary"
|
||||
EndIcon={ArrowRightIcon}
|
||||
href={`/team/${team.slug}?members=1`}
|
||||
shallow={true}>
|
||||
{t("book_a_team_member")}
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -106,54 +103,7 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
|||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
username: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
name: true,
|
||||
id: true,
|
||||
bio: true,
|
||||
});
|
||||
|
||||
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
logo: true,
|
||||
bio: true,
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
eventTypes: {
|
||||
where: {
|
||||
hidden: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
length: true,
|
||||
slug: true,
|
||||
schedulingType: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
users: {
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
select: teamSelect,
|
||||
});
|
||||
const team = await getTeamWithMembers(undefined, slug);
|
||||
|
||||
if (!team) return { notFound: true };
|
||||
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "MembershipRole" ADD VALUE 'ADMIN';
|
|
@ -135,6 +135,7 @@ model Team {
|
|||
|
||||
enum MembershipRole {
|
||||
MEMBER
|
||||
ADMIN
|
||||
OWNER
|
||||
}
|
||||
|
||||
|
|
|
@ -303,6 +303,7 @@
|
|||
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
|
||||
"edit_logo": "Edit logo",
|
||||
"upload_a_logo": "Upload a logo",
|
||||
"remove_logo": "Remove logo",
|
||||
"enable": "Enable",
|
||||
"code": "Code",
|
||||
"code_is_incorrect": "Code is incorrect.",
|
||||
|
@ -379,6 +380,7 @@
|
|||
"email_or_username": "Email or Username",
|
||||
"send_invite_email": "Send an invite email",
|
||||
"role": "Role",
|
||||
"edit_role": "Edit Role",
|
||||
"edit_team": "Edit team",
|
||||
"reject": "Reject",
|
||||
"accept": "Accept",
|
||||
|
@ -394,10 +396,12 @@
|
|||
"members": "Members",
|
||||
"member": "Member",
|
||||
"owner": "Owner",
|
||||
"admin": "Admin",
|
||||
"new_member": "New Member",
|
||||
"invite": "Invite",
|
||||
"invite_new_member": "Invite a new member",
|
||||
"invite_new_team_member": "Invite someone to your team.",
|
||||
"change_member_role": "Change team member role",
|
||||
"disable_cal_branding": "Disable Cal.com branding",
|
||||
"disable_cal_branding_description": "Hide all Cal.com branding from your public pages.",
|
||||
"danger_zone": "Danger Zone",
|
||||
|
@ -419,6 +423,10 @@
|
|||
"pending": "Pending",
|
||||
"open_options": "Open options",
|
||||
"copy_link": "Copy link to event",
|
||||
"copy_link_team": "Copy link to team",
|
||||
"leave_team": "Leave team",
|
||||
"confirm_leave_team": "Yes, leave team",
|
||||
"leave_team_confirmation_message": "Are you sure you want to leave this team? You will no longer be able to book using it.",
|
||||
"preview": "Preview",
|
||||
"link_copied": "Link copied!",
|
||||
"title": "Title",
|
||||
|
|
|
@ -15,6 +15,7 @@ import { TRPCError } from "@trpc/server";
|
|||
|
||||
import { createProtectedRouter, createRouter } from "../createRouter";
|
||||
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
||||
import { viewerTeamsRouter } from "./viewer/teams";
|
||||
import { webhookRouter } from "./viewer/webhook";
|
||||
|
||||
const checkUsername =
|
||||
|
@ -630,4 +631,5 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
export const viewerRouter = createRouter()
|
||||
.merge(publicViewerRouter)
|
||||
.merge(loggedInViewerRouter)
|
||||
.merge("teams.", viewerTeamsRouter)
|
||||
.merge("webhook.", webhookRouter);
|
||||
|
|
377
server/routers/viewer/teams.tsx
Normal file
377
server/routers/viewer/teams.tsx
Normal file
|
@ -0,0 +1,377 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { randomBytes } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
|
||||
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
||||
import { getUserAvailability } from "@lib/queries/availability";
|
||||
import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@lib/queries/teams";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
import { createProtectedRouter } from "@server/createRouter";
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const viewerTeamsRouter = createProtectedRouter()
|
||||
// Retrieves team by id
|
||||
.query("get", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const team = await getTeamWithMembers(input.teamId);
|
||||
if (!team?.members.find((m) => m.id === ctx.user.id)) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
|
||||
}
|
||||
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
|
||||
return { ...team, membership: { role: membership?.role as MembershipRole } };
|
||||
},
|
||||
})
|
||||
// Returns teams I a member of
|
||||
.query("list", {
|
||||
async resolve({ ctx }) {
|
||||
const memberships = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
orderBy: { role: "desc" },
|
||||
});
|
||||
const teams = await ctx.prisma.team.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: memberships.map((membership) => membership.teamId),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return memberships.map((membership) => ({
|
||||
role: membership.role,
|
||||
accepted: membership.role === "OWNER" ? true : membership.accepted,
|
||||
...teams.find((team) => team.id === membership.teamId),
|
||||
}));
|
||||
},
|
||||
})
|
||||
.mutation("create", {
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const slug = slugify(input.name);
|
||||
|
||||
const nameCollisions = await ctx.prisma.team.count({
|
||||
where: {
|
||||
OR: [{ name: input.name }, { slug: slug }],
|
||||
},
|
||||
});
|
||||
|
||||
if (nameCollisions > 0)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
|
||||
|
||||
const createTeam = await ctx.prisma.team.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
slug: slug,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.prisma.membership.create({
|
||||
data: {
|
||||
teamId: createTeam.id,
|
||||
userId: ctx.user.id,
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
// Allows team owner to update team metadata
|
||||
.mutation("update", {
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
bio: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
logo: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
hideBranding: z.boolean().optional(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
if (input.slug) {
|
||||
const userConflict = await ctx.prisma.team.findMany({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
},
|
||||
});
|
||||
if (userConflict.some((t) => t.id !== input.id)) return;
|
||||
}
|
||||
await ctx.prisma.team.update({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
data: {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
logo: input.logo,
|
||||
bio: input.bio,
|
||||
hideBranding: input.hideBranding,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation("delete", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
// delete all memberships
|
||||
await ctx.prisma.membership.deleteMany({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.prisma.team.delete({
|
||||
where: {
|
||||
id: input.teamId,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
// Allows owner to remove member from team
|
||||
.mutation("removeMember", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
memberId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
if (ctx.user?.id === input.memberId)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can not remove yourself from a team you own.",
|
||||
});
|
||||
|
||||
await ctx.prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: ctx.user?.id, teamId: input.teamId },
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation("inviteMember", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
usernameOrEmail: z.string(),
|
||||
role: z.nativeEnum(MembershipRole),
|
||||
language: z.string(),
|
||||
sendEmailInvitation: z.boolean(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
const translation = await getTranslation(input.language ?? "en", "common");
|
||||
|
||||
const team = await ctx.prisma.team.findFirst({
|
||||
where: {
|
||||
id: input.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
|
||||
|
||||
const invitee = await ctx.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitee) {
|
||||
// liberal email match
|
||||
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
||||
|
||||
if (!isEmail(input.usernameOrEmail))
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`,
|
||||
});
|
||||
|
||||
// valid email given, create User
|
||||
await ctx.prisma.user.create({ data: { email: input.usernameOrEmail } }).then((invitee) =>
|
||||
ctx.prisma.membership.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
userId: invitee.id,
|
||||
role: input.role as MembershipRole,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const token: string = randomBytes(32).toString("hex");
|
||||
|
||||
await ctx.prisma.verificationRequest.create({
|
||||
data: {
|
||||
identifier: input.usernameOrEmail,
|
||||
token,
|
||||
expires: new Date(new Date().setHours(168)), // +1 week
|
||||
},
|
||||
});
|
||||
|
||||
if (ctx?.user?.name && team?.name) {
|
||||
const teamInviteEvent: TeamInvite = {
|
||||
language: translation,
|
||||
from: ctx.user.name,
|
||||
to: input.usernameOrEmail,
|
||||
teamName: team.name,
|
||||
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`,
|
||||
};
|
||||
await sendTeamInviteEmail(teamInviteEvent);
|
||||
}
|
||||
} else {
|
||||
// create provisional membership
|
||||
try {
|
||||
await ctx.prisma.membership.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
userId: invitee.id,
|
||||
role: input.role as MembershipRole,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === "P2002") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "This user is a member of this team / has a pending invitation.",
|
||||
});
|
||||
}
|
||||
} else throw e;
|
||||
}
|
||||
|
||||
// inform user of membership by email
|
||||
if (input.sendEmailInvitation && ctx?.user?.name && team?.name) {
|
||||
const teamInviteEvent: TeamInvite = {
|
||||
language: translation,
|
||||
from: ctx.user.name,
|
||||
to: input.usernameOrEmail,
|
||||
teamName: team.name,
|
||||
joinLink: BASE_URL + "/settings/teams",
|
||||
};
|
||||
|
||||
await sendTeamInviteEmail(teamInviteEvent);
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
.mutation("acceptOrLeave", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
accept: z.boolean(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (input.accept) {
|
||||
await ctx.prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await ctx.prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
.mutation("changeMemberRole", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
memberId: z.number(),
|
||||
role: z.nativeEnum(MembershipRole),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
const memberships = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const targetMembership = memberships.find((m) => m.userId === input.memberId);
|
||||
const myMembership = memberships.find((m) => m.userId === ctx.user.id);
|
||||
const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER);
|
||||
|
||||
if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can not change the role of an owner if you are an admin.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!teamHasMoreThanOneOwner) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can not change the role of the only owner of a team.",
|
||||
});
|
||||
}
|
||||
|
||||
if (myMembership?.role === MembershipRole.ADMIN && input.memberId === ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can not change yourself to a higher role.",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: { userId: input.memberId, teamId: input.teamId },
|
||||
},
|
||||
data: {
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.query("getMemberAvailability", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
memberId: z.number(),
|
||||
timezone: z.string(),
|
||||
dateFrom: z.string(),
|
||||
dateTo: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const team = await isTeamAdmin(ctx.user?.id, input.teamId);
|
||||
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
// verify member is in team
|
||||
const members = await ctx.prisma.membership.findMany({
|
||||
where: { teamId: input.teamId },
|
||||
include: { user: true },
|
||||
});
|
||||
const member = members?.find((m) => m.userId === input.memberId);
|
||||
if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
||||
if (!member.user.username)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" });
|
||||
|
||||
// get availability for this member
|
||||
const availability = await getUserAvailability({
|
||||
username: member.user.username,
|
||||
timezone: input.timezone,
|
||||
dateFrom: input.dateFrom,
|
||||
dateTo: input.dateTo,
|
||||
});
|
||||
|
||||
return availability;
|
||||
},
|
||||
});
|
|
@ -374,3 +374,8 @@ body {
|
|||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* react-date-picker forces a border upon us, cast it away */
|
||||
.react-date-picker__wrapper {
|
||||
border: none !important;
|
||||
}
|
||||
|
|
47
yarn.lock
47
yarn.lock
|
@ -315,7 +315,7 @@
|
|||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.0":
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.0":
|
||||
version "7.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
|
||||
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
|
||||
|
@ -2111,6 +2111,20 @@
|
|||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-virtualized-auto-sizer@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4"
|
||||
integrity sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-window@^1.8.5":
|
||||
version "1.8.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
|
||||
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@16 || 17":
|
||||
version "17.0.34"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102"
|
||||
|
@ -6886,7 +6900,7 @@ md5.js@^1.3.4:
|
|||
inherits "^2.0.1"
|
||||
safe-buffer "^5.1.2"
|
||||
|
||||
memoize-one@^5.0.0:
|
||||
"memoize-one@>=3.1.1 <6", memoize-one@^5.0.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
@ -8340,6 +8354,22 @@ react-date-picker@^8.3.3:
|
|||
react-fit "^1.0.3"
|
||||
update-input-width "^1.2.2"
|
||||
|
||||
react-date-picker@^8.3.6:
|
||||
version "8.3.6"
|
||||
resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.3.6.tgz#446142bee5691aea66a2bac53313357aca561cd4"
|
||||
integrity sha512-c1rThf0jSKROoSGLpUEPtcC8VE+XoVgqxh+ng9aLYQvjDMGWQBgoat6Qrj8nRVzvCPpdXV4jqiCB3z2vVVuseA==
|
||||
dependencies:
|
||||
"@types/react-calendar" "^3.0.0"
|
||||
"@wojtekmaj/date-utils" "^1.0.3"
|
||||
get-user-locale "^1.2.0"
|
||||
make-event-props "^1.1.0"
|
||||
merge-class-names "^1.1.1"
|
||||
merge-refs "^1.0.0"
|
||||
prop-types "^15.6.0"
|
||||
react-calendar "^3.3.1"
|
||||
react-fit "^1.0.3"
|
||||
update-input-width "^1.2.2"
|
||||
|
||||
react-dom@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||
|
@ -8540,6 +8570,19 @@ react-use-intercom@1.4.0:
|
|||
resolved "https://registry.yarnpkg.com/react-use-intercom/-/react-use-intercom-1.4.0.tgz#796527728c131ebf132186385bf78f69dbcd84cc"
|
||||
integrity sha512-HqPp7nRnftREE01i88w2kYWOV45zvJt0Of6jtHflIBa3eKl1bAs/izZUINGCJ0DOdgAdlbLweAvJlP4VTzsJjQ==
|
||||
|
||||
react-virtualized-auto-sizer@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"
|
||||
integrity sha512-7tQ0BmZqfVF6YYEWcIGuoR3OdYe8I/ZFbNclFlGOC3pMqunkYF/oL30NCjSGl9sMEb17AnzixDz98Kqc3N76HQ==
|
||||
|
||||
react-window@^1.8.6:
|
||||
version "1.8.6"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112"
|
||||
integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||
|
|
Loading…
Reference in a new issue