Skip to content

Commit

Permalink
chore(vue): Add custom menu items to <UserButton /> (#4693)
Browse files Browse the repository at this point in the history
  • Loading branch information
wobsoriano authored Dec 3, 2024
1 parent 5276c9f commit 1c51045
Show file tree
Hide file tree
Showing 14 changed files with 519 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/moody-snails-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/vue": patch
---

Add support for custom menu items to `<UserButton />`
6 changes: 3 additions & 3 deletions integration/presets/vue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ const vite = applicationConfig()
.useTemplate(templates['vue-vite'])
.setEnvFormatter('public', key => `VITE_${key}`)
.addScript('setup', 'pnpm install')
.addScript('dev', 'npm run dev')
.addScript('build', 'npm run build')
.addScript('serve', 'npm run preview')
.addScript('dev', 'pnpm dev')
.addScript('build', 'pnpm build')
.addScript('serve', 'pnpm preview')
.addDependency('@clerk/vue', '*');

export const vue = {
Expand Down
5 changes: 3 additions & 2 deletions integration/templates/vue-vite/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { SignedIn, SignedOut, UserButton, OrganizationSwitcher, ClerkLoaded, ClerkLoading } from '@clerk/vue';
import { SignedIn, SignedOut, OrganizationSwitcher, ClerkLoaded, ClerkLoading } from '@clerk/vue';
import CustomUserButton from './components/CustomUserButton.vue';
</script>

<template>
Expand All @@ -9,7 +10,7 @@ import { SignedIn, SignedOut, UserButton, OrganizationSwitcher, ClerkLoaded, Cle
<p class="title">Vue Clerk Integration test</p>
</div>
<SignedIn>
<UserButton />
<CustomUserButton />
<OrganizationSwitcher />
</SignedIn>
<SignedOut>
Expand Down
32 changes: 32 additions & 0 deletions integration/templates/vue-vite/src/components/CustomUserButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { UserButton } from '@clerk/vue';
import { ref } from 'vue';
const isActionClicked = ref(false);
</script>

<template>
<UserButton>
<UserButton.MenuItems>
<UserButton.Action label="signOut" />
<UserButton.Action label="manageAccount" />
<UserButton.Link
label="Custom link"
href="/profile"
>
<template #labelIcon>
<div>Icon</div>
</template>
</UserButton.Link>
<UserButton.Action
label="Custom action"
@click="isActionClicked = true"
>
<template #labelIcon>
<div>Icon</div>
</template>
</UserButton.Action>
</UserButton.MenuItems>
</UserButton>
<div>Is action clicked: {{ isActionClicked }}</div>
</template>
45 changes: 45 additions & 0 deletions integration/tests/vue/components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,51 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te
await u.po.userButton.toHaveVisibleMenuItems([/Manage account/i, /Sign out$/i]);
});

test('render user button with custom menu items', 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.page.waitForAppUrl('/');
await u.po.userButton.waitForMounted();
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();

// Check if custom menu items are visible
await u.po.userButton.toHaveVisibleMenuItems([/Custom link/i, /Custom action/i]);

// Click custom action
await u.page.getByRole('menuitem', { name: /Custom action/i }).click();
await expect(u.page.getByText('Is action clicked: true')).toBeVisible();

// Trigger the popover again
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();

// Click custom link and check navigation
await u.page.getByRole('menuitem', { name: /Custom link/i }).click();
await u.page.waitForAppUrl('/profile');
});

test('reorders default user button menu items and functions as expected', 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.page.waitForAppUrl('/');
await u.po.userButton.waitForMounted();
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();

// First item should now be the sign out button
await u.page.getByRole('menuitem').first().click();
await u.po.expect.toBeSignedOut();
});

