feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)

* feat: add membership creation date to Organization Member List table (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add migration for membership creation date (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: make createdAt and updatedAt nullable (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add updatedAt column to Organization Member List table (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use type assertion to access createdAt and updatedAt fields (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: display N/A for null date values (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use proper type assertions for createdAt and updatedAt fields (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: add createdAt and updatedAt to UserTableUser mock in test (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add PostgreSQL trigger for membership timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use empty string instead of N/A and add translations for column headers (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* add i18n text

* clean up type issue

* feat: add translation keys for column headers (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* disable sort

* remove duplicated i18n texts

* feat: add filters for lastActiveAt, createdAt, and updatedAt (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* support date range filter

* fix date range for end date

* hide columns by default

* revert wrong change

* add missing selects

* fix e2e test

* fix: remove PostgreSQL trigger and let application handle timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add application-level timestamp handling for Membership model (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* add more timestamp handling

* refactor: use Prisma's built-in decorators for Membership timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* refactor: remove application-level timestamp handling in favor of Prisma decorators (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* refactor: remove more application-level timestamp handling in favor of Prisma decorators (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix e2e test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
devin-ai-integration[bot]
2025-04-24 10:48:06 -03:00
committed by GitHub
co-authored by keith@cal.com Anik Dhabal Babu Eunjae Lee
parent 4cc1bc6746
commit d21f7ccbc8
13 changed files with 152 additions and 14 deletions
+1 -3
View File
@@ -660,9 +660,7 @@ test.describe("Out of office", () => {
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await expect(
page
.locator('[data-testid="filter-popover-trigger-dateRange"] span', { hasText: "Last 7 Days" })
.nth(0)
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
).toBeVisible();
});
@@ -3093,6 +3093,9 @@
"enable_delegation_credential_description": "Grant Cal.com automatic access to the calendars of all organization members by enabling delegation credential.",
"disable_delegation_credential": "Disable Delegation Credential",
"disable_delegation_credential_description": "Once delegation credential is disabled, organization members who haven’t connected their calendars will need to do so manually.",
"last_active": "Last Active",
"member_since": "Member Since",
"last_updated": "Last Updated",
"salesforce_on_cancel_write_to_event": "On cancelled booking, write to event record instead of deleting event",
"salesforce_on_every_cancellation": "On every cancellation",
"report_issue": "Report issue",
+2
View File
@@ -68,6 +68,8 @@ describe("generate Csv for Org Users Table", () => {
teams: [],
attributes: [],
lastActiveAt: "",
createdAt: null,
updatedAt: null,
};
it("should throw if no headers", () => {
@@ -30,6 +30,7 @@ export function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
key={column.id}
column={column}
options={column.dateRangeOptions}
showColumnName
showClearButton
/>
);
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button, buttonClasses } from "@calcom/ui/components/button";
import {
Command,
@@ -35,14 +36,21 @@ import { useFilterPopoverOpen } from "./useFilterPopoverOpen";
type DateRangeFilterProps = {
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
options?: DateRangeFilterOptions;
showColumnName?: boolean;
showClearButton?: boolean;
};
export const DateRangeFilter = ({ column, options, showClearButton = false }: DateRangeFilterProps) => {
export const DateRangeFilter = ({
column,
options,
showColumnName = false,
showClearButton = false,
}: DateRangeFilterProps) => {
const { open, onOpenChange } = useFilterPopoverOpen(column.id);
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const range = options?.range ?? "past";
const endOfDay = options?.endOfDay ?? false;
const forceCustom = range === "custom";
const forcePast = range === "past";
@@ -73,13 +81,13 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: startDate.toDate().toISOString(),
endDate: endDate.toDate().toISOString(),
endDate: (endOfDay ? endDate.endOf("day") : endDate).toDate().toISOString(),
preset: preset.value,
},
});
}
},
[column.id]
[column.id, endOfDay]
);
useEffect(() => {
@@ -137,6 +145,10 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
customButtonLabel = `${format(startDate.toDate(), "LLL dd, y")} - ?`;
}
const selectedValue = isCustomPreset
? customButtonLabel
: t(selectedPreset.labelKey, selectedPreset.i18nOptions);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
@@ -146,8 +158,15 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
StartIcon="calendar-range"
EndIcon="chevron-down"
data-testid={`filter-popover-trigger-${column.id}`}>
{!isCustomPreset && <span>{t(selectedPreset.labelKey, selectedPreset.i18nOptions)}</span>}
{isCustomPreset && <span>{customButtonLabel}</span>}
{showColumnName && (
<>
<span>{column.title}</span>
<Badge variant="gray" className="ml-2">
{selectedValue}
</Badge>
</>
)}
{!showColumnName && <span>{selectedValue}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-fit p-0" align="end">
@@ -4,6 +4,7 @@ import {
isMultiSelectFilterValue,
isTextFilterValue,
isNumberFilterValue,
isDateRangeFilterValue,
} from "./utils";
type MakeWhereClauseProps = {
@@ -154,6 +155,19 @@ export function makeWhereClause(props: MakeWhereClauseProps) {
default:
throw new Error(`Invalid operator for number filter: ${operator}`);
}
} else if (isDateRangeFilterValue(filterValue)) {
const { startDate, endDate } = filterValue.data;
if (!startDate || !endDate) {
throw new Error(`Invalid date range filter: ${JSON.stringify({ columnName, startDate, endDate })}`);
}
return {
[columnName]: {
...jsonPathObj,
gte: startDate,
lte: endDate,
},
};
}
throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`);
}
+2 -1
View File
@@ -113,7 +113,8 @@ export const ZFilterValue = z.union([
]);
export type DateRangeFilterOptions = {
range: "past" | "custom";
range?: "past" | "custom";
endOfDay?: boolean;
};
export type TextFilterOptions = {
@@ -131,7 +131,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
enableHiding: false,
size: 200,
header: () => {
return `Managed Users`;
return t("managed_users");
},
cell: ({ row }) => {
if (isPending) {
@@ -166,7 +166,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
{
id: "role",
accessorFn: (data) => data.role,
header: "Role",
header: t("role"),
size: 100,
cell: ({ row, table }) => {
if (isPending) {
@@ -188,7 +188,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
{
id: "teams",
accessorFn: (data) => data.teams.map((team) => team.name),
header: "Teams",
header: t("teams"),
size: 140,
cell: ({ row, table }) => {
if (isPending) {
@@ -74,6 +74,8 @@ const initalColumnVisibility = {
member: true,
role: true,
teams: true,
createdAt: false,
updatedAt: false,
actions: true,
};
@@ -366,9 +368,52 @@ function UserListTableContent() {
...generateAttributeColumns(),
{
id: "lastActiveAt",
header: "Last Active",
accessorKey: "lastActiveAt",
header: t("last_active"),
enableSorting: false,
enableColumnFilter: true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.lastActiveAt}</div>,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: t("member_since"),
enableSorting: false,
enableColumnFilter: true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.createdAt || ""}</div>,
},
{
id: "updatedAt",
accessorKey: "updatedAt",
header: t("last_updated"),
enableSorting: false,
enableColumnFilter: true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.updatedAt || ""}</div>,
},
{
id: "actions",
enableHiding: false,
@@ -0,0 +1,2 @@
ALTER TABLE "Membership" ADD COLUMN "createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3);
+2
View File
@@ -546,6 +546,8 @@ model Membership {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
disableImpersonation Boolean @default(false)
AttributeToUser AttributeToUser[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@unique([userId, teamId])
@@index([teamId])
+4
View File
@@ -188,6 +188,8 @@ async function main() {
userId: member.user.id,
role: member.role,
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
})),
});
}
@@ -424,6 +426,8 @@ async function createPerformanceData() {
userId: memberId.id,
role: "MEMBER",
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
})),
});
@@ -83,6 +83,15 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
const teamFilter = filters.find((filter) => filter.id === "teams") as
| TypedColumnFilter<ColumnFilterType.MULTI_SELECT>
| undefined;
const lastActiveAtFilter = filters.find((filter) => filter.id === "lastActiveAt") as
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
| undefined;
const createdAtFilter = filters.find((filter) => filter.id === "createdAt") as
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
| undefined;
const updatedAtFilter = filters.find((filter) => filter.id === "updatedAt") as
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
| undefined;
const whereClause: Prisma.MembershipWhereInput = {
user: {
@@ -97,6 +106,11 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
},
},
}),
...(lastActiveAtFilter &&
makeWhereClause({
columnName: "lastActiveAt",
filterValue: lastActiveAtFilter.value,
})),
},
teamId: organizationId,
...(searchTerm && {
@@ -112,10 +126,27 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
columnName: "role",
filterValue: roleFilter.value,
})),
...(createdAtFilter &&
makeWhereClause({
columnName: "createdAt",
filterValue: createdAtFilter.value,
})),
...(updatedAtFilter &&
makeWhereClause({
columnName: "updatedAt",
filterValue: updatedAtFilter.value,
})),
};
const attributeFilters: Prisma.MembershipWhereInput["AttributeToUser"][] = filters
.filter((filter) => filter.id !== "role" && filter.id !== "teams")
.filter(
(filter) =>
filter.id !== "role" &&
filter.id !== "teams" &&
filter.id !== "lastActiveAt" &&
filter.id !== "createdAt" &&
filter.id !== "updatedAt"
)
.map((filter) => {
if (filter.value.type === ColumnFilterType.MULTI_SELECT && isAllString(filter.value.data)) {
const attributeOptionValues: string[] = [];
@@ -163,6 +194,8 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
id: true,
role: true,
accepted: true,
createdAt: true,
updatedAt: true,
user: {
select: {
id: true,
@@ -249,6 +282,20 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
.format(membership.user.lastActiveAt)
.toLowerCase()
: null,
createdAt: membership.createdAt
? new Intl.DateTimeFormat(ctx.user.locale, {
timeZone: ctx.user.timeZone,
})
.format(membership.createdAt)
.toLowerCase()
: null,
updatedAt: membership.updatedAt
? new Intl.DateTimeFormat(ctx.user.locale, {
timeZone: ctx.user.timeZone,
})
.format(membership.updatedAt)
.toLowerCase()
: null,
avatarUrl: user.avatarUrl,
teams: user.teams
.filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team