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:
@@ -8,6 +8,7 @@ node_modules
|
||||
.env.*
|
||||
!.env.example
|
||||
coverage
|
||||
exported
|
||||
.idea
|
||||
.vscode
|
||||
*.log
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user