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
121 changes: 121 additions & 0 deletions ui/src/__tests__/bug-report-dialog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useAuthStore, useAppStore, useI18nStore } from '@ligoj/host'
import BugReportDialog from '../components/BugReportDialog.vue'
import enMessages from '../i18n/en.js'

// Moved from the host (#121): the dialog now self-binds to app.bugDialogOpen
// instead of a modelValue prop, and its bugReport.* labels live in plugin-ui's
// i18n bundle (merged into the shared store here so `t()` resolves to English).
// Vuetify is externalized in the plugin build, so we stub v-dialog (render its
// slot inline) and v-icon rather than installing the real plugin.
const stubs = {
'v-dialog': { template: '<div class="v-dialog"><slot /></div>' },
'v-icon': { template: '<i class="v-icon"><slot /></i>' },
}

let wrapper

async function openDialog() {
useAppStore().openBugDialog()
wrapper = mount(BugReportDialog, { global: { stubs } })
await nextTick()
await nextTick()
return wrapper
}

const template = () => wrapper.find('.bug-template').element.value

function seedSession(applicationSettings) {
const auth = useAuthStore()
auth.session = {
userName: 'tester',
roles: ['USER'],
uiAuthorizations: ['.*'],
apiAuthorizations: [],
applicationSettings: applicationSettings ?? {
buildVersion: '4.0.2-test',
plugins: ['service:id:ldap', 'service:prov:aws'],
},
}
}

