Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/purple-apples-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@clerk/tanstack-react-start': minor
'@clerk/chrome-extension': minor
'@clerk/react-router': minor
'@clerk/clerk-js': minor
'@clerk/nextjs': minor
'@clerk/clerk-react': minor
'@clerk/remix': minor
'@clerk/types': minor
'@clerk/vue': minor
---

Add new <UserAvatar /> component
5 changes: 5 additions & 0 deletions .changeset/silver-tools-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/testing': minor
---

Add Playwright testing helpers under unstable page-objects: `userAvatar.goTo()`, `userAvatar.waitForMounted()`, and `userAvatar.toBeVisible()` for <UserAvatar />.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { UserAvatar } from '@clerk/nextjs';

export default function Page() {
return (
<div>
<UserAvatar fallback={<>Loading user avatar</>} />
</div>
);
}
5 changes: 5 additions & 0 deletions integration/templates/react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import UserButtonCustomDynamicLabelsAndCustomPages from './custom-user-button/wi
import UserButtonCustomTrigger from './custom-user-button-trigger';
import UserButtonCustomDynamicItems from './custom-user-button/with-dynamic-items.tsx';
import UserButton from './user-button';
import UserAvatar from './user-avatar';
import Waitlist from './waitlist';
import OrganizationProfile from './organization-profile';
import OrganizationList from './organization-list';
Expand Down Expand Up @@ -68,6 +69,10 @@ const router = createBrowserRouter([
path: '/user-button',
element: <UserButton />,
},
{
path: '/user-avatar',
element: <UserAvatar />,
},
{
path: '/protected',
element: <Protected />,
Expand Down
11 changes: 11 additions & 0 deletions integration/templates/react-vite/src/user-avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { UserAvatar } from '@clerk/clerk-react';
import React from 'react';

export default function UserAvatarPage() {
return (
<div>
<h1>UserAvatar</h1>
<UserAvatar fallback={<>Loading user avatar</>} />
</div>
);
}
7 changes: 6 additions & 1 deletion integration/templates/vue-vite/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ const routes = [
path: '/pricing-table',
component: () => import('./views/PricingTable.vue'),
},
{
name: 'UserAvatar',
path: '/user-avatar',
component: () => import('./views/UserAvatar.vue'),
},
// This was added for billing tests
{
name: 'User',
Expand Down Expand Up @@ -72,7 +77,7 @@ const router = createRouter({

router.beforeEach(async (to, _, next) => {
const { isSignedIn, isLoaded } = useAuth();
const authenticatedPages = ['Profile', 'Admin', 'CustomUserProfile', 'CustomOrganizationProfile'];
const authenticatedPages = ['Profile', 'Admin', 'CustomUserProfile', 'CustomOrganizationProfile', 'UserAvatar'];

if (!isLoaded.value) {
await waitForClerkJsLoaded(isLoaded);
Expand Down
10 changes: 10 additions & 0 deletions integration/templates/vue-vite/src/views/UserAvatar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { UserAvatar } from '@clerk/vue';
</script>

<template>
<div>
<h1>UserAvatar</h1>
<UserAvatar />
</div>
</template>
6 changes: 6 additions & 0 deletions integration/tests/components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('component
protected: true,
fallback: 'Loading user profile',
},
{
name: 'UserAvatar',
path: '/user-avatar',
protected: true,
fallback: 'Loading user avatar',
},
{
name: 'UserButton',
path: '/user-button',
Expand Down
48 changes: 48 additions & 0 deletions integration/tests/user-avatar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({
withEnv: [appConfigs.envs.withEmailCodes],
withPattern: ['react.vite.withEmailCodes', 'vue.vite'],
})('UserAvatar component integration tests @generic', ({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await app.teardown();
await fakeUser.deleteIfExists();
});

test.afterEach(async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.signOut();
await u.page.context().clearCookies();
});

test('UserAvatar loads and renders correctly when user is signed in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.expect.toBeSignedIn();

await u.po.userAvatar.goTo();
await u.po.userAvatar.toBeVisible();
});
});
11 changes: 11 additions & 0 deletions integration/tests/vue/components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te
await expect(u.page.getByRole('link', { name: /Sign in/i })).toBeVisible();
});

test('render UserAvatar component when user completes sign in flow', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/sign-in');
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.po.userAvatar.goTo();
await u.po.userAvatar.toBeVisible();
});

test('render user button component when user completes sign in flow', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/sign-in');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ exports[`public exports should not include a breaking change 1`] = `
"SignUpButton",
"SignedIn",
"SignedOut",
"UserAvatar",
"UserButton",
"UserProfile",
"Waitlist",
Expand Down
1 change: 1 addition & 0 deletions packages/chrome-extension/src/react/re-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
SignUpButton,
SignedIn,
SignedOut,
UserAvatar,
UserButton,
UserProfile,
Waitlist,
Expand Down
7 changes: 6 additions & 1 deletion packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Clerk as ClerkType } from '../';
import * as l from '../../localizations';
import type { Clerk as ClerkType } from '../';

const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[];

Expand All @@ -25,6 +25,7 @@ const AVAILABLE_COMPONENTS = [
'clerk', // While not a component, we want to support passing options to the Clerk class.
'signIn',
'signUp',
'userAvatar',
'userButton',
'userProfile',
'createOrganization',
Expand Down Expand Up @@ -86,6 +87,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component
clerk: buildComponentControls('clerk'),
signIn: buildComponentControls('signIn'),
signUp: buildComponentControls('signUp'),
userAvatar: buildComponentControls('userAvatar'),
userButton: buildComponentControls('userButton'),
userProfile: buildComponentControls('userProfile'),
createOrganization: buildComponentControls('createOrganization'),
Expand Down Expand Up @@ -287,6 +289,9 @@ void (async () => {
'/sign-up': () => {
Clerk.mountSignUp(app, componentControls.signUp.getProps() ?? {});
},
'/user-avatar': () => {
Clerk.mountUserAvatar(app, componentControls.userAvatar.getProps() ?? {});
},
'/user-button': () => {
Clerk.mountUserButton(app, componentControls.userButton.getProps() ?? {});
},
Expand Down
7 changes: 7 additions & 0 deletions packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@
>Sign Up</a
>
</li>
<li class="relative">
<a
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"
href="/user-avatar"
>User Avatar</a
>
</li>
<li class="relative">
<a
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"
Expand Down
25 changes: 25 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import type {
TaskChooseOrganizationProps,
TasksRedirectOptions,
UnsubscribeCallback,
UserAvatarProps,
UserButtonProps,
UserProfileProps,
UserResource,
Expand Down Expand Up @@ -867,6 +868,30 @@ export class Clerk implements ClerkInterface {
);
};

public mountUserAvatar = (node: HTMLDivElement, props?: UserAvatarProps): void => {
this.assertComponentsReady(this.#componentControls);
const component = 'UserAvatar';
void this.#componentControls.ensureMounted({ preloadHint: component }).then(controls =>
controls.mountComponent({
name: component,
appearanceKey: 'userAvatar',
node,
props,
}),
);

this.telemetry?.record(eventPrebuiltComponentMounted(component, props));
};

public unmountUserAvatar = (node: HTMLDivElement): void => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted().then(controls =>
controls.unmountComponent({
node,
}),
);
};

public mountSignUp = (node: HTMLDivElement, props?: SignUpProps): void => {
this.assertComponentsReady(this.#componentControls);
const component = 'SignUp';
Expand Down
26 changes: 26 additions & 0 deletions packages/clerk-js/src/ui/components/UserAvatar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useUser } from '@clerk/shared/react/index';
import type { UserAvatarProps } from '@clerk/types';

import { useUserAvatarContext, withCoreUserGuard } from '@/ui/contexts';
import { descriptors } from '@/ui/customizables';
import { UserAvatar as InternalUserAvatar } from '@/ui/elements/UserAvatar';
import { InternalThemeProvider } from '@/ui/styledSystem';

export const _UserAvatar = (props: UserAvatarProps) => {
const ctx = useUserAvatarContext();
const { user } = useUser();

return (
<InternalThemeProvider>
<InternalUserAvatar
boxElementDescriptor={descriptors.userAvatarBox}
imageElementDescriptor={descriptors.userAvatarImage}
{...user}
rounded={props.rounded ?? ctx.rounded ?? true}
size={theme => theme.sizes.$7}
/>
</InternalThemeProvider>
);
};

export const UserAvatar = withCoreUserGuard(_UserAvatar);
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
SignInContext,
SignUpContext,
SubscriberTypeContext,
UserAvatarContext,
UserButtonContext,
UserProfileContext,
UserVerificationContext,
Expand Down Expand Up @@ -50,6 +51,8 @@ export function ComponentContextProvider({
{children}
</UserVerificationContext.Provider>
);
case 'UserAvatar':
return <UserAvatarContext.Provider value={{ componentName, ...props }}>{children}</UserAvatarContext.Provider>;
case 'UserButton':
return (
<UserButtonContext.Provider value={{ componentName, ...(props as UserButtonProps) }}>
Expand Down
20 changes: 20 additions & 0 deletions packages/clerk-js/src/ui/contexts/components/UserAvatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';

import type { UserAvatarCtx } from '../../types';

export const UserAvatarContext = createContext<UserAvatarCtx | null>(null);

export const useUserAvatarContext = () => {
const context = useContext(UserAvatarContext);

if (!context || context.componentName !== 'UserAvatar') {
throw new Error('Clerk: useUserAvatarContext called outside UserAvatar.');
}

const { componentName, ...ctx } = context;

return {
...ctx,
componentName,
};
};
26 changes: 14 additions & 12 deletions packages/clerk-js/src/ui/contexts/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
export * from './ApiKeys';
export * from './Checkout';
export * from './CreateOrganization';
export * from './GoogleOneTap';
export * from './OAuthConsent';
export * from './OrganizationList';
export * from './OrganizationProfile';
export * from './OrganizationSwitcher';
export * from './Plans';
export * from './PricingTable';
export * from './SignIn';
export * from './SignUp';
export * from './SignOut';
export * from './SignUp';
export * from './SubscriberType';
export * from './SubscriptionDetails';
export * from './UserAvatar';
export * from './UserButton';
export * from './UserProfile';
export * from './UserVerification';
export * from './UserButton';
export * from './OrganizationSwitcher';
export * from './OrganizationList';
export * from './OrganizationProfile';
export * from './CreateOrganization';
export * from './GoogleOneTap';
export * from './Waitlist';
export * from './PricingTable';
export * from './Checkout';
export * from './Plans';
export * from './ApiKeys';
export * from './OAuthConsent';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/customizables/elementDescriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'taskChooseOrganizationCreateOrganizationActionButton',
'taskChooseOrganizationPreviewButton',

'userAvatarBox',
'userAvatarImage',

'userPreview',
'userPreviewAvatarContainer',
'userPreviewAvatarBox',
Expand Down
Loading
Loading