Skip to content
Open
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
87 changes: 87 additions & 0 deletions ui/src/__tests__/system-configuration-source.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount, flushPromises } from '@vue/test-utils'
import SystemConfigurationView from '../views/SystemConfigurationView.vue'

// The Source column renders icon-only; the readable name + business
// explanation live in a v-tooltip (project convention: tooltips are
// v-tooltip, never native `title`). We drive rows through the mocked
// fetch and stub the table so it renders the `cell.source` slot per row,
// plus the tooltip default slot, so we can inspect what's rendered.
const tableStub = {
name: 'VibrantDataTable',
props: ['items'],
template: '<div class="tbl"><div v-for="(it, i) in items" :key="i" class="srccell"><slot name="cell.source" :item="it" /></div></div>',
}
const vtooltipStub = { template: '<span class="vtt"><slot /></span>' }
const viconStub = { template: '<i class="vi"><slot /></i>' }

// One row per known source type + a classpath-style path + an unknown type.
const ROWS = [
{ name: 'a', source: 'systemEnvironment', overridden: false },
{ name: 'b', source: 'systemProperties:foo', overridden: false },
{ name: 'c', source: 'database', overridden: true },
{ name: 'd', source: 'applicationConfig', overridden: false },
{ name: 'e', source: 'jar:file:/x/classpath/app.cfg', overridden: false },
{ name: 'f', source: 'totally-unknown-xyz', overridden: false },
]

function mountView() {
return mount(SystemConfigurationView, {
global: {
stubs: {
LjPageHeader: true, LjSearch: true, LjButton: true, LjDialog: true,
LjAvailabilityField: true, LigojConfirmDialog: true, RowActionsCog: true,
VibrantDataTable: tableStub,
'v-tooltip': vtooltipStub, 'v-icon': viconStub, 'v-form': true, 'v-textarea': true,
},
},
})
}

describe('SystemConfigurationView — source column', () => {
beforeEach(() => {
setActivePinia(createPinia())
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ROWS,
text: async () => '',
clone() { return this },
})
})

it('renders the source as an icon only (no text label)', async () => {
const w = mountView()
await flushPromises()
expect(w.findAll('.srccell').length).toBe(ROWS.length)
// The legacy text label is gone everywhere.
expect(w.find('.src-txt').exists()).toBe(false)
// Every source pill carries exactly its icon.
expect(w.findAll('.srcpill .vi').length).toBe(ROWS.length)
})

it('exposes a tooltip with the bold name and a business explanation', async () => {
const w = mountView()
await flushPromises()
const first = w.findAll('.srccell')[0]
expect(first.find('.srcpill .vtt').exists()).toBe(true)
expect(first.find('.src-tip-name').text()).toBe('System Environment')
expect(first.find('.src-tip-exp').exists()).toBe(true)
})

it('falls back to the raw source string for unknown types', async () => {
const w = mountView()
await flushPromises()
const unknown = w.findAll('.srccell').at(-1)
expect(unknown.find('.src-tip-exp').text()).toBe('totally-unknown-xyz')
})

