Skip to content

Commit fdfbf0b

Browse files
feat(organizations): add Organization tab + rename General route to tab-addressable (#4184)
* refactor(organizations): rename General child path to 'general' + bare-path redirect * feat(organizations): add 'Organization' tab descriptor (alongside Billing) * test(organizations): assert SurfaceTabBar receives both Organization + Billing tabs * test(organizations): tighten resolveSurfaceTabs assertion to expect both tabs (CR feedback)
1 parent 9f5bdc1 commit fdfbf0b

5 files changed

Lines changed: 77 additions & 8 deletions

File tree

src/modules/organizations/config/organizations.development.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default {
55
// Each entry: { value, label, icon?, route, action?, subject? }
66
// action + subject are the CASL pair filtered by resolveSurfaceTabs.
77
tabs: [
8+
{ value: 'organization', label: 'Organization', icon: 'fa-solid fa-building', route: 'general', action: 'read', subject: 'Organization' },
89
{ value: 'billing', label: 'Billing', icon: 'fa-solid fa-credit-card', route: 'billing', action: 'manage', subject: 'Organization' },
910
],
1011
},

src/modules/organizations/router/organizations.router.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ export default [
5252
// are injected via organizationChildModules in app.router.js.
5353
children: [
5454
{
55+
// Redirect bare /users/organizations/:id → :id/general so SurfaceTabBar
56+
// descriptors with `route: 'general'` resolve correctly.
5557
path: '',
58+
redirect: { name: 'Account Organization General' },
59+
},
60+
{
61+
path: 'general',
5662
name: 'Account Organization General',
5763
component: organizationGeneralTab,
5864
props: (route) => ({ organizationId: route.params.organizationId }),

src/modules/organizations/tests/organizations.config.unit.tests.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,35 @@ describe('organizations module config — organizations.tabs', () => {
4949
expect(isValidTab(billingTab)).toBe(true);
5050
});
5151

52-
it('resolveSurfaceTabs returns billing tab when can("manage","Organization") is true', () => {
52+
it('resolveSurfaceTabs returns both Organization + Billing tabs when can() is true', () => {
5353
const tabs = organizationsDefaultConfig.organizations.tabs;
5454
const result = resolveSurfaceTabs(tabs, () => true);
55-
expect(result).toHaveLength(1);
56-
expect(result[0].value).toBe('billing');
55+
expect(result).toHaveLength(2);
56+
expect(result.find((t) => t.value === 'organization')).toBeDefined();
57+
expect(result.find((t) => t.value === 'billing')).toBeDefined();
5758
});
5859

5960
it('resolveSurfaceTabs filters out billing tab when can("manage","Organization") is false', () => {
6061
const tabs = organizationsDefaultConfig.organizations.tabs;
62+
// can() = false filters out all CASL-gated tabs; organization tab has action:'read' so also filtered
6163
const result = resolveSurfaceTabs(tabs, () => false);
6264
expect(result).toHaveLength(0);
6365
});
66+
67+
test('organizations.tabs has the "organization" tab as the first entry', () => {
68+
expect(organizationsDefaultConfig.organizations.tabs[0]).toEqual({
69+
value: 'organization',
70+
label: 'Organization',
71+
icon: 'fa-solid fa-building',
72+
route: 'general',
73+
action: 'read',
74+
subject: 'Organization',
75+
});
76+
});
77+
78+
test('organizations.tabs still has the "billing" tab (preserved)', () => {
79+
const billing = organizationsDefaultConfig.organizations.tabs.find((t) => t.value === 'billing');
80+
expect(billing).toBeTruthy();
81+
expect(billing.route).toBe('billing');
82+
});
6483
});

src/modules/organizations/tests/organizations.detail.layout.unit.tests.js

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ vi.mock('../../../lib/services/config', () => ({
4242
vuetify: { theme: { flat: true, rounded: 'rounded-lg' } },
4343
organizations: {
4444
tabs: [
45+
{
46+
value: 'organization',
47+
label: 'Organization',
48+
icon: 'fa-solid fa-building',
49+
route: 'general',
50+
action: 'read',
51+
subject: 'Organization',
52+
},
4553
{
4654
value: 'billing',
4755
label: 'Billing',
@@ -103,6 +111,14 @@ function mountLayout(orgId = 'abc123', routePath = null) {
103111
vuetify: { theme: { flat: true, rounded: 'rounded-lg' } },
104112
organizations: {
105113
tabs: [
114+
{
115+
value: 'organization',
116+
label: 'Organization',
117+
icon: 'fa-solid fa-building',
118+
route: 'general',
119+
action: 'read',
120+
subject: 'Organization',
121+
},
106122
{
107123
value: 'billing',
108124
label: 'Billing',
@@ -193,6 +209,14 @@ describe('organization.detail.component.vue — tabbed parent layout (C3)', () =
193209
expect(tabs.find((t) => t.value === 'billing')).toBeDefined();
194210
});
195211

212+
it('passes both Organization + Billing tabs to CoreSurfaceTabBar', () => {
213+
const wrapper = mountLayout();
214+
const tabBar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' });
215+
expect(tabBar.exists()).toBe(true);
216+
const tabs = tabBar.props('tabs');
217+
expect(tabs.map((t) => t.value)).toEqual(['organization', 'billing']);
218+
});
219+
196220
it('passes a function as the `can` prop to CoreSurfaceTabBar', () => {
197221
const wrapper = mountLayout();
198222
const tabBar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' });
@@ -481,15 +505,15 @@ describe('organizations router — nested route structure (C3)', () => {
481505
expect(Array.isArray(orgParent.children)).toBe(true);
482506
});
483507

484-
it('default child (path="") passes organizationId as props function', async () => {
508+
it('general child (path="general") passes organizationId as props function', async () => {
485509
const { default: orgRoutes, ORG_PARENT_PATH } = await import('../router/organizations.router.js');
486510
const orgParent = orgRoutes.find((r) => r.path === ORG_PARENT_PATH);
487-
const defaultChild = orgParent.children.find((c) => c.path === '');
488-
expect(defaultChild).toBeDefined();
511+
const generalChild = orgParent.children.find((c) => c.path === 'general');
512+
expect(generalChild).toBeDefined();
489513
// props must be a function (not static object) so it maps route.params → component props
490-
expect(typeof defaultChild.props).toBe('function');
514+
expect(typeof generalChild.props).toBe('function');
491515
// Verify the function maps organizationId correctly
492-
const result = defaultChild.props({ params: { organizationId: 'test-id' } });
516+
const result = generalChild.props({ params: { organizationId: 'test-id' } });
493517
expect(result).toEqual({ organizationId: 'test-id' });
494518
});
495519
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, test, expect } from 'vitest';
2+
import routes from '../router/organizations.router';
3+
4+
describe('organizations router', () => {
5+
test('Account Organization General child path is "general" not ""', () => {
6+
const parent = routes.find((r) => r.name === 'Account Organization');
7+
expect(parent).toBeTruthy();
8+
const general = parent.children?.find((c) => c.name === 'Account Organization General');
9+
expect(general).toBeTruthy();
10+
expect(general.path).toBe('general');
11+
});
12+
13+
test('Account Organization has a redirect from "" to General', () => {
14+
const parent = routes.find((r) => r.name === 'Account Organization');
15+
const redirect = parent.children?.find((c) => c.path === '' && c.redirect);
16+
expect(redirect).toBeTruthy();
17+
expect(redirect.redirect).toEqual({ name: 'Account Organization General' });
18+
});
19+
});

0 commit comments

Comments
 (0)