mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
4 Commits
v1.45.12
...
glary/clou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba736c0a5 | ||
|
|
bfbd8fdcdc | ||
|
|
d171a874e0 | ||
|
|
b0dff1b453 |
@@ -83,6 +83,7 @@
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
@@ -113,6 +114,7 @@
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -162,6 +162,9 @@ catalogs:
|
||||
'@types/three':
|
||||
specifier: ^0.169.0
|
||||
version: 0.169.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -360,6 +363,9 @@ catalogs:
|
||||
unplugin-vue-components:
|
||||
specifier: ^30.0.0
|
||||
version: 30.0.0
|
||||
vee-validate:
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
vite-plugin-dts:
|
||||
specifier: ^4.5.4
|
||||
version: 4.5.4
|
||||
@@ -497,6 +503,9 @@ importers:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: 'catalog:'
|
||||
version: 2.27.2
|
||||
'@vee-validate/zod':
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -587,6 +596,9 @@ importers:
|
||||
typegpu:
|
||||
specifier: 'catalog:'
|
||||
version: 0.8.2
|
||||
vee-validate:
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -4721,6 +4733,11 @@ packages:
|
||||
peerDependencies:
|
||||
valibot: ^1.2.0
|
||||
|
||||
'@vee-validate/zod@4.15.1':
|
||||
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vercel/analytics@2.0.1':
|
||||
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
|
||||
peerDependencies:
|
||||
@@ -9593,6 +9610,11 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vee-validate@4.15.1:
|
||||
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.26
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
@@ -14038,6 +14060,14 @@ snapshots:
|
||||
dependencies:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)':
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
@@ -14156,7 +14186,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20051,6 +20081,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
type-fest: 4.41.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -55,6 +55,7 @@ catalog:
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
@@ -121,6 +122,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.0.0
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
|
||||
@@ -79,10 +79,13 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get onboardingSurveyEnabled() {
|
||||
// DO NOT MERGE: default flipped to true so QA can preview the redesigned
|
||||
// onboarding survey without backend flag work. Revert to `false` before
|
||||
// merging — the survey should remain backend-gated in production.
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED,
|
||||
remoteConfig.value.onboarding_survey_enabled,
|
||||
false
|
||||
true
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
|
||||
@@ -2790,51 +2790,81 @@
|
||||
"survey": {
|
||||
"title": "Cloud Survey",
|
||||
"placeholder": "Survey questions placeholder",
|
||||
"intro": "Help us tailor your ComfyUI experience.",
|
||||
"steps": {
|
||||
"usage": "How do you plan to use ComfyUI?",
|
||||
"familiarity": "How familiar are you with ComfyUI?",
|
||||
"purpose": "What will you primarily use ComfyUI for?",
|
||||
"industry": "What's your primary industry?",
|
||||
"making": "What do you plan on making?"
|
||||
},
|
||||
"questions": {
|
||||
"familiarity": "How familiar are you with ComfyUI?",
|
||||
"purpose": "What will you primarily use ComfyUI for?",
|
||||
"industry": "What's your primary industry?",
|
||||
"making": "What do you plan on making?"
|
||||
"role": "What's your role?",
|
||||
"teamSize": "How big is your team?",
|
||||
"industry": "What industry are you in?",
|
||||
"making": "What are you making?",
|
||||
"source": "Where did you hear about ComfyUI?"
|
||||
},
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"new": "New to ComfyUI (never used it before)",
|
||||
"starting": "Just getting started (following tutorials)",
|
||||
"basics": "Comfortable with basics",
|
||||
"advanced": "Advanced user (custom workflows)",
|
||||
"expert": "Expert (help others)"
|
||||
"usage": {
|
||||
"personal": "Personal use",
|
||||
"work": "Work",
|
||||
"education": "Education (student or educator)"
|
||||
},
|
||||
"purpose": {
|
||||
"personal": "Personal projects / hobby",
|
||||
"community": "Community contributions (nodes, workflows, etc.)",
|
||||
"client": "Client work (freelance)",
|
||||
"inhouse": "My own workplace (in-house)",
|
||||
"research": "Academic research"
|
||||
"familiarity": {
|
||||
"new": "Never used it",
|
||||
"starting": "Following tutorials",
|
||||
"basics": "Comfortable with basics",
|
||||
"advanced": "Build and edit workflows",
|
||||
"expert": "Expert — I help others"
|
||||
},
|
||||
"role": {
|
||||
"creative_technologist": "Creative Technologist",
|
||||
"creative_director": "Creative Director",
|
||||
"ai_researcher": "AI Researcher",
|
||||
"concept_artist": "Concept Artist / Illustrator",
|
||||
"pipeline_td": "Pipeline TD / Technical Artist",
|
||||
"producer": "Producer",
|
||||
"engineer": "Engineer",
|
||||
"student": "Student",
|
||||
"leadership": "Leadership",
|
||||
"content_creator": "Content Creator",
|
||||
"educator": "Educator",
|
||||
"marketing": "Marketing",
|
||||
"other": "Other"
|
||||
},
|
||||
"source": {
|
||||
"youtube": "YouTube",
|
||||
"reddit": "Reddit",
|
||||
"twitter": "Twitter / X",
|
||||
"instagram": "Instagram",
|
||||
"friend": "Friend or colleague",
|
||||
"search": "Google / search",
|
||||
"newsletter": "Newsletter or blog",
|
||||
"conference": "Conference or event",
|
||||
"discord": "Discord / community",
|
||||
"github": "GitHub",
|
||||
"other": "Other"
|
||||
},
|
||||
"teamSize": {
|
||||
"solo": "Just me",
|
||||
"small": "2–5",
|
||||
"studio": "6–20",
|
||||
"midsize": "21–100",
|
||||
"enterprise": "100+"
|
||||
},
|
||||
"industry": {
|
||||
"film_tv_animation": "Film, TV, & animation",
|
||||
"film_tv": "Film, TV & animation",
|
||||
"vfx_post": "VFX & post-production",
|
||||
"advertising": "Advertising & marketing",
|
||||
"gaming": "Gaming",
|
||||
"marketing": "Marketing & advertising",
|
||||
"architecture": "Architecture",
|
||||
"product_design": "Product & graphic design",
|
||||
"fine_art": "Fine art & illustration",
|
||||
"software": "Software & technology",
|
||||
"education": "Education",
|
||||
"fashion": "Fashion",
|
||||
"design": "Design (product / graphic / architectural / industrial)",
|
||||
"software": "Software / AI / tech",
|
||||
"other": "Other",
|
||||
"otherPlaceholder": "Please specify"
|
||||
},
|
||||
"making": {
|
||||
"video": "Video",
|
||||
"images": "Images",
|
||||
"video": "Video & animation",
|
||||
"3d": "3D assets",
|
||||
"audio": "Audio / music",
|
||||
"custom_nodes": "Custom nodes & workflows"
|
||||
"custom_nodes": "Custom Nodes",
|
||||
"audio": "Audio / music"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2904,10 +2934,13 @@
|
||||
"cloudForgotPassword_emailRequired": "Email is required",
|
||||
"cloudForgotPassword_passwordResetSent": "Password reset sent",
|
||||
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
|
||||
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
|
||||
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
|
||||
"cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?",
|
||||
"cloudSurvey_steps_industry": "What's your primary industry?",
|
||||
"cloudSurvey_steps_making": "What do you plan on making?",
|
||||
"cloudSurvey_steps_role": "What's your role?",
|
||||
"cloudSurvey_steps_teamSize": "How big is your team?",
|
||||
"cloudSurvey_steps_industry": "What industry are you in?",
|
||||
"cloudSurvey_steps_making": "What are you making?",
|
||||
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
|
||||
"assetBrowser": {
|
||||
"allCategory": "All {category}",
|
||||
"allModels": "All Models",
|
||||
|
||||
@@ -1,251 +1,40 @@
|
||||
<template>
|
||||
<div>
|
||||
<Stepper
|
||||
value="1"
|
||||
class="flex h-[638px] max-h-[80vh] w-[320px] max-w-[90vw] flex-col"
|
||||
>
|
||||
<ProgressBar
|
||||
:value="progressPercent"
|
||||
:show-value="false"
|
||||
class="mb-8 h-2"
|
||||
/>
|
||||
|
||||
<StepPanels class="flex flex-1 flex-col p-0">
|
||||
<StepPanel
|
||||
v-slot="{ activateCallback }"
|
||||
value="1"
|
||||
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-8 block text-lg font-medium">{{
|
||||
t('cloudSurvey_steps_familiarity')
|
||||
}}</label>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="opt in familiarityOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="surveyData.familiarity"
|
||||
:input-id="`fam-${opt.value}`"
|
||||
name="familiarity"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label
|
||||
:for="`fam-${opt.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ opt.label }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-4">
|
||||
<span />
|
||||
<Button
|
||||
:disabled="!validStep1"
|
||||
class="h-10 w-full border-none text-white"
|
||||
@click="goTo(2, activateCallback)"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
|
||||
<StepPanel
|
||||
v-slot="{ activateCallback }"
|
||||
value="2"
|
||||
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-8 block text-lg font-medium">{{
|
||||
t('cloudSurvey_steps_purpose')
|
||||
}}</label>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="opt in purposeOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="surveyData.useCase"
|
||||
:input-id="`purpose-${opt.value}`"
|
||||
name="purpose"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label
|
||||
:for="`purpose-${opt.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ opt.label }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="surveyData.useCase === 'other'" class="mt-4 ml-8">
|
||||
<InputText
|
||||
v-model="surveyData.useCaseOther"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(1, activateCallback)"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!validStep2"
|
||||
class="h-10 flex-1 text-white"
|
||||
@click="goTo(3, activateCallback)"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
|
||||
<StepPanel
|
||||
v-slot="{ activateCallback }"
|
||||
value="3"
|
||||
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-8 block text-lg font-medium">{{
|
||||
t('cloudSurvey_steps_industry')
|
||||
}}</label>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="opt in industryOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="surveyData.industry"
|
||||
:input-id="`industry-${opt.value}`"
|
||||
name="industry"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label
|
||||
:for="`industry-${opt.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ opt.label }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="surveyData.industry === 'other'" class="mt-4 ml-8">
|
||||
<InputText
|
||||
v-model="surveyData.industryOther"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(2, activateCallback)"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!validStep3"
|
||||
class="h-10 flex-1 border-none text-white"
|
||||
@click="goTo(4, activateCallback)"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
|
||||
<StepPanel
|
||||
v-slot="{ activateCallback }"
|
||||
value="4"
|
||||
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-8 block text-lg font-medium">{{
|
||||
t('cloudSurvey_steps_making')
|
||||
}}</label>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="opt in makingOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="surveyData.making"
|
||||
:input-id="`making-${opt.value}`"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label
|
||||
:for="`making-${opt.value}`"
|
||||
class="cursor-pointer text-sm"
|
||||
>{{ opt.label }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="flex-1 text-white"
|
||||
@click="goTo(3, activateCallback)"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!validStep4 || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
class="h-10 flex-1 border-none text-white"
|
||||
@click="onSubmitSurvey"
|
||||
>
|
||||
{{ $t('g.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
</StepPanels>
|
||||
</Stepper>
|
||||
<div class="flex h-[700px] max-h-[85vh] w-[320px] max-w-[90vw] flex-col">
|
||||
<DynamicSurveyForm
|
||||
:key="activeSurvey.version"
|
||||
:survey="activeSurvey"
|
||||
:is-submitting="isSubmitting"
|
||||
@submit="onSubmitSurvey"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import StepPanel from 'primevue/steppanel'
|
||||
import StepPanels from 'primevue/steppanels'
|
||||
import Stepper from 'primevue/stepper'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
getSurveyCompletedStatus,
|
||||
submitSurvey
|
||||
} from '@/platform/cloud/onboarding/auth'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { t } = useI18n()
|
||||
import DynamicSurveyForm from './survey/DynamicSurveyForm.vue'
|
||||
import { defaultOnboardingSurvey } from './survey/defaultSurveySchema'
|
||||
|
||||
const router = useRouter()
|
||||
const { flags } = useFeatureFlags()
|
||||
const onboardingSurveyEnabled = computed(() => flags.onboardingSurveyEnabled)
|
||||
|
||||
// Check if survey is already completed on mount
|
||||
const activeSurvey = computed(
|
||||
() => remoteConfig.value.onboarding_survey ?? defaultOnboardingSurvey
|
||||
)
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!onboardingSurveyEnabled.value) {
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
@@ -254,156 +43,31 @@ onMounted(async () => {
|
||||
try {
|
||||
const surveyCompleted = await getSurveyCompletedStatus()
|
||||
if (surveyCompleted) {
|
||||
// User already completed survey, return to onboarding flow
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
} else {
|
||||
// Track survey opened event
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('opened')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('opened')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check survey status:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const activeStep = ref(1)
|
||||
const totalSteps = 4
|
||||
const progressPercent = computed(() =>
|
||||
Math.max(20, Math.min(100, ((activeStep.value - 1) / (totalSteps - 1)) * 100))
|
||||
)
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const surveyData = ref({
|
||||
familiarity: '',
|
||||
useCase: '',
|
||||
useCaseOther: '',
|
||||
industry: '',
|
||||
industryOther: '',
|
||||
making: [] as string[]
|
||||
})
|
||||
|
||||
// Options
|
||||
const familiarityOptions = [
|
||||
{ label: 'New to ComfyUI (never used it before)', value: 'new' },
|
||||
{ label: 'Just getting started (following tutorials)', value: 'starting' },
|
||||
{ label: 'Comfortable with basics', value: 'basics' },
|
||||
{ label: 'Advanced user (custom workflows)', value: 'advanced' },
|
||||
{ label: 'Expert (help others)', value: 'expert' }
|
||||
]
|
||||
|
||||
const purposeOptions = [
|
||||
{ label: 'Personal projects/hobby', value: 'personal' },
|
||||
{
|
||||
label: 'Community contributions (nodes, workflows, etc.)',
|
||||
value: 'community'
|
||||
},
|
||||
{ label: 'Client work (freelance)', value: 'client' },
|
||||
{ label: 'My own workplace (in-house)', value: 'inhouse' },
|
||||
{ label: 'Academic research', value: 'research' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
|
||||
const industryOptions = [
|
||||
{ label: 'Film, TV, & animation', value: 'film_tv_animation' },
|
||||
{ label: 'Gaming', value: 'gaming' },
|
||||
{ label: 'Marketing & advertising', value: 'marketing' },
|
||||
{ label: 'Architecture', value: 'architecture' },
|
||||
{ label: 'Product & graphic design', value: 'product_design' },
|
||||
{ label: 'Fine art & illustration', value: 'fine_art' },
|
||||
{ label: 'Software & technology', value: 'software' },
|
||||
{ label: 'Education', value: 'education' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
|
||||
const makingOptions = [
|
||||
{ label: 'Images', value: 'images' },
|
||||
{ label: 'Video & animation', value: 'video' },
|
||||
{ label: '3D assets', value: '3d' },
|
||||
{ label: 'Audio/music', value: 'audio' },
|
||||
{ label: 'Custom nodes & workflows', value: 'custom_nodes' }
|
||||
]
|
||||
|
||||
// Validation per step
|
||||
const validStep1 = computed(() => !!surveyData.value.familiarity)
|
||||
const validStep2 = computed(() => {
|
||||
if (!surveyData.value.useCase) return false
|
||||
if (surveyData.value.useCase === 'other') {
|
||||
return !!surveyData.value.useCaseOther?.trim()
|
||||
const onSubmitSurvey = async (payload: Record<string, unknown>) => {
|
||||
if (!onboardingSurveyEnabled.value) {
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
return
|
||||
}
|
||||
return true
|
||||
})
|
||||
const validStep3 = computed(() => {
|
||||
if (!surveyData.value.industry) return false
|
||||
if (surveyData.value.industry === 'other') {
|
||||
return !!surveyData.value.industryOther?.trim()
|
||||
}
|
||||
return true
|
||||
})
|
||||
const validStep4 = computed(() => surveyData.value.making.length > 0)
|
||||
|
||||
const changeActiveStep = (step: number) => {
|
||||
activeStep.value = step
|
||||
}
|
||||
|
||||
const goTo = (step: number, activate: (val: string | number) => void) => {
|
||||
// keep Stepper panel and progress bar in sync; Stepper values are strings
|
||||
changeActiveStep(step)
|
||||
activate(String(step))
|
||||
}
|
||||
|
||||
// Submit
|
||||
const onSubmitSurvey = async () => {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
if (!onboardingSurveyEnabled.value) {
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
return
|
||||
}
|
||||
isSubmitting.value = true
|
||||
// prepare payload with consistent structure
|
||||
const payload = {
|
||||
familiarity: surveyData.value.familiarity,
|
||||
useCase:
|
||||
surveyData.value.useCase === 'other'
|
||||
? surveyData.value.useCaseOther?.trim() || 'other'
|
||||
: surveyData.value.useCase,
|
||||
industry:
|
||||
surveyData.value.industry === 'other'
|
||||
? surveyData.value.industryOther?.trim() || 'other'
|
||||
: surveyData.value.industry,
|
||||
making: surveyData.value.making
|
||||
}
|
||||
|
||||
await submitSurvey(payload)
|
||||
|
||||
// Track survey submitted event with responses
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('submitted', {
|
||||
industry: payload.industry,
|
||||
useCase: payload.useCase,
|
||||
familiarity: payload.familiarity,
|
||||
making: payload.making
|
||||
})
|
||||
useTelemetry()?.trackSurvey('submitted', payload)
|
||||
}
|
||||
|
||||
await router.push({ name: 'cloud-user-check' })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-progressbar .p-progressbar-value) {
|
||||
background-color: #f0ff41 !important;
|
||||
}
|
||||
:deep(.p-radiobutton-checked .p-radiobutton-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
}
|
||||
:deep(.p-checkbox-checked .p-checkbox-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
135
src/platform/cloud/onboarding/survey/DynamicSurveyField.vue
Normal file
135
src/platform/cloud/onboarding/survey/DynamicSurveyField.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<fieldset
|
||||
v-if="field.type !== 'text'"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
class="flex flex-col gap-3 border-0 p-0"
|
||||
>
|
||||
<legend class="block text-lg font-medium text-base-foreground">
|
||||
{{ resolvedLabel }}
|
||||
</legend>
|
||||
<ToggleGroup
|
||||
v-if="field.type === 'single'"
|
||||
:model-value="(modelValue as string) ?? ''"
|
||||
type="single"
|
||||
class="flex flex-col gap-2"
|
||||
@update:model-value="onSingleChange"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
v-for="option in field.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="h-auto justify-start rounded-md px-4 py-3 text-left whitespace-normal"
|
||||
>
|
||||
{{ option.label }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
v-else
|
||||
:model-value="(modelValue as string[]) ?? []"
|
||||
type="multiple"
|
||||
class="flex flex-col gap-2"
|
||||
@update:model-value="onMultiChange"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
v-for="option in field.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="h-auto justify-start rounded-md px-4 py-3 text-left whitespace-normal"
|
||||
>
|
||||
{{ option.label }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Input
|
||||
v-if="field.allowOther && field.otherFieldId && modelValue === 'other'"
|
||||
:model-value="(otherValue as string) ?? ''"
|
||||
:placeholder="
|
||||
$t(
|
||||
`cloudOnboarding.survey.options.${field.id}.otherPlaceholder`,
|
||||
$t('cloudOnboarding.survey.otherPlaceholder')
|
||||
)
|
||||
"
|
||||
class="ml-1"
|
||||
@update:model-value="onOtherChange"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
|
||||
</fieldset>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<label
|
||||
:for="controlId"
|
||||
class="block text-lg font-medium text-base-foreground"
|
||||
>
|
||||
{{ resolvedLabel }}
|
||||
</label>
|
||||
<Input
|
||||
:id="controlId"
|
||||
:model-value="(modelValue as string) ?? ''"
|
||||
:placeholder="field.placeholder"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
@update:model-value="onTextChange"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
|
||||
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
|
||||
import type { OnboardingSurveyField } from '@/platform/remoteConfig/types'
|
||||
|
||||
type RekaSelectionValue =
|
||||
| string
|
||||
| number
|
||||
| bigint
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined
|
||||
|
||||
const {
|
||||
field,
|
||||
modelValue,
|
||||
otherValue,
|
||||
errorMessage = ''
|
||||
} = defineProps<{
|
||||
field: OnboardingSurveyField
|
||||
modelValue: string | string[] | undefined
|
||||
otherValue?: string
|
||||
errorMessage?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | string[]]
|
||||
'update:otherValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const controlId = useId()
|
||||
|
||||
const resolvedLabel = (() => {
|
||||
if (field.labelKey && te(field.labelKey)) return t(field.labelKey)
|
||||
return field.label ?? field.id
|
||||
})()
|
||||
|
||||
const onSingleChange = (value: RekaSelectionValue | RekaSelectionValue[]) => {
|
||||
emit('update:modelValue', typeof value === 'string' ? value : '')
|
||||
}
|
||||
const onMultiChange = (value: RekaSelectionValue | RekaSelectionValue[]) => {
|
||||
if (!Array.isArray(value)) {
|
||||
emit('update:modelValue', [])
|
||||
return
|
||||
}
|
||||
emit(
|
||||
'update:modelValue',
|
||||
value.filter((v): v is string => typeof v === 'string')
|
||||
)
|
||||
}
|
||||
const onTextChange = (value: string | number | undefined) => {
|
||||
emit('update:modelValue', String(value ?? ''))
|
||||
}
|
||||
const onOtherChange = (value: string | number | undefined) => {
|
||||
emit('update:otherValue', String(value ?? ''))
|
||||
}
|
||||
</script>
|
||||
197
src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue
Normal file
197
src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<form class="flex size-full flex-col" @submit.prevent="onSubmit">
|
||||
<p v-if="introText" class="mb-4 text-sm text-muted">
|
||||
{{ introText }}
|
||||
</p>
|
||||
<div
|
||||
class="mb-8 h-2 w-full overflow-hidden rounded-full bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
class="h-full transition-[width] duration-300 ease-out"
|
||||
:style="{
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: '#f0ff41'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="currentField"
|
||||
:key="currentField.id"
|
||||
class="flex flex-1 flex-col gap-4 overflow-y-auto pr-1"
|
||||
>
|
||||
<DynamicSurveyField
|
||||
:field="currentField"
|
||||
:model-value="values[currentField.id]"
|
||||
:other-value="
|
||||
currentField.otherFieldId
|
||||
? (values[currentField.otherFieldId] as string)
|
||||
: undefined
|
||||
"
|
||||
:error-message="
|
||||
errors[currentField.id] ??
|
||||
(currentField.otherFieldId
|
||||
? errors[currentField.otherFieldId]
|
||||
: undefined)
|
||||
"
|
||||
@update:model-value="(value) => onFieldChange(currentField.id, value)"
|
||||
@update:other-value="
|
||||
(value) =>
|
||||
currentField.otherFieldId &&
|
||||
onFieldChange(currentField.otherFieldId, value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 pt-4">
|
||||
<Button
|
||||
v-if="!isFirst"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="h-10 flex-1"
|
||||
@click="goPrevious"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<span v-else />
|
||||
<Button
|
||||
v-if="!isLast"
|
||||
type="button"
|
||||
:disabled="!isCurrentValid"
|
||||
:class="cn('h-10', isFirst ? 'w-full' : 'flex-1')"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ $t('g.next') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
:disabled="!isCurrentValid || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
class="h-10 flex-1"
|
||||
>
|
||||
{{ $t('g.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
import DynamicSurveyField from './DynamicSurveyField.vue'
|
||||
import {
|
||||
buildInitialValues,
|
||||
buildSubmissionPayload,
|
||||
buildZodSchema,
|
||||
prepareSurvey,
|
||||
visibleFields
|
||||
} from './surveySchema'
|
||||
import type { SurveyValues } from './surveySchema'
|
||||
|
||||
const { survey } = defineProps<{
|
||||
survey: OnboardingSurvey
|
||||
isSubmitting?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: Record<string, unknown>]
|
||||
}>()
|
||||
|
||||
const { t, te } = useI18n()
|
||||
|
||||
const preparedSurvey = computed(() => prepareSurvey(survey))
|
||||
|
||||
const introText = computed(() => {
|
||||
const key = preparedSurvey.value.introKey
|
||||
if (!key) return ''
|
||||
return te(key) ? t(key) : ''
|
||||
})
|
||||
|
||||
const liveValues = ref<SurveyValues>(buildInitialValues(preparedSurvey.value))
|
||||
|
||||
const validationSchema = computed(() =>
|
||||
toTypedSchema(buildZodSchema(preparedSurvey.value, liveValues.value))
|
||||
)
|
||||
|
||||
const { values, errors, setFieldValue, validate, resetForm } =
|
||||
useForm<SurveyValues>({
|
||||
initialValues: liveValues.value,
|
||||
validationSchema
|
||||
})
|
||||
|
||||
watch(
|
||||
() => survey,
|
||||
() => {
|
||||
const fresh = buildInitialValues(preparedSurvey.value)
|
||||
liveValues.value = { ...fresh }
|
||||
resetForm({ values: fresh })
|
||||
stepIndex.value = 0
|
||||
}
|
||||
)
|
||||
|
||||
const visible = computed(() =>
|
||||
visibleFields(preparedSurvey.value, values as SurveyValues)
|
||||
)
|
||||
const stepIndex = ref(0)
|
||||
|
||||
const currentField = computed(() => visible.value[stepIndex.value])
|
||||
const isFirst = computed(() => stepIndex.value === 0)
|
||||
const isLast = computed(() => stepIndex.value === visible.value.length - 1)
|
||||
|
||||
const totalSteps = computed(() => Math.max(visible.value.length, 1))
|
||||
const progressPercent = computed(() =>
|
||||
Math.max(
|
||||
100 / totalSteps.value,
|
||||
((stepIndex.value + 1) / totalSteps.value) * 100
|
||||
)
|
||||
)
|
||||
|
||||
const isCurrentValid = computed(() => {
|
||||
const field = currentField.value
|
||||
if (!field) return false
|
||||
const value = values[field.id]
|
||||
if (field.type === 'multi') {
|
||||
return Array.isArray(value) && value.length > 0
|
||||
}
|
||||
if (typeof value !== 'string' || value.length === 0) return false
|
||||
if (field.allowOther && field.otherFieldId && value === 'other') {
|
||||
const other = values[field.otherFieldId]
|
||||
return typeof other === 'string' && other.trim().length > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const onFieldChange = (id: string, value: string | string[]) => {
|
||||
setFieldValue(id, value)
|
||||
liveValues.value = { ...liveValues.value, [id]: value }
|
||||
if (stepIndex.value > visible.value.length - 1) {
|
||||
stepIndex.value = Math.max(0, visible.value.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (stepIndex.value < visible.value.length - 1) stepIndex.value += 1
|
||||
}
|
||||
const goPrevious = () => {
|
||||
if (stepIndex.value > 0) stepIndex.value -= 1
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
const result = await validate()
|
||||
if (!result.valid) return
|
||||
emit(
|
||||
'submit',
|
||||
buildSubmissionPayload(preparedSurvey.value, values as SurveyValues)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
126
src/platform/cloud/onboarding/survey/defaultSurveySchema.ts
Normal file
126
src/platform/cloud/onboarding/survey/defaultSurveySchema.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
export const defaultOnboardingSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
introKey: 'cloudOnboarding.survey.intro',
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_usage',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'personal', label: 'Personal use' },
|
||||
{ value: 'work', label: 'Work' },
|
||||
{ value: 'education', label: 'Education (student or educator)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'familiarity',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_familiarity',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'new', label: 'Never used it' },
|
||||
{ value: 'starting', label: 'Following tutorials' },
|
||||
{ value: 'basics', label: 'Comfortable with basics' },
|
||||
{ value: 'advanced', label: 'Build and edit workflows' },
|
||||
{ value: 'expert', label: 'Expert — I help others' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_role',
|
||||
required: true,
|
||||
randomize: true,
|
||||
showWhen: { field: 'usage', equals: ['work', 'education'] },
|
||||
options: [
|
||||
{ value: 'creative_technologist', label: 'Creative Technologist' },
|
||||
{ value: 'creative_director', label: 'Creative Director' },
|
||||
{ value: 'ai_researcher', label: 'AI Researcher' },
|
||||
{ value: 'concept_artist', label: 'Concept Artist / Illustrator' },
|
||||
{ value: 'pipeline_td', label: 'Pipeline TD / Technical Artist' },
|
||||
{ value: 'producer', label: 'Producer' },
|
||||
{ value: 'engineer', label: 'Engineer' },
|
||||
{ value: 'student', label: 'Student' },
|
||||
{ value: 'leadership', label: 'Leadership' },
|
||||
{ value: 'content_creator', label: 'Content Creator' },
|
||||
{ value: 'educator', label: 'Educator' },
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'teamSize',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_teamSize',
|
||||
required: true,
|
||||
showWhen: { field: 'usage', equals: 'work' },
|
||||
options: [
|
||||
{ value: 'solo', label: 'Just me' },
|
||||
{ value: 'small', label: '2–5' },
|
||||
{ value: 'studio', label: '6–20' },
|
||||
{ value: 'midsize', label: '21–100' },
|
||||
{ value: 'enterprise', label: '100+' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'industry',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_industry',
|
||||
required: true,
|
||||
randomize: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'industryOther',
|
||||
showWhen: { field: 'usage', equals: 'work' },
|
||||
options: [
|
||||
{ value: 'film_tv', label: 'Film, TV & animation' },
|
||||
{ value: 'vfx_post', label: 'VFX & post-production' },
|
||||
{ value: 'advertising', label: 'Advertising & marketing' },
|
||||
{ value: 'gaming', label: 'Gaming' },
|
||||
{ value: 'fashion', label: 'Fashion' },
|
||||
{
|
||||
value: 'design',
|
||||
label: 'Design (product / graphic / architectural / industrial)'
|
||||
},
|
||||
{ value: 'software', label: 'Software / AI / tech' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
labelKey: 'cloudSurvey_steps_making',
|
||||
required: true,
|
||||
randomize: true,
|
||||
options: [
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'images', label: 'Images' },
|
||||
{ value: '3d', label: '3D assets' },
|
||||
{ value: 'custom_nodes', label: 'Custom Nodes' },
|
||||
{ value: 'audio', label: 'Audio / music' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
type: 'single',
|
||||
labelKey: 'cloudSurvey_steps_source',
|
||||
required: true,
|
||||
randomize: true,
|
||||
options: [
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'reddit', label: 'Reddit' },
|
||||
{ value: 'twitter', label: 'Twitter / X' },
|
||||
{ value: 'instagram', label: 'Instagram' },
|
||||
{ value: 'friend', label: 'Friend or colleague' },
|
||||
{ value: 'search', label: 'Google / search' },
|
||||
{ value: 'newsletter', label: 'Newsletter or blog' },
|
||||
{ value: 'conference', label: 'Conference or event' },
|
||||
{ value: 'discord', label: 'Discord / community' },
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
225
src/platform/cloud/onboarding/survey/surveySchema.test.ts
Normal file
225
src/platform/cloud/onboarding/survey/surveySchema.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
|
||||
|
||||
import {
|
||||
buildInitialValues,
|
||||
buildSubmissionPayload,
|
||||
buildZodSchema,
|
||||
prepareSurvey,
|
||||
visibleFields
|
||||
} from './surveySchema'
|
||||
|
||||
const baseSurvey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'usage',
|
||||
type: 'single',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'work', label: 'Work' },
|
||||
{ value: 'personal', label: 'Personal' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
type: 'single',
|
||||
required: true,
|
||||
showWhen: { field: 'usage', equals: 'work' },
|
||||
options: [{ value: 'engineer', label: 'Engineer' }]
|
||||
},
|
||||
{
|
||||
id: 'industry',
|
||||
type: 'single',
|
||||
required: true,
|
||||
allowOther: true,
|
||||
otherFieldId: 'industryOther',
|
||||
showWhen: { field: 'usage', equals: 'work' },
|
||||
options: [
|
||||
{ value: 'tech', label: 'Tech' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'images', label: 'Images' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('visibleFields', () => {
|
||||
it('hides fields when showWhen does not match', () => {
|
||||
const visible = visibleFields(baseSurvey, { usage: 'personal' })
|
||||
expect(visible.map((f) => f.id)).toEqual(['usage', 'making'])
|
||||
})
|
||||
|
||||
it('shows gated fields when showWhen matches', () => {
|
||||
const visible = visibleFields(baseSurvey, { usage: 'work' })
|
||||
expect(visible.map((f) => f.id)).toEqual([
|
||||
'usage',
|
||||
'role',
|
||||
'industry',
|
||||
'making'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats array equals as membership', () => {
|
||||
const survey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'role',
|
||||
type: 'single',
|
||||
showWhen: { field: 'usage', equals: ['work', 'education'] }
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(visibleFields(survey, { usage: 'education' })).toHaveLength(1)
|
||||
expect(visibleFields(survey, { usage: 'personal' })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('intersects multi-select source values with expected set', () => {
|
||||
const survey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'follow_up',
|
||||
type: 'single',
|
||||
showWhen: { field: 'making', equals: ['video', '3d'] }
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(visibleFields(survey, { making: [] })).toHaveLength(0)
|
||||
expect(visibleFields(survey, { making: ['images'] })).toHaveLength(0)
|
||||
expect(visibleFields(survey, { making: ['images', 'video'] })).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInitialValues', () => {
|
||||
it('initializes single fields to empty string and multi to empty array', () => {
|
||||
expect(buildInitialValues(baseSurvey)).toMatchObject({
|
||||
usage: '',
|
||||
role: '',
|
||||
industry: '',
|
||||
industryOther: '',
|
||||
making: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildZodSchema', () => {
|
||||
it('omits hidden fields from validation', () => {
|
||||
const schema = buildZodSchema(baseSurvey, { usage: 'personal' })
|
||||
const result = schema.safeParse({ usage: 'personal', making: ['video'] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('requires gated fields once visible', () => {
|
||||
const schema = buildZodSchema(baseSurvey, { usage: 'work' })
|
||||
const result = schema.safeParse({ usage: 'work', making: ['video'] })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires "other" detail when option is selected', () => {
|
||||
const schema = buildZodSchema(baseSurvey, {
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
making: ['video']
|
||||
})
|
||||
expect(
|
||||
schema.safeParse({
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
industryOther: '',
|
||||
making: ['video']
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
industryOther: 'Aerospace',
|
||||
making: ['video']
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSubmissionPayload', () => {
|
||||
it('clears hidden fields and prefers free-text "other" detail', () => {
|
||||
const payload = buildSubmissionPayload(baseSurvey, {
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
industryOther: ' Aerospace ',
|
||||
making: ['video']
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'Aerospace',
|
||||
making: ['video']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to "other" when free-text is empty', () => {
|
||||
const payload = buildSubmissionPayload(baseSurvey, {
|
||||
usage: 'work',
|
||||
role: 'engineer',
|
||||
industry: 'other',
|
||||
industryOther: '',
|
||||
making: ['video']
|
||||
})
|
||||
expect(payload.industry).toBe('other')
|
||||
})
|
||||
|
||||
it('zeroes out fields hidden by showWhen', () => {
|
||||
const payload = buildSubmissionPayload(baseSurvey, {
|
||||
usage: 'personal',
|
||||
role: 'engineer',
|
||||
making: ['video']
|
||||
})
|
||||
expect(payload).toMatchObject({
|
||||
usage: 'personal',
|
||||
role: '',
|
||||
industry: '',
|
||||
making: ['video']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareSurvey', () => {
|
||||
it('preserves option contents but may reorder when randomize=true', () => {
|
||||
const survey: OnboardingSurvey = {
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
id: 'making',
|
||||
type: 'multi',
|
||||
randomize: true,
|
||||
options: [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
const prepared = prepareSurvey(survey)
|
||||
const values = prepared.fields[0]!.options!.map((o) => o.value)
|
||||
expect(values).toContain('a')
|
||||
expect(values).toContain('b')
|
||||
expect(values[values.length - 1]).toBe('other')
|
||||
})
|
||||
})
|
||||
120
src/platform/cloud/onboarding/survey/surveySchema.ts
Normal file
120
src/platform/cloud/onboarding/survey/surveySchema.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { shuffle } from 'es-toolkit'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type {
|
||||
OnboardingSurvey,
|
||||
OnboardingSurveyField,
|
||||
OnboardingSurveyFieldCondition
|
||||
} from '@/platform/remoteConfig/types'
|
||||
|
||||
export type SurveyValues = Record<string, string | string[] | undefined>
|
||||
|
||||
const hasNonEmptyValue = (current: string | string[] | undefined): boolean => {
|
||||
if (current === undefined || current === '') return false
|
||||
if (Array.isArray(current)) return current.length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
const conditionMatches = (
|
||||
condition: OnboardingSurveyFieldCondition | undefined,
|
||||
values: SurveyValues
|
||||
): boolean => {
|
||||
if (!condition) return true
|
||||
const current = values[condition.field]
|
||||
if (!hasNonEmptyValue(current)) return false
|
||||
const expected = condition.equals
|
||||
if (expected === undefined) return true
|
||||
const expectedSet = Array.isArray(expected) ? expected : [expected]
|
||||
if (Array.isArray(current)) {
|
||||
return current.some((v) => expectedSet.includes(v))
|
||||
}
|
||||
return typeof current === 'string' && expectedSet.includes(current)
|
||||
}
|
||||
|
||||
export const visibleFields = (
|
||||
survey: OnboardingSurvey,
|
||||
values: SurveyValues
|
||||
): OnboardingSurveyField[] =>
|
||||
survey.fields.filter((field) => conditionMatches(field.showWhen, values))
|
||||
|
||||
const randomizeOptions = (field: OnboardingSurveyField) => {
|
||||
if (!field.randomize || !field.options) return field
|
||||
const other = field.options.find((opt) => opt.value === 'other')
|
||||
const rest = field.options.filter((opt) => opt.value !== 'other')
|
||||
return {
|
||||
...field,
|
||||
options: other ? [...shuffle(rest), other] : shuffle(rest)
|
||||
}
|
||||
}
|
||||
|
||||
export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({
|
||||
...survey,
|
||||
fields: survey.fields.map(randomizeOptions)
|
||||
})
|
||||
|
||||
const fieldSchema = (field: OnboardingSurveyField) => {
|
||||
if (field.type === 'multi') {
|
||||
const arr = z.array(z.string())
|
||||
return field.required ? arr.min(1) : arr.optional()
|
||||
}
|
||||
if (field.required) return z.string().min(1)
|
||||
return z.string().optional()
|
||||
}
|
||||
|
||||
export const buildZodSchema = (
|
||||
survey: OnboardingSurvey,
|
||||
values: SurveyValues
|
||||
) => {
|
||||
const shape: Record<string, z.ZodTypeAny> = {}
|
||||
for (const field of survey.fields) {
|
||||
if (!conditionMatches(field.showWhen, values)) continue
|
||||
shape[field.id] = fieldSchema(field)
|
||||
if (
|
||||
field.allowOther &&
|
||||
field.otherFieldId &&
|
||||
values[field.id] === 'other'
|
||||
) {
|
||||
shape[field.otherFieldId] = z.string().min(1)
|
||||
} else if (field.otherFieldId) {
|
||||
shape[field.otherFieldId] = z.string().optional()
|
||||
}
|
||||
}
|
||||
return z.object(shape)
|
||||
}
|
||||
|
||||
export const buildInitialValues = (survey: OnboardingSurvey): SurveyValues => {
|
||||
const initial: SurveyValues = {}
|
||||
for (const field of survey.fields) {
|
||||
initial[field.id] = field.type === 'multi' ? [] : ''
|
||||
if (field.otherFieldId) initial[field.otherFieldId] = ''
|
||||
}
|
||||
return initial
|
||||
}
|
||||
|
||||
export const buildSubmissionPayload = (
|
||||
survey: OnboardingSurvey,
|
||||
values: SurveyValues
|
||||
): Record<string, unknown> => {
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const field of survey.fields) {
|
||||
const visible = conditionMatches(field.showWhen, values)
|
||||
if (!visible) {
|
||||
payload[field.id] = field.type === 'multi' ? [] : ''
|
||||
continue
|
||||
}
|
||||
const value = values[field.id]
|
||||
const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined
|
||||
if (
|
||||
field.allowOther &&
|
||||
field.otherFieldId &&
|
||||
value === 'other' &&
|
||||
typeof otherRaw === 'string'
|
||||
) {
|
||||
const other = otherRaw.trim()
|
||||
payload[field.id] = other || 'other'
|
||||
} else {
|
||||
payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '')
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -23,6 +23,45 @@ type FirebaseRuntimeConfig = {
|
||||
measurementId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-driven onboarding survey schema.
|
||||
*
|
||||
* The backend ships the entire form definition so onboarding questions can
|
||||
* be tweaked without a frontend release. Field types map 1:1 to a component
|
||||
* in our internal UI library — see `DynamicSurveyField.vue`.
|
||||
*/
|
||||
export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
|
||||
|
||||
export type OnboardingSurveyOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type OnboardingSurveyFieldCondition = {
|
||||
field: string
|
||||
equals?: string | string[]
|
||||
}
|
||||
|
||||
export type OnboardingSurveyField = {
|
||||
id: string
|
||||
type: OnboardingSurveyFieldType
|
||||
labelKey?: string
|
||||
label?: string
|
||||
options?: OnboardingSurveyOption[]
|
||||
required?: boolean
|
||||
randomize?: boolean
|
||||
allowOther?: boolean
|
||||
otherFieldId?: string
|
||||
placeholder?: string
|
||||
showWhen?: OnboardingSurveyFieldCondition
|
||||
}
|
||||
|
||||
export type OnboardingSurvey = {
|
||||
version: number
|
||||
introKey?: string
|
||||
fields: OnboardingSurveyField[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote configuration type
|
||||
* Configuration fetched from the server at runtime
|
||||
@@ -45,6 +84,7 @@ export type RemoteConfig = {
|
||||
asset_rename_enabled?: boolean
|
||||
private_models_enabled?: boolean
|
||||
onboarding_survey_enabled?: boolean
|
||||
onboarding_survey?: OnboardingSurvey
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface SurveyResponses {
|
||||
industry?: string
|
||||
useCase?: string
|
||||
making?: string[]
|
||||
role?: string
|
||||
workflowRelationship?: string
|
||||
teamSize?: string
|
||||
source?: string
|
||||
usage?: string
|
||||
}
|
||||
|
||||
export interface SurveyResponsesNormalized extends SurveyResponses {
|
||||
|
||||
Reference in New Issue
Block a user