mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 14:54:12 +00:00
Add forms plugin to issue report component (#2302)
This commit is contained in:
67
package-lock.json
generated
67
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.11",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@comfyorg/litegraph": "^0.8.61",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -3961,6 +3962,25 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/forms": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/forms/-/forms-0.0.2.tgz",
|
||||
"integrity": "sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==",
|
||||
"dependencies": {
|
||||
"@primeuix/utils": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/forms/node_modules/@primeuix/utils": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.3.2.tgz",
|
||||
"integrity": "sha512-B+nphqTQeq+i6JuICLdVWnDMjONome2sNz0xI65qIOyeB4EF12CoKRiCsxuZ5uKAkHi/0d1LqlQ9mIWRSdkavw==",
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/styled": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.3.2.tgz",
|
||||
@@ -3998,6 +4018,53 @@
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/forms": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@primevue/forms/-/forms-4.2.5.tgz",
|
||||
"integrity": "sha512-5jarJQ9Qv32bOo/0tY5bqR3JZI6+YmmoUQ2mjhVSbVElQsE4FNfhT7a7JwF+xgBPMPc8KWGNA1QB248HhPNVAg==",
|
||||
"dependencies": {
|
||||
"@primeuix/forms": "^0.0.2",
|
||||
"@primeuix/utils": "^0.3.2",
|
||||
"@primevue/core": "4.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/forms/node_modules/@primeuix/styled": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.3.2.tgz",
|
||||
"integrity": "sha512-ColZes0+/WKqH4ob2x8DyNYf1NENpe5ZguOvx5yCLxaP8EIMVhLjWLO/3umJiDnQU4XXMLkn2mMHHw+fhTX/mw==",
|
||||
"dependencies": {
|
||||
"@primeuix/utils": "^0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/forms/node_modules/@primeuix/utils": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.3.2.tgz",
|
||||
"integrity": "sha512-B+nphqTQeq+i6JuICLdVWnDMjONome2sNz0xI65qIOyeB4EF12CoKRiCsxuZ5uKAkHi/0d1LqlQ9mIWRSdkavw==",
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/forms/node_modules/@primevue/core": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.2.5.tgz",
|
||||
"integrity": "sha512-+oWBIQs5dLd2Ini4KEVOlvPILk989EHAskiFS3R/dz3jeOllJDMZFcSp8V9ddV0R3yDaPdLVkfHm2Q5t42kU2Q==",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.3.2",
|
||||
"@primeuix/utils": "^0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/icons": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.2.5.tgz",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.11",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@comfyorg/litegraph": "^0.8.61",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<template>
|
||||
<div :class="['flex flex-wrap', $attrs.class]">
|
||||
<div
|
||||
v-for="checkbox in checkboxes"
|
||||
:key="checkbox.value"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="internalSelection"
|
||||
:inputId="checkbox.value"
|
||||
:value="checkbox.value"
|
||||
/>
|
||||
<label :for="checkbox.value" class="ml-2">{{ checkbox.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface CheckboxItem {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
checkboxes: CheckboxItem[]
|
||||
modelValue: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}>()
|
||||
|
||||
const internalSelection = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string[]) => emit('update:modelValue', value)
|
||||
})
|
||||
</script>
|
||||
@@ -90,10 +90,10 @@ const stackTraceField = computed<ReportField>(() => {
|
||||
label: t('issueReport.stackTrace'),
|
||||
value: 'StackTrace',
|
||||
optIn: true,
|
||||
data: {
|
||||
getData: () => ({
|
||||
nodeType: props.error.node_type,
|
||||
stackTrace: props.error.traceback?.join('\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<header class="flex flex-col items-center">
|
||||
<header class="flex flex-col items-center w-full">
|
||||
<h2 id="issue-report-title" class="text-4xl">{{ title }}</h2>
|
||||
<span v-if="subtitle" class="text-muted mt-2">{{ subtitle }}</span>
|
||||
<span v-if="subtitle" class="text-muted mt-0">{{ subtitle }}</span>
|
||||
</header>
|
||||
</template>
|
||||
<ReportIssuePanel v-bind="panelProps" :pt="{ root: 'border-none' }" />
|
||||
@@ -20,10 +20,9 @@
|
||||
<script setup lang="ts">
|
||||
import Panel from 'primevue/panel'
|
||||
|
||||
import ReportIssuePanel from '@/components/dialog/content/error/ReportIssuePanel.vue'
|
||||
import type { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
import ReportIssuePanel from './error/ReportIssuePanel.vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
|
||||
@@ -1,213 +1,251 @@
|
||||
<template>
|
||||
<Panel>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ title }}</span>
|
||||
<Form
|
||||
v-slot="$form"
|
||||
@submit="submit"
|
||||
:resolver="zodResolver(issueReportSchema)"
|
||||
>
|
||||
<Panel :pt="$attrs.pt">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-4">
|
||||
<Button
|
||||
v-tooltip="!submitted ? $t('g.reportIssueTooltip') : undefined"
|
||||
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
|
||||
:severity="submitted ? 'secondary' : 'primary'"
|
||||
:icon="submitted ? 'pi pi-check' : 'pi pi-send'"
|
||||
:disabled="submitted"
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 mt-2 border border-round surface-border shadow-1">
|
||||
<div class="flex flex-row gap-3 mb-2">
|
||||
<div v-for="field in fields" :key="field.value">
|
||||
<FormField
|
||||
v-if="field.optIn"
|
||||
v-slot="$field"
|
||||
:name="field.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
:inputId="field.value"
|
||||
:value="field.value"
|
||||
v-model="selection"
|
||||
/>
|
||||
<label :for="field.value">{{ field.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField class="mb-4" v-slot="$field" name="details">
|
||||
<Textarea
|
||||
v-bind="$field"
|
||||
class="w-full"
|
||||
rows="5"
|
||||
:placeholder="$t('issueReport.provideAdditionalDetails')"
|
||||
:aria-label="$t('issueReport.provideAdditionalDetails')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.maxLength') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
<FormField v-slot="$field" name="contactInfo">
|
||||
<InputText
|
||||
v-bind="$field"
|
||||
class="w-full"
|
||||
:placeholder="$t('issueReport.provideEmail')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value !== ''"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.invalidEmail') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
|
||||
<div class="flex flex-row gap-3 mt-2">
|
||||
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
:name="checkbox.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
:inputId="checkbox.value"
|
||||
:value="checkbox.value"
|
||||
v-model="contactPrefs"
|
||||
:disabled="
|
||||
$form.contactInfo?.error || !$form.contactInfo?.value
|
||||
"
|
||||
/>
|
||||
<label :for="checkbox.value">{{ checkbox.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
v-tooltip="$t('g.reportIssueTooltip')"
|
||||
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
|
||||
:severity="isButtonDisabled ? 'secondary' : 'primary'"
|
||||
:icon="icon"
|
||||
:disabled="isButtonDisabled"
|
||||
@click="reportIssue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 mt-4 border border-round surface-border shadow-1">
|
||||
<CheckboxGroup
|
||||
v-if="reportCheckboxes.length"
|
||||
v-model="selection"
|
||||
class="gap-4 mb-4"
|
||||
:checkboxes="reportCheckboxes"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<InputText
|
||||
v-model="contactInfo"
|
||||
class="w-full"
|
||||
:placeholder="$t('issueReport.provideEmail')"
|
||||
:maxlength="CONTACT_MAX_LEN"
|
||||
:invalid="isContactInfoInvalid"
|
||||
/>
|
||||
<CheckboxGroup
|
||||
v-model="contactPrefs"
|
||||
class="gap-3 mt-2"
|
||||
:checkboxes="contactCheckboxes"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<Textarea
|
||||
v-model="details"
|
||||
class="w-full"
|
||||
rows="4"
|
||||
:maxlength="DETAILS_MAX_LEN"
|
||||
:placeholder="$t('issueReport.provideAdditionalDetails')"
|
||||
:aria-label="$t('issueReport.provideAdditionalDetails')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Panel>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
|
||||
// @ts-expect-error https://github.com/primefaces/primevue/issues/6722
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import type { CaptureContext, User } from '@sentry/core'
|
||||
import { captureMessage } from '@sentry/core'
|
||||
import cloneDeep from 'lodash/cloneDeep'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Panel from 'primevue/panel'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CheckboxGroup from '@/components/common/CheckboxGroup.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
type IssueReportFormData,
|
||||
type ReportField,
|
||||
issueReportSchema
|
||||
} from '@/types/issueReportTypes'
|
||||
import type {
|
||||
DefaultField,
|
||||
IssueReportPanelProps
|
||||
} from '@/types/issueReportTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const ISSUE_NAME = 'User reported issue'
|
||||
const DETAILS_MAX_LEN = 5_000
|
||||
const CONTACT_MAX_LEN = 320
|
||||
|
||||
const props = defineProps<IssueReportPanelProps>()
|
||||
|
||||
const {
|
||||
defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'],
|
||||
tags = {}
|
||||
} = props
|
||||
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
|
||||
props
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const selection = ref<string[]>([])
|
||||
const contactPrefs = ref<string[]>([])
|
||||
const contactInfo = ref('')
|
||||
const details = ref('')
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const followUp = computed(() => contactPrefs.value.includes('FollowUp'))
|
||||
const notifyResolve = computed(() => contactPrefs.value.includes('Resolution'))
|
||||
|
||||
const icon = computed(() => {
|
||||
if (submitting.value) return 'pi pi-spin pi-spinner'
|
||||
if (submitted.value) return 'pi pi-check'
|
||||
return 'pi pi-send'
|
||||
})
|
||||
const isFormEmpty = computed(() => !selection.value.length && !details.value)
|
||||
const isContactInfoInvalid = computed(() => {
|
||||
if (!contactInfo.value) return false
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return !emailRegex.test(contactInfo.value)
|
||||
})
|
||||
const isButtonDisabled = computed(
|
||||
() =>
|
||||
submitted.value ||
|
||||
submitting.value ||
|
||||
isFormEmpty.value ||
|
||||
isContactInfoInvalid.value
|
||||
)
|
||||
|
||||
const contactCheckboxes = [
|
||||
{ label: t('issueReport.contactFollowUp'), value: 'FollowUp' },
|
||||
{ label: t('issueReport.notifyResolve'), value: 'Resolution' }
|
||||
{ label: t('issueReport.contactFollowUp'), value: 'followUp' },
|
||||
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
|
||||
]
|
||||
const defaultReportCheckboxes = [
|
||||
{ label: t('g.workflow'), value: 'Workflow' },
|
||||
{ label: t('g.logs'), value: 'Logs' },
|
||||
{ label: t('issueReport.systemStats'), value: 'SystemStats' },
|
||||
{ label: t('g.settings'), value: 'Settings' }
|
||||
|
||||
const defaultFieldsConfig: ReportField[] = [
|
||||
{
|
||||
label: t('issueReport.systemStats'),
|
||||
value: 'SystemStats',
|
||||
getData: () => api.getSystemStats(),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.workflow'),
|
||||
value: 'Workflow',
|
||||
getData: () => cloneDeep(app.graph.asSerialisable()),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.logs'),
|
||||
value: 'Logs',
|
||||
getData: () => api.getLogs(),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.settings'),
|
||||
value: 'Settings',
|
||||
getData: () => api.getSettings(),
|
||||
optIn: true
|
||||
}
|
||||
]
|
||||
const reportCheckboxes = computed(() => [
|
||||
...(props.extraFields
|
||||
?.filter(({ optIn }) => optIn)
|
||||
.map(({ label, value }) => ({ label, value })) ?? []),
|
||||
...defaultReportCheckboxes.filter(({ value }) =>
|
||||
|
||||
const fields = computed(() => [
|
||||
...defaultFieldsConfig.filter(({ value }) =>
|
||||
defaultFields.includes(value as DefaultField)
|
||||
)
|
||||
),
|
||||
...(props.extraFields ?? [])
|
||||
])
|
||||
|
||||
const getUserInfo = (): User => ({ email: contactInfo.value })
|
||||
const createUser = (formData: IssueReportFormData): User => ({
|
||||
email: formData.contactInfo || undefined
|
||||
})
|
||||
|
||||
const getLogs = async () =>
|
||||
selection.value.includes('Logs') ? api.getLogs() : null
|
||||
const createExtraData = async (formData: IssueReportFormData) => {
|
||||
const result = {}
|
||||
const isChecked = (fieldValue: string) => formData[fieldValue]
|
||||
|
||||
const getSystemStats = async () =>
|
||||
selection.value.includes('SystemStats') ? api.getSystemStats() : null
|
||||
await Promise.all(
|
||||
fields.value
|
||||
.filter((field) => !field.optIn || isChecked(field.value))
|
||||
.map(async (field) => {
|
||||
try {
|
||||
result[field.value] = await field.getData()
|
||||
} catch (error) {
|
||||
console.error(`Failed to collect ${field.value}:`, error)
|
||||
result[field.value] = { error: String(error) }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const getSettings = async () =>
|
||||
selection.value.includes('Settings') ? api.getSettings() : null
|
||||
|
||||
const getWorkflow = () =>
|
||||
selection.value.includes('Workflow')
|
||||
? cloneDeep(app.graph.asSerialisable())
|
||||
: null
|
||||
|
||||
const createDefaultFields = async () => {
|
||||
const [settings, systemStats, logs, workflow] = await Promise.all([
|
||||
getSettings(),
|
||||
getSystemStats(),
|
||||
getLogs(),
|
||||
getWorkflow()
|
||||
])
|
||||
return { settings, systemStats, logs, workflow }
|
||||
return result
|
||||
}
|
||||
|
||||
const createExtraFields = (): Record<string, unknown> | undefined => {
|
||||
if (!props.extraFields) return undefined
|
||||
|
||||
return props.extraFields
|
||||
.filter((field) => !field.optIn || selection.value.includes(field.value))
|
||||
.reduce((acc, field) => ({ ...acc, ...cloneDeep(field.data) }), {})
|
||||
}
|
||||
|
||||
const createFeedback = () => {
|
||||
const createCaptureContext = async (
|
||||
formData: IssueReportFormData
|
||||
): Promise<CaptureContext> => {
|
||||
return {
|
||||
details: details.value,
|
||||
contactPreferences: {
|
||||
followUp: followUp.value,
|
||||
notifyOnResolution: notifyResolve.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createCaptureContext = async (): Promise<CaptureContext> => {
|
||||
return {
|
||||
user: getUserInfo(),
|
||||
user: createUser(formData),
|
||||
level: 'error',
|
||||
tags: {
|
||||
errorType: props.errorType,
|
||||
...tags
|
||||
followUp: formData.contactInfo ? formData.followUp : false,
|
||||
notifyOnResolution: formData.contactInfo
|
||||
? formData.notifyOnResolution
|
||||
: false,
|
||||
isElectron: isElectron(),
|
||||
...props.tags
|
||||
},
|
||||
extra: {
|
||||
...createFeedback(),
|
||||
...(await createDefaultFields()),
|
||||
...createExtraFields()
|
||||
details: formData.details,
|
||||
...(await createExtraData(formData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reportIssue = async () => {
|
||||
if (isButtonDisabled.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
captureMessage(ISSUE_NAME, await createCaptureContext())
|
||||
submitted.value = true
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reportSent'),
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
const submit = async (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
try {
|
||||
const captureContext = await createCaptureContext(event.values)
|
||||
captureMessage(ISSUE_NAME, captureContext)
|
||||
submitted.value = true
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reportSent'),
|
||||
life: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error.message,
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
// @ts-strict-ignore
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Panel from 'primevue/panel'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CheckboxGroup from '@/components/common/CheckboxGroup.vue'
|
||||
import enMesages from '@/locales/en/main.json'
|
||||
import { DefaultField, ReportField } from '@/types/issueReportTypes'
|
||||
import { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
import ReportIssuePanel from '../ReportIssuePanel.vue'
|
||||
|
||||
type ReportIssuePanelProps = {
|
||||
errorType: string
|
||||
defaultFields?: DefaultField[]
|
||||
extraFields?: ReportField[]
|
||||
tags?: Record<string, string>
|
||||
title?: string
|
||||
const DEFAULT_FIELDS = ['Workflow', 'Logs', 'Settings', 'SystemStats']
|
||||
const CUSTOM_FIELDS = [
|
||||
{
|
||||
label: 'Custom Field',
|
||||
value: 'CustomField',
|
||||
optIn: true,
|
||||
getData: () => 'mock data'
|
||||
}
|
||||
]
|
||||
|
||||
async function getSubmittedContext() {
|
||||
const { captureMessage } = (await import('@sentry/core')) as any
|
||||
return captureMessage.mock.calls[0][1]
|
||||
}
|
||||
|
||||
async function submitForm(wrapper: any) {
|
||||
await wrapper.findComponent(Form).trigger('submit')
|
||||
return getSubmittedContext()
|
||||
}
|
||||
|
||||
async function findAndUpdateCheckbox(
|
||||
wrapper: any,
|
||||
value: string,
|
||||
checked = true
|
||||
) {
|
||||
const checkbox = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
.find((c: any) => c.props('value') === value)
|
||||
if (!checkbox) throw new Error(`Checkbox with value "${value}" not found`)
|
||||
|
||||
await checkbox.vm.$emit('update:modelValue', checked)
|
||||
return checkbox
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -59,18 +82,64 @@ vi.mock('@sentry/core', () => ({
|
||||
captureMessage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@primevue/forms', () => ({
|
||||
Form: {
|
||||
name: 'Form',
|
||||
template:
|
||||
'<form @submit.prevent="onSubmit"><slot :values="formValues" /></form>',
|
||||
props: ['resolver'],
|
||||
data() {
|
||||
return {
|
||||
formValues: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
this.$emit('submit', {
|
||||
valid: true,
|
||||
values: this.formValues
|
||||
})
|
||||
},
|
||||
updateFieldValue(name: string, value: any) {
|
||||
this.formValues[name] = value
|
||||
}
|
||||
}
|
||||
},
|
||||
FormField: {
|
||||
name: 'FormField',
|
||||
template:
|
||||
'<div><slot :modelValue="modelValue" @update:modelValue="updateValue" /></div>',
|
||||
props: ['name'],
|
||||
data() {
|
||||
return {
|
||||
modelValue: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(value) {
|
||||
this.modelValue = value
|
||||
let parent = this.$parent
|
||||
while (parent && parent.$options.name !== 'Form') {
|
||||
parent = parent.$parent
|
||||
}
|
||||
if (parent) {
|
||||
parent.updateFieldValue(this.name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('ReportIssuePanel', () => {
|
||||
beforeAll(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = (props: ReportIssuePanelProps, options = {}): any => {
|
||||
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
|
||||
return mount(ReportIssuePanel, {
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n],
|
||||
directives: { tooltip: Tooltip },
|
||||
components: { InputText, Button, Panel, Textarea, CheckboxGroup }
|
||||
plugins: [PrimeVue, i18n],
|
||||
directives: { tooltip: Tooltip }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
@@ -80,44 +149,66 @@ describe('ReportIssuePanel', () => {
|
||||
it('renders the panel with all required components', () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
expect(wrapper.find('.p-panel').exists()).toBe(true)
|
||||
expect(wrapper.findAllComponents(CheckboxGroup).length).toBe(2)
|
||||
expect(wrapper.findAllComponents(Checkbox).length).toBe(6)
|
||||
expect(wrapper.findComponent(InputText).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Textarea).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates selection when checkboxes are selected', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
|
||||
await checkboxes?.setValue(['Workflow', 'Logs'])
|
||||
expect(wrapper.vm.selection).toEqual(['Workflow', 'Logs'])
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error'
|
||||
})
|
||||
|
||||
const checkboxes = wrapper.findAllComponents(Checkbox)
|
||||
|
||||
for (const field of DEFAULT_FIELDS) {
|
||||
const checkbox = checkboxes.find(
|
||||
(checkbox) => checkbox.props('value') === field
|
||||
)
|
||||
expect(checkbox).toBeDefined()
|
||||
|
||||
await checkbox?.vm.$emit('update:modelValue', [field])
|
||||
expect(wrapper.vm.selection).toContain(field)
|
||||
}
|
||||
})
|
||||
|
||||
it('updates contactInfo when input is changed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const input = wrapper.findComponent(InputText)
|
||||
await input.setValue('test@example.com')
|
||||
expect(wrapper.vm.contactInfo).toBe('test@example.com')
|
||||
|
||||
await input.vm.$emit('update:modelValue', 'test@example.com')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.user.email).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('updates additional details when textarea is changed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const textarea = wrapper.findComponent(Textarea)
|
||||
await textarea.setValue('This is a test detail.')
|
||||
expect(wrapper.vm.details).toBe('This is a test detail.')
|
||||
|
||||
await textarea.vm.$emit('update:modelValue', 'This is a test detail.')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra.details).toBe('This is a test detail.')
|
||||
})
|
||||
|
||||
it('updates contactPrefs when preferences are selected', async () => {
|
||||
it('set contact preferences back to false if email is removed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const preferences = wrapper.findAllComponents(CheckboxGroup).at(1)
|
||||
await preferences?.setValue(['FollowUp'])
|
||||
expect(wrapper.vm.contactPrefs).toEqual(['FollowUp'])
|
||||
})
|
||||
const input = wrapper.findComponent(InputText)
|
||||
|
||||
it('does not allow submission if the form is empty', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
await wrapper.vm.reportIssue()
|
||||
expect(wrapper.vm.submitted).toBe(false)
|
||||
// Set a valid email, enabling the contact preferences to be changed
|
||||
await input.vm.$emit('update:modelValue', 'name@example.com')
|
||||
|
||||
// Enable both contact preferences
|
||||
for (const pref of ['followUp', 'notifyOnResolution']) {
|
||||
await findAndUpdateCheckbox(wrapper, pref)
|
||||
}
|
||||
|
||||
// Change the email back to empty
|
||||
await input.vm.$emit('update:modelValue', '')
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
// Check that the contact preferences are back to false automatically
|
||||
expect(context.tags.followUp).toBe(false)
|
||||
expect(context.tags.notifyOnResolution).toBe(false)
|
||||
})
|
||||
|
||||
it('renders with overridden default fields', () => {
|
||||
@@ -125,83 +216,87 @@ describe('ReportIssuePanel', () => {
|
||||
errorType: 'Test Error',
|
||||
defaultFields: ['Settings']
|
||||
})
|
||||
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
|
||||
expect(checkboxes?.props('checkboxes')).toEqual([
|
||||
{ label: 'Settings', value: 'Settings' }
|
||||
])
|
||||
|
||||
// Filter out the contact preferences checkboxes
|
||||
const fieldCheckboxes = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
.filter(
|
||||
(checkbox) =>
|
||||
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
|
||||
)
|
||||
expect(fieldCheckboxes.length).toBe(1)
|
||||
expect(fieldCheckboxes.at(0)?.props('value')).toBe('Settings')
|
||||
})
|
||||
|
||||
it('renders additional fields when extraFields prop is provided', () => {
|
||||
const extraFields = [
|
||||
{ label: 'Custom Field', value: 'CustomField', optIn: true, data: {} }
|
||||
]
|
||||
const wrapper = mountComponent({ errorType: 'Test Error', extraFields })
|
||||
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
|
||||
expect(checkboxes?.props('checkboxes')).toContainEqual({
|
||||
label: 'Custom Field',
|
||||
value: 'CustomField'
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
extraFields: CUSTOM_FIELDS
|
||||
})
|
||||
const customCheckbox = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
.find((checkbox) => checkbox.props('value') === 'CustomField')
|
||||
expect(customCheckbox).toBeDefined()
|
||||
})
|
||||
|
||||
it('allows custom fields to be selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
extraFields: CUSTOM_FIELDS
|
||||
})
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, 'CustomField')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra.CustomField).toBe('mock data')
|
||||
})
|
||||
|
||||
it('does not submit unchecked fields', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const textarea = wrapper.findComponent(Textarea)
|
||||
|
||||
await textarea.setValue('Report with only text but no fields selected')
|
||||
await wrapper.vm.reportIssue()
|
||||
// Set details but don't check any field checkboxes
|
||||
await textarea.vm.$emit(
|
||||
'update:modelValue',
|
||||
'Report with only text but no fields selected'
|
||||
)
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
const { captureMessage } = (await import('@sentry/core')) as any
|
||||
const captureContext = captureMessage.mock.calls[0][1]
|
||||
|
||||
expect(captureContext.extra.logs).toBeNull()
|
||||
expect(captureContext.extra.systemStats).toBeNull()
|
||||
expect(captureContext.extra.settings).toBeNull()
|
||||
expect(captureContext.extra.workflow).toBeNull()
|
||||
// Verify none of the optional fields were included
|
||||
for (const field of DEFAULT_FIELDS) {
|
||||
expect(context.extra[field]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
checkbox: 'Logs',
|
||||
apiMethod: 'getLogs',
|
||||
expectedKey: 'logs',
|
||||
expectedKey: 'Logs',
|
||||
mockValue: 'mock logs'
|
||||
},
|
||||
{
|
||||
checkbox: 'SystemStats',
|
||||
apiMethod: 'getSystemStats',
|
||||
expectedKey: 'systemStats',
|
||||
expectedKey: 'SystemStats',
|
||||
mockValue: 'mock stats'
|
||||
},
|
||||
{
|
||||
checkbox: 'Settings',
|
||||
apiMethod: 'getSettings',
|
||||
expectedKey: 'settings',
|
||||
expectedKey: 'Settings',
|
||||
mockValue: 'mock settings'
|
||||
}
|
||||
])(
|
||||
'submits (%s) when the (%s) checkbox is selected',
|
||||
'submits $checkbox data when checkbox is selected',
|
||||
async ({ checkbox, apiMethod, expectedKey, mockValue }) => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
|
||||
const { api } = (await import('@/scripts/api')) as any
|
||||
vi.spyOn(api, apiMethod).mockResolvedValue(mockValue)
|
||||
|
||||
const { captureMessage } = await import('@sentry/core')
|
||||
|
||||
// Select the checkbox
|
||||
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
|
||||
await checkboxes?.vm.$emit('update:modelValue', [checkbox])
|
||||
|
||||
await wrapper.vm.reportIssue()
|
||||
expect(api[apiMethod]).toHaveBeenCalled()
|
||||
|
||||
// Verify the message includes the associated data
|
||||
expect(captureMessage).toHaveBeenCalledWith(
|
||||
'User reported issue',
|
||||
expect.objectContaining({
|
||||
extra: expect.objectContaining({ [expectedKey]: mockValue })
|
||||
})
|
||||
)
|
||||
await findAndUpdateCheckbox(wrapper, checkbox)
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra[expectedKey]).toBe(mockValue)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -209,24 +304,12 @@ describe('ReportIssuePanel', () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
|
||||
const { app } = (await import('@/scripts/app')) as any
|
||||
const { captureMessage } = await import('@sentry/core')
|
||||
|
||||
const mockWorkflow = { nodes: [], edges: [] }
|
||||
vi.spyOn(app.graph, 'asSerialisable').mockReturnValue(mockWorkflow)
|
||||
|
||||
// Select the "Workflow" checkbox
|
||||
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
|
||||
await checkboxes?.vm.$emit('update:modelValue', ['Workflow'])
|
||||
await findAndUpdateCheckbox(wrapper, 'Workflow')
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
await wrapper.vm.reportIssue()
|
||||
expect(app.graph.asSerialisable).toHaveBeenCalled()
|
||||
|
||||
// Verify the message includes the workflow
|
||||
expect(captureMessage).toHaveBeenCalledWith(
|
||||
'User reported issue',
|
||||
expect.objectContaining({
|
||||
extra: expect.objectContaining({ workflow: mockWorkflow })
|
||||
})
|
||||
)
|
||||
expect(context.extra.Workflow).toEqual(mockWorkflow)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -548,9 +548,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
function: () => {
|
||||
dialogService.showIssueReportDialog({
|
||||
title: t('g.feedback'),
|
||||
subtitle: t('issueReport.feedbackTitle'),
|
||||
panelProps: {
|
||||
errorType: 'Feedback',
|
||||
title: t('issueReport.feedbackTitle'),
|
||||
defaultFields: ['SystemStats', 'Settings']
|
||||
}
|
||||
})
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
},
|
||||
"issueReport": {
|
||||
"submitErrorReport": "Submit Error Report (Optional)",
|
||||
"provideEmail": "Give us your email (Optional)",
|
||||
"provideEmail": "Give us your email (optional)",
|
||||
"provideAdditionalDetails": "Provide additional details (optional)",
|
||||
"stackTrace": "Stack Trace",
|
||||
"systemStats": "System Stats",
|
||||
@@ -85,7 +85,11 @@
|
||||
"notifyResolve": "Notify me when resolved",
|
||||
"helpFix": "Help Fix This",
|
||||
"rating": "Rating",
|
||||
"feedbackTitle": "Help us improve ComfyUI by providing feedback"
|
||||
"feedbackTitle": "Help us improve ComfyUI by providing feedback",
|
||||
"validation": {
|
||||
"maxLength": "Message too long",
|
||||
"invalidEmail": "Please enter a valid email address"
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"default": "Default",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export type DefaultField = 'Workflow' | 'Logs' | 'SystemStats' | 'Settings'
|
||||
|
||||
export interface ReportField {
|
||||
@@ -14,7 +16,7 @@ export interface ReportField {
|
||||
/**
|
||||
* The data associated with this field, sent as part of the report.
|
||||
*/
|
||||
data: Record<string, unknown>
|
||||
getData: () => unknown
|
||||
|
||||
/**
|
||||
* Indicates whether the field requires explicit opt-in from the user
|
||||
@@ -49,3 +51,15 @@ export interface IssueReportPanelProps {
|
||||
*/
|
||||
title?: string
|
||||
}
|
||||
|
||||
const checkboxField = z.boolean().optional()
|
||||
export const issueReportSchema = z
|
||||
.object({
|
||||
contactInfo: z.string().email().max(320).optional().or(z.literal('')),
|
||||
details: z.string().max(5_000).optional()
|
||||
})
|
||||
.catchall(checkboxField)
|
||||
.refine((data) => Object.values(data).some((value) => value), {
|
||||
path: ['details']
|
||||
})
|
||||
export type IssueReportFormData = z.infer<typeof issueReportSchema>
|
||||
|
||||
Reference in New Issue
Block a user