From bca0e772ed176f56cca87884077b49290da0d9a6 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 4 Dec 2024 13:26:47 -0800 Subject: [PATCH] chore(vue): Add custom pages and links (#4708) --- .changeset/four-beans-argue.md | 5 + .../src/components/CustomUserButton.vue | 20 +++ integration/templates/vue-vite/src/router.ts | 13 +- .../custom-pages/OrganizationProfile.vue | 29 ++++ .../src/views/custom-pages/UserProfile.vue | 29 ++++ integration/tests/vue/components.test.ts | 67 +++++++- packages/vue/src/components/uiComponents.ts | 155 ++++++++++++++++-- packages/vue/src/errors/messages.ts | 20 ++- packages/vue/src/keys.ts | 10 +- packages/vue/src/types.ts | 34 ++++ .../vue/src/utils/useCustomElementPortal.ts | 13 +- packages/vue/src/utils/useCustomPages.ts | 136 +++++++++++++++ 12 files changed, 512 insertions(+), 19 deletions(-) create mode 100644 .changeset/four-beans-argue.md create mode 100644 integration/templates/vue-vite/src/views/custom-pages/OrganizationProfile.vue create mode 100644 integration/templates/vue-vite/src/views/custom-pages/UserProfile.vue create mode 100644 packages/vue/src/utils/useCustomPages.ts diff --git a/.changeset/four-beans-argue.md b/.changeset/four-beans-argue.md new file mode 100644 index 0000000000..efba861967 --- /dev/null +++ b/.changeset/four-beans-argue.md @@ -0,0 +1,5 @@ +--- +"@clerk/vue": patch +--- + +Add support for custom pages and links diff --git a/integration/templates/vue-vite/src/components/CustomUserButton.vue b/integration/templates/vue-vite/src/components/CustomUserButton.vue index c973ec12ed..3da0675028 100644 --- a/integration/templates/vue-vite/src/components/CustomUserButton.vue +++ b/integration/templates/vue-vite/src/components/CustomUserButton.vue @@ -18,6 +18,14 @@ const isActionClicked = ref(false);
Icon
+ + + + + +
+

Custom Terms Page

+

This is the custom terms page

+
+
Is action clicked: {{ isActionClicked }}
diff --git a/integration/templates/vue-vite/src/router.ts b/integration/templates/vue-vite/src/router.ts index 245fa6427f..db90e9a319 100644 --- a/integration/templates/vue-vite/src/router.ts +++ b/integration/templates/vue-vite/src/router.ts @@ -26,6 +26,16 @@ const routes = [ path: '/unstyled', component: () => import('./views/Unstyled.vue'), }, + { + name: 'CustomUserProfile', + path: '/custom-pages/user-profile', + component: () => import('./views/custom-pages/UserProfile.vue'), + }, + { + name: 'CustomOrganizationProfile', + path: '/custom-pages/organization-profile', + component: () => import('./views/custom-pages/OrganizationProfile.vue'), + }, ]; const router = createRouter({ @@ -35,6 +45,7 @@ const router = createRouter({ router.beforeEach(async (to, _, next) => { const { isSignedIn, isLoaded } = useAuth(); + const authenticatedPages = ['Profile', 'Admin', 'CustomUserProfile', 'CustomOrganizationProfile']; if (!isLoaded.value) { await waitForClerkJsLoaded(isLoaded); @@ -42,7 +53,7 @@ router.beforeEach(async (to, _, next) => { if (isSignedIn.value && to.name === 'Sign in') { next('/profile'); - } else if (!isSignedIn.value && ['Profile', 'Admin'].includes(to.name as string)) { + } else if (!isSignedIn.value && authenticatedPages.includes(to.name)) { next('/sign-in'); } else { next(); diff --git a/integration/templates/vue-vite/src/views/custom-pages/OrganizationProfile.vue b/integration/templates/vue-vite/src/views/custom-pages/OrganizationProfile.vue new file mode 100644 index 0000000000..b2799065ef --- /dev/null +++ b/integration/templates/vue-vite/src/views/custom-pages/OrganizationProfile.vue @@ -0,0 +1,29 @@ + + + diff --git a/integration/templates/vue-vite/src/views/custom-pages/UserProfile.vue b/integration/templates/vue-vite/src/views/custom-pages/UserProfile.vue new file mode 100644 index 0000000000..455e44b95e --- /dev/null +++ b/integration/templates/vue-vite/src/views/custom-pages/UserProfile.vue @@ -0,0 +1,29 @@ + + + diff --git a/integration/tests/vue/components.test.ts b/integration/tests/vue/components.test.ts index af6c01f4ae..b0eedadd61 100644 --- a/integration/tests/vue/components.test.ts +++ b/integration/tests/vue/components.test.ts @@ -66,7 +66,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te await u.po.userButton.waitForPopover(); // Check if custom menu items are visible - await u.po.userButton.toHaveVisibleMenuItems([/Custom link/i, /Custom action/i]); + await u.po.userButton.toHaveVisibleMenuItems([/Custom link/i, /Custom page/i, /Custom action/i]); // Click custom action await u.page.getByRole('menuitem', { name: /Custom action/i }).click(); @@ -76,6 +76,16 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); + // Click custom action and check for custom page availbility + await u.page.getByRole('menuitem', { name: /Custom page/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 link and check navigation await u.page.getByRole('menuitem', { name: /Custom link/i }).click(); await u.page.waitForAppUrl('/profile'); @@ -111,6 +121,61 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te await expect(u.page.getByText(`Hello, ${fakeUser.firstName}`)).toBeVisible(); }); + test('render user profile with custom pages and links', 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.goToRelative('/custom-pages/user-profile'); + await u.po.userProfile.waitForMounted(); + + // Check if custom pages and links are visible + await expect(u.page.getByRole('button', { name: /Terms/i })).toBeVisible(); + await expect(u.page.getByRole('button', { name: /Homepage/i })).toBeVisible(); + + // Navigate to custom page + await u.page.getByRole('button', { name: /Terms/i }).click(); + await expect(u.page.getByRole('heading', { name: 'Custom Terms Page' })).toBeVisible(); + + // Check reordered default label. Security tab is now the last item. + await u.page.locator('.cl-navbarButton').last().click(); + await expect(u.page.getByRole('heading', { name: 'Security' })).toBeVisible(); + + // Click custom link and check navigation + await u.page.getByRole('button', { name: /Homepage/i }).click(); + await u.page.waitForAppUrl('/'); + }); + + test('render organization profile with custom pages and links', 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.goToRelative('/custom-pages/organization-profile'); + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + // Check if custom pages and links are visible + await expect(u.page.getByRole('button', { name: /Terms/i })).toBeVisible(); + await expect(u.page.getByRole('button', { name: /Homepage/i })).toBeVisible(); + + // Navigate to custom page + await u.page.getByRole('button', { name: /Terms/i }).click(); + await expect(u.page.getByRole('heading', { name: 'Custom Terms Page' })).toBeVisible(); + + // Check reordered default label. General tab is now the last item. + await u.page.locator('.cl-navbarButton').last().click(); + await expect(u.page.getByRole('heading', { name: 'General' })).toBeVisible(); + + // Click custom link and check navigation + await u.page.getByRole('button', { name: /Homepage/i }).click(); + await u.page.waitForAppUrl('/'); + }); + test('redirects to sign-in when unauthenticated', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToRelative('/profile'); diff --git a/packages/vue/src/components/uiComponents.ts b/packages/vue/src/components/uiComponents.ts index 26afc92924..57de3a0821 100644 --- a/packages/vue/src/components/uiComponents.ts +++ b/packages/vue/src/components/uiComponents.ts @@ -16,13 +16,31 @@ import { computed, defineComponent, h, inject, onScopeDispose, provide, ref, wat import { useClerk } from '../composables/useClerk'; import { errorThrower } from '../errors/errorThrower'; import { + organizationProfileLinkRenderedError, + organizationProfilePageRenderedError, userButtonMenuActionRenderedError, userButtonMenuItemsRenderedError, userButtonMenuLinkRenderedError, + userProfileLinkRenderedError, + userProfilePageRenderedError, } from '../errors/messages'; -import { UserButtonInjectionKey, UserButtonMenuItemsInjectionKey } from '../keys'; -import type { CustomPortalsRendererProps, UserButtonActionProps, UserButtonLinkProps } from '../types'; +import { + OrganizationProfileInjectionKey, + UserButtonInjectionKey, + UserButtonMenuItemsInjectionKey, + UserProfileInjectionKey, +} from '../keys'; +import type { + CustomPortalsRendererProps, + OrganizationLinkProps, + OrganizationProfilePageProps, + UserButtonActionProps, + UserButtonLinkProps, + UserProfileLinkProps, + UserProfilePageProps, +} from '../types'; import { useUserButtonCustomMenuItems } from '../utils/useCustomMenuItems'; +import { useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils/useCustomPages'; import { ClerkLoaded } from './controlComponents'; type AnyObject = Record; @@ -71,16 +89,70 @@ const Portal = defineComponent((props: MountProps) => { return () => h(ClerkLoaded, () => h('div', { ref: portalRef })); }); -export const UserProfile = defineComponent((props: UserProfileProps) => { +const _UserProfile = defineComponent((props: UserProfileProps, { slots }) => { const clerk = useClerk(); + const { customPages, customPagesPortals, addCustomPage } = useUserProfileCustomPages(); - return () => + const finalProps = computed(() => ({ + ...props, + customPages: customPages.value, + })); + + provide(UserProfileInjectionKey, { + addCustomPage, + }); + + return () => [ h(Portal, { mount: clerk.value?.mountUserProfile, unmount: clerk.value?.unmountUserProfile, updateProps: (clerk.value as any)?.__unstable__updateProps, + props: finalProps.value, + }), + h(CustomPortalsRenderer, { customPagesPortals: customPagesPortals.value }), + slots.default?.(), + ]; +}); + +export const UserProfilePage = defineComponent( + (props: UserProfilePageProps, { slots }) => { + const ctx = inject(UserProfileInjectionKey); + if (!ctx) { + return errorThrower.throw(userProfilePageRenderedError); + } + + ctx.addCustomPage({ props, + slots, + component: UserProfilePage, }); + + return () => null; + }, + { name: 'UserProfilePage' }, +); + +export const UserProfileLink = defineComponent( + (props: UserProfileLinkProps, { slots }) => { + const ctx = inject(UserProfileInjectionKey); + if (!ctx) { + return errorThrower.throw(userProfileLinkRenderedError); + } + + ctx.addCustomPage({ + props, + slots, + component: UserProfileLink, + }); + + return () => null; + }, + { name: 'UserProfileLink' }, +); + +export const UserProfile = Object.assign(_UserProfile, { + Page: UserProfilePage, + Link: UserProfileLink, }); type UserButtonPropsWithoutCustomMenuItems = Without; @@ -89,16 +161,23 @@ const _UserButton = defineComponent((props: UserButtonPropsWithoutCustomMenuItem const clerk = useClerk(); const { customMenuItems, customMenuItemsPortals, addCustomMenuItem } = useUserButtonCustomMenuItems(); + const { customPages, customPagesPortals, addCustomPage } = useUserProfileCustomPages(); const finalProps = computed(() => ({ ...props, + userProfileProps: { + ...(props.userProfileProps || {}), + customPages: customPages.value, + }, customMenuItems: customMenuItems.value, - // TODO: Add custom pages })); provide(UserButtonInjectionKey, { addCustomMenuItem, }); + provide(UserProfileInjectionKey, { + addCustomPage, + }); return () => [ h(Portal, { @@ -108,7 +187,7 @@ const _UserButton = defineComponent((props: UserButtonPropsWithoutCustomMenuItem props: finalProps.value, }), h(CustomPortalsRenderer, { - // TODO: Add custom pages portals + customPagesPortals: customPagesPortals.value, customMenuItemsPortals: customMenuItemsPortals.value, }), slots.default?.(), @@ -166,7 +245,7 @@ export const UserButton = Object.assign(_UserButton, { MenuItems, Action: MenuAction, Link: MenuLink, - // TODO: Add custom pages + UserProfilePage, }); export const GoogleOneTap = defineComponent((props: GoogleOneTapProps) => { @@ -239,16 +318,70 @@ export const OrganizationList = defineComponent((props: OrganizationListProps) = }); }); -export const OrganizationProfile = defineComponent((props: OrganizationProfileProps) => { +export const OrganizationProfilePage = defineComponent( + (props: OrganizationProfilePageProps, { slots }) => { + const ctx = inject(OrganizationProfileInjectionKey); + if (!ctx) { + return errorThrower.throw(organizationProfilePageRenderedError); + } + + ctx.addCustomPage({ + props, + slots, + component: OrganizationProfilePage, + }); + + return () => null; + }, + { name: 'OrganizationProfilePage' }, +); + +export const OrganizationProfileLink = defineComponent( + (props: OrganizationLinkProps, { slots }) => { + const ctx = inject(OrganizationProfileInjectionKey); + if (!ctx) { + return errorThrower.throw(organizationProfileLinkRenderedError); + } + + ctx.addCustomPage({ + props, + slots, + component: OrganizationProfileLink, + }); + + return () => null; + }, + { name: 'OrganizationProfileLink' }, +); + +const _OrganizationProfile = defineComponent((props: OrganizationProfileProps, { slots }) => { const clerk = useClerk(); + const { customPages, customPagesPortals, addCustomPage } = useOrganizationProfileCustomPages(); - return () => + const finalProps = computed(() => ({ + ...props, + customPages: customPages.value, + })); + + provide(OrganizationProfileInjectionKey, { + addCustomPage, + }); + + return () => [ h(Portal, { mount: clerk.value?.mountOrganizationProfile, unmount: clerk.value?.unmountOrganizationProfile, updateProps: (clerk.value as any)?.__unstable__updateProps, - props, - }); + props: finalProps.value, + }), + h(CustomPortalsRenderer, { customPagesPortals: customPagesPortals.value }), + slots.default?.(), + ]; +}); + +export const OrganizationProfile = Object.assign(_OrganizationProfile, { + Page: OrganizationProfilePage, + Link: OrganizationProfileLink, }); export const Waitlist = defineComponent((props: WaitlistProps) => { diff --git a/packages/vue/src/errors/messages.ts b/packages/vue/src/errors/messages.ts index aad303f809..b294b8b260 100644 --- a/packages/vue/src/errors/messages.ts +++ b/packages/vue/src/errors/messages.ts @@ -14,10 +14,26 @@ export const userButtonMenuLinkRenderedError = ' component needs to be a direct child of ``.'; export const userButtonMenuItemLinkWrongProps = - 'Missing requirements. component requires props: href, label and slot: labelIcon'; + 'Missing requirements. component requires props: href, label and slots: labelIcon.'; export const userButtonMenuItemActionWrongProps = - 'Missing requirements. component requires props: label and slot: labelIcon'; + 'Missing requirements. component requires props: label and slots: labelIcon.'; export const userButtonMenuItemsRenderedError = ' component needs to be a direct child of ``.'; + +export const customPageWrongProps = (componentName: string) => + `Missing requirements. <${componentName}.Page /> component requires props: url, label and slots: labelIcon and a default slot for page content`; + +export const customLinkWrongProps = (componentName: string) => + `Missing requirements. <${componentName}.Link /> component requires the following props: url, label and slots: labelIcon.`; + +export const userProfilePageRenderedError = + ' component needs to be a direct child of `` or ``.'; +export const userProfileLinkRenderedError = + ' component needs to be a direct child of `` or ``.'; + +export const organizationProfilePageRenderedError = + ' component needs to be a direct child of `` or ``.'; +export const organizationProfileLinkRenderedError = + ' component needs to be a direct child of `` or ``.'; diff --git a/packages/vue/src/keys.ts b/packages/vue/src/keys.ts index 1958d1ad57..7a5ca1f5d3 100644 --- a/packages/vue/src/keys.ts +++ b/packages/vue/src/keys.ts @@ -1,6 +1,6 @@ import type { InjectionKey } from 'vue'; -import type { AddCustomMenuItemParams, VueClerkInjectionKeyType } from './types'; +import type { AddCustomMenuItemParams, AddCustomPagesParams, VueClerkInjectionKeyType } from './types'; export const ClerkInjectionKey = Symbol('clerk') as InjectionKey; @@ -11,3 +11,11 @@ export const UserButtonInjectionKey = Symbol('UserButton') as InjectionKey<{ export const UserButtonMenuItemsInjectionKey = Symbol('UserButton.MenuItems') as InjectionKey<{ addCustomMenuItem(params: AddCustomMenuItemParams): void; }>; + +export const UserProfileInjectionKey = Symbol('UserProfile') as InjectionKey<{ + addCustomPage(params: AddCustomPagesParams): void; +}>; + +export const OrganizationProfileInjectionKey = Symbol('OrganizationProfile') as InjectionKey<{ + addCustomPage(params: AddCustomPagesParams): void; +}>; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index ed1a5c71aa..213c909b65 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -5,6 +5,7 @@ import type { ClerkOptions, ClientResource, CustomMenuItem, + CustomPage, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, OrganizationResource, @@ -57,6 +58,39 @@ export type AddCustomMenuItemParams = { component: Component; }; +export type AddCustomPagesParams = { + props: CustomItemOrPageWithoutHandler; + slots: { + default?: Slot; + labelIcon?: Slot; + }; + component: Component; +}; + +type PageProps = + | { + label: string; + url: string; + } + | { + label: T; + url?: never; + }; + +export type UserProfilePageProps = PageProps<'account' | 'security'>; + +export type UserProfileLinkProps = { + url: string; + label: string; +}; + +export type OrganizationProfilePageProps = PageProps<'general' | 'members'>; + +export type OrganizationLinkProps = { + url: string; + label: string; +}; + type ButtonActionProps = | { label: string; diff --git a/packages/vue/src/utils/useCustomElementPortal.ts b/packages/vue/src/utils/useCustomElementPortal.ts index 8b196cd2bb..70258bd330 100644 --- a/packages/vue/src/utils/useCustomElementPortal.ts +++ b/packages/vue/src/utils/useCustomElementPortal.ts @@ -7,6 +7,10 @@ interface RawPortal { slot: Slot; } +function generateElementIdentifier() { + return Math.random().toString(36).substring(2, 7); +} + export const useCustomElementPortal = () => { const rawPortals = ref([]); const portals = computed(() => { @@ -16,16 +20,19 @@ export const useCustomElementPortal = () => { }); const mount = (el: HTMLDivElement, slot: Slot) => { + const id = generateElementIdentifier(); + el.setAttribute('data-clerk-mount-id', id); rawPortals.value.push({ - id: el.id, + id, el, slot, }); }; const unmount = (el: HTMLDivElement | undefined) => { - if (el) { - const index = rawPortals.value.findIndex(portal => portal.id === el.id); + const id = el?.getAttribute('data-clerk-mount-id'); + if (id) { + const index = rawPortals.value.findIndex(portal => portal.id === id); if (index !== -1) { rawPortals.value.splice(index, 1); } diff --git a/packages/vue/src/utils/useCustomPages.ts b/packages/vue/src/utils/useCustomPages.ts new file mode 100644 index 0000000000..2ced0a7f09 --- /dev/null +++ b/packages/vue/src/utils/useCustomPages.ts @@ -0,0 +1,136 @@ +import { logErrorInDevMode } from '@clerk/shared/utils'; +import type { CustomPage } from '@clerk/types'; +import type { Component } from 'vue'; +import { ref } from 'vue'; + +import { + OrganizationProfileLink, + OrganizationProfilePage, + UserProfileLink, + UserProfilePage, +} from '../components/uiComponents'; +import { customLinkWrongProps, customPageWrongProps } from '../errors/messages'; +import type { AddCustomPagesParams } from '../types'; +import { isThatComponent } from './componentValidation'; +import { useCustomElementPortal } from './useCustomElementPortal'; + +export const useUserProfileCustomPages = () => { + const { customPages, customPagesPortals, addCustomPage } = useCustomPages({ + reorderItemsLabels: ['account', 'security'], + PageComponent: UserProfilePage, + LinkComponent: UserProfileLink, + componentName: 'UserProfile', + }); + + const addUserProfileCustomPage = (params: AddCustomPagesParams) => { + return addCustomPage(params); + }; + + return { + customPages, + customPagesPortals, + addCustomPage: addUserProfileCustomPage, + }; +}; + +export const useOrganizationProfileCustomPages = () => { + const { customPages, customPagesPortals, addCustomPage } = useCustomPages({ + reorderItemsLabels: ['general', 'members'], + PageComponent: OrganizationProfilePage, + LinkComponent: OrganizationProfileLink, + componentName: 'OrganizationProfile', + }); + + const addOrganizationProfileCustomPage = (params: AddCustomPagesParams) => { + return addCustomPage(params); + }; + + return { + customPages, + customPagesPortals, + addCustomPage: addOrganizationProfileCustomPage, + }; +}; + +type UseCustomPagesParams = { + LinkComponent: Component; + PageComponent: Component; + reorderItemsLabels: string[]; + componentName: string; +}; + +export const useCustomPages = (customPagesParams: UseCustomPagesParams) => { + const customPages = ref([]); + const { portals: customPagesPortals, mount, unmount } = useCustomElementPortal(); + const { PageComponent, LinkComponent, reorderItemsLabels, componentName } = customPagesParams; + + const addCustomPage = (params: AddCustomPagesParams) => { + const { props, slots, component } = params; + const { label, url } = props; + + if (isThatComponent(component, PageComponent)) { + if (isReorderItem(props, slots, reorderItemsLabels)) { + // This is a reordering item + customPages.value.push({ label }); + } else if (isCustomPage(props, slots)) { + // This is a custom page + customPages.value.push({ + label, + url, + mountIcon(el) { + mount(el, slots.labelIcon!); + }, + unmountIcon: unmount, + mount(el) { + mount(el, slots.default!); + }, + unmount, + }); + } else { + logErrorInDevMode(customPageWrongProps(componentName)); + return; + } + } + + if (isThatComponent(component, LinkComponent)) { + if (isExternalLink(props, slots)) { + // This is an external link + customPages.value.push({ + label, + url, + mountIcon(el) { + mount(el, slots.labelIcon!); + }, + unmountIcon: unmount, + }); + } else { + logErrorInDevMode(customLinkWrongProps(componentName)); + return; + } + } + }; + + return { + customPages, + customPagesPortals, + addCustomPage, + }; +}; + +const isReorderItem = (props: any, slots: AddCustomPagesParams['slots'], validItems: string[]): boolean => { + const { label, url } = props; + const { default: defaultSlot, labelIcon } = slots; + return !defaultSlot && !url && !labelIcon && validItems.some(v => v === label); +}; + +const isCustomPage = (props: any, slots: AddCustomPagesParams['slots']): boolean => { + const { label, url } = props; + const { default: defaultSlot, labelIcon } = slots; + return !!defaultSlot && !!url && !!labelIcon && !!label; +}; + +const isExternalLink = (props: any, slots: AddCustomPagesParams['slots']): boolean => { + const { label, url } = props; + const { default: defaultSlot, labelIcon } = slots; + return !defaultSlot && !!url && !!labelIcon && !!label; +};