Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
79 changes: 79 additions & 0 deletions src/modules/core/components/core.pageTabs.component.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<template>
<v-card
color="surface"
:flat="config.vuetify.theme.flat"
:class="config.vuetify.theme.rounded"
data-test="page-tabs"
>
<v-tabs
:model-value="modelValue"
color="primary"
:grow="grow"
@update:model-value="(v) => $emit('update:modelValue', v)"
>
<template v-for="tab in visibleTabs" :key="tab.value">
<v-tab :value="tab.value" class="text-none text-body-medium">
<v-icon v-if="tab.icon" :icon="tab.icon" size="small" class="mr-2"></v-icon>
{{ tab.label }}
</v-tab>
</template>
</v-tabs>
<v-divider></v-divider>
<v-window :model-value="modelValue">
<v-window-item
v-for="tab in visibleTabs"
:key="tab.value"
:value="tab.value"
Comment on lines +22 to +26
>
<div class="pa-6">
<slot :name="tab.value"></slot>
</div>
</v-window-item>
</v-window>
</v-card>
</template>

<script>
/**
* @desc Reusable page-level tab strip. Wraps PageHeader-bounded
* pages (Account, Organization, Admin) so the tab styling +
* inset padding stay aligned with the sidenav across the app.
*
* Usage:
* <PageTabs v-model="tab" :tabs="tabs">
* <template #profile> ... </template>
* <template #organizations> ... </template>
* </PageTabs>
*
* Each `tab` is { value, label, icon?, visible? }. When `visible`
* is false, the entry is omitted from the strip (used for
* permission-gated tabs like Subscriptions).
*
* `config` is injected via Vue globalProperties (same pattern as
* CoreDatatable) — provides `config.vuetify.theme.flat` and
* `config.vuetify.theme.rounded` for surface-consistent card styling.
*/
export default {
name: 'CorePageTabs',
props: {
modelValue: { type: String, required: true },
tabs: {
type: Array,
required: true,
validator: (arr) => arr.every((t) => t && typeof t.value === 'string' && typeof t.label === 'string'),
},
grow: { type: Boolean, default: false },
},
emits: ['update:modelValue'],
computed: {
/**
* Returns only tabs where `visible` is not explicitly false.
* Tabs without a `visible` key are shown by default.
* @returns {Array<{value: string, label: string, icon?: string, visible?: boolean}>}
*/
visibleTabs() {
return this.tabs.filter((t) => t.visible !== false);
},
},
};
</script>
59 changes: 59 additions & 0 deletions src/modules/core/tests/core.pageTabs.component.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import { describe, test, expect } from 'vitest';
import PageTabs from '../components/core.pageTabs.component.vue';

// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Qwik rule does not apply in a Vue/Vitest context
const vuetify = createVuetify({ components, directives });

const mockConfig = { vuetify: { theme: { flat: false, rounded: '' } } };

// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Qwik rule does not apply in a Vue/Vitest context
const globalOpts = {
plugins: [vuetify],
config: {
globalProperties: { config: mockConfig },
},
};

describe('core.pageTabs.component', () => {
const tabs = [
{ value: 'one', label: 'One', icon: 'fa-solid fa-cube' },
{ value: 'two', label: 'Two', icon: 'fa-solid fa-cube' },
];

test('renders tab strip + window with one slot per tab', () => {
const wrapper = mount(PageTabs, {
props: { modelValue: 'one', tabs },
slots: {
one: '<div data-test="slot-one">Content One</div>',
two: '<div data-test="slot-two">Content Two</div>',
},
global: globalOpts,
});
expect(wrapper.find('[data-test="page-tabs"]').exists()).toBe(true);
expect(wrapper.findAll('.v-tab').length).toBe(2);
expect(wrapper.find('[data-test="slot-one"]').exists()).toBe(true);
});

test('emits update:modelValue when a tab is clicked', async () => {
const wrapper = mount(PageTabs, {
props: { modelValue: 'one', tabs },
slots: { one: '<div></div>', two: '<div></div>' },
global: globalOpts,
});
await wrapper.findAll('.v-tab')[1].trigger('click');
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['two']);
});

test('hides a tab when its `visible` prop is false', () => {
const wrapper = mount(PageTabs, {
props: { modelValue: 'one', tabs: [...tabs, { value: 'three', label: 'Three', visible: false }] },
slots: { one: '<div></div>', two: '<div></div>', three: '<div></div>' },
global: globalOpts,
});
expect(wrapper.findAll('.v-tab').length).toBe(2);
});
});
76 changes: 62 additions & 14 deletions src/modules/users/tests/user.view.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,25 +196,20 @@ describe('UserView – C4 decoupling: billing tab removed', () => {
});

