diff --git a/apps/web/components/ui/LimitedBadges.tsx b/apps/web/components/ui/LimitedBadges.tsx
new file mode 100644
index 00000000000000..b9359e425d8fd9
--- /dev/null
+++ b/apps/web/components/ui/LimitedBadges.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
+import { Badge } from "@calcom/ui/components/badge";
+import { Button } from "@calcom/ui/components/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
+import { useCallback, useMemo, useState, useEffect } from "react";
+
+const MAX_VISIBLE_BADGES = 2;
+
+type BadgeItem = {
+ label: string;
+ variant?:
+ | "default"
+ | "warning"
+ | "orange"
+ | "success"
+ | "green"
+ | "gray"
+ | "blue"
+ | "red"
+ | "error"
+ | "grayWithoutHover"
+ | "purple";
+ onClick?: () => void;
+};
+
+type LimitedBadgesProps = {
+ items: BadgeItem[];
+ maxVisible?: number;
+ className?: string;
+};
+
+function LimitedBadges({
+ items,
+ maxVisible = MAX_VISIBLE_BADGES,
+ className,
+}: LimitedBadgesProps): JSX.Element | null {
+ const [isOpen, setIsOpen] = useState(false);
+ const isMobile = useMediaQuery("(max-width: 768px)");
+
+ const { visibleItems, hiddenItems } = useMemo(
+ () => ({
+ visibleItems: items.slice(0, maxVisible),
+ hiddenItems: items.slice(maxVisible),
+ }),
+ [items, maxVisible]
+ );
+
+ const handleMouseEnter = useCallback(() => {
+ if (!isMobile) {
+ setIsOpen(true);
+ }
+ }, [isMobile]);
+
+ const handleMouseLeave = useCallback(() => {
+ if (!isMobile) {
+ setIsOpen(false);
+ }
+ }, [isMobile]);
+
+ if (items.length === 0) return null;
+
+ const hasHiddenItems = hiddenItems.length > 0;
+
+ return (
+
+ {visibleItems.map((item, index) => (
+
+ {item.label}
+
+ ))}
+ {hasHiddenItems && (
+
+
+
+
+
+
+ {hiddenItems.map((item, index) => (
+
+ {item.label}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+export { LimitedBadges };
+export type { BadgeItem };
diff --git a/apps/web/modules/insights/components/ResponseValueCell.tsx b/apps/web/modules/insights/components/ResponseValueCell.tsx
index d3369f52079974..e642af3c4bf435 100644
--- a/apps/web/modules/insights/components/ResponseValueCell.tsx
+++ b/apps/web/modules/insights/components/ResponseValueCell.tsx
@@ -1,60 +1,24 @@
-import { useId } from "react";
-
-import { Badge } from "@calcom/ui/components/badge";
-import {
- HoverCard,
- HoverCardContent,
- HoverCardTrigger,
- HoverCardPortal,
-} from "@calcom/ui/components/hover-card";
+import { LimitedBadges } from "@calcom/web/components/ui/LimitedBadges";
import { CellWithOverflowX } from "./CellWithOverflowX";
export function ResponseValueCell({
optionMap,
values,
- rowId,
}: {
optionMap: Record;
values: string[];
- rowId: number;
-}) {
- const cellId = useId();
+}): JSX.Element {
if (values.length === 0) return ;
return (
- {values.length > 2 ? (
- <>
- {values.slice(0, 2).map((id: string, i: number) => (
-
- {optionMap[id] ?? id}
-
- ))}
-
-
- +{values.length - 2}
-
-
-
-
- {values.slice(2).map((id: string, i: number) => (
-
- {optionMap[id] ?? id}
-
- ))}
-
-
-
-
- >
- ) : (
- values.map((id: string, i: number) => (
-
- {optionMap[id] ?? id}
-
- ))
- )}
+ ({
+ label: optionMap[id] ?? id,
+ variant: "gray" as const,
+ }))}
+ />
);
}
diff --git a/apps/web/modules/insights/hooks/useInsightsColumns.tsx b/apps/web/modules/insights/hooks/useInsightsColumns.tsx
index 58e8ef8017b382..2c3b25565108b5 100644
--- a/apps/web/modules/insights/hooks/useInsightsColumns.tsx
+++ b/apps/web/modules/insights/hooks/useInsightsColumns.tsx
@@ -200,7 +200,6 @@ export const useInsightsColumns = ({
)
);
diff --git a/apps/web/modules/users/components/UserTable/UserListTable.tsx b/apps/web/modules/users/components/UserTable/UserListTable.tsx
index 3412157b7f092b..74afe4913a7510 100644
--- a/apps/web/modules/users/components/UserTable/UserListTable.tsx
+++ b/apps/web/modules/users/components/UserTable/UserListTable.tsx
@@ -1,42 +1,57 @@
"use client";
-import { keepPreviousData } from "@tanstack/react-query";
-import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
-import { useSession } from "next-auth/react";
-import { usePathname } from "next/navigation";
-import { useQueryState, parseAsBoolean } from "nuqs";
-import { useMemo, useReducer, useState } from "react";
-import { createPortal } from "react-dom";
-import posthog from "posthog-js";
-
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import {
- DataTableProvider,
- useColumnFilters,
ColumnFilterType,
convertFacetedValuesToMap,
+ DataTableProvider,
+ type FacetedValue,
+ useColumnFilters,
useDataTable,
} from "@calcom/features/data-table";
-import { DataTableWrapper, DataTableToolbar, DataTableFilters, DataTableSegment, DataTableSelectionBar } from "~/data-table/components";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
-import {
- generateCsvRawForMembersTable,
- generateHeaderFromReactTable,
-} from "@calcom/web/modules/users/lib/UserListTableUtils";
+import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
+import { trpc } from "@calcom/trpc/react";
+import type { FilterType } from "@calcom/types/data-table";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Checkbox } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
-
+import { LimitedBadges } from "@calcom/web/components/ui/LimitedBadges";
+import {
+ generateCsvRawForMembersTable,
+ generateHeaderFromReactTable,
+} from "@calcom/web/modules/users/lib/UserListTableUtils";
+import { keepPreviousData } from "@tanstack/react-query";
+import {
+ type CellContext,
+ type ColumnDef,
+ getCoreRowModel,
+ getSortedRowModel,
+ type HeaderContext,
+ useReactTable,
+} from "@tanstack/react-table";
+import { usePathname } from "next/navigation";
+import { useSession } from "next-auth/react";
+import { parseAsBoolean, useQueryState } from "nuqs";
+import posthog from "posthog-js";
+import { useMemo, useReducer, useState } from "react";
+import { createPortal } from "react-dom";
+import {
+ DataTableFilters,
+ DataTableSegment,
+ DataTableSelectionBar,
+ DataTableToolbar,
+ DataTableWrapper,
+} from "~/data-table/components";
import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers";
import { DynamicLink } from "./BulkActions/DynamicLink";
import { EventTypesList } from "./BulkActions/EventTypesList";
@@ -47,9 +62,8 @@ import { DeleteMemberModal } from "./DeleteMemberModal";
import { EditUserSheet } from "./EditSheet/EditUserSheet";
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
import { InviteMemberModal } from "./InviteMemberModal";
+import type { UserTableAction, UserTableState, UserTableUser } from "./types";
import { TableActions } from "./UserTableActions";
-import type { UserTableState, UserTableAction, UserTableUser } from "./types";
-import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
const initialState: UserTableState = {
changeMemberRole: {
@@ -107,7 +121,7 @@ function reducer(state: UserTableState, action: UserTableAction): UserTableState
}
}
-export type UserListTableProps = {
+type UserListTableProps = {
org: RouterOutputs["viewer"]["organizations"]["listCurrent"];
teams: RouterOutputs["viewer"]["organizations"]["getTeams"];
attributes?: RouterOutputs["viewer"]["attributes"]["list"];
@@ -125,7 +139,7 @@ export type UserListTableProps = {
permissions?: MemberPermissions;
};
-export function UserListTable(props: UserListTableProps) {
+function UserListTable(props: UserListTableProps): JSX.Element | null {
const pathname = usePathname();
if (!pathname) return null;
return (
@@ -141,7 +155,7 @@ function UserListTableContent({
teams,
facetedTeamValues,
permissions,
-}: UserListTableProps) {
+}: UserListTableProps): JSX.Element {
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const orgBranding = useOrgBranding();
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
@@ -183,7 +197,7 @@ function UserListTableContent({
canResendInvitation: permissions?.canInvite ?? adminOrOwner,
canImpersonate: permissions?.canImpersonate ?? adminOrOwner,
};
- const generateAttributeColumns = () => {
+ const generateAttributeColumns = (): ColumnDef[] => {
if (!attributes?.length) {
return [];
}
@@ -198,13 +212,14 @@ function UserListTableContent({
const isText = attribute.type === "TEXT";
const isSingleSelect = attribute.type === "SINGLE_SELECT";
// const isMultiSelect = attribute.type === "MULTI_SELECT";
- const filterType = isNumber
- ? ColumnFilterType.NUMBER
- : isText
- ? ColumnFilterType.TEXT
- : isSingleSelect
- ? ColumnFilterType.SINGLE_SELECT
- : ColumnFilterType.MULTI_SELECT;
+ let filterType: FilterType = ColumnFilterType.MULTI_SELECT;
+ if (isNumber) {
+ filterType = ColumnFilterType.NUMBER;
+ } else if (isText) {
+ filterType = ColumnFilterType.TEXT;
+ } else if (isSingleSelect) {
+ filterType = ColumnFilterType.SINGLE_SELECT;
+ }
return {
id: attribute.id,
@@ -213,41 +228,42 @@ function UserListTableContent({
filter: { type: filterType },
},
size: 120,
- accessorFn: (data) => data.attributes?.find((attr) => attr.attributeId === attribute.id)?.value,
- cell: ({ row }) => {
+ accessorFn: (data: UserTableUser) =>
+ data.attributes?.find((attr) => attr.attributeId === attribute.id)?.value,
+ cell: ({ row }: CellContext) => {
const attributeValues = row.original.attributes?.filter(
(attr) => attr.attributeId === attribute.id
);
- if (attributeValues?.length === 0) return null;
+ if (!attributeValues || attributeValues.length === 0) return null;
+
return (
-
- {attributeValues?.map((attributeValue) => {
+
{
const isAGroupOption = attributeValue.contains?.length > 0;
- const suffix = attribute.isWeightsEnabled
- ? `${attributeValue.weight || 100}%`
- : undefined;
- return (
-
-
- {attributeValue.value}
-
-
- {suffix ? (
-
- {suffix}
-
- ) : null}
-
- );
+ let weight = "";
+ if (attribute.isWeightsEnabled) {
+ weight = `${attributeValue.weight || 100}%`;
+ }
+ let groupIndicator = "";
+ if (isAGroupOption) {
+ groupIndicator = " (group)";
+ }
+ let label = attributeValue.value;
+ label = `${label} ${weight}`;
+ label = `${label}${groupIndicator}`;
+
+ let variant: "orange" | "gray" = "gray";
+ if (isAGroupOption) {
+ variant = "orange";
+ }
+
+ return {
+ label,
+ variant,
+ };
})}
-
+ className={classNames(isNumber && "w-full justify-center")}
+ />
);
},
};
@@ -263,17 +279,17 @@ function UserListTableContent({
enableSorting: false,
enableResizing: false,
size: 30,
- header: ({ table }) => (
+ header: ({ table }: HeaderContext) => (
table.toggleAllPageRowsSelected(!!value)}
+ onCheckedChange={(value: boolean | "indeterminate") => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
- cell: ({ row }) => (
+ cell: ({ row }: CellContext) => (
row.toggleSelected(!!value)}
+ onCheckedChange={(value: boolean | "indeterminate") => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
@@ -281,12 +297,12 @@ function UserListTableContent({
},
{
id: "member",
- accessorFn: (data) => data.email,
+ accessorFn: (data: UserTableUser) => data.email,
enableHiding: false,
enableColumnFilter: false,
size: 200,
header: t("members"),
- cell: ({ row }) => {
+ cell: ({ row }: CellContext) => {
const { username, email, avatarUrl } = row.original;
return (
@@ -315,19 +331,23 @@ function UserListTableContent({
},
{
id: "role",
- accessorFn: (data) => data.role,
+ accessorFn: (data: UserTableUser) => data.role,
header: t("role"),
size: 100,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
- cell: ({ row, table }) => {
+ cell: ({ row, table }: CellContext
) => {
const { role, username, customRole } = row.original;
const roleName = customRole?.name || role;
+ let roleVariant: "gray" | "blue" = "blue";
+ if (role === "MEMBER") {
+ roleVariant = "gray";
+ }
return (
{
table.getColumn("role")?.setFilterValue([role]);
}}>
@@ -338,18 +358,18 @@ function UserListTableContent({
},
{
id: "teams",
- accessorFn: (data) => data.teams.map((team) => team.name),
+ accessorFn: (data: UserTableUser) => data.teams.map((team) => team.name),
header: t("teams"),
size: 140,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
- cell: ({ row, table }) => {
+ cell: ({ row, table }: CellContext) => {
const { teams, accepted, email, username } = row.original;
- // TODO: Implement click to filter
+
return (
- {accepted ? null : (
+ {!accepted && (
)}
- {teams.map((team) => (
- {
+ ({
+ label: team.name,
+ variant: "gray" as const,
+ onClick: () => {
table.getColumn("teams")?.setFilterValue([team.name]);
- }}>
- {team.name}
-
- ))}
+ },
+ }))}
+ />
);
},
@@ -388,7 +407,7 @@ function UserListTableContent({
type: ColumnFilterType.DATE_RANGE,
},
},
- cell: ({ row }) => {row.original.lastActiveAt}
,
+ cell: ({ row }: CellContext) => {row.original.lastActiveAt}
,
},
{
id: "createdAt",
@@ -401,7 +420,7 @@ function UserListTableContent({
type: ColumnFilterType.DATE_RANGE,
},
},
- cell: ({ row }) => {row.original.createdAt || ""}
,
+ cell: ({ row }: CellContext) => {row.original.createdAt || ""}
,
},
{
id: "updatedAt",
@@ -414,7 +433,7 @@ function UserListTableContent({
type: ColumnFilterType.DATE_RANGE,
},
},
- cell: ({ row }) => {row.original.updatedAt || ""}
,
+ cell: ({ row }: CellContext) => {row.original.updatedAt || ""}
,
},
{
id: "completedOnboarding",
@@ -423,13 +442,15 @@ function UserListTableContent({
enableSorting: false,
enableColumnFilter: false,
size: 80,
- cell: ({ row }) => {
+ cell: ({ row }: CellContext) => {
const { completedOnboarding } = row.original;
- return (
-
- {completedOnboarding ? t("yes") : t("no")}
-
- );
+ let onboardingVariant: "green" | "gray" = "gray";
+ let onboardingText = t("no");
+ if (completedOnboarding) {
+ onboardingVariant = "green";
+ onboardingText = t("yes");
+ }
+ return {onboardingText};
},
},
{
@@ -440,16 +461,18 @@ function UserListTableContent({
enableSorting: false,
enableColumnFilter: false,
size: 80,
- cell: ({ row }) => {
+ cell: ({ row }: CellContext) => {
const { twoFactorEnabled } = row.original;
if (!adminOrOwner || twoFactorEnabled === undefined) {
return null;
}
- return (
-
- {twoFactorEnabled ? t("enabled") : t("disabled")}
-
- );
+ let twoFaVariant: "green" | "gray" = "gray";
+ let twoFaText = t("disabled");
+ if (twoFactorEnabled) {
+ twoFaVariant = "green";
+ twoFaText = t("enabled");
+ }
+ return {twoFaText};
},
},
{
@@ -458,7 +481,7 @@ function UserListTableContent({
enableSorting: false,
enableResizing: false,
size: 80,
- cell: ({ row }) => {
+ cell: ({ row }: CellContext) => {
const user = row.original;
const permissionsRaw = tablePermissions;
const isSelf = user.id === session?.user.id;
@@ -492,7 +515,7 @@ function UserListTableContent({
];
return cols;
- }, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate, permissions]);
+ }, [session?.user.id, adminOrOwner, domain, attributes, org?.canAdminImpersonate, permissions, t]);
const table = useReactTable({
data: flatData,
@@ -515,8 +538,8 @@ function UserListTableContent({
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
- getRowId: (row) => `${row.id}`,
- getFacetedUniqueValues: (_, columnId) => () => {
+ getRowId: (row: UserTableUser) => `${row.id}`,
+ getFacetedUniqueValues: (_: unknown, columnId: string) => (): Map => {
if (facetedTeamValues) {
switch (columnId) {
case "role":
@@ -555,7 +578,7 @@ function UserListTableContent({
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
- const handleDownload = async () => {
+ const handleDownload = async (): Promise => {
try {
if (!org?.slug || !org?.name) {
throw new Error("Org slug or name is missing.");
@@ -640,7 +663,7 @@ function UserListTableContent({
{t("number_selected", { count: numberOfSelectedRows })}
- {!isPlatformUser ? (
+ {!isPlatformUser && (
<>
{permissions?.canChangeMemberRole && }
{numberOfSelectedRows >= 2 && (
@@ -658,7 +681,7 @@ function UserListTableContent({
)}
>
- ) : null}
+ )}
{(permissions?.canRemove ?? adminOrOwner) && (
row.original)}
@@ -707,7 +730,7 @@ function UserListTableContent({
showModal: true,
},
});
- posthog.capture("add_organization_member_clicked")
+ posthog.capture("add_organization_member_clicked");
}}
data-testid="new-organization-member-button">
{t("add")}
@@ -719,3 +742,6 @@ function UserListTableContent({
>
);
}
+
+export { UserListTable };
+export type { UserListTableProps };