Skip to content

Commit d21f7cc

Browse files
devin-ai-integration[bot]keith@cal.comanikdhabaleunjae-lee
authored
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: [email protected] <[email protected]> * feat: add migration for membership creation date (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * feat: make createdAt and updatedAt nullable (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * feat: add updatedAt column to Organization Member List table (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * fix: use type assertion to access createdAt and updatedAt fields (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * fix: display N/A for null date values (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * fix: use proper type assertions for createdAt and updatedAt fields (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * fix: add createdAt and updatedAt to UserTableUser mock in test (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * feat: add PostgreSQL trigger for membership timestamps (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * fix: use empty string instead of N/A and add translations for column headers (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * add i18n text * clean up type issue * feat: add translation keys for column headers (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * disable sort * remove duplicated i18n texts * feat: add filters for lastActiveAt, createdAt, and updatedAt (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * 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: [email protected] <[email protected]> * feat: add application-level timestamp handling for Membership model (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * add more timestamp handling * refactor: use Prisma's built-in decorators for Membership timestamps (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * refactor: remove application-level timestamp handling in favor of Prisma decorators (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * refactor: remove more application-level timestamp handling in favor of Prisma decorators (CAL-5406) Co-Authored-By: [email protected] <[email protected]> * fix e2e test --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]> Co-authored-by: Anik Dhabal Babu <[email protected]> Co-authored-by: Eunjae Lee <[email protected]>
1 parent 4cc1bc6 commit d21f7cc

File tree

13 files changed

+152
-14
lines changed

13 files changed

+152
-14
lines changed

apps/web/playwright/out-of-office.e2e.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -660,9 +660,7 @@ test.describe("Out of office", () => {
660660
await page.locator('[data-testid="add-filter-button"]').click();
661661
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
662662
await expect(
663-
page
664-
.locator('[data-testid="filter-popover-trigger-dateRange"] span', { hasText: "Last 7 Days" })
665-
.nth(0)
663+
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
666664
).toBeVisible();
667665
});
668666

apps/web/public/static/locales/en/common.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3093,6 +3093,9 @@
30933093
"enable_delegation_credential_description": "Grant Cal.com automatic access to the calendars of all organization members by enabling delegation credential.",
30943094
"disable_delegation_credential": "Disable Delegation Credential",
30953095
"disable_delegation_credential_description": "Once delegation credential is disabled, organization members who haven’t connected their calendars will need to do so manually.",
3096+
"last_active": "Last Active",
3097+
"member_since": "Member Since",
3098+
"last_updated": "Last Updated",
30963099
"salesforce_on_cancel_write_to_event": "On cancelled booking, write to event record instead of deleting event",
30973100
"salesforce_on_every_cancellation": "On every cancellation",
30983101
"report_issue": "Report issue",

apps/web/test/lib/generateCsv.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ describe("generate Csv for Org Users Table", () => {
6868
teams: [],
6969
attributes: [],
7070
lastActiveAt: "",
71+
createdAt: null,
72+
updatedAt: null,
7173
};
7274

7375
it("should throw if no headers", () => {

packages/features/data-table/components/filters/ActiveFilters.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
3030
key={column.id}
3131
column={column}
3232
options={column.dateRangeOptions}
33+
showColumnName
3334
showClearButton
3435
/>
3536
);

packages/features/data-table/components/filters/DateRangeFilter.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from "react";
55
import dayjs from "@calcom/dayjs";
66
import { useLocale } from "@calcom/lib/hooks/useLocale";
77
import classNames from "@calcom/ui/classNames";
8+
import { Badge } from "@calcom/ui/components/badge";
89
import { Button, buttonClasses } from "@calcom/ui/components/button";
910
import {
1011
Command,
@@ -35,14 +36,21 @@ import { useFilterPopoverOpen } from "./useFilterPopoverOpen";
3536
type DateRangeFilterProps = {
3637
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
3738
options?: DateRangeFilterOptions;
39+
showColumnName?: boolean;
3840
showClearButton?: boolean;
3941
};
4042

41-
export const DateRangeFilter = ({ column, options, showClearButton = false }: DateRangeFilterProps) => {
43+
export const DateRangeFilter = ({
44+
column,
45+
options,
46+
showColumnName = false,
47+
showClearButton = false,
48+
}: DateRangeFilterProps) => {
4249
const { open, onOpenChange } = useFilterPopoverOpen(column.id);
4350
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
4451
const { updateFilter, removeFilter } = useDataTable();
4552
const range = options?.range ?? "past";
53+
const endOfDay = options?.endOfDay ?? false;
4654
const forceCustom = range === "custom";
4755
const forcePast = range === "past";
4856

@@ -73,13 +81,13 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
7381
type: ColumnFilterType.DATE_RANGE,
7482
data: {
7583
startDate: startDate.toDate().toISOString(),
76-
endDate: endDate.toDate().toISOString(),
84+
endDate: (endOfDay ? endDate.endOf("day") : endDate).toDate().toISOString(),
7785
preset: preset.value,
7886
},
7987
});
8088
}
8189
},
82-
[column.id]
90+
[column.id, endOfDay]
8391
);
8492

8593
useEffect(() => {
@@ -137,6 +145,10 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
137145
customButtonLabel = `${format(startDate.toDate(), "LLL dd, y")} - ?`;
138146
}
139147

148+
const selectedValue = isCustomPreset
149+
? customButtonLabel
150+
: t(selectedPreset.labelKey, selectedPreset.i18nOptions);
151+
140152
return (
141153
<Popover open={open} onOpenChange={onOpenChange}>
142154
<PopoverTrigger asChild>
@@ -146,8 +158,15 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
146158
StartIcon="calendar-range"
147159
EndIcon="chevron-down"
148160
data-testid={`filter-popover-trigger-${column.id}`}>
149-
{!isCustomPreset && <span>{t(selectedPreset.labelKey, selectedPreset.i18nOptions)}</span>}
150-
{isCustomPreset && <span>{customButtonLabel}</span>}
161+
{showColumnName && (
162+
<>
163+
<span>{column.title}</span>
164+
<Badge variant="gray" className="ml-2">
165+
{selectedValue}
166+
</Badge>
167+
</>
168+
)}
169+
{!showColumnName && <span>{selectedValue}</span>}
151170
</Button>
152171
</PopoverTrigger>
153172
<PopoverContent className="flex w-fit p-0" align="end">

packages/features/data-table/lib/server.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isMultiSelectFilterValue,
55
isTextFilterValue,
66
isNumberFilterValue,
7+
isDateRangeFilterValue,
78
} from "./utils";
89

910
type MakeWhereClauseProps = {
@@ -154,6 +155,19 @@ export function makeWhereClause(props: MakeWhereClauseProps) {
154155
default:
155156
throw new Error(`Invalid operator for number filter: ${operator}`);
156157
}
158+
} else if (isDateRangeFilterValue(filterValue)) {
159+
const { startDate, endDate } = filterValue.data;
160+
if (!startDate || !endDate) {
161+
throw new Error(`Invalid date range filter: ${JSON.stringify({ columnName, startDate, endDate })}`);
162+
}
163+
164+
return {
165+
[columnName]: {
166+
...jsonPathObj,
167+
gte: startDate,
168+
lte: endDate,
169+
},
170+
};
157171
}
158172
throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`);
159173
}

packages/features/data-table/lib/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ export const ZFilterValue = z.union([
113113
]);
114114

115115
export type DateRangeFilterOptions = {
116-
range: "past" | "custom";
116+
range?: "past" | "custom";
117+
endOfDay?: boolean;
117118
};
118119

119120
export type TextFilterOptions = {

packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
131131
enableHiding: false,
132132
size: 200,
133133
header: () => {
134-
return `Managed Users`;
134+
return t("managed_users");
135135
},
136136
cell: ({ row }) => {
137137
if (isPending) {
@@ -166,7 +166,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
166166
{
167167
id: "role",
168168
accessorFn: (data) => data.role,
169-
header: "Role",
169+
header: t("role"),
170170
size: 100,
171171
cell: ({ row, table }) => {
172172
if (isPending) {
@@ -188,7 +188,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
188188
{
189189
id: "teams",
190190
accessorFn: (data) => data.teams.map((team) => team.name),
191-
header: "Teams",
191+
header: t("teams"),
192192
size: 140,
193193
cell: ({ row, table }) => {
194194
if (isPending) {

packages/features/users/components/UserTable/UserListTable.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ const initalColumnVisibility = {
7474
member: true,
7575
role: true,
7676
teams: true,
77+
createdAt: false,
78+
updatedAt: false,
7779
actions: true,
7880
};
7981

@@ -366,9 +368,52 @@ function UserListTableContent() {
366368
...generateAttributeColumns(),
367369
{
368370
id: "lastActiveAt",
369-
header: "Last Active",
371+
accessorKey: "lastActiveAt",
372+
header: t("last_active"),
373+
enableSorting: false,
374+
enableColumnFilter: true,
375+
meta: {
376+
filter: {
377+
type: ColumnFilterType.DATE_RANGE,
378+
dateRangeOptions: {
379+
endOfDay: true,
380+
},
381+
},
382+
},
370383
cell: ({ row }) => <div>{row.original.lastActiveAt}</div>,
371384
},
385+
{
386+
id: "createdAt",
387+
accessorKey: "createdAt",
388+
header: t("member_since"),
389+
enableSorting: false,
390+
enableColumnFilter: true,
391+
meta: {
392+
filter: {
393+
type: ColumnFilterType.DATE_RANGE,
394+
dateRangeOptions: {
395+
endOfDay: true,
396+
},
397+
},
398+
},
399+
cell: ({ row }) => <div>{row.original.createdAt || ""}</div>,
400+
},
401+
{
402+
id: "updatedAt",
403+
accessorKey: "updatedAt",
404+
header: t("last_updated"),
405+
enableSorting: false,
406+
enableColumnFilter: true,
407+
meta: {
408+
filter: {
409+
type: ColumnFilterType.DATE_RANGE,
410+
dateRangeOptions: {
411+
endOfDay: true,
412+
},
413+
},
414+
},
415+
cell: ({ row }) => <div>{row.original.updatedAt || ""}</div>,
416+
},
372417
{
373418
id: "actions",
374419
enableHiding: false,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "Membership" ADD COLUMN "createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
2+
ADD COLUMN "updatedAt" TIMESTAMP(3);

packages/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,8 @@ model Membership {
546546
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
547547
disableImpersonation Boolean @default(false)
548548
AttributeToUser AttributeToUser[]
549+
createdAt DateTime? @default(now())
550+
updatedAt DateTime? @updatedAt
549551
550552
@@unique([userId, teamId])
551553
@@index([teamId])

packages/prisma/seed-insights.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ async function main() {
188188
userId: member.user.id,
189189
role: member.role,
190190
accepted: true,
191+
createdAt: new Date(),
192+
updatedAt: new Date(),
191193
})),
192194
});
193195
}
@@ -424,6 +426,8 @@ async function createPerformanceData() {
424426
userId: memberId.id,
425427
role: "MEMBER",
426428
accepted: true,
429+
createdAt: new Date(),
430+
updatedAt: new Date(),
427431
})),
428432
});
429433

packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
8383
const teamFilter = filters.find((filter) => filter.id === "teams") as
8484
| TypedColumnFilter<ColumnFilterType.MULTI_SELECT>
8585
| undefined;
86+
const lastActiveAtFilter = filters.find((filter) => filter.id === "lastActiveAt") as
87+
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
88+
| undefined;
89+
const createdAtFilter = filters.find((filter) => filter.id === "createdAt") as
90+
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
91+
| undefined;
92+
const updatedAtFilter = filters.find((filter) => filter.id === "updatedAt") as
93+
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
94+
| undefined;
8695

8796
const whereClause: Prisma.MembershipWhereInput = {
8897
user: {
@@ -97,6 +106,11 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
97106
},
98107
},
99108
}),
109+
...(lastActiveAtFilter &&
110+
makeWhereClause({
111+
columnName: "lastActiveAt",
112+
filterValue: lastActiveAtFilter.value,
113+
})),
100114
},
101115
teamId: organizationId,
102116
...(searchTerm && {
@@ -112,10 +126,27 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
112126
columnName: "role",
113127
filterValue: roleFilter.value,
114128
})),
129+
...(createdAtFilter &&
130+
makeWhereClause({
131+
columnName: "createdAt",
132+
filterValue: createdAtFilter.value,
133+
})),
134+
...(updatedAtFilter &&
135+
makeWhereClause({
136+
columnName: "updatedAt",
137+
filterValue: updatedAtFilter.value,
138+
})),
115139
};
116140

117141
const attributeFilters: Prisma.MembershipWhereInput["AttributeToUser"][] = filters
118-
.filter((filter) => filter.id !== "role" && filter.id !== "teams")
142+
.filter(
143+
(filter) =>
144+
filter.id !== "role" &&
145+
filter.id !== "teams" &&
146+
filter.id !== "lastActiveAt" &&
147+
filter.id !== "createdAt" &&
148+
filter.id !== "updatedAt"
149+
)
119150
.map((filter) => {
120151
if (filter.value.type === ColumnFilterType.MULTI_SELECT && isAllString(filter.value.data)) {
121152
const attributeOptionValues: string[] = [];
@@ -163,6 +194,8 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
163194
id: true,
164195
role: true,
165196
accepted: true,
197+
createdAt: true,
198+
updatedAt: true,
166199
user: {
167200
select: {
168201
id: true,
@@ -249,6 +282,20 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
249282
.format(membership.user.lastActiveAt)
250283
.toLowerCase()
251284
: null,
285+
createdAt: membership.createdAt
286+
? new Intl.DateTimeFormat(ctx.user.locale, {
287+
timeZone: ctx.user.timeZone,
288+
})
289+
.format(membership.createdAt)
290+
.toLowerCase()
291+
: null,
292+
updatedAt: membership.updatedAt
293+
? new Intl.DateTimeFormat(ctx.user.locale, {
294+
timeZone: ctx.user.timeZone,
295+
})
296+
.format(membership.updatedAt)
297+
.toLowerCase()
298+
: null,
252299
avatarUrl: user.avatarUrl,
253300
teams: user.teams
254301
.filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team

0 commit comments

Comments
 (0)