mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 08:14:06 +00:00
Merge branch 'main' into sno-fix-playwright-babel-config
This commit is contained in:
15
.github/workflows/test-ui.yaml
vendored
15
.github/workflows/test-ui.yaml
vendored
@@ -229,7 +229,13 @@ jobs:
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
|
||||
run: |
|
||||
# Run tests with both HTML and JSON reporters
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
npx playwright test --project=${{ matrix.browser }} \
|
||||
--reporter=list \
|
||||
--reporter=html \
|
||||
--reporter=json
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -275,7 +281,12 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge into HTML Report
|
||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
run: |
|
||||
# Generate HTML report
|
||||
npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
# Generate JSON report separately with explicit output path
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
npx playwright merge-reports --reporter=json ./all-blob-reports
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Upload HTML report
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 169 KiB |
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/assets/css/style.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"composables": "@/composables",
|
||||
"utils": "@/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
62
docs/adr/0004-fork-primevue-ui-library.md
Normal file
62
docs/adr/0004-fork-primevue-ui-library.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 4. Fork PrimeVue UI Library
|
||||
|
||||
Date: 2025-08-27
|
||||
|
||||
## Status
|
||||
|
||||
Rejected
|
||||
|
||||
## Context
|
||||
|
||||
ComfyUI's frontend requires modifications to PrimeVue components that cannot be achieved through the library's customization APIs. Two specific technical incompatibilities have been identified with the transform-based canvas architecture:
|
||||
|
||||
**Screen Coordinate Hit-Testing Conflicts:**
|
||||
- PrimeVue components use `getBoundingClientRect()` for screen coordinate calculations that don't account for CSS transforms
|
||||
- The Slider component directly uses raw `pageX/pageY` coordinates ([lines 102-103](https://github.com/primefaces/primevue/blob/master/packages/primevue/src/slider/Slider.vue#L102-L103)) without transform-aware positioning
|
||||
- This breaks interaction in transformed coordinate spaces where screen coordinates don't match logical element positions
|
||||
|
||||
**Virtual Canvas Scroll Interference:**
|
||||
- LiteGraph's infinite canvas uses scroll coordinates semantically for graph navigation via the `DragAndScale` coordinate system
|
||||
- PrimeVue overlay components automatically trigger `scrollIntoView` behavior which interferes with this virtual positioning
|
||||
- This issue is documented in [PrimeVue discussion #4270](https://github.com/orgs/primefaces/discussions/4270) where the feature request was made to disable this behavior
|
||||
|
||||
**Historical Overlay Issues:**
|
||||
- Previous z-index positioning conflicts required manual workarounds (commit `6d4eafb0`) where PrimeVue Dialog components needed `autoZIndex: false` and custom mask styling, later resolved by removing PrimeVue's automatic z-index management entirely
|
||||
|
||||
**Minimal Update Overhead:**
|
||||
- Analysis of git history shows only 2 PrimeVue version updates in 2+ years, indicating that upstream sync overhead is negligible for this project
|
||||
|
||||
**Future Interaction System Requirements:**
|
||||
- The ongoing canvas architecture evolution will require more granular control over component interaction and event handling as the transform-based system matures
|
||||
- Predictable need for additional component modifications beyond current identified issues
|
||||
|
||||
## Decision
|
||||
|
||||
We will **NOT** fork PrimeVue. After evaluation, forking was determined to be unnecessarily complex and costly.
|
||||
|
||||
**Rationale for Rejection:**
|
||||
|
||||
- **Significant Implementation Complexity**: PrimeVue is structured as a monorepo ([primefaces/primevue](https://github.com/primefaces/primevue)) with significant code in a separate monorepo ([PrimeUIX](https://github.com/primefaces/primeuix)). Forking would require importing both repositories whole and selectively pruning or exempting components from our workspace tooling, adding substantial complexity.
|
||||
|
||||
- **Alternative Solutions Available**: The modifications we identified (e.g., scroll interference issues, coordinate system conflicts) have less costly solutions that don't require maintaining a full fork. For example, coordinate issues could be addressed through event interception and synthetic event creation with scaled values.
|
||||
|
||||
- **Maintenance Burden**: Ongoing maintenance and upgrades would be very painful, requiring manual conflict resolution and keeping pace with upstream changes across multiple repositories.
|
||||
|
||||
- **Limited Tooling Support**: There isn't adequate tooling that provides the granularity needed to cleanly manage a PrimeVue fork within our existing infrastructure.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Alternative Approach
|
||||
|
||||
- **Use PrimeVue as External Dependency**: Continue using PrimeVue as a standard npm dependency
|
||||
- **Targeted Workarounds**: Implement specific solutions for identified issues (coordinate system conflicts, scroll interference) without forking the entire library
|
||||
- **Selective Component Replacement**: Use libraries like shadcn/ui to replace specific problematic PrimeVue components and adjust them to match our design system
|
||||
- **Upstream Engagement**: Continue engaging with PrimeVue community for feature requests and bug reports
|
||||
- **Maintain Flexibility**: Preserve ability to upgrade PrimeVue versions without fork maintenance overhead
|
||||
|
||||
## Notes
|
||||
|
||||
- Technical issues documented in the Context section remain valid concerns
|
||||
- Solutions will be pursued through targeted fixes rather than wholesale forking
|
||||
- Future re-evaluation possible if PrimeVue's architecture significantly changes or if alternative tooling becomes available
|
||||
- This decision prioritizes maintainability and development velocity over maximum customization control
|
||||
@@ -13,6 +13,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ export default [
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'vue/no-restricted-class': ['error', '/^dark:/'],
|
||||
'vue/multi-word-component-names': 'off', // TODO: fix
|
||||
'vue/no-template-shadow': 'off', // TODO: fix
|
||||
'vue/one-component-per-file': 'off', // TODO: fix
|
||||
// Restrict deprecated PrimeVue components
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"tsx": "^4.15.6",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"unplugin-icons": "^0.22.0",
|
||||
@@ -153,6 +154,7 @@
|
||||
"pinia": "^2.1.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.2.5",
|
||||
"reka-ui": "^2.5.0",
|
||||
"semver": "^7.7.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.170.0",
|
||||
|
||||
150
pnpm-lock.yaml
generated
150
pnpm-lock.yaml
generated
@@ -134,6 +134,9 @@ importers:
|
||||
primevue:
|
||||
specifier: ^4.2.5
|
||||
version: 4.2.5(vue@3.5.13(typescript@5.9.2))
|
||||
reka-ui:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2))
|
||||
semver:
|
||||
specifier: ^7.7.2
|
||||
version: 7.7.2
|
||||
@@ -342,6 +345,9 @@ importers:
|
||||
tsx:
|
||||
specifier: ^4.15.6
|
||||
version: 4.19.4
|
||||
tw-animate-css:
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
@@ -1622,6 +1628,18 @@ packages:
|
||||
'@firebase/webchannel-wrapper@1.0.3':
|
||||
resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==}
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
'@floating-ui/dom@1.7.4':
|
||||
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@floating-ui/vue@1.1.9':
|
||||
resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==}
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
|
||||
engines: {node: ^8.13.0 || >=10.10.0}
|
||||
@@ -1662,6 +1680,12 @@ packages:
|
||||
'@iconify/utils@2.3.0':
|
||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||
|
||||
'@internationalized/date@3.9.0':
|
||||
resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==}
|
||||
|
||||
'@internationalized/number@3.6.5':
|
||||
resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==}
|
||||
|
||||
'@intlify/core-base@9.14.3':
|
||||
resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -2295,6 +2319,9 @@ packages:
|
||||
storybook: ^9.1.1
|
||||
vue: ^3.0.0
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
||||
|
||||
'@tailwindcss/node@4.1.12':
|
||||
resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==}
|
||||
|
||||
@@ -2385,6 +2412,14 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/virtual-core@3.13.12':
|
||||
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12':
|
||||
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2661,6 +2696,9 @@ packages:
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/webxr@0.5.20':
|
||||
resolution: {integrity: sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==}
|
||||
|
||||
@@ -2926,12 +2964,21 @@ packages:
|
||||
'@vueuse/core@11.0.0':
|
||||
resolution: {integrity: sha512-shibzNGjmRjZucEm97B8V0NO5J3vPHMCE/mltxQ3vHezbDoFQBMtK11XsfwfPionxSbo+buqPmsCljtYuXIBpw==}
|
||||
|
||||
'@vueuse/core@12.8.2':
|
||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||
|
||||
'@vueuse/metadata@11.0.0':
|
||||
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
|
||||
|
||||
'@vueuse/metadata@12.8.2':
|
||||
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
||||
|
||||
'@vueuse/shared@11.0.0':
|
||||
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
|
||||
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
'@webgpu/types@0.1.51':
|
||||
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
|
||||
|
||||
@@ -3093,6 +3140,10 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
aria-query@5.3.0:
|
||||
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
||||
|
||||
@@ -3754,6 +3805,9 @@ packages:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
defu@6.1.4:
|
||||
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -5427,6 +5481,9 @@ packages:
|
||||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
@@ -5921,6 +5978,11 @@ packages:
|
||||
resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==}
|
||||
hasBin: true
|
||||
|
||||
reka-ui@2.5.0:
|
||||
resolution: {integrity: sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==}
|
||||
peerDependencies:
|
||||
vue: '>= 3.2.0'
|
||||
|
||||
relateurl@0.2.7:
|
||||
resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -6374,6 +6436,9 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tw-animate-css@1.3.8:
|
||||
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -8405,6 +8470,26 @@ snapshots:
|
||||
|
||||
'@firebase/webchannel-wrapper@1.0.3': {}
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.4':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@floating-ui/vue@1.1.9(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.4
|
||||
'@floating-ui/utils': 0.2.10
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.7.13
|
||||
@@ -8456,6 +8541,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@internationalized/date@3.9.0':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
'@internationalized/number@3.6.5':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
'@intlify/core-base@9.14.3':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 9.14.3
|
||||
@@ -9240,6 +9333,10 @@ snapshots:
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.6
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/node@4.1.12':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -9311,6 +9408,13 @@ snapshots:
|
||||
tailwindcss: 4.1.12
|
||||
vite: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.12
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
@@ -9618,6 +9722,8 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/webxr@0.5.20': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)':
|
||||
@@ -10056,8 +10162,19 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/core@12.8.2(typescript@5.9.2)':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 12.8.2
|
||||
'@vueuse/shared': 12.8.2(typescript@5.9.2)
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/metadata@11.0.0': {}
|
||||
|
||||
'@vueuse/metadata@12.8.2': {}
|
||||
|
||||
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
|
||||
@@ -10065,6 +10182,12 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/shared@12.8.2(typescript@5.9.2)':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@webgpu/types@0.1.51': {}
|
||||
|
||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||
@@ -10217,6 +10340,10 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
aria-query@5.3.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -11082,6 +11209,8 @@ snapshots:
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
defu@6.1.4: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
@@ -13023,6 +13152,8 @@ snapshots:
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
@@ -13643,6 +13774,23 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 3.0.2
|
||||
|
||||
reka-ui@2.5.0(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.4
|
||||
'@floating-ui/vue': 1.1.9(vue@3.5.13(typescript@5.9.2))
|
||||
'@internationalized/date': 3.9.0
|
||||
'@internationalized/number': 3.6.5
|
||||
'@tanstack/vue-virtual': 3.13.12(vue@3.5.13(typescript@5.9.2))
|
||||
'@vueuse/core': 12.8.2(typescript@5.9.2)
|
||||
'@vueuse/shared': 12.8.2(typescript@5.9.2)
|
||||
aria-hidden: 1.2.6
|
||||
defu: 6.1.4
|
||||
ohash: 2.0.11
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- typescript
|
||||
|
||||
relateurl@0.2.7: {}
|
||||
|
||||
remark-frontmatter@5.0.0:
|
||||
@@ -14102,6 +14250,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tw-animate-css@1.3.8: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
183
scripts/cicd/extract-playwright-counts.ts
Executable file
183
scripts/cicd/extract-playwright-counts.ts
Executable file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env tsx
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
interface TestStats {
|
||||
expected?: number
|
||||
unexpected?: number
|
||||
flaky?: number
|
||||
skipped?: number
|
||||
finished?: number
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
stats?: TestStats
|
||||
}
|
||||
|
||||
interface TestCounts {
|
||||
passed: number
|
||||
failed: number
|
||||
flaky: number
|
||||
skipped: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract test counts from Playwright HTML report
|
||||
* @param reportDir - Path to the playwright-report directory
|
||||
* @returns Test counts { passed, failed, flaky, skipped, total }
|
||||
*/
|
||||
function extractTestCounts(reportDir: string): TestCounts {
|
||||
const counts: TestCounts = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
flaky: 0,
|
||||
skipped: 0,
|
||||
total: 0
|
||||
}
|
||||
|
||||
try {
|
||||
// First, try to find report.json which Playwright generates with JSON reporter
|
||||
const jsonReportFile = path.join(reportDir, 'report.json')
|
||||
if (fs.existsSync(jsonReportFile)) {
|
||||
const reportJson: ReportData = JSON.parse(
|
||||
fs.readFileSync(jsonReportFile, 'utf-8')
|
||||
)
|
||||
if (reportJson.stats) {
|
||||
const stats = reportJson.stats
|
||||
counts.total = stats.expected || 0
|
||||
counts.passed =
|
||||
(stats.expected || 0) -
|
||||
(stats.unexpected || 0) -
|
||||
(stats.flaky || 0) -
|
||||
(stats.skipped || 0)
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
return counts
|
||||
}
|
||||
}
|
||||
|
||||
// Try index.html - Playwright HTML report embeds data in a script tag
|
||||
const indexFile = path.join(reportDir, 'index.html')
|
||||
if (fs.existsSync(indexFile)) {
|
||||
const content = fs.readFileSync(indexFile, 'utf-8')
|
||||
|
||||
// Look for the embedded report data in various formats
|
||||
// Format 1: window.playwrightReportBase64
|
||||
let dataMatch = content.match(
|
||||
/window\.playwrightReportBase64\s*=\s*["']([^"']+)["']/
|
||||
)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const decodedData = Buffer.from(dataMatch[1], 'base64').toString(
|
||||
'utf-8'
|
||||
)
|
||||
const reportData: ReportData = JSON.parse(decodedData)
|
||||
|
||||
if (reportData.stats) {
|
||||
const stats = reportData.stats
|
||||
counts.total = stats.expected || 0
|
||||
counts.passed =
|
||||
(stats.expected || 0) -
|
||||
(stats.unexpected || 0) -
|
||||
(stats.flaky || 0) -
|
||||
(stats.skipped || 0)
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
return counts
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to try other formats
|
||||
}
|
||||
}
|
||||
|
||||
// Format 2: window.playwrightReport
|
||||
dataMatch = content.match(/window\.playwrightReport\s*=\s*({[\s\S]*?});/)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
// Use Function constructor instead of eval for safety
|
||||
const reportData = new Function(
|
||||
'return ' + dataMatch[1]
|
||||
)() as ReportData
|
||||
|
||||
if (reportData.stats) {
|
||||
const stats = reportData.stats
|
||||
counts.total = stats.expected || 0
|
||||
counts.passed =
|
||||
(stats.expected || 0) -
|
||||
(stats.unexpected || 0) -
|
||||
(stats.flaky || 0) -
|
||||
(stats.skipped || 0)
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
return counts
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to try other formats
|
||||
}
|
||||
}
|
||||
|
||||
// Format 3: Look for stats in the HTML content directly
|
||||
// Playwright sometimes renders stats in the UI
|
||||
const statsMatch = content.match(
|
||||
/(\d+)\s+passed[^0-9]*(\d+)\s+failed[^0-9]*(\d+)\s+flaky[^0-9]*(\d+)\s+skipped/i
|
||||
)
|
||||
if (statsMatch) {
|
||||
counts.passed = parseInt(statsMatch[1]) || 0
|
||||
counts.failed = parseInt(statsMatch[2]) || 0
|
||||
counts.flaky = parseInt(statsMatch[3]) || 0
|
||||
counts.skipped = parseInt(statsMatch[4]) || 0
|
||||
counts.total =
|
||||
counts.passed + counts.failed + counts.flaky + counts.skipped
|
||||
return counts
|
||||
}
|
||||
|
||||
// Format 4: Try to extract from summary text patterns
|
||||
const passedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+passed/i)
|
||||
const failedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+failed/i)
|
||||
const flakyMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+flaky/i)
|
||||
const skippedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+skipped/i)
|
||||
const totalMatch = content.match(
|
||||
/(\d+)\s+(?:tests?|specs?)\s+(?:total|ran)/i
|
||||
)
|
||||
|
||||
if (passedMatch) counts.passed = parseInt(passedMatch[1]) || 0
|
||||
if (failedMatch) counts.failed = parseInt(failedMatch[1]) || 0
|
||||
if (flakyMatch) counts.flaky = parseInt(flakyMatch[1]) || 0
|
||||
if (skippedMatch) counts.skipped = parseInt(skippedMatch[1]) || 0
|
||||
if (totalMatch) {
|
||||
counts.total = parseInt(totalMatch[1]) || 0
|
||||
} else if (
|
||||
counts.passed ||
|
||||
counts.failed ||
|
||||
counts.flaky ||
|
||||
counts.skipped
|
||||
) {
|
||||
counts.total =
|
||||
counts.passed + counts.failed + counts.flaky + counts.skipped
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading report from ${reportDir}:`, error)
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const reportDir = process.argv[2]
|
||||
|
||||
if (!reportDir) {
|
||||
console.error('Usage: extract-playwright-counts.ts <report-directory>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const counts = extractTestCounts(reportDir)
|
||||
|
||||
// Output as JSON for easy parsing in shell script
|
||||
console.log(JSON.stringify(counts))
|
||||
|
||||
export { extractTestCounts }
|
||||
@@ -58,6 +58,12 @@ if ! command -v wrangler > /dev/null 2>&1; then
|
||||
}
|
||||
fi
|
||||
|
||||
# Check if tsx is available, install if not
|
||||
if ! command -v tsx > /dev/null 2>&1; then
|
||||
echo "Installing tsx..." >&2
|
||||
npm install -g tsx >&2 || echo "Failed to install tsx" >&2
|
||||
fi
|
||||
|
||||
# Deploy a single browser report, WARN: ensure inputs are sanitized before calling this function
|
||||
deploy_report() {
|
||||
dir="$1"
|
||||
@@ -159,12 +165,16 @@ else
|
||||
echo "Available reports:"
|
||||
ls -la reports/ 2>/dev/null || echo "Reports directory not found"
|
||||
|
||||
# Deploy all reports in parallel and collect URLs
|
||||
# Deploy all reports in parallel and collect URLs + test counts
|
||||
temp_dir=$(mktemp -d)
|
||||
pids=""
|
||||
i=0
|
||||
|
||||
# Start parallel deployments
|
||||
# Store current working directory for absolute paths
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BASE_DIR="$(pwd)"
|
||||
|
||||
# Start parallel deployments and count extractions
|
||||
for browser in $BROWSERS; do
|
||||
if [ -d "reports/playwright-report-$browser" ]; then
|
||||
echo "Found report for $browser, deploying in parallel..."
|
||||
@@ -172,11 +182,26 @@ else
|
||||
url=$(deploy_report "reports/playwright-report-$browser" "$browser" "$cloudflare_branch")
|
||||
echo "$url" > "$temp_dir/$i.url"
|
||||
echo "Deployment result for $browser: $url"
|
||||
|
||||
# Extract test counts using tsx (TypeScript executor)
|
||||
EXTRACT_SCRIPT="$SCRIPT_DIR/extract-playwright-counts.ts"
|
||||
REPORT_DIR="$BASE_DIR/reports/playwright-report-$browser"
|
||||
|
||||
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
|
||||
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
|
||||
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
|
||||
echo "Extracted counts for $browser: $counts" >&2
|
||||
echo "$counts" > "$temp_dir/$i.counts"
|
||||
else
|
||||
echo "Script not found or tsx not available: $EXTRACT_SCRIPT" >&2
|
||||
echo '{}' > "$temp_dir/$i.counts"
|
||||
fi
|
||||
) &
|
||||
pids="$pids $!"
|
||||
else
|
||||
echo "Report not found for $browser at reports/playwright-report-$browser"
|
||||
echo "failed" > "$temp_dir/$i.url"
|
||||
echo '{}' > "$temp_dir/$i.counts"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
@@ -186,8 +211,9 @@ else
|
||||
wait $pid
|
||||
done
|
||||
|
||||
# Collect URLs in order
|
||||
# Collect URLs and counts in order
|
||||
urls=""
|
||||
all_counts=""
|
||||
i=0
|
||||
for browser in $BROWSERS; do
|
||||
if [ -f "$temp_dir/$i.url" ]; then
|
||||
@@ -200,37 +226,147 @@ else
|
||||
else
|
||||
urls="$urls $url"
|
||||
fi
|
||||
|
||||
if [ -f "$temp_dir/$i.counts" ]; then
|
||||
counts=$(cat "$temp_dir/$i.counts")
|
||||
echo "Read counts for $browser from $temp_dir/$i.counts: $counts" >&2
|
||||
else
|
||||
counts="{}"
|
||||
echo "No counts file found for $browser at $temp_dir/$i.counts" >&2
|
||||
fi
|
||||
if [ -z "$all_counts" ]; then
|
||||
all_counts="$counts"
|
||||
else
|
||||
all_counts="$all_counts|$counts"
|
||||
fi
|
||||
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$temp_dir"
|
||||
|
||||
# Calculate total test counts across all browsers
|
||||
total_passed=0
|
||||
total_failed=0
|
||||
total_flaky=0
|
||||
total_skipped=0
|
||||
total_tests=0
|
||||
|
||||
# Parse counts and calculate totals
|
||||
IFS='|'
|
||||
set -- $all_counts
|
||||
for counts_json; do
|
||||
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
|
||||
# Parse JSON counts using simple grep/sed if jq is not available
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
passed=$(echo "$counts_json" | jq -r '.passed // 0')
|
||||
failed=$(echo "$counts_json" | jq -r '.failed // 0')
|
||||
flaky=$(echo "$counts_json" | jq -r '.flaky // 0')
|
||||
skipped=$(echo "$counts_json" | jq -r '.skipped // 0')
|
||||
total=$(echo "$counts_json" | jq -r '.total // 0')
|
||||
else
|
||||
# Fallback parsing without jq
|
||||
passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p')
|
||||
failed=$(echo "$counts_json" | sed -n 's/.*"failed":\([0-9]*\).*/\1/p')
|
||||
flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p')
|
||||
skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p')
|
||||
total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')
|
||||
fi
|
||||
|
||||
total_passed=$((total_passed + ${passed:-0}))
|
||||
total_failed=$((total_failed + ${failed:-0}))
|
||||
total_flaky=$((total_flaky + ${flaky:-0}))
|
||||
total_skipped=$((total_skipped + ${skipped:-0}))
|
||||
total_tests=$((total_tests + ${total:-0}))
|
||||
fi
|
||||
done
|
||||
unset IFS
|
||||
|
||||
# Determine overall status
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
status_icon="❌"
|
||||
status_text="Some tests failed"
|
||||
elif [ $total_flaky -gt 0 ]; then
|
||||
status_icon="⚠️"
|
||||
status_text="Tests passed with flaky tests"
|
||||
elif [ $total_tests -gt 0 ]; then
|
||||
status_icon="✅"
|
||||
status_text="All tests passed!"
|
||||
else
|
||||
status_icon="🕵🏻"
|
||||
status_text="No test results found"
|
||||
fi
|
||||
|
||||
# Generate completion comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright Test Results
|
||||
|
||||
✅ **Tests completed successfully!**
|
||||
$status_icon **$status_text**
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
|
||||
|
||||
# Add summary counts if we have test data
|
||||
if [ $total_tests -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
### 📈 Summary
|
||||
- **Total Tests:** $total_tests
|
||||
- **Passed:** $total_passed ✅
|
||||
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
|
||||
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
|
||||
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
|
||||
### 📊 Test Reports by Browser"
|
||||
|
||||
# Add browser results
|
||||
# Add browser results with individual counts
|
||||
i=0
|
||||
for browser in $BROWSERS; do
|
||||
IFS='|'
|
||||
set -- $all_counts
|
||||
for counts_json; do
|
||||
# Get browser name
|
||||
browser=$(echo "$BROWSERS" | cut -d' ' -f$((i + 1)))
|
||||
# Get URL at position i
|
||||
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
|
||||
|
||||
if [ "$url" != "failed" ] && [ -n "$url" ]; then
|
||||
# Parse individual browser counts
|
||||
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
b_passed=$(echo "$counts_json" | jq -r '.passed // 0')
|
||||
b_failed=$(echo "$counts_json" | jq -r '.failed // 0')
|
||||
b_flaky=$(echo "$counts_json" | jq -r '.flaky // 0')
|
||||
b_skipped=$(echo "$counts_json" | jq -r '.skipped // 0')
|
||||
b_total=$(echo "$counts_json" | jq -r '.total // 0')
|
||||
else
|
||||
b_passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p')
|
||||
b_failed=$(echo "$counts_json" | sed -n 's/.*"failed":\([0-9]*\).*/\1/p')
|
||||
b_flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p')
|
||||
b_skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p')
|
||||
b_total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')
|
||||
fi
|
||||
|
||||
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
|
||||
counts_str=" • ✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
|
||||
else
|
||||
counts_str=""
|
||||
fi
|
||||
else
|
||||
counts_str=""
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
- ✅ **${browser}**: [View Report](${url})"
|
||||
- ✅ **${browser}**: [View Report](${url})${counts_str}"
|
||||
else
|
||||
comment="$comment
|
||||
- ❌ **${browser}**: Deployment failed"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
unset IFS
|
||||
|
||||
comment="$comment
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
@import 'tailwindcss/theme' layer(theme);
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@plugin "tailwindcss-primeui";
|
||||
@plugin 'tailwindcss-primeui';
|
||||
|
||||
@config '../../../tailwind.config.ts';
|
||||
|
||||
@@ -114,6 +115,14 @@
|
||||
--color-dark-elevation-2: rgba(from white r g b / 0.03);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-node-component-surface: var(--color-charcoal-300);
|
||||
--color-node-component-surface-highlight: var(--color-slate-100);
|
||||
--color-node-component-surface-hovered: var(--color-charcoal-500);
|
||||
--color-node-component-surface-selected: var(--color-charcoal-700);
|
||||
--color-node-stroke: var(--color-stone-100);
|
||||
}
|
||||
|
||||
@custom-variant dark-theme {
|
||||
.dark-theme & {
|
||||
@slot;
|
||||
|
||||
@@ -37,7 +37,7 @@ const visible = computed(() => position.value !== 'Disabled')
|
||||
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- Prompt user that the workflow contains API nodes that needs login to run -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
|
||||
<div class="text-2xl font-medium mb-2">
|
||||
|
||||
80
src/components/ui/slider/Slider.vue
Normal file
80
src/components/ui/slider/Slider.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
|
||||
import {
|
||||
SliderRange,
|
||||
SliderRoot,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import { type HTMLAttributes, ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<
|
||||
SliderRootProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const pressed = ref(false)
|
||||
const setPressed = (val: boolean) => {
|
||||
pressed.value = val
|
||||
}
|
||||
|
||||
const emits = defineEmits<SliderRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SliderRoot
|
||||
v-slot="{ modelValue }"
|
||||
data-slot="slider"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
|
||||
'data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@slide-start="() => setPressed(true)"
|
||||
@slide-move="() => setPressed(true)"
|
||||
@slide-end="() => setPressed(false)"
|
||||
>
|
||||
<SliderTrack
|
||||
data-slot="slider-track"
|
||||
:class="
|
||||
cn(
|
||||
'bg-node-stroke relative grow overflow-hidden rounded-full',
|
||||
'cursor-pointer overflow-visible',
|
||||
`before:absolute before:-inset-2 before:block before:bg-transparent`,
|
||||
'data-[orientation=horizontal]:h-0.5 data-[orientation=horizontal]:w-full',
|
||||
'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-0.5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SliderRange
|
||||
data-slot="slider-range"
|
||||
class="bg-node-component-surface-highlight absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
/>
|
||||
</SliderTrack>
|
||||
|
||||
<SliderThumb
|
||||
v-for="(_, key) in modelValue"
|
||||
:key="key"
|
||||
data-slot="slider-thumb"
|
||||
:class="
|
||||
cn(
|
||||
'bg-node-component-surface-highlight ring-node-component-surface-selected block size-3.5 shrink-0 rounded-full shadow-sm transition-[color,box-shadow]',
|
||||
'cursor-grab',
|
||||
'before:absolute before:-inset-1 before:block before:bg-transparent before:rounded-full',
|
||||
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed }
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SliderRoot>
|
||||
</template>
|
||||
@@ -208,6 +208,9 @@ export class LitegraphLinkAdapter {
|
||||
return 'up'
|
||||
case LinkDirection.DOWN:
|
||||
return 'down'
|
||||
case LinkDirection.CENTER:
|
||||
case LinkDirection.NONE:
|
||||
return 'none'
|
||||
default:
|
||||
return 'right'
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface Point {
|
||||
y: number
|
||||
}
|
||||
|
||||
export type Direction = 'left' | 'right' | 'up' | 'down'
|
||||
export type Direction = 'left' | 'right' | 'up' | 'down' | 'none'
|
||||
export type RenderMode = 'spline' | 'straight' | 'linear'
|
||||
export type ArrowShape = 'triangle' | 'circle' | 'square'
|
||||
|
||||
@@ -255,6 +255,8 @@ export class CanvasPathRenderer {
|
||||
case 'down':
|
||||
innerA.y += l
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
switch (endDir) {
|
||||
@@ -270,6 +272,8 @@ export class CanvasPathRenderer {
|
||||
case 'down':
|
||||
innerB.y += l
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
// Draw 4-point path: start -> innerA -> innerB -> end
|
||||
@@ -306,6 +310,8 @@ export class CanvasPathRenderer {
|
||||
case 'down':
|
||||
innerA.y += l
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
switch (endDir) {
|
||||
@@ -321,6 +327,8 @@ export class CanvasPathRenderer {
|
||||
case 'down':
|
||||
innerB.y += l
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate midpoint using innerA/innerB positions (matching original)
|
||||
@@ -399,6 +407,9 @@ export class CanvasPathRenderer {
|
||||
return { x: 0, y: -distance }
|
||||
case 'down':
|
||||
return { x: 0, y: distance }
|
||||
case 'none':
|
||||
default:
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +484,8 @@ export class CanvasPathRenderer {
|
||||
case 'down':
|
||||
pa.y += dist * factor
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
switch (endDirection) {
|
||||
@@ -488,6 +501,8 @@ export class CanvasPathRenderer {
|
||||
case 'down':
|
||||
pb.y += dist * factor
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate bezier point (matching original computeConnectionPoint)
|
||||
@@ -608,6 +623,9 @@ export class CanvasPathRenderer {
|
||||
return 'down'
|
||||
case 'down':
|
||||
return 'up'
|
||||
case 'none':
|
||||
default:
|
||||
return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,11 +104,6 @@
|
||||
@slot-click="handleSlotClick"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="shouldRenderSlots && shouldShowWidgets"
|
||||
:class="separatorClasses"
|
||||
/>
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
<NodeWidgets
|
||||
v-if="shouldShowWidgets"
|
||||
@@ -118,11 +113,6 @@
|
||||
:lod-level="lodLevel"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="(shouldRenderSlots || shouldShowWidgets) && shouldShowContent"
|
||||
:class="separatorClasses"
|
||||
/>
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<NodeContent
|
||||
v-if="shouldShowContent"
|
||||
|
||||
129
src/renderer/extensions/vueNodes/components/NodeHeader.spec.ts
Normal file
129
src/renderer/extensions/vueNodes/components/NodeHeader.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
|
||||
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
type: 'KSampler',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: { collapsed: false },
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountHeader = (
|
||||
props?: Partial<InstanceType<typeof NodeHeader>['$props']>
|
||||
) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return mount(NodeHeader, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
components: { InputText }
|
||||
},
|
||||
props: {
|
||||
nodeData: makeNodeData(),
|
||||
readonly: false,
|
||||
collapsed: false,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('NodeHeader.vue', () => {
|
||||
it('emits collapse when collapse button is clicked', async () => {
|
||||
const wrapper = mountHeader()
|
||||
const btn = wrapper.get('[data-testid="node-collapse-button"]')
|
||||
await btn.trigger('click')
|
||||
expect(wrapper.emitted('collapse')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the current node title and updates when prop changes', async () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ title: 'Original' })
|
||||
})
|
||||
// Title visible via EditableText in view mode
|
||||
expect(wrapper.get('[data-testid="node-title"]').text()).toContain(
|
||||
'Original'
|
||||
)
|
||||
|
||||
// Update prop title; should sync displayTitle
|
||||
await wrapper.setProps({ nodeData: makeNodeData({ title: 'Updated' }) })
|
||||
expect(wrapper.get('[data-testid="node-title"]').text()).toContain(
|
||||
'Updated'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows renaming via double click and emits update:title on confirm', async () => {
|
||||
const wrapper = mountHeader({ nodeData: makeNodeData({ title: 'Start' }) })
|
||||
|
||||
// Enter edit mode
|
||||
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
|
||||
|
||||
// Edit and confirm (EditableText uses blur or enter to emit)
|
||||
const input = wrapper.get('[data-testid="node-title-input"]')
|
||||
await input.setValue('My Custom Sampler')
|
||||
await input.trigger('keyup.enter')
|
||||
await input.trigger('blur')
|
||||
|
||||
// NodeHeader should emit update:title with trimmed value
|
||||
const e = wrapper.emitted('update:title')
|
||||
expect(e).toBeTruthy()
|
||||
expect(e?.[0]).toEqual(['My Custom Sampler'])
|
||||
})
|
||||
|
||||
it('cancels rename on escape and keeps previous title', async () => {
|
||||
const wrapper = mountHeader({ nodeData: makeNodeData({ title: 'KeepMe' }) })
|
||||
|
||||
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
|
||||
const input = wrapper.get('[data-testid="node-title-input"]')
|
||||
await input.setValue('Should Not Save')
|
||||
await input.trigger('keyup.escape')
|
||||
|
||||
// Should not emit update:title
|
||||
expect(wrapper.emitted('update:title')).toBeFalsy()
|
||||
|
||||
// Title remains the original
|
||||
expect(wrapper.get('[data-testid="node-title"]').text()).toContain('KeepMe')
|
||||
})
|
||||
|
||||
it('honors readonly: hides collapse button and prevents editing', async () => {
|
||||
const wrapper = mountHeader({ readonly: true })
|
||||
|
||||
// Collapse button should be hidden
|
||||
const btn = wrapper.find('[data-testid="node-collapse-button"]')
|
||||
expect(btn.exists()).toBe(true)
|
||||
// v-show hides via display:none
|
||||
expect((btn.element as HTMLButtonElement).style.display).toBe('none')
|
||||
// In unit test, presence is fine; simulate double click should not create input
|
||||
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
|
||||
const input = wrapper.find('[data-testid="node-title-input"]')
|
||||
expect(input.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders correct chevron icon based on collapsed prop', async () => {
|
||||
const wrapper = mountHeader({ collapsed: false })
|
||||
const expandedIcon = wrapper.get('i')
|
||||
expect(expandedIcon.classes()).toContain('pi-chevron-down')
|
||||
|
||||
await wrapper.setProps({ collapsed: true })
|
||||
const collapsedIcon = wrapper.get('i')
|
||||
expect(collapsedIcon.classes()).toContain('pi-chevron-right')
|
||||
})
|
||||
})
|
||||
@@ -1,62 +1,63 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Slider from 'primevue/slider'
|
||||
import type { SliderProps } from 'primevue/slider'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||
|
||||
describe('WidgetInputNumberSlider Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
value: number = 5,
|
||||
options: Partial<SliderProps & { precision?: number }> = {},
|
||||
callback?: (value: number) => void
|
||||
): SimplifiedWidget<number> => ({
|
||||
function createMockWidget(
|
||||
value: number = 5,
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: number) => void
|
||||
): SimplifiedWidget<number> {
|
||||
return {
|
||||
name: 'test_slider',
|
||||
type: 'float',
|
||||
value,
|
||||
options: { min: 0, max: 100, step: 1, precision: 0, ...options },
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetInputNumberSlider, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputText, Slider }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getNumberInput = (wrapper: ReturnType<typeof mount>) => {
|
||||
const input = wrapper.find('input[type="number"]')
|
||||
if (!(input.element instanceof HTMLInputElement)) {
|
||||
throw new Error(
|
||||
'Number input element not found or is not an HTMLInputElement'
|
||||
)
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number,
|
||||
readonly = false
|
||||
) {
|
||||
return mount(WidgetInputNumberSlider, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputNumber, Slider }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
return { element: input.element }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getNumberInput(wrapper: ReturnType<typeof mount>) {
|
||||
const input = wrapper.find('input[inputmode="numeric"]')
|
||||
if (!(input.element instanceof HTMLInputElement)) {
|
||||
throw new Error(
|
||||
'Number input element not found or is not an HTMLInputElement'
|
||||
)
|
||||
}
|
||||
return input.element
|
||||
}
|
||||
|
||||
describe('WidgetInputNumberSlider Value Binding', () => {
|
||||
describe('Props and Values', () => {
|
||||
it('passes modelValue to slider component', () => {
|
||||
const widget = createMockWidget(5)
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('modelValue')).toBe(5)
|
||||
expect(slider.props('modelValue')).toEqual([5])
|
||||
})
|
||||
|
||||
it('handles different initial values', () => {
|
||||
@@ -67,10 +68,10 @@ describe('WidgetInputNumberSlider Value Binding', () => {
|
||||
const wrapper2 = mountComponent(widget2, 10)
|
||||
|
||||
const slider1 = wrapper1.findComponent({ name: 'Slider' })
|
||||
expect(slider1.props('modelValue')).toBe(5)
|
||||
expect(slider1.props('modelValue')).toEqual([5])
|
||||
|
||||
const slider2 = wrapper2.findComponent({ name: 'Slider' })
|
||||
expect(slider2.props('modelValue')).toBe(10)
|
||||
expect(slider2.props('modelValue')).toEqual([10])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,8 +86,9 @@ describe('WidgetInputNumberSlider Value Binding', () => {
|
||||
it('renders input field', () => {
|
||||
const widget = createMockWidget(5)
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
console.log(wrapper.html())
|
||||
|
||||
expect(wrapper.find('input[type="number"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[inputmode="numeric"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays initial value in input field', () => {
|
||||
@@ -94,7 +96,7 @@ describe('WidgetInputNumberSlider Value Binding', () => {
|
||||
const wrapper = mountComponent(widget, 42)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.element.value).toBe('42')
|
||||
expect(input.value).toBe('42')
|
||||
})
|
||||
|
||||
it('disables components in readonly mode', () => {
|
||||
@@ -105,7 +107,7 @@ describe('WidgetInputNumberSlider Value Binding', () => {
|
||||
expect(slider.props('disabled')).toBe(true)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.element.disabled).toBe(true)
|
||||
expect(input.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,5 +129,47 @@ describe('WidgetInputNumberSlider Value Binding', () => {
|
||||
expect(slider.props('min')).toBe(-100)
|
||||
expect(slider.props('max')).toBe(100)
|
||||
})
|
||||
|
||||
describe('Step Size', () => {
|
||||
it('should default to 1', () => {
|
||||
const widget = createMockWidget(5)
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(1)
|
||||
})
|
||||
|
||||
it('should get the step2 value if present', () => {
|
||||
const widget = createMockWidget(5, { step2: 0.01 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(0.01)
|
||||
})
|
||||
|
||||
it('should be 1 for precision 0', () => {
|
||||
const widget = createMockWidget(5, { precision: 0 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(1)
|
||||
})
|
||||
|
||||
it('should be .1 for precision 1', () => {
|
||||
const widget = createMockWidget(5, { precision: 1 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(0.1)
|
||||
})
|
||||
|
||||
it('should be .00001 for precision 5', () => {
|
||||
const widget = createMockWidget(5, { precision: 5 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(0.00001)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,31 +6,35 @@
|
||||
"
|
||||
>
|
||||
<Slider
|
||||
v-model="localValue"
|
||||
:model-value="[localValue]"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="flex-grow text-xs"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<InputText
|
||||
v-model="inputDisplayValue"
|
||||
:disabled="readonly"
|
||||
type="number"
|
||||
:step="stepValue"
|
||||
class="w-[4em] text-center text-xs px-0 !border-none !shadow-none !bg-transparent"
|
||||
@update:model-value="updateLocalValue"
|
||||
/>
|
||||
<InputNumber
|
||||
:key="timesEmptied"
|
||||
:model-value="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:step="stepValue"
|
||||
:min-fraction-digits="precision"
|
||||
:max-fraction-digits="precision"
|
||||
size="small"
|
||||
@blur="handleInputBlur"
|
||||
@keydown="handleInputKeydown"
|
||||
pt:pc-input-text:root="min-w-full bg-transparent border-none text-center"
|
||||
class="w-16"
|
||||
@update:model-value="handleNumberInputUpdate"
|
||||
/>
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -42,7 +46,7 @@ import {
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget, modelValue, readonly } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
modelValue: number
|
||||
readonly?: boolean
|
||||
@@ -53,19 +57,29 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useNumberWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
const { localValue, onChange } = useNumberWidgetValue(widget, modelValue, emit)
|
||||
|
||||
const timesEmptied = ref(0)
|
||||
|
||||
const updateLocalValue = (newValue: number[] | undefined): void => {
|
||||
onChange(newValue ?? [localValue.value])
|
||||
}
|
||||
|
||||
const handleNumberInputUpdate = (newValue: number | undefined) => {
|
||||
if (newValue) {
|
||||
updateLocalValue([newValue])
|
||||
return
|
||||
}
|
||||
timesEmptied.value += 1
|
||||
}
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
// Get the precision value for proper number formatting
|
||||
const precision = computed(() => {
|
||||
const p = props.widget.options?.precision
|
||||
const p = widget.options?.precision
|
||||
// Treat negative or non-numeric precision as undefined
|
||||
return typeof p === 'number' && p >= 0 ? p : undefined
|
||||
})
|
||||
@@ -73,96 +87,21 @@ const precision = computed(() => {
|
||||
// Calculate the step value based on precision or widget options
|
||||
const stepValue = computed(() => {
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (props.widget.options?.step2 !== undefined) {
|
||||
return String(props.widget.options.step2)
|
||||
if (widget.options?.step2 !== undefined) {
|
||||
return widget.options.step2
|
||||
}
|
||||
|
||||
// Otherwise, derive from precision
|
||||
if (precision.value !== undefined) {
|
||||
if (precision.value === 0) {
|
||||
return '1'
|
||||
}
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
||||
return (1 / Math.pow(10, precision.value)).toFixed(precision.value)
|
||||
}
|
||||
// Default to 'any' for unrestricted stepping
|
||||
return 'any'
|
||||
})
|
||||
|
||||
// Format a number according to the widget's precision
|
||||
const formatNumber = (value: number): string => {
|
||||
if (precision.value === undefined) {
|
||||
// No precision specified, return as-is
|
||||
return String(value)
|
||||
return undefined
|
||||
}
|
||||
// Use toFixed to ensure correct decimal places
|
||||
return value.toFixed(precision.value)
|
||||
}
|
||||
|
||||
// Apply precision-based rounding to a number
|
||||
const applyPrecision = (value: number): number => {
|
||||
if (precision.value === undefined) {
|
||||
// No precision specified, return as-is
|
||||
return value
|
||||
}
|
||||
if (precision.value === 0) {
|
||||
// Integer precision
|
||||
return Math.round(value)
|
||||
return 1
|
||||
}
|
||||
// Round to the specified decimal places
|
||||
const multiplier = Math.pow(10, precision.value)
|
||||
return Math.round(value * multiplier) / multiplier
|
||||
}
|
||||
|
||||
// Keep a separate display value for the input field
|
||||
const inputDisplayValue = ref(formatNumber(localValue.value))
|
||||
|
||||
// Update display value when localValue changes from external sources
|
||||
watch(localValue, (newValue) => {
|
||||
inputDisplayValue.value = formatNumber(newValue)
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
||||
return 1 / Math.pow(10, precision.value)
|
||||
})
|
||||
|
||||
const handleInputBlur = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value || '0'
|
||||
const parsed = parseFloat(value)
|
||||
|
||||
if (!isNaN(parsed)) {
|
||||
// Apply precision-based rounding
|
||||
const roundedValue = applyPrecision(parsed)
|
||||
onChange(roundedValue)
|
||||
// Update display value with proper formatting
|
||||
inputDisplayValue.value = formatNumber(roundedValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value || '0'
|
||||
const parsed = parseFloat(value)
|
||||
|
||||
if (!isNaN(parsed)) {
|
||||
// Apply precision-based rounding
|
||||
const roundedValue = applyPrecision(parsed)
|
||||
onChange(roundedValue)
|
||||
// Update display value with proper formatting
|
||||
inputDisplayValue.value = formatNumber(roundedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Remove number input spinners */
|
||||
:deep(input[type='number']::-webkit-inner-spin-button),
|
||||
:deep(input[type='number']::-webkit-outer-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(input[type='number']) {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ defineProps<{
|
||||
{{ widget.name }}
|
||||
</p>
|
||||
<div
|
||||
class="w-75"
|
||||
class="w-75 cursor-default"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const WidgetInputBaseClass = [
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export const WidgetInputBaseClass = cn([
|
||||
// Background
|
||||
'bg-zinc-500/10',
|
||||
// Outline
|
||||
@@ -11,4 +13,4 @@ export const WidgetInputBaseClass = [
|
||||
'!rounded-lg',
|
||||
// Hover
|
||||
'hover:outline-blue-500/80'
|
||||
].join(' ')
|
||||
])
|
||||
|
||||
@@ -28,7 +28,11 @@ export default defineConfig({
|
||||
server: {
|
||||
host: VITE_REMOTE_DEV ? '0.0.0.0' : undefined,
|
||||
watch: {
|
||||
ignored: ['**/coverage/**', '**/playwright-report/**']
|
||||
ignored: [
|
||||
'**/coverage/**',
|
||||
'**/playwright-report/**',
|
||||
'**/*.{test,spec}.ts'
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
'/internal': {
|
||||
|
||||
Reference in New Issue
Block a user