* WIP restored from .git cache * fix exports * sortable row model * feat column visibility component * wip filters with nuqs * pull in unique values from table into filters * correctly assign filters via v/f * inital selection bar refactor * data-table selection bar + optmistic update of delete * dynamic link * migrate member list table to new data-table * total list shows filtered value > db valuie * add filters for attributes * type errors * make content bigger on lg * add mb-6 to teams user datatable to match spacing spec * correctly render multi-badge * fix: masss asignment optimistic UI * fix type errors * remove log * fix toolbar type error * chore: Remove debug artifact * type errors * Update apps/web/public/static/locales/en/common.json * use max-w-fit * chore: Remove unused translation now we don't specify 'mass' in assign * perf: fix: use the onBlur event to prevent focus loss whilst the list is rerendering * Move the data-table exports together in the main barrel, then import * fix exports that were lost in a merge * fix exports that were lost in a merge * fix groupteammapping/availbilityslider * fix overflow problems * add scrollbar-thin class * fix type error * user serverside values for faceted filters * pass filters to serverside * filter serverside * fix team server side filter * add loaded x of y * attributes icon change * correct implementation for text/input attr optimistic * type check fixes * fix platform checks * fix types again * fix types again * fix types again * add use client * add use client * fix-types * fix: Add missing translation in EN * fix e2e tests via testid * fix e2e tests via testid * fix: Member invite popup not popping up * Update copyInviteLink to new-member-button testid * Hopefully fix test ids this time * fix: Use the right buttons on the right pages --------- Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import type { Table } from "@tanstack/react-table";
|
|
import type { ColumnFiltersState } from "@tanstack/react-table";
|
|
import { parseAsString, useQueryState, parseAsArrayOf } from "nuqs";
|
|
import { useState } from "react";
|
|
|
|
import classNames from "@calcom/lib/classNames";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import slugify from "@calcom/lib/slugify";
|
|
import { trpc } from "@calcom/trpc";
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
Icon,
|
|
Input,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
showToast,
|
|
} from "@calcom/ui";
|
|
|
|
import type { UserTableUser } from "../types";
|
|
|
|
interface Props {
|
|
table: Table<UserTableUser>;
|
|
filters: ColumnFiltersState;
|
|
}
|
|
|
|
function useSelectedAttributes() {
|
|
const [selectedAttribute, setSelectedAttribute] = useQueryState("a", parseAsString);
|
|
const utils = trpc.useUtils();
|
|
const attributeData = utils.viewer.attributes.list.getData();
|
|
const foundAttribute = attributeData?.find((attr) => attr.id === selectedAttribute);
|
|
|
|
return {
|
|
selectedAttribute,
|
|
setSelectedAttribute,
|
|
foundAttributeInCache: foundAttribute,
|
|
};
|
|
}
|
|
|
|
function useSelectedAttributeOption() {
|
|
return useQueryState("ao", parseAsArrayOf(parseAsString).withDefault([]));
|
|
}
|
|
|
|
function SelectedAttributeToAssign() {
|
|
const { t } = useLocale();
|
|
const [selectedAttributeOption, setSelectedAttributeOption] = useSelectedAttributeOption();
|
|
const { selectedAttribute, setSelectedAttribute } = useSelectedAttributes();
|
|
const utils = trpc.useUtils();
|
|
const attributeData = utils.viewer.attributes.list.getData();
|
|
const foundAttribute = attributeData?.find((attr) => attr.id === selectedAttribute);
|
|
|
|
if (!foundAttribute) {
|
|
setSelectedAttribute(null);
|
|
return null;
|
|
}
|
|
|
|
function getTranslateableStringFromType(type: string) {
|
|
switch (type) {
|
|
case "SINGLE_SELECT":
|
|
return "single_select";
|
|
case "MULTI_SELECT":
|
|
return "multi_select";
|
|
case "TEXT":
|
|
return "text";
|
|
case "NUMBER":
|
|
return "number";
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
const translateableType = getTranslateableStringFromType(foundAttribute.type);
|
|
|
|
const isSelectable = foundAttribute.type === "SINGLE_SELECT" || foundAttribute.type === "MULTI_SELECT";
|
|
|
|
return (
|
|
<CommandList>
|
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|
<span className="block">{foundAttribute.name}</span>
|
|
{translateableType && <span className="text-muted block text-xs">({t(translateableType)})</span>}
|
|
</div>
|
|
<CommandGroup>
|
|
{isSelectable ? (
|
|
<>
|
|
{foundAttribute.options.map((option) => {
|
|
return (
|
|
<CommandItem
|
|
key={option.id}
|
|
className="hover:cursor-pointer"
|
|
onSelect={() => {
|
|
if (foundAttribute.type === "SINGLE_SELECT") {
|
|
setSelectedAttributeOption([option.id]);
|
|
} else {
|
|
setSelectedAttributeOption((prev) => {
|
|
if (prev.includes(option.id)) {
|
|
return prev.filter((id) => id !== option.id);
|
|
}
|
|
return [...prev, option.id];
|
|
});
|
|
}
|
|
}}>
|
|
<span>{option.value}</span>
|
|
<div
|
|
className={classNames(
|
|
"ml-auto flex h-4 w-4 items-center justify-center rounded-sm border"
|
|
)}>
|
|
{selectedAttributeOption?.includes(option.id) ? (
|
|
<Icon name="check" className={classNames("h-4 w-4")} />
|
|
) : null}
|
|
</div>
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</>
|
|
) : (
|
|
<>
|
|
<CommandItem>
|
|
<Input
|
|
defaultValue={selectedAttributeOption[0] || ""}
|
|
type={foundAttribute.type === "TEXT" ? "text" : "number"}
|
|
onBlur={(e) => {
|
|
// trigger onBlur so it's set as Apply is pressed (but not onChange) which triggers
|
|
// a re-render which also loses focus.
|
|
setSelectedAttributeOption([e.target.value]);
|
|
}}
|
|
/>
|
|
</CommandItem>
|
|
</>
|
|
)}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
);
|
|
}
|
|
|
|
export function MassAssignAttributesBulkAction({ table, filters }: Props) {
|
|
const { selectedAttribute, setSelectedAttribute, foundAttributeInCache } = useSelectedAttributes();
|
|
const [selectedAttributeOptions, setSelectedAttributeOptions] = useSelectedAttributeOption();
|
|
const [showMultiSelectWarning, setShowMultiSelectWarning] = useState(false);
|
|
const { t } = useLocale();
|
|
const utils = trpc.useContext();
|
|
const bulkAssignAttributes = trpc.viewer.attributes.bulkAssignAttributes.useMutation({
|
|
onSuccess: (success) => {
|
|
// Optimistically update the infinite query data
|
|
const selectedRows = table.getSelectedRowModel().flatRows;
|
|
|
|
utils.viewer.organizations.listMembers.setInfiniteData(
|
|
{
|
|
limit: 10,
|
|
searchTerm: "",
|
|
expand: ["attributes"],
|
|
filters: filters.map((filter) => ({
|
|
id: filter.id,
|
|
value: filter.value as string[],
|
|
})),
|
|
},
|
|
// @ts-expect-error i really dont know how to type this
|
|
(oldData) => {
|
|
const newPages = oldData?.pages.map((page) => ({
|
|
...page,
|
|
rows: page.rows.map((row) => {
|
|
if (selectedRows.some((selectedRow) => selectedRow.original.id === row.id)) {
|
|
// Update the attributes for the selected users
|
|
|
|
const attributeOptionValues = foundAttributeInCache?.options.filter((option) =>
|
|
selectedAttributeOptions.includes(option.id)
|
|
);
|
|
|
|
const newAttributes =
|
|
row.attributes?.filter((attr) => attr.attributeId !== selectedAttribute) || [];
|
|
|
|
if (attributeOptionValues && attributeOptionValues.length > 0) {
|
|
const newAttributeValues = attributeOptionValues?.map((value) => ({
|
|
id: value.id,
|
|
attributeId: value.attributeId,
|
|
value: value.value,
|
|
slug: value.slug,
|
|
}));
|
|
newAttributes.push(...newAttributeValues);
|
|
} else {
|
|
// Text or number input we don't have an option to fall back on
|
|
newAttributes.push({
|
|
id: "-1",
|
|
attributeId: foundAttributeInCache?.id ?? "-1",
|
|
value: selectedAttributeOptions[0],
|
|
slug: slugify(selectedAttributeOptions[0]),
|
|
});
|
|
}
|
|
|
|
return {
|
|
...row,
|
|
attributes: newAttributes,
|
|
};
|
|
}
|
|
return row;
|
|
}),
|
|
}));
|
|
|
|
return {
|
|
...oldData,
|
|
pages: newPages,
|
|
};
|
|
}
|
|
);
|
|
|
|
setSelectedAttribute(null);
|
|
setSelectedAttributeOptions([]);
|
|
showToast(success.message, "success");
|
|
},
|
|
onError: (error) => {
|
|
showToast(`Error assigning attributes: ${error.message}`, "error");
|
|
},
|
|
});
|
|
const { data } = trpc.viewer.attributes.list.useQuery();
|
|
|
|
function Content() {
|
|
if (!selectedAttribute) {
|
|
return (
|
|
<>
|
|
<CommandInput placeholder={t("search")} />
|
|
<CommandList>
|
|
<CommandEmpty>No attributes found</CommandEmpty>
|
|
<CommandGroup>
|
|
{data &&
|
|
data.map((option) => {
|
|
return (
|
|
<CommandItem
|
|
key={option.id}
|
|
className="hover:cursor-pointer"
|
|
onSelect={() => {
|
|
setSelectedAttribute(option.id);
|
|
}}>
|
|
<span>{option.name}</span>
|
|
<div
|
|
className={classNames("ml-auto flex h-4 w-4 items-center justify-center rounded-sm")}>
|
|
<Icon name="chevron-right" className={classNames("h-4 w-4")} />
|
|
</div>
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (showMultiSelectWarning) {
|
|
return (
|
|
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden px-3 py-2">
|
|
<Alert
|
|
severity="warning"
|
|
message="You are mass assigning to a multi select. This will assign the attribute to all selected users and
|
|
will not override existing values."
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (selectedAttribute) {
|
|
return <SelectedAttributeToAssign />;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button StartIcon="map-pin">{t("add_attributes")}</Button>
|
|
</PopoverTrigger>
|
|
{/* We dont really use shadows much - but its needed here */}
|
|
<PopoverContent className="p-0 shadow-md" align="start" sideOffset={12}>
|
|
<Command>
|
|
<Content />
|
|
</Command>
|
|
<div className="my-1.5 flex w-full justify-end gap-2 p-1.5">
|
|
{selectedAttribute ? (
|
|
<>
|
|
<Button
|
|
color="secondary"
|
|
className="rounded-md"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedAttribute(null);
|
|
setSelectedAttributeOptions([]);
|
|
setShowMultiSelectWarning(false);
|
|
}}>
|
|
{t("clear")}
|
|
</Button>
|
|
<Button
|
|
className="rounded-md"
|
|
loading={bulkAssignAttributes.isPending}
|
|
size="sm"
|
|
onClick={() => {
|
|
if (
|
|
foundAttributeInCache &&
|
|
foundAttributeInCache.type === "MULTI_SELECT" &&
|
|
!showMultiSelectWarning
|
|
) {
|
|
setShowMultiSelectWarning(true);
|
|
} else {
|
|
if (!foundAttributeInCache) {
|
|
return;
|
|
}
|
|
setShowMultiSelectWarning(false);
|
|
let attributesToAssign;
|
|
if (
|
|
foundAttributeInCache?.type === "MULTI_SELECT" ||
|
|
foundAttributeInCache?.type === "SINGLE_SELECT"
|
|
) {
|
|
attributesToAssign = [
|
|
{
|
|
id: foundAttributeInCache.id,
|
|
options: selectedAttributeOptions.map((v) => ({
|
|
value: v,
|
|
})),
|
|
},
|
|
];
|
|
} else {
|
|
attributesToAssign = [
|
|
{ id: foundAttributeInCache.id, value: selectedAttributeOptions[0] },
|
|
];
|
|
}
|
|
|
|
bulkAssignAttributes.mutate({
|
|
attributes: attributesToAssign,
|
|
userIds: table.getSelectedRowModel().rows.map((row) => row.original.id),
|
|
});
|
|
}
|
|
}}>
|
|
{t("apply")}
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</>
|
|
);
|
|
}
|