Skip to content

Commit 0ac4431

Browse files
committed
feat: enhance menu configuration and visibility options
- Added support for JSON files in the .gitignore to improve configuration management. - Updated the `useMenu` composable to include a new `useDefaultEntries` option for menu entries, allowing for better control over default menu visibility. - Introduced `hideInDashboard` property in `MenuItem` type to manage item visibility in the dashboard. - Enhanced the menu rendering logic to filter out items marked as hidden in the dashboard, improving user experience.
1 parent edfa5d2 commit 0ac4431

File tree

7 files changed

+391
-216
lines changed

7 files changed

+391
-216
lines changed

apps/web/config/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
*.yml
2+
*.json

apps/web/src/composables/useMenu.ts

Lines changed: 107 additions & 212 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import type { BadgeItem, MenuItem } from '~/constants/types'
2-
import { useIdentityStates } from './useIdentityStates'
3-
import { DefaultMenuParts, MaxMenuBadgeCount, MenuPart } from '~/constants/variables'
4-
import { IdentityState } from '~/constants/enums'
2+
import { DefaultMenuParts, MaxMenuBadgeCount } from '~/constants/variables'
53
import qs from 'qs'
64
import type { useIdentityStateStore } from '~/stores/identityState'
5+
import { getDefaultMenuEntries } from '~/constants/defaultMenuEntries'
76
import { sort } from 'radash'
87

9-
const { getStateBadge } = useIdentityStates()
10-
const config = useAppConfig()
8+
const config = useAppConfig() as any
119