test('render user profile and current user data', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/sign-in');
Expand Down
94 changes: 91 additions & 3 deletions packages/vue/src/components/uiComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,20 @@ import type {
UserButtonProps,
UserProfileProps,
WaitlistProps,
Without,
} from '@clerk/types';
import { computed, defineComponent, h, onScopeDispose, ref, watchEffect } from 'vue';
import { computed, defineComponent, h, inject, onScopeDispose, provide, ref, watchEffect } from 'vue';

import { useClerk } from '../composables/useClerk';
import { errorThrower } from '../errors/errorThrower';
import {
userButtonMenuActionRenderedError,
userButtonMenuItemsRenderedError,
userButtonMenuLinkRenderedError,
} from '../errors/messages';
import { UserButtonInjectionKey, UserButtonMenuItemsInjectionKey } from '../keys';
import type { CustomPortalsRendererProps, UserButtonActionProps, UserButtonLinkProps } from '../types';
import { useUserButtonCustomMenuItems } from '../utils/useCustomMenuItems';
import { ClerkLoaded } from './controlComponents';

type AnyObject = Record<string, any>;
Expand All @@ -24,6 +34,10 @@ interface MountProps {
props?: AnyObject;
}

const CustomPortalsRenderer = defineComponent((props: CustomPortalsRendererProps) => {
return () => [...(props?.customPagesPortals ?? []), ...(props?.customMenuItemsPortals ?? [])];
});

/**
* A utility component that handles mounting and unmounting of Clerk UI components.
* The component only mounts when Clerk is fully loaded and automatically
Expand Down Expand Up @@ -69,16 +83,90 @@ export const UserProfile = defineComponent((props: UserProfileProps) => {
});
});

export const UserButton = defineComponent((props: UserButtonProps) => {
type UserButtonPropsWithoutCustomMenuItems = Without<UserButtonProps, 'customMenuItems'>;

const _UserButton = defineComponent((props: UserButtonPropsWithoutCustomMenuItems, { slots }) => {
const clerk = useClerk();

return () =>
const { customMenuItems, customMenuItemsPortals, addCustomMenuItem } = useUserButtonCustomMenuItems();

const finalProps = computed<UserButtonProps>(() => ({
...props,
customMenuItems: customMenuItems.value,
// TODO: Add custom pages
}));

provide(UserButtonInjectionKey, {
addCustomMenuItem,
});

return () => [
h(Portal, {
mount: clerk.value?.mountUserButton,
unmount: clerk.value?.unmountUserButton,
updateProps: (clerk.value as any)?.__unstable__updateProps,
props: finalProps.value,
}),
h(CustomPortalsRenderer, {
// TODO: Add custom pages portals
customMenuItemsPortals: customMenuItemsPortals.value,
}),
slots.default?.(),
];
});

const MenuItems = defineComponent((_, { slots }) => {
const ctx = inject(UserButtonInjectionKey);

if (!ctx) {
return errorThrower.throw(userButtonMenuItemsRenderedError);
}

provide(UserButtonMenuItemsInjectionKey, ctx);
return () => slots.default?.();
});

export const MenuAction = defineComponent(
(props: UserButtonActionProps, { slots }) => {
const ctx = inject(UserButtonMenuItemsInjectionKey);
if (!ctx) {
return errorThrower.throw(userButtonMenuActionRenderedError);
}

ctx.addCustomMenuItem({
props,
slots,
component: MenuAction,
});

return () => null;
},
{ name: 'MenuAction' },
);

export const MenuLink = defineComponent(
(props: UserButtonLinkProps, { slots }) => {
const ctx = inject(UserButtonMenuItemsInjectionKey);
if (!ctx) {
return errorThrower.throw(userButtonMenuLinkRenderedError);
}

ctx.addCustomMenuItem({
props,
slots,
component: MenuLink,
});

return () => null;
},
{ name: 'MenuLink' },
);

export const UserButton = Object.assign(_UserButton, {
MenuItems,
Action: MenuAction,
Link: MenuLink,
// TODO: Add custom pages
});

export const GoogleOneTap = defineComponent((props: GoogleOneTapProps) => {
Expand Down
15 changes: 15 additions & 0 deletions packages/vue/src/errors/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,18 @@ export const invalidStateError =

export const useAuthHasRequiresRoleOrPermission =
'Missing parameters. `has` from `useAuth` requires a permission or role key to be passed. Example usage: `has({permission: "org:posts:edit"})`';

export const userButtonMenuActionRenderedError =
'<UserButton.Action /> component needs to be a direct child of `<UserButton.MenuItems />`.';

export const userButtonMenuLinkRenderedError =
'<UserButton.Link /> component needs to be a direct child of `<UserButton.MenuItems />`.';

export const userButtonMenuItemLinkWrongProps =
'Missing requirements. <UserButton.Link /> component requires props: href, label and slot: labelIcon';

export const userButtonMenuItemActionWrongProps =
'Missing requirements. <UserButton.Action /> component requires props: label and slot: labelIcon';

export const userButtonMenuItemsRenderedError =
'<UserButton.MenuItems /> component needs to be a direct child of `<UserButton />`.';
10 changes: 9 additions & 1 deletion packages/vue/src/keys.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { InjectionKey } from 'vue';

import type { VueClerkInjectionKeyType } from './types';
import type { AddCustomMenuItemParams, VueClerkInjectionKeyType } from './types';

export const ClerkInjectionKey = Symbol('clerk') as InjectionKey<VueClerkInjectionKeyType>;

export const UserButtonInjectionKey = Symbol('UserButton') as InjectionKey<{
addCustomMenuItem(params: AddCustomMenuItemParams): void;
}>;

export const UserButtonMenuItemsInjectionKey = Symbol('UserButton.MenuItems') as InjectionKey<{
addCustomMenuItem(params: AddCustomMenuItemParams): void;
}>;
42 changes: 41 additions & 1 deletion packages/vue/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import type {
Clerk,
ClerkOptions,
ClientResource,
CustomMenuItem,
OrganizationCustomPermissionKey,
OrganizationCustomRoleKey,
OrganizationResource,
UserResource,
Without,
} from '@clerk/types';
import type { ComputedRef, ShallowRef } from 'vue';
import type { Component, ComputedRef, ShallowRef, Slot, VNode } from 'vue';

export interface VueClerkInjectionKeyType {
clerk: ShallowRef<Clerk | null>;
Expand Down Expand Up @@ -41,6 +42,45 @@ export interface BrowserClerk extends HeadlessBrowserClerk {
components: any;
}

export interface CustomPortalsRendererProps {
customPagesPortals?: VNode[];
customMenuItemsPortals?: VNode[];
}

export type CustomItemOrPageWithoutHandler<T> = Without<T, 'mount' | 'unmount' | 'mountIcon' | 'unmountIcon'>;

export type AddCustomMenuItemParams = {
props: CustomItemOrPageWithoutHandler<CustomMenuItem>;
slots: {
labelIcon?: Slot;
};
component: Component;
};

type ButtonActionProps<T extends string> =
| {
label: string;
onClick: () => void;
open?: never;
}
| {
label: T;
onClick?: never;
open?: never;
}
| {
label: string;
onClick?: never;
open: string;
};

export type UserButtonActionProps = ButtonActionProps<'manageAccount' | 'signOut'>;

export type UserButtonLinkProps = {
href: string;
label: string;
};

declare global {
interface Window {
Clerk: BrowserClerk;
Expand Down
29 changes: 29 additions & 0 deletions packages/vue/src/utils/__tests__/useCustomElementPortal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { h, Teleport } from 'vue';

import { useCustomElementPortal } from '../useCustomElementPortal';

describe('useCustomElementPortal', () => {
it('should return empty array when no element is mounted', () => {
const { portals } = useCustomElementPortal();
expect(portals.value).toEqual([]);
});

it('should add/remove element to portal list', () => {
const { mount, unmount, portals } = useCustomElementPortal();

const el = document.createElement('div');
el.id = 'test-portal';
const slot = () => [h('div', 'Test Content')];

mount(el, slot);

expect(portals.value).toHaveLength(1);

expect(portals.value[0].type).toBe(Teleport);
expect(portals.value[0].props!.to).toBe(el);

unmount(el);

expect(portals.value).toHaveLength(0);
});
});
Loading

0 comments on commit 1c51045

Please sign in to comment.