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')
+ })
+})