it('converts the overridden indicator title into a v-tooltip', async () => {
const w = mountView()
await flushPromises()
const overriddenCell = w.findAll('.srccell')[2] // row 'c' (overridden)
expect(overriddenCell.find('.ovr .vtt').exists()).toBe(true)
expect(w.find('.ovr[title]').exists()).toBe(false)
})
})
5 changes: 5 additions & 0 deletions ui/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ export default {
'system.config.tipOverridden': 'Overridden',
'system.config.sourcePrefix': 'Source: {source}',
'system.config.sourceOverridden': '{base} — overridden',
'system.config.source.systemEnvironment': 'Operating-system environment variable.',
'system.config.source.systemProperties': 'Java system property (-D…).',
'system.config.source.applicationConfig': 'Application configuration file.',
'system.config.source.database': 'Stored in the Ligoj database (editable here).',
'system.config.source.classpath': 'Bundled in the application classpath.',

// System → Cache
'system.cache.title': 'Caches',
Expand Down
5 changes: 5 additions & 0 deletions ui/src/i18n/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export default {
'system.config.tipOverridden': 'Surchargée',
'system.config.sourcePrefix': 'Source : {source}',
'system.config.sourceOverridden': '{base} — surchargée',
'system.config.source.systemEnvironment': 'Variable d\'environnement du système d\'exploitation.',
'system.config.source.systemProperties': 'Propriété système Java (-D…).',
'system.config.source.applicationConfig': 'Fichier de configuration de l\'application.',
'system.config.source.database': 'Stockée en base Ligoj (modifiable ici).',
'system.config.source.classpath': 'Embarquée dans le classpath de l\'application.',

// Système → Cache
'system.cache.title': 'Caches',
Expand Down
46 changes: 38 additions & 8 deletions ui/src/views/SystemConfigurationView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,19 @@
<code v-else class="cval" :title="item.value">{{ item.value }}</code>
</template>
<template #cell.source="{ item }">
<span class="srcpill" :title="item.source || ''">
<v-icon size="14">{{ sourceIcon(item.source) }}</v-icon><span class="src-txt">{{ sourceLabel(item.source) }}</span>
<span class="srcpill">
<v-icon size="15">{{ sourceIcon(item.source) }}</v-icon>
<v-tooltip activator="parent" location="top" max-width="320">
<div class="src-tip">
<b class="src-tip-name">{{ sourceLabel(item.source) }}</b>
<span class="src-tip-exp">{{ sourceExplanation(item.source) }}</span>
</div>
</v-tooltip>
</span>
<span v-if="item.overridden" class="ovr">
<v-icon size="13">mdi-alert</v-icon>
<v-tooltip activator="parent" location="top" :text="t('system.config.tipOverridden')" />
</span>
<span v-if="item.overridden" class="ovr" :title="t('system.config.tipOverridden')"><v-icon size="13">mdi-alert</v-icon></span>
</template>
<template #actions="{ item }">
<RowActionsCog>
Expand Down Expand Up @@ -194,15 +203,32 @@ const SOURCE_ICONS = {
database: 'mdi-database',
classpath: 'mdi-file-code-outline',
}
const SOURCE_EXPLANATIONS = {
systemEnvironment: 'system.config.source.systemEnvironment',
systemProperties: 'system.config.source.systemProperties',
applicationConfig: 'system.config.source.applicationConfig',
database: 'system.config.source.database',
classpath: 'system.config.source.classpath',
}
// Map a raw source string to its known type key (shared by icon + tooltip):
// classpath wins if mentioned anywhere, otherwise the prefix before ':'.
function sourceKey(source) {
if (!source) return null
return source.includes('classpath') ? 'classpath' : source.split(':')[0]
}
function sourceIcon(source) {
if (!source) return 'mdi-help-circle-outline'
const key = source.split(':')[0]
return SOURCE_ICONS[source.includes('classpath') ? 'classpath' : key] || 'mdi-help-circle-outline'
return SOURCE_ICONS[sourceKey(source)] || 'mdi-help-circle-outline'
}
function sourceLabel(source) {
if (!source) return '—'
return source.split(':')[0].replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()).trim()
}
// Business explanation shown in the tooltip; unknown types fall back to the
// raw source string so nothing is hidden from the operator.
function sourceExplanation(source) {
const key = sourceKey(source)
return SOURCE_EXPLANATIONS[key] ? t(SOURCE_EXPLANATIONS[key]) : (source || '')
}

/* --- crypto helper --- */
const cryptoOpen = ref(false)
Expand Down Expand Up @@ -336,9 +362,13 @@ onMounted(() => {
.kname { font-family: var(--mono); font-size: 12.5px; font-weight: 600; color: var(--ink); word-break: break-all; }
.masked { font-family: var(--mono); color: var(--ink-3); letter-spacing: .1em; }
.cval { font-family: var(--mono); font-size: 12.5px; color: var(--ink-2); background: var(--pill); padding: 2px 8px; border-radius: var(--radius-sm); display: inline-block; max-width: 460px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
.srcpill { display: inline-flex; align-items: center; gap: 5px; max-width: 240px; font-family: var(--font); font-weight: 700; font-size: 11px; padding: 3px 10px; border-radius: 999px; color: var(--ink-2); background: var(--pill); }
/* Source column is icon-only (the name + business explanation live in the
hover tooltip): a compact circular badge sized to the glyph. */
.srcpill { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 999px; color: var(--ink-2); background: var(--pill); cursor: default; }
.srcpill :deep(.v-icon) { flex: none; }
.src-txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.src-tip { display: flex; flex-direction: column; gap: 2px; }
.src-tip-name { font-weight: 700; }
.src-tip-exp { font-size: 12px; opacity: .85; }
.ovr { color: #d9701a; margin-left: 5px; vertical-align: middle; }

/* Custom checkboxes inside the edit dialog (slotted content keeps this
Expand Down