Skip to content

Commit a00caa5

Browse files
feat(core+home+tasks): promote 4 generic improvements from trawl downstream (#4239)
T3: Add config.modules[name].display override in core.store.js refreshNav() so downstream projects can hide nav entries via config without touching meta.display. T4: Skip homeStore.getStatistics() in home.view.vue created() when config.home.statistics.dynamic === false — avoids unnecessary API calls. T5: Add haloCount prop (1–5, default 4) to home.statistics.component.vue and home.blur.background.component.vue — lets downstreams trade visual richness for perf. T6: Remove deep watch:{task} from task.view.vue; set this.save=true in title/description computed setters so dirty-detection fires only on user-triggered edits. Tests: core.store (4 T3), home.view (6 T4, new file), home.statistics (3 T5), task.view (4 T6). Lint: clean. Test delta: +38 passing, 0 new failures vs baseline. Closes #4238. Partially closes pierreb-projects/infra#38.
1 parent 8e0be0f commit a00caa5

9 files changed

Lines changed: 278 additions & 14 deletions

File tree

src/modules/core/stores/core.store.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ export const useCoreStore = defineStore('core', {
5050
*/
5151
refreshNav(isLoggedIn) {
5252
const visible = pickBy(this.routes, (i) => {
53-
if (i.meta.display !== false && i.meta.icon) {
53+
const moduleDisplay = config.modules?.[i.name]?.display;
54+
if (moduleDisplay !== false && i.meta.display !== false && i.meta.icon) {
5455
if (!('action' in i.meta)) return i; // no guard, always displayed
5556
if (isLoggedIn && ability.can(i.meta.action, i.meta.subject)) return i;
5657
}

src/modules/core/tests/core.store.unit.tests.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ vi.mock('../../../lib/helpers/ability', () => ({
77
ability: mockAbility,
88
}));
99

10+
// Mutable config mock — mutate mockConfig.modules between tests to exercise T3 config-driven nav filter
11+
const mockConfig = vi.hoisted(() => ({
12+
vuetify: { theme: { dark: false } },
13+
modules: {},
14+
}));
15+
vi.mock('../../../lib/services/config', () => ({ default: mockConfig }));
16+
1017
import { useCoreStore } from '../stores/core.store';
1118

1219
describe('Core Store', () => {
@@ -457,4 +464,65 @@ describe('Core Store', () => {
457464
expect(coreStore.nav.map((r) => r.name)).toEqual(['zero', 'ten']);
458465
});
459466
});
467+
468+
describe('refreshNav — config.modules display override (T3)', () => {
469+
beforeEach(() => {
470+
// Reset modules config between tests
471+
mockConfig.modules = {};
472+
});
473+
474+
it('filters out a route when config.modules[name].display is false even if meta.display is undefined', () => {
475+
const coreStore = useCoreStore();
476+
mockConfig.modules = { tasks: { display: false } };
477+
const mockRoutes = [
478+
{ path: '/', name: 'home', meta: { display: true, icon: 'fa-solid fa-house' } },
479+
{ path: '/tasks', name: 'tasks', meta: { icon: 'fa-solid fa-check' } }, // meta.display undefined
480+
];
481+
482+
coreStore.init(mockRoutes);
483+
coreStore.refreshNav(false);
484+
485+
expect(coreStore.nav.find((r) => r.name === 'tasks')).toBeUndefined();
486+
expect(coreStore.nav.find((r) => r.name === 'home')).toBeDefined();
487+
});
488+
489+
it('shows a route when config.modules[name] is absent (no override)', () => {
490+
const coreStore = useCoreStore();
491+
mockConfig.modules = {};
492+
const mockRoutes = [
493+
{ path: '/tasks', name: 'tasks', meta: { display: true, icon: 'fa-solid fa-check' } },
494+
];
495+
496+
coreStore.init(mockRoutes);
497+
coreStore.refreshNav(false);
498+
499+
expect(coreStore.nav.find((r) => r.name === 'tasks')).toBeDefined();
500+
});
501+
502+
it('shows a route when config.modules[name].display is true', () => {
503+
const coreStore = useCoreStore();
504+
mockConfig.modules = { tasks: { display: true } };
505+
const mockRoutes = [
506+
{ path: '/tasks', name: 'tasks', meta: { display: true, icon: 'fa-solid fa-check' } },
507+
];
508+
509+
coreStore.init(mockRoutes);
510+
coreStore.refreshNav(false);
511+
512+
expect(coreStore.nav.find((r) => r.name === 'tasks')).toBeDefined();
513+
});
514+
515+
it('config.modules display:false takes priority over meta.display:true', () => {
516+
const coreStore = useCoreStore();
517+
mockConfig.modules = { tasks: { display: false } };
518+
const mockRoutes = [
519+
{ path: '/tasks', name: 'tasks', meta: { display: true, icon: 'fa-solid fa-check' } },
520+
];
521+
522+
coreStore.init(mockRoutes);
523+
coreStore.refreshNav(false);
524+
525+
expect(coreStore.nav.find((r) => r.name === 'tasks')).toBeUndefined();
526+
});
527+
});
460528
});

src/modules/home/components/home.statistics.component.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
:animation-speed="animationSpeed"
5151
:background-colors="backgroundColors"
5252
:halo-colors="haloColors"
53+
:halo-count="haloCount"
5354
no-margin
5455
>
5556
<v-container class="fill-height" :style="`max-width: ${config.vuetify.theme.maxWidth}`">
@@ -144,6 +145,12 @@ export default {
144145
type: Array,
145146
default: null,
146147
},
148+
// For blur variant - number of animated halos (1..5). Lower = cheaper.
149+
haloCount: {
150+
type: Number,
151+
default: 4,
152+
validator: (v) => v >= 1 && v <= 5,
153+
},
147154
// Overlap: slides section up into previous section
148155
overlap: {
149156
type: [Boolean, String, Object],

src/modules/home/components/utils/home.blur.background.component.vue

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@
4848
<!-- Halo/Blob layers -->
4949
<div class="halo-container">
5050
<div class="halo halo-1" :style="haloStyle(0)"></div>
51-
<div class="halo halo-2" :style="haloStyle(1)"></div>
52-
<div class="halo halo-3" :style="haloStyle(2)"></div>
53-
<div class="halo halo-4" :style="haloStyle(3)"></div>
54-
<div class="halo halo-5" :style="haloStyle(4)"></div>
51+
<div v-if="haloCount >= 2" class="halo halo-2" :style="haloStyle(1)"></div>
52+
<div v-if="haloCount >= 3" class="halo halo-3" :style="haloStyle(2)"></div>
53+
<div v-if="haloCount >= 4" class="halo halo-4" :style="haloStyle(3)"></div>
54+
<div v-if="haloCount >= 5" class="halo halo-5" :style="haloStyle(4)"></div>
5555
</div>
5656

5757
<!-- Glass overlay -->
@@ -91,6 +91,12 @@ export default {
9191
type: Array,
9292
default: null,
9393
},
94+
// Number of animated halos to render (1..5). Lower = cheaper.
95+
haloCount: {
96+
type: Number,
97+
default: 5,
98+
validator: (v) => v >= 1 && v <= 5,
99+
},
94100
// Remove top margin (for non-banner usage like stats)
95101
noMargin: {
96102
type: Boolean,

src/modules/home/tests/home.statistics.component.unit.tests.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ vi.mock('../components/utils/home.blur.background.component.vue', () => ({
1010
default: {
1111
name: 'HomeBlurBackgroundComponent',
1212
template: '<div><slot /></div>',
13-
props: ['ratio', 'animationSpeed', 'backgroundColors', 'haloColors', 'noMargin'],
13+
props: ['ratio', 'animationSpeed', 'backgroundColors', 'haloColors', 'haloCount', 'noMargin'],
1414
},
1515
}));
1616

@@ -208,4 +208,33 @@ describe('HomeStatisticsComponent', () => {
208208
expect(wrapper.vm.isPendingStatValue('1000+')).toBe(false);
209209
expect(wrapper.vm.isPendingStatValue(0)).toBe(false);
210210
});
211+
212+
describe('haloCount prop (T5)', () => {
213+
it('defaults haloCount to 4', () => {
214+
const wrapper = mount(HomeStatisticsComponent, {
215+
props: { setup: [{ value: '100', title: 'Test' }] },
216+
global: globalOpts(vuetify),
217+
});
218+
expect(wrapper.vm.haloCount).toBe(4);
219+
});
220+
221+
it('passes haloCount to homeBlurBackgroundComponent', () => {
222+
const wrapper = mount(HomeStatisticsComponent, {
223+
props: { setup: [{ value: '100', title: 'Test' }], haloCount: 2 },
224+
global: globalOpts(vuetify),
225+
});
226+
const blur = wrapper.findComponent({ name: 'HomeBlurBackgroundComponent' });
227+
expect(blur.props('haloCount')).toBe(2);
228+
});
229+
230+
it('accepts valid haloCount values 1 through 5', () => {
231+
[1, 2, 3, 4, 5].forEach((count) => {
232+
const wrapper = mount(HomeStatisticsComponent, {
233+
props: { setup: [{ value: '10', title: 'T' }], haloCount: count },
234+
global: globalOpts(vuetify),
235+
});
236+
expect(wrapper.vm.haloCount).toBe(count);
237+
});
238+
});
239+
});
211240
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { mount, flushPromises } from '@vue/test-utils';
3+
import { createPinia, setActivePinia } from 'pinia';
4+
import { createVuetify } from 'vuetify';
5+
6+
/**
7+
* Hoisted store action mocks — must be defined before any imports that use them.
8+
*/
9+
const initStatisticsMock = vi.hoisted(() => vi.fn());
10+
const getStatisticsMock = vi.hoisted(() => vi.fn());
11+
const getNewsMock = vi.hoisted(() => vi.fn());
12+
13+
vi.mock('../stores/home.store', () => ({
14+
useHomeStore: () => ({
15+
news: [],
16+
statistics: null,
17+
initStatistics: initStatisticsMock,
18+
getStatistics: getStatisticsMock,
19+
getNews: getNewsMock,
20+
}),
21+
}));
22+
23+
// Stub all child components to avoid deep render
24+
vi.mock('../components/home.hero.component.vue', () => ({ default: { template: '<div />' } }));
25+
vi.mock('../components/home.presentation.component.vue', () => ({ default: { template: '<div />' } }));
26+
vi.mock('../components/home.about.component.vue', () => ({ default: { template: '<div />' } }));
27+
vi.mock('../components/home.capabilities.component.vue', () => ({ default: { template: '<div />' } }));
28+
vi.mock('../components/home.features.component.vue', () => ({ default: { template: '<div />' } }));
29+
vi.mock('../components/home.services.component.vue', () => ({ default: { template: '<div />' } }));
30+
vi.mock('../components/home.steps.component.vue', () => ({ default: { template: '<div />' } }));
31+
vi.mock('../components/home.gallery.component.vue', () => ({ default: { template: '<div />' } }));
32+
vi.mock('../components/home.social.component.vue', () => ({ default: { template: '<div />' } }));
33+
vi.mock('../components/home.articles.component.vue', () => ({ default: { template: '<div />' } }));
34+
vi.mock('../components/home.statistics.component.vue', () => ({ default: { template: '<div />' } }));
35+
vi.mock('../components/home.faq.component.vue', () => ({ default: { template: '<div />' } }));
36+
vi.mock('../components/home.cta.component.vue', () => ({ default: { template: '<div />' } }));
37+
vi.mock('../components/home.contact.component.vue', () => ({ default: { template: '<div />' } }));
38+
39+
import HomeView from '../views/home.view.vue';
40+
41+
/**
42+
* Mount home.view with a given config.
43+
* @param {Object} homeConfig - Value of config.home
44+
* @returns {import('@vue/test-utils').VueWrapper}
45+
*/
46+
const mountView = (homeConfig = {}) =>
47+
mount(HomeView, {
48+
global: {
49+
plugins: [createVuetify(), createPinia()],
50+
config: {
51+
globalProperties: {
52+
config: { home: homeConfig },
53+
},
54+
},
55+
},
56+
});
57+
58+
describe('home.view — created() statistics.dynamic guard (T4)', () => {
59+
beforeEach(() => {
60+
setActivePinia(createPinia());
61+
vi.clearAllMocks();
62+
});
63+
64+
it('calls initStatistics and getStatistics when statistics is truthy and dynamic is not set', async () => {
65+
mountView({ statistics: { content: [] } });
66+
await flushPromises();
67+
68+
expect(initStatisticsMock).toHaveBeenCalledTimes(1);
69+
expect(getStatisticsMock).toHaveBeenCalledTimes(1);
70+
});
71+
72+
it('calls initStatistics but skips getStatistics when statistics.dynamic is false', async () => {
73+
mountView({ statistics: { content: [], dynamic: false } });
74+
await flushPromises();
75+
76+
expect(initStatisticsMock).toHaveBeenCalledTimes(1);
77+
expect(getStatisticsMock).not.toHaveBeenCalled();
78+
});
79+
80+
it('calls initStatistics and getStatistics when statistics.dynamic is true', async () => {
81+
mountView({ statistics: { content: [], dynamic: true } });
82+
await flushPromises();
83+
84+
expect(initStatisticsMock).toHaveBeenCalledTimes(1);
85+
expect(getStatisticsMock).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('skips initStatistics and getStatistics when config.home.statistics is falsy', async () => {
89+
mountView({});
90+
await flushPromises();
91+
92+
expect(initStatisticsMock).not.toHaveBeenCalled();
93+
expect(getStatisticsMock).not.toHaveBeenCalled();
94+
});
95+
96+
it('calls getNews when config.home.articles is set', async () => {
97+
mountView({ articles: { enabled: true } });
98+
await flushPromises();
99+
100+
expect(getNewsMock).toHaveBeenCalledTimes(1);
101+
});
102+
103+
it('does not call getNews when config.home.articles is falsy', async () => {
104+
mountView({});
105+
await flushPromises();
106+
107+
expect(getNewsMock).not.toHaveBeenCalled();
108+
});
109+
});

src/modules/home/views/home.view.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export default {
109109
const homeStore = useHomeStore();
110110
if (this.config.home.statistics) {
111111
homeStore.initStatistics();
112-
homeStore.getStatistics();
112+
if (this.config.home.statistics.dynamic !== false) homeStore.getStatistics();
113113
}
114114
if (this.config.home.articles) homeStore.getNews();
115115
},

src/modules/tasks/tests/task.view.unit.tests.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,53 @@ describe('task.view — remove()', () => {
195195
expect(mockPush).not.toHaveBeenCalled();
196196
});
197197
});
198+
199+
describe('task.view — save flag on user action (T6)', () => {
200+
beforeEach(() => {
201+
setActivePinia(createPinia());
202+
vi.clearAllMocks();
203+
mockTask.id = null;
204+
mockTask.title = 'My Task';
205+
mockTask.description = 'My Description';
206+
});
207+
208+
it('sets save=true when title computed setter is called', async () => {
209+
const wrapper = mountView();
210+
await flushPromises();
211+
212+
// Simulate user editing the title
213+
wrapper.vm.title = 'Updated Title';
214+
215+
expect(wrapper.vm.save).toBe(true);
216+
});
217+
218+
it('sets save=true when description computed setter is called', async () => {
219+
const wrapper = mountView();
220+
await flushPromises();
221+
222+
// Simulate user editing the description
223+
wrapper.vm.description = 'Updated Description';
224+
225+
expect(wrapper.vm.save).toBe(true);
226+
});
227+
228+
it('does not have a watch block on task (removed in T6)', () => {
229+
const wrapper = mountView();
230+
// The $options.watch should be absent or not contain a 'task' watcher
231+
const watch = wrapper.vm.$options.watch;
232+
if (watch) {
233+
expect(watch).not.toHaveProperty('task');
234+
} else {
235+
// No watch block at all — task watcher is gone (expected outcome)
236+
expect(watch).toBeUndefined();
237+
}
238+
});
239+
240+
it('save starts as null (not pre-triggered on mount for new task)', async () => {
241+
const wrapper = mountView();
242+
await flushPromises();
243+
244+
// For a new task (no id), save should remain null until user edits
245+
expect(wrapper.vm.save).toBeNull();
246+
});
247+
});

src/modules/tasks/views/task.view.vue

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export default {
8787
set(title) {
8888
const tasksStore = useTasksStore();
8989
tasksStore.task.title = title;
90+
this.save = true;
9091
},
9192
},
9293
description: {
@@ -96,15 +97,8 @@ export default {
9697
set(description) {
9798
const tasksStore = useTasksStore();
9899
tasksStore.task.description = description;
99-
},
100-
},
101-
},
102-
watch: {
103-
task: {
104-
handler() {
105100
this.save = true;
106101
},
107-
deep: true,
108102
},
109103
},
110104
async created() {

0 commit comments

Comments
 (0)