mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 08:14:06 +00:00
feat: scroll to specific setting when opening settings dialog (#8761)
## Summary - Adds `settingId` parameter to `showSettingsDialog` that auto-navigates to the correct category tab, scrolls to the setting, and briefly highlights it with a CSS pulse animation - Adds `data-setting-id` attributes to setting items for stable DOM targeting - Adds "Don't show this again" checkbox with "Re-enable in Settings" deep-link to the missing nodes dialog - Adds "Re-enable in Settings" deep-link to missing models and blueprint overwrite "Don't show this again" checkboxes - Fixes #3437 ## Test plan - [x] `pnpm typecheck` passes - [x] `pnpm lint` passes - [x] Unit tests pass (59/59 including 5 new tests for `useSettingUI`) https://github.com/user-attachments/assets/a9e80aea-7b69-4686-b030-55a2e0570ff0 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8761-feat-scroll-to-specific-setting-when-opening-settings-dialog-3036d73d365081d18d9afe9f9ed41ebc) by [Unito](https://www.unito.io)
This commit is contained in:
committed by
GitHub
parent
19a724710c
commit
e411a104f4
@@ -112,7 +112,7 @@ import Listbox from 'primevue/listbox'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
||||
@@ -129,7 +129,7 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
|
||||
const { defaultPanel } = defineProps<{
|
||||
const { defaultPanel, scrollToSettingId } = defineProps<{
|
||||
defaultPanel?:
|
||||
| 'about'
|
||||
| 'keybinding'
|
||||
@@ -140,6 +140,7 @@ const { defaultPanel } = defineProps<{
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
| 'secrets'
|
||||
scrollToSettingId?: string
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -153,7 +154,7 @@ const {
|
||||
settingCategories,
|
||||
groupedMenuTreeNodes,
|
||||
panels
|
||||
} = useSettingUI(defaultPanel)
|
||||
} = useSettingUI(defaultPanel, scrollToSettingId)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
@@ -202,6 +203,31 @@ const tabValue = computed<string>(() =>
|
||||
inSearch.value ? 'Search Results' : (activeCategory.value?.label ?? '')
|
||||
)
|
||||
|
||||
// Scroll to and highlight the target setting once the correct tab renders.
|
||||
if (scrollToSettingId) {
|
||||
const stopScrollWatch = watch(
|
||||
tabValue,
|
||||
() => {
|
||||
void nextTick(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-setting-id="${CSS.escape(scrollToSettingId)}"]`
|
||||
)
|
||||
if (!el) return
|
||||
stopScrollWatch()
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('setting-highlight')
|
||||
el.addEventListener(
|
||||
'animationend',
|
||||
() => el.classList.remove('setting-highlight'),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onBeforeUnmount(stopScrollWatch)
|
||||
}
|
||||
|
||||
// Don't allow null category to be set outside of search.
|
||||
// In search mode, the active category can be null to show all search results.
|
||||
watch(activeCategory, (_, oldValue) => {
|
||||
@@ -218,6 +244,26 @@ watch(activeCategory, (_, oldValue) => {
|
||||
.settings-tab-panels {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.setting-highlight {
|
||||
animation: setting-highlight-pulse 1.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes setting-highlight-pulse {
|
||||
0%,
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
30% {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--p-primary-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<div
|
||||
v-for="setting in group.settings.filter((s) => !s.deprecated)"
|
||||
:key="setting.id"
|
||||
:data-setting-id="setting.id"
|
||||
class="setting-item mb-4"
|
||||
>
|
||||
<SettingItem :setting="setting" />
|
||||
|
||||
140
src/platform/settings/composables/useSettingUI.test.ts
Normal file
140
src/platform/settings/composables/useSettingUI.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
getSettingInfo,
|
||||
useSettingStore
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
|
||||
import { useSettingUI } from './useSettingUI'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({ isActiveSubscription: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(),
|
||||
getSettingInfo: vi.fn()
|
||||
}))
|
||||
|
||||
interface MockSettingParams {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
defaultValue: unknown
|
||||
category?: string[]
|
||||
}
|
||||
|
||||
describe('useSettingUI', () => {
|
||||
const mockSettings: Record<string, MockSettingParams> = {
|
||||
'Comfy.Locale': {
|
||||
id: 'Comfy.Locale',
|
||||
name: 'Locale',
|
||||
type: 'combo',
|
||||
defaultValue: 'en'
|
||||
},
|
||||
'LiteGraph.Zoom': {
|
||||
id: 'LiteGraph.Zoom',
|
||||
name: 'Zoom',
|
||||
type: 'slider',
|
||||
defaultValue: 1
|
||||
},
|
||||
'Appearance.Theme': {
|
||||
id: 'Appearance.Theme',
|
||||
name: 'Theme',
|
||||
type: 'combo',
|
||||
defaultValue: 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById: mockSettings
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
vi.mocked(getSettingInfo).mockImplementation((setting) => {
|
||||
const parts = setting.category || setting.id.split('.')
|
||||
return {
|
||||
category: parts[0] ?? 'Other',
|
||||
subCategory: parts[1] ?? 'Other'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function findCategory(
|
||||
categories: SettingTreeNode[],
|
||||
label: string
|
||||
): SettingTreeNode | undefined {
|
||||
return categories.find((c) => c.label === label)
|
||||
}
|
||||
|
||||
it('defaults to first category when no params are given', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI()
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('resolves category from scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
undefined,
|
||||
'Comfy.Locale'
|
||||
)
|
||||
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
|
||||
expect(comfyCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(comfyCategory)
|
||||
})
|
||||
|
||||
it('resolves different category from scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
undefined,
|
||||
'Appearance.Theme'
|
||||
)
|
||||
const appearanceCategory = findCategory(
|
||||
settingCategories.value,
|
||||
'Appearance'
|
||||
)
|
||||
expect(appearanceCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(appearanceCategory)
|
||||
})
|
||||
|
||||
it('falls back to first category for unknown scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
undefined,
|
||||
'NonExistent.Setting'
|
||||
)
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('gives defaultPanel precedence over scrollToSettingId', () => {
|
||||
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
|
||||
expect(defaultCategory.value.key).toBe('about')
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,12 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
import {
|
||||
getSettingInfo,
|
||||
useSettingStore
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
|
||||
@@ -30,7 +32,8 @@ export function useSettingUI(
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
| 'secrets'
|
||||
| 'secrets',
|
||||
scrollToSettingId?: string
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
@@ -238,12 +241,23 @@ export function useSettingUI(
|
||||
* The default category to show when the dialog is opened.
|
||||
*/
|
||||
const defaultCategory = computed<SettingTreeNode>(() => {
|
||||
if (!defaultPanel) return settingCategories.value[0]
|
||||
// Search through all groups in groupedMenuTreeNodes
|
||||
for (const group of groupedMenuTreeNodes.value) {
|
||||
const found = group.children?.find((node) => node.key === defaultPanel)
|
||||
if (found) return found
|
||||
if (defaultPanel) {
|
||||
for (const group of groupedMenuTreeNodes.value) {
|
||||
const found = group.children?.find((node) => node.key === defaultPanel)
|
||||
if (found) return found
|
||||
}
|
||||
return settingCategories.value[0]
|
||||
}
|
||||
|
||||
if (scrollToSettingId) {
|
||||
const setting = settingStore.settingsById[scrollToSettingId]
|
||||
if (setting) {
|
||||
const { category } = getSettingInfo(setting)
|
||||
const found = settingCategories.value.find((c) => c.label === category)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
|
||||
return settingCategories.value[0]
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user