Skip to content

Commit

Permalink
feat(astro): Add support for custom menu items (#3969)
Browse files Browse the repository at this point in the history
Co-authored-by: Lennart <[email protected]>
  • Loading branch information
wobsoriano and LekoArts authored Aug 19, 2024
1 parent 7bcab70 commit 77a02d3
Show file tree
Hide file tree
Showing 16 changed files with 349 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/eighty-timers-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/astro": minor
---

Add support for custom menu items in the `<UserButton />` Astro component.
5 changes: 4 additions & 1 deletion integration/presets/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const astroNode = applicationConfig()
.setName('astro-node')
.useTemplate(templates['astro-node'])
.setEnvFormatter('public', key => `PUBLIC_${key}`)
.addScript('setup', 'npm i')
// When creating symlinks, the Astro vite plugin is unable to process `<script>` tags.
// Without `--install-link`, it throws this error:
// https://github.com/withastro/astro/blob/cb98b74881355de9ec9d90a613a3f1d27d154463/packages/astro/src/vite-plugin-astro/index.ts#L114
.addScript('setup', 'npm i --install-links')
.addScript('dev', 'npm run dev')
.addScript('build', 'npm run build')
.addScript('serve', 'npm run preview')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
import { UserButton } from '@clerk/astro/components';
---

<UserButton afterSignOutUrl="/">
<UserButton.MenuItems>
<UserButton.Action label="signOut" />
<UserButton.Action label="manageAccount" />
<UserButton.Link label="Custom link" href="/user">
<div slot="label-icon">Icon</div>
</UserButton.Link>
<UserButton.Action label="Custom action" open="terms">
<div slot="label-icon">Icon</div>
</UserButton.Action>
<UserButton.Action label="Custom click" clickIdentifier="custom_click">
<div slot="label-icon">Icon</div>
</UserButton.Action>
</UserButton.MenuItems>
<UserButton.UserProfilePage label="Terms" url="terms">
<div slot="label-icon">Icon</div>
<div>
<h1>Custom Terms Page</h1>
<p>This is the custom terms page</p>
</div>
</UserButton.UserProfilePage>
</UserButton>
5 changes: 3 additions & 2 deletions integration/templates/astro-node/src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ interface Props {
const { title } = Astro.props;
import { SignedIn, SignedOut, UserButton } from "@clerk/astro/components";
import { SignedIn, SignedOut } from "@clerk/astro/components";
import { LanguagePicker } from "../components/LanguagePicker";
import CustomUserButton from "../components/CustomUserButton.astro";
---

<!doctype html>
Expand Down Expand Up @@ -64,7 +65,7 @@ import { LanguagePicker } from "../components/LanguagePicker";
<LanguagePicker client:idle />

<SignedIn>
<UserButton afterSignOutUrl="/" />
<CustomUserButton />
</SignedIn>

<SignedOut>
Expand Down
68 changes: 67 additions & 1 deletion integration/tests/astro/components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f
await u.po.expect.toBeSignedIn();
});

test('render user button', async ({ page, context }) => {
test('renders user button', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/sign-in');
await u.po.signIn.waitForMounted();
Expand All @@ -77,6 +77,72 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f
await expect(u.page.getByText(/profile details/i)).toBeVisible();
});

test('renders 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: fakeAdmin.email, password: fakeAdmin.password });
await u.page.waitForAppUrl('/');
await u.po.expect.toBeSignedIn();

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, /Custom click/i]);

// Click custom action and check for custom page availbility
await u.page.getByRole('menuitem', { name: /Custom action/i }).click();
await u.po.userProfile.waitForUserProfileModal();
await expect(u.page.getByRole('heading', { name: 'Custom Terms Page' })).toBeVisible();

// Close the modal and trigger the popover again
await u.page.locator('.cl-modalCloseButton').click();
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();

// Click custom action with click handler
const eventPromise = u.page.evaluate(() => {
return new Promise<string>(resolve => {
document.addEventListener(
'clerk:menu-item-click',
(e: CustomEvent<string>) => {
resolve(e.detail);
},
{ once: true },
);
});
});
await u.page.getByRole('menuitem', { name: /Custom click/i }).click();
expect(await eventPromise).toBe('custom_click');

