feat: Base implementation of v2 of avatars (#12369)
* feat: Base implementation of v2 of avatars * Make avatarUrl and logoUrl entirely optional * Made necessary backwards compat changes * fix: type errors * Fix: OG image * fix types * Consistency with other behaviour, ux tweak --------- Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
09cc4d8227
commit
27d969f995
|
@ -14,6 +14,6 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageS
|
|||
* It is aware of the user's organization to correctly show the avatar from the correct URL
|
||||
*/
|
||||
export function UserAvatar(props: UserAvatarProps) {
|
||||
const { user, previewSrc, ...rest } = props;
|
||||
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
|
||||
const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
|
||||
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} />;
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
|||
description={markdownStrippedBio}
|
||||
meeting={{
|
||||
title: markdownStrippedBio,
|
||||
profile: { name: `${profile.name}`, image: null },
|
||||
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
|
||||
users: [{ username: `${user.username}`, name: `${user.name}` }],
|
||||
}}
|
||||
nextSeoProps={{
|
||||
|
@ -246,7 +246,7 @@ export type UserPageProps = {
|
|||
allowSEOIndexing: boolean;
|
||||
username: string | null;
|
||||
};
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
|
||||
themeBasis: string | null;
|
||||
markdownStrippedBio: string;
|
||||
safeBio: string;
|
||||
|
@ -297,6 +297,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||
metadata: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
avatarUrl: true,
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
|
@ -365,6 +366,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||
image: user.avatar,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
avatarUrl: user.avatarUrl,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
username: user.username,
|
||||
|
@ -399,6 +401,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||
name: user.name,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl,
|
||||
away: user.away,
|
||||
verified: user.verified,
|
||||
})),
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const querySchema = z.object({
|
||||
uuid: z.string().transform((objectKey) => objectKey.split(".")[0]),
|
||||
});
|
||||
|
||||
const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => {
|
||||
const errors = error.errors.map((err) => ({
|
||||
path: err.path.join("."),
|
||||
errorCode: `error.validation.${err.code}`,
|
||||
}));
|
||||
|
||||
res.status(400).json({
|
||||
message: "VALIDATION_ERROR",
|
||||
errors,
|
||||
});
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const result = querySchema.safeParse(req.query);
|
||||
if (!result.success) {
|
||||
return handleValidationError(res, result.error);
|
||||
}
|
||||
|
||||
const { uuid: objectKey } = result.data;
|
||||
|
||||
let img;
|
||||
try {
|
||||
const { data } = await prisma.avatar.findUniqueOrThrow({
|
||||
where: {
|
||||
objectKey,
|
||||
},
|
||||
select: {
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
img = data;
|
||||
} catch (e) {
|
||||
// If anything goes wrong or avatar is not found, use default avatar
|
||||
res.writeHead(302, {
|
||||
Location: AVATAR_FALLBACK,
|
||||
});
|
||||
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
|
||||
const imageResp = Buffer.from(decoded, "base64");
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Length": imageResp.length,
|
||||
});
|
||||
|
||||
res.end(imageResp);
|
||||
}
|
|
@ -9,8 +9,8 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
|||
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage";
|
||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
|
@ -82,19 +82,12 @@ const ProfileView = () => {
|
|||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { update } = useSession();
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
|
||||
const [fetchedImgSrc, setFetchedImgSrc] = useState<string>("");
|
||||
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
|
||||
onSuccess: async (userData) => {
|
||||
try {
|
||||
const res = await fetch(userData.avatar);
|
||||
if (res.url) setFetchedImgSrc(res.url);
|
||||
} catch (err) {
|
||||
setFetchedImgSrc("");
|
||||
}
|
||||
},
|
||||
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
|
||||
enabled: !isLoading && !user?.avatarUrl,
|
||||
});
|
||||
|
||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
await update(res);
|
||||
|
@ -226,7 +219,7 @@ const ProfileView = () => {
|
|||
|
||||
const defaultValues = {
|
||||
username: user.username || "",
|
||||
avatar: fetchedImgSrc || "",
|
||||
avatar: getUserAvatarUrl(user),
|
||||
name: user.name || "",
|
||||
email: user.email || "",
|
||||
bio: user.bio || "",
|
||||
|
@ -243,6 +236,7 @@ const ProfileView = () => {
|
|||
key={JSON.stringify(defaultValues)}
|
||||
defaultValues={defaultValues}
|
||||
isLoading={updateProfileMutation.isLoading}
|
||||
isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
|
||||
user={user}
|
||||
userOrganization={user.organization}
|
||||
onSubmit={(values) => {
|
||||
|
@ -387,6 +381,7 @@ const ProfileForm = ({
|
|||
onSubmit,
|
||||
extraField,
|
||||
isLoading = false,
|
||||
isFallbackImg,
|
||||
user,
|
||||
userOrganization,
|
||||
}: {
|
||||
|
@ -394,6 +389,7 @@ const ProfileForm = ({
|
|||
onSubmit: (values: FormValues) => void;
|
||||
extraField?: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
isFallbackImg: boolean;
|
||||
user: RouterOutputs["viewer"]["me"];
|
||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||
}) => {
|
||||
|
@ -432,7 +428,7 @@ const ProfileForm = ({
|
|||
control={formMethods.control}
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => {
|
||||
const showRemoveAvatarButton = !checkIfItFallbackImage(value);
|
||||
const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
|
||||
const organization =
|
||||
userOrganization && userOrganization.id
|
||||
? {
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
|
@ -0,0 +1,56 @@
|
|||
import { expect } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe("UploadAvatar", async () => {
|
||||
test("can upload an image", async ({ page, users }) => {
|
||||
const user = await users.create({});
|
||||
await user.apiLogin();
|
||||
|
||||
await test.step("Can upload an initial picture", async () => {
|
||||
await page.goto("/settings/my-account/profile");
|
||||
|
||||
await page.getByTestId("open-upload-avatar-dialog").click();
|
||||
|
||||
const [fileChooser] = await Promise.all([
|
||||
// It is important to call waitForEvent before click to set up waiting.
|
||||
page.waitForEvent("filechooser"),
|
||||
// Opens the file chooser.
|
||||
page.getByTestId("open-upload-image-filechooser").click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`);
|
||||
|
||||
await page.getByTestId("upload-avatar").click();
|
||||
|
||||
await page.locator("input[name='name']").fill(user.email);
|
||||
|
||||
await page.getByText("Update").click();
|
||||
await page.waitForSelector("text=Settings updated successfully");
|
||||
|
||||
const response = await prisma.avatar.findUniqueOrThrow({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
userId: user.id,
|
||||
teamId: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// todo: remove this; ideally the organization-avatar is updated the moment
|
||||
// 'Settings updated succesfully' is saved.
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(await page.getByTestId("organization-avatar").innerHTML()).toContain(response.objectKey);
|
||||
|
||||
const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
await expect(urlResponse?.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -268,6 +268,7 @@ function getTrpcHandlerData({
|
|||
user: {
|
||||
...getSampleUserInSession(),
|
||||
...user,
|
||||
avatarUrl: user.avatarUrl || null,
|
||||
} satisfies TrpcSessionUser,
|
||||
},
|
||||
input: input,
|
||||
|
|
|
@ -10,6 +10,7 @@ import Shell from "@calcom/features/shell/Shell";
|
|||
import { classNames } from "@calcom/lib";
|
||||
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
@ -145,7 +146,7 @@ const useTabs = () => {
|
|||
if (tab.href === "/settings/my-account") {
|
||||
tab.name = user?.name || "my_account";
|
||||
tab.icon = undefined;
|
||||
tab.avatar = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${session?.data?.user?.username}/avatar.png`;
|
||||
tab.avatar = getUserAvatarUrl(user);
|
||||
} else if (tab.href === "/settings/organizations") {
|
||||
tab.name = orgBranding?.name || "organization";
|
||||
tab.avatar = `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`;
|
||||
|
|
|
@ -6,19 +6,28 @@ import type { User, Team } from "@calcom/prisma/client";
|
|||
* Gives an organization aware avatar url for a user
|
||||
* It ensures that the wrong avatar isn't fetched by ensuring that organizationId is always passed
|
||||
*/
|
||||
export const getUserAvatarUrl = (user: Pick<User, "username" | "organizationId">) => {
|
||||
if (!user.username) return AVATAR_FALLBACK;
|
||||
export const getUserAvatarUrl = (
|
||||
user: (Pick<User, "username" | "organizationId"> & { avatarUrl?: string | null }) | undefined
|
||||
) => {
|
||||
if (user?.avatarUrl) {
|
||||
return user.avatarUrl;
|
||||
}
|
||||
if (!user?.username) return AVATAR_FALLBACK;
|
||||
// avatar.png automatically redirects to fallback avatar if user doesn't have one
|
||||
return `${WEBAPP_URL}/${user.username}/avatar.png${
|
||||
user.organizationId ? `?orgId=${user.organizationId}` : ""
|
||||
}`;
|
||||
};
|
||||
|
||||
export const getOrgAvatarUrl = (org: {
|
||||
id: Team["id"];
|
||||
slug: Team["slug"];
|
||||
requestedSlug: string | null;
|
||||
}) => {
|
||||
export const getOrgAvatarUrl = (
|
||||
org: Pick<Team, "id" | "slug"> & {
|
||||
logoUrl?: string | null;
|
||||
requestedSlug: string | null;
|
||||
}
|
||||
) => {
|
||||
if (org.logoUrl) {
|
||||
return org.logoUrl;
|
||||
}
|
||||
const slug = org.slug ?? org.requestedSlug;
|
||||
return `${WEBAPP_URL}/org/${slug}/avatar.png`;
|
||||
};
|
||||
|
|
|
@ -191,6 +191,7 @@ export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload
|
|||
allowDynamicBooking: true,
|
||||
availability: [],
|
||||
avatar: "",
|
||||
avatarUrl: "",
|
||||
away: false,
|
||||
backupCodes: null,
|
||||
bio: null,
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "logoUrl" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "avatarUrl" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "avatars" (
|
||||
"teamId" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL DEFAULT 0,
|
||||
"data" TEXT NOT NULL,
|
||||
"objectKey" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "avatars_objectKey_key" ON "avatars"("objectKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "avatars_teamId_userId_key" ON "avatars"("teamId", "userId");
|
|
@ -192,6 +192,7 @@ model User {
|
|||
password String?
|
||||
bio String?
|
||||
avatar String?
|
||||
avatarUrl String?
|
||||
timeZone String @default("Europe/London")
|
||||
weekStart String @default("Sunday")
|
||||
// DEPRECATED - TO BE REMOVED
|
||||
|
@ -279,6 +280,7 @@ model Team {
|
|||
/// @zod.min(1)
|
||||
slug String?
|
||||
logo String?
|
||||
logoUrl String?
|
||||
appLogo String?
|
||||
appIconLogo String?
|
||||
bio String?
|
||||
|
@ -1011,3 +1013,17 @@ model TempOrgRedirect {
|
|||
|
||||
@@unique([from, type, fromOrgId])
|
||||
}
|
||||
|
||||
model Avatar {
|
||||
// e.g. NULL(0), organization ID or team logo
|
||||
teamId Int @default(0)
|
||||
// Avatar, NULL(0) if team logo
|
||||
userId Int @default(0)
|
||||
// base64 string
|
||||
data String
|
||||
// different every time to pop the cache.
|
||||
objectKey String @unique
|
||||
|
||||
@@unique([teamId, userId])
|
||||
@@map(name: "avatars")
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
|||
email: true,
|
||||
emailVerified: true,
|
||||
bio: true,
|
||||
avatarUrl: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
startTime: true,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import prisma from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type AvatarOptions = {
|
||||
|
@ -7,7 +8,15 @@ type AvatarOptions = {
|
|||
};
|
||||
|
||||
export const avatarHandler = async ({ ctx }: AvatarOptions) => {
|
||||
const data = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
avatar: ctx.user.avatar,
|
||||
avatar: data?.avatar,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -25,6 +25,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
|
|||
timeFormat: user.timeFormat,
|
||||
timeZone: user.timeZone,
|
||||
avatar: getUserAvatarUrl(user),
|
||||
avatarUrl: user.avatarUrl,
|
||||
createdDate: user.createdDate,
|
||||
trialEndsAt: user.trialEndsAt,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext, NextApiResponse } from "next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
|
@ -31,12 +32,35 @@ type UpdateProfileOptions = {
|
|||
input: TUpdateProfileInputSchema;
|
||||
};
|
||||
|
||||
const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => {
|
||||
const objectKey = uuidv4();
|
||||
|
||||
await prisma.avatar.upsert({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId: 0,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: userId,
|
||||
data,
|
||||
objectKey,
|
||||
},
|
||||
update: {
|
||||
data,
|
||||
objectKey,
|
||||
},
|
||||
});
|
||||
|
||||
return `/api/avatar/${objectKey}.png`;
|
||||
};
|
||||
|
||||
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
|
||||
const { user } = ctx;
|
||||
const userMetadata = handleUserMetadata({ ctx, input });
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
...input,
|
||||
avatar: input.avatar ? await getAvatarToSet(input.avatar) : null,
|
||||
metadata: userMetadata,
|
||||
};
|
||||
|
||||
|
@ -114,6 +138,15 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
|||
// when the email changes, the user needs to sign in again.
|
||||
signOutUser = true;
|
||||
}
|
||||
// don't do anything if avatar is undefined.
|
||||
if (typeof input.avatar !== "undefined") {
|
||||
data.avatarUrl = input.avatar
|
||||
? await uploadAvatar({
|
||||
avatar: await resizeBase64Image(input.avatar),
|
||||
userId: user.id,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
|
@ -129,6 +162,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
|||
metadata: true,
|
||||
name: true,
|
||||
createdDate: true,
|
||||
avatarUrl: true,
|
||||
locale: true,
|
||||
schedules: {
|
||||
select: {
|
||||
|
@ -186,28 +220,11 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
|||
},
|
||||
});
|
||||
}
|
||||
// Revalidate booking pages
|
||||
// Disabled because the booking pages are currently not using getStaticProps
|
||||
/*const res = ctx.res as NextApiResponse;
|
||||
if (typeof res?.revalidate !== "undefined") {
|
||||
const eventTypes = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
team: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
// waiting for this isn't needed
|
||||
Promise.all(
|
||||
eventTypes.map((eventType) => res?.revalidate(`/new-booker/${ctx.user.username}/${eventType.slug}`))
|
||||
)
|
||||
.then(() => console.info("Booking pages revalidated"))
|
||||
.catch((e) => console.error(e));
|
||||
}*/
|
||||
return { ...input, signOutUser, passwordReset };
|
||||
|
||||
// don't return avatar, we don't need it anymore.
|
||||
delete input.avatar;
|
||||
|
||||
return { ...input, signOutUser, passwordReset, avatarUrl: updatedUser.avatarUrl };
|
||||
};
|
||||
|
||||
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
|
||||
|
@ -230,17 +247,3 @@ const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
|
|||
// Required so we don't override and delete saved values
|
||||
return { ...userMetadata, ...cleanMetadata };
|
||||
};
|
||||
|
||||
async function getAvatarToSet(avatar: string | null | undefined) {
|
||||
if (avatar === null || avatar === undefined) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
if (!avatar.startsWith("data:image")) {
|
||||
// Non Base64 avatar currently could only be the dynamic avatar URL(i.e. /{USER}/avatar.png). If we allow setting that URL, we would get infinite redirects on /user/avatar.ts endpoint
|
||||
log.warn("Non Base64 avatar, ignored it", { avatar });
|
||||
// `undefined` would not ignore the avatar, but `null` would remove it. So, we return `undefined` here.
|
||||
return undefined;
|
||||
}
|
||||
return await resizeBase64Image(avatar);
|
||||
}
|
||||
|
|
|
@ -177,6 +177,7 @@ export const DialogTrigger = DialogPrimitive.Trigger;
|
|||
|
||||
export function DialogClose(
|
||||
props: {
|
||||
"data-testid"?: string;
|
||||
dialogCloseProps?: React.ComponentProps<(typeof DialogPrimitive)["Close"]>;
|
||||
children?: ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
|
@ -188,7 +189,10 @@ export function DialogClose(
|
|||
return (
|
||||
<DialogPrimitive.Close asChild {...props.dialogCloseProps}>
|
||||
{/* This will require the i18n string passed in */}
|
||||
<Button data-testid="dialog-rejection" color={props.color || "minimal"} {...props}>
|
||||
<Button
|
||||
data-testid={props["data-testid"] || "dialog-rejection"}
|
||||
color={props.color || "minimal"}
|
||||
{...props}>
|
||||
{props.children ? props.children : t("Close")}
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
|
|
|
@ -170,7 +170,11 @@ export default function ImageUploader({
|
|||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button color={triggerButtonColor ?? "secondary"} type="button" className="py-1 text-sm">
|
||||
<Button
|
||||
color={triggerButtonColor ?? "secondary"}
|
||||
type="button"
|
||||
data-testid="open-upload-avatar-dialog"
|
||||
className="py-1 text-sm">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
@ -190,7 +194,9 @@ export default function ImageUploader({
|
|||
</div>
|
||||
)}
|
||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||
<label className="bg-subtle hover:bg-muted hover:text-emphasis border-subtle text-default mt-8 rounded-sm border px-3 py-1 text-xs font-medium leading-4 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
<label
|
||||
data-testid="open-upload-image-filechooser"
|
||||
className="bg-subtle hover:bg-muted hover:text-emphasis border-subtle text-default mt-8 rounded-sm border px-3 py-1 text-xs font-medium leading-4 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
<input
|
||||
onInput={onInputFile}
|
||||
type="file"
|
||||
|
@ -205,8 +211,10 @@ export default function ImageUploader({
|
|||
</div>
|
||||
<DialogFooter className="relative">
|
||||
<DialogClose color="minimal">{t("cancel")}</DialogClose>
|
||||
|
||||
<DialogClose color="primary" onClick={() => showCroppedImage(croppedAreaPixels)}>
|
||||
<DialogClose
|
||||
data-testid="upload-avatar"
|
||||
color="primary"
|
||||
onClick={() => showCroppedImage(croppedAreaPixels)}>
|
||||
{t("save")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
|
|
Loading…
Reference in New Issue
Block a user