mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-10 16:29:26 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b14f4df8a | ||
|
|
ef93b4696c | ||
|
|
24c512d144 |
48
browser_tests/assets/missing/missing_nodes_same_pack.json
Normal file
48
browser_tests/assets/missing/missing_nodes_same_pack.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "TEST_MISSING_PACK_NODE_A",
|
||||
"pos": [48, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "TEST_MISSING_PACK_NODE_B",
|
||||
"pos": [520, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -45,6 +45,8 @@ export const TestIds = {
|
||||
errorOverlayMessages: 'error-overlay-messages',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
missingNodePackExpand: 'missing-node-pack-expand',
|
||||
missingNodePackCount: 'missing-node-pack-count',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
errorDialog: 'error-dialog',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
@@ -12,27 +12,39 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
test('Should show missing node pack card with guidance', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
await expect(
|
||||
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
test('Should show unknown pack node rows by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
|
||||
await expect(
|
||||
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show subgraph missing node rows by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -43,66 +55,72 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
missingNodeCard.getByRole('button', {
|
||||
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
test('Should locate missing node from the row label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
|
||||
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /collapse/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeHidden()
|
||||
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBeforeLocate)
|
||||
})
|
||||
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
test('Should toggle grouped pack nodes from chevron and title', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
'missing/missing_nodes_same_pack'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const locateButton = missingNodeCard.getByRole('button', {
|
||||
name: /locate/i
|
||||
const packTitle = missingNodeCard.getByRole('button', {
|
||||
name: 'test-missing-node-pack'
|
||||
})
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
// TODO: Add navigation assertion once subgraph node ID deduplication
|
||||
// timing is fixed. Currently, collectMissingNodes runs before
|
||||
// configure(), so execution IDs use pre-remapped node IDs that don't
|
||||
// match the runtime graph. See PR #9510 / #8762.
|
||||
const expandButton = missingNodeCard.getByTestId(
|
||||
TestIds.dialogs.missingNodePackExpand
|
||||
)
|
||||
const firstNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_A'
|
||||
})
|
||||
const secondNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_B'
|
||||
})
|
||||
|
||||
await expect(packTitle).toBeVisible()
|
||||
await expect(
|
||||
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
|
||||
).toHaveText('2')
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
|
||||
await expandButton.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"PreviewImage": 4314,
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImageAdvanced": 1763,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
@@ -14,6 +14,7 @@
|
||||
"UpscaleModelLoader": 629,
|
||||
"UNETLoader": 606,
|
||||
"VAELoader": 604,
|
||||
"PreviewAny": 528,
|
||||
"ShowText|pysssss": 527.5526981023964,
|
||||
"ImageUpscaleWithModel": 523,
|
||||
"ControlNetApplyAdvanced": 513,
|
||||
@@ -24,10 +25,12 @@
|
||||
"VHS_LoadVideo": 440,
|
||||
"ImpactSwitch": 349,
|
||||
"Reroute": 348,
|
||||
"ResizeImageMaskNode": 337,
|
||||
"ResizeAndPadImage": 336,
|
||||
"ImageResizeKJv2": 335,
|
||||
"StringConcatenate": 326,
|
||||
"Text Concatenate": 325.7030402103206,
|
||||
"SaveVideo": 321,
|
||||
"PreviewAny": 319,
|
||||
"KSamplerAdvanced": 304,
|
||||
"SDXLPromptStyler": 297.0913411304729,
|
||||
"Note": 291,
|
||||
@@ -52,6 +55,7 @@
|
||||
"CLIPLoader": 202,
|
||||
"GeminiNode": 202,
|
||||
"KSampler (Efficient)": 194.01083622636423,
|
||||
"RemoveBackground": 187,
|
||||
"ImageRemoveBackground+": 186,
|
||||
"IPAdapterModelLoader": 184,
|
||||
"PrimitiveInt": 183,
|
||||
@@ -59,7 +63,9 @@
|
||||
"LoadVideo": 179,
|
||||
"Text Concatenate (JPS)": 175.98154639522735,
|
||||
"PrimitiveNode": 175,
|
||||
"Text Multiline": 163.04749064680308,
|
||||
"PrimitiveStringMultiline": 166,
|
||||
"Text Multiline": 165,
|
||||
"GetImageSize": 164,
|
||||
"GetImageSize+": 163,
|
||||
"ImageScaleToTotalPixels": 157,
|
||||
"String Literal": 150.11343489837878,
|
||||
@@ -68,15 +74,14 @@
|
||||
"DownloadAndLoadFlorence2Model": 144,
|
||||
"LoadImageOutput": 143,
|
||||
"IPAdapterUnifiedLoader": 141,
|
||||
"FluxGuidance": 133,
|
||||
"BatchImagesNode": 134,
|
||||
"ImageBatchMulti": 133,
|
||||
"FluxGuidance": 132,
|
||||
"ByteDanceSeedreamNode": 130,
|
||||
"CR Text Input Switch": 128.16473423438606,
|
||||
"IPAdapterAdvanced": 128,
|
||||
"If ANY execute A else B": 127.77279315110049,
|
||||
"GeminiImage2Node": 124,
|
||||
"GetImageSize": 121,
|
||||
"PrimitiveStringMultiline": 120,
|
||||
"IPAdapter": 118,
|
||||
"CreateVideo": 116,
|
||||
"ConditioningZeroOut": 115,
|
||||
@@ -102,6 +107,7 @@
|
||||
"DepthAnythingPreprocessor": 100,
|
||||
"CR Apply LoRA Stack": 96.02556540496816,
|
||||
"Image Filter Adjustments": 95.24168323839699,
|
||||
"ComfyMathExpression": 96,
|
||||
"SimpleMath+": 95,
|
||||
"GroundingDinoSAMSegment (segment anything)": 93.28197782196906,
|
||||
"Image Overlay": 93.28197782196906,
|
||||
@@ -147,7 +153,6 @@
|
||||
"Image Resize": 63.494455492264656,
|
||||
"Automatic CFG": 63.494455492264656,
|
||||
"Canny": 63,
|
||||
"StringConcatenate": 63,
|
||||
"DepthAnything_V2": 61,
|
||||
"ImageCrop+": 60,
|
||||
"ModelSamplingSD3": 59,
|
||||
@@ -199,6 +204,7 @@
|
||||
"BNK_CLIPTextEncodeAdvanced": 45.857106744413365,
|
||||
"CR SDXL Aspect Ratio": 45.46516566112778,
|
||||
"LoadAudio": 45,
|
||||
"ResolutionSelector": 45,
|
||||
"smZ CLIPTextEncode": 44.68128349455661,
|
||||
"Bus Node": 44.68128349455661,
|
||||
"PreviewTextNode": 44.68128349455661,
|
||||
@@ -389,7 +395,6 @@
|
||||
"SD_4XUpscale_Conditioning": 21,
|
||||
"UltimateSDUpscaleCustomSample": 21,
|
||||
"StyleModelLoader": 21,
|
||||
"ResizeAndPadImage": 21,
|
||||
"Text Random Prompt": 20.77287741413597,
|
||||
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
|
||||
"BrushNet": 20.77287741413597,
|
||||
|
||||
@@ -71,12 +71,11 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
|
||||
name: 'MissingPackGroupRow',
|
||||
template: `<div class="pack-row" data-testid="pack-row"
|
||||
:data-show-info-button="String(showInfoButton)"
|
||||
:data-show-node-id-badge="String(showNodeIdBadge)"
|
||||
>
|
||||
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
|
||||
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
|
||||
</div>`,
|
||||
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
|
||||
props: ['group', 'showInfoButton'],
|
||||
emits: ['locate-node', 'open-manager-info']
|
||||
}
|
||||
}))
|
||||
@@ -122,7 +121,6 @@ function makePackGroups(count = 2): MissingPackGroup[] {
|
||||
function renderCard(
|
||||
props: Partial<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}> = {}
|
||||
) {
|
||||
@@ -130,7 +128,6 @@ function renderCard(
|
||||
const result = render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
...props
|
||||
},
|
||||
@@ -169,12 +166,10 @@ describe('MissingNodeCard', () => {
|
||||
|
||||
it('passes props correctly to MissingPackGroupRow children', () => {
|
||||
renderCard({
|
||||
showInfoButton: true,
|
||||
showNodeIdBadge: true
|
||||
showInfoButton: true
|
||||
})
|
||||
const row = screen.getAllByTestId('pack-row')[0]
|
||||
expect(row.getAttribute('data-show-info-button')).toBe('true')
|
||||
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,7 +251,6 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onLocateNode
|
||||
},
|
||||
@@ -279,7 +273,6 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onOpenManagerInfo
|
||||
},
|
||||
|
||||
@@ -56,27 +56,29 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
|
||||
<div v-if="shouldShowManagerButtons" class="px-4">
|
||||
<Button
|
||||
v-if="hasInstalledPacksPendingRestart"
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
@@ -105,9 +107,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
|
||||
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
|
||||
const { showInfoButton, missingPackGroups } = defineProps<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -61,16 +61,16 @@ const i18n = createI18n({
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
loading: 'Loading'
|
||||
install: 'Install',
|
||||
loading: 'Loading',
|
||||
search: 'Search'
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate node on canvas',
|
||||
missingNodePacks: {
|
||||
unknownPack: 'Unknown pack',
|
||||
installNodePack: 'Install node pack',
|
||||
installing: 'Installing...',
|
||||
installed: 'Installed',
|
||||
searchInManager: 'Search in Node Manager',
|
||||
viewInManager: 'View in Manager',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand'
|
||||
@@ -100,7 +100,6 @@ function renderRow(
|
||||
props: Partial<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
@@ -110,7 +109,6 @@ function renderRow(
|
||||
props: {
|
||||
group: makeGroup(),
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
onLocateNode,
|
||||
onOpenManagerInfo,
|
||||
...props
|
||||
@@ -118,7 +116,6 @@ function renderRow(
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
DotSpinner: {
|
||||
template: '<span role="status" aria-label="loading" />'
|
||||
}
|
||||
@@ -156,9 +153,22 @@ describe('MissingPackGroupRow', () => {
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render header locate while pack metadata is resolving', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
isResolving: true,
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node count', () => {
|
||||
renderRow()
|
||||
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders count of 5 for 5 nodeTypes', () => {
|
||||
@@ -171,38 +181,29 @@ describe('MissingPackGroupRow', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Expand / Collapse', () => {
|
||||
it('starts collapsed', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands when chevron is clicked', async () => {
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when chevron is clicked again', async () => {
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type List', () => {
|
||||
async function expand(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
}
|
||||
it('hides multiple nodeTypes behind the expand control by default', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all nodeTypes when expanded', async () => {
|
||||
it('shows unknown pack nodeTypes by default', () => {
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Collapse' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all nodeTypes after expanding', async () => {
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
@@ -212,40 +213,87 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
expect(screen.getByText('NodeA')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeB')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows nodeId badge when showNodeIdBadge is true', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
it('hides multiple nodeTypes again after collapsing', async () => {
|
||||
const { user } = renderRow()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides nodeId badge when showNodeIdBadge is false', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: false })
|
||||
await expand(user)
|
||||
expect(screen.queryByText('#10')).not.toBeInTheDocument()
|
||||
it('hides a single nodeType without an expand control', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Expand' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits locateNode when Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
|
||||
const { user, onLocateNode } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'my-pack' }))
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('100')
|
||||
})
|
||||
|
||||
it('moves locate to the header when there is one nodeType', async () => {
|
||||
const { user, onLocateNode } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Locate node on canvas' })
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('100')
|
||||
})
|
||||
|
||||
it('emits locateNode when expanded child Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
it('emits locateNode when node label is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
await user.click(screen.getByRole('button', { name: 'MissingA' }))
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -253,7 +301,6 @@ describe('MissingPackGroupRow', () => {
|
||||
|
||||
it('handles mixed nodeTypes with and without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
showNodeIdBadge: true,
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
{ type: 'WithId', nodeId: '100', isReplaceable: false },
|
||||
@@ -261,7 +308,7 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('WithId')).toBeInTheDocument()
|
||||
expect(screen.getByText('WithoutId')).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -274,21 +321,25 @@ describe('MissingPackGroupRow', () => {
|
||||
it('hides install UI when shouldShowManagerButtons is false', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
renderRow()
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides install UI when packId is null', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
|
||||
it('shows Search when packId exists but pack not in registry', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = []
|
||||
renderRow()
|
||||
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Installed" state when pack is installed', () => {
|
||||
@@ -312,7 +363,9 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
renderRow()
|
||||
expect(screen.getByText('Install node pack')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Install' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls installAllPacks when Install button is clicked', async () => {
|
||||
@@ -320,9 +373,7 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const { user } = renderRow()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Install node pack/ })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Install' }))
|
||||
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -369,7 +420,7 @@ describe('MissingPackGroupRow', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty nodeTypes array', () => {
|
||||
renderRow({ group: makeGroup({ nodeTypes: [] }) })
|
||||
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,187 +1,221 @@
|
||||
<template>
|
||||
<div class="mb-2 flex w-full flex-col">
|
||||
<!-- Pack header row: pack name + info + chevron -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<!-- Warning icon for unknown packs -->
|
||||
<i
|
||||
v-if="group.packId === null && !group.isResolving"
|
||||
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:class="
|
||||
group.packId === null && !group.isResolving
|
||||
? 'text-warning-background'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<Button
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="missing-node-pack-expand"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
{ 'rotate-180': expanded }
|
||||
)
|
||||
"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-labels: individual node instances, each with their own Locate button -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
<i
|
||||
v-if="isUnknownPack"
|
||||
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<button
|
||||
v-if="hasMultipleNodeTypes && !group.isResolving"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
:aria-expanded="expanded"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
#{{ nodeType.nodeId }}
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="primaryLocatableNodeType"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ packDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
|
||||
<div
|
||||
v-if="
|
||||
shouldShowManagerButtons &&
|
||||
group.packId !== null &&
|
||||
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
|
||||
"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
:disabled="
|
||||
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
|
||||
"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
|
||||
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: comfyManagerStore.isPackInstalled(group.packId)
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('rightSidePanel.missingNodePacks.installNodePack')
|
||||
}}
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Registry still loading: packId known but result not yet available -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
</span>
|
||||
<div v-if="showInstallAction" class="ml-auto shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: isPackInstalled
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('g.install')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search in Manager: fetch done but pack not found in registry -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="text-foreground min-w-0 truncate">
|
||||
{{ t('g.search') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
|
||||
</span>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<ul
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
'text-muted-foreground hover:text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
@@ -193,10 +227,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
|
||||
const { group, showInfoButton } = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -205,6 +238,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
@@ -219,17 +256,73 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||
nodePack.value ? [nodePack.value] : []
|
||||
)
|
||||
|
||||
const isUnknownPack = computed(
|
||||
() => group.packId === null && !group.isResolving
|
||||
)
|
||||
|
||||
const packDisplayName = computed(() => {
|
||||
if (group.packId === null) {
|
||||
return t('rightSidePanel.missingNodePacks.unknownPack')
|
||||
}
|
||||
return nodePack.value?.name ?? group.packId
|
||||
})
|
||||
|
||||
const isPackInstalled = computed(
|
||||
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
|
||||
)
|
||||
|
||||
const showInstallAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
(nodePack.value !== null || isPackInstalled.value)
|
||||
)
|
||||
|
||||
const showLoadingAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
!showInstallAction.value &&
|
||||
isLoading.value
|
||||
)
|
||||
|
||||
const showSearchAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
!showInstallAction.value &&
|
||||
!showLoadingAction.value
|
||||
)
|
||||
|
||||
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
|
||||
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
|
||||
const expanded = computed(
|
||||
() =>
|
||||
expandedOverride.value ??
|
||||
(isUnknownPack.value && hasMultipleNodeTypes.value)
|
||||
)
|
||||
const showNodeTypeList = computed(
|
||||
() =>
|
||||
(isUnknownPack.value && group.nodeTypes.length === 1) ||
|
||||
(hasMultipleNodeTypes.value && expanded.value)
|
||||
)
|
||||
const primaryLocatableNodeType = computed(() => {
|
||||
if (group.isResolving) return null
|
||||
if (isUnknownPack.value) return null
|
||||
if (group.nodeTypes.length !== 1) return null
|
||||
const [nodeType] = group.nodeTypes
|
||||
return isLocatableNodeType(nodeType) ? nodeType : null
|
||||
})
|
||||
|
||||
function handlePackInstallClick() {
|
||||
if (!group.packId) return
|
||||
if (!comfyManagerStore.isPackInstalled(group.packId)) {
|
||||
if (!isPackInstalled.value) {
|
||||
void installAllPacks()
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expanded.value = !expanded.value
|
||||
expandedOverride.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
@@ -241,10 +334,14 @@ function getLabel(nodeType: MissingNodeType): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function isLocatableNodeType(
|
||||
nodeType: MissingNodeType
|
||||
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
|
||||
return typeof nodeType !== 'string' && nodeType.nodeId != null
|
||||
}
|
||||
|
||||
function handleLocateNode(nodeType: MissingNodeType) {
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
if (!isLocatableNodeType(nodeType)) return
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
|
||||
@@ -2423,6 +2423,7 @@
|
||||
"member": "member",
|
||||
"usdPerMonth": "USD / mo",
|
||||
"usdPerMonthPerMember": "USD / mo / member",
|
||||
"creditSliderSave": "Save {percent}% ({amount})",
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
@@ -3629,12 +3630,10 @@
|
||||
"unsupportedTitle": "Unsupported Node Packs",
|
||||
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
|
||||
"installAll": "Install All",
|
||||
"installNodePack": "Install node pack",
|
||||
"unknownPack": "Unknown pack",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"applyChanges": "Apply Changes",
|
||||
"searchInManager": "Search in Node Manager",
|
||||
"viewInManager": "View in Manager",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const meta: Meta<typeof CreditSlider> = {
|
||||
title: 'Platform/Subscription/CreditSlider',
|
||||
component: CreditSlider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
// Previews at the real layout width: the Figma "Team Plan" card column is
|
||||
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
|
||||
// width the slider actually renders into inside PricingTableWorkspace.
|
||||
template: '<div class="w-[512px] px-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
|
||||
// In production this comes from the API; here it shows the stops being driven
|
||||
// entirely through props rather than the hardcoded default constant.
|
||||
const apiTeamCreditStops = {
|
||||
default_stop_index: 2,
|
||||
stops: [
|
||||
{
|
||||
id: 'team_200',
|
||||
credits: 42_200,
|
||||
yearly: { price_cents: 20_000, discount_percent: 0 }
|
||||
},
|
||||
{
|
||||
id: 'team_400',
|
||||
credits: 84_400,
|
||||
yearly: { price_cents: 38_000, discount_percent: 5 }
|
||||
},
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
yearly: { price_cents: 63_000, discount_percent: 10 }
|
||||
},
|
||||
{
|
||||
id: 'team_1400',
|
||||
credits: 295_400,
|
||||
yearly: { price_cents: 119_000, discount_percent: 15 }
|
||||
},
|
||||
{
|
||||
id: 'team_2500',
|
||||
credits: 527_500,
|
||||
yearly: { price_cents: 200_000, discount_percent: 20 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
|
||||
// The pre-discount list price is recovered as discounted / (1 - discount).
|
||||
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
|
||||
credits: s.credits,
|
||||
discountPercentYearly: s.yearly.discount_percent,
|
||||
usd: Math.round(
|
||||
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
|
||||
)
|
||||
}))
|
||||
|
||||
export const BackendDrivenStops: Story = {
|
||||
name: 'Backend-driven stops (props)',
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const defaultStopIndex = apiTeamCreditStops.default_stop_index
|
||||
const value = ref(mappedStops[defaultStopIndex].usd)
|
||||
return { args, value, mappedStops, defaultStopIndex }
|
||||
},
|
||||
template:
|
||||
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
189
src/platform/cloud/subscription/components/CreditSlider.test.ts
Normal file
189
src/platform/cloud/subscription/components/CreditSlider.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
usdPerMonth: 'USD / mo',
|
||||
billedYearly: '{total} Billed yearly',
|
||||
creditSliderSave: 'Save {percent}% ({amount})'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderSlider(props: Record<string, unknown> = {}) {
|
||||
return render(CreditSlider, { props, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('CreditSlider', () => {
|
||||
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
|
||||
renderSlider()
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '4')
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
|
||||
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(1400)
|
||||
})
|
||||
|
||||
it('snaps to the previous fixed stop on ArrowLeft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(400)
|
||||
})
|
||||
|
||||
it('emits change with the full {index, usd, credits} payload', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({ modelValue: 700, onChange })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
index: 3,
|
||||
usd: 1400,
|
||||
credits: 295_400
|
||||
})
|
||||
})
|
||||
|
||||
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({
|
||||
modelValue: 700,
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': onUpdate,
|
||||
onChange
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
|
||||
renderSlider() // default $700 stop → 10% yearly discount
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 10% ($70)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'$7,560'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the discount UI at the 0% stop ($200)', async () => {
|
||||
renderSlider({ modelValue: 200 })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
|
||||
expect(
|
||||
screen.queryByTestId('credit-slider-original-price')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all five fixed credit stop labels', async () => {
|
||||
renderSlider({ modelValue: 700 })
|
||||
await flush()
|
||||
|
||||
const stops = within(screen.getByTestId('credit-slider-stops'))
|
||||
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
|
||||
expect(stops.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders nothing when stops is empty (defensive for BE-sourced data)', async () => {
|
||||
renderSlider({ stops: [] })
|
||||
await flush()
|
||||
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('credit-slider-price')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
|
||||
const stops = [
|
||||
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
|
||||
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
|
||||
]
|
||||
// No modelValue → the model default ($700) matches no stop, so selectedIndex
|
||||
// falls back to defaultStopIndex (here index 1 → $100).
|
||||
renderSlider({ stops, defaultStopIndex: 1 })
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
|
||||
|
||||
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$100')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 25% ($25)'
|
||||
)
|
||||
|
||||
// Only the prop's labels render — none of the DES-197 defaults.
|
||||
const labels = within(screen.getByTestId('credit-slider-stops'))
|
||||
expect(labels.getByText('10.6K')).toBeInTheDocument()
|
||||
expect(labels.getByText('21.1K')).toBeInTheDocument()
|
||||
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
|
||||
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
|
||||
expect(stop.credits).toBe(usdToCredits(stop.usd))
|
||||
}
|
||||
})
|
||||
})
|
||||
228
src/platform/cloud/subscription/components/CreditSlider.vue
Normal file
228
src/platform/cloud/subscription/components/CreditSlider.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TransitionPresets,
|
||||
usePreferredReducedMotion,
|
||||
useTransition
|
||||
} from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
class: rootClass,
|
||||
stops = TEAM_PLAN_CREDIT_STOPS,
|
||||
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
/**
|
||||
* The fixed credit stops the slider snaps to; when empty, the component
|
||||
* renders nothing. Defaults to the hardcoded DES-197 set; pass the
|
||||
* backend-sourced stops once the contract lands — map
|
||||
* `GET /api/billing/plans → team_credit_stops.stops` to `CreditStop[]`
|
||||
* (credits, the pre-discount `usd`, and `discountPercentYearly`).
|
||||
*/
|
||||
stops?: readonly CreditStop[]
|
||||
/**
|
||||
* Stop selected when the bound value matches none (e.g. first render).
|
||||
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
|
||||
*/
|
||||
defaultStopIndex?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Fired when the selected stop changes, with the full derived payload. */
|
||||
change: [stop: { index: number; usd: number; credits: number }]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* v-model carries the selected USD value (one of the `stops`). The literal
|
||||
* default keeps `defineModel` statically analyzable; when custom `stops` are
|
||||
* passed without a matching v-model, `selectedIndex` falls back to
|
||||
* `defaultStopIndex`, so the displayed stop is still correct.
|
||||
*/
|
||||
const usd = defineModel<number>({
|
||||
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
|
||||
})
|
||||
|
||||
const selectedIndex = computed(() => {
|
||||
const i = stops.findIndex((stop) => stop.usd === usd.value)
|
||||
if (i !== -1) return i
|
||||
// Fall back to the default stop, clamped into range: a backend-driven `stops`
|
||||
// array can be shorter than expected (or `defaultStopIndex` out of bounds).
|
||||
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
|
||||
})
|
||||
|
||||
// Zero-stop fallback: `useTransition` reads its source eagerly at setup, so an
|
||||
// empty `stops` must not crash even though the template then renders nothing.
|
||||
const EMPTY_STOP: CreditStop = { usd: 0, credits: 0, discountPercentYearly: 0 }
|
||||
const current = computed(() => stops.at(selectedIndex.value) ?? EMPTY_STOP)
|
||||
|
||||
// Yearly commitment (per DES-197): the discount applies to the monthly figure.
|
||||
// The card shows the discounted monthly price, the struck pre-discount price,
|
||||
// the saving, and the annual total.
|
||||
const discountedMonthly = computed(() =>
|
||||
Math.round(
|
||||
current.value.usd * (1 - current.value.discountPercentYearly / 100)
|
||||
)
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => current.value.discountPercentYearly > 0)
|
||||
|
||||
/**
|
||||
* Smoothly count the price figures up/down as the slider moves between stops
|
||||
* instead of snapping. Honors the user's reduced-motion preference. The save
|
||||
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
|
||||
* tier, so animating the bracketed amount alone would read inconsistently.
|
||||
*/
|
||||
const prefersReducedMotion = usePreferredReducedMotion()
|
||||
const priceTween = {
|
||||
duration: 350,
|
||||
easing: TransitionPresets.easeOutCubic,
|
||||
disabled: computed(() => prefersReducedMotion.value === 'reduce')
|
||||
}
|
||||
// One vector tween keeps both figures in phase. Deriving the monthly from the
|
||||
// animated original instead would jump at the start of each move: the discount
|
||||
// tier snaps per stop while the base price is still mid-tween.
|
||||
const animatedPrices = useTransition(
|
||||
() => [discountedMonthly.value, current.value.usd],
|
||||
priceTween
|
||||
)
|
||||
|
||||
const displayMonthly = computed(() => Math.round(animatedPrices.value[0]))
|
||||
const displayOriginal = computed(() => Math.round(animatedPrices.value[1]))
|
||||
// Derive the yearly total from the displayed monthly so it always reads as
|
||||
// exactly 12× the price shown — even mid-count — rather than drifting as a
|
||||
// second, independently-phased tween would.
|
||||
const displayBilledYearly = computed(() => displayMonthly.value * 12)
|
||||
|
||||
/**
|
||||
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
|
||||
* model. Driving the slider in index space with `step = 1` guarantees the
|
||||
* thumb can only land on the fixed stops — never a value in between.
|
||||
*/
|
||||
const sliderModel = computed<number[]>({
|
||||
get: () => [selectedIndex.value],
|
||||
set: ([index]) => {
|
||||
const stop = stops[index]
|
||||
if (!stop) return
|
||||
usd.value = stop.usd
|
||||
emit('change', { index, usd: stop.usd, credits: stop.credits })
|
||||
}
|
||||
})
|
||||
|
||||
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
|
||||
|
||||
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
|
||||
const formatCreditsCompact = (value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1
|
||||
}).format(value)
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="stops.length > 0"
|
||||
:class="cn('flex w-full flex-col gap-3', rootClass)"
|
||||
>
|
||||
<!-- Price: discounted monthly + struck pre-discount + save badge -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem] leading-none font-semibold text-base-foreground"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
class="text-base text-muted-foreground line-through"
|
||||
data-testid="credit-slider-original-price"
|
||||
>
|
||||
{{ formatUsd(displayOriginal) }}
|
||||
</span>
|
||||
<span class="text-base text-muted-foreground">
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- Save badge: outlined primary pill, pushed to the right (DES-197) -->
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
data-testid="credit-slider-save"
|
||||
class="ms-auto shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background"
|
||||
>
|
||||
{{
|
||||
t('subscription.creditSliderSave', {
|
||||
percent: current.discountPercentYearly,
|
||||
amount: formatUsd(saveAmount)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="m-0 text-sm text-muted-foreground"
|
||||
data-testid="credit-slider-billed-yearly"
|
||||
>
|
||||
{{
|
||||
t('subscription.billedYearly', {
|
||||
total: formatUsd(displayBilledYearly)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
|
||||
<Slider
|
||||
v-model="sliderModel"
|
||||
:min="0"
|
||||
:max="lastIndex"
|
||||
:step="1"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
|
||||
<!-- Credit stop labels; the selected stop is emphasized -->
|
||||
<ol
|
||||
data-testid="credit-slider-stops"
|
||||
class="m-0 flex list-none justify-between p-0"
|
||||
>
|
||||
<li
|
||||
v-for="(stop, i) in stops"
|
||||
:key="stop.usd"
|
||||
:data-selected="i === selectedIndex ? '' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
i === selectedIndex
|
||||
? 'font-semibold text-base-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatCreditsCompact(stop.credits) }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface CreditStop {
|
||||
/** Monthly subscription price in USD (pre-discount). */
|
||||
usd: number
|
||||
/** Monthly credit grant at this stop. */
|
||||
credits: number
|
||||
/**
|
||||
* Yearly-commitment discount applied to `usd`, as a whole-number percent.
|
||||
* Threshold-based per the pricing decision (Slack — Alex Tov, 2026-05-08):
|
||||
* yearly tiers are 0 / 5 / 10 / 15 / 20% with nothing in between (monthly is
|
||||
* halved, but still being iterated). Only the $700 → 10% tier is
|
||||
* design-confirmed (DES-197 shows "Save 10% ($70)"); the rest follow the
|
||||
* agreed 0/5/10/15/20 sequence and should be re-confirmed with design/BE.
|
||||
*/
|
||||
discountPercentYearly: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-plan credit-subscription slider stops.
|
||||
*
|
||||
* Hardcoded per Figma DES-197 (Updates to PricingTable dialog): the team-plan
|
||||
* credit slider snaps to exactly these 5 fixed breakpoints — the user cannot
|
||||
* select a value in between. The `credits` figures equal `usdToCredits(usd)` at
|
||||
* the current rate (`CREDITS_PER_USD = 211`); a unit test guards against rate
|
||||
* drift silently changing the designed values.
|
||||
*
|
||||
* TODO(FE-934): once the backend slider contract lands, these stops (and their
|
||||
* discount tiers) will come from `GET /api/billing/plans` instead of being
|
||||
* hardcoded here.
|
||||
*/
|
||||
export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
|
||||
{ usd: 200, credits: 42_200, discountPercentYearly: 0 },
|
||||
{ usd: 400, credits: 84_400, discountPercentYearly: 5 },
|
||||
{ usd: 700, credits: 147_700, discountPercentYearly: 10 },
|
||||
{ usd: 1_400, credits: 295_400, discountPercentYearly: 15 },
|
||||
{ usd: 2_500, credits: 527_500, discountPercentYearly: 20 }
|
||||
] as const
|
||||
|
||||
/** Default stop per DES-197: index 2 = $700 / 147,700 credits. */
|
||||
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
|
||||
Reference in New Issue
Block a user