Merge branch 'feat/add-federation-graph-page' into 'main'

feat(graph): add Federation Graph page and enhance coverage

See merge request unboundsoftware/schemas-app!4
This commit was merged in pull request #5.
This commit is contained in:
2025-11-22 20:20:11 +01:00
10 changed files with 382 additions and 12 deletions
+1
View File
@@ -8,6 +8,7 @@ node_modules
.env.*
!.env.example
coverage
exported
.idea
.vscode
*.log
+8
View File
@@ -6,6 +6,10 @@
.cache
dist
# Test coverage
coverage
exported
# Node dependencies
node_modules
@@ -18,6 +22,10 @@ logs
.fleet
.idea
# Linter caches
.eslintcache
.stylelintcache
# Local env files
.env
.env.*
+3
View File
@@ -15,6 +15,9 @@ build:
stage: build
script:
- build
- curl -Os https://uploader.codecov.io/latest/linux/codecov
- chmod +x codecov
- ./codecov -t ${CODECOV_TOKEN} -R $CI_PROJECT_DIR -C $CI_COMMIT_SHA -r $CI_PROJECT_PATH
- push
artifacts:
paths:
+4 -1
View File
@@ -7,7 +7,10 @@ RUN npm install --global node-gyp && \
COPY ./package.json ./package-lock.json ./
RUN npm ci --no-progress
COPY . /build
RUN npm run generate
RUN npm run postinstall && npm run generate && npm run lint && npm run test:coverage
FROM scratch as export
COPY --from=builder /build/coverage /
FROM amd64/nginx:1.29.3@sha256:4fd8a65a560a906addb9930f2cd5a3d33ff5b8e8b50e983bce275c9c78151a96
MAINTAINER Joakim Olsson <joakim@unbound.se>
@@ -0,0 +1,46 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
const mockSelectOrganization = vi.fn()
const mockOrganizations = [
{ id: '1', name: 'Org 1', apiKeys: [{ name: 'key1' }, { name: 'key2' }] },
{ id: '2', name: 'Org 2', apiKeys: [{ name: 'key3' }] },
{ id: '3', name: 'Org 3', apiKeys: [] },
]
// TODO: Add more comprehensive component tests once vitest module mocking issues are resolved
// For now, the component is indirectly tested through useOrganizationSelector tests
// and manual testing shows it works correctly
describe('OrganizationSwitcher - Single Organization', () => {
beforeEach(() => {
vi.resetModules()
mockSelectOrganization.mockClear()
// Mock with single organization
vi.mock('~/composables/useOrganizationSelector', () => ({
useOrganizationSelector: () => ({
organizations: ref([mockOrganizations[0]]),
selectedOrganization: ref(mockOrganizations[0]),
selectedOrgId: ref('1'),
selectOrganization: mockSelectOrganization,
}),
}))
})
it('should show a chip for single organization', async () => {
const module = await import('../OrganizationSwitcher.vue')
const wrapper = mount(module.default)
const html = wrapper.html()
// Should show chip
expect(html).toContain('v-chip')
expect(html).toContain('Org 1')
// Menu should not exist
expect(html).not.toContain('v-menu')
})
})
@@ -0,0 +1,135 @@
import { afterEach,beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick,ref } from 'vue'
const mockOrganizations = [
{ id: '1', name: 'Org 1' },
{ id: '2', name: 'Org 2' },
{ id: '3', name: 'Org 3' },
]
const mockResult = ref({
organizations: mockOrganizations,
})
// Mock Auth0
vi.mock('@auth0/auth0-vue', () => ({
useAuth0: () => ({
isAuthenticated: ref(true),
}),
}))
// Mock the GraphQL query
vi.mock('~/graphql/generated', () => ({
useOrganizationsQuery: () => ({
result: mockResult,
loading: ref(false),
error: ref(null),
refetch: vi.fn(),
}),
}))
describe('useOrganizationSelector', () => {
let useOrganizationSelector: any
beforeEach(async () => {
// Clear localStorage before each test
localStorage.clear()
// Reset modules to clear the shared selectedOrgId ref
vi.resetModules()
// Re-import the composable to get fresh module state
const module = await import('../useOrganizationSelector')
useOrganizationSelector = module.useOrganizationSelector
})
afterEach(() => {
localStorage.clear()
})
it('should return organizations list', async () => {
const { organizations } = useOrganizationSelector()
await nextTick()
expect(organizations.value).toHaveLength(3)
expect(organizations.value[0]).toEqual({ id: '1', name: 'Org 1' })
})
it('should auto-select first organization if none selected and orgs available', async () => {
const { selectedOrganization, organizations } = useOrganizationSelector()
await nextTick()
// Should auto-select first org
expect(selectedOrganization.value).toEqual(organizations.value[0])
})
it('should select an organization by ID', async () => {
const { selectedOrganization, selectOrganization, organizations } = useOrganizationSelector()
await nextTick()
// Should start with first org auto-selected
expect(selectedOrganization.value).toEqual(organizations.value[0])
// Select a different organization by ID
selectOrganization('2')
await nextTick()
expect(selectedOrganization.value).toEqual({ id: '2', name: 'Org 2' })
})
it('should persist selected organization ID to localStorage', async () => {
const { selectOrganization } = useOrganizationSelector()
await nextTick()
// Auto-selection will have set it to '1', so select '2' to trigger the watch
selectOrganization('2')
await nextTick()
const stored = localStorage.getItem('selectedOrgId')
expect(stored).toBe('2')
})
it('should restore selected organization from localStorage', async () => {
// Set up localStorage with a saved organization ID
localStorage.setItem('selectedOrgId', '2')
const { selectedOrganization } = useOrganizationSelector()
await nextTick()
expect(selectedOrganization.value).toEqual({ id: '2', name: 'Org 2' })
})
it('should return selectedOrgId computed property', async () => {
const { selectedOrgId, selectOrganization } = useOrganizationSelector()
await nextTick()
// Auto-selected to first org
expect(selectedOrgId.value).toBe('1')
selectOrganization('3')
await nextTick()
expect(selectedOrgId.value).toBe('3')
})
it('should handle selecting an organization that does not exist', async () => {
const { selectedOrganization, selectOrganization } = useOrganizationSelector()
await nextTick()
selectOrganization('999')
await nextTick()
// selectedOrgId is set but selectedOrganization returns null
expect(selectedOrganization.value).toBeNull()
})
it('should prioritize localStorage over auto-selection', async () => {
localStorage.setItem('selectedOrgId', '3')
const { selectedOrganization } = useOrganizationSelector()
await nextTick()
// Should restore from localStorage, not auto-select first org
expect(selectedOrganization.value).toEqual({ id: '3', name: 'Org 3' })
})
})
+124
View File
@@ -0,0 +1,124 @@
<template>
<div>
<v-row>
<v-col cols="12">
<div class="d-flex align-center mb-4">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.back()"
/>
<h1 class="text-h4 ml-2">
Federation Graph: {{ route.params.ref }}
</h1>
</div>
</v-col>
</v-row>
<v-row v-if="loading">
<v-col cols="12" class="text-center py-12">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4">
Loading federation graph...
</p>
</v-col>
</v-row>
<v-row v-else-if="supergraphData">
<v-col cols="12">
<v-card>
<v-card-title>Subgraphs in Federation</v-card-title>
<v-card-text>
<v-list>
<v-list-item
v-for="subgraph in subgraphs"
:key="subgraph.service"
:title="subgraph.service"
:subtitle="`URL: ${subgraph.url}`"
>
<template #prepend>
<v-icon icon="mdi-cube-outline" />
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span>Federation Schema (SDL)</span>
<v-btn
icon="mdi-content-copy"
variant="text"
size="small"
@click="copySupergraph"
/>
</v-card-title>
<v-card-text>
<pre class="sdl-viewer"><code>{{ supergraphData.sdl }}</code></pre>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" class="text-center py-12">
<v-icon icon="mdi-alert-circle" size="64" class="mb-4 text-warning" />
<p class="text-h6">
No federation graph available
</p>
</v-col>
</v-row>
<v-snackbar v-model="snackbar" :timeout="3000">
{{ snackbarText }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { useSupergraphQuery } from '~/graphql/generated'
const route = useRoute()
const loading = ref(true)
const snackbar = ref(false)
const snackbarText = ref('')
const { result, loading: queryLoading } = useSupergraphQuery(() => ({
ref: route.params.ref as string,
isAfter: null,
}))
const supergraphData = computed(() => result.value?.supergraph)
const subgraphs = computed(() => supergraphData.value?.subgraphs || [])
watch(queryLoading, (newLoading) => {
loading.value = newLoading
})
const copySupergraph = async () => {
try {
await navigator.clipboard.writeText(supergraphData.value?.sdl || '')
snackbarText.value = 'Supergraph SDL copied to clipboard'
snackbar.value = true
} catch (_err) {
snackbarText.value = 'Failed to copy'
snackbar.value = true
}
}
</script>
<style scoped>
.sdl-viewer {
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
overflow: auto;
font-family: Monaco, Menlo, 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
max-height: 60vh;
}
</style>
+6
View File
@@ -82,6 +82,7 @@
prepend-icon="mdi-graph"
variant="outlined"
color="primary"
@click="viewFederationGraph"
>
View Federation Graph
</v-btn>
@@ -159,6 +160,11 @@ const copyToClipboard = async () => {
}
}
const viewFederationGraph = () => {
if (!schema.value?.ref) return
navigateTo(`/graph/${schema.value.ref}`)
}
// TODO: Fetch actual data from the GraphQL API
onMounted(() => {
// Mock data for now
+54 -10
View File
@@ -2,16 +2,36 @@ import { config } from '@vue/test-utils'
import { vi } from 'vitest'
import * as Vue from 'vue'
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(),
length: 0,
// Mock localStorage with actual implementation
class LocalStorageMock {
private store: Map<string, string> = new Map()
getItem(key: string): string | null {
return this.store.get(key) ?? null
}
setItem(key: string, value: string): void {
this.store.set(key, value)
}
removeItem(key: string): void {
this.store.delete(key)
}
clear(): void {
this.store.clear()
}
key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null
}
get length(): number {
return this.store.size
}
}
global.localStorage = localStorageMock as unknown as Storage
global.localStorage = new LocalStorageMock() as unknown as Storage
// Make Vue composables globally available
global.ref = Vue.ref
@@ -87,7 +107,31 @@ config.global.stubs = {
VIcon: {
name: 'v-icon',
template: '<i class="v-icon" :class="icon" @click="$emit(\'click\')"></i>',
props: ['icon', 'large'],
props: ['icon', 'large', 'start', 'end', 'size'],
emits: ['click'],
},
VMenu: {
name: 'v-menu',
template: '<div class="v-menu"><slot name="activator" :props="{}" /><slot /></div>',
props: ['modelValue', 'closeOnContentClick'],
},
VList: {
name: 'v-list',
template: '<div class="v-list"><slot /></div>',
},
VListItem: {
name: 'v-list-item',
template: '<div class="v-list-item" :data-active="active" @click="$emit(\'click\')"><slot /><slot name="append" /></div>',
props: ['active', 'value'],
emits: ['click'],
},
VListItemTitle: {
name: 'v-list-item-title',
template: '<div class="v-list-item-title"><slot /></div>',
},
VChip: {
name: 'v-chip',
template: '<div class="v-chip" :class="variant"><slot /></div>',
props: ['size', 'variant', 'prependIcon'],
},
}
+1 -1
View File
@@ -14,7 +14,7 @@ export default defineConfig({
setupFiles: ['./test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
reporter: ['text', 'json', 'html', 'lcov'],
include: [
'app/utils/**/*.{ts,js}',
'app/components/**/*.vue',