diff --git a/.github/pr-assets/org-billing-plans.webp b/.github/pr-assets/org-billing-plans.webp new file mode 100644 index 0000000000..f17f416e19 Binary files /dev/null and b/.github/pr-assets/org-billing-plans.webp differ diff --git a/.github/pr-assets/org-general.webp b/.github/pr-assets/org-general.webp new file mode 100644 index 0000000000..c5aca7a2a6 Binary files /dev/null and b/.github/pr-assets/org-general.webp differ diff --git a/.github/pr-assets/org-team-members.webp b/.github/pr-assets/org-team-members.webp new file mode 100644 index 0000000000..896f64f11f Binary files /dev/null and b/.github/pr-assets/org-team-members.webp differ diff --git a/messages/en.context.json b/messages/en.context.json index 066405eb3d..f3baa50863 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -2590,6 +2590,7 @@ "support-usernames-prompt-save": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "support-usernames-prompt-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "switch-to-billing-period": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "team": "Used in Capgo web console areas: constants. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "terms-of-service": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "test": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "test-bundle": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", diff --git a/messages/en.json b/messages/en.json index 9229a66c31..05b82fcce5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1543,6 +1543,7 @@ "member-added": "Member added", "member-deleted": "Successfully deleted member", "members": "Members", + "team": "Team", "metadata": "Metadata", "metadata-min-ver-not-set": "Minimal update version for current bundle is undefined", "metadata-not-found": "metadata not found", diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts index 32317805c2..1b4d587a38 100644 --- a/playwright/visual-diff.config.ts +++ b/playwright/visual-diff.config.ts @@ -20,6 +20,9 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'app-overview', path: '/app/com.demo.app', auth: true }, { slug: 'app-settings', path: '/app/com.demo.app/settings', auth: true }, { slug: 'app-settings-access', path: '/app/com.demo.app/settings/access', auth: true }, + { slug: 'org-settings', path: '/settings/organization', auth: true }, + { slug: 'org-settings-team', path: '/settings/organization/members', auth: true }, + { slug: 'org-settings-billing', path: '/settings/organization/plans', auth: true }, { slug: 'channels', path: '/app/com.demo.app/channels', auth: true }, { slug: 'devices', diff --git a/src/components/Tabs.vue b/src/components/Tabs.vue index 36b5100711..63efd7b91a 100644 --- a/src/components/Tabs.vue +++ b/src/components/Tabs.vue @@ -7,24 +7,34 @@ const props = defineProps<{ activeTab: string secondaryTabs?: Tab[] secondaryActiveTab?: string + tertiaryTabs?: Tab[] + tertiaryActiveTab?: string noWrap?: boolean }>() -const emit = defineEmits(['update:activeTab', 'update:secondaryActiveTab']) +const emit = defineEmits(['update:activeTab', 'update:secondaryActiveTab', 'update:tertiaryActiveTab']) const { t } = useI18n() -function activeTabColor(tab: string, isSecondary = false) { - const isActive = (isSecondary ? props.secondaryActiveTab : props.activeTab) === tab +function activeTabColor(tab: string, row: 'primary' | 'secondary' | 'tertiary' = 'primary') { + const isActive = row === 'tertiary' + ? props.tertiaryActiveTab === tab + : row === 'secondary' + ? props.secondaryActiveTab === tab + : props.activeTab === tab - // Secondary row tabs - if (isSecondary) { + if (row === 'tertiary') { + return isActive + ? 'border-b-2 border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' + : 'border-b-2 border-transparent text-slate-500/75 dark:text-slate-400/75 hover:text-slate-700 dark:hover:text-slate-200' + } + + if (row === 'secondary') { return isActive ? 'text-blue-600 dark:text-blue-400 bg-white dark:bg-slate-800 border border-blue-200/70 dark:border-blue-800 shadow-sm hover:ring-1 hover:ring-blue-200 dark:hover:ring-blue-700 hover:bg-blue-50 dark:hover:bg-slate-900 transition-colors' : 'border border-transparent text-slate-500/75 dark:text-slate-400/75 hover:bg-white dark:hover:bg-slate-900 hover:text-slate-700 dark:hover:text-slate-200 transition-colors' } - // Primary row tabs - open tab style return isActive ? 'text-blue-500 dark:text-blue-300 bg-blue-50 dark:bg-slate-800/40 border-t border-l border-r border-blue-200/60 dark:border-blue-800/70 border-b-0 before:content-[\'\'] before:absolute before:bottom-[-1px] before:left-0 before:right-0 before:h-[3px] before:bg-blue-50 dark:before:bg-[#141e33] before:z-[11] hover:bg-blue-100 dark:hover:bg-[#1e3050] transition-colors' : 'border border-transparent text-slate-500/75 dark:text-slate-400/75 hover:bg-blue-100/70 dark:hover:bg-[#1a2744cc] hover:text-slate-700 dark:hover:text-slate-200 transition-colors' @@ -32,9 +42,11 @@ function activeTabColor(tab: string, isSecondary = false) { const ulPrimaryClass = 'flex text-xs md:text-sm font-medium text-center text-gray-500 dark:text-gray-300 gap-1 pt-1 px-1' const ulSecondaryClass = 'flex text-sm font-medium text-center text-gray-600 dark:text-gray-200 gap-2 py-2' +const ulTertiaryClass = 'flex text-sm font-medium text-center text-gray-600 dark:text-gray-200 gap-1 px-1' const noWrapClass = 'flex-nowrap max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x no-scrollbar px-1 pb-px' const buttonPrimaryClass = 'inline-flex items-center gap-2 px-3 py-2 min-w-[42px] min-h-[38px] rounded-t-md cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900 transition-all group relative' const buttonSecondaryClass = 'inline-flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900 transition-colors group' +const buttonTertiaryClass = 'inline-flex items-center gap-2 px-3 py-2 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900 transition-colors group' const iconClass = 'w-5 h-5 transition-colors' const labelClass = 'hidden md:block text-xs md:text-sm font-medium transition-colors first-letter:uppercase' @@ -65,7 +77,7 @@ const labelClass = 'hidden md:block text-xs md:text-sm font-medium transition-co type="button" :aria-current="secondaryActiveTab === tab.key ? 'page' : undefined" :aria-label="t(tab.label)" - :class="[buttonSecondaryClass, activeTabColor(tab.key, true)]" + :class="[buttonSecondaryClass, activeTabColor(tab.key, 'secondary')]" @click="emit('update:secondaryActiveTab', tab.key)" > @@ -74,6 +86,21 @@ const labelClass = 'hidden md:block text-xs md:text-sm font-medium transition-co + diff --git a/src/components/comp_def.ts b/src/components/comp_def.ts index 8da2f7eb23..f8d08aecf1 100644 --- a/src/components/comp_def.ts +++ b/src/components/comp_def.ts @@ -55,4 +55,5 @@ export interface Tab { badge?: string onClick?: (elem: T | undefined) => void redirect?: boolean + children?: Tab[] } diff --git a/src/constants/organizationTabs.ts b/src/constants/organizationTabs.ts index 2a0ba00e89..a0abc5cc11 100644 --- a/src/constants/organizationTabs.ts +++ b/src/constants/organizationTabs.ts @@ -8,14 +8,29 @@ import IconWebhook from '~icons/heroicons/globe-alt' import IconInfo from '~icons/heroicons/information-circle' import IconSecurity from '~icons/heroicons/shield-check' import IconUsers from '~icons/heroicons/users' +import { BILLING_TAB_KEY, TEAM_TAB_KEY } from '~/utils/organizationTabs' export const organizationTabs: Tab[] = [ { label: 'general', key: '/settings/organization', icon: IconInfo }, - { label: 'members', key: '/settings/organization/members', icon: IconUsers }, - { label: 'groups', key: '/settings/organization/groups', icon: IconUsers }, - { label: 'plans', key: '/settings/organization/plans', icon: IconPlan }, - { label: 'credits', key: '/settings/organization/credits', icon: IconCredits }, - { label: 'security', key: '/settings/organization/security', icon: IconSecurity }, + { + label: 'team', + key: TEAM_TAB_KEY, + icon: IconUsers, + children: [ + { label: 'members', key: '/settings/organization/members', icon: IconUsers }, + { label: 'groups', key: '/settings/organization/groups', icon: IconUsers }, + { label: 'security', key: '/settings/organization/security', icon: IconSecurity }, + ], + }, + { + label: 'billing', + key: BILLING_TAB_KEY, + icon: IconPlan, + children: [ + { label: 'plans', key: '/settings/organization/plans', icon: IconPlan }, + { label: 'credits', key: '/settings/organization/credits', icon: IconCredits }, + ], + }, { label: 'usage', key: '/settings/organization/usage', icon: IconChart }, { label: 'notifications', key: '/settings/organization/notifications', icon: IconBell }, { label: 'audit-logs', key: '/settings/organization/auditlogs', icon: IconAudit }, diff --git a/src/layouts/settings.vue b/src/layouts/settings.vue index 557dc6848f..2613ae7e9e 100644 --- a/src/layouts/settings.vue +++ b/src/layouts/settings.vue @@ -17,6 +17,15 @@ import { checkPermissions } from '~/services/permissions' import { openPortal } from '~/services/stripe' import { stripeEnabled } from '~/services/supabase' import { useOrganizationStore } from '~/stores/organization' +import { + BILLING_TAB_KEY, + cloneTabs, + defaultChild, + findActiveChildKey, + findActiveTabKey, + pathMatchesTab, + TEAM_TAB_KEY, +} from '~/utils/organizationTabs' const { t } = useI18n() const organizationStore = useOrganizationStore() @@ -24,6 +33,12 @@ const router = useRouter() const route = useRoute() const hideExternalPurchaseFlows = isNativeAppStoreContext() +const restrictedPurchaseKeys = new Set([ + '/billing', + '/settings/organization/credits', + '/settings/organization/plans', +]) + // Modal state for non-admin billing access (triggered by billing tab click) const showBillingModal = ref(false) @@ -40,20 +55,23 @@ const shouldBlockContent = computed(() => { return needsSecurityCompliance.value && route.path.startsWith('/settings/organization') }) -// keep Tab icon typing (including ShallowRef) instead of Vue's UnwrapRef narrowing -function withoutExternalPurchaseTabs(tabs: Tab[]) { +function withoutRestrictedPurchaseTabs(tabs: Tab[]): Tab[] { if (!hideExternalPurchaseFlows) return tabs - const restrictedKeys = new Set([ - '/billing', - '/settings/organization/credits', - '/settings/organization/plans', - ]) - return tabs.filter(tab => !restrictedKeys.has(tab.key)) + return tabs + .map((tab) => { + if (!tab.children?.length) + return restrictedPurchaseKeys.has(tab.key) ? null : tab + const children = tab.children.filter(child => !restrictedPurchaseKeys.has(child.key)) + if (!children.length) + return null + return { ...tab, children } + }) + .filter((tab): tab is Tab => tab !== null) } -const organizationTabs = ref(withoutExternalPurchaseTabs([...baseOrgTabs])) as Ref +const organizationTabs = ref(withoutRestrictedPurchaseTabs(cloneTabs(baseOrgTabs))) as Ref const canReadBilling = computedAsync(async () => { const orgId = organizationStore.currentOrganization?.gid @@ -121,111 +139,54 @@ watchEffect(() => { watchEffect(() => { const billingEnabled = stripeEnabled.value - const needsGroups = !!organizationStore.currentOrganization?.gid - const hasGroups = organizationTabs.value.find(tab => tab.key === '/settings/organization/groups') - if (needsGroups && !hasGroups) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/groups') - const membersIndex = organizationTabs.value.findIndex(tab => tab.key === '/settings/organization/members') - if (base && membersIndex >= 0) - organizationTabs.value.splice(membersIndex + 1, 0, { ...base }) - else if (base) - organizationTabs.value.push({ ...base }) + const tabs = withoutRestrictedPurchaseTabs(cloneTabs(baseOrgTabs)) + + const teamTab = tabs.find(tab => tab.key === TEAM_TAB_KEY) + if (teamTab?.children) { + if (!organizationStore.currentOrganization?.gid) + teamTab.children = teamTab.children.filter(child => child.key !== '/settings/organization/groups') + if (!canManageSecurity.value) + teamTab.children = teamTab.children.filter(child => child.key !== '/settings/organization/security') } - if (!needsGroups && hasGroups) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/groups') - // ensure usage/plans tabs based on permissions (keeps icons from base) - const needsUsage = billingEnabled && canReadBilling.value - const hasUsage = organizationTabs.value.find(tab => tab.key === '/settings/organization/usage') - if (needsUsage && !hasUsage) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/usage') - if (base) - organizationTabs.value.push({ ...base }) + const billingTab = tabs.find(tab => tab.key === BILLING_TAB_KEY) + const needsBillingGroup = billingEnabled && canReadBilling.value + if (!needsBillingGroup) { + const billingIndex = tabs.findIndex(tab => tab.key === BILLING_TAB_KEY) + if (billingIndex >= 0) + tabs.splice(billingIndex, 1) } - if (!needsUsage && hasUsage) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/usage') - - // Plans/Credits are readable with org.read_billing; mutations stay gated by org.update_billing. - const needsCredits = billingEnabled && canReadBilling.value && !hideExternalPurchaseFlows - const hasCredits = organizationTabs.value.find(tab => tab.key === '/settings/organization/credits') - - if (needsCredits && !hasCredits) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/credits') - if (base) - organizationTabs.value.push({ ...base }) + else if (billingTab && !hideExternalPurchaseFlows) { + billingTab.children = [ + ...(billingTab.children ?? []), + { + label: 'billing', + icon: IconBilling, + key: '/billing', + onClick: () => { + if (canUpdateBilling.value) + openPortal(organizationStore.currentOrganization?.gid ?? '', t) + else + showBillingModal.value = true + }, + }, + ] } - if (!needsCredits && hasCredits) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/credits') - - const needsPlans = billingEnabled && canReadBilling.value && !hideExternalPurchaseFlows - const hasPlans = organizationTabs.value.find(tab => tab.key === '/settings/organization/plans') - if (needsPlans && !hasPlans) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/plans') - if (base) - organizationTabs.value.push({ ...base }) - } - if (!needsPlans && hasPlans) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/plans') - - // Audit logs - visible only to super_admins - const needsAuditLogs = canReadAuditLogs.value - const hasAuditLogs = organizationTabs.value.find(tab => tab.key === '/settings/organization/audit-logs') - if (needsAuditLogs && !hasAuditLogs) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/audit-logs') - if (base) - organizationTabs.value.push({ ...base }) - } - if (!needsAuditLogs && hasAuditLogs) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/audit-logs') - - // Security - visible only to super_admins - const needsSecurity = canManageSecurity.value - const hasSecurity = organizationTabs.value.find(tab => tab.key === '/settings/organization/security') - if (needsSecurity && !hasSecurity) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/security') - if (base) - organizationTabs.value.push({ ...base }) - } - if (!needsSecurity && hasSecurity) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/security') - - // Ensure tabs appear in the exact order defined by baseOrgTabs - organizationTabs.value.sort((a, b) => { - const idxA = baseOrgTabs.findIndex(t => t.key === a.key) - const idxB = baseOrgTabs.findIndex(t => t.key === b.key) - if (idxA === -1 && idxB === -1) - return 0 - if (idxA === -1) - return 1 - if (idxB === -1) - return -1 - return idxA - idxB - }) - - // Check billing access - users with org.read_billing permission can access billing - if (!hideExternalPurchaseFlows - && billingEnabled - && canReadBilling.value - && !organizationTabs.value.find(tab => tab.key === '/billing')) { - organizationTabs.value.push({ - label: 'billing', - icon: IconBilling, - key: '/billing', - onClick: () => { - // Check permission at click time to handle role changes - if (canUpdateBilling.value) { - openPortal(organizationStore.currentOrganization?.gid ?? '', t) - } - else { - showBillingModal.value = true - } - }, - }) + const needsUsage = billingEnabled && canReadBilling.value + if (!needsUsage) { + const usageIndex = tabs.findIndex(tab => tab.key === '/settings/organization/usage') + if (usageIndex >= 0) + tabs.splice(usageIndex, 1) } - else if (hideExternalPurchaseFlows || !canReadBilling.value || !billingEnabled) { - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/billing') + + if (!canReadAuditLogs.value) { + const auditIndex = tabs.findIndex(tab => tab.key === '/settings/organization/auditlogs') + if (auditIndex >= 0) + tabs.splice(auditIndex, 1) } + + organizationTabs.value = tabs.filter(tab => !tab.children || tab.children.length > 0) }) const activePrimary = computed(() => { @@ -240,33 +201,53 @@ const secondaryTabs = computed(() => { }) const activeSecondary = computed(() => { - const tabs = secondaryTabs.value - const path = route.path.replace(/\/$/, '') + return findActiveTabKey(secondaryTabs.value, route.path) ?? secondaryTabs.value[0]?.key +}) - // Prefer the most specific match (longest path) so nested routes like - // `/settings/organization/members` don't get claimed by the parent - // `/settings/organization` tab. - const ordered = [...tabs].sort((a, b) => b.key.length - a.key.length) +const activeSecondaryTab = computed(() => { + return secondaryTabs.value.find(tab => tab.key === activeSecondary.value) +}) - const match = ordered.find((t) => { - const key = t.key.replace(/\/$/, '') - return path === key || path.startsWith(`${key}/`) - }) +const tertiaryTabs = computed(() => { + const children = activeSecondaryTab.value?.children ?? [] + return children.length > 1 ? children : [] +}) - return match?.key ?? tabs[0]?.key +const activeTertiary = computed(() => { + return findActiveChildKey(activeSecondaryTab.value, route.path) }) +function activateTab(tab: Tab | undefined) { + if (!tab) + return + if (tab.onClick) { + tab.onClick(tab.key) + return + } + router.push(tab.key) +} + function handlePrimary(val: string) { // Clicking primary switches to the root of that section router.push(val === '/settings/organization' ? '/settings/organization' : '/settings/account') } + function handleSecondary(val: string) { const tab = secondaryTabs.value.find(t => t.key === val) - if (tab?.onClick) { - tab.onClick(val) + if (!tab) + return + if (tab.children?.length) { + if (pathMatchesTab(tab, route.path)) + return + activateTab(defaultChild(tab)) return } - router.push(val) + activateTab(tab) +} + +function handleTertiary(val: string) { + const tab = tertiaryTabs.value.find(t => t.key === val) + activateTab(tab) } @@ -277,9 +258,12 @@ function handleSecondary(val: string) { :active-tab="activePrimary" :secondary-tabs="shouldBlockContent ? [] : secondaryTabs" :secondary-active-tab="activeSecondary" + :tertiary-tabs="shouldBlockContent ? [] : tertiaryTabs" + :tertiary-active-tab="activeTertiary" no-wrap @update:active-tab="handlePrimary" @update:secondary-active-tab="handleSecondary" + @update:tertiary-active-tab="handleTertiary" />
({ + ...tab, + ...(tab.children ? { children: cloneTabs(tab.children) } : {}), + })) +} + +function pathMatchesKey(path: string, key: string): boolean { + const normalizedPath = path.replace(/\/$/, '').toLowerCase() + const normalizedKey = key.replace(/\/$/, '').toLowerCase() + return normalizedPath === normalizedKey || normalizedPath.startsWith(`${normalizedKey}/`) +} + +export function pathMatchesTab(tab: Tab, path: string): boolean { + if (tab.children?.length) + return tab.children.some(child => pathMatchesTab(child, path)) + return pathMatchesKey(path, tab.key) +} + +export function findActiveTabKey(tabs: Tab[], path: string): string | undefined { + const grouped = tabs.find(tab => tab.children?.length && pathMatchesTab(tab, path)) + if (grouped) + return grouped.key + + const leaves = tabs.filter(tab => !tab.children?.length) + const match = [...leaves] + .sort((a, b) => b.key.length - a.key.length) + .find(tab => pathMatchesKey(path, tab.key)) + + return match?.key ?? tabs[0]?.key +} + +export function findActiveChildKey(tab: Tab | undefined, path: string): string | undefined { + if (!tab?.children?.length) + return undefined + return findActiveTabKey(tab.children, path) +} + +export function defaultChild(tab: Tab | undefined): Tab | undefined { + return tab?.children?.[0] +} diff --git a/tests/organization-tabs.unit.test.ts b/tests/organization-tabs.unit.test.ts new file mode 100644 index 0000000000..05dd5b24a9 --- /dev/null +++ b/tests/organization-tabs.unit.test.ts @@ -0,0 +1,78 @@ +import type { Tab } from '../src/components/comp_def' +import { describe, expect, it } from 'vitest' +import { + BILLING_TAB_KEY, + cloneTabs, + defaultChild, + findActiveChildKey, + findActiveTabKey, + pathMatchesTab, + TEAM_TAB_KEY, +} from '../src/utils/organizationTabs' + +const organizationTabs: Tab[] = [ + { label: 'general', key: '/settings/organization' }, + { + label: 'team', + key: TEAM_TAB_KEY, + children: [ + { label: 'members', key: '/settings/organization/members' }, + { label: 'groups', key: '/settings/organization/groups' }, + { label: 'security', key: '/settings/organization/security' }, + ], + }, + { + label: 'billing', + key: BILLING_TAB_KEY, + children: [ + { label: 'plans', key: '/settings/organization/plans' }, + { label: 'credits', key: '/settings/organization/credits' }, + ], + }, + { label: 'usage', key: '/settings/organization/usage' }, + { label: 'webhooks', key: '/settings/organization/webhooks' }, +] + +describe('organization settings tab groups', () => { + it.concurrent('defaults team to members and billing to plans', () => { + const team = organizationTabs.find(tab => tab.key === TEAM_TAB_KEY) + const billing = organizationTabs.find(tab => tab.key === BILLING_TAB_KEY) + + expect(defaultChild(team)?.key).toBe('/settings/organization/members') + expect(defaultChild(billing)?.key).toBe('/settings/organization/plans') + expect(team?.children?.map(tab => tab.label)).toEqual(['members', 'groups', 'security']) + expect(billing?.children?.map(tab => tab.label)).toEqual(['plans', 'credits']) + }) + + it.concurrent('keeps grouped parent tabs active for nested routes', () => { + expect(findActiveTabKey(organizationTabs, '/settings/organization/members')).toBe(TEAM_TAB_KEY) + expect(findActiveTabKey(organizationTabs, '/settings/organization/groups/abc')).toBe(TEAM_TAB_KEY) + expect(findActiveTabKey(organizationTabs, '/settings/organization/security')).toBe(TEAM_TAB_KEY) + expect(findActiveTabKey(organizationTabs, '/settings/organization/plans')).toBe(BILLING_TAB_KEY) + expect(findActiveTabKey(organizationTabs, '/settings/organization/credits')).toBe(BILLING_TAB_KEY) + expect(findActiveTabKey(organizationTabs, '/settings/organization/Credits')).toBe(BILLING_TAB_KEY) + expect(findActiveTabKey(organizationTabs, '/settings/organization/Members')).toBe(TEAM_TAB_KEY) + expect(findActiveTabKey(organizationTabs, '/settings/organization')).toBe('/settings/organization') + expect(findActiveTabKey(organizationTabs, '/settings/organization/webhooks')).toBe('/settings/organization/webhooks') + }) + + it.concurrent('matches the active child inside a group', () => { + const tabs = cloneTabs(organizationTabs) + const team = tabs.find(tab => tab.key === TEAM_TAB_KEY) + + expect(findActiveChildKey(team, '/settings/organization/groups/new')).toBe('/settings/organization/groups') + expect(findActiveChildKey(team, '/settings/organization/Groups/new')).toBe('/settings/organization/groups') + expect(pathMatchesTab(team!, '/settings/organization/members')).toBe(true) + expect(pathMatchesTab(team!, '/settings/organization/plans')).toBe(false) + }) + + it.concurrent('clones nested children without sharing arrays', () => { + const tabs = cloneTabs(organizationTabs) + const source = organizationTabs.find(tab => tab.key === TEAM_TAB_KEY)! + const clone = tabs.find(tab => tab.key === TEAM_TAB_KEY)! + + expect(clone.children).not.toBe(source.children) + clone.children = clone.children!.filter(child => child.key !== '/settings/organization/security') + expect(source.children?.map(child => child.key)).toContain('/settings/organization/security') + }) +})