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:
49 files changed
+2295
-998
No files matched your search
@@ -110,7 +110,7 @@ export default function ImageUploader({
|
||||
(opened) => !opened && setFile(null) // unset file on close
|
||||
}>
|
||||
<DialogTrigger asChild>
|
||||
<div className="flex items-center px-3">
|
||||
<div className="flex items-center">
|
||||
<Button color="secondary" type="button" className="py-1 text-xs">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
|
||||
+32
-3
@@ -1,6 +1,7 @@
|
||||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ArrowLeftIcon,
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExternalLinkIcon,
|
||||
@@ -36,6 +37,7 @@ import Dropdown, {
|
||||
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
import Logo from "./Logo";
|
||||
import Button from "./ui/Button";
|
||||
|
||||
function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
@@ -118,6 +120,10 @@ export default function Shell(props: {
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
CTA?: ReactNode;
|
||||
HeadingLeftIcon?: ReactNode;
|
||||
showBackButton?: boolean;
|
||||
// use when content needs to expand with flex
|
||||
flexChildrenContainer?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -249,7 +255,11 @@ export default function Shell(props: {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 w-0 overflow-hidden">
|
||||
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]">
|
||||
<main
|
||||
className={classNames(
|
||||
"flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]",
|
||||
props.flexChildrenContainer && "flex flex-col"
|
||||
)}>
|
||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 md:hidden">
|
||||
<Link href="/event-types">
|
||||
@@ -269,8 +279,21 @@ export default function Shell(props: {
|
||||
<UserDropdown small />
|
||||
</div>
|
||||
</nav>
|
||||
<div className={classNames(props.centered && "md:max-w-5xl mx-auto", "py-8")}>
|
||||
<div
|
||||
className={classNames(
|
||||
props.centered && "md:max-w-5xl mx-auto",
|
||||
props.flexChildrenContainer && "flex flex-col flex-1",
|
||||
"py-8"
|
||||
)}>
|
||||
{props.showBackButton && (
|
||||
<div className="mx-3 mb-8 sm:mx-8">
|
||||
<Button onClick={() => router.back()} StartIcon={ArrowLeftIcon} color="secondary">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8 min-h-[80px]">
|
||||
{props.HeadingLeftIcon && <div className="mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="w-full mb-8">
|
||||
<h1 className="mb-1 text-xl font-bold tracking-wide text-gray-900 font-cal">
|
||||
{props.heading}
|
||||
@@ -279,7 +302,13 @@ export default function Shell(props: {
|
||||
</div>
|
||||
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
|
||||
</div>
|
||||
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"px-4 sm:px-6 md:px-8",
|
||||
props.flexChildrenContainer && "flex flex-col flex-1"
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="fixed bottom-0 flex w-full bg-white shadow bottom-nav md:hidden">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm",
|
||||
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-1 focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
@@ -54,9 +54,11 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
|
||||
} = props;
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
{!!props.name && (
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
{addOnLeading ? (
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
{addOnLeading}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
import showToast from "@lib/notification";
|
||||
import { Team } from "@lib/team";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import { UsernameInput } from "@components/ui/UsernameInput";
|
||||
import ErrorAlert from "@components/ui/alerts/Error";
|
||||
|
||||
import MemberList from "./MemberList";
|
||||
|
||||
export default function EditTeam(props: { team: Team | undefined | null; onCloseEdit: () => void }) {
|
||||
const [members, setMembers] = useState([]);
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
|
||||
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
|
||||
const loadMembers = () =>
|
||||
fetch("/api/teams/" + props.team?.id + "/membership")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMembers(data.members));
|
||||
|
||||
useEffect(() => {
|
||||
loadMembers();
|
||||
}, []);
|
||||
|
||||
const deleteTeam = () => {
|
||||
return fetch("/api/teams/" + props.team?.id, {
|
||||
method: "DELETE",
|
||||
}).then(props.onCloseEdit());
|
||||
};
|
||||
|
||||
const onRemoveMember = (member: Member) => {
|
||||
return fetch("/api/teams/" + props.team?.id + "/membership", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ userId: member.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(loadMembers);
|
||||
};
|
||||
|
||||
const onInviteMember = (team: Team | null | undefined) => {
|
||||
setShowMemberInvitationModal(true);
|
||||
setInviteModalTeam(team);
|
||||
};
|
||||
|
||||
const handleError = async (resp: Response) => {
|
||||
if (!resp.ok) {
|
||||
const error = await resp.json();
|
||||
throw new Error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
async function updateTeamHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const enteredUsername = teamUrlRef?.current?.value.toLowerCase();
|
||||
const enteredName = nameRef?.current?.value;
|
||||
const enteredDescription = descriptionRef?.current?.value;
|
||||
const enteredLogo = logoRef?.current?.value;
|
||||
const enteredHideBranding = hideBrandingRef?.current?.checked;
|
||||
|
||||
// TODO: Add validation
|
||||
|
||||
await fetch("/api/teams/" + props.team?.id + "/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
username: enteredUsername,
|
||||
name: enteredName,
|
||||
description: enteredDescription,
|
||||
logo: enteredLogo,
|
||||
hideBranding: enteredHideBranding,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleError)
|
||||
.then(() => {
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
setHasErrors(false); // dismiss any open errors
|
||||
})
|
||||
.catch((err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
const onMemberInvitationModalExit = () => {
|
||||
loadMembers();
|
||||
setShowMemberInvitationModal(false);
|
||||
};
|
||||
|
||||
const handleLogoChange = (newLogo: string) => {
|
||||
logoRef.current.value = newLogo;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement?.prototype, "value").set;
|
||||
nativeInputValueSetter?.call(logoRef.current, newLogo);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
logoRef?.current?.dispatchEvent(ev2);
|
||||
updateTeamHandler(ev2);
|
||||
setImageSrc(newLogo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => props.onCloseEdit()}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-5 pr-4 sm:pb-6">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900">{props.team?.name}</h3>
|
||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
||||
<p>{t("manage_your_team")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-2" />
|
||||
<h3 className="font-bold leading-6 text-gray-900 font-cal mt-7 text-md">{t("profile")}</h3>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateTeamHandler}>
|
||||
{hasErrors && <ErrorAlert message={errorMessage} />}
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="block sm:flex">
|
||||
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
|
||||
<UsernameInput
|
||||
ref={teamUrlRef}
|
||||
defaultValue={props.team?.slug}
|
||||
label={t("my_team_url")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2 sm:ml-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("team_name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder={t("your_team_name")}
|
||||
required
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
defaultValue={props.team?.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
|
||||
{t("about")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
id="about"
|
||||
name="about"
|
||||
rows={3}
|
||||
defaultValue={props.team?.bio}
|
||||
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex mt-1">
|
||||
<Avatar
|
||||
className="relative w-10 h-10 rounded-full"
|
||||
imageSrc={imageSrc ? imageSrc : props.team?.logo}
|
||||
displayName="Logo"
|
||||
/>
|
||||
<input
|
||||
ref={logoRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
defaultValue={imageSrc ?? props.team?.logo}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="logo"
|
||||
id="logo-upload"
|
||||
buttonMsg={imageSrc !== "" ? t("edit_logo") : t("upload_a_logo")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
imageSrc={imageSrc ?? props.team?.logo}
|
||||
/>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<div className="flex justify-between mt-7">
|
||||
<h3 className="font-bold leading-6 text-gray-900 font-cal text-md">{t("members")}</h3>
|
||||
<div className="relative flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={PlusIcon}
|
||||
onClick={() => onInviteMember(props.team)}>
|
||||
{t("new_member")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{!!members.length && (
|
||||
<MemberList members={members} onRemoveMember={onRemoveMember} onChange={loadMembers} />
|
||||
)}
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="hide-branding"
|
||||
name="hide-branding"
|
||||
type="checkbox"
|
||||
ref={hideBrandingRef}
|
||||
defaultChecked={props.team?.hideBranding}
|
||||
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">{t("danger_zone")}</h3>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" color="secondary" StartIcon={TrashIcon}>
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => deleteTeam()}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-8" />
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit" color="primary">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{showMemberInvitationModal && (
|
||||
<MemberInvitationModal team={inviteModalTeam} onExit={onMemberInvitationModalExit} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [role, setRole] = useState(props.initialRole || MembershipRole.MEMBER);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function changeRole(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
changeRoleMutation.mutate({
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContainer>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("change_member_role")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={changeRole}>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as MembershipRole)}
|
||||
id="role"
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
<option value="OWNER">{t("owner")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" color="primary" className="ml-2">
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</ModalContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,49 @@
|
||||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { UserIcon } from "@heroicons/react/outline";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Team } from "@lib/team";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
|
||||
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const handleError = async (res: Response) => {
|
||||
const responseData = await res.json();
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok === false) {
|
||||
setErrorMessage(responseData.message);
|
||||
throw new Error(responseData.message);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
};
|
||||
|
||||
const inviteMember = (e: SyntheticEvent) => {
|
||||
function inviteMember(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!props.team) return;
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
elements: {
|
||||
role: { value: string };
|
||||
role: { value: MembershipRole };
|
||||
inviteUser: { value: string };
|
||||
sendInviteEmail: { checked: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
const payload = {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
language: i18n.language,
|
||||
role: target.elements["role"].value,
|
||||
usernameOrEmail: target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||
};
|
||||
|
||||
return fetch("/api/teams/" + props?.team?.id + "/invite", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleError)
|
||||
.then(props.onExit)
|
||||
.catch(() => {
|
||||
// do nothing.
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -71,8 +62,8 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
|
||||
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="w-6 h-6 text-black" />
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-brand bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UserIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
@@ -106,6 +97,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
id="role"
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
<option value="OWNER">{t("owner")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import { Member } from "@lib/member";
|
||||
import { inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import MemberListItem from "./MemberListItem";
|
||||
|
||||
export default function MemberList(props: {
|
||||
members: Member[];
|
||||
onRemoveMember: (text: Member) => void;
|
||||
onChange: (text: string) => void;
|
||||
}) {
|
||||
const selectAction = (action: string, member: Member) => {
|
||||
switch (action) {
|
||||
case "remove":
|
||||
props.onRemoveMember(member);
|
||||
break;
|
||||
}
|
||||
};
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
members: inferQueryOutput<"viewer.teams.get">["members"];
|
||||
}
|
||||
|
||||
export default function MemberList(props: Props) {
|
||||
if (!props.members.length) return <></>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="px-6 mb-2 -mx-6 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
|
||||
{props.members.map((member) => (
|
||||
<MemberListItem
|
||||
onChange={props.onChange}
|
||||
key={member.id}
|
||||
member={member}
|
||||
onActionSelect={(action: string) => selectAction(action, member)}
|
||||
/>
|
||||
{props.members?.map((member) => (
|
||||
<MemberListItem key={member.id} member={member} team={props.team} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,104 +1,162 @@
|
||||
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamRole from "./TeamRole";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
export default function MemberListItem(props: {
|
||||
member: Member;
|
||||
onActionSelect: (text: string) => void;
|
||||
onChange: (text: string) => void;
|
||||
}) {
|
||||
const [member] = useState(props.member);
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
export default function MemberListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
|
||||
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const name =
|
||||
props.member.name ||
|
||||
(() => {
|
||||
const emailName = props.member.email.split("@")[0];
|
||||
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
|
||||
})();
|
||||
|
||||
const removeMember = () =>
|
||||
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
|
||||
|
||||
return (
|
||||
member && (
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex flex-col justify-between w-full sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={
|
||||
props.member.avatar
|
||||
? props.member.avatar
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||
encodeURIComponent(props.member.name || "")
|
||||
}
|
||||
alt={props.member.name || ""}
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{props.member.name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{props.member.role === "INVITEE" && (
|
||||
<>
|
||||
<span className="self-center h-6 px-3 py-1 mr-2 text-xs text-yellow-700 capitalize rounded-md bg-yellow-50">
|
||||
{t("pending")}
|
||||
</span>
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
|
||||
{t("member")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{props.member.role === "MEMBER" && (
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
|
||||
{t("member")}
|
||||
</span>
|
||||
)}
|
||||
{props.member.role === "OWNER" && (
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-blue-700 capitalize rounded-md bg-blue-50">
|
||||
{t("owner")}
|
||||
</span>
|
||||
)}
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex flex-col justify-between w-full sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, name)}
|
||||
alt={name || ""}
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{/* <div className="flex flex-col-reverse"> */}
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger>
|
||||
<DotsHorizontalIcon className="w-5 h-5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={UserRemoveIcon}
|
||||
className="w-full">
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={() => props.onActionSelect("remove")}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{/* </div> */}
|
||||
<div className="flex justify-center mr-2">
|
||||
{!props.member.accepted && <TeamRole invitePending />}
|
||||
<TeamRole role={props.member.role} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
<div className="flex">
|
||||
<Tooltip content={t("View user availability")}>
|
||||
<Button
|
||||
onClick={() => setShowTeamAvailabilityModal(true)}
|
||||
color="minimal"
|
||||
className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
|
||||
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
|
||||
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/" + props.member.username}>
|
||||
<a target="_blank">
|
||||
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full">
|
||||
{t("view_public_page")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{(props.team.membership.role === MembershipRole.OWNER ||
|
||||
props.team.membership.role === MembershipRole.ADMIN) && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
color="minimal"
|
||||
StartIcon={PencilIcon}
|
||||
className="flex-shrink-0 w-full">
|
||||
{t("edit_role")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={UserRemoveIcon}
|
||||
className="w-full">
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={removeMember}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
onExit={() => setShowChangeMemberRoleModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<ModalContainer wide noPadding>
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<div className="p-5 space-x-2 border-t">
|
||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<Button color="secondary">{t("Open Team Availability")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</ModalContainer>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TeamCreate(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
props.onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
createTeamMutation.mutate({ name: nameRef?.current?.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-sm shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="w-6 h-6 text-neutral-900" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("create_new_team")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={createTeam}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
required
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("create_team")}
|
||||
</button>
|
||||
<button onClick={props.onClose} type="button" className="mr-2 btn btn-white">
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,44 @@
|
||||
import { Team } from "@lib/team";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import TeamListItem from "./TeamListItem";
|
||||
|
||||
export default function TeamList(props: {
|
||||
teams: Team[];
|
||||
onChange: () => void;
|
||||
onEditTeam: (text: Team) => void;
|
||||
}) {
|
||||
const selectAction = (action: string, team: Team) => {
|
||||
interface Props {
|
||||
teams: inferQueryOutput<"viewer.teams.list">;
|
||||
}
|
||||
|
||||
export default function TeamList(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
function selectAction(action: string, teamId: number) {
|
||||
switch (action) {
|
||||
case "edit":
|
||||
props.onEditTeam(team);
|
||||
break;
|
||||
case "disband":
|
||||
deleteTeam(team);
|
||||
deleteTeam(teamId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const deleteTeam = async (team: Team) => {
|
||||
await fetch("/api/teams/" + team.id, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return props.onChange();
|
||||
};
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam(teamId: number) {
|
||||
deleteTeamMutation.mutate({ teamId });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
|
||||
{props.teams.map((team: Team) => (
|
||||
<ul className="mb-2 bg-white border divide-y rounded divide-neutral-200">
|
||||
{props.teams.map((team) => (
|
||||
<TeamListItem
|
||||
onChange={props.onChange}
|
||||
key={team.id}
|
||||
key={team?.id as number}
|
||||
team={team}
|
||||
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
|
||||
onActionSelect={(action: string) => selectAction(action, team?.id as number)}></TeamListItem>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+149
-122
@@ -1,137 +1,137 @@
|
||||
import {
|
||||
DotsHorizontalIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
PencilAltIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/ui/Dropdown";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
import TeamRole from "./TeamRole";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
interface Team {
|
||||
id: number;
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
logo: string | null;
|
||||
bio: string | null;
|
||||
role: string | null;
|
||||
hideBranding: boolean;
|
||||
prevState: null;
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.list">[number];
|
||||
key: number;
|
||||
onActionSelect: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function TeamListItem(props: {
|
||||
onChange: () => void;
|
||||
key: number;
|
||||
team: Team;
|
||||
onActionSelect: (text: string) => void;
|
||||
}) {
|
||||
const [team, setTeam] = useState<Team | null>(props.team);
|
||||
export default function TeamListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const team = props.team;
|
||||
|
||||
const acceptInvite = () => invitationResponse(true);
|
||||
const declineInvite = () => invitationResponse(false);
|
||||
|
||||
const invitationResponse = (accept: boolean) =>
|
||||
fetch("/api/user/membership", {
|
||||
method: accept ? "PATCH" : "DELETE",
|
||||
body: JSON.stringify({ teamId: props.team.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(() => {
|
||||
// success
|
||||
setTeam(null);
|
||||
props.onChange();
|
||||
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
});
|
||||
function acceptOrLeave(accept: boolean) {
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: team?.id as number,
|
||||
accept,
|
||||
});
|
||||
}
|
||||
const acceptInvite = () => acceptOrLeave(true);
|
||||
const declineInvite = () => acceptOrLeave(false);
|
||||
|
||||
const isOwner = props.team.role === MembershipRole.OWNER;
|
||||
const isInvitee = !props.team.accepted;
|
||||
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
|
||||
|
||||
if (!team) return <></>;
|
||||
|
||||
const teamInfo = (
|
||||
<div className="flex px-5 py-5">
|
||||
<Avatar
|
||||
size={9}
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="rounded-full w-9 h-9 min-w-9 min-h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
team && (
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
size={9}
|
||||
imageSrc={
|
||||
props.team.logo
|
||||
? props.team.logo
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||
encodeURIComponent(props.team.name || "")
|
||||
}
|
||||
alt="Team Logo"
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{props.team.name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{props.team.role === "INVITEE" && (
|
||||
<div>
|
||||
<li className="divide-y">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex justify-between items-center",
|
||||
!isInvitee && "group hover:bg-neutral-50"
|
||||
)}>
|
||||
{!isInvitee ? (
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<a className="flex-grow text-sm truncate cursor-pointer" title={`${team.name}`}>
|
||||
{teamInfo}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
teamInfo
|
||||
)}
|
||||
<div className="px-5 py-5">
|
||||
{isInvitee && (
|
||||
<>
|
||||
<Button type="button" color="secondary" onClick={declineInvite}>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
|
||||
<Button type="button" color="primary" className="ml-2" onClick={acceptInvite}>
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{props.team.role === "MEMBER" && (
|
||||
<div>
|
||||
<Button type="button" color="primary" onClick={declineInvite}>
|
||||
{t("leave")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{props.team.role === "OWNER" && (
|
||||
<div className="flex space-x-4">
|
||||
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
|
||||
{t("owner")}
|
||||
</span>
|
||||
<Tooltip content={t("copy_link")}>
|
||||
{!isInvitee && (
|
||||
<div className="flex space-x-2">
|
||||
<TeamRole role={team.role as MembershipRole} />
|
||||
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
|
||||
);
|
||||
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
className="w-10 h-10 transition-none"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={LinkIcon}
|
||||
type="button"
|
||||
/>
|
||||
type="button">
|
||||
<LinkIcon className="w-5 h-5 group-hover:text-gray-600" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="group w-10 h-10 p-0 border border-transparent text-neutral-400 hover:border-gray-200">
|
||||
<DotsHorizontalIcon className="w-5 h-5" />
|
||||
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 ">
|
||||
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<a>
|
||||
<Button type="button" color="minimal" className="w-full" StartIcon={PencilIcon}>
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
onClick={() => props.onActionSelect("edit")}
|
||||
StartIcon={PencilAltIcon}>
|
||||
{" "}
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team.slug}`} passHref={true}>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
|
||||
<a target="_blank">
|
||||
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
|
||||
{" "}
|
||||
@@ -140,34 +140,61 @@ export default function TeamListItem(props: {
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={TrashIcon}
|
||||
className="w-full">
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => props.onActionSelect("disband")}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
{isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={TrashIcon}
|
||||
className="w-full">
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => props.onActionSelect("disband")}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="warn"
|
||||
StartIcon={LogoutIcon}
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={declineInvite}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ClockIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, TrashIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import LinkIconButton from "@components/ui/LinkIconButton";
|
||||
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
// import Switch from "@components/ui/Switch";
|
||||
|
||||
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
|
||||
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`;
|
||||
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
router.push(`/settings/teams`);
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam() {
|
||||
if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id });
|
||||
}
|
||||
function leaveTeam() {
|
||||
if (props.team?.id)
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
accept: false,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 space-y-6">
|
||||
{/* <Switch
|
||||
name="isHidden"
|
||||
defaultChecked={hidden}
|
||||
onCheckedChange={setHidden}
|
||||
label={"Hide team from view"}
|
||||
/> */}
|
||||
<div className="space-y-1">
|
||||
<Link href={permalink} passHref={true}>
|
||||
<a target="_blank">
|
||||
<LinkIconButton Icon={ExternalLinkIcon}>{t("preview")}</LinkIconButton>
|
||||
</a>
|
||||
</Link>
|
||||
<LinkIconButton
|
||||
Icon={LinkIcon}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_link_team")}
|
||||
</LinkIconButton>
|
||||
{props.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
Icon={TrashIcon}>
|
||||
{t("disband_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
Icon={LogoutIcon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={leaveTeam}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
{/* TODO: Team availability */}
|
||||
{props.team?.id && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<div className="mt-5 space-y-1">
|
||||
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
|
||||
<p className="mt-2 text-sm text-gray-500">See your team members availability at a glance.</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/solid";
|
||||
import classnames from "classnames";
|
||||
import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
@@ -10,10 +11,14 @@ import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import Text from "@components/ui/Text";
|
||||
|
||||
const Team = ({ team }) => {
|
||||
type TeamType = TeamPageProps["team"];
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number];
|
||||
|
||||
const Team = ({ team }: TeamPageProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const Member = ({ member }) => {
|
||||
const Member = ({ member }: { member: MemberType }) => {
|
||||
const classes = classnames(
|
||||
"group",
|
||||
"relative",
|
||||
@@ -29,7 +34,7 @@ const Team = ({ team }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Link key={member.id} href={`/${member.user.username}`}>
|
||||
<Link key={member.id} href={`/${member.username}`}>
|
||||
<div className={classes}>
|
||||
<ArrowRightIcon
|
||||
className={classnames(
|
||||
@@ -42,11 +47,11 @@ const Team = ({ team }) => {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Avatar displayName={member.user.name} imageSrc={member.user.avatar} className="w-12 h-12" />
|
||||
<Avatar alt={member.name || ""} imageSrc={member.avatar} className="w-12 h-12" />
|
||||
<section className="space-y-2">
|
||||
<Text variant="title">{member.user.name}</Text>
|
||||
<Text variant="subtitle" className="w-6/8 max-w-md">
|
||||
{member.user.bio}
|
||||
<Text variant="title">{member.name}</Text>
|
||||
<Text variant="subtitle" className="w-6/8">
|
||||
{member.bio}
|
||||
</Text>
|
||||
</section>
|
||||
</div>
|
||||
@@ -55,15 +60,15 @@ const Team = ({ team }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Members = ({ members }) => {
|
||||
const Members = ({ members }: { members: MembersType }) => {
|
||||
if (!members || members.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto min-w-full lg:min-w-lg max-w-5xl flex flex-wrap gap-x-12 gap-y-6 justify-center">
|
||||
<section className="flex flex-wrap justify-center max-w-5xl min-w-full mx-auto lg:min-w-lg gap-x-12 gap-y-6">
|
||||
{members.map((member) => {
|
||||
return member.user.username !== null && <Member key={member.id} member={member} />;
|
||||
return member.username !== null && <Member key={member.id} member={member} />;
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
@@ -73,7 +78,7 @@ const Team = ({ team }) => {
|
||||
<div>
|
||||
<Members members={team.members} />
|
||||
{team.eventTypes.length > 0 && (
|
||||
<aside className="text-center dark:text-white mt-8">
|
||||
<aside className="mt-8 text-center dark:text-white">
|
||||
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
|
||||
@@ -90,12 +90,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
||||
},
|
||||
<>
|
||||
{StartIcon && (
|
||||
<StartIcon
|
||||
className={classNames(
|
||||
"inline",
|
||||
size === "icon" ? "w-5 h-5 group-hover:text-black" : "w-5 h-5 mr-2 -ml-1"
|
||||
)}
|
||||
/>
|
||||
<StartIcon className={classNames("inline", size === "icon" ? "w-5 h-5 " : "w-5 h-5 mr-2 -ml-1")} />
|
||||
)}
|
||||
{props.children}
|
||||
{loading && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CalendarIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
import "react-calendar/dist/Calendar.css";
|
||||
import "react-date-picker/dist/DatePicker.css";
|
||||
import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
type Props = {
|
||||
date: Date;
|
||||
onDatesChange?: ((date: Date) => void) | undefined;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
||||
return (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
"p-1 pl-2 border border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
|
||||
className
|
||||
)}
|
||||
clearIcon={null}
|
||||
calendarIcon={<CalendarIcon className="w-5 h-5 text-gray-500" />}
|
||||
value={date}
|
||||
onChange={onDatesChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,30 @@
|
||||
import classNames from "classnames";
|
||||
import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: ReactNode;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
|
||||
return (
|
||||
<div className="block sm:flex">
|
||||
<div className="mb-4 min-w-48 sm:mb-0">
|
||||
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
{!!label && (
|
||||
<div className="mb-4 min-w-48 sm:mb-0">
|
||||
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="relative rounded-sm shadow-sm">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="number"
|
||||
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className={classNames(
|
||||
"block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
|
||||
rest.className
|
||||
)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm" id="duration">
|
||||
|
||||
Reference in new issue
Block a user