1210
type useMenuReturnType = {
1311
getMenu: () => MenuItem[]
@@ -23,225 +21,122 @@ type MenuPartItem = {
2321

2422
function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>): useMenuReturnType {
2523
const menuParts = ref<MenuPartItem[]>(DefaultMenuParts)
24+
const useDefaultEntries = config?.menus?.useDefaultEntries !== false
2625
const menuEntries = (config?.menus?.entries as any[]) || []
2726
const { hasPermission, hasPermissionStartsWith } = useAccessControl()
2827

29-
if (config?.menus?.parts) {
30-
menuParts.value = sort([...menuParts.value, ...(config.menus?.parts as any) || []], (part) => {
31-
const pos = part.position || 9_999
32-
return pos
33-
})
28+
function normalizeNameFromLabel(label: string): string {
29+
// Auto-remplissage côté déploiement :
30+
// - minuscules
31+
// - espaces -> `_`
32+
// - le reste est conservé tel quel
33+
return label.toLowerCase().trim().replace(/\s+/g, '_')
3434
}
3535

36-
const menus = ref<MenuItem[]>([
37-
{
38-
icon: 'mdi-account',
39-
label: 'Liste des identités',
40-
path: '/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0',
41-
color: 'primary',
42-
badge: { color: 'primary' },
43-
part: MenuPart.DONNEES,
44-
hideInMenuBar: false,
45-
_acl: '/management/identities',
46-
},
47-
{
48-
icon: 'mdi-download-outline',
49-
label: 'Exporter',
50-
path: '/identities/export',
51-
color: 'accent',
52-
part: MenuPart.DONNEES,
53-
hideInMenuBar: true,
54-
_acl: '/management/identities',
55-
}, {
56-
icon: 'mdi-book-clock',
57-
label: 'Journal des jobs',
58-
path: '/jobs/table?filters[:state]=-1',
59-
color: 'info',
60-
part: MenuPart.DONNEES,
61-
hideInMenuBar: false,
62-
_acl: '/core/jobs',
63-
}, {
64-
icon: 'mdi-timeline-clock-outline',
65-
label: 'Cycle de vie des identités',
66-
path: '/lifecycles/table',
67-
color: 'info',
68-
part: MenuPart.DONNEES,
69-
hideInMenuBar: false,
70-
_acl: '/management/lifecycle',
71-
},
72-
{
73-
icon: 'mdi-clipboard-text-clock',
74-
label: 'Historique des changements',
75-
path: '/audits/table',
76-
color: 'lime-8',
77-
hideInMenuBar: false,
78-
part: MenuPart.DONNEES,
79-
_acl: '/core/audits',
80-
},
81-
{
82-
icon: 'mdi-set-merge',
83-
label: 'Detection des doublons',
84-
path: '/identities/fusion',
85-
color: 'positive',
86-
part: MenuPart.DONNEES,
87-
hideInMenuBar: true,
88-
_acl: '/management/identities',
89-
},
90-
{
91-
icon: 'mdi-trash-can',
92-
label: 'Corbeille',
93-
path: '/identities/trash',
94-
color: 'grey-10',
95-
hideInMenuBar: true,
96-
part: MenuPart.DONNEES,
97-
_acl: '/management/identities',
98-
},
99-
{
100-
icon: 'mdi-account-check',
101-
label: 'A valider',
102-
path: `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[#state]=${IdentityState.TO_VALIDATE}`,
103-
color: 'warning',
104-
textColor: 'black',
105-
badge: getStateBadge(IdentityState.TO_VALIDATE),
106-
part: MenuPart.ETATS,
107-
hideInMenuBar: false,
108-
_acl: '/management/identities',
109-
},
110-
{
111-
icon: 'mdi-account-alert',
112-
label: 'A compléter',
113-
path: `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${IdentityState.TO_COMPLETE}`,
114-
color: 'secondary',
115-
textColor: 'black',
116-
badge: getStateBadge(IdentityState.TO_COMPLETE),
117-
part: MenuPart.ETATS,
118-
hideInMenuBar: false,
119-
_acl: '/management/identities',
120-
},
121-
{
122-
icon: 'mdi-sync',
123-
label: 'A synchroniser',
124-
path: `/identities/table?readonly=1&sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${IdentityState.TO_SYNC}`,
125-
color: 'orange-8',
126-
badge: getStateBadge(IdentityState.TO_SYNC),
127-
part: MenuPart.ETATS,
128-
hideInMenuBar: false,
129-
_acl: '/management/identities',
130-
},
131-
{
132-
icon: 'mdi-loading',
133-
label: 'En cours de synchro.',
134-
path: `/identities/table?readonly=1&sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${IdentityState.PROCESSING}`,
135-
color: 'grey-8',
136-
badge: getStateBadge(IdentityState.PROCESSING),
137-
part: MenuPart.ETATS,
138-
hideInMenuBar: false,
139-
_acl: '/management/identities',
140-
},
141-
{
142-
icon: 'mdi-check',
143-
label: 'Synchronisées',
144-
path: `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${IdentityState.SYNCED}`,
145-
badge: getStateBadge(IdentityState.SYNCED),
146-
color: 'positive',
147-
part: MenuPart.ETATS,
148-
hideInMenuBar: false,
149-
_acl: '/management/identities',
150-
},
151-
{
152-
icon: 'mdi-account-switch-outline',
153-
label: 'Fusionnées',
154-
path: '/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[~primaryEmployeeNumber]=true',
155-
color: 'linear-gradient(135deg, #7C3AED 0%, #EC4899 50%, #F97316 100%)',
156-
textColor: 'white',
157-
badge: { color: 'linear-gradient(135deg, #7C3AED 0%, #EC4899 50%, #F97316 100%)', textColor: 'white' },
158-
part: MenuPart.ETATS,
159-
hideInMenuBar: false,
160-
_acl: '/management/identities',
161-
},
162-
...menuEntries
163-
.filter((entry: any) => {
164-
if (entry._acl) {
165-
return hasPermissionStartsWith([entry._acl])
36+
if (config?.menus?.parts) {
37+
const overrideParts = config.menus.parts as any[]
38+
const mergedParts: MenuPartItem[] = [...menuParts.value]
39+
40+
for (const overridePart of overrideParts) {
41+
const overrideLabel = typeof overridePart?.label === 'string' ? overridePart.label : ''
42+
if (!overrideLabel) continue
43+
44+
const matchIndex = mergedParts.findIndex((part) => part.label === overrideLabel)
45+
if (matchIndex >= 0) {
46+
mergedParts[matchIndex] = {
47+
...mergedParts[matchIndex],
48+
...(overridePart as any),
16649
}
50+
} else {
51+
mergedParts.push(overridePart as any)
52+
}
53+
}
54+
55+
menuParts.value = sort(mergedParts, (part) => part.position || 9_999)
56+
}
57+
58+
const defaultMenuEntries = getDefaultMenuEntries()
59+
60+
// Fusion des menus : les entrées YAML peuvent surcharger (override) les entrées par défaut.
61+
// Override :
62+
// - priorité au match par `path` (exact)
63+
// - sinon match par `name` (fourni ou dérivé du `label`)
64+
// - sinon match par `label` (normalisé, pour compat)
65+
// - si aucune entrée ne correspond : ajout (placé avant "En erreur" si possible)
66+
const baseMenuEntries = useDefaultEntries ? defaultMenuEntries : []
67+
const insertionIndex = baseMenuEntries.findIndex((m) => m.label === 'En erreur')
68+
69+
const mergedMenuEntries: MenuItem[] = [...baseMenuEntries]
70+
const unmatchedOverrides: MenuItem[] = []
71+
72+
for (const overrideEntry of menuEntries as any[]) {
73+
const overridePath = typeof overrideEntry?.path === 'string' ? overrideEntry.path.trim() : ''
74+
const overrideLabel = typeof overrideEntry?.label === 'string' ? overrideEntry.label : ''
75+
const overrideName =
76+
typeof overrideEntry?.name === 'string'
77+
? normalizeNameFromLabel(overrideEntry.name)
78+
: overrideLabel
79+
? normalizeNameFromLabel(overrideLabel)
80+
: ''
81+
82+
const matchIndexByPath = overridePath ? mergedMenuEntries.findIndex((m) => m.path === overridePath) : -1
83+
const matchIndexByName =
84+
matchIndexByPath === -1 && overrideName
85+
? mergedMenuEntries.findIndex((m) => typeof m.name === 'string' && normalizeNameFromLabel(m.name) === overrideName)
86+
: -1
87+
88+
const matchIndexByLabel =
89+
matchIndexByPath === -1 && matchIndexByName === -1 && !overrideName && overrideLabel
90+
? mergedMenuEntries.findIndex((m) => normalizeLabel(m.label) === normalizeLabel(overrideLabel))
91+
: -1
92+
93+
const matchIndex = matchIndexByPath >= 0 ? matchIndexByPath : matchIndexByName >= 0 ? matchIndexByName : matchIndexByLabel
94+
95+
if (matchIndex >= 0) {
96+
mergedMenuEntries[matchIndex] = {
97+
...mergedMenuEntries[matchIndex],
98+
...(overrideEntry as any),
99+
}
100+
} else {
101+
unmatchedOverrides.push(overrideEntry as any)
102+
}
103+
}
104+
105+
if (unmatchedOverrides.length > 0) {
106+
const indexToInsert = insertionIndex >= 0 ? insertionIndex : mergedMenuEntries.length
107+
mergedMenuEntries.splice(indexToInsert, 0, ...unmatchedOverrides)
108+
}
109+
110+
// `name` est optionnel dans `menus.yml`. On l'auto-remplit sinon.
111+
for (let i = 0; i < mergedMenuEntries.length; i++) {
112+
const entry = mergedMenuEntries[i]
113+
if (typeof (entry as any)?.name === 'string' && (entry as any).name.trim()) continue
114+
if (typeof entry.label !== 'string' || !entry.label.trim()) continue
115+
;(mergedMenuEntries[i] as any).name = normalizeNameFromLabel(entry.label)
116+
}
117+
118+
const menus = ref<MenuItem[]>(
119+
mergedMenuEntries.filter((entry) => {
120+
// Si `_acl` est fourni, on laisse `getMenu()` gérer l'autorisation exacte.
121+
if (entry._acl) return true
122+
if (!entry.path) return false
123+
124+
const basePath = entry.path.replace(/^\//, '')
125+
const pathWithoutQuery = basePath.split('?')[0] || ''
126+
const uri = pathWithoutQuery.split('/')[0] || ''
167127

168-
const basePath = entry.path.replace(/^\//, '')
169-
const path = basePath.split('?')[0] || ''
170-
const uri = path[0].split('/')[0] || ''
171-
172-
return hasPermissionStartsWith([uri])
173-
}) || [],
174-
{
175-
icon: 'mdi-account-remove',
176-
label: 'En erreur',
177-
path: `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${IdentityState.ON_ERROR}`,
178-
color: 'negative',
179-
badge: getStateBadge(IdentityState.ON_ERROR),
180-
part: MenuPart.ETATS,
181-
hideInMenuBar: false,
182-
_acl: '/management/identities',
183-
},
184-
{
185-
icon: 'mdi-publish-off',
186-
label: 'À ne pas synchroniser',
187-
path: `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${IdentityState.DONT_SYNC}`,
188-
color: 'black',
189-
badge: getStateBadge(IdentityState.DONT_SYNC),
190-
part: MenuPart.ETATS,
191-
hideInMenuBar: false,
192-
_acl: '/management/identities',
193-
},
194-
{
195-
icon: 'mdi-email-alert',
196-
label: 'Invitations non envoyées',
197-
path: '/identities/table?limit=10&skip=0&filters[%23initState]=0&sort[metadata.lastUpdatedAt]=desc',
198-
color: 'negative',
199-
part: MenuPart.ACTIVATION,
200-
badge: { color: 'negative' },
201-
hideInMenuBar: false,
202-
_acl: '/management/identities',
203-
},
204-
{
205-
icon: 'mdi-email-fast',
206-
label: 'Invitations envoyées',
207-
path: '/identities/table?limit=10&skip=0&filters[%23initState]=1&sort[metadata.lastUpdatedAt]=desc',
208-
color: 'warning',
209-
textColor: 'black',
210-
part: MenuPart.ACTIVATION,
211-
badge: { color: 'warning', textColor: 'black' },
212-
hideInMenuBar: false,
213-
_acl: '/management/identities',
214-
},
215-
{
216-
icon: 'mdi-email-open',
217-
label: 'Comptes activés',
218-
path: '/identities/table?limit=10&skip=0&filters[%23initState]=2&sort[metadata.lastUpdatedAt]=desc',
219-
color: 'positive',
220-
textColor: 'white',
221-
part: MenuPart.ACTIVATION,
222-
badge: { color: 'positive' },
223-
hideInMenuBar: false,
224-
_acl: '/management/identities',
225-
},
226-
{
227-
icon: 'mdi-email-remove',
228-
label: 'Invitations périmées',
229-
path: '/identities/outdated',
230-
color: 'accent',
231-
part: MenuPart.ACTIVATION,
232-
hideInMenuBar: false,
233-
_acl: '/management/identities',
234-
},
235-
])
128+
return uri ? hasPermissionStartsWith([uri]) : false
129+
}),
130+
)
236131

237132
function normalizeLabel(label: string): string {
238133
return label.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, '').replace(/ /g, '_').replace(/\./g, '')
239134
}
240135

241136
function getMenu(): MenuItem[] {
242137
const menuList: MenuItem[] = menus.value.reduce((acc: MenuItem[], menu) => {
243-
const label = normalizeLabel(menu.label)
244-
const stateValue = identityStateStore.getStateValue(label)
138+
const stateKey = typeof menu.name === 'string' && menu.name.trim() ? menu.name : normalizeLabel(menu.label)
139+
const stateValue = identityStateStore.getStateValue(stateKey)
245140
const value = stateValue > MaxMenuBadgeCount ? MaxMenuBadgeCount + '+' : stateValue?.toString() || '0'
246141

247142
if (menu._acl && !hasPermission(menu._acl, AccessControlAction.READ, AccessControlPossession.ANY)) {
@@ -272,7 +167,7 @@ function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>):
272167
const filters = {}
273168
for (const menu of menus.value) {
274169
if (menu.path && menu.badge) {
275-
const label = normalizeLabel(menu.label)
170+
const stateKey = typeof menu.name === 'string' && menu.name.trim() ? menu.name : normalizeLabel(menu.label)
276171
const params = new URL(window.location.origin + encodePath(menu.path)).searchParams
277172
const queryString = qs.parse(params.toString())
278173
const qsFilters = {}
@@ -281,7 +176,7 @@ function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>):
281176
qsFilters[decodeURIComponent(key)] = decodeURIComponent(value as string)
282177
}
283178

284-
filters[label] = qsFilters
179+
filters[stateKey] = qsFilters
285180
}
286181
}
287182

0 commit comments

Comments
 (0)