feat: orgs - user management v2
figma: https://www.figma.com/file/ZJNAFHCAq6rnuGRgUFxjzQ/Organizations----New-features?type=design&node-id=4304-330348&mode=design&t=nKonHZpucwxnqwQI-4 (Not all features are in this PR) loom: https://www.loom.com/share/30e8ef20a5b34a708dca160d8c04ae6f shoutout @shadcn for the base of this table <3 Member View:  Admin view:  How to test - Create ORG - add members - view settings -> orgs -> members
This commit is contained in:
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { z } from "zod";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
@@ -17,14 +17,23 @@ import {
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
|
||||
import type { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
|
||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip, Alert } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
CheckboxField,
|
||||
Label,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Alert,
|
||||
} from "@calcom/ui";
|
||||
import { Edit, Copy } from "@calcom/ui/components/icon";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
@@ -342,7 +351,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
description={t("show_attendees")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AnimatedPopover } from "@calcom/ui";
|
||||
import { Checkbox } from "@calcom/ui";
|
||||
import { CheckboxField } from "@calcom/ui";
|
||||
|
||||
import { groupBy } from "../groupBy";
|
||||
import { useFilterQuery } from "../lib/useFilterQuery";
|
||||
@@ -73,7 +73,7 @@ export const EventTypeFilter = () => {
|
||||
</div>
|
||||
{groupedEventTypes[teamName].map((eventType) => (
|
||||
<div key={eventType.id} className="flex items-center px-4 py-1.5">
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
checked={query.eventTypeIds?.includes(eventType.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
|
||||
@@ -1,133 +1,17 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
import MemberListItem from "@calcom/features/ee/teams/components/MemberListItem";
|
||||
import TeamInviteList from "@calcom/features/ee/teams/components/TeamInviteList";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button, Meta, TextField, showToast } from "@calcom/ui";
|
||||
import { Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
type Team = RouterOutputs["viewer"]["teams"]["get"];
|
||||
|
||||
interface MembersListProps {
|
||||
team: Team | undefined;
|
||||
}
|
||||
|
||||
const checkIfExist = (comp: string, query: string) =>
|
||||
comp.toLowerCase().replace(/\s+/g, "").includes(query.toLowerCase().replace(/\s+/g, ""));
|
||||
|
||||
function MembersList(props: MembersListProps) {
|
||||
const { team } = props;
|
||||
const { t } = useLocale();
|
||||
const [query, setQuery] = useState<string>("");
|
||||
|
||||
const members = team?.members;
|
||||
const membersList = members
|
||||
? members && query === ""
|
||||
? members
|
||||
: members.filter((member) => {
|
||||
const email = member.email ? checkIfExist(member.email, query) : false;
|
||||
const username = member.username ? checkIfExist(member.username, query) : false;
|
||||
const name = member.name ? checkIfExist(member.name, query) : false;
|
||||
|
||||
return email || username || name;
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<TextField
|
||||
type="search"
|
||||
autoComplete="false"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={query}
|
||||
placeholder={`${t("search")}...`}
|
||||
/>
|
||||
{membersList?.length && team ? (
|
||||
<ul className="divide-subtle border-subtle divide-y rounded-md border ">
|
||||
{membersList.map((member) => {
|
||||
return <MemberListItem key={member.id} team={team} member={member} />;
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
const MembersView = () => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const showDialog = router.query.inviteModal === "true";
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
|
||||
const { data: team, isLoading } = trpc.viewer.organizations.listMembers.useQuery(undefined, {
|
||||
onError: () => {
|
||||
router.push("/settings");
|
||||
},
|
||||
});
|
||||
|
||||
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
|
||||
async onSuccess(data) {
|
||||
await utils.viewer.organizations.listMembers.invalidate();
|
||||
setShowMemberInvitationModal(false);
|
||||
if (data.sendEmailInvitation) {
|
||||
if (Array.isArray(data.usernameOrEmail)) {
|
||||
showToast(
|
||||
t("email_invite_team_bulk", {
|
||||
userCount: data.usernameOrEmail.length,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t("email_invite_team", {
|
||||
email: data.usernameOrEmail,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const isInviteOpen = !team?.membership.accepted;
|
||||
|
||||
const isAdminOrOwner =
|
||||
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Meta
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
CTA={
|
||||
isAdminOrOwner ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon={Plus}
|
||||
className="ml-auto"
|
||||
onClick={() => setShowMemberInvitationModal(true)}
|
||||
data-testid="new-organization-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div>
|
||||
{team && (
|
||||
<Meta title={t("organization_members")} description={t("organization_description")} />
|
||||
<div>
|
||||
{/* {team && (
|
||||
<>
|
||||
{isInviteOpen && (
|
||||
<TeamInviteList
|
||||
@@ -144,30 +28,9 @@ const MembersView = () => {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<MembersList team={team} />
|
||||
</div>
|
||||
{showMemberInvitationModal && team && (
|
||||
<MemberInvitationModal
|
||||
teamId={team.id}
|
||||
isOpen={showMemberInvitationModal}
|
||||
members={team.members}
|
||||
onExit={() => setShowMemberInvitationModal(false)}
|
||||
isLoading={inviteMemberMutation.isLoading}
|
||||
onSubmit={(values) => {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: team.id,
|
||||
language: i18n.language,
|
||||
role: values.role,
|
||||
usernameOrEmail: values.emailOrUsername,
|
||||
sendEmailInvitation: values.sendInviteEmail,
|
||||
isOrg: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)} */}
|
||||
<UserListTable />
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store
|
||||
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Checkbox } from "@calcom/ui";
|
||||
import { Button, CheckboxField } from "@calcom/ui";
|
||||
|
||||
import type { EventType } from ".prisma/client";
|
||||
|
||||
@@ -101,7 +101,7 @@ const PaymentForm = (props: Props) => {
|
||||
</div>
|
||||
{paymentOption === "HOLD" && (
|
||||
<div className="bg-info mb-5 mt-2 rounded-md p-3">
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
description={t("acknowledge_booking_no_show_fee", {
|
||||
amount: props.payment.amount / 100,
|
||||
formatParams: { amount: { currency: props.payment.currency } },
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function MemberChangeRoleModal(props: {
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter showDivider className="mt-12 sm:flex sm:flex-row-reverse">
|
||||
<DialogFooter className="mt-12 sm:flex sm:flex-row-reverse">
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import {
|
||||
Button,
|
||||
Checkbox as CheckboxField,
|
||||
CheckboxField,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WorkflowActions } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
CheckboxField,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -239,7 +239,7 @@ export const AddActionDialog = (props: IAddActionDialog) => {
|
||||
name="numberRequired"
|
||||
control={form.control}
|
||||
render={() => (
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
defaultChecked={form.getValues("numberRequired") || false}
|
||||
description={t("make_phone_number_required")}
|
||||
onChange={(e) => form.setValue("numberRequired", e.target.checked)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
CheckboxField,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -657,7 +657,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
name={`steps.${step.stepNumber - 1}.numberRequired`}
|
||||
control={form.control}
|
||||
render={() => (
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
disabled={props.readOnly}
|
||||
defaultChecked={
|
||||
form.getValues(`steps.${step.stepNumber - 1}.numberRequired`) || false
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Dialog, DialogContent, Form, DialogFooter, DialogClose, Button, Checkbox } from "@calcom/ui";
|
||||
import { Dialog, DialogContent, Form, DialogFooter, DialogClose, Button, CheckboxField } from "@calcom/ui";
|
||||
|
||||
export const BulkUpdateEventSchema = z.object({
|
||||
eventTypeIds: z.array(z.number()),
|
||||
@@ -47,7 +47,7 @@ export function BulkEditDefaultConferencingModal(props: { open: boolean; setOpen
|
||||
<div className="flex flex-col space-y-2">
|
||||
{data.eventTypes.length > 0 && (
|
||||
<div className="flex items-center space-x-2 rounded-md px-3 pb-2.5 pt-1">
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
description={t("select_all")}
|
||||
descriptionAsLabel
|
||||
onChange={(e) => {
|
||||
@@ -59,7 +59,7 @@ export function BulkEditDefaultConferencingModal(props: { open: boolean; setOpen
|
||||
)}
|
||||
{data.eventTypes.map((eventType) => (
|
||||
<div key={eventType.id} className="bg-muted flex items-center space-x-2 rounded-md px-3 py-2.5">
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
description={eventType.title}
|
||||
descriptionAsLabel
|
||||
checked={eventTypesSelected.includes(eventType.id)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
RadioField,
|
||||
EmailField,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
CheckboxField,
|
||||
} from "@calcom/ui";
|
||||
import { UserPlus, X } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -370,7 +370,7 @@ export const Components: Record<BookingFieldType, Component> = {
|
||||
factory: ({ readOnly, label, value, setValue }) => {
|
||||
return (
|
||||
<div className="flex">
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setValue(true);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FilterCheckboxFieldsContainer } from "@calcom/features/filters/componen
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { AnimatedPopover, Checkbox } from "@calcom/ui";
|
||||
import { AnimatedPopover, CheckboxField } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
@@ -49,7 +49,7 @@ export const EventTypeList = memo(() => {
|
||||
<FilterCheckboxFieldsContainer>
|
||||
{filterOptions?.map((eventType) => (
|
||||
<div key={eventType.value} className="item-center hover:bg-muted flex cursor-pointer px-4 py-2">
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
checked={eventTypeValue?.value === eventType.value}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
"@calcom/trpc": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@lexical/react": "^0.9.0",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"dompurify": "^2.4.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lexical": "^0.9.0",
|
||||
"react-select": "^5.7.0",
|
||||
"react-sticky-box": "^2.0.4",
|
||||
"react-virtual": "^2.10.4",
|
||||
"zustand": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
Select,
|
||||
SkeletonText,
|
||||
Switch,
|
||||
Checkbox,
|
||||
CheckboxField,
|
||||
} from "@calcom/ui";
|
||||
import { Copy, Plus, Trash } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -447,7 +447,7 @@ const CopyTimes = ({
|
||||
<li key="select all">
|
||||
<label className="text-default flex w-full items-center justify-between">
|
||||
<span className="px-1">{t("select_all")}</span>
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
description=""
|
||||
value={t("select_all")}
|
||||
checked={selected.length === 7}
|
||||
@@ -472,7 +472,7 @@ const CopyTimes = ({
|
||||
<li key={weekday}>
|
||||
<label className="text-default flex w-full items-center justify-between">
|
||||
<span className="px-1">{weekday}</span>
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
description=""
|
||||
value={weekdayIndex}
|
||||
checked={selected.includes(weekdayIndex) || disabled === weekdayIndex}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
|
||||
import { bookerLayoutOptions, type BookerLayoutSettings } from "@calcom/prisma/zod-utils";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Label, Checkbox, Button } from "@calcom/ui";
|
||||
import { Label, CheckboxField, Button } from "@calcom/ui";
|
||||
|
||||
type BookerLayoutSelectorProps = {
|
||||
title?: string;
|
||||
@@ -156,7 +156,7 @@ const BookerLayoutFields = ({ settings, onChange, showUserSettings, isDark }: Bo
|
||||
src={`/bookerlayout_${layout}${isDark ? "_dark" : ""}.svg`}
|
||||
alt="Layout preview"
|
||||
/>
|
||||
<Checkbox
|
||||
<CheckboxField
|
||||
value={layout}
|
||||
name={`bookerlayout_${layout}`}
|
||||
description={t(`bookerlayout_${layout}`)}
|
||||
|
||||
@@ -487,7 +487,7 @@ export default function SettingsLayout({
|
||||
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
|
||||
}>
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<div className="mx-auto max-w-full justify-center md:max-w-3xl">
|
||||
<div className="mx-auto max-w-full justify-center md:max-w-4xl">
|
||||
<ShellHeader />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>{children}</Suspense>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { Dispatch } from "react";
|
||||
|
||||
import MemberChangeRoleModal from "@calcom/features/ee/teams/components/MemberChangeRoleModal";
|
||||
|
||||
import type { Action, State } from "./UserListTable";
|
||||
|
||||
export function ChangeUserRoleModal(props: { state: State; dispatch: Dispatch<Action> }) {
|
||||
const { data: session } = useSession();
|
||||
const orgId = session?.user.organizationId;
|
||||
if (!orgId || !props.state.changeMemberRole.user) return null;
|
||||
|
||||
return (
|
||||
<MemberChangeRoleModal
|
||||
isOpen={true}
|
||||
currentMember={props.state.changeMemberRole.user?.role}
|
||||
teamId={orgId}
|
||||
memberId={props.state.changeMemberRole.user?.id}
|
||||
initialRole={props.state.changeMemberRole.user?.role}
|
||||
onExit={() =>
|
||||
props.dispatch({
|
||||
type: "CLOSE_MODAL",
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { Dispatch } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Dialog, ConfirmationDialogContent, showToast } from "@calcom/ui";
|
||||
|
||||
import type { State, Action } from "./UserListTable";
|
||||
|
||||
export function DeleteMemberModal({ state, dispatch }: { state: State; dispatch: Dispatch<Action> }) {
|
||||
const { t } = useLocale();
|
||||
const { data: session } = useSession();
|
||||
const utils = trpc.useContext();
|
||||
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
|
||||
onSuccess() {
|
||||
// We don't need to wait for invalidate to finish
|
||||
Promise.all([
|
||||
utils.viewer.teams.get.invalidate(),
|
||||
utils.viewer.eventTypes.invalidate(),
|
||||
utils.viewer.organizations.listMembers.invalidate(),
|
||||
]);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Dialog
|
||||
open={state.deleteMember.showModal}
|
||||
onOpenChange={(open) =>
|
||||
!open &&
|
||||
dispatch({
|
||||
type: "CLOSE_MODAL",
|
||||
})
|
||||
}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={() => {
|
||||
// Shouldnt ever happen just for type safety
|
||||
if (!session?.user.organizationId || !state?.deleteMember?.user?.id) return;
|
||||
|
||||
removeMemberMutation.mutate({
|
||||
teamId: session?.user.organizationId,
|
||||
memberId: state?.deleteMember?.user.id,
|
||||
isOrg: true,
|
||||
});
|
||||
}}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import type { Dispatch } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui";
|
||||
|
||||
import type { Action, State } from "./UserListTable";
|
||||
|
||||
export function ImpersonationMemberModal(props: { state: State; dispatch: Dispatch<Action> }) {
|
||||
const { t } = useLocale();
|
||||
const { data: session } = useSession();
|
||||
const teamId = session?.user.organizationId;
|
||||
const user = props.state.impersonateMember.user;
|
||||
|
||||
if (!user || !teamId) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={() =>
|
||||
props.dispatch({
|
||||
type: "CLOSE_MODAL",
|
||||
})
|
||||
}>
|
||||
<DialogContent type="creation" title={t("impersonate")} description={t("impersonation_user_tip")}>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("impersonation-auth", {
|
||||
username: user.username || user.email,
|
||||
teamId: teamId,
|
||||
});
|
||||
props.dispatch({
|
||||
type: "CLOSE_MODAL",
|
||||
});
|
||||
}}>
|
||||
<DialogFooter showDivider className="mt-8">
|
||||
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||
<Button color="primary" type="submit">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { Dispatch } from "react";
|
||||
|
||||
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import type { Action } from "./UserListTable";
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch<Action>;
|
||||
}
|
||||
|
||||
export function InviteMemberModal(props: Props) {
|
||||
const { data: session } = useSession();
|
||||
const utils = trpc.useContext();
|
||||
const { t, i18n } = useLocale();
|
||||
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
|
||||
async onSuccess(data) {
|
||||
props.dispatch({ type: "CLOSE_MODAL" });
|
||||
// Need to figure out if invalidating here is the right approach - we could have already
|
||||
// loaded a bunch of data and idk how pagination works with invalidation. We may need to use
|
||||
// Optimistic updates here instead.
|
||||
await utils.viewer.organizations.listMembers.invalidate();
|
||||
if (data.sendEmailInvitation) {
|
||||
if (Array.isArray(data.usernameOrEmail)) {
|
||||
showToast(
|
||||
t("email_invite_team_bulk", {
|
||||
userCount: data.usernameOrEmail.length,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t("email_invite_team", {
|
||||
email: data.usernameOrEmail,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
if (!session?.user.organizationId) return null;
|
||||
|
||||
const orgId = session.user.organizationId;
|
||||
|
||||
return (
|
||||
<MemberInvitationModal
|
||||
members={[]}
|
||||
isOpen={true}
|
||||
onExit={() => {
|
||||
props.dispatch({
|
||||
type: "CLOSE_MODAL",
|
||||
});
|
||||
}}
|
||||
teamId={orgId}
|
||||
isLoading={inviteMemberMutation.isLoading}
|
||||
onSubmit={(values) => {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: orgId,
|
||||
language: i18n.language,
|
||||
role: values.role,
|
||||
usernameOrEmail: values.emailOrUsername,
|
||||
sendEmailInvitation: values.sendInviteEmail,
|
||||
isOrg: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Plus, StopCircle, Users } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useMemo, useRef, useCallback, useEffect, useReducer } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Avatar, Badge, Button, DataTable } from "@calcom/ui";
|
||||
|
||||
import { useOrgBrandingValues } from "../../../ee/organizations/hooks";
|
||||
import { ChangeUserRoleModal } from "./ChangeUserRoleModal";
|
||||
import { DeleteMemberModal } from "./DeleteMemberModal";
|
||||
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
|
||||
import { InviteMemberModal } from "./InviteMemberModal";
|
||||
import { TableActions } from "./UserTableActions";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string | null;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
role: MembershipRole;
|
||||
accepted: boolean;
|
||||
disableImpersonation: boolean;
|
||||
completedOnboarding: boolean;
|
||||
teams: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
type Payload = {
|
||||
showModal: boolean;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export type State = {
|
||||
changeMemberRole: Payload;
|
||||
deleteMember: Payload;
|
||||
impersonateMember: Payload;
|
||||
inviteMember: Payload;
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type: "SET_CHANGE_MEMBER_ROLE_ID" | "SET_DELETE_ID" | "SET_IMPERSONATE_ID" | "INVITE_MEMBER";
|
||||
payload: Payload;
|
||||
}
|
||||
| {
|
||||
type: "CLOSE_MODAL";
|
||||
};
|
||||
|
||||
const initialState: State = {
|
||||
changeMemberRole: {
|
||||
showModal: false,
|
||||
},
|
||||
deleteMember: {
|
||||
showModal: false,
|
||||
},
|
||||
impersonateMember: {
|
||||
showModal: false,
|
||||
},
|
||||
inviteMember: {
|
||||
showModal: false,
|
||||
},
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_CHANGE_MEMBER_ROLE_ID":
|
||||
return { ...state, changeMemberRole: action.payload };
|
||||
case "SET_DELETE_ID":
|
||||
return { ...state, deleteMember: action.payload };
|
||||
case "SET_IMPERSONATE_ID":
|
||||
return { ...state, impersonateMember: action.payload };
|
||||
case "INVITE_MEMBER":
|
||||
return { ...state, inviteMember: action.payload };
|
||||
case "CLOSE_MODAL":
|
||||
return {
|
||||
...state,
|
||||
changeMemberRole: { showModal: false },
|
||||
deleteMember: { showModal: false },
|
||||
impersonateMember: { showModal: false },
|
||||
inviteMember: { showModal: false },
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function UserListTable() {
|
||||
const { data: session } = useSession();
|
||||
const { data: currentMembership } = trpc.viewer.organizations.listCurrent.useQuery();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { t } = useLocale();
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
|
||||
const { data, isLoading, fetchNextPage, isFetching } =
|
||||
trpc.viewer.organizations.listMembers.useInfiniteQuery(
|
||||
{
|
||||
limit: 10,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const adminOrOwner = currentMembership?.user.role === "ADMIN" || currentMembership?.user.role === "OWNER";
|
||||
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
|
||||
|
||||
const memorisedColumns = useMemo(() => {
|
||||
const permissions = {
|
||||
canEdit: adminOrOwner,
|
||||
canRemove: adminOrOwner,
|
||||
canImpersonate: false,
|
||||
};
|
||||
const cols: ColumnDef<User>[] = [
|
||||
// Disabling select for this PR: Will work on actions etc in a follow up
|
||||
// {
|
||||
// id: "select",
|
||||
// header: ({ table }) => (
|
||||
// <Checkbox
|
||||
// checked={table.getIsAllPageRowsSelected()}
|
||||
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
// aria-label="Select all"
|
||||
// className="translate-y-[2px]"
|
||||
// />
|
||||
// ),
|
||||
// cell: ({ row }) => (
|
||||
// <Checkbox
|
||||
// checked={row.getIsSelected()}
|
||||
// onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
// aria-label="Select row"
|
||||
// className="translate-y-[2px]"
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
id: "member",
|
||||
accessorFn: (data) => data.email,
|
||||
header: "Member",
|
||||
cell: ({ row }) => {
|
||||
const { username, email } = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username || email}
|
||||
imageSrc={domain + "/" + username + "/avatar.png"}
|
||||
gravatarFallbackMd5="fallback"
|
||||
/>
|
||||
<div className="">
|
||||
<div className="text-emphasis text-sm font-medium leading-none">
|
||||
{username || "No username"}
|
||||
</div>
|
||||
<div className="text-subtle text-sm leading-none">{email}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (rows, id, filterValue) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Weird typing issue
|
||||
return rows.getValue(id).includes(filterValue);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorFn: (data) => data.role,
|
||||
header: "Role",
|
||||
cell: ({ row, table }) => {
|
||||
const { role } = row.original;
|
||||
return (
|
||||
<Badge
|
||||
variant={role === "MEMBER" ? "gray" : "blue"}
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue(role);
|
||||
}}>
|
||||
{role}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (rows, id, filterValue) => {
|
||||
return filterValue.includes(rows.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
header: "Teams",
|
||||
cell: ({ row }) => {
|
||||
const { teams, accepted } = row.original;
|
||||
// TODO: Implement click to filter
|
||||
return (
|
||||
<div className="flex h-full flex-wrap items-center gap-2">
|
||||
{accepted ? null : (
|
||||
<Badge variant="red" className="text-xs">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{teams.map((team) => (
|
||||
<Badge key={team.id} variant="gray">
|
||||
{team.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const permissionsRaw = permissions;
|
||||
const isSelf = user.id === session?.user.id;
|
||||
|
||||
const permissionsForUser = {
|
||||
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
|
||||
canRemove: permissionsRaw.canRemove && !isSelf,
|
||||
canImpersonate: user.accepted && !user.disableImpersonation && !isSelf,
|
||||
canLeave: user.accepted && isSelf,
|
||||
};
|
||||
|
||||
return (
|
||||
<TableActions
|
||||
user={user}
|
||||
permissionsForUser={permissionsForUser}
|
||||
dispatch={dispatch}
|
||||
domain={domain}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [session?.user.id, adminOrOwner, dispatch, domain]);
|
||||
|
||||
//we must flatten the array of arrays from the useInfiniteQuery hook
|
||||
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[];
|
||||
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
|
||||
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
searchKey="member"
|
||||
selectionOptions={[
|
||||
{
|
||||
label: "Add To Team",
|
||||
onClick: () => {
|
||||
console.log("Add To Team");
|
||||
},
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: () => {
|
||||
console.log("Delete");
|
||||
},
|
||||
icon: StopCircle,
|
||||
},
|
||||
]}
|
||||
tableContainerRef={tableContainerRef}
|
||||
tableCTA={
|
||||
adminOrOwner && (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon={Plus}
|
||||
size="sm"
|
||||
className="rounded-md"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "INVITE_MEMBER",
|
||||
payload: {
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="new-organization-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
columns={memorisedColumns}
|
||||
data={flatData}
|
||||
isLoading={isLoading}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
|
||||
filterableItems={[
|
||||
{
|
||||
tableAccessor: "role",
|
||||
title: "Role",
|
||||
options: [
|
||||
{ label: "Owner", value: "OWNER" },
|
||||
{ label: "Admin", value: "ADMIN" },
|
||||
{ label: "Member", value: "MEMBER" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
|
||||
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
|
||||
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
|
||||
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { ExternalLink, MoreHorizontal, Edit2, UserX, Lock } from "lucide-react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
ButtonGroup,
|
||||
Tooltip,
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useOrgBrandingValues } from "../../../ee/organizations/hooks";
|
||||
import type { Action } from "./UserListTable";
|
||||
import type { User } from "./UserListTable";
|
||||
|
||||
export function TableActions({
|
||||
user,
|
||||
permissionsForUser,
|
||||
dispatch,
|
||||
domain,
|
||||
}: {
|
||||
user: User;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
domain: string;
|
||||
permissionsForUser: {
|
||||
canEdit: boolean;
|
||||
canRemove: boolean;
|
||||
canImpersonate: boolean;
|
||||
};
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
|
||||
const usersProfileUrl = domain + "/" + user.username;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
|
||||
<Tooltip content={t("view_public_page")}>
|
||||
<Button
|
||||
target="_blank"
|
||||
href={usersProfileUrl}
|
||||
color="secondary"
|
||||
className={classNames(!permissionsForUser.canEdit ? "rounded-r-md" : "")}
|
||||
variant="icon"
|
||||
StartIcon={ExternalLink}
|
||||
/>
|
||||
</Tooltip>
|
||||
{(permissionsForUser.canEdit || permissionsForUser.canRemove) && (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="radix-state-open:rounded-r-md"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={MoreHorizontal}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{permissionsForUser.canEdit && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_CHANGE_MEMBER_ROLE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon={Edit2}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{permissionsForUser.canImpersonate && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_IMPERSONATE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon={Lock}>
|
||||
{t("impersonate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{permissionsForUser.canRemove && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_DELETE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
color="destructive"
|
||||
StartIcon={UserX}>
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
<div className="flex md:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="icon" color="minimal" StartIcon={MoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem type="button" StartIcon={ExternalLink}>
|
||||
{t("view_public_page")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{permissionsForUser.canEdit && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_IMPERSONATE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon={Edit2}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{permissionsForUser.canRemove && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_DELETE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon={UserX}>
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
export function usePagination({
|
||||
defaultPageIndex = 1,
|
||||
defaultPageSize = 20,
|
||||
}: {
|
||||
defaultPageIndex?: number;
|
||||
defaultPageSize?: number;
|
||||
}) {
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: defaultPageIndex,
|
||||
pageSize: defaultPageSize,
|
||||
});
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize]
|
||||
);
|
||||
|
||||
return {
|
||||
pagination,
|
||||
setPagination,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ZAdminVerifyInput } from "./adminVerify.schema";
|
||||
import { ZCreateInputSchema } from "./create.schema";
|
||||
import { ZCreateTeamsSchema } from "./createTeams.schema";
|
||||
import { ZGetMembersInput } from "./getMembers.schema";
|
||||
import { ZListMembersSchema } from "./listMembers.schema";
|
||||
import { ZSetPasswordSchema } from "./setPassword.schema";
|
||||
import { ZUpdateInputSchema } from "./update.schema";
|
||||
import { ZVerifyCodeInputSchema } from "./verifyCode.schema";
|
||||
@@ -194,7 +195,7 @@ export const viewerOrganizationsRouter = router({
|
||||
input,
|
||||
});
|
||||
}),
|
||||
listMembers: authedProcedure.query(async ({ ctx }) => {
|
||||
listMembers: authedProcedure.input(ZListMembersSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
|
||||
UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then(
|
||||
(mod) => mod.listMembersHandler
|
||||
@@ -208,6 +209,7 @@ export const viewerOrganizationsRouter = router({
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.listMembers({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
getBrand: authedProcedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -75,7 +75,6 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
|
||||
});
|
||||
|
||||
const userIds = foundUsersWithMatchingEmailDomain.map((user) => user.id);
|
||||
const userEmails = foundUsersWithMatchingEmailDomain.map((user) => user.email);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.membership.updateMany({
|
||||
@@ -108,14 +107,6 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
|
||||
organizationId: input.orgId,
|
||||
},
|
||||
}),
|
||||
prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
identifier: {
|
||||
in: userEmails,
|
||||
},
|
||||
teamId: input.orgId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,36 +1,102 @@
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
import type { TListMembersSchema } from "./listMembers.schema";
|
||||
|
||||
type GetOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TListMembersSchema;
|
||||
};
|
||||
|
||||
export const listMembersHandler = async ({ ctx }: GetOptions) => {
|
||||
export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const organizationId = ctx.user.organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
|
||||
}
|
||||
const team = await getTeamWithMembers(organizationId, undefined, ctx.user.id);
|
||||
|
||||
if (!team) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
|
||||
const { cursor, limit } = input;
|
||||
|
||||
const getTotalMembers = await prisma?.membership.count({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
// I couldnt get this query to work direct on membership table
|
||||
const teamMembers = await prisma?.membership.findMany({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
accepted: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
disableImpersonation: true,
|
||||
completedOnboarding: true,
|
||||
teams: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
take: limit + 1, // We take +1 as itll be used for the next cursor
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
let nextCursor: typeof cursor | undefined = undefined;
|
||||
if (teamMembers && teamMembers.length > limit) {
|
||||
const nextItem = teamMembers.pop();
|
||||
nextCursor = nextItem!.id;
|
||||
}
|
||||
|
||||
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
|
||||
const members = teamMembers?.map((member) => {
|
||||
return {
|
||||
id: member.user.id,
|
||||
username: member.user.username,
|
||||
email: member.user.email,
|
||||
timeZone: member.user.timeZone,
|
||||
role: member.role,
|
||||
accepted: member.accepted,
|
||||
disableImpersonation: member.user.disableImpersonation,
|
||||
completedOnboarding: member.user.completedOnboarding,
|
||||
teams: member.user.teams
|
||||
.filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team
|
||||
.map((team) => {
|
||||
if (team.team.id === organizationId) return;
|
||||
return {
|
||||
id: team.team.id,
|
||||
name: team.team.name,
|
||||
slug: team.team.slug,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...team,
|
||||
safeBio: markdownToSafeHTML(team.bio),
|
||||
membership: {
|
||||
role: membership?.role as MembershipRole,
|
||||
accepted: membership?.accepted,
|
||||
rows: members || [],
|
||||
nextCursor,
|
||||
meta: {
|
||||
totalRowCount: getTotalMembers || 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZListMembersSchema = z.object({
|
||||
limit: z.number().min(1).max(100),
|
||||
cursor: z.number().nullish(),
|
||||
});
|
||||
|
||||
export type TListMembersSchema = z.infer<typeof ZListMembersSchema>;
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import "@tanstack/react-table";
|
||||
|
||||
declare module "@tanstack/table-core" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
hasPermissions?: (data?: TData) => boolean; // We can conditionally render a column based on this callback -> User doesnt have admin for example.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import * as React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import { Dialog, DialogContent } from "../dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"bg-popover text-default flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
type CommandDialogProps = DialogProps;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted hover:bg-subtle [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3 py-2" cmdk-input-wrapper="">
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"placeholder:text-muted hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default disabled:bg-subtle focus:ring-brand-default block flex h-[28px] w-full rounded-md rounded-md border bg-transparent px-3 py-1.5 text-sm text-sm leading-4 outline-none focus:border-neutral-300 focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={classNames("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"text-default [&_[cmdk-group-heading]]:text-muted overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={classNames("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"aria-selected:bg-subtle aria-selected:text-emphasis relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={classNames("text-muted ml-auto text-xs tracking-widest", className)} {...props} />;
|
||||
};
|
||||
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Check, Filter } from "lucide-react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import { Badge } from "../badge";
|
||||
import { Button } from "../button";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandSeparator,
|
||||
} from "../command";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "../popover";
|
||||
|
||||
interface DataTableFilter<TData, TValue> {
|
||||
column?: Column<TData, TValue>;
|
||||
title?: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: LucideIcon;
|
||||
}[];
|
||||
}
|
||||
export function DataTableFilter<TData, TValue>({ column, title, options }: DataTableFilter<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues();
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button color="secondary" size="sm" className="border-subtle h-8 rounded-md">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<div className="ml-2 hidden space-x-1 md:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge color="gray" className="rounded-sm px-1 font-normal">
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge color="gray" key={option.value} className="rounded-sm px-1 font-normal">
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
// TODO: It would be nice to pull these from data instead of options
|
||||
const isSelected = selectedValues.has(option.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value);
|
||||
} else {
|
||||
selectedValues.add(option.value);
|
||||
}
|
||||
const filterValues = Array.from(selectedValues);
|
||||
column?.setFilterValue(filterValues.length ? filterValues : undefined);
|
||||
}}>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Check className={classNames("h-4 w-4")} />
|
||||
</div>
|
||||
{option.icon && <option.icon className="text-muted mr-2 h-4 w-4" />}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className="justify-center text-center">
|
||||
Clear filters
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
|
||||
import { Button } from "../button";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||
selected.
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex} of {table.getPageCount() - 1}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={ChevronsLeft}
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
StartIcon={ChevronLeft}>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={ChevronRight}
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={!table.getCanNextPage()}
|
||||
onClick={() => table.nextPage()}>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
StartIcon={ChevronsRight}
|
||||
onClick={() => table.setPageIndex(table.getPageCount())}>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
|
||||
import { Button } from "../button";
|
||||
|
||||
interface DataTableSelectionBarProps<TData> {
|
||||
table: Table<TData>;
|
||||
actions?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: SVGComponent;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DataTableSelectionBar<TData>({ table, actions }: DataTableSelectionBarProps<TData>) {
|
||||
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
|
||||
|
||||
if (numberOfSelectedRows === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-brand-default text-brand item-center absolute bottom-0 left-1/2 flex -translate-x-1/2 gap-4 rounded-lg p-2">
|
||||
<div className="text-brand-subtle my-auto px-2">{numberOfSelectedRows} selected</div>
|
||||
{actions?.map((action) => (
|
||||
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon} key={action.label}>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Button } from "../button";
|
||||
import { Input } from "../form";
|
||||
import { DataTableFilter } from "./DataTableFilter";
|
||||
|
||||
export type FilterableItems = {
|
||||
title: string;
|
||||
tableAccessor: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: LucideIcon;
|
||||
}[];
|
||||
}[];
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table: Table<TData>;
|
||||
filterableItems?: FilterableItems;
|
||||
searchKey?: string;
|
||||
tableCTA?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
filterableItems,
|
||||
tableCTA,
|
||||
searchKey,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
// TODO: Is there a better way to check if the table is filtered?
|
||||
// If you select ALL filters for a column, the table is not filtered and we dont get a reset button
|
||||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{searchKey && (
|
||||
<Input
|
||||
className="max-w-64 mb-0 mr-auto"
|
||||
placeholder="Search"
|
||||
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn(searchKey)?.setFilterValue(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{isFiltered && (
|
||||
<Button
|
||||
color="minimal"
|
||||
EndIcon={X}
|
||||
onClick={() => table.resetColumnFilters()}
|
||||
className="h-8 px-2 lg:px-3">
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{filterableItems &&
|
||||
filterableItems?.map((item) => {
|
||||
const foundColumn = table.getColumn(item.tableAccessor);
|
||||
if (foundColumn?.getCanFilter()) {
|
||||
return (
|
||||
<DataTableFilter
|
||||
column={foundColumn}
|
||||
title={item.title}
|
||||
options={item.options}
|
||||
key={item.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{tableCTA ? tableCTA : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "../../badge";
|
||||
import { Checkbox } from "../../form";
|
||||
import type { FilterableItems } from "../DataTableToolbar";
|
||||
import type { DataTableUserStorybook } from "./data";
|
||||
|
||||
export const columns: ColumnDef<DataTableUserStorybook>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "Username",
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: ({ row, table }) => {
|
||||
const user = row.original;
|
||||
const BadgeColor = user.role === "admin" ? "blue" : "gray";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={BadgeColor}
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue(user.role);
|
||||
}}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (rows, id, filterValue) => {
|
||||
return filterValue.includes(rows.getValue(id));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const filterableItems: FilterableItems = [
|
||||
{
|
||||
title: "Role",
|
||||
tableAccessor: "role",
|
||||
options: [
|
||||
{
|
||||
label: "Admin",
|
||||
value: "admin",
|
||||
},
|
||||
{
|
||||
label: "User",
|
||||
value: "user",
|
||||
},
|
||||
{
|
||||
label: "Owner",
|
||||
value: "owner",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,100 @@
|
||||
import { StopCircle, Users } from "lucide-react";
|
||||
|
||||
export type DataTableUserStorybook = {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
};
|
||||
|
||||
export const dataTableSelectionActions = [
|
||||
{
|
||||
label: "Add To Team",
|
||||
onClick: () => {
|
||||
console.log("Add To Team");
|
||||
},
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: () => {
|
||||
console.log("Delete");
|
||||
},
|
||||
icon: StopCircle,
|
||||
},
|
||||
];
|
||||
|
||||
export const dataTableDemousers: DataTableUserStorybook[] = [
|
||||
{
|
||||
id: "728ed52f",
|
||||
email: "m@example.com",
|
||||
username: "m",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
id: "489e1d42",
|
||||
email: "example@gmail.com",
|
||||
username: "e",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "7b8a6f1d-2d2d-4d29-9c1a-0a8b3f5f9d2f",
|
||||
email: "Keshawn_Schroeder@hotmail.com",
|
||||
username: "Ava_Waelchi",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "f4d9e2a3-7e3c-4d6e-8e4c-8d0d7d1c2c9b",
|
||||
email: "Jovanny_Grant@hotmail.com",
|
||||
username: "Kamren_Gerhold",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
id: "1b2a4b6e-5b2d-4c38-9c7e-9d5e8f9c0a6a",
|
||||
email: "Emilie.McKenzie@yahoo.com",
|
||||
username: "Lennie_Harber",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "d6f3e6e9-9c2a-4c8a-8f3c-0d63a0eaf5a5",
|
||||
email: "Jolie_Beatty@hotmail.com",
|
||||
username: "Lorenzo_Will",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
id: "7c1e5d1d-8b9c-4b1c-9d1b-7d9f8b5a7e3e",
|
||||
email: "Giovanny_Cruickshank@hotmail.com",
|
||||
username: "Monserrat_Lang",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "f7d8b7a2-0a5c-4f8d-9f4f-8d1a2c3e4b3e",
|
||||
email: "Lela_Haag@hotmail.com",
|
||||
username: "Eddie_Effertz",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "2f8b9c8d-1a5c-4e3d-9b7a-6c5d4e3f2b1a",
|
||||
email: "Lura_Kohler@gmail.com",
|
||||
username: "Alyce_Olson",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "d8c7b6a5-4e3d-2b1a-9c8d-1f2e3d4c5b6a",
|
||||
email: "Maurice.Koch@hotmail.com",
|
||||
username: "Jovanny_Kiehn",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
id: "3c2b1a5d-4e3d-8c7b-9a6f-0d1e2f3g4h5i",
|
||||
email: "Brenda_Bernhard@yahoo.com",
|
||||
username: "Aurelia_Kemmer",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "e4d3c2b1-5e4d-3c2b-1a9f-8g7h6i5j4k3l",
|
||||
email: "Lorenzo_Rippin@hotmail.com",
|
||||
username: "Waino_Lang",
|
||||
role: "admin",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
|
||||
|
||||
import {
|
||||
Examples,
|
||||
Example,
|
||||
Note,
|
||||
Title,
|
||||
CustomArgsTable,
|
||||
VariantsTable,
|
||||
VariantRow,
|
||||
} from "@calcom/storybook/components";
|
||||
|
||||
import { DataTable } from "../";
|
||||
import { columns, filterableItems } from "./columns";
|
||||
import { dataTableDemousers,dataTableSelectionActions } from "./data";
|
||||
|
||||
<Meta title="UI/table/data-table" component={DataTable} />
|
||||
|
||||
<Title title="Data-table" suffix="Brief" subtitle="Version 2.0 — Last Update: 06 Jan 2023" />
|
||||
|
||||
## Definition
|
||||
|
||||
<Canvas>
|
||||
<Story name="Datatable">
|
||||
<DataTable columns={columns} data={dataTableDemousers} filterableItems={filterableItems} searchKey="username" selectionOptions={dataTableSelectionActions} />
|
||||
</Story>
|
||||
</Canvas>
|
||||
@@ -0,0 +1,173 @@
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
Row,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
import { useVirtual } from "react-virtual";
|
||||
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew";
|
||||
import { DataTableSelectionBar } from "./DataTableSelectionBar";
|
||||
import type { FilterableItems } from "./DataTableToolbar";
|
||||
import { DataTableToolbar } from "./DataTableToolbar";
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
tableContainerRef: React.RefObject<HTMLDivElement>;
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchKey?: string;
|
||||
filterableItems?: FilterableItems;
|
||||
selectionOptions?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: SVGComponent;
|
||||
}[];
|
||||
tableCTA?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;
|
||||
CTA?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
filterableItems,
|
||||
tableCTA,
|
||||
searchKey,
|
||||
selectionOptions,
|
||||
tableContainerRef,
|
||||
isLoading,
|
||||
onScroll,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
debugTable: true,
|
||||
manualPagination: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtual({
|
||||
parentRef: tableContainerRef,
|
||||
size: rows.length,
|
||||
overscan: 10,
|
||||
});
|
||||
const { virtualItems: virtualRows, totalSize } = rowVirtualizer;
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
|
||||
|
||||
return (
|
||||
<div className="relative space-y-4">
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
filterableItems={filterableItems}
|
||||
searchKey={searchKey}
|
||||
tableCTA={tableCTA}
|
||||
/>
|
||||
<div
|
||||
className="rounded-md border"
|
||||
ref={tableContainerRef}
|
||||
onScroll={onScroll}
|
||||
style={{
|
||||
height: "calc(100vh - 30vh)",
|
||||
overflow: "auto",
|
||||
}}>
|
||||
<Table>
|
||||
<TableHeader
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualRows && !isLoading ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as Row<TData>;
|
||||
|
||||
return (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* <DataTablePagination table={table} /> */}
|
||||
<DataTableSelectionBar table={table} actions={selectionOptions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,7 @@ export function DialogFooter(props: {
|
||||
return (
|
||||
<div className={classNames("bg-default sticky bottom-0", props.className)}>
|
||||
{props.showDivider && (
|
||||
// TODO: the -mx-8 is causing overflow in the dialog buttons
|
||||
<hr className={classNames("border-subtle -mx-8", props.customDividerClassNames)} />
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { useId } from "@radix-ui/react-id";
|
||||
import { Check } from "lucide-react";
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
@@ -14,6 +16,24 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
descriptionClassName?: string;
|
||||
};
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"border-default data-[state=checked]:bg-brand-default data-[state=checked]:text-brand peer h-4 w-4 shrink-0 rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<CheckboxPrimitive.Indicator className={classNames("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
({ label, description, error, disabled, ...rest }, ref) => {
|
||||
const descriptionAsLabel = !label || rest.descriptionAsLabel;
|
||||
@@ -79,4 +99,4 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
|
||||
CheckboxField.displayName = "CheckboxField";
|
||||
|
||||
export default CheckboxField;
|
||||
export { Checkbox, CheckboxField };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';
|
||||
import { Examples, Example, Note, Title,CustomArgsTable,VariantsTable,VariantRow } from '@calcom/storybook/components'
|
||||
import Checkbox from './Checkbox';
|
||||
import {CheckboxField as Checkbox} from './Checkbox';
|
||||
|
||||
|
||||
<Meta title="UI/Form/Checkbox" component={Checkbox} />
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { default as Checkbox } from "./Checkbox";
|
||||
export { Checkbox, CheckboxField } from "./Checkbox";
|
||||
export { default as MultiSelectCheckbox } from "./MultiSelectCheckboxes";
|
||||
export type { Option } from "./MultiSelectCheckboxes";
|
||||
|
||||
@@ -74,7 +74,7 @@ type DropdownMenuCheckboxItemProps = ComponentProps<(typeof DropdownMenuPrimitiv
|
||||
export const DropdownMenuCheckboxItem = forwardRef<HTMLDivElement, DropdownMenuCheckboxItemProps>(
|
||||
({ children, ...props }, forwardedRef) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem {...props} ref={forwardedRef}>
|
||||
<DropdownMenuPrimitive.CheckboxItem {...props} ref={forwardedRef} className="">
|
||||
{children}
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckCircle />
|
||||
@@ -138,7 +138,8 @@ export const DropdownItem = (props: DropdownItemProps) => {
|
||||
{...rest}
|
||||
className={classNames(
|
||||
"hover:text-emphasis text-default inline-flex w-full items-center space-x-2 px-3 py-2 disabled:cursor-not-allowed",
|
||||
color === "destructive" ? "hover:bg-error hover:text-red-700" : "hover:bg-subtle"
|
||||
color === "destructive" ? "hover:bg-error hover:text-red-700" : "hover:bg-subtle",
|
||||
props.className
|
||||
)}>
|
||||
<>
|
||||
{StartIcon && <StartIcon className="h-4 w-4" />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { Checkbox, MultiSelectCheckbox } from "./checkbox";
|
||||
export { Checkbox, MultiSelectCheckbox, CheckboxField } from "./checkbox";
|
||||
export type { Option } from "./checkbox";
|
||||
export { HintsOrErrors } from "./inputs/HintOrErrors";
|
||||
export {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AnimatedPopover } from "./AnimatedPopover";
|
||||
export { default as MeetingTimeInTimezones } from "./MeetingTimeInTimezones";
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={classNames(
|
||||
"bg-default text-emphasis z-50 w-72 rounded-md border p-4 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
|
||||
export { AnimatedPopover } from "./AnimatedPopover";
|
||||
export { default as MeetingTimeInTimezones } from "./MeetingTimeInTimezones";
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-auto">
|
||||
<table ref={ref} className={classNames("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={classNames("[&_tr]:bg-subtle [&_tr]:border-b", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={classNames("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot ref={ref} className={classNames("bg-default text-emphasis font-medium", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={classNames("hover:bg-subtle data-[state=selected]:bg-subtle border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"text-default h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={classNames("text-default p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={classNames("text-default mt-4 text-sm", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
@@ -8,6 +8,7 @@ export type { ButtonBaseProps, ButtonProps } from "./components/button";
|
||||
export { ButtonGroup } from "./components/buttonGroup";
|
||||
export {
|
||||
Checkbox,
|
||||
CheckboxField,
|
||||
EmailField,
|
||||
EmailInput,
|
||||
FieldsetLegend,
|
||||
@@ -135,3 +136,4 @@ export { CreateButton, CreateButtonWithTeamsList } from "./components/createButt
|
||||
export { useCalcomTheme } from "./styles/useCalcomTheme";
|
||||
export { ScrollableArea } from "./components/scrollable/ScrollableArea";
|
||||
export { WizardLayout } from "./layouts/WizardLayout";
|
||||
export { DataTable } from "./components/data-table";
|
||||
|
||||
@@ -23,14 +23,18 @@
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.2",
|
||||
"@radix-ui/react-portal": "^1.0.0",
|
||||
"@radix-ui/react-select": "^0.1.1",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@tanstack/react-query": "^4.3.9",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@wojtekmaj/react-daterange-picker": "^3.3.1",
|
||||
"class-variance-authority": "^0.4.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"cmk": "^0.1.1",
|
||||
"downshift": "^6.1.9",
|
||||
"lucide-react": "^0.171.0",
|
||||
"next": "^13.4.6",
|
||||
@@ -39,7 +43,8 @@
|
||||
"react-feather": "^2.0.10",
|
||||
"react-hook-form": "^7.43.3",
|
||||
"react-icons": "^4.4.0",
|
||||
"react-select": "^5.7.0"
|
||||
"react-select": "^5.7.0",
|
||||
"react-virtual": "^2.10.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/config": "*",
|
||||
|
||||
Reference in New Issue
Block a user