Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(astro): Add support for custom menu items #3969

Merged
merged 24 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9ca6419
feat(astro): Add custom menu items
wobsoriano Aug 15, 2024
5b0ec29
chore(astro): Add custom user profile page
wobsoriano Aug 15, 2024
c184539
chore(astro): Replace action prop with open
wobsoriano Aug 15, 2024
909127e
chore(astro): Add changeset
wobsoriano Aug 15, 2024
b0727a8
chore(astro): Remove old component export
wobsoriano Aug 15, 2024
4127d11
chore(astro): Select single element for user button
wobsoriano Aug 15, 2024
74d1462
test(astro): Add custom menu item action test
wobsoriano Aug 16, 2024
73cc657
chore(astro): Update changeset
wobsoriano Aug 16, 2024
49b8488
test(astro): Add custom menu item link test
wobsoriano Aug 16, 2024
147cda4
chore(astro): Add custom menu items reordering
wobsoriano Aug 16, 2024
cecb1bb
Merge branch 'main' into rob/eco-130-custom-menu-items-in-astro
wobsoriano Aug 16, 2024
dcd56d4
chore(astro): Make sure items are wrapped in parent menu items component
wobsoriano Aug 16, 2024
cc63e13
chore(astro): Add more description to parent wrappers
wobsoriano Aug 16, 2024
3f5ce80
chore(astro): Update changeset
wobsoriano Aug 16, 2024
e8842f2
chore(astro): Add click handler to menu items
wobsoriano Aug 16, 2024
2a7fbaa
chore(astro): Rename selector to clickIdentifier
wobsoriano Aug 16, 2024
f5755fa
test(astro): Add menu item reordering test
wobsoriano Aug 16, 2024
6158081
chore(astro): formatting update
wobsoriano Aug 16, 2024
febe748
Merge branch 'main' into rob/eco-130-custom-menu-items-in-astro
wobsoriano Aug 16, 2024
bd90ce6
chore(astro): Make sure multiple user buttons are properly scoped
wobsoriano Aug 16, 2024
c43c401
chore(astro): Add clerk identifier to web component
wobsoriano Aug 16, 2024
57313e1
chore(astro): Remove unused prop type
wobsoriano Aug 16, 2024
f89a32e
chore(astro): Update click event name
wobsoriano Aug 18, 2024
7542e82
chore(astro): Update test preset
wobsoriano Aug 18, 2024
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
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="/">
LekoArts marked this conversation as resolved.
Show resolved Hide resolved
<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);
LekoArts marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

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
Loading