From a202210b9f135dc9d5b6baa8cbcc3db4c185d34a Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 14:30:36 +0200 Subject: [PATCH] kin: KIN-120-frontend_dev --- web/frontend/src/locales/en.json | 11 +- web/frontend/src/locales/ru.json | 11 +- web/frontend/src/views/ProjectView.vue | 240 +++++++++- web/frontend/src/views/SettingsView.vue | 441 +----------------- .../__tests__/SettingsView.worktrees.test.ts | 190 +++----- 5 files changed, 333 insertions(+), 560 deletions(-) diff --git a/web/frontend/src/locales/en.json b/web/frontend/src/locales/en.json index 39ab613..0b2bbdb 100644 --- a/web/frontend/src/locales/en.json +++ b/web/frontend/src/locales/en.json @@ -97,6 +97,8 @@ }, "settings": { "title": "Settings", + "navigate_hint": "Each project's settings are available in its own Settings tab.", + "open_settings": "Open Settings", "obsidian_vault_path": "Obsidian Vault Path", "test_command": "Test Command", "test_command_hint": "Test run command, executed via shell in the project directory.", @@ -206,7 +208,14 @@ "loading_phases": "Loading phases...", "revise_modal_title": "Revise phase", "reject_modal_title": "Reject phase", - "add_link_title": "Add link" + "add_link_title": "Add link", + "settings_tab": "Settings", + "settings_agent_section": "Agent Execution", + "settings_deploy_section": "Deploy", + "settings_integrations_section": "Integrations", + "settings_execution_mode": "Execution mode", + "settings_autocommit": "Autocommit", + "settings_autocommit_hint": "— git commit after pipeline" }, "escalation": { "watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}", diff --git a/web/frontend/src/locales/ru.json b/web/frontend/src/locales/ru.json index 083c363..1027a90 100644 --- a/web/frontend/src/locales/ru.json +++ b/web/frontend/src/locales/ru.json @@ -97,6 +97,8 @@ }, "settings": { "title": "Настройки", + "navigate_hint": "Настройки каждого проекта доступны в его собственной вкладке «Настройки».", + "open_settings": "Открыть настройки", "obsidian_vault_path": "Путь к Obsidian Vault", "test_command": "Команда тестирования", "test_command_hint": "Команда запуска тестов, выполняется через shell в директории проекта.", @@ -206,7 +208,14 @@ "loading_phases": "Загрузка фаз...", "revise_modal_title": "Доработать фазу", "reject_modal_title": "Отклонить фазу", - "add_link_title": "Добавить связь" + "add_link_title": "Добавить связь", + "settings_tab": "Настройки", + "settings_agent_section": "Запуск агентов", + "settings_deploy_section": "Деплой", + "settings_integrations_section": "Интеграции", + "settings_execution_mode": "Режим выполнения", + "settings_autocommit": "Автокоммит", + "settings_autocommit_hint": "— git commit после pipeline" }, "escalation": { "watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}", diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 346dbd0..4765046 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -2,7 +2,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' -import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink } from '../api' +import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink, type ObsidianSyncResult } from '../api' import Badge from '../components/Badge.vue' import Modal from '../components/Modal.vue' @@ -14,7 +14,7 @@ const { t } = useI18n() const project = ref(null) const loading = ref(true) const error = ref('') -const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links'>('tasks') +const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links' | 'settings'>('tasks') // Phases const phases = ref([]) @@ -276,6 +276,99 @@ async function toggleWorktrees() { } } +// Settings form +const settingsForm = ref({ + execution_mode: 'review', + autocommit_enabled: false, + auto_test_enabled: false, + worktrees_enabled: false, + test_command: '', + deploy_host: '', + deploy_path: '', + deploy_runtime: '', + deploy_restart_cmd: '', + deploy_command: '', + obsidian_vault_path: '', + ssh_host: '', + ssh_user: '', + ssh_key_path: '', + ssh_proxy_jump: '', +}) +const settingsSaving = ref(false) +const settingsSaveStatus = ref('') +const syncingObsidian = ref(false) +const obsidianSyncResult = ref(null) + +function loadSettingsForm() { + if (!project.value) return + settingsForm.value = { + execution_mode: project.value.execution_mode ?? 'review', + autocommit_enabled: !!(project.value.autocommit_enabled), + auto_test_enabled: !!(project.value.auto_test_enabled), + worktrees_enabled: !!(project.value.worktrees_enabled), + test_command: project.value.test_command ?? '', + deploy_host: project.value.deploy_host ?? '', + deploy_path: project.value.deploy_path ?? '', + deploy_runtime: project.value.deploy_runtime ?? '', + deploy_restart_cmd: project.value.deploy_restart_cmd ?? '', + deploy_command: project.value.deploy_command ?? '', + obsidian_vault_path: project.value.obsidian_vault_path ?? '', + ssh_host: project.value.ssh_host ?? '', + ssh_user: project.value.ssh_user ?? '', + ssh_key_path: project.value.ssh_key_path ?? '', + ssh_proxy_jump: project.value.ssh_proxy_jump ?? '', + } +} + +async function saveSettings() { + settingsSaving.value = true + settingsSaveStatus.value = '' + try { + const updated = await api.patchProject(props.id, { + execution_mode: settingsForm.value.execution_mode, + autocommit_enabled: settingsForm.value.autocommit_enabled, + auto_test_enabled: settingsForm.value.auto_test_enabled, + worktrees_enabled: settingsForm.value.worktrees_enabled, + test_command: settingsForm.value.test_command, + deploy_host: settingsForm.value.deploy_host, + deploy_path: settingsForm.value.deploy_path, + deploy_runtime: settingsForm.value.deploy_runtime, + deploy_restart_cmd: settingsForm.value.deploy_restart_cmd, + deploy_command: settingsForm.value.deploy_command, + obsidian_vault_path: settingsForm.value.obsidian_vault_path, + ssh_host: settingsForm.value.ssh_host, + ssh_user: settingsForm.value.ssh_user, + ssh_key_path: settingsForm.value.ssh_key_path, + ssh_proxy_jump: settingsForm.value.ssh_proxy_jump, + }) + if (project.value) { + project.value = { ...project.value, ...updated } + loadMode() + loadAutocommit() + loadAutoTest() + loadWorktrees() + } + settingsSaveStatus.value = t('common.saved') + } catch (e: any) { + settingsSaveStatus.value = `${t('common.error')}: ${e.message}` + } finally { + settingsSaving.value = false + } +} + +async function syncObsidianVault() { + syncingObsidian.value = true + obsidianSyncResult.value = null + try { + await api.patchProject(props.id, { obsidian_vault_path: settingsForm.value.obsidian_vault_path }) + obsidianSyncResult.value = await api.syncObsidian(props.id) + } catch (e: any) { + settingsSaveStatus.value = `${t('common.error')}: ${e.message}` + } finally { + syncingObsidian.value = false + } +} + // Audit const auditLoading = ref(false) const auditResult = ref(null) @@ -504,6 +597,7 @@ async function load() { loadAutocommit() loadAutoTest() loadWorktrees() + loadSettingsForm() } catch (e: any) { error.value = e.message } finally { @@ -534,6 +628,9 @@ onMounted(async () => { const all = await api.projects() allProjects.value = all.map(p => ({ id: p.id, name: p.name })) } catch {} + if (route.query.tab === 'settings') { + activeTab.value = 'settings' + } }) onUnmounted(() => { @@ -874,13 +971,13 @@ async function addDecision() {
-
@@ -1444,6 +1542,138 @@ async function addDecision() { + +
+ + +
+

{{ t('projectView.settings_agent_section') }}

+
+
+ + +
+ + + +
+ + +

{{ t('settings.test_command_hint') }}

+
+
+
+ + +
+

{{ t('projectView.settings_deploy_section') }}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

{{ t('projectView.settings_integrations_section') }}

+
+
+ +
+ + +
+
+ {{ obsidianSyncResult.exported_decisions }} decisions + {{ obsidianSyncResult.tasks_updated }} tasks +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + + {{ settingsSaveStatus }} + +
+
+
diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue index 42a91ff..6c677b6 100644 --- a/web/frontend/src/views/SettingsView.vue +++ b/web/frontend/src/views/SettingsView.vue @@ -1,445 +1,48 @@ diff --git a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts index abd1810..4d3fa2e 100644 --- a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts +++ b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts @@ -1,18 +1,20 @@ /** - * KIN-103: Тесты worktrees_enabled toggle в SettingsView + * KIN-120: Тесты SettingsView — навигатор по настройкам проектов + * + * После рефакторинга SettingsView стал навигатором: + * показывает список проектов и ссылки на /project/{id}?tab=settings. + * Детальные настройки каждого проекта переехали в ProjectView → вкладка Settings. * * Проверяет: - * 1. Интерфейс Project содержит worktrees_enabled: number | null - * 2. patchProject принимает worktrees_enabled?: boolean - * 3. Инициализацию из числовых значений (0, 1, null) → !!() - * 4. Toggle вызывает PATCH с true/false - * 5. Откат при ошибке PATCH - * 6. Checkbox disabled пока идёт сохранение - * 7. Статусные сообщения (Saved / Error) + * 1. Загрузка и отображение списка проектов + * 2. Имя и id проекта видны + * 3. Ссылки ведут на /project/{id}?tab=settings + * 4. execution_mode отображается если задан */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' import SettingsView from '../SettingsView.vue' vi.mock('../../api', async (importOriginal) => { @@ -21,9 +23,6 @@ vi.mock('../../api', async (importOriginal) => { ...actual, api: { projects: vi.fn(), - patchProject: vi.fn(), - syncObsidian: vi.fn(), - projectLinks: vi.fn().mockResolvedValue([]), }, } }) @@ -37,7 +36,7 @@ const BASE_PROJECT = { status: 'active', priority: 5, tech_stack: ['python'], - execution_mode: null, + execution_mode: null as string | null, autocommit_enabled: null, auto_test_enabled: null, worktrees_enabled: null as number | null, @@ -62,142 +61,65 @@ const BASE_PROJECT = { description: null, } +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/settings', component: SettingsView }, + { path: '/project/:id', component: { template: '
' } }, + ], + }) +} + beforeEach(() => { vi.clearAllMocks() - vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any) }) async function mountSettings(overrides: Partial = {}) { const project = { ...BASE_PROJECT, ...overrides } vi.mocked(api.projects).mockResolvedValue([project as any]) - const wrapper = mount(SettingsView) + const router = makeRouter() + await router.push('/settings') + const wrapper = mount(SettingsView, { global: { plugins: [router] } }) await flushPromises() return wrapper } -function findWorktreesCheckbox(wrapper: ReturnType) { - const labels = wrapper.findAll('label') - const worktreesLabel = labels.find(l => l.text().includes('Worktrees')) - const checkbox = worktreesLabel?.find('input[type="checkbox"]') - // decision #544: assertion безусловная — ложный зелёный недопустим - expect(checkbox?.exists()).toBe(true) - return checkbox! -} - -// ───────────────────────────────────────────────────────────── -// 1. Инициализация из числовых значений -// ───────────────────────────────────────────────────────────── -describe('worktreesEnabled — инициализация', () => { - it('worktrees_enabled=1 → checkbox checked', async () => { - const wrapper = await mountSettings({ worktrees_enabled: 1 }) - const checkbox = findWorktreesCheckbox(wrapper) - expect((checkbox.element as HTMLInputElement).checked).toBe(true) +describe('SettingsView — навигатор', () => { + it('показывает имя проекта', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Test Project') }) - it('worktrees_enabled=0 → checkbox unchecked', async () => { - const wrapper = await mountSettings({ worktrees_enabled: 0 }) - const checkbox = findWorktreesCheckbox(wrapper) - expect((checkbox.element as HTMLInputElement).checked).toBe(false) + it('показывает id проекта', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('proj-1') }) - it('worktrees_enabled=null → checkbox unchecked', async () => { - const wrapper = await mountSettings({ worktrees_enabled: null }) - const checkbox = findWorktreesCheckbox(wrapper) - expect((checkbox.element as HTMLInputElement).checked).toBe(false) - }) -}) - -// ───────────────────────────────────────────────────────────── -// 2. Toggle → patchProject вызывается с корректным значением -// ───────────────────────────────────────────────────────────── -describe('toggleWorktrees — вызов patchProject', () => { - it('toggle с unchecked → patchProject({ worktrees_enabled: true })', async () => { - const wrapper = await mountSettings({ worktrees_enabled: 0 }) - await findWorktreesCheckbox(wrapper).trigger('change') - await flushPromises() - - expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith( - 'proj-1', - expect.objectContaining({ worktrees_enabled: true }), - ) - }) - - it('toggle с checked → patchProject({ worktrees_enabled: false }), не undefined (decision #524)', async () => { - const wrapper = await mountSettings({ worktrees_enabled: 1 }) - await findWorktreesCheckbox(wrapper).trigger('change') - await flushPromises() - - const payload = vi.mocked(api.patchProject).mock.calls[0][1] as any - expect(payload.worktrees_enabled).toBe(false) - expect(payload.worktrees_enabled).not.toBeUndefined() - }) -}) - -// ───────────────────────────────────────────────────────────── -// 3. Откат при ошибке PATCH -// ───────────────────────────────────────────────────────────── -describe('toggleWorktrees — откат при ошибке PATCH', () => { - it('ошибка при включении: checkbox откатывается обратно к unchecked', async () => { - vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('network error')) - const wrapper = await mountSettings({ worktrees_enabled: 0 }) - await findWorktreesCheckbox(wrapper).trigger('change') - await flushPromises() - - expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(false) - }) - - it('ошибка при выключении: checkbox откатывается обратно к checked', async () => { - vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('server error')) - const wrapper = await mountSettings({ worktrees_enabled: 1 }) - await findWorktreesCheckbox(wrapper).trigger('change') - await flushPromises() - - expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(true) - }) -}) - -// ───────────────────────────────────────────────────────────── -// 4. Disabled во время сохранения -// ───────────────────────────────────────────────────────────── -describe('toggleWorktrees — disabled во время сохранения', () => { - it('checkbox disabled пока идёт PATCH, enabled после завершения', async () => { - let resolveRequest!: (v: any) => void - vi.mocked(api.patchProject).mockImplementationOnce( - () => new Promise(resolve => { resolveRequest = resolve }), - ) - - const wrapper = await mountSettings({ worktrees_enabled: 0 }) - const checkbox = findWorktreesCheckbox(wrapper) - await checkbox.trigger('change') - - // savingWorktrees = true → checkbox должен быть disabled - expect((checkbox.element as HTMLInputElement).disabled).toBe(true) - - resolveRequest(BASE_PROJECT) - await flushPromises() - - expect((checkbox.element as HTMLInputElement).disabled).toBe(false) - }) -}) - -// ───────────────────────────────────────────────────────────── -// 5. Статусные сообщения -// ───────────────────────────────────────────────────────────── -describe('toggleWorktrees — статусное сообщение', () => { - it('успех → показывает "Saved"', async () => { - const wrapper = await mountSettings({ worktrees_enabled: 0 }) - await findWorktreesCheckbox(wrapper).trigger('change') - await flushPromises() - - expect(wrapper.text()).toContain('Saved') - }) - - it('ошибка → показывает "Error: "', async () => { - vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('connection timeout')) - const wrapper = await mountSettings({ worktrees_enabled: 0 }) - await findWorktreesCheckbox(wrapper).trigger('change') - await flushPromises() - - expect(wrapper.text()).toContain('Error: connection timeout') + it('содержит ссылку на страницу настроек проекта', async () => { + const wrapper = await mountSettings() + const links = wrapper.findAll('a') + expect(links.length).toBeGreaterThan(0) + const settingsLink = links.find(l => l.attributes('href')?.includes('proj-1')) + expect(settingsLink?.exists()).toBe(true) + expect(settingsLink?.attributes('href')).toContain('settings') + }) + + it('ссылка ведёт на /project/{id} с tab=settings', async () => { + const wrapper = await mountSettings() + const link = wrapper.find('a[href*="proj-1"]') + expect(link.exists()).toBe(true) + expect(link.attributes('href')).toMatch(/\/project\/proj-1/) + expect(link.attributes('href')).toContain('settings') + }) + + it('показывает execution_mode если задан', async () => { + const wrapper = await mountSettings({ execution_mode: 'auto_complete' }) + expect(wrapper.text()).toContain('auto_complete') + }) + + it('не показывает execution_mode если null', async () => { + const wrapper = await mountSettings({ execution_mode: null }) + expect(wrapper.text()).not.toContain('auto_complete') }) })