mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
Merge branch 'main' into fix/load-image-output-ds-store
This commit is contained in:
61
.cursorrules
61
.cursorrules
@@ -1,61 +0,0 @@
|
|||||||
# Vue 3 Composition API Project Rules
|
|
||||||
|
|
||||||
## Vue 3 Composition API Best Practices
|
|
||||||
- Use setup() function for component logic
|
|
||||||
- Utilize ref and reactive for reactive state
|
|
||||||
- Implement computed properties with computed()
|
|
||||||
- Use watch and watchEffect for side effects
|
|
||||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
|
||||||
- Utilize provide/inject for dependency injection
|
|
||||||
- Use vue 3.5 style of default prop declaration. Example:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { nodes, showTotal = true } = defineProps<{
|
|
||||||
nodes: ApiNodeCost[]
|
|
||||||
showTotal?: boolean
|
|
||||||
}>()
|
|
||||||
```
|
|
||||||
|
|
||||||
- Organize vue component in <template> <script> <style> order
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
components/
|
|
||||||
constants/
|
|
||||||
composables/
|
|
||||||
views/
|
|
||||||
stores/
|
|
||||||
services/
|
|
||||||
App.vue
|
|
||||||
main.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling Guidelines
|
|
||||||
- Use Tailwind CSS for styling
|
|
||||||
- Implement responsive design with Tailwind CSS
|
|
||||||
|
|
||||||
## PrimeVue Component Guidelines
|
|
||||||
DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
|
||||||
- Dropdown → Use Select (import from 'primevue/select')
|
|
||||||
- OverlayPanel → Use Popover (import from 'primevue/popover')
|
|
||||||
- Calendar → Use DatePicker (import from 'primevue/datepicker')
|
|
||||||
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
|
|
||||||
- Sidebar → Use Drawer (import from 'primevue/drawer')
|
|
||||||
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
|
|
||||||
- TabMenu → Use Tabs without panels
|
|
||||||
- Steps → Use Stepper without panels
|
|
||||||
- InlineMessage → Use Message component
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
1. Leverage VueUse functions for performance-enhancing styles
|
|
||||||
2. Use es-toolkit for utility functions
|
|
||||||
3. Use TypeScript for type safety
|
|
||||||
4. Implement proper props and emits definitions
|
|
||||||
5. Utilize Vue 3's Teleport component when needed
|
|
||||||
6. Use Suspense for async components
|
|
||||||
7. Implement proper error handling
|
|
||||||
8. Follow Vue 3 style guide and naming conventions
|
|
||||||
9. Use Vite for fast development and building
|
|
||||||
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
|
||||||
11. Never use deprecated PrimeVue components listed above
|
|
||||||
232
AGENTS.md
232
AGENTS.md
@@ -1,38 +1,224 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
- Source: `src/` (Vue 3 + TypeScript). Key areas: `components/`, `views/`, `stores/` (Pinia), `composables/`, `services/`, `utils/`, `assets/`, `locales/`.
|
|
||||||
- Routing/i18n/entry: `src/router.ts`, `src/i18n.ts`, `src/main.ts`.
|
- Source: `src/`
|
||||||
- Tests: unit/component in `tests-ui/` and `src/components/**/*.{test,spec}.ts`; E2E in `browser_tests/`.
|
- Vue 3.5+
|
||||||
- Public assets: `public/`. Build output: `dist/`.
|
- TypeScript
|
||||||
- Config: `vite.config.mts`, `vitest.config.ts`, `playwright.config.ts`, `eslint.config.ts`, `.prettierrc`.
|
- Tailwind 4
|
||||||
|
- Key areas:
|
||||||
|
- `components/`
|
||||||
|
- `views/`
|
||||||
|
- `stores/` (Pinia)
|
||||||
|
- `composables/`
|
||||||
|
- `services/`
|
||||||
|
- `utils/`
|
||||||
|
- `assets/`
|
||||||
|
- `locales/`
|
||||||
|
- Routing: `src/router.ts`,
|
||||||
|
- i18n: `src/i18n.ts`,
|
||||||
|
- Entry Point: `src/main.ts`.
|
||||||
|
- Tests:
|
||||||
|
- unit/component in `tests-ui/` and `src/**/*.test.ts`
|
||||||
|
- E2E (Playwright) in `browser_tests/**/*.spec.ts`
|
||||||
|
- Public assets: `public/`
|
||||||
|
- Build output: `dist/`
|
||||||
|
- Configs
|
||||||
|
- `vite.config.mts`
|
||||||
|
- `vitest.config.ts`
|
||||||
|
- `playwright.config.ts`
|
||||||
|
- `eslint.config.ts`
|
||||||
|
- `.prettierrc`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
## Monorepo Architecture
|
||||||
|
|
||||||
|
The project uses **Nx** for build orchestration and task management:
|
||||||
|
|
||||||
|
- **Task Orchestration**: Commands like `dev`, `build`, `lint`, and `test:browser` run via Nx
|
||||||
|
- **Caching**: Nx provides intelligent caching for faster rebuilds
|
||||||
|
- **Configuration**: Managed through `nx.json` with plugins for ESLint, Storybook, Vite, and Playwright
|
||||||
|
- **Dependencies**: Nx handles dependency graph analysis and parallel execution
|
||||||
|
|
||||||
|
Key Nx features:
|
||||||
|
|
||||||
|
- Build target caching and incremental builds
|
||||||
|
- Parallel task execution across the monorepo
|
||||||
|
- Plugin-based architecture for different tools
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
- `pnpm dev`: Start Vite dev server.
|
- `pnpm dev`: Start Vite dev server.
|
||||||
- `pnpm dev:electron`: Dev server with Electron API mocks.
|
- `pnpm dev:electron`: Dev server with Electron API mocks
|
||||||
- `pnpm build`: Type-check then production build to `dist/`.
|
- `pnpm build`: Type-check then production build to `dist/`
|
||||||
- `pnpm preview`: Preview the production build locally.
|
- `pnpm preview`: Preview the production build locally
|
||||||
- `pnpm test:unit`: Run Vitest unit tests.
|
- `pnpm test:unit`: Run Vitest unit tests
|
||||||
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`).
|
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
|
||||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint). `pnpm format` / `format:check`: Prettier.
|
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||||
- `pnpm typecheck`: Vue TSC type checking.
|
- `pnpm format` / `pnpm format:check`: Prettier
|
||||||
|
- `pnpm typecheck`: Vue TSC type checking
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
- Language: TypeScript, Vue SFCs (`.vue`). Indent 2 spaces; single quotes; no semicolons; width 80 (see `.prettierrc`).
|
|
||||||
- Imports: sorted/grouped by plugin; run `pnpm format` before committing.
|
- Language:
|
||||||
- ESLint: Vue + TS rules; no floating promises; unused imports disallowed; i18n raw text restrictions in templates.
|
- TypeScript (exclusive, no new JavaScript)
|
||||||
- Naming: Vue components in PascalCase (e.g., `MenuHamburger.vue`); composables `useXyz.ts`; Pinia stores `*Store.ts`.
|
- Vue 3 SFCs (`.vue`)
|
||||||
|
- Composition API only
|
||||||
|
- Tailwind 4 styling
|
||||||
|
- Avoid `<style>` blocks
|
||||||
|
- Style: (see `.prettierrc`)
|
||||||
|
- Indent 2 spaces
|
||||||
|
- single quotes
|
||||||
|
- no trailing semicolons
|
||||||
|
- width 80
|
||||||
|
- Imports:
|
||||||
|
- sorted/grouped by plugin
|
||||||
|
- run `pnpm format` before committing
|
||||||
|
- ESLint:
|
||||||
|
- Vue + TS rules
|
||||||
|
- no floating promises
|
||||||
|
- unused imports disallowed
|
||||||
|
- i18n raw text restrictions in templates
|
||||||
|
- Naming:
|
||||||
|
- Vue components in PascalCase (e.g., `MenuHamburger.vue`)
|
||||||
|
- composables `useXyz.ts`
|
||||||
|
- Pinia stores `*Store.ts`
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
- Frameworks: Vitest (unit/component, happy-dom) and Playwright (E2E).
|
|
||||||
- Test files: `**/*.{test,spec}.{ts,tsx,js}` under `tests-ui/`, `src/components/`, and `src/lib/litegraph/test/`.
|
- Frameworks:
|
||||||
- Coverage: text/json/html reporters enabled; aim to cover critical logic and new features.
|
- Vitest (unit/component, happy-dom)
|
||||||
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
|
- Playwright (E2E)
|
||||||
|
- Test files:
|
||||||
|
- Unit/Component: `**/*.test.ts`
|
||||||
|
- E2E: `browser_tests/**/*.spec.ts`
|
||||||
|
- Litegraph Specific: `src/lib/litegraph/test/`
|
||||||
|
- Coverage: text/json/html reporters enabled
|
||||||
|
- aim to cover critical logic and new features
|
||||||
|
- Playwright:
|
||||||
|
- optional tags like `@mobile`, `@2x` are respected by config
|
||||||
|
- Tests to avoid
|
||||||
|
- Change detector tests
|
||||||
|
- e.g. a test that just asserts that the defaults are certain values
|
||||||
|
- Tests that are dependent on non-behavioral features like utility classes or styles
|
||||||
|
- Redundant tests
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
- Commits: Use `[skip ci]` for locale-only updates when appropriate.
|
|
||||||
- PRs: Include clear description, linked issues (`- Fixes #123`), and screenshots/GIFs for UI changes.
|
- PRs:
|
||||||
- Quality gates: `pnpm lint`, `pnpm typecheck`, and relevant tests must pass. Keep PRs focused and small.
|
- Include clear description
|
||||||
|
- Reference linked issues (e.g. `- Fixes #123`)
|
||||||
|
- Keep it extremely concise and information-dense
|
||||||
|
- Don't use emojis or add excessive headers/sections
|
||||||
|
- Follow the PR description template in the `.github/` folder.
|
||||||
|
- Quality gates:
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm typecheck`
|
||||||
|
- `pnpm knip`
|
||||||
|
- Relevant tests must pass
|
||||||
|
- Never use `--no-verify` to bypass failing tests
|
||||||
|
- Identify the issue and present root cause analysis and possible solutions if you are unable to solve quickly yourself
|
||||||
|
- Keep PRs focused and small
|
||||||
|
- If it looks like the current changes will have 300+ lines of non-test code, suggest ways it could be broken into multiple PRs
|
||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
|
|
||||||
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
|
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
|
||||||
|
|
||||||
|
## Vue 3 Composition API Best Practices
|
||||||
|
|
||||||
|
- Use `<script setup lang="ts">` for component logic
|
||||||
|
- Utilize `ref` for reactive state
|
||||||
|
- Implement computed properties with computed()
|
||||||
|
- Use watch and watchEffect for side effects
|
||||||
|
- Avoid using a `ref` and a `watch` if a `computed` would work instead
|
||||||
|
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||||
|
- Utilize provide/inject for dependency injection
|
||||||
|
- Do not use dependency injection if a Store or a shared composable would be simpler
|
||||||
|
- Use Vue 3.5 TypeScript style of default prop declaration
|
||||||
|
- Example:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { nodes, showTotal = true } = defineProps<{
|
||||||
|
nodes: ApiNodeCost[]
|
||||||
|
showTotal?: boolean
|
||||||
|
}>()
|
||||||
|
```
|
||||||
|
|
||||||
|
- Do not use `withDefaults` or runtime props declaration
|
||||||
|
- Do not import Vue macros unnecessarily
|
||||||
|
- Prefer `useModel` to separately defining a prop and emit
|
||||||
|
- Be judicious with addition of new refs or other state
|
||||||
|
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
|
||||||
|
- If it's possible to use the `ref` or prop directly, don't add a `computed`
|
||||||
|
- If it's possible to use a `computed` to name and reuse a derived value, don't use a `watch`
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
1. Leverage VueUse functions for performance-enhancing styles
|
||||||
|
2. Use es-toolkit for utility functions
|
||||||
|
3. Use TypeScript for type safety
|
||||||
|
4. Implement proper props and emits definitions
|
||||||
|
5. Utilize Vue 3's Teleport component when needed
|
||||||
|
6. Use Suspense for async components
|
||||||
|
7. Implement proper error handling
|
||||||
|
8. Follow Vue 3 style guide and naming conventions
|
||||||
|
9. Use Vite for fast development and building
|
||||||
|
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||||
|
11. Avoid new usage of PrimeVue components
|
||||||
|
12. Write tests for all changes, especially bug fixes to catch future regressions
|
||||||
|
13. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
|
||||||
|
14. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
|
||||||
|
15. Refactoring should be used to make complex code simpler
|
||||||
|
|
||||||
|
## External Resources
|
||||||
|
|
||||||
|
- Vue: <https://vuejs.org/api/>
|
||||||
|
- Tailwind: <https://tailwindcss.com/docs/styling-with-utility-classes>
|
||||||
|
- shadcn/vue: <https://www.shadcn-vue.com/>
|
||||||
|
- Reka UI: <https://reka-ui.com/>
|
||||||
|
- PrimeVue: <https://primevue.org>
|
||||||
|
- ComfyUI: <https://docs.comfy.org>
|
||||||
|
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||||
|
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||||
|
|
||||||
|
## Project Philosophy
|
||||||
|
|
||||||
|
- Follow good software engineering principles
|
||||||
|
- YAGNI
|
||||||
|
- AHA
|
||||||
|
- DRY
|
||||||
|
- SOLID
|
||||||
|
- Clean, stable public APIs
|
||||||
|
- Domain-driven design
|
||||||
|
- Thousands of users and extensions
|
||||||
|
- Prioritize clean interfaces that restrict extension access
|
||||||
|
|
||||||
|
## Repository Navigation
|
||||||
|
|
||||||
|
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
|
||||||
|
- Prefer running single tests for performance
|
||||||
|
- Use --help for unfamiliar CLI tools
|
||||||
|
|
||||||
|
## GitHub Integration
|
||||||
|
|
||||||
|
When referencing Comfy-Org repos:
|
||||||
|
|
||||||
|
1. Check for local copy
|
||||||
|
2. Use GitHub API for branches/PRs/metadata
|
||||||
|
3. Curl GitHub website if needed
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- NEVER use `any` type - use proper TypeScript types
|
||||||
|
- NEVER use `as any` type assertions - fix the underlying type issue
|
||||||
|
- NEVER use `--no-verify` flag when committing
|
||||||
|
- NEVER delete or disable tests to make them pass
|
||||||
|
- NEVER circumvent quality checks
|
||||||
|
- NEVER use the `dark:` tailwind variant
|
||||||
|
- Instead use a semantic value from the `style.css` theme
|
||||||
|
- e.g. `bg-node-component-surface`
|
||||||
|
- NEVER use `:class="[]"` to merge class names
|
||||||
|
- Always use `import { cn } from '@/utils/tailwindUtil'`
|
||||||
|
- e.g. `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
|
||||||
|
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
|
||||||
|
|||||||
120
CLAUDE.md
120
CLAUDE.md
@@ -1,44 +1,19 @@
|
|||||||
# ComfyUI Frontend Project Guidelines
|
# Claude Code specific instructions
|
||||||
|
|
||||||
|
@Agents.md
|
||||||
|
|
||||||
## Repository Setup
|
## Repository Setup
|
||||||
|
|
||||||
For first-time setup, use the Claude command:
|
For first-time setup, use the Claude command:
|
||||||
```
|
|
||||||
|
```sh
|
||||||
/setup_repo
|
/setup_repo
|
||||||
```
|
```
|
||||||
|
|
||||||
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
|
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
|
||||||
|
|
||||||
**Prerequisites:** Node.js >= 24, Git repository, available ports (5173, 6006)
|
**Prerequisites:** Node.js >= 24, Git repository, available ports (5173, 6006)
|
||||||
|
|
||||||
## Quick Commands
|
|
||||||
|
|
||||||
- `pnpm`: See all available commands
|
|
||||||
- `pnpm dev`: Start development server (port 5173, via nx)
|
|
||||||
- `pnpm typecheck`: Type checking
|
|
||||||
- `pnpm build`: Build for production (via nx)
|
|
||||||
- `pnpm lint`: Linting (via nx)
|
|
||||||
- `pnpm oxlint`: Fast Rust-based linting with Oxc
|
|
||||||
- `pnpm format`: Prettier formatting
|
|
||||||
- `pnpm test:unit`: Run all unit tests
|
|
||||||
- `pnpm test:browser`: Run E2E tests via Playwright
|
|
||||||
- `pnpm test:unit -- tests-ui/tests/example.test.ts`: Run single test file
|
|
||||||
- `pnpm storybook`: Start Storybook development server (port 6006)
|
|
||||||
- `pnpm knip`: Detect unused code and dependencies
|
|
||||||
|
|
||||||
## Monorepo Architecture
|
|
||||||
|
|
||||||
The project now uses **Nx** for build orchestration and task management:
|
|
||||||
|
|
||||||
- **Task Orchestration**: Commands like `dev`, `build`, `lint`, and `test:browser` run via Nx
|
|
||||||
- **Caching**: Nx provides intelligent caching for faster rebuilds
|
|
||||||
- **Configuration**: Managed through `nx.json` with plugins for ESLint, Storybook, Vite, and Playwright
|
|
||||||
- **Dependencies**: Nx handles dependency graph analysis and parallel execution
|
|
||||||
|
|
||||||
Key Nx features:
|
|
||||||
- Build target caching and incremental builds
|
|
||||||
- Parallel task execution across the monorepo
|
|
||||||
- Plugin-based architecture for different tools
|
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
1. **First-time setup**: Run `/setup_repo` Claude command
|
1. **First-time setup**: Run `/setup_repo` Claude command
|
||||||
@@ -50,87 +25,6 @@ Key Nx features:
|
|||||||
|
|
||||||
## Git Conventions
|
## Git Conventions
|
||||||
|
|
||||||
- Use [prefix] format: [feat], [bugfix], [docs]
|
- Use `prefix:` format: `feat:`, `fix:`, `test:`
|
||||||
- Add "Fixes #n" to PR descriptions
|
- Add "Fixes #n" to PR descriptions
|
||||||
- Never mention Claude/AI in commits
|
- Never mention Claude/AI in commits
|
||||||
|
|
||||||
## External Resources
|
|
||||||
|
|
||||||
- PrimeVue docs: <https://primevue.org>
|
|
||||||
- ComfyUI docs: <https://docs.comfy.org>
|
|
||||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
|
||||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
|
||||||
|
|
||||||
## Project Philosophy
|
|
||||||
|
|
||||||
- Follow good software engineering principles
|
|
||||||
- YAGNI
|
|
||||||
- AHA
|
|
||||||
- DRY
|
|
||||||
- SOLID
|
|
||||||
- Clean, stable public APIs
|
|
||||||
- Domain-driven design
|
|
||||||
- Thousands of users and extensions
|
|
||||||
- Prioritize clean interfaces that restrict extension access
|
|
||||||
|
|
||||||
## Repository Navigation
|
|
||||||
|
|
||||||
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
|
|
||||||
- Prefer running single tests for performance
|
|
||||||
- Use --help for unfamiliar CLI tools
|
|
||||||
|
|
||||||
## GitHub Integration
|
|
||||||
|
|
||||||
When referencing Comfy-Org repos:
|
|
||||||
|
|
||||||
1. Check for local copy
|
|
||||||
2. Use GitHub API for branches/PRs/metadata
|
|
||||||
3. Curl GitHub website if needed
|
|
||||||
|
|
||||||
## Settings and Feature Flags Quick Reference
|
|
||||||
|
|
||||||
### Settings Usage
|
|
||||||
```typescript
|
|
||||||
const settingStore = useSettingStore()
|
|
||||||
const value = settingStore.get('Comfy.SomeSetting') // Get setting
|
|
||||||
await settingStore.set('Comfy.SomeSetting', newValue) // Update setting
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic Defaults
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: 'Comfy.Example.Setting',
|
|
||||||
defaultValue: () => window.innerWidth < 1024 ? 'small' : 'large' // Runtime context
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Version-Based Defaults
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: 'Comfy.Example.Feature',
|
|
||||||
defaultValue: 'legacy',
|
|
||||||
defaultsByInstallVersion: { '1.25.0': 'enhanced' } // Gradual rollout
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Feature Flags
|
|
||||||
```typescript
|
|
||||||
if (api.serverSupportsFeature('feature_name')) { // Check capability
|
|
||||||
// Use enhanced feature
|
|
||||||
}
|
|
||||||
const value = api.getServerFeature('config_name', defaultValue) // Get config
|
|
||||||
```
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
- Settings system: `docs/SETTINGS.md`
|
|
||||||
- Feature flags system: `docs/FEATURE_FLAGS.md`
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- NEVER use `any` type - use proper TypeScript types
|
|
||||||
- NEVER use `as any` type assertions - fix the underlying type issue
|
|
||||||
- NEVER use `--no-verify` flag when committing
|
|
||||||
- NEVER delete or disable tests to make them pass
|
|
||||||
- NEVER circumvent quality checks
|
|
||||||
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the `style.css` theme, e.g. `bg-node-component-surface`
|
|
||||||
- NEVER use `:class="[]"` to merge class names - always use `import { cn } from '@/utils/tailwindUtil'`, for example: `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||||
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import { loadRemoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
import { loadRemoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||||
|
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||||
import { useExtensionService } from '@/services/extensionService'
|
import { useExtensionService } from '@/services/extensionService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,6 +14,18 @@ useExtensionService().registerExtension({
|
|||||||
name: 'Comfy.Cloud.RemoteConfig',
|
name: 'Comfy.Cloud.RemoteConfig',
|
||||||
|
|
||||||
setup: async () => {
|
setup: async () => {
|
||||||
|
const { isLoggedIn } = useCurrentUser()
|
||||||
|
const { isActiveSubscription } = useSubscription()
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
[isLoggedIn, isActiveSubscription],
|
||||||
|
() => {
|
||||||
|
if (!isLoggedIn.value) return
|
||||||
|
void refreshRemoteConfig()
|
||||||
|
},
|
||||||
|
{ debounce: 256, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
// Poll for config updates every 10 minutes
|
// Poll for config updates every 10 minutes
|
||||||
setInterval(() => void loadRemoteConfig(), 600_000)
|
setInterval(() => void loadRemoteConfig(), 600_000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2091,7 +2091,7 @@
|
|||||||
"connectionError": "Please check your connection and try again",
|
"connectionError": "Please check your connection and try again",
|
||||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||||
"noModelsInFolder": "No {type} available in this folder",
|
"noModelsInFolder": "No {type} available in this folder",
|
||||||
"uploadModel": "Import model",
|
"uploadModel": "Import",
|
||||||
"uploadModelFromCivitai": "Import a model from Civitai",
|
"uploadModelFromCivitai": "Import a model from Civitai",
|
||||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||||
@@ -2113,6 +2113,8 @@
|
|||||||
"uploadingModel": "Importing model...",
|
"uploadingModel": "Importing model...",
|
||||||
"uploadSuccess": "Model imported successfully!",
|
"uploadSuccess": "Model imported successfully!",
|
||||||
"uploadFailed": "Import failed",
|
"uploadFailed": "Import failed",
|
||||||
|
"uploadModelHelpVideo": "Upload Model Help Video",
|
||||||
|
"uploadModelHowDoIFindThis": "How do I find this?",
|
||||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||||
"modelTypeSelectorLabel": "What type of model is this?",
|
"modelTypeSelectorLabel": "What type of model is this?",
|
||||||
"modelTypeSelectorPlaceholder": "Select model type",
|
"modelTypeSelectorPlaceholder": "Select model type",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
:on-click="showUploadDialog"
|
:on-click="showUploadDialog"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i class="icon-[lucide--package-plus]" />
|
<i class="icon-[lucide--folder-input]" />
|
||||||
</template>
|
</template>
|
||||||
</IconTextButton>
|
</IconTextButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
v-else
|
v-else
|
||||||
:src="asset.preview_url"
|
:src="asset.preview_url"
|
||||||
:alt="displayName"
|
:alt="displayName"
|
||||||
class="size-full object-contain cursor-pointer"
|
class="size-full object-cover cursor-pointer"
|
||||||
role="button"
|
role="button"
|
||||||
@click.self="interactive && $emit('select', asset)"
|
@click.self="interactive && $emit('select', asset)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="containerClasses" data-component-id="asset-filter-bar">
|
<div
|
||||||
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
class="flex gap-4 items-center justify-between px-6 pt-2 pb-6"
|
||||||
|
data-component-id="asset-filter-bar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex gap-4 items-center"
|
||||||
|
data-component-id="asset-filter-bar-left"
|
||||||
|
>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-if="availableFileFormats.length > 0"
|
v-if="availableFileFormats.length > 0"
|
||||||
v-model="fileFormats"
|
v-model="fileFormats"
|
||||||
:label="$t('assetBrowser.fileFormats')"
|
:label="$t('assetBrowser.fileFormats')"
|
||||||
:options="availableFileFormats"
|
:options="availableFileFormats"
|
||||||
:class="selectClasses"
|
class="min-w-32"
|
||||||
data-component-id="asset-filter-file-formats"
|
data-component-id="asset-filter-file-formats"
|
||||||
@update:model-value="handleFilterChange"
|
@update:model-value="handleFilterChange"
|
||||||
/>
|
/>
|
||||||
@@ -16,18 +22,18 @@
|
|||||||
v-model="baseModels"
|
v-model="baseModels"
|
||||||
:label="$t('assetBrowser.baseModels')"
|
:label="$t('assetBrowser.baseModels')"
|
||||||
:options="availableBaseModels"
|
:options="availableBaseModels"
|
||||||
:class="selectClasses"
|
class="min-w-32"
|
||||||
data-component-id="asset-filter-base-models"
|
data-component-id="asset-filter-base-models"
|
||||||
@update:model-value="handleFilterChange"
|
@update:model-value="handleFilterChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
|
<div class="flex items-center" data-component-id="asset-filter-bar-right">
|
||||||
<SingleSelect
|
<SingleSelect
|
||||||
v-model="sortBy"
|
v-model="sortBy"
|
||||||
:label="$t('assetBrowser.sortBy')"
|
:label="$t('assetBrowser.sortBy')"
|
||||||
:options="sortOptions"
|
:options="sortOptions"
|
||||||
:class="selectClasses"
|
class="min-w-32"
|
||||||
data-component-id="asset-filter-sort"
|
data-component-id="asset-filter-sort"
|
||||||
@update:model-value="handleFilterChange"
|
@update:model-value="handleFilterChange"
|
||||||
>
|
>
|
||||||
@@ -48,7 +54,6 @@ import type { SelectOption } from '@/components/input/types'
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
fileFormats: string[]
|
fileFormats: string[]
|
||||||
@@ -56,35 +61,31 @@ export interface FilterState {
|
|||||||
sortBy: string
|
sortBy: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
|
||||||
|
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
|
||||||
|
{ name: t('assetBrowser.sortZA'), value: 'name-desc' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type SortOption = (typeof SORT_OPTIONS)[number]['value']
|
||||||
|
|
||||||
|
const sortOptions = [...SORT_OPTIONS]
|
||||||
|
|
||||||
const { assets = [] } = defineProps<{
|
const { assets = [] } = defineProps<{
|
||||||
assets?: AssetItem[]
|
assets?: AssetItem[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fileFormats = ref<SelectOption[]>([])
|
const fileFormats = ref<SelectOption[]>([])
|
||||||
const baseModels = ref<SelectOption[]>([])
|
const baseModels = ref<SelectOption[]>([])
|
||||||
const sortBy = ref('name-asc')
|
const sortBy = ref<SortOption>('recent')
|
||||||
|
|
||||||
const { availableFileFormats, availableBaseModels } =
|
const { availableFileFormats, availableBaseModels } =
|
||||||
useAssetFilterOptions(assets)
|
useAssetFilterOptions(assets)
|
||||||
|
|
||||||
const sortOptions = [
|
|
||||||
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
|
|
||||||
{ name: t('assetBrowser.sortZA'), value: 'name-desc' },
|
|
||||||
{ name: t('assetBrowser.sortRecent'), value: 'recent' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
filterChange: [filters: FilterState]
|
filterChange: [filters: FilterState]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const containerClasses = cn(
|
|
||||||
'flex gap-4 items-center justify-between',
|
|
||||||
'px-6 pt-2 pb-6'
|
|
||||||
)
|
|
||||||
const leftSideClasses = cn('flex gap-4 items-center')
|
|
||||||
const rightSideClasses = cn('flex items-center')
|
|
||||||
const selectClasses = cn('min-w-32')
|
|
||||||
|
|
||||||
function handleFilterChange() {
|
function handleFilterChange() {
|
||||||
emit('filterChange', {
|
emit('filterChange', {
|
||||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex justify-end gap-2 w-full">
|
<div class="flex justify-end gap-2 w-full">
|
||||||
<span
|
<IconTextButton
|
||||||
v-if="currentStep === 1"
|
v-if="currentStep === 1"
|
||||||
class="text-muted-foreground mr-auto underline flex items-center gap-2"
|
:label="$t('assetBrowser.uploadModelHowDoIFindThis')"
|
||||||
|
type="transparent"
|
||||||
|
size="md"
|
||||||
|
class="mr-auto underline text-muted-foreground"
|
||||||
|
data-attr="upload-model-step1-help-link"
|
||||||
|
@click="showVideoHelp = true"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--circle-question-mark]" />
|
<template #icon>
|
||||||
<a
|
<i class="icon-[lucide--circle-question-mark]" />
|
||||||
href="#"
|
</template>
|
||||||
target="_blank"
|
</IconTextButton>
|
||||||
class="text-muted-foreground"
|
|
||||||
data-attr="upload-model-step1-help-link"
|
|
||||||
>{{ $t('How do I find this?') }}</a
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<TextButton
|
<TextButton
|
||||||
v-if="currentStep === 1"
|
v-if="currentStep === 1"
|
||||||
:label="$t('g.cancel')"
|
:label="$t('g.cancel')"
|
||||||
@@ -73,12 +73,22 @@
|
|||||||
data-attr="upload-model-step3-finish-button"
|
data-attr="upload-model-step3-finish-button"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
/>
|
/>
|
||||||
|
<VideoHelpDialog
|
||||||
|
v-model="showVideoHelp"
|
||||||
|
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
|
||||||
|
:aria-label="$t('assetBrowser.uploadModelHelpVideo')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
import TextButton from '@/components/button/TextButton.vue'
|
import TextButton from '@/components/button/TextButton.vue'
|
||||||
|
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
|
||||||
|
|
||||||
|
const showVideoHelp = ref(false)
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
currentStep: number
|
currentStep: number
|
||||||
|
|||||||
75
src/platform/assets/components/VideoHelpDialog.vue
Normal file
75
src/platform/assets/components/VideoHelpDialog.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="isVisible"
|
||||||
|
modal
|
||||||
|
:closable="false"
|
||||||
|
:close-on-escape="false"
|
||||||
|
:dismissable-mask="true"
|
||||||
|
:pt="{
|
||||||
|
root: { class: 'video-help-dialog' },
|
||||||
|
header: { class: '!hidden' },
|
||||||
|
content: { class: '!p-0' },
|
||||||
|
mask: { class: '!bg-black/70' }
|
||||||
|
}"
|
||||||
|
:style="{ width: '90vw', maxWidth: '800px' }"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<IconButton
|
||||||
|
class="absolute top-4 right-6 z-10"
|
||||||
|
:aria-label="$t('g.close')"
|
||||||
|
@click="isVisible = false"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times text-sm" />
|
||||||
|
</IconButton>
|
||||||
|
<video
|
||||||
|
autoplay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
class="w-full rounded-lg"
|
||||||
|
:src="videoUrl"
|
||||||
|
>
|
||||||
|
{{ $t('g.videoFailedToLoad') }}
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import { onWatcherCleanup, watch } from 'vue'
|
||||||
|
|
||||||
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
|
|
||||||
|
const isVisible = defineModel<boolean>({ required: true })
|
||||||
|
|
||||||
|
const { videoUrl, ariaLabel = 'Help video' } = defineProps<{
|
||||||
|
videoUrl: string
|
||||||
|
ariaLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
event.stopPropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
isVisible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add listener with capture phase to intercept before parent dialogs
|
||||||
|
// Only active when dialog is visible
|
||||||
|
watch(
|
||||||
|
isVisible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
const stop = useEventListener(document, 'keydown', handleEscapeKey, {
|
||||||
|
capture: true
|
||||||
|
})
|
||||||
|
onWatcherCleanup(stop)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
23
src/platform/remoteConfig/refreshRemoteConfig.ts
Normal file
23
src/platform/remoteConfig/refreshRemoteConfig.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { api } from '@/scripts/api'
|
||||||
|
|
||||||
|
import { remoteConfig } from './remoteConfig'
|
||||||
|
|
||||||
|
export async function refreshRemoteConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi('/features', { cache: 'no-store' })
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json()
|
||||||
|
window.__CONFIG__ = config
|
||||||
|
remoteConfig.value = config
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Failed to load remote config:', response.statusText)
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
window.__CONFIG__ = {}
|
||||||
|
remoteConfig.value = {}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch remote config:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import type { FilterOption, OptionId } from './types'
|
import type { FilterOption, OptionId } from './types'
|
||||||
|
|
||||||
defineProps<{
|
const { filterOptions } = defineProps<{
|
||||||
filterOptions: FilterOption[]
|
filterOptions: FilterOption[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const filterSelected = defineModel<OptionId>('filterSelected')
|
const filterSelected = defineModel<OptionId>('filterSelected')
|
||||||
|
|
||||||
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
|
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
|
||||||
|
|
||||||
|
// TODO: Add real check to differentiate between the Model dialogs and Load Image
|
||||||
|
const singleFilterOption = computed(() => filterOptions.length === 1)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="text-secondary mb-4 flex gap-1 px-4 justify-between">
|
<div class="text-secondary mb-4 flex gap-1 px-4 justify-start">
|
||||||
<div
|
<button
|
||||||
v-for="option in filterOptions"
|
v-for="option in filterOptions"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
|
type="button"
|
||||||
|
:disabled="singleFilterOption"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
|
'px-4 py-2 rounded-md inline-flex justify-center items-center select-none appearance-none border-0 text-base-foreground',
|
||||||
'transition-all duration-150',
|
!singleFilterOption &&
|
||||||
'hover:text-base-foreground hover:bg-interface-menu-component-surface-hovered',
|
'transition-all duration-150 hover:text-base-foreground hover:bg-interface-menu-component-surface-hovered cursor-pointer active:scale-95',
|
||||||
'active:scale-95',
|
!singleFilterOption && filterSelected === option.id
|
||||||
filterSelected === option.id
|
|
||||||
? '!bg-interface-menu-component-surface-selected text-base-foreground'
|
? '!bg-interface-menu-component-surface-selected text-base-foreground'
|
||||||
: 'bg-transparent'
|
: 'bg-transparent'
|
||||||
)
|
)
|
||||||
@@ -33,10 +39,11 @@ const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
|
|||||||
@click="filterSelected = option.id"
|
@click="filterSelected = option.id"
|
||||||
>
|
>
|
||||||
{{ option.name }}
|
{{ option.name }}
|
||||||
</div>
|
</button>
|
||||||
<IconTextButton
|
<IconTextButton
|
||||||
v-if="isUploadButtonEnabled"
|
v-if="isUploadButtonEnabled && singleFilterOption"
|
||||||
:label="$t('g.import')"
|
:label="$t('g.import')"
|
||||||
|
class="ml-auto"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
@click="showUploadDialog"
|
@click="showUploadDialog"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -75,23 +75,6 @@ function mountAssetFilterBar(props = {}) {
|
|||||||
|
|
||||||
describe('AssetFilterBar', () => {
|
describe('AssetFilterBar', () => {
|
||||||
describe('Filter State Management', () => {
|
describe('Filter State Management', () => {
|
||||||
it('maintains correct initial state', () => {
|
|
||||||
// Provide assets with options so filters are visible
|
|
||||||
const assets = [
|
|
||||||
createAssetWithSpecificExtension('safetensors'),
|
|
||||||
createAssetWithSpecificBaseModel('sd15')
|
|
||||||
]
|
|
||||||
const wrapper = mountAssetFilterBar({ assets })
|
|
||||||
|
|
||||||
// Test initial state through component props
|
|
||||||
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
|
|
||||||
const singleSelect = wrapper.findComponent({ name: 'SingleSelect' })
|
|
||||||
|
|
||||||
expect(multiSelects[0].props('modelValue')).toEqual([])
|
|
||||||
expect(multiSelects[1].props('modelValue')).toEqual([])
|
|
||||||
expect(singleSelect.props('modelValue')).toBe('name-asc')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles multiple simultaneous filter changes correctly', async () => {
|
it('handles multiple simultaneous filter changes correctly', async () => {
|
||||||
// Provide assets with options so filters are visible
|
// Provide assets with options so filters are visible
|
||||||
const assets = [
|
const assets = [
|
||||||
|
|||||||
Reference in New Issue
Block a user