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
Syed Ali Shahbaz 2022-05-09 16:41:07 +05:30 committed by GitHub
parent 5464d4c010
commit 9322b4ab4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 60 additions and 36 deletions

View File

@ -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}

View File

@ -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);

View File

@ -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!",

View File

@ -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 },
},