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": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": true
|
"source.fixAll.eslint": true
|
||||||
},
|
},
|
||||||
"eslint.run": "onSave",
|
"eslint.run": "onSave"
|
||||||
"workbench.colorCustomizations": {
|
|
||||||
"titleBar.activeBackground": "#292929",
|
|
||||||
"titleBar.inactiveBackground": "#888888"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -110,7 +110,7 @@ export default function ImageUploader({
|
||||||
(opened) => !opened && setFile(null) // unset file on close
|
(opened) => !opened && setFile(null) // unset file on close
|
||||||
}>
|
}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<div className="flex items-center px-3">
|
<div className="flex items-center">
|
||||||
<Button color="secondary" type="button" className="py-1 text-xs">
|
<Button color="secondary" type="button" className="py-1 text-xs">
|
||||||
{buttonMsg}
|
{buttonMsg}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { SelectorIcon } from "@heroicons/react/outline";
|
import { SelectorIcon } from "@heroicons/react/outline";
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
|
@ -36,6 +37,7 @@ import Dropdown, {
|
||||||
|
|
||||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
|
import Button from "./ui/Button";
|
||||||
|
|
||||||
function useMeQuery() {
|
function useMeQuery() {
|
||||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||||
|
@ -118,6 +120,10 @@ export default function Shell(props: {
|
||||||
subtitle?: ReactNode;
|
subtitle?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
CTA?: ReactNode;
|
CTA?: ReactNode;
|
||||||
|
HeadingLeftIcon?: ReactNode;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
// use when content needs to expand with flex
|
||||||
|
flexChildrenContainer?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -249,7 +255,11 @@ export default function Shell(props: {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 w-0 overflow-hidden">
|
<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) */}
|
{/* 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">
|
<nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 md:hidden">
|
||||||
<Link href="/event-types">
|
<Link href="/event-types">
|
||||||
|
@ -269,8 +279,21 @@ export default function Shell(props: {
|
||||||
<UserDropdown small />
|
<UserDropdown small />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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]">
|
<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">
|
<div className="w-full mb-8">
|
||||||
<h1 className="mb-1 text-xl font-bold tracking-wide text-gray-900 font-cal">
|
<h1 className="mb-1 text-xl font-bold tracking-wide text-gray-900 font-cal">
|
||||||
{props.heading}
|
{props.heading}
|
||||||
|
@ -279,7 +302,13 @@ export default function Shell(props: {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
|
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
|
||||||
</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) */}
|
{/* 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">
|
<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 */}
|
{/* 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}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
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
|
props.className
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -54,9 +54,11 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
|
||||||
} = props;
|
} = props;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{!!props.name && (
|
||||||
<Label htmlFor={id} {...labelProps}>
|
<Label htmlFor={id} {...labelProps}>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
|
)}
|
||||||
{addOnLeading ? (
|
{addOnLeading ? (
|
||||||
<div className="flex mt-1 rounded-md shadow-sm">
|
<div className="flex mt-1 rounded-md shadow-sm">
|
||||||
{addOnLeading}
|
{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 { useState } from "react";
|
||||||
import React, { SyntheticEvent } from "react";
|
import React, { SyntheticEvent } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
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";
|
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 [errorMessage, setErrorMessage] = useState("");
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
const handleError = async (res: Response) => {
|
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||||
const responseData = await res.json();
|
async onSuccess() {
|
||||||
|
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||||
|
props.onExit();
|
||||||
|
},
|
||||||
|
async onError(err) {
|
||||||
|
setErrorMessage(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (res.ok === false) {
|
function inviteMember(e: SyntheticEvent) {
|
||||||
setErrorMessage(responseData.message);
|
|
||||||
throw new Error(responseData.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const inviteMember = (e: SyntheticEvent) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!props.team) return;
|
||||||
|
|
||||||
const target = e.target as typeof e.target & {
|
const target = e.target as typeof e.target & {
|
||||||
elements: {
|
elements: {
|
||||||
role: { value: string };
|
role: { value: MembershipRole };
|
||||||
inviteUser: { value: string };
|
inviteUser: { value: string };
|
||||||
sendInviteEmail: { checked: boolean };
|
sendInviteEmail: { checked: boolean };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload = {
|
inviteMemberMutation.mutate({
|
||||||
|
teamId: props.team.id,
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
role: target.elements["role"].value,
|
role: target.elements["role"].value,
|
||||||
usernameOrEmail: target.elements["inviteUser"].value,
|
usernameOrEmail: target.elements["inviteUser"].value,
|
||||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
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 (
|
return (
|
||||||
<div
|
<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="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="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">
|
<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">
|
||||||
<UsersIcon className="w-6 h-6 text-black" />
|
<UserIcon className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<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">
|
<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"
|
id="role"
|
||||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
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="MEMBER">{t("member")}</option>
|
||||||
|
<option value="ADMIN">{t("admin")}</option>
|
||||||
<option value="OWNER">{t("owner")}</option>
|
<option value="OWNER">{t("owner")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,30 +1,20 @@
|
||||||
import { Member } from "@lib/member";
|
import { inferQueryOutput } from "@lib/trpc";
|
||||||
|
|
||||||
import MemberListItem from "./MemberListItem";
|
import MemberListItem from "./MemberListItem";
|
||||||
|
|
||||||
export default function MemberList(props: {
|
interface Props {
|
||||||
members: Member[];
|
team: inferQueryOutput<"viewer.teams.get">;
|
||||||
onRemoveMember: (text: Member) => void;
|
members: inferQueryOutput<"viewer.teams.get">["members"];
|
||||||
onChange: (text: string) => void;
|
|
||||||
}) {
|
|
||||||
const selectAction = (action: string, member: Member) => {
|
|
||||||
switch (action) {
|
|
||||||
case "remove":
|
|
||||||
props.onRemoveMember(member);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
export default function MemberList(props: Props) {
|
||||||
|
if (!props.members.length) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ul className="px-6 mb-2 -mx-6 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
|
<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) => (
|
{props.members?.map((member) => (
|
||||||
<MemberListItem
|
<MemberListItem key={member.id} member={member} team={props.team} />
|
||||||
onChange={props.onChange}
|
|
||||||
key={member.id}
|
|
||||||
member={member}
|
|
||||||
onActionSelect={(action: string) => selectAction(action, member)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,76 +1,115 @@
|
||||||
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
|
||||||
import { useState } from "react";
|
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 { 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 { Dialog, DialogTrigger } from "@components/Dialog";
|
||||||
|
import { Tooltip } from "@components/Tooltip";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
import ModalContainer from "@components/ui/ModalContainer";
|
||||||
|
|
||||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
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: {
|
interface Props {
|
||||||
member: Member;
|
team: inferQueryOutput<"viewer.teams.get">;
|
||||||
onActionSelect: (text: string) => void;
|
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||||
onChange: (text: string) => void;
|
}
|
||||||
}) {
|
|
||||||
const [member] = useState(props.member);
|
export default function MemberListItem(props: Props) {
|
||||||
const { t } = useLocale();
|
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 (
|
return (
|
||||||
member && (
|
|
||||||
<li className="divide-y">
|
<li className="divide-y">
|
||||||
<div className="flex justify-between my-4">
|
<div className="flex justify-between my-4">
|
||||||
<div className="flex flex-col justify-between w-full sm:flex-row">
|
<div className="flex flex-col justify-between w-full sm:flex-row">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Avatar
|
<Avatar
|
||||||
imageSrc={
|
imageSrc={getPlaceholderAvatar(props.member?.avatar, name)}
|
||||||
props.member.avatar
|
alt={name || ""}
|
||||||
? 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"
|
className="rounded-full w-9 h-9"
|
||||||
/>
|
/>
|
||||||
<div className="inline-block ml-3">
|
<div className="inline-block ml-3">
|
||||||
<span className="text-sm font-bold text-neutral-700">{props.member.name}</span>
|
<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>
|
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex justify-center mr-2">
|
||||||
{props.member.role === "INVITEE" && (
|
{!props.member.accepted && <TeamRole invitePending />}
|
||||||
<>
|
<TeamRole role={props.member.role} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* <div className="flex flex-col-reverse"> */}
|
<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>
|
<Dropdown>
|
||||||
<DropdownMenuTrigger>
|
<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" />
|
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<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>
|
<DropdownMenuItem>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
@ -88,17 +127,36 @@ export default function MemberListItem(props: {
|
||||||
variety="danger"
|
variety="danger"
|
||||||
title={t("remove_member")}
|
title={t("remove_member")}
|
||||||
confirmBtnText={t("confirm_remove_member")}
|
confirmBtnText={t("confirm_remove_member")}
|
||||||
onConfirm={() => props.onActionSelect("remove")}>
|
onConfirm={removeMember}>
|
||||||
{t("remove_member_confirmation_message")}
|
{t("remove_member_confirmation_message")}
|
||||||
</ConfirmationDialogContent>
|
</ConfirmationDialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
{/* </div> */}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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";
|
import TeamListItem from "./TeamListItem";
|
||||||
|
|
||||||
export default function TeamList(props: {
|
interface Props {
|
||||||
teams: Team[];
|
teams: inferQueryOutput<"viewer.teams.list">;
|
||||||
onChange: () => void;
|
}
|
||||||
onEditTeam: (text: Team) => void;
|
|
||||||
}) {
|
export default function TeamList(props: Props) {
|
||||||
const selectAction = (action: string, team: Team) => {
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
|
function selectAction(action: string, teamId: number) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "edit":
|
|
||||||
props.onEditTeam(team);
|
|
||||||
break;
|
|
||||||
case "disband":
|
case "disband":
|
||||||
deleteTeam(team);
|
deleteTeam(teamId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const deleteTeam = async (team: Team) => {
|
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||||
await fetch("/api/teams/" + team.id, {
|
async onSuccess() {
|
||||||
method: "DELETE",
|
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||||
|
},
|
||||||
|
async onError(err) {
|
||||||
|
showToast(err.message, "error");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return props.onChange();
|
|
||||||
};
|
function deleteTeam(teamId: number) {
|
||||||
|
deleteTeamMutation.mutate({ teamId });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
|
<ul className="mb-2 bg-white border divide-y rounded divide-neutral-200">
|
||||||
{props.teams.map((team: Team) => (
|
{props.teams.map((team) => (
|
||||||
<TeamListItem
|
<TeamListItem
|
||||||
onChange={props.onChange}
|
key={team?.id as number}
|
||||||
key={team.id}
|
|
||||||
team={team}
|
team={team}
|
||||||
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
|
onActionSelect={(action: string) => selectAction(action, team?.id as number)}></TeamListItem>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,137 +1,137 @@
|
||||||
import {
|
import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
|
||||||
DotsHorizontalIcon,
|
import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||||
ExternalLinkIcon,
|
|
||||||
LinkIcon,
|
|
||||||
PencilAltIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from "@heroicons/react/outline";
|
|
||||||
import Link from "next/link";
|
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 { useLocale } from "@lib/hooks/useLocale";
|
||||||
import showToast from "@lib/notification";
|
import showToast from "@lib/notification";
|
||||||
|
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||||
|
|
||||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||||
import { Tooltip } from "@components/Tooltip";
|
import { Tooltip } from "@components/Tooltip";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import Button from "@components/ui/Button";
|
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 {
|
interface Props {
|
||||||
id: number;
|
team: inferQueryOutput<"viewer.teams.list">[number];
|
||||||
name: string | null;
|
key: number;
|
||||||
slug: string | null;
|
onActionSelect: (text: string) => void;
|
||||||
logo: string | null;
|
|
||||||
bio: string | null;
|
|
||||||
role: string | null;
|
|
||||||
hideBranding: boolean;
|
|
||||||
prevState: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamListItem(props: {
|
export default function TeamListItem(props: Props) {
|
||||||
onChange: () => void;
|
|
||||||
key: number;
|
|
||||||
team: Team;
|
|
||||||
onActionSelect: (text: string) => void;
|
|
||||||
}) {
|
|
||||||
const [team, setTeam] = useState<Team | null>(props.team);
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
const team = props.team;
|
||||||
|
|
||||||
const acceptInvite = () => invitationResponse(true);
|
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||||
const declineInvite = () => invitationResponse(false);
|
onSuccess: () => {
|
||||||
|
utils.invalidateQueries(["viewer.teams.list"]);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
function acceptOrLeave(accept: boolean) {
|
||||||
|
acceptOrLeaveMutation.mutate({
|
||||||
|
teamId: team?.id as number,
|
||||||
|
accept,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const acceptInvite = () => acceptOrLeave(true);
|
||||||
|
const declineInvite = () => acceptOrLeave(false);
|
||||||
|
|
||||||
return (
|
const isOwner = props.team.role === MembershipRole.OWNER;
|
||||||
team && (
|
const isInvitee = !props.team.accepted;
|
||||||
<li className="divide-y">
|
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
|
||||||
<div className="flex justify-between my-4">
|
|
||||||
<div className="flex">
|
if (!team) return <></>;
|
||||||
|
|
||||||
|
const teamInfo = (
|
||||||
|
<div className="flex px-5 py-5">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={9}
|
size={9}
|
||||||
imageSrc={
|
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||||
props.team.logo
|
|
||||||
? props.team.logo
|
|
||||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
|
||||||
encodeURIComponent(props.team.name || "")
|
|
||||||
}
|
|
||||||
alt="Team Logo"
|
alt="Team Logo"
|
||||||
className="rounded-full w-9 h-9"
|
className="rounded-full w-9 h-9 min-w-9 min-h-9"
|
||||||
/>
|
/>
|
||||||
<div className="inline-block ml-3">
|
<div className="inline-block ml-3">
|
||||||
<span className="text-sm font-bold text-neutral-700">{props.team.name}</span>
|
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
|
||||||
<span className="block -mt-1 text-xs text-gray-400">
|
<span className="block text-xs text-gray-400">
|
||||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug}
|
{process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.team.role === "INVITEE" && (
|
);
|
||||||
<div>
|
|
||||||
|
return (
|
||||||
|
<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}>
|
<Button type="button" color="secondary" onClick={declineInvite}>
|
||||||
{t("reject")}
|
{t("reject")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
|
<Button type="button" color="primary" className="ml-2" onClick={acceptInvite}>
|
||||||
{t("accept")}
|
{t("accept")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
{props.team.role === "MEMBER" && (
|
{!isInvitee && (
|
||||||
<div>
|
<div className="flex space-x-2">
|
||||||
<Button type="button" color="primary" onClick={declineInvite}>
|
<TeamRole role={team.role as MembershipRole} />
|
||||||
{t("leave")}
|
|
||||||
</Button>
|
<Tooltip content={t("copy_link_team")}>
|
||||||
</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")}>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
|
||||||
process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
|
|
||||||
);
|
|
||||||
showToast(t("link_copied"), "success");
|
showToast(t("link_copied"), "success");
|
||||||
}}
|
}}
|
||||||
|
className="w-10 h-10 transition-none"
|
||||||
size="icon"
|
size="icon"
|
||||||
color="minimal"
|
color="minimal"
|
||||||
StartIcon={LinkIcon}
|
type="button">
|
||||||
type="button"
|
<LinkIcon className="w-5 h-5 group-hover:text-gray-600" />
|
||||||
/>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownMenuTrigger className="group w-10 h-10 p-0 border border-transparent text-neutral-400 hover:border-gray-200">
|
<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" />
|
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
|
{isAdmin && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Button
|
<Link href={"/settings/teams/" + team.id}>
|
||||||
type="button"
|
<a>
|
||||||
color="minimal"
|
<Button type="button" color="minimal" className="w-full" StartIcon={PencilIcon}>
|
||||||
className="w-full"
|
|
||||||
onClick={() => props.onActionSelect("edit")}
|
|
||||||
StartIcon={PencilAltIcon}>
|
|
||||||
{" "}
|
|
||||||
{t("edit_team")}
|
{t("edit_team")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<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">
|
<a target="_blank">
|
||||||
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
|
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
|
||||||
{" "}
|
{" "}
|
||||||
|
@ -140,6 +140,7 @@ export default function TeamListItem(props: {
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{isOwner && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
@ -162,12 +163,38 @@ export default function TeamListItem(props: {
|
||||||
</ConfirmationDialogContent>
|
</ConfirmationDialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</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 { ArrowLeftIcon } from "@heroicons/react/solid";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { TeamPageProps } from "pages/team/[slug]";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
@ -10,10 +11,14 @@ import Avatar from "@components/ui/Avatar";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
import Text from "@components/ui/Text";
|
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 { t } = useLocale();
|
||||||
|
|
||||||
const Member = ({ member }) => {
|
const Member = ({ member }: { member: MemberType }) => {
|
||||||
const classes = classnames(
|
const classes = classnames(
|
||||||
"group",
|
"group",
|
||||||
"relative",
|
"relative",
|
||||||
|
@ -29,7 +34,7 @@ const Team = ({ team }) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={member.id} href={`/${member.user.username}`}>
|
<Link key={member.id} href={`/${member.username}`}>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<ArrowRightIcon
|
<ArrowRightIcon
|
||||||
className={classnames(
|
className={classnames(
|
||||||
|
@ -42,11 +47,11 @@ const Team = ({ team }) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<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">
|
<section className="space-y-2">
|
||||||
<Text variant="title">{member.user.name}</Text>
|
<Text variant="title">{member.name}</Text>
|
||||||
<Text variant="subtitle" className="w-6/8 max-w-md">
|
<Text variant="subtitle" className="w-6/8">
|
||||||
{member.user.bio}
|
{member.bio}
|
||||||
</Text>
|
</Text>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
@ -55,15 +60,15 @@ const Team = ({ team }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Members = ({ members }) => {
|
const Members = ({ members }: { members: MembersType }) => {
|
||||||
if (!members || members.length === 0) {
|
if (!members || members.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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) => {
|
{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>
|
</section>
|
||||||
);
|
);
|
||||||
|
@ -73,7 +78,7 @@ const Team = ({ team }) => {
|
||||||
<div>
|
<div>
|
||||||
<Members members={team.members} />
|
<Members members={team.members} />
|
||||||
{team.eventTypes.length > 0 && (
|
{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}>
|
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
|
||||||
{t("go_back")}
|
{t("go_back")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -90,12 +90,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
||||||
},
|
},
|
||||||
<>
|
<>
|
||||||
{StartIcon && (
|
{StartIcon && (
|
||||||
<StartIcon
|
<StartIcon className={classNames("inline", size === "icon" ? "w-5 h-5 " : "w-5 h-5 mr-2 -ml-1")} />
|
||||||
className={classNames(
|
|
||||||
"inline",
|
|
||||||
size === "icon" ? "w-5 h-5 group-hover:text-black" : "w-5 h-5 mr-2 -ml-1"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{props.children}
|
{props.children}
|
||||||
{loading && (
|
{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";
|
import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||||
label: ReactNode;
|
label?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
|
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div className="block sm:flex">
|
<div className="block sm:flex">
|
||||||
|
{!!label && (
|
||||||
<div className="mb-4 min-w-48 sm:mb-0">
|
<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 htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="relative rounded-sm shadow-sm">
|
<div className="relative rounded-sm shadow-sm">
|
||||||
<input
|
<input
|
||||||
{...rest}
|
{...rest}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="number"
|
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">
|
<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">
|
<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",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.0",
|
"qrcode": "^1.5.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
|
"react-date-picker": "^8.3.6",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-easy-crop": "^3.5.2",
|
"react-easy-crop": "^3.5.2",
|
||||||
"react-hook-form": "^7.20.2",
|
"react-hook-form": "^7.20.2",
|
||||||
|
@ -93,6 +94,8 @@
|
||||||
"react-select": "^5.2.1",
|
"react-select": "^5.2.1",
|
||||||
"react-timezone-select": "^1.1.15",
|
"react-timezone-select": "^1.1.15",
|
||||||
"react-use-intercom": "1.4.0",
|
"react-use-intercom": "1.4.0",
|
||||||
|
"react-virtualized-auto-sizer": "^1.0.6",
|
||||||
|
"react-window": "^1.8.6",
|
||||||
"short-uuid": "^4.2.0",
|
"short-uuid": "^4.2.0",
|
||||||
"stripe": "^8.191.0",
|
"stripe": "^8.191.0",
|
||||||
"superjson": "1.8.0",
|
"superjson": "1.8.0",
|
||||||
|
@ -115,6 +118,8 @@
|
||||||
"@types/qrcode": "^1.4.1",
|
"@types/qrcode": "^1.4.1",
|
||||||
"@types/react": "^17.0.37",
|
"@types/react": "^17.0.37",
|
||||||
"@types/react-phone-number-input": "^3.0.13",
|
"@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/stripe": "^8.0.417",
|
||||||
"@types/uuid": "8.3.1",
|
"@types/uuid": "8.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
import slugify from "@lib/slugify";
|
import slugify from "@lib/slugify";
|
||||||
|
|
||||||
import prisma from "../../lib/prisma";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
|
|
||||||
if (!session) {
|
if (!session?.user?.id) {
|
||||||
res.status(401).json({ message: "Not authenticated" });
|
res.status(401).json({ message: "Not authenticated" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -23,9 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nameCollisions > 0) {
|
if (nameCollisions > 0) {
|
||||||
return res
|
return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already taken." });
|
||||||
.status(409)
|
|
||||||
.json({ errorCode: "TeamNameCollision", message: "Team username already taken." });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createTeam = await prisma.team.create({
|
const createTeam = await prisma.team.create({
|
||||||
|
|
|
@ -2,24 +2,35 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { getTeamWithMembers } from "@lib/queries/teams";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return res.status(401).json({ message: "Not authenticated" });
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/teams/{team}
|
|
||||||
if (req.method === "DELETE") {
|
|
||||||
if (!session.user?.id) {
|
if (!session.user?.id) {
|
||||||
console.log("Received session token without a user id.");
|
console.log("Received session token without a user id.");
|
||||||
return res.status(500).json({ message: "Something went wrong." });
|
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") {
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id,
|
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({
|
await prisma.membership.delete({
|
||||||
where: {
|
where: {
|
||||||
userId_teamId: { userId: session.user.id, teamId: parseInt(req.query.team) },
|
userId_teamId: { userId: session.user.id, teamId },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.team.delete({
|
await prisma.team.delete({
|
||||||
where: {
|
where: {
|
||||||
id: parseInt(req.query.team),
|
id: teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return res.status(204).send(null);
|
return res.status(204).send(null);
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
import prisma from "../../../../lib/prisma";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req });
|
const session = await getSession({ req });
|
||||||
|
@ -14,7 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
const isTeamOwner = !!(await prisma.membership.findFirst({
|
const isTeamOwner = !!(await prisma.membership.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id,
|
userId: session.user?.id,
|
||||||
teamId: parseInt(req.query.team as string),
|
teamId: parseInt(req.query.team as string),
|
||||||
role: "OWNER",
|
role: "OWNER",
|
||||||
},
|
},
|
||||||
|
@ -54,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
const membership = memberships.find((membership) => member.id === membership.userId);
|
const membership = memberships.find((membership) => member.id === membership.userId);
|
||||||
return {
|
return {
|
||||||
...member,
|
...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") {
|
if (req.method === "DELETE") {
|
||||||
await prisma.membership.delete({
|
await prisma.membership.delete({
|
||||||
where: {
|
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);
|
return res.status(204).send(null);
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { getSession } from "next-auth/client";
|
|
||||||
|
|
||||||
|
import { getSession } from "@lib/auth";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
|
// @deprecated - USE TRPC
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req });
|
||||||
if (!session) {
|
if (!session?.user?.id) {
|
||||||
return res.status(401).json({ message: "Not authenticated" });
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
import prisma from "../../../lib/prisma";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
if (!session) {
|
if (!session || !session.user?.id) {
|
||||||
return res.status(401).json({ message: "Not authenticated" });
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,8 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { ComponentProps, FormEvent, RefObject, useEffect, useRef, useState, useMemo } from "react";
|
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Select, { OptionTypeBase } from "react-select";
|
import Select from "react-select";
|
||||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
@ -21,11 +21,11 @@ import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
|
||||||
import ImageUploader from "@components/ImageUploader";
|
import ImageUploader from "@components/ImageUploader";
|
||||||
import SettingsShell from "@components/SettingsShell";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
|
import { TextField } from "@components/form/fields";
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
import { UsernameInput } from "@components/ui/UsernameInput";
|
|
||||||
|
|
||||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
|
||||||
ref={props.hideBrandingRef}
|
ref={props.hideBrandingRef}
|
||||||
defaultChecked={isBrandingHidden(props.user)}
|
defaultChecked={isBrandingHidden(props.user)}
|
||||||
className={
|
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) => {
|
onClick={(e) => {
|
||||||
if (!e.currentTarget.checked || props.user.plan !== "FREE") {
|
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: "light", label: t("light") },
|
||||||
{ value: "dark", label: t("dark") },
|
{ value: "dark", label: t("dark") },
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const usernameRef = useRef<HTMLInputElement>(null!);
|
const usernameRef = useRef<HTMLInputElement>(null!);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const nameRef = useRef<HTMLInputElement>(null!);
|
const nameRef = useRef<HTMLInputElement>(null!);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
|
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const brandColorRef = useRef<HTMLInputElement>(null!);
|
const brandColorRef = useRef<HTMLInputElement>(null!);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const hideBrandingRef = useRef<HTMLInputElement>(null!);
|
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 [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(props.user.timeZone);
|
||||||
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState<OptionTypeBase>({
|
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({
|
||||||
value: props.user.weekStart,
|
value: props.user.weekStart,
|
||||||
label: nameOfDay(props.localeProp, props.user.weekStart === "Sunday" ? 0 : 1),
|
label: nameOfDay(props.localeProp, props.user.weekStart === "Sunday" ? 0 : 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState<OptionTypeBase>({
|
const [selectedLanguage, setSelectedLanguage] = useState({
|
||||||
value: props.localeProp,
|
value: props.localeProp || "",
|
||||||
label: localeOptions.find((option) => option.value === props.localeProp)?.label,
|
label: localeOptions.find((option) => option.value === props.localeProp)?.label || "",
|
||||||
});
|
});
|
||||||
const [imageSrc, setImageSrc] = useState<string>(props.user.avatar || "");
|
const [imageSrc, setImageSrc] = useState<string>(props.user.avatar || "");
|
||||||
const [hasErrors, setHasErrors] = useState(false);
|
const [hasErrors, setHasErrors] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTheme(
|
if (!props.user.theme) return;
|
||||||
props.user.theme ? themeOptions.find((theme) => theme.value === props.user.theme) : undefined
|
const userTheme = themeOptions.find((theme) => theme.value === props.user.theme);
|
||||||
);
|
if (!userTheme) return;
|
||||||
|
setSelectedTheme(userTheme);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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="flex-grow space-y-6">
|
||||||
<div className="block sm:flex">
|
<div className="block sm:flex">
|
||||||
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
|
<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>
|
||||||
<div className="w-full sm:w-1/2 sm:ml-2">
|
<div className="w-full sm:w-1/2 sm:ml-2">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
<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"
|
autoComplete="given-name"
|
||||||
placeholder={t("your_name")}
|
placeholder={t("your_name")}
|
||||||
required
|
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}
|
defaultValue={props.user.name || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -251,7 +255,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
placeholder={t("little_something_about")}
|
placeholder={t("little_something_about")}
|
||||||
rows={3}
|
rows={3}
|
||||||
defaultValue={props.user.bio || undefined}
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -268,9 +272,10 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
name="avatar"
|
name="avatar"
|
||||||
id="avatar"
|
id="avatar"
|
||||||
placeholder="URL"
|
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}
|
defaultValue={imageSrc}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center px-5">
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
target="avatar"
|
target="avatar"
|
||||||
id="avatar-upload"
|
id="avatar-upload"
|
||||||
|
@ -290,6 +295,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<hr className="mt-6" />
|
<hr className="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -300,9 +306,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
<Select
|
<Select
|
||||||
id="languageSelect"
|
id="languageSelect"
|
||||||
value={selectedLanguage || props.localeProp}
|
value={selectedLanguage || props.localeProp}
|
||||||
onChange={setSelectedLanguage}
|
onChange={(v) => v && setSelectedLanguage(v)}
|
||||||
classNamePrefix="react-select"
|
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}
|
options={localeOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -315,9 +321,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
<TimezoneSelect
|
<TimezoneSelect
|
||||||
id="timeZone"
|
id="timeZone"
|
||||||
value={selectedTimeZone}
|
value={selectedTimeZone}
|
||||||
onChange={setSelectedTimeZone}
|
onChange={(v) => v && setSelectedTimeZone(v)}
|
||||||
classNamePrefix="react-select"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
@ -329,9 +335,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
<Select
|
<Select
|
||||||
id="weekStart"
|
id="weekStart"
|
||||||
value={selectedWeekStartDay}
|
value={selectedWeekStartDay}
|
||||||
onChange={setSelectedWeekStartDay}
|
onChange={(v) => v && setSelectedWeekStartDay(v)}
|
||||||
classNamePrefix="react-select"
|
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={[
|
options={[
|
||||||
{ value: "Sunday", label: nameOfDay(props.localeProp, 0) },
|
{ value: "Sunday", label: nameOfDay(props.localeProp, 0) },
|
||||||
{ value: "Monday", label: nameOfDay(props.localeProp, 1) },
|
{ value: "Monday", label: nameOfDay(props.localeProp, 1) },
|
||||||
|
@ -349,8 +355,8 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
isDisabled={!selectedTheme}
|
isDisabled={!selectedTheme}
|
||||||
defaultValue={selectedTheme || themeOptions[0]}
|
defaultValue={selectedTheme || themeOptions[0]}
|
||||||
value={selectedTheme || themeOptions[0]}
|
value={selectedTheme || themeOptions[0]}
|
||||||
onChange={setSelectedTheme}
|
onChange={(v) => v && setSelectedTheme(v)}
|
||||||
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"
|
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}
|
options={themeOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -362,7 +368,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
|
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
|
||||||
checked={!selectedTheme}
|
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>
|
||||||
<div className="ml-3 text-sm">
|
<div className="ml-3 text-sm">
|
||||||
|
@ -383,7 +389,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
name="brandColor"
|
name="brandColor"
|
||||||
id="brandColor"
|
id="brandColor"
|
||||||
placeholder="#hex-code"
|
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}
|
defaultValue={props.user.brandColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,207 +1,54 @@
|
||||||
import { UsersIcon } from "@heroicons/react/outline";
|
|
||||||
import { PlusIcon } from "@heroicons/react/solid";
|
import { PlusIcon } from "@heroicons/react/solid";
|
||||||
import { useSession } from "next-auth/client";
|
import { useSession } from "next-auth/client";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { Member } from "@lib/member";
|
import { trpc } from "@lib/trpc";
|
||||||
import { Team } from "@lib/team";
|
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import SettingsShell from "@components/SettingsShell";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import EditTeam from "@components/team/EditTeam";
|
import TeamCreateModal from "@components/team/TeamCreateModal";
|
||||||
import TeamList from "@components/team/TeamList";
|
import TeamList from "@components/team/TeamList";
|
||||||
import TeamListItem from "@components/team/TeamListItem";
|
|
||||||
import { Alert } from "@components/ui/Alert";
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
export default function Teams() {
|
export default function Teams() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const noop = () => undefined;
|
|
||||||
const [, loading] = useSession();
|
const [, loading] = useSession();
|
||||||
const [teams, setTeams] = useState([]);
|
|
||||||
const [invites, setInvites] = useState([]);
|
|
||||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
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 [errorMessage, setErrorMessage] = useState("");
|
||||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
|
||||||
|
|
||||||
const handleErrors = async (resp: Response) => {
|
const { data } = trpc.useQuery(["viewer.teams.list"], {
|
||||||
if (!resp.ok) {
|
onError: (e) => {
|
||||||
const err = await resp.json();
|
setErrorMessage(e.message);
|
||||||
throw new Error(err.message);
|
|
||||||
}
|
|
||||||
return resp.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
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) => {
|
if (loading) return <Loader />;
|
||||||
setEditTeamEnabled(true);
|
|
||||||
setTeamToEdit(team);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCloseEdit = () => {
|
const teams = data?.filter((m) => m.accepted) || [];
|
||||||
loadData();
|
const invites = data?.filter((m) => !m.accepted) || [];
|
||||||
setEditTeamEnabled(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
<Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
||||||
<SettingsShell>
|
<SettingsShell>
|
||||||
{!editTeamEnabled && (
|
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
|
||||||
<div className="py-6 lg:pb-8">
|
{showCreateTeamModal && <TeamCreateModal onClose={() => setShowCreateTeamModal(false)} />}
|
||||||
<div className="flex flex-col justify-between md:flex-row">
|
<div className="flex justify-end my-4">
|
||||||
<div>
|
<Button type="button" className="btn btn-white" onClick={() => setShowCreateTeamModal(true)}>
|
||||||
{!(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" />
|
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
|
||||||
{t("new_team")}
|
{t("new_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{invites.length > 0 && (
|
||||||
<div>
|
|
||||||
{!!teams.length && (
|
|
||||||
<TeamList teams={teams} onChange={loadData} onEditTeam={editTeam}></TeamList>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!invites.length && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-medium leading-6 text-gray-900 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">
|
<div className="mb-4">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||||
{t("name")}
|
<TeamList teams={invites}></TeamList>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{teams.length > 0 && <TeamList teams={teams}></TeamList>}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
</Shell>
|
</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 { ArrowRightIcon } from "@heroicons/react/solid";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
@ -7,8 +6,8 @@ import React from "react";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
import { defaultAvatarSrc } from "@lib/profile";
|
import { defaultAvatarSrc } from "@lib/profile";
|
||||||
|
import { getTeamWithMembers } from "@lib/queries/teams";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||||
|
@ -17,8 +16,11 @@ import Team from "@components/team/screens/Team";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
import Button from "@components/ui/Button";
|
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 { isReady } = useTheme();
|
||||||
const showMembers = useToggleQuery("members");
|
const showMembers = useToggleQuery("members");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
@ -28,12 +30,12 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
||||||
{team.eventTypes.map((type) => (
|
{team.eventTypes.map((type) => (
|
||||||
<li
|
<li
|
||||||
key={type.id}
|
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">
|
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 transition-opacity h-4 w-4 right-3 top-3 text-black dark:text-white opacity-0 group-hover:opacity-100" />
|
<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}`}>
|
<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">
|
<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} />
|
<EventTypeDescription className="text-sm" eventType={type} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
|
@ -60,18 +62,14 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
||||||
isReady && (
|
isReady && (
|
||||||
<div>
|
<div>
|
||||||
<HeadSeo title={teamName} description={teamName} />
|
<HeadSeo title={teamName} description={teamName} />
|
||||||
<div className="h-screen bg-neutral-50 dark:bg-black">
|
<div className="px-4 pt-24 pb-12">
|
||||||
<main className="max-w-3xl px-4 py-24 mx-auto">
|
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Avatar alt={teamName} imageSrc={team.logo} className="mx-auto w-20 h-20 rounded-full mb-4" />
|
<Avatar alt={teamName} imageSrc={team.logo} className="w-20 h-20 mx-auto mb-4 rounded-full" />
|
||||||
<h1 className="mb-1 text-3xl font-bold font-cal text-neutral-900 dark:text-white">
|
<Text variant="headline">{teamName}</Text>
|
||||||
{teamName}
|
|
||||||
</h1>
|
|
||||||
<p className="text-neutral-500 dark:text-white">{team.bio}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="max-w-3xl mx-auto">
|
||||||
{eventTypes}
|
{eventTypes}
|
||||||
|
|
||||||
<div className="relative mt-12">
|
<div className="relative mt-12">
|
||||||
|
@ -79,13 +77,13 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
||||||
<div className="w-full border-t border-gray-200 dark:border-gray-900" />
|
<div className="w-full border-t border-gray-200 dark:border-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center">
|
<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">
|
<span className="px-2 text-sm text-gray-500 bg-gray-100 dark:bg-brand dark:text-gray-500">
|
||||||
{t("or")}
|
{t("or")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="text-center dark:text-white mt-8">
|
<aside className="mt-8 text-center dark:text-white">
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
EndIcon={ArrowRightIcon}
|
EndIcon={ArrowRightIcon}
|
||||||
|
@ -96,7 +94,6 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -106,54 +103,7 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||||
|
|
||||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
const team = await getTeamWithMembers(undefined, slug);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!team) return { notFound: true };
|
if (!team) return { notFound: true };
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "MembershipRole" ADD VALUE 'ADMIN';
|
|
@ -135,6 +135,7 @@ model Team {
|
||||||
|
|
||||||
enum MembershipRole {
|
enum MembershipRole {
|
||||||
MEMBER
|
MEMBER
|
||||||
|
ADMIN
|
||||||
OWNER
|
OWNER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -303,6 +303,7 @@
|
||||||
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
|
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
|
||||||
"edit_logo": "Edit logo",
|
"edit_logo": "Edit logo",
|
||||||
"upload_a_logo": "Upload a logo",
|
"upload_a_logo": "Upload a logo",
|
||||||
|
"remove_logo": "Remove logo",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"code": "Code",
|
"code": "Code",
|
||||||
"code_is_incorrect": "Code is incorrect.",
|
"code_is_incorrect": "Code is incorrect.",
|
||||||
|
@ -379,6 +380,7 @@
|
||||||
"email_or_username": "Email or Username",
|
"email_or_username": "Email or Username",
|
||||||
"send_invite_email": "Send an invite email",
|
"send_invite_email": "Send an invite email",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
|
"edit_role": "Edit Role",
|
||||||
"edit_team": "Edit team",
|
"edit_team": "Edit team",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
|
@ -394,10 +396,12 @@
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
"member": "Member",
|
"member": "Member",
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
|
"admin": "Admin",
|
||||||
"new_member": "New Member",
|
"new_member": "New Member",
|
||||||
"invite": "Invite",
|
"invite": "Invite",
|
||||||
"invite_new_member": "Invite a new member",
|
"invite_new_member": "Invite a new member",
|
||||||
"invite_new_team_member": "Invite someone to your team.",
|
"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": "Disable Cal.com branding",
|
||||||
"disable_cal_branding_description": "Hide all Cal.com branding from your public pages.",
|
"disable_cal_branding_description": "Hide all Cal.com branding from your public pages.",
|
||||||
"danger_zone": "Danger Zone",
|
"danger_zone": "Danger Zone",
|
||||||
|
@ -419,6 +423,10 @@
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"open_options": "Open options",
|
"open_options": "Open options",
|
||||||
"copy_link": "Copy link to event",
|
"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",
|
"preview": "Preview",
|
||||||
"link_copied": "Link copied!",
|
"link_copied": "Link copied!",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { createProtectedRouter, createRouter } from "../createRouter";
|
import { createProtectedRouter, createRouter } from "../createRouter";
|
||||||
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
import { resizeBase64Image } from "../lib/resizeBase64Image";
|
||||||
|
import { viewerTeamsRouter } from "./viewer/teams";
|
||||||
import { webhookRouter } from "./viewer/webhook";
|
import { webhookRouter } from "./viewer/webhook";
|
||||||
|
|
||||||
const checkUsername =
|
const checkUsername =
|
||||||
|
@ -630,4 +631,5 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
export const viewerRouter = createRouter()
|
export const viewerRouter = createRouter()
|
||||||
.merge(publicViewerRouter)
|
.merge(publicViewerRouter)
|
||||||
.merge(loggedInViewerRouter)
|
.merge(loggedInViewerRouter)
|
||||||
|
.merge("teams.", viewerTeamsRouter)
|
||||||
.merge("webhook.", webhookRouter);
|
.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;
|
background: #000;
|
||||||
color: #fff;
|
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:
|
dependencies:
|
||||||
regenerator-runtime "^0.13.4"
|
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"
|
version "7.16.3"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
|
||||||
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
|
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
|
||||||
|
@ -2111,6 +2111,20 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@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":
|
"@types/react@*", "@types/react@16 || 17":
|
||||||
version "17.0.34"
|
version "17.0.34"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102"
|
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"
|
inherits "^2.0.1"
|
||||||
safe-buffer "^5.1.2"
|
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"
|
version "5.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||||
|
@ -8340,6 +8354,22 @@ react-date-picker@^8.3.3:
|
||||||
react-fit "^1.0.3"
|
react-fit "^1.0.3"
|
||||||
update-input-width "^1.2.2"
|
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:
|
react-dom@^17.0.2:
|
||||||
version "17.0.2"
|
version "17.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
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"
|
resolved "https://registry.yarnpkg.com/react-use-intercom/-/react-use-intercom-1.4.0.tgz#796527728c131ebf132186385bf78f69dbcd84cc"
|
||||||
integrity sha512-HqPp7nRnftREE01i88w2kYWOV45zvJt0Of6jtHflIBa3eKl1bAs/izZUINGCJ0DOdgAdlbLweAvJlP4VTzsJjQ==
|
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:
|
react@^17.0.2:
|
||||||
version "17.0.2"
|
version "17.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||||
|
|
Loading…
Reference in a new issue