Flow, UX and other improvements for hash my url feature (#2644)
* added toast feedback * updated flow * locale * updated locale data * removed unused booking call for reschedule flow * fixed hashedURL test * test adjustment * further test changes * added check in test to click check only if unchecked * Added private link quick copy button * fixed spacing * fix lint * consistency * moved create hash function out of component render Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>pull/2702/head^2
parent
5464d4c010
commit
9322b4ab4c
|
@ -26,7 +26,9 @@ import { useRouter } from "next/router";
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Controller, Noop, useForm, UseFormReturn } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import short, { generate } from "short-uuid";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SelectGifInput } from "@calcom/app-store/giphy/components";
|
||||
|
@ -281,6 +283,14 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
);
|
||||
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
|
||||
const generateHashedLink = (id: number) => {
|
||||
const translator = short();
|
||||
const seed = `${id}:${new Date().getTime()}`;
|
||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||
return uid;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTokens = async () => {
|
||||
|
@ -315,6 +325,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
console.log(tokensList); // Just here to make sure it passes the gc hook. Can remove once actual use is made of tokensList.
|
||||
|
||||
fetchTokens();
|
||||
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0].id));
|
||||
}, []);
|
||||
|
||||
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
||||
|
@ -461,9 +473,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
team ? `team/${team.slug}` : eventType.users[0].username
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${
|
||||
eventType.hashedLink ? eventType.hashedLink.link : "xxxxxxxxxxxxxxxxx"
|
||||
}/${eventType.slug}`;
|
||||
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
|
||||
const mapUserToValue = ({
|
||||
id,
|
||||
|
@ -495,7 +505,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
currency: string;
|
||||
hidden: boolean;
|
||||
hideCalendarNotes: boolean;
|
||||
hashedLink: boolean;
|
||||
hashedLink: string | undefined;
|
||||
locations: { type: LocationType; address?: string; link?: string }[];
|
||||
customInputs: EventTypeCustomInput[];
|
||||
users: string[];
|
||||
|
@ -1368,27 +1378,31 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
<Controller
|
||||
name="hashedLink"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.hashedLink ? true : false}
|
||||
defaultValue={hashedUrl}
|
||||
render={() => (
|
||||
<>
|
||||
<CheckboxField
|
||||
id="hashedLink"
|
||||
name="hashedLink"
|
||||
label={t("hashed_link")}
|
||||
description={t("hashed_link_description")}
|
||||
id="hashedLinkCheck"
|
||||
name="hashedLinkCheck"
|
||||
label={t("private_link")}
|
||||
description={t("private_link_description")}
|
||||
defaultChecked={eventType.hashedLink ? true : false}
|
||||
onChange={(e) => {
|
||||
setHashedLinkVisible(e?.target.checked);
|
||||
formMethods.setValue("hashedLink", e?.target.checked);
|
||||
formMethods.setValue(
|
||||
"hashedLink",
|
||||
e?.target.checked ? hashedUrl : undefined
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{hashedLinkVisible && (
|
||||
<div className="block items-center sm:flex">
|
||||
<div className="!mt-1 block items-center sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0"></div>
|
||||
<div className="w-full">
|
||||
<div className="relative mt-1 flex w-full">
|
||||
<input
|
||||
disabled
|
||||
name="hashedLink"
|
||||
data-testid="generated-hash-url"
|
||||
type="text"
|
||||
className=" grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm"
|
||||
|
@ -1403,9 +1417,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
<Button
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (eventType.hashedLink) {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
showToast("Link copied!", "success");
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
|
@ -1836,6 +1852,22 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy_link")}
|
||||
</button>
|
||||
{hashedLinkVisible && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (eventType.hashedLink) {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
|
||||
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy_private_link")}
|
||||
</button>
|
||||
)}
|
||||
<EmbedButton
|
||||
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900"
|
||||
eventTypeId={eventType.id}
|
||||
|
|
|
@ -28,25 +28,19 @@ test.describe("hash my url", () => {
|
|||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@id="hashedLink"]');
|
||||
await page.click('//*[@id="hashedLink"]');
|
||||
await page.waitForSelector('//*[@id="hashedLinkCheck"]');
|
||||
// ignore if it is already checked, and click if unchecked
|
||||
const isChecked = await page.isChecked('//*[@id="hashedLinkCheck"]');
|
||||
!isChecked && (await page.click('//*[@id="hashedLinkCheck"]'));
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
|
||||
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
// click update
|
||||
await page.focus('//button[@type="submit"]');
|
||||
await page.keyboard.press("Enter");
|
||||
});
|
||||
|
||||
test("book using generated url hash", async ({ page }) => {
|
||||
// await page.pause();
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await page.click('//ul[@data-testid="event-types"]/li[1]');
|
||||
// We wait for the page to load
|
||||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
|
||||
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
await page.goto($url);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
|
|
|
@ -500,6 +500,7 @@
|
|||
"user_from_team": "{{user}} from {{team}}",
|
||||
"preview": "Preview",
|
||||
"link_copied": "Link copied!",
|
||||
"private_link_copied": "Private link copied!",
|
||||
"link_shared": "Link shared!",
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
|
@ -614,8 +615,9 @@
|
|||
"starting": "Starting",
|
||||
"disable_guests": "Disable Guests",
|
||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||
"hashed_link": "Generate hashed URL",
|
||||
"hashed_link_description": "Generate a hashed URL to share without exposing your Cal username",
|
||||
"private_link": "Generate private URL",
|
||||
"copy_private_link": "Copy private link",
|
||||
"private_link_description": "Generate a private URL to share without exposing your Cal username",
|
||||
"invitees_can_schedule": "Invitees can schedule",
|
||||
"date_range": "Date Range",
|
||||
"calendar_days": "calendar days",
|
||||
|
@ -779,6 +781,7 @@
|
|||
"you_will_only_view_it_once": "You will not be able to view it again once you close this modal.",
|
||||
"copy_to_clipboard": "Copy to clipboard",
|
||||
"enabled_after_update": "Enabled after update",
|
||||
"enabled_after_update_description": "The private link will work after saving",
|
||||
"confirm_delete_api_key": "Revoke this API key",
|
||||
"revoke_api_key": "Revoke API key",
|
||||
"api_key_copied": "API key copied!",
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma } from "@prisma/client";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
|
||||
|
@ -88,7 +86,7 @@ const EventTypeUpdateInput = _EventTypeModel
|
|||
}),
|
||||
users: z.array(stringOrNumber).optional(),
|
||||
schedule: z.number().optional(),
|
||||
hashedLink: z.boolean(),
|
||||
hashedLink: z.string(),
|
||||
})
|
||||
.partial()
|
||||
.merge(
|
||||
|
@ -318,19 +316,16 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
if (hashedLink) {
|
||||
// check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection
|
||||
if (!connectedLink) {
|
||||
const translator = short();
|
||||
const seed = `${input.eventName}:${input.id}:${new Date().getTime()}`;
|
||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||
// create a hashed link
|
||||
await ctx.prisma.hashedLink.upsert({
|
||||
where: {
|
||||
eventTypeId: input.id,
|
||||
},
|
||||
update: {
|
||||
link: uid,
|
||||
link: hashedLink,
|
||||
},
|
||||
create: {
|
||||
link: uid,
|
||||
link: hashedLink,
|
||||
eventType: {
|
||||
connect: { id: input.id },
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue