diff --git a/apps/web/components/eventtype/EventLimitsTab.tsx b/apps/web/components/eventtype/EventLimitsTab.tsx index 2d422c7344..7bc559266f 100644 --- a/apps/web/components/eventtype/EventLimitsTab.tsx +++ b/apps/web/components/eventtype/EventLimitsTab.tsx @@ -189,7 +189,7 @@ export const EventLimitsTab = ({ eventType }: Pick ({ - label: `minutes ${t("minutes")}`, + label: `${minutes} ${t("minutes")}`, value: minutes, })), ]; @@ -225,7 +225,7 @@ export const EventLimitsTab = ({ eventType }: Pick ({ - label: `minutes ${t("minutes")}`, + label: `${minutes} ${t("minutes")}`, value: minutes, })), ]; @@ -272,7 +272,7 @@ export const EventLimitsTab = ({ eventType }: Pick ({ - label: `minutes ${t("minutes")}`, + label: `${minutes} ${t("minutes")}`, value: minutes, })), ]; diff --git a/apps/web/package.json b/apps/web/package.json index 08a0fff1ef..89dbc162ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.3.4", + "version": "3.3.5", "private": true, "scripts": { "analyze": "ANALYZE=true next build", diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts new file mode 100644 index 0000000000..bcf0a71b53 --- /dev/null +++ b/apps/web/playwright/insights.e2e.ts @@ -0,0 +1,239 @@ +import { expect } from "@playwright/test"; + +import { randomString } from "@calcom/lib/random"; +import prisma from "@calcom/prisma"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +const createTeamsAndMembership = async (userIdOne: number, userIdTwo: number) => { + const teamOne = await prisma.team.create({ + data: { + name: "test-insights", + slug: `test-insights-${Date.now()}-${randomString(5)}}`, + }, + }); + + const teamTwo = await prisma.team.create({ + data: { + name: "test-insights-2", + slug: `test-insights-2-${Date.now()}-${randomString(5)}}`, + }, + }); + if (!userIdOne || !userIdTwo || !teamOne || !teamTwo) { + throw new Error("Failed to create test data"); + } + + // create memberships + await prisma.membership.create({ + data: { + userId: userIdOne, + teamId: teamOne.id, + accepted: true, + role: "ADMIN", + }, + }); + await prisma.membership.create({ + data: { + teamId: teamTwo.id, + userId: userIdOne, + accepted: true, + role: "ADMIN", + }, + }); + await prisma.membership.create({ + data: { + teamId: teamOne.id, + userId: userIdTwo, + accepted: true, + role: "MEMBER", + }, + }); + await prisma.membership.create({ + data: { + teamId: teamTwo.id, + userId: userIdTwo, + accepted: true, + role: "MEMBER", + }, + }); + return { teamOne, teamTwo }; +}; + +test.afterAll(async ({ users }) => { + await users.deleteAll(); +}); + +test.describe("Insights", async () => { + test("should be able to go to insights as admins", async ({ page, users }) => { + const user = await users.create(); + const userTwo = await users.create(); + await createTeamsAndMembership(user.id, userTwo.id); + + await user.apiLogin(); + + // go to insights page + await page.goto("/insights"); + await page.waitForLoadState("networkidle"); + + // expect url to have isAll and TeamId in query params + expect(page.url()).toContain("isAll=false"); + expect(page.url()).toContain("teamId="); + }); + + test("should be able to go to insights as members", async ({ page, users }) => { + const user = await users.create(); + const userTwo = await users.create(); + + await userTwo.apiLogin(); + + await createTeamsAndMembership(user.id, userTwo.id); + // go to insights page + await page.goto("/insights"); + + await page.waitForLoadState("networkidle"); + + // expect url to have isAll and TeamId in query params + + expect(page.url()).toContain("isAll=false"); + expect(page.url()).not.toContain("teamId="); + }); + + test("team select filter should have 2 teams and your account option only as member", async ({ + page, + users, + }) => { + const user = await users.create(); + const userTwo = await users.create(); + + await user.apiLogin(); + + await createTeamsAndMembership(user.id, userTwo.id); + // go to insights page + await page.goto("/insights"); + + await page.waitForLoadState("networkidle"); + + // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 + await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); + await page + .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') + .click(); + const teamSelectFilter = await page.locator( + 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]' + ); + + await expect(teamSelectFilter).toHaveCount(3); + }); + + test("Insights Organization should have isAll option true", async ({ users, page }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + await owner.apiLogin(); + + await page.goto("/insights"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("dashboard-shell").getByText("All").nth(1).click(); + + const teamSelectFilter = await page.locator( + 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]' + ); + + await expect(teamSelectFilter).toHaveCount(4); + }); + + test("should have all option in team-and-self filter as admin", async ({ page, users }) => { + const owner = await users.create(); + const member = await users.create(); + + await createTeamsAndMembership(owner.id, member.id); + + await owner.apiLogin(); + + await page.goto("/insights"); + + // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 + await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); + await page + .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') + .click(); + const teamSelectFilter = await page.locator( + 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]' + ); + + await expect(teamSelectFilter).toHaveCount(3); + }); + + test("should be able to switch between teams and self profile for insights", async ({ page, users }) => { + const owner = await users.create(); + const member = await users.create(); + + await createTeamsAndMembership(owner.id, member.id); + + await owner.apiLogin(); + + await page.goto("/insights"); + + // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 + await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); + await page + .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') + .click(); + const teamSelectFilter = await page.locator( + 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]' + ); + + await expect(teamSelectFilter).toHaveCount(3); + + // switch to self profile + await page.getByTestId("dashboard-shell").getByText("Your Account").click(); + + // switch to team 1 + await page.getByTestId("dashboard-shell").getByText("test-insights").nth(0).click(); + + // switch to team 2 + await page.getByTestId("dashboard-shell").getByText("test-insights-2").click(); + }); + + test("should be able to switch between memberUsers", async ({ page, users }) => { + const owner = await users.create(); + const member = await users.create(); + + await createTeamsAndMembership(owner.id, member.id); + + await owner.apiLogin(); + + await page.goto("/insights"); + + await page.getByText("Add filter").click(); + + await page.getByRole("button", { name: "User" }).click(); + //
People
+ await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click(); + + await page + .locator('div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]') + .nth(0) + .click(); + await page.waitForLoadState("networkidle"); + + await page + .locator('div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]') + .nth(1) + .click(); + await page.waitForLoadState("networkidle"); + // press escape button to close the filter + await page.keyboard.press("Escape"); + + await page.getByRole("button", { name: "Clear" }).click(); + + // expect for "Team: test-insight" text in page + expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy(); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 74ac083f23..2491c787c4 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1658,7 +1658,7 @@ "no_recordings_found": "No recordings found", "new_workflow_subtitle": "New workflow for...", "reporting": "Reporting", - "reporting_feature": "See all incoming from data and download it as a CSV", + "reporting_feature": "See all incoming form data and download it as a CSV", "teams_plan_required": "Teams plan required", "routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.", "choose_a_license": "Choose a license", diff --git a/apps/web/public/static/locales/eu/common.json b/apps/web/public/static/locales/eu/common.json index 736535c4e6..01e610bfd4 100644 --- a/apps/web/public/static/locales/eu/common.json +++ b/apps/web/public/static/locales/eu/common.json @@ -7,17 +7,32 @@ "second_other": "{{count}} segundo", "upgrade_now": "Eguneratu orain", "accept_invitation": "Onartu gonbidapena", + "calcom_explained": "{{appName}}-ek bilerak programatzeko azpiegitura eskaintzen du guztiontzat.", + "calcom_explained_new_user": "Bukatu zure {{appName}} kontua konfiguratzen! Bileren programazio-arazo guztiak konpontzeko urrats gutxi batzuk besterik ez zaizkizu geratzen.", "have_any_questions": "Galderarik? Laguntzeko gaude.", "reset_password_subject": "{{appName}}: Pasahitza berrezartzeko argibideak", "verify_email_subject": "{{appName}}: egiaztatu zure kontua", "check_your_email": "Begiratu zure emaila", + "verify_email_page_body": "Email bat bidali dugu {{email}} helbidera. Garrantzitsua da zure email helbidea egiaztatzea, {{appName}}-tik mezuak eta egutegiko eguneratzeak ahalik eta hobekien jasoko dituzula bermatzeko.", + "verify_email_banner_body": "Egiaztatu zure email helbidea mezuak eta egutegiko eguneratzeak ahalik eta hobekien jasoko dituzula bermatzeko", "verify_email_email_header": "Egiaztatu zure email helbidea", "verify_email_email_button": "Egiaztatu emaila", + "verify_email_email_body": "Mesedez, egiaztatu zure email helbidea beheko botoia sakatuz.", + "verify_email_by_code_email_body": "Mesedez, egiaztatu zure email helbidea beheko kodea erabiliz.", + "verify_email_email_link_text": "Hemen duzu esteka, botoiak sakatzea gustuko ez baduzu:", + "email_verification_code": "Sartu egiaztatze-kodea", + "email_verification_code_placeholder": "Sartu zure email helbidera bidalitako egiaztatze-kodea", + "incorrect_email_verification_code": "Egiaztatze-kodea ez da zuzena.", + "email_sent": "Email mezua zuzen bidali da", + "email_not_sent": "Errore bat gertatu da email mezua bidaltzerakoan", "event_declined_subject": "Baztertua: {{title}} {{date}}(e)an", + "event_cancelled_subject": "Bertan behera: {{title}} {{date}}(e)an", "event_request_declined": "Zure gertaera-eskaera baztertua izan da", "event_request_declined_recurring": "Zure gertaera errepikari-eskaera baztertua izan da", + "event_request_cancelled": "Zure programatutako gertaera bertan behera utzi da", "organizer": "Antolatzailea", "need_to_reschedule_or_cancel": "Programazioa aldatu edo bertan behera utzi behar duzu?", + "no_options_available": "Ez dago aukerarik eskuragarri", "cancellation_reason": "Bertan behera uztearen arrazoia (aukerakoa)", "cancellation_reason_placeholder": "Zergatik utzi duzu bertan behera?", "rejection_reason": "Errefusatzeko arrazoia", @@ -25,7 +40,11 @@ "rejection_reason_description": "Ziur zaude erreserba errefusatu nahi duzula? Erreserba-eskaera egin duen pertsonari jakinaraziko zaio. Arrazoi bat adieraz dezakezu behean.", "rejection_confirmation": "Errefusatu erreserba", "manage_this_event": "Kudeatu gertaera hau", + "invite_team_member": "Gonbidatu taldekidea", + "invite_team_individual_segment": "Gonbidatu norbanakoa", + "invite_team_notifcation_badge": "Gon.", "your_event_has_been_scheduled": "Zure gertaera programatu da", + "your_event_has_been_scheduled_recurring": "Zure gertaera errepikaria programatu da", "error_message": "Errore-mezua honakoa ian da: '{{errorMessage}}'", "refund_failed_subject": "Itzulketak huts egin du: {{name}} - {{date}} - {{eventType}}", "refund_failed": "Huts egin du itzulketak {{eventType}} gertaerarako, {{userName}}(r)ekin {{date}}(e)an.", @@ -37,26 +56,79 @@ "refunded": "Itzulita", "payment": "Ordainketa", "pay_now": "Ordaindu orain", + "still_waiting_for_approval": "Gertaera bat onarpenaren zain dago", + "event_is_still_waiting": "Gertaera-eskaera oraindik zain dago: {{attendeeName}} - {{date}} - {{eventType}}", "no_more_results": "Emaitza gehiagorik ez", "no_results": "Emaitzarik ez", "load_more_results": "Kargatu emaitza gehiago", + "integration_meeting_id": "{{integrationName}} bileraren IDa: {{meetingId}}", "confirmed_event_type_subject": "Baieztatua: {{eventType}} {{name}}(r)ekin {{date}}(e)an", + "new_event_request": "Gertaera berriaren eskaera: {{attendeeName}} - {{date}} - {{eventType}}", "confirm_or_reject_request": "Baieztatu edo errefusatu eskaera", "check_bookings_page_to_confirm_or_reject": "Begiratu zure erreserba-orrialdea erreserba baieztatu edo errefusatzeko.", "event_awaiting_approval": "Gertaera bat zure onarpenaren zain dago", + "event_awaiting_approval_recurring": "Gertaera errepikari bat zure onarpenaren zain dago", + "someone_requested_an_event": "Norbaitek zure egutegian gertaera bat programatzeko eskaera egin du.", + "someone_requested_password_reset": "Norbaitek zure pasahitza aldatzeko esteka bat eskatu du.", + "password_reset_email_sent": "Email helbide hau gure sisteman baldin badago, berrezartzeko email mezu bat jaso behar zenuke.", + "password_reset_instructions": "Ez baduzu eskaera hau egin, segurua da email mezu honi kasurik ez egitea, eta zure pasahitza ez da aldatuko.", + "event_awaiting_approval_subject": "Onarpenaren zain: {{title}} {{date}}(e)an", + "event_still_awaiting_approval": "Gertaera bat zure onarpenaren zain dago oraindik", "booking_submitted_subject": "Erreserba bidalita: {{title}} {{date}}(e)an", + "download_recording_subject": "Deskargatu grabaketa: {{title}} {{date}}(e)an", + "download_your_recording": "Deskargatu zure grabaketa", + "your_meeting_has_been_booked": "Zure bileraren erreserba egin da", "event_type_has_been_rescheduled_on_time_date": "Zure {{title}} getaeraren programazioa aldatu egin da {{date}}(e)ra.", + "event_has_been_rescheduled": "Eguneratuta - Zure gertaeraren programazioa aldatu egin da", + "request_reschedule_subtitle": "{{organizer}}(e)k erreserba bertan behera utzi du eta beste denbora-tarte bat hautatzeko eskatu dizu.", + "request_reschedule_title_organizer": "Beste denbora-tarte bat hautatzeko eskatu diozu {{attendee}}(r)i", "hi_user_name": "Kaixo {{name}}", "ics_event_title": "{{eventType}} {{name}}(r)ekin", "notes": "Oharrak", "manage_my_bookings": "Kudeatu nire erreserbak", "rejected_event_type_with_organizer": "Errefusatua: {{eventType}} {{organizer}}(r)ekin {{date}}(e)an", "hi": "Kaixo", + "use_link_to_reset_password": "Erabili beheko esteka pasahitza berrezartzeko", + "hey_there": "Kaixo,", + "forgot_your_password_calcom": "Pasahitza ahaztu duzu? - {{appName}}", + "dismiss": "Alde batera utzi", + "no_data_yet": "Ez dago daturik", + "ping_test": "Ping testa", + "upcoming": "Laster", + "recurring": "Errepikariak", + "past": "Iraganekoak", + "choose_a_file": "Hautatu fitxategi bat...", + "upload_image": "Igo irudia", + "upload_target": "Igo {{target}}", + "no_target": "Ez dago {{target}}(r)ik", + "view_notifications": "Ikusi jakinarazpenak", + "view_public_page": "Ikusi orrialde publikoa", + "copy_public_page_link": "Kopiatu orrialde publikoaren esteka", + "sign_out": "Saioa itxi", + "add_another": "Gehitu beste bat", + "install_another": "Instalatu beste bat", + "unavailable": "Ez eskuragarri", + "set_work_schedule": "Ezarri zure laneko ordutegia", "change_bookings_availability": "Aldatu noiz zauden prest erreserbak jasotzeko", + "select": "Hautatu...", + "text": "Testua", + "multiline_text": "Lerro ugaritako testua", + "number": "Zenbakia", + "checkbox": "Kontrol-laukia", + "is_required": "Derrigorrezkoa da", + "required": "Derrigorrezkoa", + "optional": "Hautazkoa", + "input_type": "Sarrera-mota", + "rejected": "Baztertua", + "unconfirmed": "Baieztatu gabea", + "guests": "Gonbidatuak", + "create_account": "Sortu kontua", + "confirm_password": "Baieztatu pasahitza", "create_booking_link_with_calcom": "Sor ezazu zeure erreserba-esteka {{appName}}(e)kin", "user_needs_to_confirm_or_reject_booking": "{{user}}(e)k erreserba baieztatu edo errefusatu behar du oraindik.", "booking_submitted": "Zure erreserba bidali da", "booking_confirmed": "Zure erreserba baieztatu da", + "bookerlayout_column_view": "Zutabea", "back_to_bookings": "Itzuli erreserbatara", "really_cancel_booking": "Benetan bertan behera utzi nahi duzu zure erreserba?", "cannot_cancel_booking": "Ezin duzu erreserba hau bertan behera utzi", diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 553a1eab28..d8995856ff 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -115,6 +115,7 @@ const BookerComponent = ({ columnViewExtraDays.current = Math.abs(dayjs(selectedDate).diff(availableSlots[availableSlots.length - 2], "day")) + addonDays; const prefetchNextMonth = + layout === BookerLayouts.COLUMN_VIEW && dayjs(date).month() !== dayjs(date).add(columnViewExtraDays.current, "day").month(); const monthCount = dayjs(date).add(1, "month").month() !== dayjs(date).add(columnViewExtraDays.current, "day").month() diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 0fd86fe815..ed8a797d52 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -2148,6 +2148,7 @@ async function handler( id: originalRescheduledBooking.id, }, data: { + rescheduled: true, status: BookingStatus.CANCELLED, }, }); diff --git a/packages/features/insights/context/FiltersProvider.tsx b/packages/features/insights/context/FiltersProvider.tsx index 339aba49b9..c23430c19e 100644 --- a/packages/features/insights/context/FiltersProvider.tsx +++ b/packages/features/insights/context/FiltersProvider.tsx @@ -114,17 +114,19 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) { selectedFilter, isAll, dateRange, + initialConfig, } = newConfigFilters; const [startTime, endTime] = dateRange || [null, null]; - const newSearchParams = new URLSearchParams(searchParams); + const newSearchParams = new URLSearchParams(searchParams.toString()); function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) { if (value !== undefined && value !== null) newSearchParams.set(key, value.toString()); } + setParamsIfDefined("memberUserId", selectedMemberUserId); - setParamsIfDefined("teamId", selectedTeamId); - setParamsIfDefined("userId", selectedUserId); + setParamsIfDefined("teamId", selectedTeamId || initialConfig?.teamId); + setParamsIfDefined("userId", selectedUserId || initialConfig?.userId); setParamsIfDefined("eventTypeId", selectedEventTypeId); - setParamsIfDefined("isAll", isAll); + setParamsIfDefined("isAll", isAll || initialConfig?.isAll); setParamsIfDefined("startTime", startTime?.toISOString()); setParamsIfDefined("endTime", endTime?.toISOString()); setParamsIfDefined("filter", selectedFilter?.[0]); diff --git a/packages/features/insights/filters/TeamAndSelfList.tsx b/packages/features/insights/filters/TeamAndSelfList.tsx index afb80a2218..9428848e40 100644 --- a/packages/features/insights/filters/TeamAndSelfList.tsx +++ b/packages/features/insights/filters/TeamAndSelfList.tsx @@ -22,6 +22,11 @@ export const TeamAndSelfList = () => { const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, { // Teams don't change that frequently refetchOnWindowFocus: false, + trpc: { + context: { + skipBatch: true, + }, + }, }); useEffect(() => { @@ -48,6 +53,7 @@ export const TeamAndSelfList = () => { } else if (session.data?.user.id) { // default to user setConfigFilters({ + selectedUserId: session.data?.user.id, initialConfig: { teamId: null, userId: session.data?.user.id, diff --git a/packages/prisma/migrations/20230921002822_fix_booking_time_status/migration.sql b/packages/prisma/migrations/20230921002822_fix_booking_time_status/migration.sql new file mode 100644 index 0000000000..0fbf5d5e86 --- /dev/null +++ b/packages/prisma/migrations/20230921002822_fix_booking_time_status/migration.sql @@ -0,0 +1,35 @@ +-- View: public.BookingsTimeStatus + +-- DROP VIEW public."BookingsTimeStatus"; + +CREATE OR REPLACE VIEW public."BookingTimeStatus" + AS + SELECT "Booking".id, + "Booking".uid, + "Booking"."eventTypeId", + "Booking".title, + "Booking".description, + "Booking"."startTime", + "Booking"."endTime", + "Booking"."createdAt", + "Booking".location, + "Booking".paid, + "Booking".status, + "Booking".rescheduled, + "Booking"."userId", + "et"."teamId", + "et"."length" as "eventLength", + CASE + WHEN "Booking".rescheduled IS TRUE THEN 'rescheduled'::text + WHEN "Booking".status = 'cancelled'::"BookingStatus" AND "Booking".rescheduled IS NULL THEN 'cancelled'::text + WHEN "Booking"."endTime" < now() THEN 'completed'::text + WHEN "Booking"."endTime" > now() THEN 'uncompleted'::text + ELSE NULL::text + END AS "timeStatus", + "et"."parentId" as "eventParentId" + FROM "Booking" + LEFT JOIN "EventType" et ON "Booking"."eventTypeId" = et.id + LEFT JOIN "Membership" mb ON "mb"."userId" = "Booking"."userId"; + + +