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
87 changes: 83 additions & 4 deletions src/frontend/web/von_interface/static/js/domUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
subscribeToWorkflowCapabilityIndexStatus,
} from './utils/workflowCapabilityStatusCoordinator.js';
import { createFooterOrganisationSwitcher } from './components/footerOrganisationSwitcher.js';
import { selectShortestNameForContext } from './utils/nameSelection.js';
import {
selectAbbreviationForContext,
selectShortestNameForContext,
} from './utils/nameSelection.js';

export const elements = {};

Expand Down Expand Up @@ -92,6 +95,58 @@ const VON_SYSTEM_CONCEPT_ID = '#V#von_system';
const VON_DOCUMENT_TITLE = 'Von';

let _documentTitleRequestGeneration = 0;
const footerCompactIdentityLabelRequests = new Map();

function normaliseFooterIdentityText(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

function getFooterCompactIdentityLabel(conceptId) {
const normalisedConceptId = normaliseFooterIdentityText(conceptId);
if (!normalisedConceptId) return Promise.resolve(null);

const existing = footerCompactIdentityLabelRequests.get(normalisedConceptId);
if (existing) return existing;

const request = fetch(`/api/concepts/${encodeURIComponent(normalisedConceptId)}`)
.then(async (response) => {
if (!response.ok) return null;
const concept = await response.json();
const names = Array.isArray(concept?.names) && concept.names.length
? concept.names
: (Array.isArray(concept?.raw_doc?.names) ? concept.raw_doc.names : []);
return normaliseFooterIdentityText(selectAbbreviationForContext(names));
})
.catch(() => null);

footerCompactIdentityLabelRequests.set(normalisedConceptId, request);
return request;
}

function queueFooterCompactIdentityLabel({
button,
identityKind,
conceptId,
fullName,
}) {
if (!button || !conceptId) return;
const semanticName = normaliseFooterIdentityText(fullName) || conceptId;
button.dataset.footerIdentityConceptId = conceptId;

void getFooterCompactIdentityLabel(conceptId).then((compactName) => {
if (!compactName || button.dataset.footerIdentityConceptId !== conceptId) return;

button.textContent = compactName;
button.setAttribute(
'aria-label',
`${identityKind}: ${semanticName}. Compact label: ${compactName}. Open in Vontology.`,
);
setKeptNativeTitle(
button,
`${identityKind}: ${semanticName}\nCompact label: ${compactName}\nID: ${conceptId}`,
);
});
}

function formatVonDocumentTitle(organisationName) {
const name = typeof organisationName === 'string' ? organisationName.trim() : '';
Expand Down Expand Up @@ -1494,6 +1549,7 @@ export async function setModelInfoFooterText() {

// Build dynamic segments (User / Org as concept buttons)
const segments = [];
const footerIdentityTargets = [];

function makeConceptButton(labelPrefix, displayName, conceptId, conceptName) {
const span = document.createElement('span');
Expand Down Expand Up @@ -1728,13 +1784,32 @@ export async function setModelInfoFooterText() {
// Always render a user segment with a button so downstream highlight logic and tests have a consistent target.
const dName = (userInfo && (userInfo.name || userInfo.conceptId)) ? (userInfo.name || userInfo.conceptId) : 'User';
// We do not pass concept id unless actually known to avoid misleading navigation.
segments.push(makeConceptButton('User', dName, userInfo?.conceptId || null, userInfo?.name || null));
const userSegment = makeConceptButton(
'User',
dName,
userInfo?.conceptId || null,
userInfo?.name || null,
);
footerIdentityTargets.push({
button: userSegment.querySelector('.concept-footer-button'),
identityKind: 'User',
conceptId: userInfo?.conceptId || null,
fullName: userInfo?.name || dName,
});
segments.push(userSegment);
}
// Organisation segment: retain concept navigation and add direct switching.
segments.push(createFooterOrganisationSwitcher({
const organisationSegment = createFooterOrganisationSwitcher({
organisationInfo: orgInfo,
onOpenConcept: openFooterConcept,
}));
});
footerIdentityTargets.push({
button: organisationSegment.querySelector('.footer-org-current-button'),
identityKind: 'Organisation',
conceptId: orgInfo?.conceptId || null,
fullName: orgInfo?.name || orgInfo?.conceptId || null,
});
segments.push(organisationSegment);

if (workflowCapabilityStatus && workflowCapabilityStatus.ready === false) {
const workflowIndexSegment = makeActionButton(
Expand Down Expand Up @@ -1857,6 +1932,10 @@ export async function setModelInfoFooterText() {
}
} catch (_) { /* non-fatal */ }

// Apply abbreviations only after the authentication status pass so it cannot
// replace the full identity tooltip with a generic login-only one.
footerIdentityTargets.forEach(queueFooterCompactIdentityLabel);

// Clicking empty space (not concept buttons) still opens settings
footer.style.cursor = 'pointer';
setKeptNativeTitle(footer, 'Click empty area to open settings');
Expand Down
22 changes: 21 additions & 1 deletion src/frontend/web/von_interface/static/js/utils/nameSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,5 +272,25 @@ export function selectShortestNameForContext(names, preferredLanguage = getPrefe
return filtered[0]?.text || null;
}

export { DEFAULT_LANGUAGE };
/**
* Choose a represented abbreviation for dense UI while retaining a separate
* full label for accessible naming and descriptions. An abbreviation is an
* explicit piece of concept data, not a guess made by shortening prose.
*/
export function selectAbbreviationForContext(names, preferredLanguage = getPreferredLanguage()) {
if (!Array.isArray(names) || names.length === 0) {
return null;
}

const abbreviations = names.filter((entry) => {
const type = (entry?.type ?? entry?.name_type ?? '')
.toString()
.trim()
.toUpperCase();
return type === 'ABBR';
});

return selectShortestNameForContext(abbreviations, preferredLanguage);
}

export { DEFAULT_LANGUAGE };
103 changes: 103 additions & 0 deletions tests/frontend/footerCompactIdentityLabels.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/** @jest-environment jsdom */

const domUtilsPath = '../../src/frontend/web/von_interface/static/js/domUtils.js';

function findFooterSegment(label) {
return [...document.querySelectorAll('.footer-segment')]
.find((segment) => segment.querySelector('.footer-label-inline')?.textContent?.trim() === `${label}:`);
}

async function flushUiTicks(ticks = 4) {
for (let index = 0; index < ticks; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}

describe('footer compact identity labels', () => {
beforeEach(() => {
jest.resetModules();
document.body.innerHTML = '<div class="footer-container"><p id="modelInfoFooter"></p></div>';
localStorage.clear();
sessionStorage.clear();
localStorage.setItem('von_current_user', JSON.stringify({
concept_id: '#V#michael_witbrock',
name: 'michael.witbrock@example.test',
}));
sessionStorage.setItem('von_current_user', localStorage.getItem('von_current_user'));
sessionStorage.setItem('von_current_org', JSON.stringify({
concept_id: '#V#university_of_auckland_strong_ai_lab',
name: 'University of Auckland Strong AI Lab',
}));

global.fetch = jest.fn(async (url) => {
const parsed = new URL(String(url), 'http://localhost');
if (parsed.pathname === '/api/settings/' || parsed.pathname === '/api/settings') {
return { ok: true, json: async () => ({ resolved_llm: null }) };
}
if (parsed.pathname === '/api/settings/llm/info' || parsed.pathname === '/api/settings/db/info') {
return { ok: true, json: async () => ({}) };
}
if (parsed.pathname === '/api/workflows/capability-index/status') {
return { ok: true, json: async () => ({ ready: true, status: 'ready' }) };
}
if (parsed.pathname === '/von/api/auth/status' || parsed.pathname === '/api/auth/status') {
return {
ok: true,
json: async () => ({ authenticated: true, email: 'michael.witbrock@example.test' }),
};
}
if (parsed.pathname === '/von/api/organisations/my_organisations') {
return {
ok: true,
json: async () => ({ organisations: [{
concept_id: '#V#university_of_auckland_strong_ai_lab',
name: 'University of Auckland Strong AI Lab',
role: 'owner',
}] }),
};
}
if (parsed.pathname === '/api/concepts/%23V%23michael_witbrock') {
return {
ok: true,
json: async () => ({ names: [
{ name: 'Michael Witbrock', language: 'en-NZ', type: 'NL' },
{ name: 'MJW', language: 'en-NZ', type: 'ABBR' },
] }),
};
}
if (parsed.pathname === '/api/concepts/%23V%23university_of_auckland_strong_ai_lab') {
return {
ok: true,
json: async () => ({ names: [
{ name: 'University of Auckland Strong AI Lab', language: 'en-NZ', type: 'NL' },
{ name: 'SAIL', language: 'en-NZ', type: 'ABBR' },
] }),
};
}
return { ok: true, json: async () => ({}) };
});
});

afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
});

test('uses canonical abbreviations for the dense footer without losing full identity semantics', async () => {
const { setModelInfoFooterText } = require(domUtilsPath);

await setModelInfoFooterText();
await flushUiTicks();

const userButton = findFooterSegment('User')?.querySelector('.concept-footer-button');
expect(userButton?.textContent).toBe('MJW');
expect(userButton?.getAttribute('aria-label')).toContain('User: michael.witbrock@example.test');
expect(userButton?.getAttribute('aria-label')).toContain('Compact label: MJW');

const orgButton = findFooterSegment('Org')?.querySelector('.footer-org-current-button');
expect(orgButton?.textContent).toBe('SAIL');
expect(orgButton?.getAttribute('aria-label')).toContain('Organisation: University of Auckland Strong AI Lab');
expect(orgButton?.getAttribute('aria-label')).toContain('Compact label: SAIL');
});
});
21 changes: 20 additions & 1 deletion tests/frontend/nameSelection.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { selectBestNameForContext } from '../../src/frontend/web/von_interface/static/js/utils/nameSelection.js';
import {
selectAbbreviationForContext,
selectBestNameForContext,
} from '../../src/frontend/web/von_interface/static/js/utils/nameSelection.js';

describe('nameSelection', () => {
test('prefers exact language match over others', () => {
Expand Down Expand Up @@ -84,4 +87,20 @@ describe('nameSelection', () => {

expect(selectBestNameForContext(names, 'en-NZ')).toBe('#V#thing');
});

test('selects an explicitly represented abbreviation for compact identity UI', () => {
const names = [
{ name: 'Michael Witbrock', language: 'en-NZ', type: 'NL' },
{ name: 'MJW', language: 'en-NZ', type: 'ABBR' },
{ name: 'MW', language: 'en-US', type: 'ABBR' },
];

expect(selectAbbreviationForContext(names, 'en-NZ')).toBe('MJW');
});

test('does not invent a compact name when no abbreviation is represented', () => {
expect(selectAbbreviationForContext([
{ name: 'University of Auckland Strong AI Lab', language: 'en-NZ', type: 'NL' },
], 'en-NZ')).toBeNull();
});
});
Loading