it('only exposes profile and organizations tabs', () => {
const capturedValues = [];
const vTabStub = {
template: '<div><slot /></div>',
inheritAttrs: false,
created() {
if (this.$attrs.value) capturedValues.push(this.$attrs.value);
},
};

shallowMount(UserView, {
// After C1.2 refactor, tabs are declared via tabsConfig (consumed by PageTabs).
// v-tab elements no longer appear directly in user.view.vue, so we inspect
// tabsConfig to verify the exposed set of tabs.
const wrapper = shallowMount(UserView, {
global: {
mocks: sharedMocks(),
stubs: { ...sharedStubs, 'v-tab': vTabStub },
stubs: sharedStubs,
},
});

expect(capturedValues).toContain('profile');
expect(capturedValues).toContain('organizations');
expect(capturedValues).not.toContain('subscriptions');
const tabValues = wrapper.vm.tabsConfig.map((t) => t.value);
expect(tabValues).toContain('profile');
expect(tabValues).toContain('organizations');
expect(tabValues).not.toContain('subscriptions');
});
});

Expand Down Expand Up @@ -332,6 +327,59 @@ describe('UserView – organizations refetch on auth state change', () => {
});
});

// ── UserView – C1.2: PageTabs integration ────────────────────────────────────

describe('UserView – renders PageTabs with profile + organizations entries', () => {
beforeEach(() => {
setActivePinia(createPinia());
const organizationsStore = useOrganizationsStore();
organizationsStore.fetchOrganizations = vi.fn().mockResolvedValue([]);
});

it('renders a [data-test="page-tabs"] element via PageTabs', async () => {
const PageTabsStub = {
template: '<div data-test="page-tabs"><slot /></div>',
inheritAttrs: false,
};
Comment on lines +339 to +343

const wrapper = shallowMount(UserView, {
global: {
mocks: sharedMocks(),
stubs: { ...sharedStubs, PageTabs: PageTabsStub },
},
});
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-test="page-tabs"]').exists()).toBe(true);
});

it('tabsConfig includes profile and organizations entries', () => {
const wrapper = shallowMount(UserView, {
global: { mocks: sharedMocks(), stubs: sharedStubs },
});
const config = wrapper.vm.tabsConfig;
expect(Array.isArray(config)).toBe(true);
const values = config.map((t) => t.value);
expect(values).toContain('profile');
expect(values).toContain('organizations');
});

it('tabsConfig profile entry has correct label', () => {
const wrapper = shallowMount(UserView, {
global: { mocks: sharedMocks(), stubs: sharedStubs },
});
const profile = wrapper.vm.tabsConfig.find((t) => t.value === 'profile');
expect(profile?.label).toBe('Profile');
});

it('tabsConfig organizations entry has correct label', () => {
const wrapper = shallowMount(UserView, {
global: { mocks: sharedMocks(), stubs: sharedStubs },
});
const orgs = wrapper.vm.tabsConfig.find((t) => t.value === 'organizations');
expect(orgs?.label).toBe('Organizations');
});
});

// ── UserView – delete account danger zone in Profile tab ─────────────────────

describe('UserView – delete account danger zone', () => {
Expand Down
Loading
Loading