describe('<BugReportDialog>', () => {
beforeEach(() => {
setActivePinia(createPinia())
useI18nStore().merge(enMessages, 'en')
window.location.hash = '#/about'
})

it('builds a template containing the version, the URL and the plugins', async () => {
seedSession()
await openDialog()

const text = template()
expect(text).toContain('4.0.2-test')
expect(text).toContain('#/about')
expect(text).toContain('service:id:ldap')
expect(text).toContain('service:prov:aws')
expect(text).toContain('## Description')
expect(text).toContain('## Context')
})

it('uses the path when there is no hash, and never leaks a domain', async () => {
seedSession()
window.location.hash = ''
await openDialog()

const text = template()
// jsdom default pathname is "/"; no protocol/host should appear.
expect(text).not.toContain('http')
expect(text).not.toContain('localhost')
})

it('copies the template to the clipboard via the Clipboard API', async () => {
seedSession()
const writeText = vi.fn().mockResolvedValue()
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
await openDialog()

await wrapper.find('.bug-btn.primary').trigger('click')

expect(writeText).toHaveBeenCalledTimes(1)
const copied = writeText.mock.calls[0][0]
expect(copied).toContain('4.0.2-test')
expect(copied).toContain('service:id:ldap')
delete navigator.clipboard
})

it('falls back gracefully when the Clipboard API is unavailable', async () => {
seedSession()
delete navigator.clipboard
document.execCommand = vi.fn().mockReturnValue(true)
await openDialog()

// Should not throw, and should fall back to execCommand('copy').
await wrapper.find('.bug-btn.primary').trigger('click')
expect(document.execCommand).toHaveBeenCalledWith('copy')
})

it('links to the Ligoj GitHub issue form in a new tab', async () => {
seedSession()
await openDialog()

const link = wrapper.find('.bug-foot a.bug-btn')
expect(link.attributes('href')).toContain('https://github.com/ligoj/ligoj/issues/new')
expect(link.attributes('target')).toBe('_blank')
expect(link.attributes('rel')).toContain('noopener')
})

it('shows a no-plugin placeholder when none are installed', async () => {
seedSession({ buildVersion: '1.0', plugins: [] })
await openDialog()

const text = template()
expect(text).toContain('1.0')
expect(text).toContain('Installed plugins')
expect(text).toContain('(none)')
})
})
Binary file added ui/src/assets/brand.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions ui/src/components/AgreementDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<v-dialog :model-value="modelValue" max-width="500" @update:model-value="$emit('update:modelValue', $event)">
<v-card>
<v-card-title>{{ t('agreement.title') }}</v-card-title>
<v-card-text>
<p class="mb-4">{{ t('agreement.text') }}</p>
<v-checkbox v-model="agreed" :label="t('agreement.checkbox')" hide-details />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">{{ t('common.cancel') }}</v-btn>
<v-btn color="primary" variant="elevated" :disabled="!agreed" :loading="loading" @click="accept">
{{ t('agreement.accept') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script setup>
import { ref } from 'vue'
import { useI18nStore } from '@ligoj/host'

defineProps({
modelValue: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
})

const emit = defineEmits(['update:modelValue', 'agreed'])

const i18n = useI18nStore()
const t = i18n.t
const agreed = ref(false)

function accept() {
emit('agreed')
}
</script>
228 changes: 228 additions & 0 deletions ui/src/components/BugReportDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<!--
BugReportDialog — front-only "report a bug" helper. Builds a copy/paste
Markdown template pre-filled with the build version, the current in-app URL
(path only, no domain) and the installed plugins (all read from the session
via auth.appSettings — no backend call), then links to the GitHub issue form.

Chrome mirrors AboutView's License dialog (.lic look): the root re-declares
its design tokens locally because v-dialog teleports the card to <body>,
where scoped page vars don't reach.

Moved from the host to plugin-ui (#121): mounted once persistently via
registerHeaderItem, it self-binds to the shared app.bugDialogOpen flag so the
host footer, the Information build card and AboutView all just flip the flag.
-->
<template>
<v-dialog v-model="open" max-width="680" scrollable>
<div class="bug" :style="{ '--c': '#e0567a' }">
<header class="bug-head">
<span class="bug-orb"><v-icon size="20">mdi-bug-outline</v-icon></span>
<div class="bug-htxt">
<h3>{{ t('bugReport.title') }}</h3>
<p>{{ t('bugReport.hint') }}</p>
</div>
<button class="bug-x" :aria-label="t('common.close')" @click="close">
<v-icon size="20">mdi-close</v-icon>
</button>
</header>

<div class="bug-body">
<textarea ref="taRef" class="bug-template" readonly :value="template" @focus="selectAll" />
</div>

<footer class="bug-foot">
<a class="bug-btn" :href="issueUrl" target="_blank" rel="noopener noreferrer">
<v-icon size="16">mdi-github</v-icon>{{ t('bugReport.openIssue') }}
<v-icon size="14" class="bug-go">mdi-open-in-new</v-icon>
</a>
<span class="bug-sp" />
<button class="bug-btn primary" @click="copy">
<v-icon size="16">{{ copied ? 'mdi-check' : 'mdi-content-copy' }}</v-icon>
{{ copied ? t('bugReport.copied') : t('bugReport.copy') }}
</button>
</footer>
</div>
</v-dialog>
</template>

<script setup>
import { computed, ref, watch } from 'vue'
import { useAppStore, useAuthStore, useI18nStore } from '@ligoj/host'

const app = useAppStore()
const auth = useAuthStore()
const { t } = useI18nStore()

// Self-bound to the shared flag (no props): the dialog is mounted once and any
// trigger just sets app.bugDialogOpen.
const open = computed({
get: () => app.bugDialogOpen,
set: (v) => { if (!v) app.closeBugDialog() },
})

const GITHUB_ISSUE_URL = 'https://github.com/ligoj/ligoj/issues/new'

const version = computed(() => auth.appSettings?.buildVersion || t('bugReport.unknown'))

// The in-app location WITHOUT the domain: prefer the hash route (this is a
// hash-router SPA, e.g. "#/about"), else the pathname. Captured into a ref on
// open so it reflects where the user actually was when they hit the button.
const url = ref('')
function captureUrl() {
if (typeof window === 'undefined') { url.value = ''; return }
url.value = window.location.hash || window.location.pathname || '/'
}

// appSettings.plugins is a list of backend keys (strings like
// "service:id:ldap"). Stay robust to an object shape too (id/key/name), since
// the exact form is backend-driven — fall back to JSON for anything exotic.
const pluginLines = computed(() => {
const list = auth.appSettings?.plugins
if (!Array.isArray(list) || !list.length) return []
return list.map((p) => {
if (typeof p === 'string') return p
if (p && typeof p === 'object') return p.id || p.key || p.name || p.artifact || JSON.stringify(p)
return String(p)
})
})

const template = computed(() => {
const lines = [
`## ${t('bugReport.tplDescription')}`,
t('bugReport.tplDescPlaceholder'),
'',
`## ${t('bugReport.tplContext')}`,
`- ${t('bugReport.tplVersion')}: ${version.value}`,
`- ${t('bugReport.tplUrl')}: ${url.value}`,
`- ${t('bugReport.tplPlugins')}:`,
]
const plugins = pluginLines.value
if (plugins.length) lines.push(...plugins.map((p) => ` - ${p}`))
else lines.push(` - ${t('bugReport.tplNoPlugin')}`)
return lines.join('\n')
})

// Pre-fill the GitHub issue body so the user can submit directly; they can
// still copy/paste from the textarea if they prefer.
const issueUrl = computed(() => `${GITHUB_ISSUE_URL}?body=${encodeURIComponent(template.value)}`)

const taRef = ref(null)
const copied = ref(false)
let copiedT

function selectAll() {
taRef.value?.select?.()
}

async function copy() {
const text = template.value
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
throw new Error('clipboard API unavailable')
}
} catch {
// Fallback: select the textarea and let the legacy execCommand copy it,
// leaving the selection visible so the user can Ctrl/Cmd+C manually if
// even that is blocked.
const ta = taRef.value
if (ta) {
ta.focus()
ta.select()
try { document.execCommand('copy') } catch { /* user can copy manually */ }
}
}
copied.value = true
clearTimeout(copiedT)
copiedT = setTimeout(() => { copied.value = false }, 2000)
}

function close() { app.closeBugDialog() }

// Refresh the captured URL each time the dialog opens.
watch(open, (isOpen) => {
if (isOpen) {
captureUrl()
copied.value = false
}
}, { immediate: true })
</script>

<style scoped>
.bug {
--surface: rgb(var(--v-theme-surface));
--card: rgb(var(--v-theme-surface));
--ink: rgb(var(--v-theme-on-surface));
--ink-3: rgba(var(--v-theme-on-surface), .55);
--border: rgba(var(--v-theme-on-surface), .12);
--hover: rgba(var(--v-theme-on-surface), .06);
--font: var(--lj-font, var(--v26-font, "Bricolage Grotesque", system-ui, sans-serif));
--mono: var(--lj-mono, var(--v26-mono, "JetBrains Mono", ui-monospace, monospace));
--radius: var(--lj-radius, 18px);
--radius-sm: var(--lj-radius-sm, 12px);
--shadow-lg: var(--lj-shadow-lg, 0 32px 64px -24px color-mix(in srgb, var(--c) 45%, transparent));
--border-w: var(--lj-border-width, 1px);
--border-c: var(--lj-border-color, var(--border));
--bold: var(--lj-weight-bold, 800);
color: var(--ink);
display: flex;
flex-direction: column;
max-height: 82vh;
border: var(--border-w) var(--lj-border-style, solid) var(--border-c);
border-radius: var(--radius);
background: linear-gradient(135deg, color-mix(in srgb, var(--c) 6%, var(--card)), var(--card));
box-shadow: var(--shadow-lg);
overflow: hidden;
}

.bug-head { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--border); }
.bug-orb { width: 40px; height: 40px; border-radius: var(--radius-sm); flex: none; display: grid; place-items: center; color: #fff; background: linear-gradient(135deg, var(--c), color-mix(in srgb, var(--c) 70%, #000)); box-shadow: 0 8px 18px -8px color-mix(in srgb, var(--c) 65%, transparent); }
.bug-htxt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.bug-htxt h3 { font-family: var(--font); font-weight: var(--bold); font-size: 17px; margin: 0; letter-spacing: -.02em; }
.bug-htxt p { margin: 1px 0 0; font-size: 12px; color: var(--ink-3); font-weight: 600; }
.bug-x { display: grid; place-items: center; width: 34px; height: 34px; flex: none; border: 0; border-radius: var(--lj-radius-sm, 10px); background: transparent; color: var(--ink-3); cursor: pointer; transition: background .14s, color .14s; }
.bug-x:hover { background: var(--hover); color: var(--ink); }

.bug-body { padding: 16px 18px; overflow-y: auto; }
.bug-template {
width: 100%;
min-height: 220px;
resize: vertical;
box-sizing: border-box;
font-family: var(--mono);
font-size: 12.5px;
line-height: 1.55;
color: var(--ink);
background: rgba(var(--v-theme-on-surface), .04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 14px;
white-space: pre;
overflow: auto;
}
.bug-template:focus { outline: 2px solid color-mix(in srgb, var(--c) 55%, transparent); outline-offset: 1px; }

.bug-foot { display: flex; align-items: center; gap: 10px; padding: 12px 18px; border-top: 1px solid var(--border); }
.bug-sp { flex: 1; }
.bug-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--font);
font-weight: 700;
font-size: 13px;
color: var(--ink);
text-decoration: none;
padding: 8px 16px;
border: var(--border-w) var(--lj-border-style, solid) var(--border-c);
border-radius: var(--lj-radius-sm, 10px);
background: transparent;
cursor: pointer;
transition: background .14s, border-color .14s;
}
.bug-btn:hover { background: var(--hover); border-color: color-mix(in srgb, var(--c) 35%, var(--border)); }
.bug-btn.primary { color: #fff; border-color: transparent; background: linear-gradient(135deg, var(--c), color-mix(in srgb, var(--c) 70%, #000)); }
.bug-btn.primary:hover { filter: brightness(1.05); }
.bug-go { opacity: .7; }
</style>
Loading