// 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('/user');
});

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: fakeAdmin.email, password: fakeAdmin.password });
await u.page.waitForAppUrl('/');
await u.po.expect.toBeSignedIn();

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 with streamed data', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/sign-in');
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module.exports = {
rules: {
'import/no-unresolved': ['error', { ignore: ['^#'] }],
},
ignorePatterns: ['src/astro-components/index.ts'],
ignorePatterns: ['src/astro-components/index.ts', 'src/astro-components/interactive/UserButton/index.ts'],
overrides: [
{
files: ['./env.d.ts'],
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/astro-components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export { default as SignOutButton } from './unstyled/SignOutButton.astro';
*/
export { default as SignIn } from './interactive/SignIn.astro';
export { default as SignUp } from './interactive/SignUp.astro';
export { default as UserButton } from './interactive/UserButton.astro';
export { UserButton } from './interactive/UserButton';
export { default as UserProfile } from './interactive/UserProfile.astro';
export { default as OrganizationProfile } from './interactive/OrganizationProfile.astro';
export { default as OrganizationSwitcher } from './interactive/OrganizationSwitcher.astro';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
---
interface Props {
[key: string]: unknown
id?: string
component: 'sign-in' | 'sign-up' | 'organization-list' | 'organization-profile' | 'organization-switcher' | 'user-button' | 'user-profile' | 'google-one-tap'
}
import { generateSafeId } from '@clerk/astro/internal';
const safeId = generateSafeId();
const { component, id, ...props } = Astro.props
const { component, ...props } = Astro.props
const safeId = id || generateSafeId();
---

<div data-clerk-id={`clerk-${component}-${safeId}`}></div>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
type Props = {
label: string
href?: string
open?: string
clickIdentifier?: string
parent?: string
}
const { label, href, open, clickIdentifier, parent } = Astro.props
let labelIcon = '';
if (Astro.slots.has('label-icon')) {
labelIcon = await Astro.slots.render('label-icon');
}
const isDevMode = import.meta.env.DEV;
---

<script is:inline define:vars={{ label, href, open, clickIdentifier, labelIcon, isDevMode, parent }}>
const parentElement = document.currentScript.parentElement;

// We used a web component in the `<UserButton.MenuItems>` component.
const hasParentMenuItem = parentElement.tagName.toLowerCase() === 'clerk-user-button-menu-items';
if (!hasParentMenuItem) {
if (isDevMode) {
throw new Error(
`Clerk: <UserButton.MenuItems /> component can only accept <UserButton.Action /> and <UserButton.Link /> as its children. Any other provided component will be ignored.`
);
}
return
}

// Get the user button map from window that we set in the `<InternalUIComponentRenderer />`.
const userButtonComponentMap = window.__astro_clerk_component_props.get('user-button');

let userButton
if (parent) {
userButton = document.querySelector(`[data-clerk-id="clerk-user-button-${parent}"]`);
} else {
userButton = document.querySelector('[data-clerk-id^="clerk-user-button"]');
}

const safeId = userButton.getAttribute('data-clerk-id');
const currentOptions = userButtonComponentMap.get(safeId);

const reorderItemsLabels = ['manageAccount', 'signOut'];
const isReorderItem = reorderItemsLabels.includes(label);

let newMenuItem = {
label,
}

if (!isReorderItem) {
newMenuItem = {
...newMenuItem,
mountIcon: (el) => {
el.innerHTML = labelIcon
},
unmountIcon: () => { /* What to clean up? */}
}

if (href) {
newMenuItem.href = href;
} else if (open) {
newMenuItem.open = open.startsWith('/') ? open : `/${open}`;
} else if (clickIdentifier) {
const clickEvent = new CustomEvent('clerk:menu-item-click', { detail: clickIdentifier });
newMenuItem.onClick = () => {
document.dispatchEvent(clickEvent);
}
}
}

userButtonComponentMap.set(safeId, {
...currentOptions,
customMenuItems: [
...(currentOptions?.customMenuItems ?? []),
newMenuItem,
]
})
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
import type { UserButtonProps, UserProfileProps, Without } from "@clerk/types";
type Props = Without<UserButtonProps, 'userProfileProps'> & {
userProfileProps?: Pick<UserProfileProps, 'additionalOAuthScopes' | 'appearance'>;
/**
* If you have more than one UserButton on a page, providing a custom ID is required
* to properly scope menu items to the correct button.
*
* Example usage:
* ```tsx
* <UserButton id="someId">
* <UserButton.MenuItems>
* <UserButton.Link parent="someId" label="User" href="/user">
* <Icon slot="label-icon" />
* </UserButton.Link>
* </UserButton.MenuItems>
* </UserButton>
* ```
*/
id?: string;
}
import InternalUIComponentRenderer from '../InternalUIComponentRenderer.astro'
---

<InternalUIComponentRenderer {...Astro.props} component="user-button" />

<slot />
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
import MenuItemRenderer from './MenuItemRenderer.astro';
type ReorderItemsLabels = 'manageAccount' | 'signOut'
type Props<Label extends string> = {
label: Label
parent?: string
} & (Label extends ReorderItemsLabels
? {
open?: string
}
: (
| { open: string; clickIdentifier?: string }
| { open?: string; clickIdentifier: string }
)
)
const { label, open, clickIdentifier, parent } = Astro.props
---

<MenuItemRenderer label={label} open={open} clickIdentifier={clickIdentifier} parent={parent}>
<slot name="label-icon" slot="label-icon" />
</MenuItemRenderer>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
import MenuItemRenderer from './MenuItemRenderer.astro';
interface Props {
label: string
href: string
parent?: string
}
const { label, href, parent } = Astro.props
---

<MenuItemRenderer label={label} href={href} parent={parent}>
<slot name="label-icon" slot="label-icon" />
</MenuItemRenderer>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<clerk-user-button-menu-items>
<slot />
</clerk-user-button-menu-items>

<script>
/**
* In the React version, menu items need to be wrapped in a `<UserButton.MenuItems>` component.
* We are trying to replicate that behavior here by adding a wrapper component
* and check if it exists inside the menu items components.
*/
class ClerkUserButtonMenuItems extends HTMLElement {
constructor() {
super();
}
}

customElements.define('clerk-user-button-menu-items', ClerkUserButtonMenuItems);
</script>
Loading

0 comments on commit 77a02d3

Please sign in to comment.