mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-31 17:49:14 +00:00
Compare commits
25 Commits
AddTemplat
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160c615bc4 | ||
|
|
eb61c0bb4d | ||
|
|
4689581674 | ||
|
|
e4b456bb2c | ||
|
|
482ad401d4 | ||
|
|
9082f6bc3c | ||
|
|
7c34a0e0f6 | ||
|
|
8c3738fb77 | ||
|
|
aee207f16c | ||
|
|
164379bf4b | ||
|
|
9108b7535a | ||
|
|
2ff14fadc2 | ||
|
|
87341f2c6e | ||
|
|
02a38110cd | ||
|
|
09989b7aff | ||
|
|
0f455c73bb | ||
|
|
a94574d379 | ||
|
|
78fe639540 | ||
|
|
e333ad459e | ||
|
|
aab09b5f99 | ||
|
|
1f0ca18737 | ||
|
|
3b5649232d | ||
|
|
724827d8cc | ||
|
|
be70f6c1e6 | ||
|
|
514425b560 |
10
.github/workflows/release-draft-create.yaml
vendored
10
.github/workflows/release-draft-create.yaml
vendored
@@ -53,7 +53,13 @@ jobs:
|
||||
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
# Desktop-specific release artifact with desktop distribution flags.
|
||||
DISTRIBUTION=desktop pnpm build
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -62,6 +68,7 @@ jobs:
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
|
||||
draft_release:
|
||||
needs: build
|
||||
@@ -79,6 +86,7 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
target_commitish: ${{ github.event.pull_request.base.ref }}
|
||||
make_latest: >-
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
"block-no-empty": true,
|
||||
"no-descending-specificity": null,
|
||||
"no-duplicate-at-import-rules": true,
|
||||
"at-rule-disallowed-list": ["apply"],
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": [
|
||||
"tailwind",
|
||||
"apply",
|
||||
"layer",
|
||||
"config",
|
||||
"theme",
|
||||
|
||||
@@ -61,8 +61,7 @@
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite build --config vite.config.mts"
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
|
||||
@@ -4,3 +4,39 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.p-button-secondary {
|
||||
border: none;
|
||||
background-color: var(--color-neutral-600);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
background-color: var(--color-neutral-550);
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
background-color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
transition: opacity var(--default-transition-duration);
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.task-div .p-card:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -101,13 +101,15 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
|
||||
* required to style those generated nodes.
|
||||
*/
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
option-value="value"
|
||||
:disabled="isSwitching"
|
||||
:pt="dropdownPt"
|
||||
:size="props.size"
|
||||
:size="size"
|
||||
class="language-selector"
|
||||
@change="onLocaleChange"
|
||||
>
|
||||
@@ -36,16 +36,10 @@ import { i18n, loadLocale, st } from '@/i18n'
|
||||
type VariantKey = 'dark' | 'light'
|
||||
type SizeKey = 'small' | 'large'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>(),
|
||||
{
|
||||
variant: 'dark',
|
||||
size: 'small'
|
||||
}
|
||||
)
|
||||
const { variant = 'dark', size = 'small' } = defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>()
|
||||
|
||||
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
@@ -104,10 +98,8 @@ const VARIANT_PRESETS = {
|
||||
const selectedLocale = ref<string>(i18n.global.locale.value)
|
||||
const isSwitching = ref(false)
|
||||
|
||||
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
|
||||
const variantPreset = computed(
|
||||
() => VARIANT_PRESETS[props.variant as VariantKey]
|
||||
)
|
||||
const sizePreset = computed(() => SIZE_PRESETS[size])
|
||||
const variantPreset = computed(() => VARIANT_PRESETS[variant])
|
||||
|
||||
const dropdownPt = computed(() => ({
|
||||
root: {
|
||||
@@ -195,13 +187,17 @@ async function onLocaleChange(event: SelectChangeEvent) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-dropdown-panel .p-dropdown-item) {
|
||||
@apply transition-colors;
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: var(--default-transition-duration);
|
||||
}
|
||||
|
||||
:deep(.p-dropdown) {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-neutral-900),
|
||||
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -269,26 +269,43 @@ const onFocus = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
@apply px-12;
|
||||
padding-inline: calc(var(--spacing) * 12);
|
||||
|
||||
.p-accordionpanel {
|
||||
@apply border-0 bg-transparent;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-radius 0.5s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-700) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
border-top-right-radius: var(--radius-xl);
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.p-accordionheader-toggle-icon {
|
||||
@@ -299,11 +316,24 @@ const onFocus = async () => {
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
border: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: var(--radius-xl);
|
||||
border-bottom-left-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
background-color: transparent;
|
||||
padding-top: calc(var(--spacing) * 3);
|
||||
padding-right: calc(var(--spacing) * 5);
|
||||
padding-bottom: calc(var(--spacing) * 5);
|
||||
padding-left: calc(var(--spacing) * 5);
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
class="task-div relative grid min-h-52 max-w-48"
|
||||
:class="{ 'opacity-75': isLoading }"
|
||||
:class="
|
||||
cn(
|
||||
'task-div group/task-card relative grid min-h-52 max-w-48',
|
||||
isLoading && 'opacity-75'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Card
|
||||
class="relative h-full max-w-48 overflow-hidden"
|
||||
:class="{ 'opacity-65': runner.state !== 'error' }"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-full max-w-48 overflow-hidden',
|
||||
runner.state !== 'error' && 'opacity-65'
|
||||
)
|
||||
"
|
||||
:pt="cardPt"
|
||||
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
|
||||
>
|
||||
<template #header>
|
||||
@@ -43,7 +52,7 @@
|
||||
|
||||
<i
|
||||
v-if="!isLoading && runner.state === 'OK'"
|
||||
class="task-card-ok pi pi-check"
|
||||
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,6 +64,7 @@ import { computed } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -83,51 +93,9 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
|
||||
const cardPt = {
|
||||
header: { class: 'z-0' },
|
||||
body: { class: 'z-[1] grow justify-between' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.task-card-ok {
|
||||
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
|
||||
|
||||
font-size: 4rem;
|
||||
text-shadow: 0.25rem 0 0.5rem black;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.p-card {
|
||||
@apply transition-opacity;
|
||||
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
|
||||
&.opacity-65 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-card-header) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
:deep(.p-card-body) {
|
||||
z-index: 1;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-div {
|
||||
> i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover > i {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
|
||||
<h1 class="font-inter font-semibold text-xl m-0 italic">
|
||||
{{ t(`desktopDialogs.${id}.title`, title) }}
|
||||
{{ $t(`desktopDialogs.${id}.title`, title) }}
|
||||
</h1>
|
||||
<p class="whitespace-pre-wrap">
|
||||
{{ t(`desktopDialogs.${id}.message`, message) }}
|
||||
{{ $t(`desktopDialogs.${id}.message`, message) }}
|
||||
</p>
|
||||
<div class="flex w-full gap-2">
|
||||
<Button
|
||||
@@ -12,7 +12,7 @@
|
||||
:key="button.label"
|
||||
class="rounded-lg first:mr-auto"
|
||||
:label="
|
||||
t(
|
||||
$t(
|
||||
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
|
||||
button.label
|
||||
)
|
||||
@@ -31,7 +31,6 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { getDialog } from '@/constants/desktopDialogs'
|
||||
import type { DialogAction } from '@/constants/desktopDialogs'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -41,31 +40,3 @@ const handleButtonClick = async (button: DialogAction) => {
|
||||
await electronAPI().Dialog.clickButton(button.returnValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.p-button-secondary {
|
||||
@apply text-white border-none bg-neutral-600;
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
@apply bg-neutral-550;
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
@apply bg-neutral-500;
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
@apply bg-coral-red-600;
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
@apply bg-coral-red-500;
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
@apply bg-coral-red-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="relative m-8 text-center">
|
||||
<!-- Header -->
|
||||
<h1 class="download-bg pi-download text-4xl font-bold">
|
||||
{{ t('desktopUpdate.title') }}
|
||||
{{ $t('desktopUpdate.title') }}
|
||||
</h1>
|
||||
|
||||
<div class="m-8">
|
||||
<span>{{ t('desktopUpdate.description') }}</span>
|
||||
<span>{{ $t('desktopUpdate.description') }}</span>
|
||||
</div>
|
||||
|
||||
<ProgressSpinner class="m-8 w-48 h-48" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<Button
|
||||
style="transform: translateX(-50%)"
|
||||
class="fixed bottom-0 left-1/2 my-8"
|
||||
:label="t('maintenance.consoleLogs')"
|
||||
:label="$t('maintenance.consoleLogs')"
|
||||
icon="pi pi-desktop"
|
||||
icon-pos="left"
|
||||
severity="secondary"
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
<TerminalOutputDrawer
|
||||
v-model="terminalVisible"
|
||||
:header="t('g.terminal')"
|
||||
:default-message="t('desktopUpdate.terminalDefaultMessage')"
|
||||
:header="$t('g.terminal')"
|
||||
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +44,6 @@ import Toast from 'primevue/toast'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
@@ -61,10 +60,10 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.download-bg::before {
|
||||
@apply m-0 absolute text-muted;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: 2rem;
|
||||
|
||||
@@ -183,33 +183,37 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-steppanel) {
|
||||
@apply mt-8 flex justify-center bg-transparent;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Remove default padding/margin from StepPanels to make scrollbar flush */
|
||||
:deep(.p-steppanels) {
|
||||
@apply p-0 m-0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Ensure StepPanel content container has no top/bottom padding */
|
||||
:deep(.p-steppanel-content) {
|
||||
@apply p-0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
|
||||
:deep(.p-steppanels::-webkit-scrollbar) {
|
||||
@apply w-4;
|
||||
width: calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-track) {
|
||||
@apply bg-transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
|
||||
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
|
||||
border: 4px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
@@ -129,6 +129,7 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
|
||||
const electron = electronAPI()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
|
||||
|
||||
@@ -220,14 +221,14 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-tag) {
|
||||
--p-tag-gap: 0.375rem;
|
||||
}
|
||||
|
||||
.backspan::before {
|
||||
@apply m-0 absolute text-muted;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: -2rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseViewTemplate>
|
||||
<div class="sad-container">
|
||||
<div class="sad-container grid items-center justify-evenly">
|
||||
<!-- Right side image -->
|
||||
<img
|
||||
class="sad-girl"
|
||||
@@ -79,10 +79,7 @@ const continueToInstall = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.sad-container {
|
||||
@apply grid items-center justify-evenly;
|
||||
grid-template-columns: 25rem 1fr;
|
||||
|
||||
& > * {
|
||||
|
||||
@@ -232,8 +232,6 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
/* Hide the xterm scrollbar completely */
|
||||
:deep(.p-terminal) .xterm-viewport {
|
||||
overflow: hidden !important;
|
||||
|
||||
@@ -44,6 +44,12 @@ export const TestIds = {
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
},
|
||||
selectionToolbox: {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
},
|
||||
widgets: {
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
@@ -74,6 +80,7 @@ export type TestIdValue =
|
||||
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
|
||||
@@ -104,15 +104,13 @@ test.describe('Missing models warning', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display a warning when missing models are found in node properties', async ({
|
||||
@@ -123,15 +121,13 @@ test.describe('Missing models warning', () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display a warning when no missing models are found', async ({
|
||||
@@ -172,8 +168,10 @@ test.describe('Missing models warning', () => {
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display warning when model metadata exists but widget values have changed', async ({
|
||||
@@ -186,8 +184,10 @@ test.describe('Missing models warning', () => {
|
||||
)
|
||||
|
||||
// The missing models warning should NOT appear
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
@@ -199,13 +199,15 @@ test.describe('Missing models warning', () => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
|
||||
const downloadButton = comfyPage.page.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await downloadButton.click()
|
||||
await downloadAllButton.click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||
@@ -229,13 +231,14 @@ test.describe('Missing models warning', () => {
|
||||
test('Should disable warning dialog when checkbox is checked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await checkbox.click()
|
||||
const changeSettingPromise = comfyPage.page.waitForRequest(
|
||||
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
await closeButton.click()
|
||||
await checkbox.click()
|
||||
await changeSettingPromise
|
||||
|
||||
await closeButton.click()
|
||||
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 135 KiB |
@@ -1,6 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
@@ -10,6 +12,17 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
const getColorPickerButton = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
|
||||
|
||||
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
|
||||
|
||||
const getColorPickerGroup = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
@@ -132,28 +145,24 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should be visible
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
await expect(colorPickerButton).toBeVisible()
|
||||
|
||||
// Click color picker button
|
||||
await colorPickerButton.click()
|
||||
|
||||
// Color picker dropdown should be visible
|
||||
const colorPickerDropdown = comfyPage.page.locator(
|
||||
'.color-picker-container'
|
||||
)
|
||||
await expect(colorPickerDropdown).toBeVisible()
|
||||
const colorPickerGroup = getColorPickerGroup(comfyPage)
|
||||
await expect(colorPickerGroup).toBeVisible()
|
||||
|
||||
// Select a color (e.g., blue)
|
||||
const blueColorOption = colorPickerDropdown.locator(
|
||||
'i[data-testid="blue"]'
|
||||
const blueColorOption = colorPickerGroup.getByTestId(
|
||||
TestIds.selectionToolbox.colorBlue
|
||||
)
|
||||
await blueColorOption.click()
|
||||
|
||||
// Dropdown should close after selection
|
||||
await expect(colorPickerDropdown).not.toBeVisible()
|
||||
await expect(colorPickerGroup).not.toBeVisible()
|
||||
|
||||
// Node should have the selected color class/style
|
||||
// Note: Exact verification method depends on how color is applied to nodes
|
||||
@@ -172,22 +181,21 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
|
||||
// Initially should show default color
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
|
||||
// Click color picker and select a color
|
||||
await colorPickerButton.click()
|
||||
const redColorOption = comfyPage.page.locator(
|
||||
'.color-picker-container i[data-testid="red"]'
|
||||
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
|
||||
TestIds.selectionToolbox.colorRed
|
||||
)
|
||||
await redColorOption.click()
|
||||
|
||||
// Button should now show the selected color
|
||||
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
|
||||
})
|
||||
|
||||
test('color picker shows mixed state for differently colored selections', async ({
|
||||
@@ -195,17 +203,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select first node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Select second node and color it differently
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="red"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorRed)
|
||||
.click()
|
||||
|
||||
// Select both nodes
|
||||
@@ -215,9 +223,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
])
|
||||
|
||||
// Color picker should show null/mixed state
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
})
|
||||
|
||||
@@ -226,9 +232,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// First color a node
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
// Clear selection
|
||||
@@ -238,10 +244,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should show the correct color
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
|
||||
})
|
||||
|
||||
test('colorization via color picker can be undone', async ({
|
||||
@@ -249,9 +253,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select a node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
// Undo the colorization
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 88 KiB |
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +20,16 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
await loadCheckpointNode.getByText('Load Checkpoint').click()
|
||||
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container')
|
||||
.locator('i[data-testid="blue"]')
|
||||
const colorPickerButton = comfyPage.page.getByTestId(
|
||||
TestIds.selectionToolbox.colorPickerButton
|
||||
)
|
||||
await colorPickerButton.click()
|
||||
|
||||
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
await colorPickerGroup
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
@@ -39,6 +39,10 @@ Prefer Vue native options when available:
|
||||
- Use inline Tailwind CSS only (no `<style>` blocks)
|
||||
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
|
||||
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
|
||||
- Exception: when third-party libraries render runtime DOM outside Vue templates
|
||||
(for example xterm internals inside PrimeVue terminal wrappers), scoped
|
||||
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
|
||||
exception is required.
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ export default {
|
||||
'tests-ui/**': () =>
|
||||
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||
|
||||
'./**/*.{css,vue}': (stagedFiles: string[]) => {
|
||||
const joinedPaths = toJoinedRelativePaths(stagedFiles)
|
||||
return [`pnpm exec stylelint --allow-empty-input ${joinedPaths}`]
|
||||
},
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
|
||||
@@ -22,12 +27,17 @@ export default {
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames: string[]) {
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
function toJoinedRelativePaths(fileNames: string[]) {
|
||||
const relativePaths = fileNames.map((f) =>
|
||||
path.relative(process.cwd(), f).replace(/\\/g, '/')
|
||||
)
|
||||
return relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.0",
|
||||
"version": "1.41.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,10 +29,10 @@
|
||||
"knip": "knip --cache",
|
||||
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
|
||||
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
|
||||
"lint:no-cache": "oxlint src --type-aware && eslint src",
|
||||
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "oxlint src --type-aware && eslint src --cache",
|
||||
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
:root {
|
||||
--fg-color: #000;
|
||||
--bg-color: #fff;
|
||||
--default-transition-duration: 0.1s;
|
||||
--comfy-menu-bg: #353535;
|
||||
--comfy-menu-secondary-bg: #292929;
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
|
||||
@@ -3952,7 +3952,7 @@ export interface components {
|
||||
* @description The subscription tier level
|
||||
* @enum {string}
|
||||
*/
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
/**
|
||||
* @description The subscription billing duration
|
||||
* @enum {string}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import zipdir from 'zip-dir'
|
||||
|
||||
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
|
||||
const sourceDir = process.argv[2] || './dist'
|
||||
const outputPath = process.argv[3] || './dist.zip'
|
||||
|
||||
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
|
||||
if (err) {
|
||||
console.error('Error zipping "dist" directory:', err)
|
||||
console.error(`Error zipping "${sourceDir}" directory:`, err)
|
||||
} else {
|
||||
console.log('Successfully zipped "dist" directory.')
|
||||
process.stdout.write(
|
||||
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition
|
||||
extractFilenameFromContentDisposition,
|
||||
openFileInNewTab
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
let mockIsCloud = false
|
||||
const { mockIsCloud } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
|
||||
}))
|
||||
|
||||
// Global stubs
|
||||
const createObjectURLSpy = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
@@ -26,7 +37,7 @@ describe('downloadUtil', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsCloud = false
|
||||
mockIsCloud.value = false
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||
@@ -154,7 +165,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('streams downloads via blob when running in cloud', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -173,6 +184,7 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -183,7 +195,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('logs an error when cloud fetch fails', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchMock.mockResolvedValue({
|
||||
@@ -197,14 +209,15 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve()
|
||||
await Promise.resolve() // let fetchAsBlob throw
|
||||
await Promise.resolve() // let .catch handler run
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -223,6 +236,7 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -231,7 +245,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -253,6 +267,7 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -260,7 +275,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud = true
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -278,6 +293,7 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -285,6 +301,99 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('openFileInNewTab', () => {
|
||||
let windowOpenSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens URL directly when not in cloud mode', async () => {
|
||||
mockIsCloud.value = false
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
expect(mockTab.location.href).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('revokes blob URL after timeout in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('closes blank tab and logs error when cloud fetch fails', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(mockTab.close).toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('revokes blob URL immediately if tab was closed by user', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
expect(mockTab.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
@@ -112,14 +114,23 @@ export function extractFilenameFromContentDisposition(
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadViaBlobFetch = async (
|
||||
/**
|
||||
* Fetch a URL and return its body as a Blob.
|
||||
* Shared by download and open-in-new-tab cloud paths.
|
||||
*/
|
||||
async function fetchAsBlob(url: string): Promise<Response> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function downloadViaBlobFetch(
|
||||
href: string,
|
||||
fallbackFilename: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
): Promise<void> {
|
||||
const response = await fetchAsBlob(href)
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
@@ -129,3 +140,44 @@ const downloadViaBlobFetch = async (
|
||||
const blob = await response.blob()
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file URL in a new browser tab.
|
||||
* On cloud, fetches the resource as a blob first to avoid GCS redirects
|
||||
* that would trigger an auto-download instead of displaying the file.
|
||||
*
|
||||
* Opens the tab synchronously to preserve the user-gesture context
|
||||
* (browsers block window.open after an await), then navigates it to
|
||||
* the blob URL once the fetch completes.
|
||||
*/
|
||||
export async function openFileInNewTab(url: string): Promise<void> {
|
||||
if (!isCloud) {
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
// Open immediately to preserve user-gesture activation.
|
||||
const tab = window.open('', '_blank')
|
||||
|
||||
try {
|
||||
const response = await fetchAsBlob(url)
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (tab && !tab.closed) {
|
||||
tab.location.href = blobUrl
|
||||
// Revoke after the tab has had time to load the blob.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
||||
} else {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
tab?.close()
|
||||
console.error('Failed to open image:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.errorOpenImage', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,13 +103,12 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -195,8 +195,6 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
@@ -205,7 +203,7 @@ onUpdated(() => {
|
||||
|
||||
.subgraph-breadcrumb,
|
||||
:deep(.p-breadcrumb) {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb) {
|
||||
@@ -214,7 +212,10 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
height: calc(var(--spacing) * 8);
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
@@ -236,7 +237,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
@apply rounded-lg;
|
||||
border-radius: var(--radius-lg);
|
||||
border-color: var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
@@ -270,18 +271,16 @@ onUpdated(() => {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
@apply hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item:nth-last-child(3),
|
||||
.p-breadcrumb-separator:nth-last-child(2),
|
||||
.p-breadcrumb-item:nth-last-child(1) {
|
||||
@apply flex;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,9 +78,7 @@ interface Props {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
const { item, isActive = false } = defineProps<Props>()
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
@@ -103,7 +101,7 @@ const rename = async (
|
||||
) => {
|
||||
if (newName && newName !== initialName) {
|
||||
// Synchronize the node titles with the new name
|
||||
props.item.updateTitle?.(newName)
|
||||
item.updateTitle?.(newName)
|
||||
|
||||
if (workflowStore.activeSubgraph) {
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
@@ -127,13 +125,13 @@ const rename = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
const isRoot = item.key === 'root'
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (hasMissingNodes.value && isRoot) {
|
||||
return t('breadcrumbsMenu.missingNodesWarning')
|
||||
}
|
||||
return props.item.label
|
||||
return item.label
|
||||
})
|
||||
|
||||
const startRename = async () => {
|
||||
@@ -145,7 +143,7 @@ const startRename = async () => {
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
itemLabel.value = item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
@@ -165,12 +163,12 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
if (event.detail === 1) {
|
||||
if (props.isActive) {
|
||||
if (isActive) {
|
||||
menu.value?.toggle(event)
|
||||
} else {
|
||||
props.item.command?.({ item: props.item, originalEvent: event })
|
||||
item.command?.({ item: item, originalEvent: event })
|
||||
}
|
||||
} else if (props.isActive && event.detail === 2) {
|
||||
} else if (isActive && event.detail === 2) {
|
||||
menu.value?.hide()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
@@ -180,7 +178,7 @@ const handleClick = (event: MouseEvent) => {
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
await rename(itemLabel.value, item.label as string)
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
@@ -188,19 +186,19 @@ const inputBlur = async (doRename: boolean) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
@apply overflow-hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-label {
|
||||
@apply whitespace-nowrap text-ellipsis overflow-hidden;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active-breadcrumb-item {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
<!-- A Electron-backed download button with a label, size hint and progress bar -->
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
|
||||
<div class="file-info">
|
||||
<div class="file-details">
|
||||
<span class="file-type" :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<div v-if="props.error" class="file-error">
|
||||
{{ props.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-action flex flex-row items-center gap-2">
|
||||
<Button
|
||||
v-if="status === null || status === 'error'"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerDownload"
|
||||
>
|
||||
<i class="pi pi-download" />
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="(status === null || status === 'error') && !!props.url"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="copyURL"
|
||||
>
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status === 'in_progress' || status === 'paused'"
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="downloadProgress"
|
||||
:show-value="downloadProgress > 10"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="status === 'in_progress'"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerPauseDownload"
|
||||
>
|
||||
<i class="pi pi-pause-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="status === 'paused'"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerResumeDownload"
|
||||
>
|
||||
<i class="pi pi-play-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="file-action-button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerCancelDownload"
|
||||
>
|
||||
<i class="pi pi-times-circle" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const downloadProgress = ref<number>(0)
|
||||
const status = ref<string | null>(null)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const electronDownloadStore = useElectronDownloadStore()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const [savePath, filename] = props.label.split('/')
|
||||
|
||||
electronDownloadStore.$subscribe((_, { downloads }) => {
|
||||
const download = downloads.find((download) => props.url === download.url)
|
||||
|
||||
if (download) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
downloadProgress.value = Number((download.progress * 100).toFixed(1))
|
||||
// @ts-expect-error fixme ts strict error
|
||||
status.value = download.status
|
||||
}
|
||||
})
|
||||
|
||||
const triggerDownload = async () => {
|
||||
await electronDownloadStore.start({
|
||||
url: props.url,
|
||||
savePath: savePath.trim(),
|
||||
filename: filename.trim()
|
||||
})
|
||||
}
|
||||
|
||||
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
|
||||
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
|
||||
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
|
||||
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
<!-- A file download button with a label and a size hint -->
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<div>
|
||||
<span :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<Message
|
||||
v-if="props.error"
|
||||
severity="error"
|
||||
icon="pi pi-exclamation-triangle"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="my-2 h-min max-w-xs px-1"
|
||||
:title="props.error"
|
||||
:pt="{
|
||||
text: { class: 'overflow-hidden text-ellipsis' }
|
||||
}"
|
||||
>
|
||||
{{ props.error }}
|
||||
</Message>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="!!props.error"
|
||||
:title="props.url"
|
||||
@click="download.triggerBrowserDownload"
|
||||
>
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
@@ -117,20 +117,18 @@ function getFormComponent(item: FormItem): Component {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.form-input :deep(.input-slider) .p-inputnumber input,
|
||||
.form-input :deep(.input-slider) .slider-part {
|
||||
@apply w-20;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.form-input :deep(.input-knob) .p-inputnumber input,
|
||||
.form-input :deep(.input-knob) .knob-part {
|
||||
@apply w-32;
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.form-input :deep(.p-inputtext),
|
||||
.form-input :deep(.p-select) {
|
||||
@apply w-44;
|
||||
width: 11rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -133,8 +133,6 @@ const wrapperStyle = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Chip removable @remove="$emit('remove', $event)">
|
||||
<Badge size="small" :class="badgeClass">
|
||||
<Chip removable @remove="emit('remove', $event)">
|
||||
<Badge size="small" :class="semanticBadgeClass">
|
||||
{{ badge }}
|
||||
</Badge>
|
||||
{{ text }}
|
||||
@@ -10,6 +10,7 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from 'primevue/badge'
|
||||
import Chip from 'primevue/chip'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface SearchFilter {
|
||||
text: string
|
||||
@@ -18,26 +19,19 @@ export interface SearchFilter {
|
||||
id: string | number
|
||||
}
|
||||
|
||||
defineProps<Omit<SearchFilter, 'id'>>()
|
||||
defineEmits(['remove'])
|
||||
const semanticClassMap: Record<string, string> = {
|
||||
'i-badge': 'bg-green-500 text-white',
|
||||
'o-badge': 'bg-red-500 text-white',
|
||||
'c-badge': 'bg-blue-500 text-white',
|
||||
's-badge': 'bg-yellow-500'
|
||||
}
|
||||
|
||||
const props = defineProps<Omit<SearchFilter, 'id'>>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'remove', event: Event): void
|
||||
}>()
|
||||
|
||||
const semanticBadgeClass = computed(() => {
|
||||
return semanticClassMap[props.badgeClass] ?? props.badgeClass
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.i-badge) {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.o-badge) {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.c-badge) {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.s-badge) {
|
||||
@apply bg-yellow-500;
|
||||
}
|
||||
</style>
|
||||
|
||||
113
src/components/curve/CurveEditor.test.ts
Normal file
113
src/components/curve/CurveEditor.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
function mountEditor(points: CurvePoint[], extraProps = {}) {
|
||||
return mount(CurveEditor, {
|
||||
props: { modelValue: points, ...extraProps }
|
||||
})
|
||||
}
|
||||
|
||||
function getCurvePath(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[data-testid="curve-path"]')
|
||||
}
|
||||
|
||||
describe('CurveEditor', () => {
|
||||
it('renders SVG with curve path', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
const curvePath = getCurvePath(wrapper)
|
||||
expect(curvePath.exists()).toBe(true)
|
||||
expect(curvePath.attributes('d')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a circle for each control point', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.7],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders histogram path when provided', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const wrapper = mountEditor(
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
{ histogram }
|
||||
)
|
||||
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
|
||||
expect(histogramPath.exists()).toBe(true)
|
||||
expect(histogramPath.attributes('d')).toContain('M0,1')
|
||||
})
|
||||
|
||||
it('does not render histogram path when not provided', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns empty path with fewer than 2 points', () => {
|
||||
const wrapper = mountEditor([[0.5, 0.5]])
|
||||
expect(getCurvePath(wrapper).attributes('d')).toBe('')
|
||||
})
|
||||
|
||||
it('generates path starting with M and containing L segments', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
expect(d).toMatch(/^M/)
|
||||
expect(d).toContain('L')
|
||||
})
|
||||
|
||||
it('curve path only spans the x-range of control points', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
const xValues = d
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[0]))
|
||||
expect(Math.min(...xValues)).toBeCloseTo(0.2, 2)
|
||||
expect(Math.max(...xValues)).toBeCloseTo(0.8, 2)
|
||||
})
|
||||
|
||||
it('deletes a point on right-click but keeps minimum 2', async () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1]
|
||||
]
|
||||
const wrapper = mountEditor(points)
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
|
||||
await wrapper.findAll('circle')[1].trigger('pointerdown', {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
|
||||
await wrapper.findAll('circle')[0].trigger('pointerdown', {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
103
src/components/curve/CurveEditor.vue
Normal file
103
src/components/curve/CurveEditor.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
viewBox="-0.04 -0.04 1.08 1.08"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
|
||||
@pointerdown.stop="handleSvgPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<line
|
||||
v-for="v in [0.25, 0.5, 0.75]"
|
||||
:key="'h' + v"
|
||||
:x1="0"
|
||||
:y1="v"
|
||||
:x2="1"
|
||||
:y2="v"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
<line
|
||||
v-for="v in [0.25, 0.5, 0.75]"
|
||||
:key="'v' + v"
|
||||
:x1="v"
|
||||
:y1="0"
|
||||
:x2="v"
|
||||
:y2="1"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
|
||||
<line
|
||||
x1="0"
|
||||
y1="1"
|
||||
x2="1"
|
||||
y2="0"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.15"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
|
||||
<path
|
||||
v-if="histogramPath"
|
||||
data-testid="histogram-path"
|
||||
:d="histogramPath"
|
||||
:fill="curveColor"
|
||||
fill-opacity="0.15"
|
||||
stroke="none"
|
||||
/>
|
||||
|
||||
<path
|
||||
data-testid="curve-path"
|
||||
:d="curvePath"
|
||||
fill="none"
|
||||
:stroke="curveColor"
|
||||
stroke-width="0.008"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const { curveColor = 'white', histogram } = defineProps<{
|
||||
curveColor?: string
|
||||
histogram?: Uint32Array | null
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
required: true
|
||||
})
|
||||
|
||||
const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
|
||||
|
||||
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
|
||||
svgRef,
|
||||
modelValue
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
</script>
|
||||
16
src/components/curve/WidgetCurve.vue
Normal file
16
src/components/curve/WidgetCurve.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<CurveEditor v-model="modelValue" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
default: () => [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
]
|
||||
})
|
||||
</script>
|
||||
141
src/components/curve/curveUtils.test.ts
Normal file
141
src/components/curve/curveUtils.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
createMonotoneInterpolator,
|
||||
curvesToLUT,
|
||||
histogramToPath
|
||||
} from './curveUtils'
|
||||
|
||||
describe('createMonotoneInterpolator', () => {
|
||||
it('returns 0 for empty points', () => {
|
||||
const interpolate = createMonotoneInterpolator([])
|
||||
expect(interpolate(0.5)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns constant for single point', () => {
|
||||
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
|
||||
expect(interpolate(0)).toBe(0.7)
|
||||
expect(interpolate(1)).toBe(0.7)
|
||||
})
|
||||
|
||||
it('passes through control points exactly', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('clamps to endpoint values outside range', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0.3)
|
||||
expect(interpolate(1)).toBe(0.9)
|
||||
})
|
||||
|
||||
it('produces monotone output for monotone input', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.25, 0.2],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
let prev = -Infinity
|
||||
for (let x = 0; x <= 1; x += 0.01) {
|
||||
const y = interpolate(x)
|
||||
expect(y).toBeGreaterThanOrEqual(prev)
|
||||
prev = y
|
||||
}
|
||||
})
|
||||
|
||||
it('handles unsorted input points', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
[0.5, 0.5]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('curvesToLUT', () => {
|
||||
it('returns a 256-entry Uint8Array', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(lut).toBeInstanceOf(Uint8Array)
|
||||
expect(lut.length).toBe(256)
|
||||
})
|
||||
|
||||
it('produces identity LUT for diagonal curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeCloseTo(i, 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps output to [0, 255]', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.5, 1.5],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(0)
|
||||
expect(lut[i]).toBeLessThanOrEqual(255)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('histogramToPath', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
})
|
||||
|
||||
it('returns a closed SVG path for valid histogram', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
})
|
||||
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
// Most bins should map to y=0 (1 - 100/100 = 0) since
|
||||
// the 99.5th percentile is 100, not the outlier 100000
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
})
|
||||
})
|
||||
120
src/components/curve/curveUtils.ts
Normal file
120
src/components/curve/curveUtils.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
* Produces a smooth curve that passes through all control points
|
||||
* without overshooting (monotone property).
|
||||
*
|
||||
* Returns a function that evaluates y for any x in [0, 1].
|
||||
*/
|
||||
export function createMonotoneInterpolator(
|
||||
points: CurvePoint[]
|
||||
): (x: number) => number {
|
||||
if (points.length === 0) return () => 0
|
||||
if (points.length === 1) return () => points[0][1]
|
||||
|
||||
const sorted = [...points].sort((a, b) => a[0] - b[0])
|
||||
const n = sorted.length
|
||||
const xs = sorted.map((p) => p[0])
|
||||
const ys = sorted.map((p) => p[1])
|
||||
|
||||
const deltas: number[] = []
|
||||
const slopes: number[] = []
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const dx = xs[i + 1] - xs[i]
|
||||
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
|
||||
}
|
||||
|
||||
slopes.push(deltas[0] ?? 0)
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (deltas[i - 1] * deltas[i] <= 0) {
|
||||
slopes.push(0)
|
||||
} else {
|
||||
slopes.push((deltas[i - 1] + deltas[i]) / 2)
|
||||
}
|
||||
}
|
||||
slopes.push(deltas[n - 2] ?? 0)
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (deltas[i] === 0) {
|
||||
slopes[i] = 0
|
||||
slopes[i + 1] = 0
|
||||
} else {
|
||||
const alpha = slopes[i] / deltas[i]
|
||||
const beta = slopes[i + 1] / deltas[i]
|
||||
const s = alpha * alpha + beta * beta
|
||||
if (s > 9) {
|
||||
const t = 3 / Math.sqrt(s)
|
||||
slopes[i] = t * alpha * deltas[i]
|
||||
slopes[i + 1] = t * beta * deltas[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (x: number): number => {
|
||||
if (x <= xs[0]) return ys[0]
|
||||
if (x >= xs[n - 1]) return ys[n - 1]
|
||||
|
||||
let lo = 0
|
||||
let hi = n - 1
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (xs[mid] <= x) lo = mid
|
||||
else hi = mid
|
||||
}
|
||||
|
||||
const dx = xs[hi] - xs[lo]
|
||||
if (dx === 0) return ys[lo]
|
||||
|
||||
const t = (x - xs[lo]) / dx
|
||||
const t2 = t * t
|
||||
const t3 = t2 * t
|
||||
|
||||
const h00 = 2 * t3 - 3 * t2 + 1
|
||||
const h10 = t3 - 2 * t2 + t
|
||||
const h01 = -2 * t3 + 3 * t2
|
||||
const h11 = t3 - t2
|
||||
|
||||
return (
|
||||
h00 * ys[lo] +
|
||||
h10 * dx * slopes[lo] +
|
||||
h01 * ys[hi] +
|
||||
h11 * dx * slopes[hi]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 256-bin histogram into an SVG path string.
|
||||
* Normalizes using the 99.5th percentile to avoid outlier spikes.
|
||||
*/
|
||||
export function histogramToPath(histogram: Uint32Array): string {
|
||||
if (!histogram.length) return ''
|
||||
|
||||
const sorted = Array.from(histogram).sort((a, b) => a - b)
|
||||
const max = sorted[Math.floor(255 * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const step = 1 / 255
|
||||
let d = 'M0,1'
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i * step
|
||||
const y = 1 - Math.min(1, histogram[i] / max)
|
||||
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
|
||||
}
|
||||
d += ' L1,1 Z'
|
||||
return d
|
||||
}
|
||||
|
||||
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
|
||||
const lut = new Uint8Array(256)
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i / 255
|
||||
const y = interpolate(x)
|
||||
lut[i] = Math.max(0, Math.min(255, Math.round(y * 255)))
|
||||
}
|
||||
|
||||
return lut
|
||||
}
|
||||
@@ -267,16 +267,6 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="template.vram" #top-left>
|
||||
<SquareChip
|
||||
:label="formatSize(template.vram)"
|
||||
:title="t('templateWorkflows.vramEstimateTooltip')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu] h-3 w-3" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<template v-if="template.tags && template.tags.length > 0">
|
||||
<SquareChip
|
||||
@@ -397,7 +387,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -735,10 +724,6 @@ const sortOptions = computed(() => [
|
||||
name: t('templateWorkflows.sort.recommended', 'Recommended'),
|
||||
value: 'recommended'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.similarToCurrent', 'Similar to Current'),
|
||||
value: 'similar-to-current'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.popular', 'Popular'),
|
||||
value: 'popular'
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const stubProfile: DeveloperProfile = {
|
||||
username: '@StoneCypher',
|
||||
displayName: 'Stone Cypher',
|
||||
avatarUrl: undefined,
|
||||
bannerUrl: undefined,
|
||||
bio: 'Workflow designer',
|
||||
isVerified: true,
|
||||
monetizationEnabled: true,
|
||||
joinedAt: new Date('2024-03-15'),
|
||||
dependencies: 371,
|
||||
totalDownloads: 1000,
|
||||
totalFavorites: 50,
|
||||
averageRating: 4.2,
|
||||
templateCount: 2
|
||||
}
|
||||
|
||||
const stubReviews: TemplateReview[] = [
|
||||
{
|
||||
id: 'rev-1',
|
||||
authorName: 'Reviewer',
|
||||
rating: 4.5,
|
||||
text: 'Great work!',
|
||||
createdAt: new Date('2025-10-01'),
|
||||
templateId: 'tpl-1'
|
||||
}
|
||||
]
|
||||
|
||||
const stubTemplate: MarketplaceTemplate = {
|
||||
id: 'tpl-1',
|
||||
title: 'Test Template',
|
||||
description: 'Desc',
|
||||
shortDescription: 'Short',
|
||||
author: {
|
||||
id: 'usr-1',
|
||||
name: 'Stone Cypher',
|
||||
isVerified: true,
|
||||
profileUrl: '/u'
|
||||
},
|
||||
categories: [],
|
||||
tags: [],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 0,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
stats: {
|
||||
downloads: 500,
|
||||
favorites: 30,
|
||||
rating: 4,
|
||||
reviewCount: 5,
|
||||
weeklyTrend: 1
|
||||
}
|
||||
}
|
||||
|
||||
const stubRevenue: TemplateRevenue[] = [
|
||||
{
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 5000,
|
||||
monthlyRevenue: 500,
|
||||
currency: 'USD'
|
||||
}
|
||||
]
|
||||
|
||||
const mockService = vi.hoisted(() => ({
|
||||
getCurrentUsername: vi.fn(() => '@StoneCypher'),
|
||||
fetchDeveloperProfile: vi.fn(() => Promise.resolve({ ...stubProfile })),
|
||||
fetchDeveloperReviews: vi.fn(() => Promise.resolve([...stubReviews])),
|
||||
fetchPublishedTemplates: vi.fn(() => Promise.resolve([{ ...stubTemplate }])),
|
||||
fetchTemplateRevenue: vi.fn(() => Promise.resolve([...stubRevenue])),
|
||||
fetchDownloadHistory: vi.fn(() => Promise.resolve([])),
|
||||
unpublishTemplate: vi.fn(() => Promise.resolve()),
|
||||
saveDeveloperProfile: vi.fn((p: Partial<DeveloperProfile>) =>
|
||||
Promise.resolve({ ...stubProfile, ...p })
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/developerProfileService', () => mockService)
|
||||
|
||||
import DeveloperProfileDialog from './DeveloperProfileDialog.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
developerProfile: {
|
||||
dialogTitle: 'Developer Profile',
|
||||
username: 'Username',
|
||||
bio: 'Bio',
|
||||
reviews: 'Reviews',
|
||||
publishedTemplates: 'Published Templates',
|
||||
dependencies: 'Dependencies',
|
||||
totalDownloads: 'Downloads',
|
||||
totalFavorites: 'Favorites',
|
||||
averageRating: 'Avg. Rating',
|
||||
templateCount: 'Templates',
|
||||
revenue: 'Revenue',
|
||||
monthlyRevenue: 'Monthly',
|
||||
totalRevenue: 'Total',
|
||||
noReviews: 'No reviews yet',
|
||||
noTemplates: 'No published templates yet',
|
||||
unpublish: 'Unpublish',
|
||||
save: 'Save Profile',
|
||||
saving: 'Saving...',
|
||||
verified: 'Verified',
|
||||
quickActions: 'Quick Actions',
|
||||
bannerPlaceholder: 'Banner image',
|
||||
editUsername: 'Edit username',
|
||||
editBio: 'Edit bio',
|
||||
lookupHandle: 'Enter developer handle\u2026',
|
||||
downloads: 'Downloads',
|
||||
favorites: 'Favorites',
|
||||
rating: 'Rating'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountDialog(props?: { username?: string }) {
|
||||
return mount(DeveloperProfileDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseModalLayout: {
|
||||
template: `
|
||||
<div data-testid="modal">
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-right"><slot name="header-right-area" /></div>
|
||||
<div data-testid="content"><slot name="content" /></div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
ReviewCard: {
|
||||
template: '<div data-testid="review-card" />',
|
||||
props: ['review']
|
||||
},
|
||||
TemplateListItem: {
|
||||
template:
|
||||
'<div data-testid="template-list-item" :data-show-revenue="showRevenue" :data-is-current-user="isCurrentUser" />',
|
||||
props: ['template', 'revenue', 'showRevenue', 'isCurrentUser']
|
||||
},
|
||||
DownloadHistoryChart: {
|
||||
template: '<div data-testid="download-history-chart" />',
|
||||
props: ['entries']
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>',
|
||||
props: ['variant', 'size', 'disabled']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('DeveloperProfileDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockService.getCurrentUsername.mockReturnValue('@StoneCypher')
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue({ ...stubProfile })
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([...stubReviews])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([{ ...stubTemplate }])
|
||||
mockService.fetchTemplateRevenue.mockResolvedValue([...stubRevenue])
|
||||
})
|
||||
|
||||
it('renders the banner section', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="banner-section"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows username input when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows username text when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows bio input when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="bio-input"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows bio text when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="bio-text"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders review cards', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-testid="review-card"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders template list items', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-testid="template-list-item"]')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('passes showRevenue=true when current user with monetization', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.attributes('data-show-revenue')).toBe('true')
|
||||
})
|
||||
|
||||
it('passes showRevenue=false when not current user', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.attributes('data-show-revenue')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows quick actions when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides quick actions when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows save button when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
expect(headerRight.text()).toContain('Save Profile')
|
||||
})
|
||||
|
||||
it('hides save button when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
expect(headerRight.text()).not.toContain('Save Profile')
|
||||
})
|
||||
|
||||
it('renders summary stats', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const stats = wrapper.find('[data-testid="summary-stats"]')
|
||||
expect(stats.exists()).toBe(true)
|
||||
expect(stats.text()).toContain('371')
|
||||
expect(stats.text()).toContain('1,000')
|
||||
expect(stats.text()).toContain('50')
|
||||
})
|
||||
|
||||
it('renders the handle input with the default username', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
expect(handleInput.exists()).toBe(true)
|
||||
expect((handleInput.element as HTMLInputElement).value).toBe('@StoneCypher')
|
||||
})
|
||||
|
||||
it('reloads data when the handle input changes', async () => {
|
||||
const otherProfile: DeveloperProfile = {
|
||||
...stubProfile,
|
||||
username: '@OtherDev',
|
||||
displayName: 'Other Dev',
|
||||
bio: 'Another developer',
|
||||
isVerified: false,
|
||||
monetizationEnabled: false,
|
||||
totalDownloads: 42
|
||||
}
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
// Initial load
|
||||
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith(
|
||||
'@StoneCypher'
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([])
|
||||
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
await handleInput.setValue('@OtherDev')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith('@OtherDev')
|
||||
expect(wrapper.find('[data-testid="username-text"]').text()).toBe(
|
||||
'Other Dev'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears revenue when switching to a non-current-user handle', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
// Revenue was loaded for current user
|
||||
expect(mockService.fetchTemplateRevenue).toHaveBeenCalled()
|
||||
vi.clearAllMocks()
|
||||
|
||||
const otherProfile: DeveloperProfile = {
|
||||
...stubProfile,
|
||||
username: '@Someone',
|
||||
monetizationEnabled: false
|
||||
}
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([])
|
||||
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
await handleInput.setValue('@Someone')
|
||||
await flushPromises()
|
||||
|
||||
// Revenue should NOT be fetched for other user
|
||||
expect(mockService.fetchTemplateRevenue).not.toHaveBeenCalled()
|
||||
// showRevenue should be false
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,322 +0,0 @@
|
||||
<template>
|
||||
<BaseModalLayout :content-title="t('developerProfile.dialogTitle')" size="sm">
|
||||
<template #header>
|
||||
<input
|
||||
v-model="viewedUsername"
|
||||
type="text"
|
||||
:placeholder="t('developerProfile.lookupHandle')"
|
||||
class="h-8 w-48 rounded border border-border-default bg-secondary-background px-2 text-sm text-muted-foreground focus:outline-none"
|
||||
data-testid="handle-input"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="isCurrentUser" #header-right-area>
|
||||
<div class="mr-6">
|
||||
<Button size="lg" :disabled="isSaving" @click="saveProfile">
|
||||
{{
|
||||
isSaving ? t('developerProfile.saving') : t('developerProfile.save')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Banner Image -->
|
||||
<div
|
||||
class="h-48 w-full overflow-hidden rounded-lg bg-secondary-background"
|
||||
data-testid="banner-section"
|
||||
>
|
||||
<img
|
||||
v-if="profile?.bannerUrl"
|
||||
:src="profile.bannerUrl"
|
||||
:alt="t('developerProfile.bannerPlaceholder')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i
|
||||
class="icon-[lucide--image] size-10 text-muted-foreground opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar + Username + Bio -->
|
||||
<div class="flex items-start gap-4" data-testid="identity-section">
|
||||
<div
|
||||
class="flex size-16 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="isCurrentUser">
|
||||
<input
|
||||
v-model="editableUsername"
|
||||
type="text"
|
||||
:placeholder="t('developerProfile.editUsername')"
|
||||
class="h-8 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
data-testid="username-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-lg font-semibold" data-testid="username-text">
|
||||
{{ profile?.displayName ?? viewedUsername }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="profile?.isVerified"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-xs text-blue-400"
|
||||
>
|
||||
<i class="icon-[lucide--badge-check] size-3" />
|
||||
{{ t('developerProfile.verified') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="isCurrentUser">
|
||||
<textarea
|
||||
v-model="editableBio"
|
||||
:placeholder="t('developerProfile.editBio')"
|
||||
rows="2"
|
||||
class="resize-none rounded border border-border-default bg-secondary-background px-2 py-1 text-sm text-muted-foreground focus:outline-none"
|
||||
data-testid="bio-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p
|
||||
v-if="profile?.bio"
|
||||
class="m-0 text-sm text-muted-foreground"
|
||||
data-testid="bio-text"
|
||||
>
|
||||
{{ profile.bio }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-5 gap-3" data-testid="summary-stats">
|
||||
<div
|
||||
v-for="stat in summaryStats"
|
||||
:key="stat.label"
|
||||
class="flex flex-col items-center rounded-lg bg-secondary-background p-3"
|
||||
>
|
||||
<span class="text-lg font-semibold">{{ stat.value }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ stat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download History Chart -->
|
||||
<DownloadHistoryChart
|
||||
v-if="downloadHistory.length > 0"
|
||||
:entries="downloadHistory"
|
||||
/>
|
||||
|
||||
<!-- Quick Actions (current user only) -->
|
||||
<div
|
||||
v-if="isCurrentUser"
|
||||
class="rounded-lg border border-border-default p-4"
|
||||
data-testid="quick-actions"
|
||||
>
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.quickActions') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
@click="handleUnpublish(tpl.id)"
|
||||
>
|
||||
{{ t('developerProfile.unpublish') }}: {{ tpl.title }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reviews Section -->
|
||||
<div data-testid="reviews-section">
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.reviews') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="reviews.length === 0"
|
||||
class="py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('developerProfile.noReviews') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex max-h-80 flex-col gap-2 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<ReviewCard
|
||||
v-for="review in reviews"
|
||||
:key="review.id"
|
||||
:review="review"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Published Templates Section -->
|
||||
<div data-testid="templates-section">
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.publishedTemplates') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="templates.length === 0"
|
||||
class="py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('developerProfile.noTemplates') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex max-h-96 flex-col gap-2 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<TemplateListItem
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:template="tpl"
|
||||
:revenue="revenueByTemplateId[tpl.id]"
|
||||
:show-revenue="showRevenueColumn"
|
||||
:is-current-user="isCurrentUser"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import {
|
||||
fetchDeveloperProfile,
|
||||
fetchDeveloperReviews,
|
||||
fetchDownloadHistory,
|
||||
fetchPublishedTemplates,
|
||||
fetchTemplateRevenue,
|
||||
getCurrentUsername,
|
||||
saveDeveloperProfile,
|
||||
unpublishTemplate
|
||||
} from '@/services/developerProfileService'
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
DownloadHistoryEntry,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
import DownloadHistoryChart from './DownloadHistoryChart.vue'
|
||||
import ReviewCard from './ReviewCard.vue'
|
||||
import TemplateListItem from './TemplateListItem.vue'
|
||||
|
||||
const { onClose, username } = defineProps<{
|
||||
onClose: () => void
|
||||
username?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const viewedUsername = ref(username ?? getCurrentUsername())
|
||||
const isCurrentUser = computed(
|
||||
() => viewedUsername.value === getCurrentUsername()
|
||||
)
|
||||
|
||||
const profile = ref<DeveloperProfile | null>(null)
|
||||
const reviews = ref<TemplateReview[]>([])
|
||||
const templates = ref<MarketplaceTemplate[]>([])
|
||||
const revenue = ref<TemplateRevenue[]>([])
|
||||
const downloadHistory = ref<DownloadHistoryEntry[]>([])
|
||||
const isSaving = ref(false)
|
||||
|
||||
const editableUsername = ref('')
|
||||
const editableBio = ref('')
|
||||
|
||||
const revenueByTemplateId = computed(() => {
|
||||
const map: Record<string, TemplateRevenue> = {}
|
||||
for (const entry of revenue.value) {
|
||||
map[entry.templateId] = entry
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const showRevenueColumn = computed(
|
||||
() => isCurrentUser.value && (profile.value?.monetizationEnabled ?? false)
|
||||
)
|
||||
|
||||
const summaryStats = computed(() => [
|
||||
{
|
||||
label: t('developerProfile.dependencies'),
|
||||
value: (profile.value?.dependencies ?? 371).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.totalDownloads'),
|
||||
value: (profile.value?.totalDownloads ?? 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.totalFavorites'),
|
||||
value: (profile.value?.totalFavorites ?? 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.averageRating'),
|
||||
value: (profile.value?.averageRating ?? 0).toFixed(1)
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.templateCount'),
|
||||
value: String(profile.value?.templateCount ?? 0)
|
||||
}
|
||||
])
|
||||
|
||||
watchDebounced(viewedUsername, () => void loadData(), { debounce: 500 })
|
||||
|
||||
async function loadData() {
|
||||
const handle = viewedUsername.value
|
||||
const [profileData, reviewsData, templatesData, historyData] =
|
||||
await Promise.all([
|
||||
fetchDeveloperProfile(handle),
|
||||
fetchDeveloperReviews(handle),
|
||||
fetchPublishedTemplates(handle),
|
||||
fetchDownloadHistory(handle)
|
||||
])
|
||||
profile.value = profileData
|
||||
reviews.value = reviewsData
|
||||
templates.value = templatesData
|
||||
downloadHistory.value = historyData
|
||||
editableUsername.value = profileData.displayName
|
||||
editableBio.value = profileData.bio ?? ''
|
||||
|
||||
if (isCurrentUser.value && profileData.monetizationEnabled) {
|
||||
revenue.value = await fetchTemplateRevenue(handle)
|
||||
} else {
|
||||
revenue.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
profile.value = await saveDeveloperProfile({
|
||||
...profile.value,
|
||||
displayName: editableUsername.value,
|
||||
bio: editableBio.value
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpublish(templateId: string) {
|
||||
await unpublishTemplate(templateId)
|
||||
templates.value = templates.value.filter((t) => t.id !== templateId)
|
||||
}
|
||||
|
||||
void loadData()
|
||||
</script>
|
||||
@@ -1,217 +0,0 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DownloadHistoryEntry } from '@/types/templateMarketplace'
|
||||
|
||||
const { MockChart } = vi.hoisted(() => {
|
||||
const mockDestroyFn = vi.fn()
|
||||
|
||||
class MockChartClass {
|
||||
static register = vi.fn()
|
||||
static instances: MockChartClass[] = []
|
||||
type: string
|
||||
data: unknown
|
||||
destroy = mockDestroyFn
|
||||
|
||||
constructor(_canvas: unknown, config: { type: string; data: unknown }) {
|
||||
this.type = config.type
|
||||
this.data = config.data
|
||||
MockChartClass.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return { MockChart: MockChartClass, mockDestroyFn }
|
||||
})
|
||||
|
||||
vi.mock('chart.js', () => ({
|
||||
Chart: MockChart,
|
||||
BarController: {},
|
||||
BarElement: {},
|
||||
CategoryScale: {},
|
||||
Filler: {},
|
||||
LineController: {},
|
||||
LineElement: {},
|
||||
LinearScale: {},
|
||||
PointElement: {},
|
||||
Tooltip: {}
|
||||
}))
|
||||
|
||||
import DownloadHistoryChart from './DownloadHistoryChart.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
developerProfile: {
|
||||
downloadHistory: 'Download History',
|
||||
range: {
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
year: 'Year',
|
||||
allTime: 'All Time'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeEntries(count: number): DownloadHistoryEntry[] {
|
||||
const entries: DownloadHistoryEntry[] = []
|
||||
const now = new Date()
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
entries.push({ date, downloads: 10 + i })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
async function mountChart(entries?: DownloadHistoryEntry[]) {
|
||||
const wrapper = mount(DownloadHistoryChart, {
|
||||
props: { entries: entries ?? makeEntries(730) },
|
||||
global: { plugins: [i18n] },
|
||||
attachTo: document.createElement('div')
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function lastInstance() {
|
||||
return MockChart.instances.at(-1)
|
||||
}
|
||||
|
||||
describe('DownloadHistoryChart', () => {
|
||||
beforeEach(() => {
|
||||
MockChart.instances = []
|
||||
})
|
||||
|
||||
it('renders all four range buttons', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const buttons = wrapper.find('[data-testid="range-buttons"]')
|
||||
expect(buttons.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="range-btn-week"]').text()).toBe('Week')
|
||||
expect(wrapper.find('[data-testid="range-btn-month"]').text()).toBe('Month')
|
||||
expect(wrapper.find('[data-testid="range-btn-year"]').text()).toBe('Year')
|
||||
expect(wrapper.find('[data-testid="range-btn-allTime"]').text()).toBe(
|
||||
'All Time'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to week range with active styling', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const weekBtn = wrapper.find('[data-testid="range-btn-week"]')
|
||||
expect(weekBtn.classes()).toContain('font-semibold')
|
||||
})
|
||||
|
||||
it('creates a bar chart for week range', async () => {
|
||||
await mountChart()
|
||||
expect(lastInstance()?.type).toBe('bar')
|
||||
})
|
||||
|
||||
it('switches to month and creates a bar chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('bar')
|
||||
})
|
||||
|
||||
it('switches to year and creates a line chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('line')
|
||||
})
|
||||
|
||||
it('switches to allTime and creates a line chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('line')
|
||||
})
|
||||
|
||||
it('destroys previous chart when switching ranges', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const firstInstance = lastInstance()!
|
||||
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(firstInstance.destroy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the heading text', async () => {
|
||||
const wrapper = await mountChart()
|
||||
expect(wrapper.text()).toContain('Download History')
|
||||
})
|
||||
|
||||
it('passes 7 data points for week range', async () => {
|
||||
await mountChart()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
expect(labels).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('passes 31 data points for month range', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
expect(labels).toHaveLength(31)
|
||||
})
|
||||
|
||||
it('downsamples year range to weekly buckets', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
// 365 days / 7 per bucket = 52 full + 1 partial = 53
|
||||
expect(labels).toHaveLength(Math.ceil(365 / 7))
|
||||
})
|
||||
|
||||
it('downsamples allTime range to monthly buckets', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
// 730 days / 30 per bucket = 24 full + 1 partial = 25
|
||||
expect(labels).toHaveLength(Math.ceil(730 / 30))
|
||||
})
|
||||
|
||||
it('sums downloads within each aggregated bucket', async () => {
|
||||
// 14 entries with downloads = 1 each, aggregated by 7 → 2 buckets of 7
|
||||
const entries = makeEntries(14).map((e) => ({ ...e, downloads: 1 }))
|
||||
const wrapper = mount(DownloadHistoryChart, {
|
||||
props: { entries },
|
||||
global: { plugins: [i18n] },
|
||||
attachTo: document.createElement('div')
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
const chart = lastInstance()!
|
||||
const datasets = (chart.data as { datasets: { data: number[] }[] }).datasets
|
||||
// 14 / 30 per bucket → 1 bucket with all 14 summed
|
||||
expect(datasets[0].data).toEqual([14])
|
||||
})
|
||||
})
|
||||
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg bg-secondary-background p-4"
|
||||
data-testid="download-history-chart"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="m-0 text-sm font-semibold">
|
||||
{{ t('developerProfile.downloadHistory') }}
|
||||
</h3>
|
||||
<div
|
||||
class="flex gap-1 rounded-md bg-modal-panel-background p-0.5"
|
||||
data-testid="range-buttons"
|
||||
>
|
||||
<button
|
||||
v-for="range in RANGES"
|
||||
:key="range"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded border-none px-2 py-1 text-xs transition-colors',
|
||||
selectedRange === range
|
||||
? 'bg-secondary-background font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)
|
||||
"
|
||||
:data-testid="`range-btn-${range}`"
|
||||
@click="selectedRange = range"
|
||||
>
|
||||
{{ t(`developerProfile.range.${range}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-62.5">
|
||||
<canvas ref="canvasRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
/** * Download history chart for the developer profile dashboard. * * Renders
|
||||
daily download counts using Chart.js, with a toggle group in the * upper-right
|
||||
corner that switches between four time ranges: * * - **Week** (7 bars) and
|
||||
**Month** (31 bars) render as bar charts. * - **Year** (weekly buckets) and
|
||||
**All Time** (monthly buckets) render as * filled area charts, with entries
|
||||
aggregated into summed buckets to keep * the point count manageable. * * @prop
|
||||
entries - Chronologically-ordered daily download history produced by * {@link
|
||||
fetchDownloadHistory}. */
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
DownloadHistoryEntry,
|
||||
DownloadHistoryRange
|
||||
} from '@/types/templateMarketplace'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip
|
||||
)
|
||||
|
||||
const RANGES: DownloadHistoryRange[] = ['week', 'month', 'year', 'allTime']
|
||||
|
||||
const BAR_COLOR = '#185A8B'
|
||||
|
||||
const { entries } = defineProps<{
|
||||
entries: DownloadHistoryEntry[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedRange = ref<DownloadHistoryRange>('week')
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const chartInstance = shallowRef<Chart | null>(null)
|
||||
|
||||
/**
|
||||
* Aggregates entries into buckets by summing downloads and using the last
|
||||
* date in each bucket for the label.
|
||||
*/
|
||||
function aggregate(
|
||||
source: DownloadHistoryEntry[],
|
||||
bucketSize: number
|
||||
): DownloadHistoryEntry[] {
|
||||
const result: DownloadHistoryEntry[] = []
|
||||
for (let i = 0; i < source.length; i += bucketSize) {
|
||||
const bucket = source.slice(i, i + bucketSize)
|
||||
const downloads = bucket.reduce((sum, e) => sum + e.downloads, 0)
|
||||
result.push({ date: bucket[bucket.length - 1].date, downloads })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tail slice of entries matching the selected range, downsampled
|
||||
* for larger views, along with formatted date labels.
|
||||
*/
|
||||
function sliceEntries(range: DownloadHistoryRange): {
|
||||
labels: string[]
|
||||
data: number[]
|
||||
} {
|
||||
const count =
|
||||
range === 'week' ? 7 : range === 'month' ? 31 : range === 'year' ? 365 : 0
|
||||
|
||||
const sliced = count > 0 ? entries.slice(-count) : entries
|
||||
const sampled =
|
||||
range === 'year'
|
||||
? aggregate(sliced, 7)
|
||||
: range === 'allTime'
|
||||
? aggregate(sliced, 30)
|
||||
: sliced
|
||||
|
||||
const labels = sampled.map((e) => {
|
||||
const d = e.date
|
||||
if (range === 'week')
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short' })
|
||||
if (range === 'month') return String(d.getDate())
|
||||
if (range === 'year')
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
|
||||
})
|
||||
|
||||
return { labels, data: sampled.map((e) => e.downloads) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds or replaces the Chart.js instance on the canvas whenever range
|
||||
* or data changes.
|
||||
*/
|
||||
function renderChart() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
const range = selectedRange.value
|
||||
const isBar = range === 'week' || range === 'month'
|
||||
const { labels, data } = sliceEntries(range)
|
||||
|
||||
chartInstance.value = new Chart(canvas, {
|
||||
type: isBar ? 'bar' : 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: isBar ? BAR_COLOR : `${BAR_COLOR}33`,
|
||||
borderColor: BAR_COLOR,
|
||||
borderWidth: isBar ? 0 : 2,
|
||||
borderRadius: isBar ? { topLeft: 4, topRight: 4 } : undefined,
|
||||
fill: !isBar,
|
||||
tension: 0.3,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
transitions: { active: { animation: { duration: 0 } } },
|
||||
plugins: { legend: { display: false }, tooltip: { mode: 'index' } },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#9FA2BD',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: isBar ? undefined : 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#9FA2BD22' },
|
||||
ticks: { color: '#9FA2BD' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch([selectedRange, () => entries], renderChart, { flush: 'post' })
|
||||
|
||||
watch(canvasRef, (el) => {
|
||||
if (el) renderChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
@@ -1,63 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { TemplateReview } from '@/types/templateMarketplace'
|
||||
|
||||
import ReviewCard from './ReviewCard.vue'
|
||||
|
||||
function makeReview(overrides?: Partial<TemplateReview>): TemplateReview {
|
||||
return {
|
||||
id: 'rev-1',
|
||||
authorName: 'TestUser',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 4,
|
||||
text: 'Great template!',
|
||||
createdAt: new Date('2025-10-15'),
|
||||
templateId: 'tpl-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(review: TemplateReview) {
|
||||
return mount(ReviewCard, {
|
||||
props: { review },
|
||||
global: {
|
||||
stubs: {
|
||||
StarRating: {
|
||||
template: '<span data-testid="star-rating" :data-rating="rating" />',
|
||||
props: ['rating', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ReviewCard', () => {
|
||||
it('renders the author name', () => {
|
||||
const wrapper = mountCard(makeReview({ authorName: 'PixelWizard' }))
|
||||
expect(wrapper.text()).toContain('PixelWizard')
|
||||
})
|
||||
|
||||
it('renders the review text', () => {
|
||||
const wrapper = mountCard(makeReview({ text: 'Awesome workflow!' }))
|
||||
expect(wrapper.text()).toContain('Awesome workflow!')
|
||||
})
|
||||
|
||||
it('passes the rating to StarRating', () => {
|
||||
const wrapper = mountCard(makeReview({ rating: 3.5 }))
|
||||
const starRating = wrapper.find('[data-testid="star-rating"]')
|
||||
expect(starRating.exists()).toBe(true)
|
||||
expect(starRating.attributes('data-rating')).toBe('3.5')
|
||||
})
|
||||
|
||||
it('renders a formatted date', () => {
|
||||
const wrapper = mountCard(makeReview({ createdAt: new Date('2025-10-15') }))
|
||||
expect(wrapper.text()).toContain('2025')
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 rounded-lg bg-secondary-background p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span class="text-sm font-medium">{{ review.authorName }}</span>
|
||||
<StarRating :rating="review.rating" size="sm" />
|
||||
<span class="ml-auto text-xs text-muted-foreground">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-muted-foreground">{{ review.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TemplateReview } from '@/types/templateMarketplace'
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
const { review } = defineProps<{
|
||||
/** The review to display. */
|
||||
review: TemplateReview
|
||||
}>()
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
review.createdAt.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
)
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
function mountRating(rating: number, size?: 'sm' | 'md') {
|
||||
return mount(StarRating, {
|
||||
props: { rating, size }
|
||||
})
|
||||
}
|
||||
|
||||
describe('StarRating', () => {
|
||||
it('renders five star containers', () => {
|
||||
const wrapper = mountRating(3)
|
||||
const starContainers = wrapper.findAll('[role="img"] > div')
|
||||
expect(starContainers).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('fills all stars for a rating of 5', () => {
|
||||
const wrapper = mountRating(5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(5)
|
||||
for (const fill of fills) {
|
||||
expect(fill.attributes('style')).toContain('width: 100%')
|
||||
}
|
||||
})
|
||||
|
||||
it('fills no stars for a rating of 0', () => {
|
||||
const wrapper = mountRating(0)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders correct fills for a half-star rating of 3.5', () => {
|
||||
const wrapper = mountRating(3.5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(4)
|
||||
|
||||
expect(fills[0].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[1].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[2].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[3].attributes('style')).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('renders correct fills for a half-star rating of 2.5', () => {
|
||||
const wrapper = mountRating(2.5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(3)
|
||||
|
||||
expect(fills[0].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[1].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[2].attributes('style')).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('uses smaller size class when size is sm', () => {
|
||||
const wrapper = mountRating(3, 'sm')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('size-3.5')
|
||||
})
|
||||
|
||||
it('uses default size class when size is md', () => {
|
||||
const wrapper = mountRating(3, 'md')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('size-4')
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-0.5"
|
||||
role="img"
|
||||
:aria-label="ariaLabel"
|
||||
>
|
||||
<div v-for="i in 5" :key="i" class="relative" :class="starSizeClass">
|
||||
<i
|
||||
:class="
|
||||
cn('icon-[lucide--star]', starSizeClass, 'text-muted-foreground')
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="fillWidth(i) > 0"
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
:style="{ width: `${fillWidth(i)}%` }"
|
||||
>
|
||||
<i
|
||||
:class="cn('icon-[lucide--star]', starSizeClass, 'text-amber-400')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { rating, size = 'md' } = defineProps<{
|
||||
/** Star rating value from 0 to 5, supporting 0.5 increments. */
|
||||
rating: number
|
||||
/** Visual size variant. */
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const starSizeClass = computed(() => (size === 'sm' ? 'size-3.5' : 'size-4'))
|
||||
|
||||
const ariaLabel = computed(
|
||||
() => t('developerProfile.rating') + ': ' + String(rating) + '/5'
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the fill percentage (0, 50, or 100) for the star at position `i`.
|
||||
* @param i - 1-indexed star position.
|
||||
*/
|
||||
function fillWidth(i: number): number {
|
||||
if (rating >= i) return 100
|
||||
if (rating >= i - 0.5) return 50
|
||||
return 0
|
||||
}
|
||||
</script>
|
||||
@@ -1,154 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
import TemplateListItem from './TemplateListItem.vue'
|
||||
|
||||
function makeTemplate(
|
||||
overrides?: Partial<MarketplaceTemplate>
|
||||
): MarketplaceTemplate {
|
||||
return {
|
||||
id: 'tpl-1',
|
||||
title: 'Test Template',
|
||||
description: 'Full description',
|
||||
shortDescription: 'Short desc',
|
||||
author: {
|
||||
id: 'usr-1',
|
||||
name: 'Author',
|
||||
isVerified: true,
|
||||
profileUrl: '/author'
|
||||
},
|
||||
categories: [],
|
||||
tags: [],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 0,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
stats: {
|
||||
downloads: 1000,
|
||||
favorites: 50,
|
||||
rating: 4.5,
|
||||
reviewCount: 10,
|
||||
weeklyTrend: 2
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const stubRevenue: TemplateRevenue = {
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 10_000,
|
||||
monthlyRevenue: 1_500,
|
||||
currency: 'USD'
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
template?: MarketplaceTemplate
|
||||
revenue?: TemplateRevenue
|
||||
showRevenue?: boolean
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
function mountItem(options: MountOptions = {}) {
|
||||
return mount(TemplateListItem, {
|
||||
props: {
|
||||
template: options.template ?? makeTemplate(),
|
||||
revenue: options.revenue,
|
||||
showRevenue: options.showRevenue ?? false,
|
||||
isCurrentUser: options.isCurrentUser ?? false
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
StarRating: {
|
||||
template: '<span data-testid="star-rating" />',
|
||||
props: ['rating', 'size']
|
||||
},
|
||||
Button: {
|
||||
template: '<button data-testid="unpublish-button"><slot /></button>',
|
||||
props: ['variant', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplateListItem', () => {
|
||||
it('renders the template title and description', () => {
|
||||
const wrapper = mountItem({
|
||||
template: makeTemplate({
|
||||
title: 'My Workflow',
|
||||
shortDescription: 'A cool workflow'
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Workflow')
|
||||
expect(wrapper.text()).toContain('A cool workflow')
|
||||
})
|
||||
|
||||
it('renders download and favorite stats', () => {
|
||||
const wrapper = mountItem({
|
||||
template: makeTemplate({
|
||||
stats: {
|
||||
downloads: 5_000,
|
||||
favorites: 200,
|
||||
rating: 4,
|
||||
reviewCount: 15,
|
||||
weeklyTrend: 1
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('5,000')
|
||||
expect(wrapper.text()).toContain('200')
|
||||
})
|
||||
|
||||
it('hides revenue column when showRevenue is false', () => {
|
||||
const wrapper = mountItem({
|
||||
revenue: stubRevenue,
|
||||
showRevenue: false
|
||||
})
|
||||
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows revenue column when showRevenue is true', () => {
|
||||
const wrapper = mountItem({
|
||||
revenue: stubRevenue,
|
||||
showRevenue: true
|
||||
})
|
||||
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides unpublish button when isCurrentUser is false', () => {
|
||||
const wrapper = mountItem({ isCurrentUser: false })
|
||||
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('shows unpublish button when isCurrentUser is true', () => {
|
||||
const wrapper = mountItem({ isCurrentUser: true })
|
||||
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits unpublish event with template ID when button is clicked', async () => {
|
||||
const wrapper = mountItem({ isCurrentUser: true })
|
||||
await wrapper.find('[data-testid="unpublish-button"]').trigger('click')
|
||||
expect(wrapper.emitted('unpublish')).toEqual([['tpl-1']])
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-lg bg-secondary-background p-3"
|
||||
data-testid="template-list-item"
|
||||
>
|
||||
<div
|
||||
class="size-12 shrink-0 overflow-hidden rounded bg-modal-panel-background"
|
||||
>
|
||||
<img
|
||||
v-if="template.thumbnail"
|
||||
:src="template.thumbnail"
|
||||
:alt="template.title"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i class="icon-[lucide--image] size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="m-0 truncate text-sm font-medium">{{ template.title }}</h4>
|
||||
<p class="m-0 truncate text-xs text-muted-foreground">
|
||||
{{ template.shortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:title="t('developerProfile.downloads')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-3.5" />
|
||||
{{ template.stats.downloads.toLocaleString() }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:title="t('developerProfile.favorites')"
|
||||
>
|
||||
<i class="icon-[lucide--heart] size-3.5" />
|
||||
{{ template.stats.favorites.toLocaleString() }}
|
||||
</span>
|
||||
<StarRating :rating="template.stats.rating" size="sm" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRevenue && revenue"
|
||||
class="shrink-0 text-right text-xs"
|
||||
data-testid="revenue-column"
|
||||
>
|
||||
<div class="font-medium">{{ formatCurrency(revenue.totalRevenue) }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ formatCurrency(revenue.monthlyRevenue) }}/{{
|
||||
t('developerProfile.monthlyRevenue').toLowerCase()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isCurrentUser"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
data-testid="unpublish-button"
|
||||
@click="emit('unpublish', template.id)"
|
||||
>
|
||||
{{ t('developerProfile.unpublish') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
const {
|
||||
template,
|
||||
revenue,
|
||||
showRevenue = false,
|
||||
isCurrentUser = false
|
||||
} = defineProps<{
|
||||
/** The template to display. */
|
||||
template: MarketplaceTemplate
|
||||
/** Revenue data for this template, shown when showRevenue is true. */
|
||||
revenue?: TemplateRevenue
|
||||
/** Whether to display the revenue column. */
|
||||
showRevenue?: boolean
|
||||
/** Whether the profile being viewed belongs to the current user. */
|
||||
isCurrentUser?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Emitted when the unpublish button is clicked. */
|
||||
unpublish: [templateId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Formats a value in cents as a currency string.
|
||||
* @param cents - Amount in cents.
|
||||
*/
|
||||
function formatCurrency(cents: number): string {
|
||||
return (cents / 100).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -71,20 +71,30 @@ function getDialogPt(item: {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.global-dialog {
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-header {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
|
||||
@apply pb-0;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-content {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.global-dialog .p-dialog-header {
|
||||
padding: var(--p-dialog-header-padding);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-content {
|
||||
padding: var(--p-dialog-content-padding);
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
|
||||
173
src/components/dialog/content/MissingModelsContent.vue
Normal file
173
src/components/dialog/content/MissingModelsContent.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[490px] flex-col border-t border-border-default"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm leading-5 text-muted-foreground">
|
||||
{{ $t('missingModelsDialog.description') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<div
|
||||
v-for="model in processedModels"
|
||||
:key="model.name"
|
||||
class="flex items-center justify-between px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
class="min-w-0 truncate text-sm text-foreground"
|
||||
:title="model.name"
|
||||
>
|
||||
{{ model.name }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
|
||||
>
|
||||
{{ model.badgeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
v-if="model.isDownloadable && fileSizes.get(model.url)"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="model.isDownloadable"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.copyURL')"
|
||||
@click="void copyToClipboard(model.url)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="totalDownloadSize > 0"
|
||||
class="sticky bottom-0 flex items-center justify-between border-t border-border-default bg-secondary-background px-3 py-2"
|
||||
>
|
||||
<span class="text-xs font-medium text-muted-foreground">
|
||||
{{ $t('missingModelsDialog.totalSize') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatSize(totalDownloadSize) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="m-0 text-xs leading-5 text-muted-foreground whitespace-pre-line"
|
||||
>
|
||||
{{ $t('missingModelsDialog.footerDescription') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="hasCustomModels"
|
||||
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p
|
||||
class="m-0 text-xs font-semibold leading-5 text-warning-background"
|
||||
>
|
||||
{{ $t('missingModelsDialog.customModelsWarning') }}
|
||||
</p>
|
||||
<p class="m-0 text-xs leading-5 text-warning-background">
|
||||
{{ $t('missingModelsDialog.customModelsInstruction') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
import type { ModelWithUrl } from './missingModelsUtils'
|
||||
import {
|
||||
downloadModel,
|
||||
getBadgeLabel,
|
||||
hasValidDirectory,
|
||||
isModelDownloadable
|
||||
} from './missingModelsUtils'
|
||||
|
||||
const { missingModels, paths } = defineProps<{
|
||||
missingModels: ModelWithUrl[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
interface ProcessedModel {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
badgeLabel: string
|
||||
isDownloadable: boolean
|
||||
}
|
||||
|
||||
const processedModels = computed<ProcessedModel[]>(() =>
|
||||
missingModels.map((model) => ({
|
||||
name: model.name,
|
||||
url: model.url,
|
||||
directory: model.directory,
|
||||
badgeLabel: getBadgeLabel(model.directory),
|
||||
isDownloadable:
|
||||
hasValidDirectory(model, paths) && isModelDownloadable(model)
|
||||
}))
|
||||
)
|
||||
|
||||
const hasCustomModels = computed(() =>
|
||||
processedModels.value.some((m) => !m.isDownloadable)
|
||||
)
|
||||
|
||||
const fileSizes = reactive(new Map<string, number>())
|
||||
|
||||
const totalDownloadSize = computed(() =>
|
||||
processedModels.value
|
||||
.filter((model) => model.isDownloadable)
|
||||
.reduce((total, model) => total + (fileSizes.get(model.url) ?? 0), 0)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const downloadableUrls = processedModels.value
|
||||
.filter((m) => m.isDownloadable)
|
||||
.map((m) => m.url)
|
||||
|
||||
await Promise.allSettled(
|
||||
downloadableUrls.map(async (url) => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) return
|
||||
const size = response.headers.get('content-length')
|
||||
if (size) fileSizes.set(url, parseInt(size, 10))
|
||||
} catch {
|
||||
// Silently skip size fetch failures
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
103
src/components/dialog/content/MissingModelsFooter.vue
Normal file
103
src/components/dialog/content/MissingModelsFooter.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2 px-4 py-2">
|
||||
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
id="doNotAskAgainModels"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgainModels">{{
|
||||
$t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="ml-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button variant="secondary" size="md" @click="handleAction">
|
||||
{{ buttonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import type { ModelWithUrl } from './missingModelsUtils'
|
||||
import {
|
||||
downloadModel,
|
||||
hasValidDirectory,
|
||||
isModelDownloadable
|
||||
} from './missingModelsUtils'
|
||||
|
||||
const { missingModels, paths } = defineProps<{
|
||||
missingModels: ModelWithUrl[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
const DIALOG_KEY = 'global-missing-models-warning'
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
watch(doNotAskAgain, (value) => {
|
||||
void useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', !value)
|
||||
})
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const downloadableModels = computed(() =>
|
||||
missingModels.filter(
|
||||
(model) => hasValidDirectory(model, paths) && isModelDownloadable(model)
|
||||
)
|
||||
)
|
||||
|
||||
const hasDownloadable = computed(() => downloadableModels.value.length > 0)
|
||||
|
||||
const hasCustom = computed(
|
||||
() => downloadableModels.value.length < missingModels.length
|
||||
)
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (hasDownloadable.value && hasCustom.value)
|
||||
return t('missingModelsDialog.downloadAvailable')
|
||||
if (hasDownloadable.value) return t('missingModelsDialog.downloadAll')
|
||||
return t('missingModelsDialog.gotIt')
|
||||
})
|
||||
|
||||
function handleAction() {
|
||||
if (hasDownloadable.value) {
|
||||
for (const model of downloadableModels.value) {
|
||||
downloadModel(model, paths)
|
||||
}
|
||||
}
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
</script>
|
||||
10
src/components/dialog/content/MissingModelsHeader.vue
Normal file
10
src/components/dialog/content/MissingModelsHeader.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{ $t('missingModelsDialog.title') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,177 +0,0 @@
|
||||
<template>
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="t('missingModelsDialog.missingModels')"
|
||||
:message="t('missingModelsDialog.missingModelsMessage')"
|
||||
/>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
id="doNotAskAgain"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-6"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<ListBox :options="missingModels" class="comfy-missing-models">
|
||||
<template #option="{ option }">
|
||||
<Suspense v-if="isDesktop">
|
||||
<ElectronFileDownload
|
||||
:url="option.url"
|
||||
:label="option.label"
|
||||
:error="option.error"
|
||||
/>
|
||||
</Suspense>
|
||||
<FileDownload
|
||||
v-else
|
||||
:url="option.url"
|
||||
:label="option.label"
|
||||
:error="option.error"
|
||||
/>
|
||||
</template>
|
||||
</ListBox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
|
||||
import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
// as some installations may wish to use custom sources
|
||||
const allowedSources = [
|
||||
'https://civitai.com/',
|
||||
'https://huggingface.co/',
|
||||
'http://localhost:' // Included for testing usage only
|
||||
]
|
||||
const allowedSuffixes = ['.safetensors', '.sft']
|
||||
// Models that fail above conditions but are still allowed
|
||||
const whiteListedUrls = new Set([
|
||||
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
|
||||
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
])
|
||||
|
||||
interface ModelInfo {
|
||||
name: string
|
||||
directory: string
|
||||
url: string
|
||||
downloading?: boolean
|
||||
completed?: boolean
|
||||
progress?: number
|
||||
error?: string
|
||||
folder_path?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
missingModels: ModelInfo[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
const missingModels = computed(() => {
|
||||
return props.missingModels.map((model) => {
|
||||
const paths = props.paths[model.directory]
|
||||
if (!paths) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: 'Invalid directory specified (does this require custom nodes?)'
|
||||
}
|
||||
}
|
||||
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
|
||||
downloading: false,
|
||||
completed: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
folder_path: paths[0]
|
||||
}
|
||||
modelDownloads.value[model.name] = downloadInfo
|
||||
if (!whiteListedUrls.has(model.url)) {
|
||||
if (!allowedSources.some((source) => model.url.startsWith(source))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
|
||||
}
|
||||
}
|
||||
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: model.url,
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
downloading: downloadInfo.downloading,
|
||||
completed: downloadInfo.completed,
|
||||
progress: downloadInfo.progress,
|
||||
error: downloadInfo.error,
|
||||
name: model.name,
|
||||
paths: paths,
|
||||
folderPath: downloadInfo.folder_path
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
if (doNotAskAgain.value) {
|
||||
await useSettingStore().set(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning',
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-missing-models {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
83
src/components/dialog/content/missingModelsUtils.ts
Normal file
83
src/components/dialog/content/missingModelsUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
'https://huggingface.co/',
|
||||
'http://localhost:'
|
||||
] as const
|
||||
|
||||
const ALLOWED_SUFFIXES = [
|
||||
'.safetensors',
|
||||
'.sft',
|
||||
'.ckpt',
|
||||
'.pth',
|
||||
'.pt'
|
||||
] as const
|
||||
|
||||
const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
|
||||
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
|
||||
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
])
|
||||
|
||||
const DIRECTORY_BADGE_MAP = {
|
||||
vae: 'VAE',
|
||||
diffusion_models: 'DIFFUSION',
|
||||
text_encoders: 'TEXT ENCODER',
|
||||
loras: 'LORA',
|
||||
checkpoints: 'CHECKPOINT'
|
||||
} as const
|
||||
|
||||
export interface ModelWithUrl {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export function isModelDownloadable(model: ModelWithUrl): boolean {
|
||||
if (WHITE_LISTED_URLS.has(model.url)) return true
|
||||
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
|
||||
return false
|
||||
if (!ALLOWED_SUFFIXES.some((suffix) => model.name.endsWith(suffix)))
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasValidDirectory(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): boolean {
|
||||
return !!paths[model.directory]
|
||||
}
|
||||
|
||||
export function getBadgeLabel(directory: string): string {
|
||||
if (directory in DIRECTORY_BADGE_MAP) {
|
||||
return DIRECTORY_BADGE_MAP[directory as keyof typeof DIRECTORY_BADGE_MAP]
|
||||
}
|
||||
return directory.toUpperCase()
|
||||
}
|
||||
|
||||
export function downloadModel(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
if (!isDesktop) {
|
||||
const link = document.createElement('a')
|
||||
link.href = model.url
|
||||
link.download = model.name
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
const modelPaths = paths[model.directory]
|
||||
if (modelPaths?.[0]) {
|
||||
void useElectronDownloadStore().start({
|
||||
url: model.url,
|
||||
savePath: modelPaths[0],
|
||||
filename: model.name
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@
|
||||
}"
|
||||
@row-dblclick="editKeybinding($event.data)"
|
||||
>
|
||||
<Column field="actions" header="">
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<template #body="slotProps">
|
||||
<div class="actions invisible flex flex-row">
|
||||
<div class="actions flex flex-row">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@@ -56,6 +56,7 @@
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="truncate" :title="slotProps.data.id">
|
||||
@@ -63,7 +64,11 @@
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="keybinding" :header="$t('g.keybinding')">
|
||||
<Column
|
||||
field="keybinding"
|
||||
:header="$t('g.keybinding')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<KeyComboDisplay
|
||||
v-if="slotProps.data.keybinding"
|
||||
@@ -75,7 +80,11 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="source" :header="$t('g.source')">
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
slotProps.data.source || '-'
|
||||
@@ -293,17 +302,3 @@ async function resetAllKeybindings() {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-datatable-tbody) > tr > td {
|
||||
@apply p-1;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
:deep(.p-datatable-row-selected) .actions,
|
||||
:deep(.p-datatable-selectable-row:hover) .actions {
|
||||
@apply visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,16 +98,17 @@ describe('SignInForm', () => {
|
||||
await nextTick()
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
|
||||
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
|
||||
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
|
||||
})
|
||||
|
||||
it('shows toast and focuses email input when clicked while disabled', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
// Mock getElementById to track focus
|
||||
@@ -152,7 +153,7 @@ describe('SignInForm', () => {
|
||||
)
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
// Click the forgot password link
|
||||
|
||||
@@ -34,10 +34,13 @@
|
||||
{{ t('auth.login.passwordLabel') }}
|
||||
</label>
|
||||
<span
|
||||
class="cursor-pointer text-base font-medium text-muted select-none"
|
||||
:class="{
|
||||
'text-link-disabled': !$form.email?.value || $form.email?.invalid
|
||||
}"
|
||||
:class="
|
||||
cn('text-base font-medium text-muted select-none', {
|
||||
'cursor-not-allowed opacity-50':
|
||||
!$form.email?.value || $form.email?.invalid,
|
||||
'cursor-pointer': $form.email?.value && !$form.email?.invalid
|
||||
})
|
||||
"
|
||||
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
|
||||
>
|
||||
{{ t('auth.login.forgotPassword') }}
|
||||
@@ -89,6 +92,7 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
|
||||
import { signInSchema } from '@/schemas/signInSchema'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
@@ -126,11 +130,3 @@ const handleForgotPassword = async (
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
.text-link-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<!--
|
||||
Floating indicator that displays the estimated VRAM requirement
|
||||
for the currently loaded workflow graph.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
v-if="vramEstimate > 0"
|
||||
class="pointer-events-auto absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/40 px-2.5 py-1.5 text-xs font-medium text-white/90 backdrop-blur-sm"
|
||||
:title="t('templateWorkflows.vramEstimateTooltip')"
|
||||
>
|
||||
<i class="icon-[lucide--cpu] h-3.5 w-3.5" />
|
||||
{{ formatSize(vramEstimate) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const vramEstimate = ref(0)
|
||||
|
||||
watchEffect(() => {
|
||||
vramEstimate.value = estimateWorkflowVram(app.rootGraph)
|
||||
})
|
||||
</script>
|
||||
@@ -8,20 +8,38 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
// Import after mocks
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
function createMockWorkflow(
|
||||
overrides: Partial<LoadedComfyWorkflow> = {}
|
||||
): LoadedComfyWorkflow {
|
||||
return {
|
||||
changeTracker: {
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: 'workflows/color-picker-test.json',
|
||||
modified: 0,
|
||||
size: 0
|
||||
})
|
||||
|
||||
const changeTracker = Object.assign(
|
||||
new ChangeTracker(workflow, structuredClone(defaultGraph)),
|
||||
{
|
||||
checkState: vi.fn() as Mock
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const workflowOverrides = {
|
||||
changeTracker,
|
||||
...overrides
|
||||
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
|
||||
} satisfies Partial<LoadedComfyWorkflow>
|
||||
|
||||
return Object.assign(workflow, workflowOverrides) as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
// Mock the litegraph module
|
||||
@@ -110,12 +128,14 @@ describe('ColorPickerButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(true)
|
||||
const picker = wrapper.findComponent({ name: 'SelectButton' })
|
||||
expect(picker.exists()).toBe(true)
|
||||
expect(picker.findAll('button').length).toBeGreaterThan(0)
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,13 +11,17 @@
|
||||
@click="() => (showColorPicker = !showColorPicker)"
|
||||
>
|
||||
<div class="flex items-center gap-1 px-0">
|
||||
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
|
||||
<i
|
||||
class="pi pi-circle-fill"
|
||||
data-testid="color-picker-current-color"
|
||||
:style="{ color: currentColor ?? '' }"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</div>
|
||||
</Button>
|
||||
<div
|
||||
v-if="showColorPicker"
|
||||
class="color-picker-container absolute -top-10 left-1/2"
|
||||
class="absolute -top-10 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<SelectButton
|
||||
:model-value="selectedColorOption"
|
||||
@@ -159,13 +163,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
.color-picker-container {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) {
|
||||
@apply py-2 px-1;
|
||||
padding: calc(var(--spacing) * 2) var(--spacing);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<div
|
||||
v-show="widgetState.visible"
|
||||
ref="widgetElement"
|
||||
class="dom-widget"
|
||||
class="dom-widget h-full w-full"
|
||||
:title="tooltip"
|
||||
:style="style"
|
||||
>
|
||||
<component
|
||||
:is="widget.component"
|
||||
v-if="isComponentWidget(widget)"
|
||||
class="h-full w-full"
|
||||
:model-value="widget.value"
|
||||
:widget="widget"
|
||||
v-bind="widget.props"
|
||||
@@ -174,6 +175,8 @@ const mountElementIfVisible = () => {
|
||||
if (widgetElement.value.contains(widget.element)) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.element.classList.add('h-full', 'w-full')
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
|
||||
@@ -196,11 +199,3 @@ watch(
|
||||
|
||||
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
.dom-widget > * {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,9 +24,7 @@ interface Props {
|
||||
modelValue: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
step: 1
|
||||
})
|
||||
const { label, min, max, step = 1, modelValue } = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
<!-- Markdown fetched successfully -->
|
||||
<div
|
||||
v-else-if="!error"
|
||||
class="markdown-content"
|
||||
class="markdown-content overflow-visible text-sm leading-(--text-sm--line-height)"
|
||||
v-html="renderedHelpHtml"
|
||||
/>
|
||||
<!-- Fallback: markdown not found or fetch error -->
|
||||
<div v-else class="fallback-content space-y-6 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="fallback-content space-y-6 text-sm leading-(--text-sm--line-height)"
|
||||
>
|
||||
<p v-if="node.description">
|
||||
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
|
||||
</p>
|
||||
@@ -22,48 +25,52 @@
|
||||
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
|
||||
</p>
|
||||
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="outputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
|
||||
</p>
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,39 +107,59 @@ const outputList = computed(() =>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference './../../assets/css/style.css';
|
||||
|
||||
.node-help-content :deep(:is(img, video)) {
|
||||
@apply max-w-full h-auto block mb-4;
|
||||
}
|
||||
|
||||
.markdown-content,
|
||||
.fallback-content {
|
||||
@apply text-sm overflow-visible;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.fallback-content h1 {
|
||||
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h2),
|
||||
.fallback-content h2 {
|
||||
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3),
|
||||
.fallback-content h3 {
|
||||
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4),
|
||||
.fallback-content h4 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h5),
|
||||
.fallback-content h5 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(h6),
|
||||
.fallback-content h4,
|
||||
.fallback-content h5,
|
||||
.fallback-content h6 {
|
||||
@apply mt-8 mb-4 first:mt-0;
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.markdown-content :deep(td),
|
||||
@@ -155,7 +182,8 @@ const outputList = computed(() =>
|
||||
.markdown-content :deep(ol),
|
||||
.fallback-content ul,
|
||||
.fallback-content ol {
|
||||
@apply pl-8 my-2;
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 8);
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul ul),
|
||||
@@ -166,36 +194,42 @@ const outputList = computed(() =>
|
||||
.fallback-content ol ol,
|
||||
.fallback-content ul ol,
|
||||
.fallback-content ol ul {
|
||||
@apply pl-6 my-2;
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 6);
|
||||
}
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
@apply my-2;
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
.fallback-content > *:first-child {
|
||||
@apply mt-0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
color: var(--code-text-color);
|
||||
background-color: var(--code-bg-color);
|
||||
@apply rounded px-1.5 py-0.5;
|
||||
border-radius: var(--radius);
|
||||
padding: calc(var(--spacing) * 0.5) calc(var(--spacing) * 1.5);
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
.fallback-content table {
|
||||
@apply w-full border-collapse;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.fallback-content table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content th,
|
||||
.fallback-content td {
|
||||
@apply px-2 py-2;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr),
|
||||
@@ -215,16 +249,22 @@ const outputList = computed(() =>
|
||||
|
||||
.markdown-content :deep(pre),
|
||||
.fallback-content pre {
|
||||
@apply rounded p-4 my-4 overflow-x-auto;
|
||||
margin-block: calc(var(--spacing) * 4);
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius);
|
||||
padding: calc(var(--spacing) * 4);
|
||||
background-color: var(--code-block-bg-color);
|
||||
|
||||
code {
|
||||
@apply bg-transparent p-0;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-content :deep(table) {
|
||||
@apply overflow-x-auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,15 +75,10 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'active' | 'expanded'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>(),
|
||||
{
|
||||
menuHovered: false
|
||||
}
|
||||
)
|
||||
const { expanded, menuHovered = false } = defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
@@ -106,13 +101,12 @@ const {
|
||||
currentNodeProgressStyle
|
||||
} = useQueueProgress()
|
||||
const isHovered = ref(false)
|
||||
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
|
||||
const isOverlayHovered = computed(() => isHovered.value || menuHovered)
|
||||
const internalExpanded = ref(false)
|
||||
const isExpanded = computed({
|
||||
get: () =>
|
||||
props.expanded === undefined ? internalExpanded.value : props.expanded,
|
||||
get: () => (expanded === undefined ? internalExpanded.value : expanded),
|
||||
set: (value) => {
|
||||
if (props.expanded === undefined) {
|
||||
if (expanded === undefined) {
|
||||
internalExpanded.value = value
|
||||
}
|
||||
emit('update:expanded', value)
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
>
|
||||
<JobDetailsPopover
|
||||
:job-id="props.jobId"
|
||||
:workflow-id="props.workflowId"
|
||||
/>
|
||||
<JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" />
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
@@ -36,7 +33,7 @@
|
||||
>
|
||||
<QueueAssetPreview
|
||||
:image-url="iconImageUrl!"
|
||||
:name="props.title"
|
||||
:name="title"
|
||||
:time-label="rightText || undefined"
|
||||
@image-click="emit('view')"
|
||||
/>
|
||||
@@ -49,23 +46,20 @@
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
props.state === 'running' &&
|
||||
hasAnyProgressPercent(
|
||||
props.progressTotalPercent,
|
||||
props.progressCurrentPercent
|
||||
)
|
||||
state === 'running' &&
|
||||
hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent)
|
||||
"
|
||||
:class="progressBarContainerClass"
|
||||
>
|
||||
<div
|
||||
v-if="hasProgressPercent(props.progressTotalPercent)"
|
||||
v-if="hasProgressPercent(progressTotalPercent)"
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(props.progressTotalPercent)"
|
||||
:style="progressPercentStyle(progressTotalPercent)"
|
||||
/>
|
||||
<div
|
||||
v-if="hasProgressPercent(props.progressCurrentPercent)"
|
||||
v-if="hasProgressPercent(progressCurrentPercent)"
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(props.progressCurrentPercent)"
|
||||
:style="progressPercentStyle(progressCurrentPercent)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -93,8 +87,8 @@
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 min-w-0 flex-1">
|
||||
<div class="truncate opacity-90" :title="props.title">
|
||||
<slot name="primary">{{ props.title }}</slot>
|
||||
<div class="truncate opacity-90" :title="title">
|
||||
<slot name="primary">{{ title }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +125,7 @@
|
||||
class="inline-flex items-center gap-2 pr-1"
|
||||
>
|
||||
<Button
|
||||
v-if="props.state === 'failed' && computedShowClear"
|
||||
v-if="state === 'failed' && computedShowClear"
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
@@ -142,8 +136,8 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
props.state !== 'completed' &&
|
||||
props.state !== 'running' &&
|
||||
state !== 'completed' &&
|
||||
state !== 'running' &&
|
||||
computedShowClear
|
||||
"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
@@ -155,14 +149,14 @@
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="props.state === 'completed'"
|
||||
v-else-if="state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emit('view')"
|
||||
>{{ t('menuLabels.View') }}</Button
|
||||
>
|
||||
<Button
|
||||
v-if="props.showMenu !== undefined ? props.showMenu : true"
|
||||
v-if="showMenu !== undefined ? showMenu : true"
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
@@ -172,17 +166,13 @@
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="props.state !== 'running'"
|
||||
key="secondary"
|
||||
class="pr-2"
|
||||
>
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
|
||||
<slot name="secondary">{{ rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<Button
|
||||
v-if="props.state === 'running' && computedShowClear"
|
||||
v-if="state === 'running' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
@@ -209,34 +199,33 @@ import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
state: JobState
|
||||
title: string
|
||||
rightText?: string
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
showMenu?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
activeDetailsId?: string | null
|
||||
}>(),
|
||||
{
|
||||
workflowId: undefined,
|
||||
rightText: '',
|
||||
iconName: undefined,
|
||||
iconImageUrl: undefined,
|
||||
showClear: undefined,
|
||||
showMenu: undefined,
|
||||
progressTotalPercent: undefined,
|
||||
progressCurrentPercent: undefined,
|
||||
runningNodeName: undefined,
|
||||
activeDetailsId: null
|
||||
}
|
||||
)
|
||||
const {
|
||||
jobId,
|
||||
workflowId,
|
||||
state,
|
||||
title,
|
||||
rightText = '',
|
||||
iconName,
|
||||
iconImageUrl,
|
||||
showClear,
|
||||
showMenu,
|
||||
progressTotalPercent,
|
||||
progressCurrentPercent,
|
||||
activeDetailsId = null
|
||||
} = defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
state: JobState
|
||||
title: string
|
||||
rightText?: string
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
showMenu?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
activeDetailsId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
@@ -262,14 +251,14 @@ const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const rowRef = ref<HTMLDivElement | null>(null)
|
||||
const showDetails = computed(() => props.activeDetailsId === props.jobId)
|
||||
const showDetails = computed(() => activeDetailsId === jobId)
|
||||
|
||||
const onRowEnter = () => {
|
||||
if (!isPreviewVisible.value) emit('details-enter', props.jobId)
|
||||
if (!isPreviewVisible.value) emit('details-enter', jobId)
|
||||
}
|
||||
const onRowLeave = () => emit('details-leave', props.jobId)
|
||||
const onPopoverEnter = () => emit('details-enter', props.jobId)
|
||||
const onPopoverLeave = () => emit('details-leave', props.jobId)
|
||||
const onRowLeave = () => emit('details-leave', jobId)
|
||||
const onPopoverEnter = () => emit('details-enter', jobId)
|
||||
const onPopoverLeave = () => emit('details-leave', jobId)
|
||||
|
||||
const isPreviewVisible = ref(false)
|
||||
const previewHideTimer = ref<number | null>(null)
|
||||
@@ -286,9 +275,7 @@ const clearPreviewShowTimer = () => {
|
||||
previewShowTimer.value = null
|
||||
}
|
||||
}
|
||||
const canShowPreview = computed(
|
||||
() => props.state === 'completed' && !!props.iconImageUrl
|
||||
)
|
||||
const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl)
|
||||
const scheduleShowPreview = () => {
|
||||
if (!canShowPreview.value) return
|
||||
clearPreviewHideTimer()
|
||||
@@ -343,23 +330,23 @@ watch(
|
||||
const isHovered = ref(false)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (props.iconName) return props.iconName
|
||||
return iconForJobState(props.state)
|
||||
if (iconName) return iconName
|
||||
return iconForJobState(state)
|
||||
})
|
||||
|
||||
const shouldSpin = computed(
|
||||
() =>
|
||||
props.state === 'pending' &&
|
||||
state === 'pending' &&
|
||||
iconClass.value === iconForJobState('pending') &&
|
||||
!props.iconImageUrl
|
||||
!iconImageUrl
|
||||
)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (props.showClear !== undefined) return props.showClear
|
||||
return props.state !== 'completed'
|
||||
if (showClear !== undefined) return showClear
|
||||
return state !== 'completed'
|
||||
})
|
||||
|
||||
const emitDetailsLeave = () => emit('details-leave', props.jobId)
|
||||
const emitDetailsLeave = () => emit('details-leave', jobId)
|
||||
|
||||
const onCancelClick = () => {
|
||||
emitDetailsLeave()
|
||||
@@ -372,7 +359,7 @@ const onDeleteClick = () => {
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
|
||||
const shouldShowMenu = showMenu !== undefined ? showMenu : true
|
||||
if (shouldShowMenu) emit('menu', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,21 +11,22 @@ interface Props {
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 150,
|
||||
easingEnter: 'ease-in-out',
|
||||
easingLeave: 'ease-in-out',
|
||||
opacityClosed: 0,
|
||||
opacityOpened: 1
|
||||
})
|
||||
const {
|
||||
duration = 150,
|
||||
easingEnter = 'ease-in-out',
|
||||
easingLeave = 'ease-in-out',
|
||||
opacityClosed = 0,
|
||||
opacityOpened = 1,
|
||||
disable
|
||||
} = defineProps<Props>()
|
||||
|
||||
const closed = '0px'
|
||||
|
||||
const isMounted = ref(false)
|
||||
onMounted(() => (isMounted.value = true))
|
||||
|
||||
const duration = computed(() =>
|
||||
isMounted.value && !props.disable ? props.duration : 0
|
||||
const animationDuration = computed(() =>
|
||||
isMounted.value && !disable ? duration : 0
|
||||
)
|
||||
|
||||
interface initialStyle {
|
||||
@@ -95,7 +96,7 @@ function getEnterKeyframes(height: string, initialStyle: initialStyle) {
|
||||
return [
|
||||
{
|
||||
height: closed,
|
||||
opacity: props.opacityClosed,
|
||||
opacity: opacityClosed,
|
||||
paddingTop: closed,
|
||||
paddingBottom: closed,
|
||||
borderTopWidth: closed,
|
||||
@@ -105,7 +106,7 @@ function getEnterKeyframes(height: string, initialStyle: initialStyle) {
|
||||
},
|
||||
{
|
||||
height,
|
||||
opacity: props.opacityOpened,
|
||||
opacity: opacityOpened,
|
||||
paddingTop: initialStyle.paddingTop,
|
||||
paddingBottom: initialStyle.paddingBottom,
|
||||
borderTopWidth: initialStyle.borderTopWidth,
|
||||
@@ -121,7 +122,7 @@ function enterTransition(element: Element, done: () => void) {
|
||||
const initialStyle = getElementStyle(HTMLElement)
|
||||
const height = prepareElement(HTMLElement, initialStyle)
|
||||
const keyframes = getEnterKeyframes(height, initialStyle)
|
||||
const options = { duration: duration.value, easing: props.easingEnter }
|
||||
const options = { duration: animationDuration.value, easing: easingEnter }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ function leaveTransition(element: Element, done: () => void) {
|
||||
HTMLElement.style.height = height
|
||||
HTMLElement.style.overflow = 'hidden'
|
||||
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
|
||||
const options = { duration: duration.value, easing: props.easingLeave }
|
||||
const options = { duration: animationDuration.value, easing: easingLeave }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="_content">
|
||||
<div class="flex flex-col gap-2">
|
||||
<SelectButton
|
||||
v-model="selectedFilter"
|
||||
class="filter-type-select"
|
||||
@@ -16,7 +16,7 @@
|
||||
auto-filter-focus
|
||||
/>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<div class="flex flex-col items-end pt-4">
|
||||
<Button type="button" @click="submit">{{ $t('g.add') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,15 +67,3 @@ const submit = () => {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
._content {
|
||||
@apply flex flex-col space-y-2;
|
||||
}
|
||||
|
||||
._footer {
|
||||
@apply flex flex-col pt-4 items-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,8 +255,6 @@ onMounted(() => {
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
.floating-sidebar {
|
||||
padding: var(--sidebar-padding);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:aria-label="computedTooltip"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<div class="side-bar-button-content">
|
||||
<div class="side-bar-button-content flex flex-col items-center gap-2">
|
||||
<slot name="icon">
|
||||
<div class="sidebar-icon-wrapper relative">
|
||||
<i
|
||||
@@ -40,9 +40,11 @@
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
st(label, label)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="label && !isSmall"
|
||||
class="side-bar-button-label text-center text-[10px]"
|
||||
>{{ st(label, label) }}</span
|
||||
>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -104,8 +106,6 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.side-bar-button {
|
||||
width: var(--sidebar-width);
|
||||
height: var(--sidebar-item-height);
|
||||
@@ -117,12 +117,7 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
height: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.side-bar-button-content {
|
||||
@apply flex flex-col items-center gap-2;
|
||||
}
|
||||
|
||||
.side-bar-button-label {
|
||||
@apply text-[10px] text-center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
|
||||
<Toolbar
|
||||
class="min-h-16 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
|
||||
:pt="sidebarPt"
|
||||
>
|
||||
<template #start>
|
||||
<span class="truncate font-bold" :title="props.title">
|
||||
@@ -20,7 +21,7 @@
|
||||
</template>
|
||||
<template #end>
|
||||
<div
|
||||
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
|
||||
class="touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
|
||||
>
|
||||
<slot name="tool-buttons" />
|
||||
</div>
|
||||
@@ -54,19 +55,10 @@ const props = defineProps<{
|
||||
title: string
|
||||
class?: string
|
||||
}>()
|
||||
const sidebarPt = {
|
||||
start: 'min-w-0 flex-1 overflow-hidden'
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
provide(SidebarContainerKey, containerRef)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-toolbar-end) .p-button {
|
||||
@apply py-1 2xl:py-2;
|
||||
}
|
||||
|
||||
:deep(.p-toolbar-start) {
|
||||
@apply min-w-0 flex-1 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="node-lib-node-container"
|
||||
class="node-lib-node-container h-full w-full"
|
||||
data-testid="node-tree-leaf"
|
||||
:data-node-name="nodeDef.display_name"
|
||||
>
|
||||
@@ -206,11 +206,3 @@ onUnmounted(() => {
|
||||
nodeContentElement.value?.removeEventListener('mouseleave', handleMouseLeave)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
.node-lib-node-container {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CachedAsset } from '@/types/templateMarketplace'
|
||||
|
||||
import TemplateAssetUploadZone from './TemplateAssetUploadZone.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
previewGeneration: {
|
||||
uploadPrompt: 'Click to upload',
|
||||
removeFile: 'Remove'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeAsset(name: string): CachedAsset {
|
||||
return {
|
||||
file: new File(['data'], name, { type: 'image/png' }),
|
||||
objectUrl: `blob:http://localhost/${name}`,
|
||||
originalName: name
|
||||
}
|
||||
}
|
||||
|
||||
function mountZone(props: Record<string, unknown> = {}) {
|
||||
return mount(TemplateAssetUploadZone, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplateAssetUploadZone', () => {
|
||||
it('shows the upload prompt when no asset is provided', () => {
|
||||
const wrapper = mountZone()
|
||||
|
||||
expect(wrapper.text()).toContain('Click to upload')
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows an image preview when an asset is provided', () => {
|
||||
const asset = makeAsset('photo.png')
|
||||
const wrapper = mountZone({ asset })
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe(asset.objectUrl)
|
||||
expect(wrapper.text()).toContain('photo.png')
|
||||
})
|
||||
|
||||
it('shows a video element when previewType is video', () => {
|
||||
const asset = makeAsset('demo.mp4')
|
||||
const wrapper = mountZone({ asset, previewType: 'video' })
|
||||
|
||||
expect(wrapper.find('video').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits upload with the selected file', async () => {
|
||||
const wrapper = mountZone()
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
|
||||
const file = new File(['bytes'], 'test.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { value: [file] })
|
||||
await input.trigger('change')
|
||||
|
||||
expect(wrapper.emitted('upload')).toHaveLength(1)
|
||||
expect(wrapper.emitted('upload')![0]).toEqual([file])
|
||||
})
|
||||
|
||||
it('emits remove when the remove button is clicked', async () => {
|
||||
const wrapper = mountZone({ asset: makeAsset('photo.png') })
|
||||
const removeBtn = wrapper.find('button[aria-label="Remove"]')
|
||||
|
||||
await removeBtn.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('applies the provided sizeClass to the upload zone', () => {
|
||||
const wrapper = mountZone({ sizeClass: 'h-40 w-64' })
|
||||
const zone = wrapper.find('[role="button"]')
|
||||
|
||||
expect(zone.classes()).toContain('h-40')
|
||||
expect(zone.classes()).toContain('w-64')
|
||||
})
|
||||
|
||||
it('uses image/* accept filter by default', () => {
|
||||
const wrapper = mountZone()
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
|
||||
expect(input.attributes('accept')).toBe('image/*')
|
||||
})
|
||||
|
||||
it('applies a custom accept filter', () => {
|
||||
const wrapper = mountZone({ accept: 'video/*' })
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
|
||||
expect(input.attributes('accept')).toBe('video/*')
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
<!--
|
||||
Reusable upload zone for a single file asset. Shows a dashed click-to-upload
|
||||
area when empty, and a preview with filename overlay when populated.
|
||||
-->
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!asset"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-default hover:border-muted-foreground',
|
||||
sizeClass
|
||||
)
|
||||
"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="t('templatePublishing.steps.previewGeneration.uploadPrompt')"
|
||||
@click="fileInput?.click()"
|
||||
@keydown.enter="fileInput?.click()"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-muted-foreground">
|
||||
<i class="icon-[lucide--upload] h-5 w-5" />
|
||||
<span class="text-xs">
|
||||
{{ t('templatePublishing.steps.previewGeneration.uploadPrompt') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="cn('group relative overflow-hidden rounded-lg', sizeClass)"
|
||||
>
|
||||
<img
|
||||
v-if="previewType === 'image'"
|
||||
:src="asset.objectUrl"
|
||||
:alt="asset.originalName"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="asset.objectUrl"
|
||||
controls
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-2 py-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span class="truncate text-xs text-white">
|
||||
{{ asset.originalName }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-white hover:text-danger"
|
||||
:aria-label="
|
||||
t('templatePublishing.steps.previewGeneration.removeFile')
|
||||
"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { CachedAsset } from '@/types/templateMarketplace'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
asset = null,
|
||||
accept = 'image/*',
|
||||
previewType = 'image',
|
||||
sizeClass = 'h-32 w-48'
|
||||
} = defineProps<{
|
||||
asset?: CachedAsset | null
|
||||
accept?: string
|
||||
previewType?: 'image' | 'video'
|
||||
sizeClass?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [file: File]
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
emit('upload', file)
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,182 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplatePublishStorage',
|
||||
() => ({
|
||||
loadTemplateUnderway: vi.fn(() => null),
|
||||
saveTemplateUnderway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
import TemplatePublishingDialog from './TemplatePublishingDialog.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
dialogTitle: 'Template Publishing',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
saveDraft: 'Save Draft',
|
||||
stepProgress: 'Step {current} of {total}',
|
||||
steps: {
|
||||
landing: {
|
||||
title: 'Getting Started',
|
||||
description: 'Overview of the publishing process'
|
||||
},
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
description: 'Title, description, and author info'
|
||||
},
|
||||
description: {
|
||||
title: 'Description',
|
||||
description: 'Write a detailed description of your template'
|
||||
},
|
||||
previewGeneration: {
|
||||
title: 'Preview',
|
||||
description: 'Generate preview images and videos'
|
||||
},
|
||||
categoryAndTagging: {
|
||||
title: 'Categories & Tags',
|
||||
description: 'Categorize and tag your template'
|
||||
},
|
||||
preview: {
|
||||
title: 'Preview',
|
||||
description: 'Review your template before submitting'
|
||||
},
|
||||
submissionForReview: {
|
||||
title: 'Submit',
|
||||
description: 'Submit your template for review'
|
||||
},
|
||||
complete: {
|
||||
title: 'Complete',
|
||||
description: 'Your template has been submitted'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountDialog(props?: { initialPage?: string }) {
|
||||
return mount(TemplatePublishingDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseModalLayout: {
|
||||
template: `
|
||||
<div data-testid="modal">
|
||||
<div data-testid="left-panel"><slot name="leftPanel" /></div>
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-right"><slot name="header-right-area" /></div>
|
||||
<div data-testid="content"><slot name="content" /></div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
TemplatePublishingStepperNav: {
|
||||
template: '<div data-testid="stepper-nav" />',
|
||||
props: ['currentStep', 'stepDefinitions']
|
||||
},
|
||||
StepTemplatePublishingLanding: {
|
||||
template: '<div data-testid="step-landing" />'
|
||||
},
|
||||
StepTemplatePublishingMetadata: {
|
||||
template: '<div data-testid="step-metadata" />'
|
||||
},
|
||||
StepTemplatePublishingDescription: {
|
||||
template: '<div data-testid="step-description" />'
|
||||
},
|
||||
StepTemplatePublishingPreviewGeneration: {
|
||||
template: '<div data-testid="step-preview-generation" />'
|
||||
},
|
||||
StepTemplatePublishingCategoryAndTagging: {
|
||||
template: '<div data-testid="step-category" />'
|
||||
},
|
||||
StepTemplatePublishingPreview: {
|
||||
template: '<div data-testid="step-preview" />'
|
||||
},
|
||||
StepTemplatePublishingSubmissionForReview: {
|
||||
template: '<div data-testid="step-submission" />'
|
||||
},
|
||||
StepTemplatePublishingComplete: {
|
||||
template: '<div data-testid="step-complete" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplatePublishingDialog', () => {
|
||||
it('renders the dialog with the first step by default', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.find('[data-testid="modal"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the stepper nav in the left panel', () => {
|
||||
const wrapper = mountDialog()
|
||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||
expect(leftPanel.find('[data-testid="stepper-nav"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('maps initialPage to the correct starting step', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'metadata' })
|
||||
expect(wrapper.find('[data-testid="step-metadata"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to step 1 for unknown initialPage', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'nonexistent' })
|
||||
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows Previous button when not on first step', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'metadata' })
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const buttons = headerRight.findAll('button')
|
||||
const buttonTexts = buttons.map((b) => b.text())
|
||||
expect(buttonTexts.some((text) => text.includes('Previous'))).toBe(true)
|
||||
})
|
||||
|
||||
it('disables Previous button on first step', () => {
|
||||
const wrapper = mountDialog()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const prevButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Previous'))
|
||||
expect(prevButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Next button on last step', () => {
|
||||
const wrapper = mountDialog({
|
||||
initialPage: 'complete'
|
||||
})
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const nextButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Next'))
|
||||
expect(nextButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Next button on submit step', () => {
|
||||
const wrapper = mountDialog({
|
||||
initialPage: 'submissionForReview'
|
||||
})
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const nextButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Next'))
|
||||
expect(nextButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,152 +0,0 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="t('templatePublishing.dialogTitle')"
|
||||
size="md"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--upload]" />
|
||||
<h2 class="text-neutral text-base">
|
||||
{{ t('templatePublishing.dialogTitle') }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<template #leftPanel>
|
||||
<TemplatePublishingStepperNav
|
||||
:current-step="currentStep"
|
||||
:step-definitions="stepDefinitions"
|
||||
@update:current-step="goToStep"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t('templatePublishing.stepProgress', {
|
||||
current: currentStep,
|
||||
total: totalSteps
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
<div class="mr-6 flex gap-2">
|
||||
<Button
|
||||
:disabled="isFirstStep"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="prevStep"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left]" />
|
||||
{{ t('templatePublishing.previous') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="
|
||||
currentStep >= totalSteps - 1 ||
|
||||
currentStep === STEP_PAGE_MAP.preview
|
||||
"
|
||||
size="lg"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ t('templatePublishing.next') }}
|
||||
<i class="icon-[lucide--arrow-right]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<component :is="activeStepComponent" />
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import { useTemplatePublishingStepper } from '@/composables/useTemplatePublishingStepper'
|
||||
|
||||
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
|
||||
import StepTemplatePublishingCategoryAndTagging from './steps/StepTemplatePublishingCategoryAndTagging.vue'
|
||||
import StepTemplatePublishingComplete from './steps/StepTemplatePublishingComplete.vue'
|
||||
import StepTemplatePublishingDescription from './steps/StepTemplatePublishingDescription.vue'
|
||||
import StepTemplatePublishingLanding from './steps/StepTemplatePublishingLanding.vue'
|
||||
import StepTemplatePublishingMetadata from './steps/StepTemplatePublishingMetadata.vue'
|
||||
import StepTemplatePublishingPreview from './steps/StepTemplatePublishingPreview.vue'
|
||||
import StepTemplatePublishingPreviewGeneration from './steps/StepTemplatePublishingPreviewGeneration.vue'
|
||||
import StepTemplatePublishingSubmissionForReview from './steps/StepTemplatePublishingSubmissionForReview.vue'
|
||||
import { PublishingStepperKey } from './types'
|
||||
|
||||
const { onClose, initialPage } = defineProps<{
|
||||
onClose: () => void
|
||||
initialPage?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const STEP_PAGE_MAP: Record<string, number> = {
|
||||
publishingLanding: 1,
|
||||
metadata: 2,
|
||||
description: 3,
|
||||
previewGeneration: 4,
|
||||
categoryAndTagging: 5,
|
||||
preview: 6,
|
||||
submissionForReview: 7,
|
||||
complete: 8
|
||||
}
|
||||
|
||||
const initialStep = initialPage ? (STEP_PAGE_MAP[initialPage] ?? 1) : 1
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
template,
|
||||
stepDefinitions,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canProceed,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
saveDraft,
|
||||
setStepValid
|
||||
} = useTemplatePublishingStepper({ initialStep })
|
||||
|
||||
const STEP_COMPONENTS: Component[] = [
|
||||
StepTemplatePublishingLanding,
|
||||
StepTemplatePublishingMetadata,
|
||||
StepTemplatePublishingDescription,
|
||||
StepTemplatePublishingPreviewGeneration,
|
||||
StepTemplatePublishingCategoryAndTagging,
|
||||
StepTemplatePublishingPreview,
|
||||
StepTemplatePublishingSubmissionForReview,
|
||||
StepTemplatePublishingComplete
|
||||
]
|
||||
|
||||
const activeStepComponent = computed(
|
||||
() => STEP_COMPONENTS[currentStep.value - 1]
|
||||
)
|
||||
|
||||
provide(PublishingStepperKey, {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canProceed,
|
||||
template,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
saveDraft,
|
||||
setStepValid
|
||||
})
|
||||
</script>
|
||||
@@ -1,103 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PublishingStepDefinition } from './types'
|
||||
|
||||
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
|
||||
|
||||
const STEP_DEFINITIONS: PublishingStepDefinition[] = [
|
||||
{
|
||||
number: 1,
|
||||
titleKey: 'steps.landing.title',
|
||||
descriptionKey: 'steps.landing.description'
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
titleKey: 'steps.metadata.title',
|
||||
descriptionKey: 'steps.metadata.description'
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
titleKey: 'steps.preview.title',
|
||||
descriptionKey: 'steps.preview.description'
|
||||
}
|
||||
]
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
steps: {
|
||||
landing: { title: 'Getting Started', description: '' },
|
||||
metadata: { title: 'Metadata', description: '' },
|
||||
preview: { title: 'Preview', description: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountNav(props?: { currentStep?: number }) {
|
||||
return mount(TemplatePublishingStepperNav, {
|
||||
props: {
|
||||
currentStep: props?.currentStep ?? 1,
|
||||
stepDefinitions: STEP_DEFINITIONS
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplatePublishingStepperNav', () => {
|
||||
it('renders a button for each step definition', () => {
|
||||
const wrapper = mountNav()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(STEP_DEFINITIONS.length)
|
||||
})
|
||||
|
||||
it('displays translated step titles', () => {
|
||||
const wrapper = mountNav()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toContain('Getting Started')
|
||||
expect(buttons[1].text()).toContain('Metadata')
|
||||
expect(buttons[2].text()).toContain('Preview')
|
||||
})
|
||||
|
||||
it('marks the current step button as aria-selected', () => {
|
||||
const wrapper = mountNav({ currentStep: 2 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].attributes('aria-selected')).toBe('false')
|
||||
expect(buttons[1].attributes('aria-selected')).toBe('true')
|
||||
expect(buttons[2].attributes('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows a check icon for completed steps', () => {
|
||||
const wrapper = mountNav({ currentStep: 3 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[1].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[2].find('i.icon-\\[lucide--check\\]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows step numbers for current and future steps', () => {
|
||||
const wrapper = mountNav({ currentStep: 2 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[1].text()).toContain('2')
|
||||
expect(buttons[2].text()).toContain('3')
|
||||
})
|
||||
|
||||
it('emits update:currentStep when a step button is clicked', async () => {
|
||||
const wrapper = mountNav({ currentStep: 1 })
|
||||
await wrapper.findAll('button')[1].trigger('click')
|
||||
expect(wrapper.emitted('update:currentStep')).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('renders separators between steps', () => {
|
||||
const wrapper = mountNav()
|
||||
const separators = wrapper.findAll('div.bg-border-default')
|
||||
expect(separators).toHaveLength(STEP_DEFINITIONS.length - 1)
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
<template>
|
||||
<nav
|
||||
class="flex flex-col gap-1 px-4 py-2"
|
||||
role="tablist"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<template v-for="(step, index) in stepDefinitions" :key="step.number">
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="step.number === currentStep"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm',
|
||||
'focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2',
|
||||
step.number === currentStep &&
|
||||
step.number === stepDefinitions.length &&
|
||||
'bg-blue-900 font-medium text-neutral',
|
||||
step.number === currentStep &&
|
||||
step.number < stepDefinitions.length &&
|
||||
'font-medium text-neutral',
|
||||
step.number < currentStep && 'bg-green-900 text-muted-foreground',
|
||||
step.number > currentStep && 'text-muted-foreground opacity-50'
|
||||
)
|
||||
"
|
||||
:disabled="step.number === stepDefinitions.length"
|
||||
@click="emit('update:currentStep', step.number)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs',
|
||||
step.number === currentStep &&
|
||||
'bg-comfy-accent text-comfy-accent-foreground',
|
||||
step.number < currentStep && 'bg-comfy-accent/20 text-neutral',
|
||||
step.number > currentStep &&
|
||||
'bg-secondary-background text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="step.number < currentStep"
|
||||
class="icon-[lucide--check] h-3.5 w-3.5"
|
||||
/>
|
||||
<span v-else>{{ step.number }}</span>
|
||||
</span>
|
||||
<span class="leading-tight">
|
||||
{{ t(step.titleKey)
|
||||
}}<template
|
||||
v-if="
|
||||
step.number === currentStep &&
|
||||
step.number === stepDefinitions.length
|
||||
"
|
||||
>
|
||||
🎉</template
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="index < stepDefinitions.length - 1"
|
||||
class="bg-border-default ml-5 h-4 w-px"
|
||||
/>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { PublishingStepDefinition } from './types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
currentStep: number
|
||||
stepDefinitions: PublishingStepDefinition[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:currentStep': [step: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,189 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingCategoryAndTagging from './StepTemplatePublishingCategoryAndTagging.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
metadata: {
|
||||
categoryLabel: 'Categories',
|
||||
tagsLabel: 'Tags',
|
||||
tagsPlaceholder: 'Type to search tags…',
|
||||
category: {
|
||||
imageGeneration: 'Image Generation',
|
||||
videoGeneration: 'Video Generation',
|
||||
audio: 'Audio',
|
||||
text: 'Text',
|
||||
threeD: '3D',
|
||||
upscaling: 'Upscaling',
|
||||
inpainting: 'Inpainting',
|
||||
controlNet: 'ControlNet',
|
||||
styleTransfer: 'Style Transfer',
|
||||
other: 'Other'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(5)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingCategoryAndTagging, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingCategoryAndTagging', () => {
|
||||
it('renders category and tag labels', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Categories')
|
||||
expect(wrapper.text()).toContain('Tags')
|
||||
})
|
||||
|
||||
it('renders all category checkboxes', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(10)
|
||||
expect(wrapper.text()).toContain('Image Generation')
|
||||
expect(wrapper.text()).toContain('ControlNet')
|
||||
})
|
||||
|
||||
it('toggles category when checkbox is clicked', async () => {
|
||||
const ctx = createContext({ categories: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const checkbox = wrapper.find('#tpl-category-audio')
|
||||
await checkbox.setValue(true)
|
||||
|
||||
expect(ctx.template.value.categories).toContain('audio')
|
||||
|
||||
await checkbox.setValue(false)
|
||||
expect(ctx.template.value.categories).not.toContain('audio')
|
||||
})
|
||||
|
||||
it('preserves existing categories when toggling', async () => {
|
||||
const ctx = createContext({ categories: ['text', '3d'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const audioCheckbox = wrapper.find('#tpl-category-audio')
|
||||
await audioCheckbox.setValue(true)
|
||||
|
||||
expect(ctx.template.value.categories).toContain('text')
|
||||
expect(ctx.template.value.categories).toContain('3d')
|
||||
expect(ctx.template.value.categories).toContain('audio')
|
||||
})
|
||||
|
||||
it('adds a tag when pressing enter in the tags input', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('my-tag')
|
||||
await tagInput.trigger('keydown.enter')
|
||||
|
||||
expect(ctx.template.value.tags).toContain('my-tag')
|
||||
})
|
||||
|
||||
it('does not add duplicate tags', async () => {
|
||||
const ctx = createContext({ tags: ['existing'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('existing')
|
||||
await tagInput.trigger('keydown.enter')
|
||||
|
||||
expect(ctx.template.value.tags).toEqual(['existing'])
|
||||
})
|
||||
|
||||
it('removes a tag when the remove button is clicked', async () => {
|
||||
const ctx = createContext({ tags: ['alpha', 'beta'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const removeButtons = wrapper.findAll('button[aria-label^="Remove tag"]')
|
||||
await removeButtons[0].trigger('click')
|
||||
|
||||
expect(ctx.template.value.tags).toEqual(['beta'])
|
||||
})
|
||||
|
||||
it('shows filtered suggestions when typing in tags input', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('flux')
|
||||
await tagInput.trigger('focus')
|
||||
|
||||
const suggestions = wrapper.findAll('li')
|
||||
expect(suggestions.length).toBeGreaterThan(0)
|
||||
expect(suggestions[0].text()).toBe('flux')
|
||||
})
|
||||
|
||||
it('adds a suggestion tag when clicking it', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('flux')
|
||||
await tagInput.trigger('focus')
|
||||
|
||||
const suggestion = wrapper.find('li')
|
||||
await suggestion.trigger('mousedown')
|
||||
|
||||
expect(ctx.template.value.tags).toContain('flux')
|
||||
})
|
||||
})
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex w-28 shrink-0 items-center">
|
||||
<span id="tpl-category-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.categoryLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-labelledby="tpl-category-label"
|
||||
>
|
||||
<label
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat.value"
|
||||
:for="`tpl-category-${cat.value}`"
|
||||
class="flex cursor-pointer items-center gap-1.5 text-sm"
|
||||
>
|
||||
<input
|
||||
:id="`tpl-category-${cat.value}`"
|
||||
type="checkbox"
|
||||
:checked="ctx.template.value.categories?.includes(cat.value)"
|
||||
@change="toggleCategory(cat.value)"
|
||||
/>
|
||||
{{ t(`templatePublishing.steps.metadata.category.${cat.key}`) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex w-28 shrink-0 items-center">
|
||||
<span id="tpl-tags-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.tagsLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-if="(ctx.template.value.tags ?? []).length > 0"
|
||||
class="flex max-h-20 flex-wrap gap-1 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<span
|
||||
v-for="tag in ctx.template.value.tags ?? []"
|
||||
:key="tag"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-danger"
|
||||
:aria-label="`Remove tag ${tag}`"
|
||||
@click="removeTag(tag)"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="tagQuery"
|
||||
type="text"
|
||||
class="h-8 w-44 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
:placeholder="
|
||||
t('templatePublishing.steps.metadata.tagsPlaceholder')
|
||||
"
|
||||
aria-labelledby="tpl-tags-label"
|
||||
@focus="showSuggestions = true"
|
||||
@keydown.enter.prevent="addTag(tagQuery)"
|
||||
/>
|
||||
<ul
|
||||
v-if="showSuggestions && filteredSuggestions.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-40 w-44 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
|
||||
>
|
||||
<li
|
||||
v-for="suggestion in filteredSuggestions"
|
||||
:key="suggestion"
|
||||
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
|
||||
@mousedown.prevent="addTag(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'threeD', value: '3d' },
|
||||
{ key: 'audio', value: 'audio' },
|
||||
{ key: 'controlNet', value: 'controlnet' },
|
||||
{ key: 'imageGeneration', value: 'image-generation' },
|
||||
{ key: 'inpainting', value: 'inpainting' },
|
||||
{ key: 'other', value: 'other' },
|
||||
{ key: 'styleTransfer', value: 'style-transfer' },
|
||||
{ key: 'text', value: 'text' },
|
||||
{ key: 'upscaling', value: 'upscaling' },
|
||||
{ key: 'videoGeneration', value: 'video-generation' }
|
||||
] as const
|
||||
|
||||
const TAG_SUGGESTIONS = [
|
||||
'stable-diffusion',
|
||||
'flux',
|
||||
'sdxl',
|
||||
'sd1.5',
|
||||
'img2img',
|
||||
'txt2img',
|
||||
'upscale',
|
||||
'face-restore',
|
||||
'animation',
|
||||
'video',
|
||||
'lora',
|
||||
'controlnet',
|
||||
'ipadapter',
|
||||
'inpainting',
|
||||
'outpainting',
|
||||
'depth',
|
||||
'pose',
|
||||
'segmentation',
|
||||
'latent',
|
||||
'sampler'
|
||||
]
|
||||
|
||||
const tagQuery = ref('')
|
||||
const showSuggestions = ref(false)
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
const query = tagQuery.value.toLowerCase().trim()
|
||||
if (!query) return []
|
||||
const existing = ctx.template.value.tags ?? []
|
||||
return TAG_SUGGESTIONS.filter(
|
||||
(s) => s.includes(query) && !existing.includes(s)
|
||||
)
|
||||
})
|
||||
|
||||
function toggleCategory(value: string) {
|
||||
const categories = ctx.template.value.categories ?? []
|
||||
const index = categories.indexOf(value)
|
||||
if (index >= 0) {
|
||||
categories.splice(index, 1)
|
||||
} else {
|
||||
categories.push(value)
|
||||
}
|
||||
ctx.template.value.categories = [...categories]
|
||||
}
|
||||
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim().toLowerCase()
|
||||
if (!trimmed) return
|
||||
const tags = ctx.template.value.tags ?? []
|
||||
if (!tags.includes(trimmed)) {
|
||||
ctx.template.value.tags = [...tags, trimmed]
|
||||
}
|
||||
tagQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
const tags = ctx.template.value.tags ?? []
|
||||
ctx.template.value.tags = tags.filter((t) => t !== tag)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.complete.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,111 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingDescription from './StepTemplatePublishingDescription.vue'
|
||||
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
description: {
|
||||
title: 'Description',
|
||||
description: 'Write a detailed description of your template',
|
||||
editorLabel: 'Description (Markdown)',
|
||||
previewLabel: 'Description (Render preview)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(3)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingDescription, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingDescription', () => {
|
||||
it('renders editor and preview labels', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Description (Markdown)')
|
||||
expect(wrapper.text()).toContain('Description (Render preview)')
|
||||
})
|
||||
|
||||
it('renders a textarea for markdown editing', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('binds textarea to template.description', () => {
|
||||
const ctx = createContext({ description: 'Hello **world**' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect((textarea.element as HTMLTextAreaElement).value).toBe(
|
||||
'Hello **world**'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates template.description when textarea changes', async () => {
|
||||
const ctx = createContext({ description: '' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('New content')
|
||||
expect(ctx.template.value.description).toBe('New content')
|
||||
})
|
||||
|
||||
it('renders markdown preview from template.description', () => {
|
||||
const ctx = createContext({ description: 'Some markdown' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const preview = wrapper.find('[class*="prose"]')
|
||||
expect(preview.html()).toContain('<p>Some markdown</p>')
|
||||
})
|
||||
|
||||
it('renders empty preview when description is undefined', () => {
|
||||
const ctx = createContext({})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const preview = wrapper.find('[class*="prose"]')
|
||||
expect(preview.html()).toContain('<p></p>')
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-row gap-4 p-6">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label for="tpl-description-editor" class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.description.editorLabel') }}
|
||||
</label>
|
||||
<textarea
|
||||
id="tpl-description-editor"
|
||||
v-model="ctx.template.value.description"
|
||||
class="min-h-0 flex-1 resize-none rounded-lg border border-border-default bg-secondary-background p-3 font-mono text-sm text-base-foreground focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.description.previewLabel') }}
|
||||
</span>
|
||||
<div
|
||||
class="prose prose-invert min-h-0 flex-1 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
|
||||
const renderedHtml = computed(() =>
|
||||
renderMarkdownToHtml(ctx.template.value.description ?? '')
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.landing.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:valid': [valid: boolean]
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:valid', true)
|
||||
})
|
||||
</script>
|
||||
@@ -1,299 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingMetadata from './StepTemplatePublishingMetadata.vue'
|
||||
|
||||
const mockNodes = vi.hoisted(() => [
|
||||
{ type: 'KSampler', isSubgraphNode: () => false },
|
||||
{ type: 'MyCustomNode', isSubgraphNode: () => false },
|
||||
{ type: 'AnotherCustom', isSubgraphNode: () => false },
|
||||
{ type: 'MyCustomNode', isSubgraphNode: () => false },
|
||||
{ type: 'ExtraCustomPack', isSubgraphNode: () => false }
|
||||
])
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
nodes: mockNodes
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
mapAllNodes: vi.fn(
|
||||
(
|
||||
graph: { nodes: Array<{ type: string }> },
|
||||
mapFn: (node: { type: string }) => string | undefined
|
||||
) => graph.nodes.map(mapFn).filter(Boolean)
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVramEstimation', () => ({
|
||||
estimateWorkflowVram: vi.fn(() => 5_000_000_000)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeDefsByName: {
|
||||
KSampler: {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes',
|
||||
nodeSource: { type: NodeSourceType.Core }
|
||||
},
|
||||
MyCustomNode: {
|
||||
name: 'MyCustomNode',
|
||||
python_module: 'custom_nodes.MyPack@1.0.nodes',
|
||||
nodeSource: { type: NodeSourceType.CustomNodes }
|
||||
},
|
||||
AnotherCustom: {
|
||||
name: 'AnotherCustom',
|
||||
python_module: 'custom_nodes.MyPack@1.0.extra',
|
||||
nodeSource: { type: NodeSourceType.CustomNodes }
|
||||
},
|
||||
ExtraCustomPack: {
|
||||
name: 'ExtraCustomPack',
|
||||
python_module: 'custom_nodes.ExtraPack.nodes',
|
||||
nodeSource: { type: NodeSourceType.CustomNodes }
|
||||
},
|
||||
UnusedCustomNode: {
|
||||
name: 'UnusedCustomNode',
|
||||
python_module: 'custom_nodes.UnusedPack@2.0.nodes',
|
||||
nodeSource: { type: NodeSourceType.CustomNodes }
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
description: 'Title, description, and author info',
|
||||
titleLabel: 'Title',
|
||||
difficultyLabel: 'Difficulty',
|
||||
licenseLabel: 'License',
|
||||
requiredNodesLabel: 'Custom Nodes',
|
||||
requiredNodesDetected: 'Detected from workflow',
|
||||
requiredNodesManualPlaceholder: 'Add custom node name…',
|
||||
requiredNodesManualLabel: 'Additional custom nodes',
|
||||
vramLabel: 'Estimated VRAM Requirement',
|
||||
vramAutoDetected: 'Auto-detected from workflow:',
|
||||
vramManualOverride: 'Manual override (GB):',
|
||||
difficulty: {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced'
|
||||
},
|
||||
license: {
|
||||
mit: 'MIT',
|
||||
ccBy: 'CC BY',
|
||||
ccBySa: 'CC BY-SA',
|
||||
ccByNc: 'CC BY-NC',
|
||||
apache: 'Apache',
|
||||
custom: 'Custom'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(2)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingMetadata, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context },
|
||||
stubs: {
|
||||
FormItem: {
|
||||
template:
|
||||
'<div :data-testid="`form-item-${id}`"><input :value="formValue" @input="$emit(\'update:formValue\', $event.target.value)" /></div>',
|
||||
props: ['item', 'id', 'formValue', 'labelClass'],
|
||||
emits: ['update:formValue']
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingMetadata', () => {
|
||||
it('renders all form fields', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
expect(wrapper.find('#tpl-title').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Difficulty')
|
||||
expect(wrapper.find('[data-testid="form-item-tpl-license"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('selects difficulty when radio button is clicked', async () => {
|
||||
const ctx = createContext({})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const intermediateRadio = wrapper.find('#tpl-difficulty-intermediate')
|
||||
await intermediateRadio.setValue(true)
|
||||
|
||||
expect(ctx.template.value.difficulty).toBe('intermediate')
|
||||
})
|
||||
|
||||
it('displays detected custom nodes from the workflow', async () => {
|
||||
const { wrapper } = mountStep()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('AnotherCustom')
|
||||
expect(wrapper.text()).toContain('MyCustomNode')
|
||||
expect(wrapper.text()).not.toContain('KSampler')
|
||||
})
|
||||
|
||||
it('populates requiredNodes on mount when empty', () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
mountStep(ctx)
|
||||
|
||||
expect(ctx.template.value.requiredNodes).toContain('AnotherCustom')
|
||||
expect(ctx.template.value.requiredNodes).toContain('MyCustomNode')
|
||||
expect(ctx.template.value.requiredNodes).not.toContain('KSampler')
|
||||
})
|
||||
|
||||
it('does not overwrite existing requiredNodes on mount', () => {
|
||||
const ctx = createContext({ requiredNodes: ['PreExisting'] })
|
||||
mountStep(ctx)
|
||||
|
||||
expect(ctx.template.value.requiredNodes).toEqual(['PreExisting'])
|
||||
})
|
||||
|
||||
it('populates requiresCustomNodes with deduplicated package IDs on mount', () => {
|
||||
const ctx = createContext({})
|
||||
mountStep(ctx)
|
||||
|
||||
// MyCustomNode and AnotherCustom both come from MyPack@1.0 (@ stripped)
|
||||
// ExtraCustomPack comes from ExtraPack (no @version in module path)
|
||||
expect(ctx.template.value.requiresCustomNodes).toEqual([
|
||||
'ExtraPack',
|
||||
'MyPack'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not overwrite existing requiresCustomNodes on mount', () => {
|
||||
const ctx = createContext({ requiresCustomNodes: ['PreExisting'] })
|
||||
mountStep(ctx)
|
||||
|
||||
expect(ctx.template.value.requiresCustomNodes).toEqual(['PreExisting'])
|
||||
})
|
||||
|
||||
it('adds a manual custom node via the input', async () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const input = wrapper.find('.relative input[type="text"]')
|
||||
await input.setValue('ManualNode')
|
||||
await input.trigger('keydown.enter')
|
||||
|
||||
expect(ctx.template.value.requiredNodes).toContain('ManualNode')
|
||||
})
|
||||
|
||||
it('removes a manual custom node when its remove button is clicked', async () => {
|
||||
const ctx = createContext({
|
||||
requiredNodes: ['AnotherCustom', 'MyCustomNode', 'ManualNode']
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const removeButtons = wrapper.findAll(
|
||||
'button[aria-label="Remove ManualNode"]'
|
||||
)
|
||||
await removeButtons[0].trigger('click')
|
||||
|
||||
expect(ctx.template.value.requiredNodes).not.toContain('ManualNode')
|
||||
})
|
||||
|
||||
it('shows filtered custom node suggestions when typing', async () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const input = wrapper.find('.relative input[type="text"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('Unused')
|
||||
|
||||
const suggestions = wrapper.findAll('.relative ul li')
|
||||
expect(suggestions.length).toBe(1)
|
||||
expect(suggestions[0].text()).toBe('UnusedCustomNode')
|
||||
})
|
||||
|
||||
it('excludes already-added nodes from suggestions', async () => {
|
||||
const ctx = createContext({ requiredNodes: ['UnusedCustomNode'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const input = wrapper.find('.relative input[type="text"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('Unused')
|
||||
|
||||
const suggestions = wrapper.findAll('.relative ul li')
|
||||
expect(suggestions.length).toBe(0)
|
||||
})
|
||||
|
||||
it('adds a node from the suggestion dropdown', async () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const input = wrapper.find('.relative input[type="text"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('Unused')
|
||||
|
||||
const suggestion = wrapper.find('.relative ul li')
|
||||
await suggestion.trigger('mousedown')
|
||||
|
||||
expect(ctx.template.value.requiredNodes).toContain('UnusedCustomNode')
|
||||
})
|
||||
})
|
||||
@@ -1,384 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex w-28 shrink-0 items-center">
|
||||
<span id="tpl-title-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.titleLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="tpl-title"
|
||||
v-model="ctx.template.value.title"
|
||||
type="text"
|
||||
class="h-8 w-[100em] rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
aria-labelledby="tpl-title-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex w-28 shrink-0 items-center">
|
||||
<span id="tpl-difficulty-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.difficultyLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row gap-4"
|
||||
role="radiogroup"
|
||||
aria-labelledby="tpl-difficulty-label"
|
||||
>
|
||||
<label
|
||||
v-for="option in DIFFICULTY_OPTIONS"
|
||||
:key="option.value"
|
||||
:for="`tpl-difficulty-${option.value}`"
|
||||
class="flex cursor-pointer items-center gap-1.5 text-sm"
|
||||
>
|
||||
<input
|
||||
:id="`tpl-difficulty-${option.value}`"
|
||||
type="radio"
|
||||
name="tpl-difficulty"
|
||||
:value="option.value"
|
||||
:checked="ctx.template.value.difficulty === option.value"
|
||||
:class="
|
||||
cn(
|
||||
'h-5 w-5 appearance-none rounded-full border-2 checked:bg-current checked:shadow-[inset_0_0_0_1px_white]',
|
||||
option.borderClass
|
||||
)
|
||||
"
|
||||
@change="ctx.template.value.difficulty = option.value"
|
||||
/>
|
||||
{{ option.text }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormItem
|
||||
id="tpl-license"
|
||||
v-model:form-value="ctx.template.value.license"
|
||||
:item="licenseField"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span id="tpl-required-nodes-label" class="text-sm text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.requiredNodesLabel') }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="detectedCustomNodes.length > 0"
|
||||
aria-labelledby="tpl-required-nodes-label"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.requiredNodesDetected') }}
|
||||
</span>
|
||||
<ul class="mt-1 flex flex-col gap-1">
|
||||
<li
|
||||
v-for="nodeName in detectedCustomNodes"
|
||||
:key="nodeName"
|
||||
class="flex items-center gap-2 rounded bg-secondary-background px-2 py-1 text-sm"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
{{ nodeName }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.requiredNodesManualLabel') }}
|
||||
</span>
|
||||
<div class="relative mt-1">
|
||||
<input
|
||||
v-model="manualNodeQuery"
|
||||
type="text"
|
||||
class="h-8 w-56 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
:placeholder="
|
||||
t(
|
||||
'templatePublishing.steps.metadata.requiredNodesManualPlaceholder'
|
||||
)
|
||||
"
|
||||
@focus="showNodeSuggestions = true"
|
||||
@keydown.enter.prevent="addManualNode(manualNodeQuery)"
|
||||
/>
|
||||
<ul
|
||||
v-if="showNodeSuggestions && filteredNodeSuggestions.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-40 w-56 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
|
||||
>
|
||||
<li
|
||||
v-for="suggestion in filteredNodeSuggestions"
|
||||
:key="suggestion"
|
||||
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
|
||||
@mousedown.prevent="addManualNode(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="manualNodes.length > 0"
|
||||
class="mt-1 flex flex-wrap items-center gap-1"
|
||||
>
|
||||
<span
|
||||
v-for="node in manualNodes"
|
||||
:key="node"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ node }}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-danger"
|
||||
:aria-label="`Remove ${node}`"
|
||||
@click="removeManualNode(node)"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span id="tpl-vram-label" class="text-sm text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.vramLabel') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="icon-[lucide--cpu] h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.vramAutoDetected') }}
|
||||
</span>
|
||||
<span class="text-sm font-medium">
|
||||
{{ formatSize(autoDetectedVram) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="tpl-vram-override"
|
||||
v-model.number="manualVramGb"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
class="h-8 w-24 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
aria-labelledby="tpl-vram-label"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.vramManualOverride') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
|
||||
import type { FormItem as FormItemType } from '@/platform/settings/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.beginner'),
|
||||
value: 'beginner' as const,
|
||||
borderClass: 'border-green-400'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.intermediate'),
|
||||
value: 'intermediate' as const,
|
||||
borderClass: 'border-amber-400'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.advanced'),
|
||||
value: 'advanced' as const,
|
||||
borderClass: 'border-red-400'
|
||||
}
|
||||
]
|
||||
|
||||
const licenseField: FormItemType = {
|
||||
name: t('templatePublishing.steps.metadata.licenseLabel'),
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ text: t('templatePublishing.steps.metadata.license.mit'), value: 'mit' },
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccBy'),
|
||||
value: 'cc-by'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccBySa'),
|
||||
value: 'cc-by-sa'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccByNc'),
|
||||
value: 'cc-by-nc'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.apache'),
|
||||
value: 'apache'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.custom'),
|
||||
value: 'custom'
|
||||
}
|
||||
],
|
||||
attrs: { filter: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects unique custom node type names from the current workflow graph.
|
||||
* Excludes core, essentials, and blueprint nodes.
|
||||
*/
|
||||
function detectCustomNodes(): string[] {
|
||||
if (!app.rootGraph) return []
|
||||
|
||||
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
|
||||
const unique = new Set(nodeTypes)
|
||||
|
||||
return [...unique]
|
||||
.filter((type) => {
|
||||
const def = nodeDefStore.nodeDefsByName[type]
|
||||
if (!def) return false
|
||||
return def.nodeSource.type === NodeSourceType.CustomNodes
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the custom node package ID from a `python_module` string.
|
||||
*
|
||||
* Custom node modules follow the pattern
|
||||
* `custom_nodes.PackageName@version.submodule`, so the package ID is the
|
||||
* second dot-segment with the `@version` suffix stripped.
|
||||
*
|
||||
* @returns The package folder name, or `undefined` when the module does not
|
||||
* match the expected pattern.
|
||||
*/
|
||||
function extractPackageId(pythonModule: string): string | undefined {
|
||||
const segments = pythonModule.split('.')
|
||||
if (segments[0] !== 'custom_nodes' || !segments[1]) return undefined
|
||||
return segments[1].split('@')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects unique custom node package IDs from the current workflow graph.
|
||||
*/
|
||||
function detectCustomNodePackages(): string[] {
|
||||
if (!app.rootGraph) return []
|
||||
|
||||
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
|
||||
const packages = new Set<string>()
|
||||
|
||||
for (const type of nodeTypes) {
|
||||
const def = nodeDefStore.nodeDefsByName[type]
|
||||
if (!def || def.nodeSource.type !== NodeSourceType.CustomNodes) continue
|
||||
const pkgId = extractPackageId(def.python_module)
|
||||
if (pkgId) packages.add(pkgId)
|
||||
}
|
||||
|
||||
return [...packages].sort()
|
||||
}
|
||||
|
||||
const detectedCustomNodes = ref<string[]>([])
|
||||
const autoDetectedVram = ref(0)
|
||||
|
||||
const GB = 1_073_741_824
|
||||
|
||||
/**
|
||||
* Manual VRAM override in GB. When set to a positive number, this
|
||||
* value (converted to bytes) takes precedence over the auto-detected
|
||||
* estimate for `vramRequirement`.
|
||||
*/
|
||||
const manualVramGb = computed({
|
||||
get: () => {
|
||||
const stored = ctx.template.value.vramRequirement
|
||||
if (!stored || stored === autoDetectedVram.value) return undefined
|
||||
return Math.round((stored / GB) * 10) / 10
|
||||
},
|
||||
set: (gb: number | undefined) => {
|
||||
if (gb && gb > 0) {
|
||||
ctx.template.value.vramRequirement = Math.round(gb * GB)
|
||||
} else {
|
||||
ctx.template.value.vramRequirement = autoDetectedVram.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
detectedCustomNodes.value = detectCustomNodes()
|
||||
|
||||
const existing = ctx.template.value.requiredNodes ?? []
|
||||
if (existing.length === 0) {
|
||||
ctx.template.value.requiredNodes = [...detectedCustomNodes.value]
|
||||
}
|
||||
|
||||
const existingPackages = ctx.template.value.requiresCustomNodes ?? []
|
||||
if (existingPackages.length === 0) {
|
||||
ctx.template.value.requiresCustomNodes = detectCustomNodePackages()
|
||||
}
|
||||
|
||||
autoDetectedVram.value = estimateWorkflowVram(app.rootGraph)
|
||||
if (!ctx.template.value.vramRequirement) {
|
||||
ctx.template.value.vramRequirement = autoDetectedVram.value
|
||||
}
|
||||
})
|
||||
|
||||
const manualNodes = computed(() => {
|
||||
const all = ctx.template.value.requiredNodes ?? []
|
||||
const detected = new Set(detectedCustomNodes.value)
|
||||
return all.filter((n) => !detected.has(n))
|
||||
})
|
||||
|
||||
const manualNodeQuery = ref('')
|
||||
const showNodeSuggestions = ref(false)
|
||||
|
||||
/** All installed custom node type names for searchable suggestions. */
|
||||
const allCustomNodeNames = computed(() =>
|
||||
Object.values(nodeDefStore.nodeDefsByName)
|
||||
.filter((def) => def.nodeSource.type === NodeSourceType.CustomNodes)
|
||||
.map((def) => def.name)
|
||||
.sort()
|
||||
)
|
||||
|
||||
const filteredNodeSuggestions = computed(() => {
|
||||
const query = manualNodeQuery.value.toLowerCase().trim()
|
||||
if (!query) return []
|
||||
const existing = new Set(ctx.template.value.requiredNodes ?? [])
|
||||
return allCustomNodeNames.value.filter(
|
||||
(name) => name.toLowerCase().includes(query) && !existing.has(name)
|
||||
)
|
||||
})
|
||||
|
||||
function addManualNode(name: string) {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const nodes = ctx.template.value.requiredNodes ?? []
|
||||
if (!nodes.includes(trimmed)) {
|
||||
ctx.template.value.requiredNodes = [...nodes, trimmed]
|
||||
}
|
||||
manualNodeQuery.value = ''
|
||||
showNodeSuggestions.value = false
|
||||
}
|
||||
|
||||
function removeManualNode(name: string) {
|
||||
const nodes = ctx.template.value.requiredNodes ?? []
|
||||
ctx.template.value.requiredNodes = nodes.filter((n) => n !== name)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -1,288 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingPreview from './StepTemplatePublishingPreview.vue'
|
||||
|
||||
let blobCounter = 0
|
||||
URL.createObjectURL = vi.fn(() => `blob:http://localhost/mock-${++blobCounter}`)
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
metadata: {
|
||||
titleLabel: 'Title',
|
||||
difficultyLabel: 'Difficulty',
|
||||
licenseLabel: 'License',
|
||||
categoryLabel: 'Categories',
|
||||
tagsLabel: 'Tags',
|
||||
requiredNodesLabel: 'Custom Nodes',
|
||||
difficulty: {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced'
|
||||
},
|
||||
license: {
|
||||
mit: 'MIT',
|
||||
ccBy: 'CC BY',
|
||||
ccBySa: 'CC BY-SA',
|
||||
ccByNc: 'CC BY-NC',
|
||||
apache: 'Apache',
|
||||
custom: 'Custom'
|
||||
},
|
||||
category: {
|
||||
imageGeneration: 'Image Generation',
|
||||
videoGeneration: 'Video Generation',
|
||||
audio: 'Audio',
|
||||
text: 'Text',
|
||||
threeD: '3D',
|
||||
upscaling: 'Upscaling',
|
||||
inpainting: 'Inpainting',
|
||||
controlNet: 'ControlNet',
|
||||
styleTransfer: 'Style Transfer',
|
||||
other: 'Other'
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
sectionMetadata: 'Metadata',
|
||||
sectionDescription: 'Description',
|
||||
sectionPreviewAssets: 'Preview Assets',
|
||||
sectionCategoriesAndTags: 'Categories & Tags',
|
||||
thumbnailLabel: 'Thumbnail',
|
||||
comparisonLabel: 'Before & After',
|
||||
workflowPreviewLabel: 'Workflow Graph',
|
||||
videoPreviewLabel: 'Video Preview',
|
||||
galleryLabel: 'Gallery',
|
||||
notProvided: 'Not provided',
|
||||
noneDetected: 'None detected',
|
||||
correct: 'Correct',
|
||||
editStep: 'Edit'
|
||||
},
|
||||
previewGeneration: {
|
||||
beforeImageLabel: 'Before',
|
||||
afterImageLabel: 'After'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(6)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingPreview, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingPreview', () => {
|
||||
beforeEach(() => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.clearAll()
|
||||
vi.clearAllMocks()
|
||||
blobCounter = 0
|
||||
})
|
||||
|
||||
it('renders all section headings', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Metadata')
|
||||
expect(wrapper.text()).toContain('Description')
|
||||
expect(wrapper.text()).toContain('Preview Assets')
|
||||
expect(wrapper.text()).toContain('Categories & Tags')
|
||||
})
|
||||
|
||||
it('displays template title', () => {
|
||||
const ctx = createContext({ title: 'My Workflow' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('My Workflow')
|
||||
})
|
||||
|
||||
it('displays difficulty level', () => {
|
||||
const ctx = createContext({ difficulty: 'advanced' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('Advanced')
|
||||
})
|
||||
|
||||
it('displays license type', () => {
|
||||
const ctx = createContext({ license: 'mit' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('MIT')
|
||||
})
|
||||
|
||||
it('displays required custom nodes', () => {
|
||||
const ctx = createContext({
|
||||
requiredNodes: ['ComfyUI-Impact-Pack', 'ComfyUI-Manager']
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('ComfyUI-Impact-Pack')
|
||||
expect(wrapper.text()).toContain('ComfyUI-Manager')
|
||||
})
|
||||
|
||||
it('shows "None detected" when no custom nodes', () => {
|
||||
const ctx = createContext({ requiredNodes: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('None detected')
|
||||
})
|
||||
|
||||
it('renders description as markdown HTML', () => {
|
||||
const ctx = createContext({ description: 'Hello **bold**' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const prose = wrapper.find('[class*="prose"]')
|
||||
expect(prose.html()).toContain('<p>Hello **bold**</p>')
|
||||
})
|
||||
|
||||
it('shows "Not provided" when description is empty', () => {
|
||||
const ctx = createContext({})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Not provided')
|
||||
})
|
||||
|
||||
it('displays categories as pills', () => {
|
||||
const ctx = createContext({
|
||||
categories: ['image-generation', 'controlnet']
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('Image Generation')
|
||||
expect(wrapper.text()).toContain('ControlNet')
|
||||
})
|
||||
|
||||
it('displays tags as pills', () => {
|
||||
const ctx = createContext({ tags: ['flux', 'sdxl'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
expect(wrapper.text()).toContain('flux')
|
||||
expect(wrapper.text()).toContain('sdxl')
|
||||
})
|
||||
|
||||
it('displays thumbnail when asset is cached', () => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.setThumbnail(new File([''], 'thumb.png'))
|
||||
|
||||
const ctx = createContext({ thumbnail: 'blob:thumb' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const imgs = wrapper.findAll('img')
|
||||
const thumbImg = imgs.find((img) =>
|
||||
img.attributes('alt')?.includes('thumb.png')
|
||||
)
|
||||
expect(thumbImg?.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays gallery images when assets are cached', () => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.addGalleryImage(new File([''], 'a.png'))
|
||||
assets.addGalleryImage(new File([''], 'b.png'))
|
||||
|
||||
const ctx = createContext({
|
||||
gallery: [
|
||||
{ type: 'image', url: 'blob:a', caption: 'a.png' },
|
||||
{ type: 'image', url: 'blob:b', caption: 'b.png' }
|
||||
]
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const imgs = wrapper.findAll('img')
|
||||
const galleryImgs = imgs.filter(
|
||||
(img) =>
|
||||
img.attributes('alt') === 'a.png' || img.attributes('alt') === 'b.png'
|
||||
)
|
||||
expect(galleryImgs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders a "Correct" button', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const correctBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Correct'))
|
||||
expect(correctBtn?.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('calls nextStep when "Correct" button is clicked', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const correctBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Correct'))
|
||||
await correctBtn!.trigger('click')
|
||||
expect(ctx.nextStep).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('navigates to metadata step when edit is clicked on metadata section', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const editButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((b) => b.text().includes('Edit'))
|
||||
await editButtons[0].trigger('click')
|
||||
expect(ctx.goToStep).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('navigates to description step when edit is clicked on description section', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const editButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((b) => b.text().includes('Edit'))
|
||||
await editButtons[1].trigger('click')
|
||||
expect(ctx.goToStep).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('navigates to preview generation step when edit is clicked on assets section', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const editButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((b) => b.text().includes('Edit'))
|
||||
await editButtons[2].trigger('click')
|
||||
expect(ctx.goToStep).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('navigates to category step when edit is clicked on categories section', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const editButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((b) => b.text().includes('Edit'))
|
||||
await editButtons[3].trigger('click')
|
||||
expect(ctx.goToStep).toHaveBeenCalledWith(5)
|
||||
})
|
||||
})
|
||||
@@ -1,298 +0,0 @@
|
||||
<!--
|
||||
Step 6 of the template publishing wizard. Displays a read-only summary
|
||||
of all user-provided data so the author can audit it before submission.
|
||||
-->
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 overflow-y-auto p-6">
|
||||
<!-- Metadata -->
|
||||
<PreviewSection
|
||||
:label="t('templatePublishing.steps.preview.sectionMetadata')"
|
||||
@edit="ctx.goToStep(2)"
|
||||
>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.titleLabel')"
|
||||
:value="tpl.title"
|
||||
/>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.difficultyLabel')"
|
||||
:value="difficultyLabel"
|
||||
/>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.licenseLabel')"
|
||||
:value="licenseLabel"
|
||||
/>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.requiredNodesLabel')"
|
||||
>
|
||||
<ul
|
||||
v-if="(tpl.requiredNodes ?? []).length > 0"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<li
|
||||
v-for="node in tpl.requiredNodes"
|
||||
:key="node"
|
||||
class="flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
{{ node }}
|
||||
</li>
|
||||
</ul>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.noneDetected') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.vramLabel')"
|
||||
:value="vramLabel"
|
||||
/>
|
||||
</PreviewSection>
|
||||
|
||||
<!-- Description -->
|
||||
<PreviewSection
|
||||
:label="t('templatePublishing.steps.preview.sectionDescription')"
|
||||
@edit="ctx.goToStep(3)"
|
||||
>
|
||||
<div
|
||||
v-if="tpl.description"
|
||||
class="prose prose-invert max-h-48 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
|
||||
v-html="renderedDescription"
|
||||
/>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewSection>
|
||||
|
||||
<!-- Preview Assets -->
|
||||
<PreviewSection
|
||||
:label="t('templatePublishing.steps.preview.sectionPreviewAssets')"
|
||||
@edit="ctx.goToStep(4)"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.thumbnailLabel')"
|
||||
>
|
||||
<img
|
||||
v-if="assets.thumbnail.value"
|
||||
:src="assets.thumbnail.value.objectUrl"
|
||||
:alt="assets.thumbnail.value.originalName"
|
||||
class="h-28 w-44 rounded-lg object-cover"
|
||||
/>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<!-- Before & After -->
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.comparisonLabel')"
|
||||
>
|
||||
<div
|
||||
v-if="assets.beforeImage.value || assets.afterImage.value"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div v-if="assets.beforeImage.value" class="flex flex-col gap-0.5">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.beforeImageLabel')
|
||||
}}
|
||||
</span>
|
||||
<img
|
||||
:src="assets.beforeImage.value.objectUrl"
|
||||
:alt="assets.beforeImage.value.originalName"
|
||||
class="h-24 w-24 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="assets.afterImage.value" class="flex flex-col gap-0.5">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.afterImageLabel')
|
||||
}}
|
||||
</span>
|
||||
<img
|
||||
:src="assets.afterImage.value.objectUrl"
|
||||
:alt="assets.afterImage.value.originalName"
|
||||
class="h-24 w-24 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<!-- Workflow Graph -->
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.workflowPreviewLabel')"
|
||||
>
|
||||
<img
|
||||
v-if="assets.workflowPreview.value"
|
||||
:src="assets.workflowPreview.value.objectUrl"
|
||||
:alt="assets.workflowPreview.value.originalName"
|
||||
class="h-28 w-48 rounded-lg object-cover"
|
||||
/>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<!-- Video Preview -->
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.preview.videoPreviewLabel')"
|
||||
>
|
||||
<video
|
||||
v-if="assets.videoPreview.value"
|
||||
:src="assets.videoPreview.value.objectUrl"
|
||||
controls
|
||||
class="h-28 w-48 rounded-lg"
|
||||
/>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<!-- Gallery -->
|
||||
<PreviewField :label="t('templatePublishing.steps.preview.galleryLabel')">
|
||||
<div
|
||||
v-if="assets.galleryImages.value.length > 0"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<img
|
||||
v-for="(img, i) in assets.galleryImages.value"
|
||||
:key="img.originalName + i"
|
||||
:src="img.objectUrl"
|
||||
:alt="img.originalName"
|
||||
class="h-20 w-20 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
</PreviewSection>
|
||||
|
||||
<!-- Categories & Tags -->
|
||||
<PreviewSection
|
||||
:label="t('templatePublishing.steps.preview.sectionCategoriesAndTags')"
|
||||
@edit="ctx.goToStep(5)"
|
||||
>
|
||||
<PreviewField
|
||||
:label="t('templatePublishing.steps.metadata.categoryLabel')"
|
||||
>
|
||||
<div
|
||||
v-if="(tpl.categories ?? []).length > 0"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<span
|
||||
v-for="cat in tpl.categories"
|
||||
:key="cat"
|
||||
class="rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ categoryDisplayName(cat) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
|
||||
<PreviewField :label="t('templatePublishing.steps.metadata.tagsLabel')">
|
||||
<div v-if="(tpl.tags ?? []).length > 0" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in tpl.tags"
|
||||
:key="tag"
|
||||
class="rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</PreviewField>
|
||||
</PreviewSection>
|
||||
|
||||
<!-- Correct button -->
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button size="lg" @click="ctx.nextStep()">
|
||||
<i class="icon-[lucide--check] mr-1" />
|
||||
{{ t('templatePublishing.steps.preview.correct') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { computed, inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LicenseType } from '@/types/templateMarketplace'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import PreviewField from './preview/PreviewField.vue'
|
||||
import PreviewSection from './preview/PreviewSection.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
const assets = useTemplatePreviewAssets()
|
||||
|
||||
const tpl = computed(() => ctx.template.value)
|
||||
|
||||
const renderedDescription = computed(() =>
|
||||
renderMarkdownToHtml(tpl.value.description ?? '')
|
||||
)
|
||||
|
||||
const CATEGORY_KEY_MAP: Record<string, string> = {
|
||||
'3d': 'threeD',
|
||||
audio: 'audio',
|
||||
controlnet: 'controlNet',
|
||||
'image-generation': 'imageGeneration',
|
||||
inpainting: 'inpainting',
|
||||
other: 'other',
|
||||
'style-transfer': 'styleTransfer',
|
||||
text: 'text',
|
||||
upscaling: 'upscaling',
|
||||
'video-generation': 'videoGeneration'
|
||||
}
|
||||
|
||||
function categoryDisplayName(value: string): string {
|
||||
const key = CATEGORY_KEY_MAP[value]
|
||||
if (!key) return value
|
||||
return t(`templatePublishing.steps.metadata.category.${key}`)
|
||||
}
|
||||
|
||||
const LICENSE_KEY_MAP: Record<string, string> = {
|
||||
mit: 'mit',
|
||||
'cc-by': 'ccBy',
|
||||
'cc-by-sa': 'ccBySa',
|
||||
'cc-by-nc': 'ccByNc',
|
||||
apache: 'apache',
|
||||
custom: 'custom'
|
||||
}
|
||||
|
||||
const licenseLabel = computed(() => {
|
||||
const license = tpl.value.license
|
||||
if (!license) return t('templatePublishing.steps.preview.notProvided')
|
||||
const key = LICENSE_KEY_MAP[license as LicenseType]
|
||||
if (!key) return license
|
||||
return t(`templatePublishing.steps.metadata.license.${key}`)
|
||||
})
|
||||
|
||||
const difficultyLabel = computed(() => {
|
||||
const difficulty = tpl.value.difficulty
|
||||
if (!difficulty) return t('templatePublishing.steps.preview.notProvided')
|
||||
return t(`templatePublishing.steps.metadata.difficulty.${difficulty}`)
|
||||
})
|
||||
|
||||
const vramLabel = computed(() => {
|
||||
const vram = tpl.value.vramRequirement
|
||||
if (!vram) return t('templatePublishing.steps.preview.notProvided')
|
||||
return formatSize(vram)
|
||||
})
|
||||
</script>
|
||||
@@ -1,239 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingPreviewGeneration from './StepTemplatePublishingPreviewGeneration.vue'
|
||||
|
||||
let blobCounter = 0
|
||||
URL.createObjectURL = vi.fn(() => `blob:http://localhost/mock-${++blobCounter}`)
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
previewGeneration: {
|
||||
thumbnailLabel: 'Thumbnail',
|
||||
thumbnailHint: 'Primary image shown in marketplace listings',
|
||||
comparisonLabel: 'Before & After Comparison',
|
||||
comparisonHint: 'Show what the workflow transforms',
|
||||
beforeImageLabel: 'Before',
|
||||
afterImageLabel: 'After',
|
||||
workflowPreviewLabel: 'Workflow Graph',
|
||||
workflowPreviewHint: 'Screenshot of the workflow graph layout',
|
||||
videoPreviewLabel: 'Video Preview',
|
||||
videoPreviewHint: 'Optional short video demonstrating the workflow',
|
||||
galleryLabel: 'Example Gallery',
|
||||
galleryHint: 'Up to {max} example output images',
|
||||
uploadPrompt: 'Click to upload',
|
||||
removeFile: 'Remove'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(4)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingPreviewGeneration, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingPreviewGeneration', () => {
|
||||
beforeEach(() => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.clearAll()
|
||||
vi.clearAllMocks()
|
||||
blobCounter = 0
|
||||
})
|
||||
|
||||
it('renders all upload sections', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
expect(wrapper.text()).toContain('Thumbnail')
|
||||
expect(wrapper.text()).toContain('Before & After Comparison')
|
||||
expect(wrapper.text()).toContain('Workflow Graph')
|
||||
expect(wrapper.text()).toContain('Video Preview')
|
||||
expect(wrapper.text()).toContain('Example Gallery')
|
||||
})
|
||||
|
||||
it('renders before and after upload zones side by side', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
expect(wrapper.text()).toContain('Before')
|
||||
expect(wrapper.text()).toContain('After')
|
||||
})
|
||||
|
||||
it('updates template thumbnail on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[0].vm.$emit('upload', new File([''], 'thumb.png'))
|
||||
|
||||
expect(ctx.template.value.thumbnail).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('clears template thumbnail on remove', () => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.setThumbnail(new File([''], 'thumb.png'))
|
||||
|
||||
const ctx = createContext({ thumbnail: 'blob:old' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[0].vm.$emit('remove')
|
||||
|
||||
expect(ctx.template.value.thumbnail).toBe('')
|
||||
expect(assets.thumbnail.value).toBeNull()
|
||||
})
|
||||
|
||||
it('updates template beforeImage on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[1].vm.$emit('upload', new File([''], 'before.png'))
|
||||
|
||||
expect(ctx.template.value.beforeImage).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('updates template afterImage on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[2].vm.$emit('upload', new File([''], 'after.png'))
|
||||
|
||||
expect(ctx.template.value.afterImage).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('updates template workflowPreview on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[3].vm.$emit('upload', new File([''], 'graph.png'))
|
||||
|
||||
expect(ctx.template.value.workflowPreview).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('updates template videoPreview on upload', () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const uploadZones = wrapper.findAllComponents({
|
||||
name: 'TemplateAssetUploadZone'
|
||||
})
|
||||
uploadZones[4].vm.$emit(
|
||||
'upload',
|
||||
new File([''], 'demo.mp4', { type: 'video/mp4' })
|
||||
)
|
||||
|
||||
expect(ctx.template.value.videoPreview).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
it('shows the gallery add button when gallery is empty', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
const addButton = wrapper.find('[role="button"]')
|
||||
expect(addButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('adds gallery images to the template on upload', async () => {
|
||||
const ctx = createContext({ gallery: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const galleryInput = wrapper.find('input[multiple]')
|
||||
const file = new File([''], 'output.png', { type: 'image/png' })
|
||||
Object.defineProperty(galleryInput.element, 'files', { value: [file] })
|
||||
await galleryInput.trigger('change')
|
||||
|
||||
expect(ctx.template.value.gallery).toHaveLength(1)
|
||||
expect(ctx.template.value.gallery![0].url).toMatch(/^blob:/)
|
||||
expect(ctx.template.value.gallery![0].caption).toBe('output.png')
|
||||
})
|
||||
|
||||
it('removes a gallery image by index', async () => {
|
||||
const assets = useTemplatePreviewAssets()
|
||||
assets.addGalleryImage(new File([''], 'a.png'))
|
||||
assets.addGalleryImage(new File([''], 'b.png'))
|
||||
|
||||
const ctx = createContext({
|
||||
gallery: [
|
||||
{ type: 'image', url: 'blob:a', caption: 'a.png' },
|
||||
{ type: 'image', url: 'blob:b', caption: 'b.png' }
|
||||
]
|
||||
})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const removeButtons = wrapper.findAll('button[aria-label="Remove"]')
|
||||
await removeButtons[0].trigger('click')
|
||||
|
||||
expect(ctx.template.value.gallery).toHaveLength(1)
|
||||
expect(ctx.template.value.gallery![0].caption).toBe('b.png')
|
||||
})
|
||||
})
|
||||
@@ -1,258 +0,0 @@
|
||||
<!--
|
||||
Step 4 of the template publishing wizard. Collects preview assets:
|
||||
thumbnail, before/after comparison, workflow graph, optional video,
|
||||
and an optional gallery of up to six example output images.
|
||||
-->
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 overflow-y-auto p-6">
|
||||
<!-- Thumbnail -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{ t('templatePublishing.steps.previewGeneration.thumbnailLabel') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.previewGeneration.thumbnailHint') }}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.thumbnail.value"
|
||||
size-class="h-40 w-64"
|
||||
@upload="onThumbnailUpload"
|
||||
@remove="onThumbnailRemove"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Before & After Comparison -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{ t('templatePublishing.steps.previewGeneration.comparisonLabel') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.previewGeneration.comparisonHint') }}
|
||||
</span>
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.beforeImageLabel')
|
||||
}}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.beforeImage.value"
|
||||
@upload="onBeforeUpload"
|
||||
@remove="onBeforeRemove"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.afterImageLabel')
|
||||
}}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.afterImage.value"
|
||||
@upload="onAfterUpload"
|
||||
@remove="onAfterRemove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Workflow Graph Preview -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.workflowPreviewLabel')
|
||||
}}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.workflowPreviewHint')
|
||||
}}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.workflowPreview.value"
|
||||
size-class="h-40 w-72"
|
||||
@upload="onWorkflowUpload"
|
||||
@remove="onWorkflowRemove"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Video Preview (optional) -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{ t('templatePublishing.steps.previewGeneration.videoPreviewLabel') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.previewGeneration.videoPreviewHint') }}
|
||||
</span>
|
||||
<TemplateAssetUploadZone
|
||||
:asset="assets.videoPreview.value"
|
||||
accept="video/*"
|
||||
preview-type="video"
|
||||
size-class="h-40 w-72"
|
||||
@upload="onVideoUpload"
|
||||
@remove="onVideoRemove"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Example Output Gallery -->
|
||||
<section class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted">
|
||||
{{ t('templatePublishing.steps.previewGeneration.galleryLabel') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t('templatePublishing.steps.previewGeneration.galleryHint', {
|
||||
max: MAX_GALLERY_IMAGES
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div
|
||||
v-for="(asset, index) in assets.galleryImages.value"
|
||||
:key="asset.originalName + index"
|
||||
class="group relative h-28 w-28 overflow-hidden rounded-lg"
|
||||
>
|
||||
<img
|
||||
:src="asset.objectUrl"
|
||||
:alt="asset.originalName"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-1.5 py-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span class="truncate text-[10px] text-white">
|
||||
{{ asset.originalName }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-white hover:text-danger"
|
||||
:aria-label="
|
||||
t('templatePublishing.steps.previewGeneration.removeFile')
|
||||
"
|
||||
@click="onGalleryRemove(index)"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assets.galleryImages.value.length < MAX_GALLERY_IMAGES"
|
||||
class="flex h-28 w-28 cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-default hover:border-muted-foreground"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="
|
||||
t('templatePublishing.steps.previewGeneration.uploadPrompt')
|
||||
"
|
||||
@click="galleryInput?.click()"
|
||||
@keydown.enter="galleryInput?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--plus] h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="galleryInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onGallerySelect"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
MAX_GALLERY_IMAGES,
|
||||
useTemplatePreviewAssets
|
||||
} from '@/composables/useTemplatePreviewAssets'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import TemplateAssetUploadZone from '../TemplateAssetUploadZone.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
const assets = useTemplatePreviewAssets()
|
||||
const galleryInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onThumbnailUpload(file: File) {
|
||||
ctx.template.value.thumbnail = assets.setThumbnail(file)
|
||||
}
|
||||
|
||||
function onThumbnailRemove() {
|
||||
assets.clearThumbnail()
|
||||
ctx.template.value.thumbnail = ''
|
||||
}
|
||||
|
||||
function onBeforeUpload(file: File) {
|
||||
ctx.template.value.beforeImage = assets.setBeforeImage(file)
|
||||
}
|
||||
|
||||
function onBeforeRemove() {
|
||||
assets.clearBeforeImage()
|
||||
ctx.template.value.beforeImage = undefined
|
||||
}
|
||||
|
||||
function onAfterUpload(file: File) {
|
||||
ctx.template.value.afterImage = assets.setAfterImage(file)
|
||||
}
|
||||
|
||||
function onAfterRemove() {
|
||||
assets.clearAfterImage()
|
||||
ctx.template.value.afterImage = undefined
|
||||
}
|
||||
|
||||
function onWorkflowUpload(file: File) {
|
||||
ctx.template.value.workflowPreview = assets.setWorkflowPreview(file)
|
||||
}
|
||||
|
||||
function onWorkflowRemove() {
|
||||
assets.clearWorkflowPreview()
|
||||
ctx.template.value.workflowPreview = ''
|
||||
}
|
||||
|
||||
function onVideoUpload(file: File) {
|
||||
ctx.template.value.videoPreview = assets.setVideoPreview(file)
|
||||
}
|
||||
|
||||
function onVideoRemove() {
|
||||
assets.clearVideoPreview()
|
||||
ctx.template.value.videoPreview = undefined
|
||||
}
|
||||
|
||||
function onGallerySelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files) return
|
||||
|
||||
for (const file of files) {
|
||||
const url = assets.addGalleryImage(file)
|
||||
if (url) {
|
||||
const gallery = ctx.template.value.gallery ?? []
|
||||
ctx.template.value.gallery = [
|
||||
...gallery,
|
||||
{ type: 'image', url, caption: file.name }
|
||||
]
|
||||
}
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onGalleryRemove(index: number) {
|
||||
assets.removeGalleryImage(index)
|
||||
const gallery = ctx.template.value.gallery ?? []
|
||||
ctx.template.value.gallery = gallery.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -1,84 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingSubmissionForReview from './StepTemplatePublishingSubmissionForReview.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
submit: 'Submit for Review',
|
||||
steps: {
|
||||
submissionForReview: {
|
||||
title: 'Submit',
|
||||
description: 'Submit your template for review.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<PublishingStepperContext> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>({})
|
||||
const currentStep = ref(7)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingSubmissionForReview, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingSubmissionForReview', () => {
|
||||
it('renders the description text', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Submit your template for review.')
|
||||
})
|
||||
|
||||
it('renders a submit button', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const button = wrapper.find('button')
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.text()).toBe('Submit for Review')
|
||||
})
|
||||
|
||||
it('calls nextStep when the submit button is clicked', async () => {
|
||||
const ctx = createContext()
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const button = wrapper.find('button')
|
||||
await button.trigger('click')
|
||||
expect(ctx.nextStep).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.submissionForReview.description') }}
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<Button size="lg" @click="stepper.nextStep()">
|
||||
{{ t('templatePublishing.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { PublishingStepperKey } from '@/components/templatePublishing/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const stepper = inject(PublishingStepperKey)!
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<!--
|
||||
A labeled field within a preview section. Shows a label on the left
|
||||
and either the value text or a default slot on the right.
|
||||
-->
|
||||
<template>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">{{ label }}</span>
|
||||
<div class="text-sm">
|
||||
<slot>
|
||||
<span v-if="value">{{ value }}</span>
|
||||
<span v-else class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.notProvided') }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
value?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,38 +0,0 @@
|
||||
<!--
|
||||
A collapsible section in the preview step, showing a heading with an
|
||||
"Edit" button that navigates back to the originating step.
|
||||
-->
|
||||
<template>
|
||||
<section class="flex flex-col gap-3">
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-border-default pb-1"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-muted">{{ label }}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<i class="icon-[lucide--pencil] h-3 w-3" />
|
||||
{{ t('templatePublishing.steps.preview.editStep') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 pl-1">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
/**
|
||||
* Definition of a single step in the template publishing wizard.
|
||||
*/
|
||||
export interface PublishingStepDefinition {
|
||||
/** 1-indexed step number */
|
||||
number: number
|
||||
/** i18n key for the step's display title */
|
||||
titleKey: string
|
||||
/** i18n key for the step's short description */
|
||||
descriptionKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Context shared between the publishing dialog and its step panels
|
||||
* via provide/inject.
|
||||
*/
|
||||
export interface PublishingStepperContext {
|
||||
currentStep: Readonly<Ref<number>>
|
||||
totalSteps: number
|
||||
isFirstStep: Readonly<Ref<boolean>>
|
||||
isLastStep: Readonly<Ref<boolean>>
|
||||
canProceed: Readonly<Ref<boolean>>
|
||||
template: Ref<Partial<MarketplaceTemplate>>
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
goToStep: (step: number) => void
|
||||
saveDraft: () => void
|
||||
setStepValid: (step: number, valid: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection key for the publishing stepper context, allowing step panel
|
||||
* components to access shared navigation and draft state.
|
||||
*/
|
||||
export const PublishingStepperKey: InjectionKey<PublishingStepperContext> =
|
||||
Symbol('PublishingStepperContext')
|
||||
|
||||
export const PUBLISHING_STEP_DEFINITIONS: PublishingStepDefinition[] = [
|
||||
{
|
||||
number: 1,
|
||||
titleKey: 'templatePublishing.steps.landing.title',
|
||||
descriptionKey: 'templatePublishing.steps.landing.description'
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
titleKey: 'templatePublishing.steps.metadata.title',
|
||||
descriptionKey: 'templatePublishing.steps.metadata.description'
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
titleKey: 'templatePublishing.steps.description.title',
|
||||
descriptionKey: 'templatePublishing.steps.description.description'
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
titleKey: 'templatePublishing.steps.previewGeneration.title',
|
||||
descriptionKey: 'templatePublishing.steps.previewGeneration.description'
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
titleKey: 'templatePublishing.steps.categoryAndTagging.title',
|
||||
descriptionKey: 'templatePublishing.steps.categoryAndTagging.description'
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
titleKey: 'templatePublishing.steps.preview.title',
|
||||
descriptionKey: 'templatePublishing.steps.preview.description'
|
||||
},
|
||||
{
|
||||
number: 7,
|
||||
titleKey: 'templatePublishing.steps.submissionForReview.title',
|
||||
descriptionKey: 'templatePublishing.steps.submissionForReview.description'
|
||||
},
|
||||
{
|
||||
number: 8,
|
||||
titleKey: 'templatePublishing.steps.complete.title',
|
||||
descriptionKey: 'templatePublishing.steps.complete.description'
|
||||
}
|
||||
]
|
||||
@@ -16,20 +16,17 @@ import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
|
||||
|
||||
import TopbarBadge from './TopbarBadge.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
displayMode?: 'full' | 'compact' | 'icon-only'
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
backgroundColor?: string
|
||||
}>(),
|
||||
{
|
||||
displayMode: 'full',
|
||||
reverseOrder: false,
|
||||
noPadding: false,
|
||||
backgroundColor: 'var(--comfy-menu-bg)'
|
||||
}
|
||||
)
|
||||
const {
|
||||
displayMode = 'full',
|
||||
reverseOrder = false,
|
||||
noPadding = false,
|
||||
backgroundColor = 'var(--comfy-menu-bg)'
|
||||
} = defineProps<{
|
||||
displayMode?: 'full' | 'compact' | 'icon-only'
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
backgroundColor?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -113,12 +113,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
}))
|
||||
|
||||
// Mock the useSubscriptionDialog composable
|
||||
const mockSubscriptionDialogShow = vi.fn()
|
||||
const mockShowPricingTable = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: vi.fn(() => ({
|
||||
show: mockSubscriptionDialogShow,
|
||||
show: vi.fn(),
|
||||
showPricingTable: mockShowPricingTable,
|
||||
hide: vi.fn()
|
||||
}))
|
||||
})
|
||||
@@ -318,8 +319,8 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
|
||||
await plansPricingItem.trigger('click')
|
||||
|
||||
// Verify subscription dialog show was called
|
||||
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
|
||||
// Verify showPricingTable was called
|
||||
expect(mockShowPricingTable).toHaveBeenCalled()
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user