Ability to format user and team bio (#6572)

* use texteditor for bio

* remove block types from about editor

* add props to make editor height adjustable

* set isDirty to true when about input is edited

* add editor to getting-started

* fix editor height

* remove required error for onboarding

* add helper function to check if parsed bio has text

* add back commented code

* fix onboarding tests for optional about field

* rename function

* parse team bio for read only members

* code clean up

* fix failing e2e because of missing test id

* fix onboarding e2e test

* add missing parse of user bio

* Update apps/web/components/getting-started/steps-views/UserProfile.tsx

* Update apps/web/pages/settings/my-account/profile.tsx

Co-authored-by: Omar López <zomars@me.com>

* use css inline style for height instead of tailwind class

* fix height of editor-input

* save bio as markdown in db

* fix empty line when bio is empty

* fix hydration failed error

* Update packages/ui/components/editor/Editor.tsx

Co-authored-by: Omar López <zomars@me.com>

* remove unused import

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
authored and GitHub committed 2023-01-24 18:08:10 -07:00
1 parent abb5fd36f3
commit 8640eb9f52
13 files changed
+125 -77

No files matched your search

@@ -1,15 +1,19 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import MarkdownIt from "markdown-it";
import { useRouter } from "next/router";
import { FormEvent, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import turndownService from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import { Button, ImageUploader, showToast, TextArea } from "@calcom/ui";
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { Avatar } from "@calcom/ui";
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
const md = new MarkdownIt("default", { html: true, breaks: true });
type FormData = {
bio: string;
};
@@ -21,13 +25,10 @@ const UserProfile = (props: IUserProfileProps) => {
const { user } = props;
const { t } = useLocale();
const avatarRef = useRef<HTMLInputElement>(null!);
const bioRef = useRef<HTMLTextAreaElement>(null);
const {
register,
setValue,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ defaultValues: { bio: user?.bio || "" } });
const { setValue, handleSubmit, getValues } = useForm<FormData>({
defaultValues: { bio: user?.bio || "" },
});
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
const utils = trpc.useContext();
@@ -138,25 +139,12 @@ const UserProfile = (props: IUserProfileProps) => {
</div>
</div>
<fieldset className="mt-8">
<label htmlFor="bio" className="mb-2 block text-sm font-medium text-gray-700">
{t("about")}
</label>
<TextArea
{...register("bio", { required: true })}
ref={bioRef}
name="bio"
id="bio"
className="mt-1 block h-[60px] w-full rounded-sm border border-gray-300 px-3 py-2 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
defaultValue={user?.bio || undefined}
onChange={(event) => {
setValue("bio", event.target.value);
}}
<Label className="mb-2 block text-sm font-medium text-gray-700">{t("about")}</Label>
<Editor
getText={() => md.render(getValues("bio") || user?.bio || "")}
setText={(value: string) => setValue("bio", turndownService.turndown(value))}
excludedToolbarItems={["blockType"]}
/>
{errors.bio && (
<p data-testid="required" className="py-2 text-xs text-red-500">
{t("required")}
</p>
)}
<p className="mt-2 font-sans text-sm font-normal text-gray-600 dark:text-white">
{t("few_sentences_about_yourself")}
</p>
+17 -3
View File
@@ -1,3 +1,4 @@
import MarkdownIt from "markdown-it";
import Link from "next/link";
import { TeamPageProps } from "pages/team/[slug]";
@@ -6,6 +7,8 @@ import { Avatar } from "@calcom/ui";
import { useLocale } from "@lib/hooks/useLocale";
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
type TeamType = TeamPageProps["team"];
type MembersType = TeamType["members"];
type MemberType = MembersType[number];
@@ -13,6 +16,8 @@ type MemberType = MembersType[number];
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const { t } = useLocale();
const isBioEmpty = !member.bio || !member.bio.replace("<p><br></p>", "").length;
return (
<Link key={member.id} href={`/${member.username}`}>
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full w-[90%] flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
@@ -23,9 +28,18 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
/>
<section className="line-clamp-4 mt-2 w-full space-y-1">
<p className="font-medium text-gray-900 dark:text-white">{member.name}</p>
<p className="line-clamp-3 overflow-ellipsis text-sm font-normal text-gray-500 dark:text-white">
{member.bio || t("user_from_team", { user: member.name, team: teamName })}
</p>
<div className="line-clamp-3 overflow-ellipsis text-sm font-normal text-gray-500 dark:text-white">
{!isBioEmpty ? (
<>
<div
className="dark:text-darkgray-600 text-s text-gray-500"
dangerouslySetInnerHTML={{ __html: md.render(member.bio || "") }}
/>
</>
) : (
t("user_from_team", { user: member.name, team: teamName })
)}
</div>
</section>
</div>
</Link>
+2
View File
@@ -60,6 +60,7 @@
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^4.3.9",
"@types/turndown": "^5.0.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.0.21",
"accept-language-parser": "^1.5.0",
@@ -118,6 +119,7 @@
"stripe": "^9.16.0",
"superjson": "1.9.1",
"tailwindcss-radix": "^2.6.0",
"turndown": "^7.1.1",
"uuid": "^8.3.2",
"web3": "^1.7.5",
"zod": "^3.20.2"
+15 -3
View File
@@ -1,4 +1,5 @@
import classNames from "classnames";
import MarkdownIt from "markdown-it";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -34,6 +35,8 @@ import { EmbedProps } from "@lib/withEmbedSsr";
import { ssrInit } from "@server/lib/ssr";
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export default function User(props: inferSSRProps<typeof getServerSideProps> & EmbedProps) {
const { users, profile, eventTypes, isDynamicGroup, dynamicNames, dynamicUsernames, isSingleUser } = props;
const [user] = users; //To be used when we only have a single user, not dynamic group
@@ -41,6 +44,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
const { t } = useLocale();
const router = useRouter();
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
const groupEventTypes = props.users.some((user) => !user.allowDynamicBooking) ? (
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
@@ -55,7 +60,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
{eventTypes.map((type, index) => (
<li
key={index}
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
<FiArrowRight className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
<Link
href={getUsernameSlugLink({ users: props.users, slug: type.slug })}
@@ -138,7 +143,14 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
)}
</h1>
<p className="dark:text-darkgray-600 text-s text-gray-500">{user.bio}</p>
{!isBioEmpty && (
<>
<div
className="dark:text-darkgray-600 text-s text-gray-500"
dangerouslySetInnerHTML={{ __html: md.render(user.bio || "") }}
/>
</>
)}
</div>
)}
<div
@@ -163,7 +175,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<div
key={type.id}
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
<FiArrowRight className="absolute right-4 top-4 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
<Link
+15 -4
View File
@@ -1,5 +1,6 @@
import { IdentityProvider } from "@prisma/client";
import crypto from "crypto";
import MarkdownIt from "markdown-it";
import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { BaseSyntheticEvent, useRef, useState } from "react";
@@ -9,6 +10,7 @@ import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { ErrorCode } from "@calcom/lib/auth";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import turndownService from "@calcom/lib/turndownService";
import { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import { AppRouter } from "@calcom/trpc/server/routers/_app";
@@ -32,6 +34,7 @@ import {
SkeletonContainer,
SkeletonText,
TextField,
Editor,
} from "@calcom/ui";
import { FiAlertTriangle, FiTrash2 } from "@calcom/ui/components/icon";
@@ -40,14 +43,16 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
import { ssrInit } from "@server/lib/ssr";
const md = new MarkdownIt("default", { html: true, breaks: true });
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mt-6 mb-8 space-y-6 divide-y">
<div className="flex items-center">
<SkeletonAvatar className=" h-12 w-12 px-4" />
<SkeletonButton className=" h-6 w-32 rounded-md p-5" />
<SkeletonAvatar className="h-12 w-12 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
@@ -361,9 +366,15 @@ const ProfileForm = ({
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-8">
<TextField label={t("about")} hint={t("bio_hint")} {...formMethods.register("bio")} />
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndownService.turndown(value), { shouldDirty: true });
}}
excludedToolbarItems={["blockType"]}
/>
</div>
<Button disabled={isDisabled} color="primary" className="mt-8" type="submit">
{t("update")}
</Button>
+13 -1
View File
@@ -1,4 +1,5 @@
import classNames from "classnames";
import MarkdownIt from "markdown-it";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -20,6 +21,8 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
import Team from "@components/team/screens/Team";
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
function TeamPage({ team }: TeamPageProps) {
useTheme();
@@ -75,6 +78,8 @@ function TeamPage({ team }: TeamPageProps) {
const teamName = team.name || "Nameless Team";
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
return (
<div>
<HeadSeo
@@ -91,7 +96,14 @@ function TeamPage({ team }: TeamPageProps) {
<p className="font-cal dark:text-darkgray-900 mb-2 text-2xl tracking-wider text-gray-900">
{teamName}
</p>
<p className="dark:text-darkgray-500 mt-2 text-sm font-normal text-gray-500">{team.bio}</p>
{!isBioEmpty && (
<>
<div
className="dark:text-darkgray-600 text-s text-gray-500"
dangerouslySetInnerHTML={{ __html: md.render(team.bio || "") }}
/>
</>
)}
</div>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
+5 -14
View File
@@ -54,25 +54,16 @@ test.describe("Onboarding", () => {
});
await test.step("step 4", async () => {
const finishButton = await page.locator("button[type=submit]");
// bio field is required, try to submit (and test whether that fails)
await finishButton.click();
const requiredBio = await page.locator("data-testid=required");
await expect(requiredBio).toBeVisible();
await page.locator("textarea[name=bio]").fill("Something about me");
const isDisabled = await finishButton.isDisabled();
await expect(isDisabled).toBe(false);
await finishButton.click();
await page.locator("button[type=submit]").click();
// should redirect to /event-types after onboarding
await page.waitForURL("/event-types");
const userComplete = await user.self();
expect(userComplete.bio).toBe("Something about me");
const userCompleteBio = userComplete.bio ? userComplete.bio : "";
expect(userCompleteBio.replace("<p><br></p>", "").length).toBe(0);
});
});
});
@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { MembershipRole, Prisma } from "@prisma/client";
import MarkdownIt from "markdown-it";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -11,6 +12,7 @@ import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import objectKeys from "@calcom/lib/objectKeys";
import turndownService from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import {
Avatar,
@@ -24,13 +26,15 @@ import {
LinkIconButton,
Meta,
showToast,
TextArea,
TextField,
Editor,
} from "@calcom/ui";
import { FiExternalLink, FiLink, FiTrash2, FiLogOut } from "@calcom/ui/components/icon";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
const md = new MarkdownIt("default", { html: true, breaks: true });
const regex = new RegExp("^[a-zA-Z0-9-]*$");
const teamProfileFormSchema = z.object({
@@ -90,6 +94,8 @@ const ProfileView = () => {
const permalink = `${CAL_URL?.replace(/^(https?:|)\/\//, "")}/team/${team?.slug}`;
const isBioEmpty = !team || !team.bio || !team.bio.replace("<p><br></p>", "").length;
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
@@ -213,23 +219,14 @@ const ProfileView = () => {
</div>
)}
/>
<Controller
control={form.control}
name="bio"
render={({ field: { value } }) => (
<div className="mt-8">
<Label>{t("about")}</Label>
<TextArea
name="bio"
value={value}
className="h-14"
onChange={(e) => {
form.setValue("bio", e?.target.value);
}}
/>
</div>
)}
/>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(form.getValues("bio") || "")}
setText={(value: string) => form.setValue("bio", turndownService.turndown(value))}
excludedToolbarItems={["blockType"]}
/>
</div>
<p className="mt-2 text-sm text-gray-600">{t("team_description")}</p>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
@@ -239,7 +236,7 @@ const ProfileView = () => {
(team.metadata as Prisma.JsonObject)?.requestedSlug && (
<Button
color="secondary"
className="ml-2 mt-8"
className="mt-8 ml-2"
type="button"
onClick={() => {
publishMutation.mutate({ teamId: team.id });
@@ -255,10 +252,13 @@ const ProfileView = () => {
<Label className="text-black">{t("team_name")}</Label>
<p className="text-sm text-gray-800">{team?.name}</p>
</div>
{team?.bio && (
{team && !isBioEmpty && (
<>
<Label className="mt-5 text-black">{t("about")}</Label>
<p className="text-sm text-gray-800">{team.bio}</p>
<div
className="dark:text-darkgray-600 text-s text-gray-500"
dangerouslySetInnerHTML={{ __html: md.render(team.bio || "") }}
/>
</>
)}
</div>
@@ -597,6 +597,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
props.form.clearErrors();
}}
variables={DYNAMIC_TEXT_VARIABLES}
height="200px"
/>
</>
) : (
@@ -1,5 +1,6 @@
import { components, GroupBase, Props, ValueContainerProps } from "react-select";
import { Select } from "@calcom/ui";
import { FiPlus } from "@calcom/ui/components/icon";
const LimitedChipsContainer = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>({
+12
View File
@@ -0,0 +1,12 @@
import TurndownService from "turndown";
const turndownService = new TurndownService();
turndownService.addRule("newLine", {
filter: ["br"],
replacement: () => {
return "<p><br></p>";
},
});
export default turndownService;
+6 -2
View File
@@ -29,6 +29,7 @@ export type TextEditorProps = {
setText: (text: string) => void;
excludedToolbarItems?: string[];
variables?: string[];
height?: string;
};
const editorConfig = {
@@ -63,8 +64,11 @@ export const Editor = (props: TextEditorProps) => {
excludedToolbarItems={props.excludedToolbarItems}
variables={props.variables}
/>
<div className="editor-inner">
<RichTextPlugin contentEditable={<ContentEditable className="editor-input" />} placeholder="" />
<div className="editor-inner" style={{ height: props.height }}>
<RichTextPlugin
contentEditable={<ContentEditable style={{ height: props.height }} className="editor-input" />}
placeholder=""
/>
<AutoFocusPlugin />
<ListPlugin />
<LinkPlugin />
@@ -34,18 +34,18 @@
position: relative;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
height: 200px;
overflow: scroll;
resize: vertical;
height: auto;
}
.editor-input {
min-height: 150px;
height: auto;
font-size: 14px;
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 10px;
padding: 10px 10px;
outline: none
}