chore: commit remaining working changes

Backend:
- agent: confirmation, persistence, app, langgraph_setup updates
- routes: agent_superset_explore, environments, git helpers/operations
- services: git sync refactoring
- tests: git_status_route expanded

Frontend:
- Navbar: minor cleanup
- Profile: i18n (en/ru), page enhancements, integration tests
- New: _llm_params.py
This commit is contained in:
2026-07-05 09:24:45 +03:00
parent b773a06d52
commit 45e781fb74
17 changed files with 1030 additions and 203 deletions

View File

@@ -8,8 +8,6 @@
@LAYER UI
@RELATION BINDS_TO -> [EXT:frontend:authStore]
@RELATION BINDS_TO -> [EXT:frontend:i18n]
@RELATION DEPENDS_ON -> [LanguageSwitcher]
@UX_STATE: Idle -> Navigation links and settings menus are visible.
@UX_STATE: Authenticated -> User identity and logout action are rendered.
-->
@@ -17,7 +15,6 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import { t } from '$lib/i18n/index.svelte.js';
import { LanguageSwitcher } from '$lib/ui';
import { auth } from '$lib/auth/store.svelte.js';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/routes';
@@ -77,8 +74,6 @@
</div>
</div>
{/if}
<LanguageSwitcher />
{#if _authState.isAuthenticated}
<div class="flex items-center space-x-2 border-l pl-4 ml-4">
<span class="text-sm text-text-muted">{_authState.user?.username}</span>

View File

@@ -4,6 +4,9 @@
"dashboard_preferences": "Dashboard Preferences",
"security_access": "Security & Access",
"read_only": "Read-only",
"preferences": "Preferences",
"saved": "Preferences saved",
"language": "Language",
"security_read_only_note": "This section is read-only. Role changes are managed in Admin → Users.",
"current_role": "Current Role",
"role_source": "Role Source",
@@ -43,6 +46,12 @@
"table_density_compact": "Compact",
"table_density_comfortable": "Comfortable",
"auto_open_task_drawer": "Automatically open task drawer for long-running tasks",
"notifications": "Notifications",
"notification_email": "Notification email",
"notification_email_placeholder": "Enter notification email",
"telegram_id": "Telegram ID",
"telegram_id_placeholder": "Enter Telegram ID",
"notify_on_fail": "Notify on validation failure",
"filter_badge_active": "Profile filters active",
"filter_badge_override": "Showing all dashboards temporarily",
"filter_empty_state": "No dashboards found for active profile filters. Try adjusting your filter settings.",

View File

@@ -4,6 +4,9 @@
"dashboard_preferences": "Настройки дашбордов",
"security_access": "Безопасность и доступ",
"read_only": "Только чтение",
"preferences": "Настройки",
"saved": "Настройки сохранены",
"language": "Язык",
"security_read_only_note": "Этот раздел только для чтения. Изменение ролей выполняется в Админ → Users.",
"current_role": "Текущая роль",
"role_source": "Источник роли",
@@ -43,6 +46,12 @@
"table_density_compact": "Компактная",
"table_density_comfortable": "Комфортная",
"auto_open_task_drawer": "Автоматически открывать Task Drawer для долгих задач",
"notifications": "Уведомления",
"notification_email": "Email для уведомлений",
"notification_email_placeholder": "Введите email для уведомлений",
"telegram_id": "Telegram ID",
"telegram_id_placeholder": "Введите Telegram ID",
"notify_on_fail": "Уведомлять при ошибках валидации",
"filter_badge_active": "Активны фильтры профиля",
"filter_badge_override": "Временно показаны все дашборды",
"filter_empty_state": "По активным фильтрам профиля дашборды не найдены. Попробуйте изменить настройки фильтра.",

View File

@@ -1,29 +1,171 @@
<!-- #region ProfilePage [C:3] [TYPE Page] [SEMANTICS sveltekit, profile, preferences, user, settings] -->
<!-- #region ProfilePage [C:4] [TYPE Page] [SEMANTICS sveltekit, profile, preferences, user, settings] -->
<!-- @ingroup Routes -->
<!-- @BRIEF User profile page for viewing and editing personal preferences and settings. -->
<!-- @BRIEF User profile page for viewing and editing personal preferences, integrations, notifications, and read-only access state. -->
<!-- @LAYER Page -->
<!-- @RELATION BINDS_TO -> [EXT:frontend:api_module] -->
<!-- @RELATION BINDS_TO -> [EXT:frontend:taskDrawerStore] -->
<!-- @UX_STATE Loading -> Profile data loading. -->
<!-- @UX_STATE Loaded -> Profile form displayed. -->
<!-- @UX_STATE Error -> Error message shown. -->
<!-- @UX_STATE Error -> Error toast shown while preserving defaults. -->
<!-- @UX_FEEDBACK Success toast on profile update. -->
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/i18n/index.svelte.js';
import { PageHeader, Button, Card } from '$lib/ui';
import { PageHeader, Button, Card, Input, Select, LanguageSwitcher } from '$lib/ui';
import { api } from '$lib/api.js';
import { addToast } from '$lib/toasts.svelte.js';
import { setTaskDrawerAutoOpenPreference } from '$lib/stores/taskDrawer.svelte.js';
let preferences = $state<any>({});
type StartPage = 'dashboards' | 'datasets' | 'reports';
type TableDensity = 'compact' | 'comfortable';
type ProfilePermissionState = {
key: string;
allowed: boolean;
};
type ProfileSecuritySummary = {
read_only?: boolean;
auth_source?: string | null;
current_role?: string | null;
role_source?: string | null;
roles?: string[];
permissions?: ProfilePermissionState[];
};
type ProfilePreferences = {
user_id?: string;
superset_username: string;
show_only_my_dashboards: boolean;
show_only_slug_dashboards: boolean;
git_username: string;
git_email: string;
git_personal_access_token?: string | null;
has_git_personal_access_token?: boolean;
git_personal_access_token_masked?: string | null;
start_page: StartPage;
auto_open_task_drawer: boolean;
dashboards_table_density: TableDensity;
telegram_id: string;
email_address: string;
notify_on_fail: boolean;
};
type ProfileResponse = {
status?: string;
message?: string | null;
validation_errors?: string[];
preference?: Partial<ProfilePreferences>;
security?: ProfileSecuritySummary;
};
const defaultPreferences: ProfilePreferences = {
superset_username: '',
show_only_my_dashboards: false,
show_only_slug_dashboards: true,
git_username: '',
git_email: '',
git_personal_access_token: undefined,
has_git_personal_access_token: false,
git_personal_access_token_masked: null,
start_page: 'dashboards',
auto_open_task_drawer: true,
dashboards_table_density: 'comfortable',
telegram_id: '',
email_address: '',
notify_on_fail: true,
};
let preferences = $state<ProfilePreferences>({ ...defaultPreferences });
let security = $state<ProfileSecuritySummary>({ read_only: true, roles: [], permissions: [] });
let loading = $state(true);
let saving = $state(false);
let tokenInput = $state('');
let tokenTouched = $state(false);
const startPageOptions = $derived([
{ value: 'dashboards', label: $t.profile?.start_page_dashboards || $t.nav?.dashboards || 'Dashboards' },
{ value: 'datasets', label: $t.profile?.start_page_datasets || $t.nav?.datasets || 'Datasets' },
{ value: 'reports', label: $t.profile?.start_page_reports || $t.nav?.reports || 'Reports' },
]);
const tableDensityOptions = $derived([
{ value: 'comfortable', label: $t.profile?.table_density_comfortable || 'Comfortable' },
{ value: 'compact', label: $t.profile?.table_density_compact || 'Compact' },
]);
const saveLabel = $derived(
saving
? ($t.profile?.saving || 'Saving...')
: ($t.profile?.save_preferences || $t.common?.save || 'Save'),
);
function normalizePreferences(raw: Partial<ProfilePreferences> | undefined): ProfilePreferences {
return {
...defaultPreferences,
...raw,
superset_username: raw?.superset_username || '',
git_username: raw?.git_username || '',
git_email: raw?.git_email || '',
telegram_id: raw?.telegram_id || '',
email_address: raw?.email_address || '',
start_page: (raw?.start_page || defaultPreferences.start_page) as StartPage,
auto_open_task_drawer: raw?.auto_open_task_drawer !== false,
dashboards_table_density: (raw?.dashboards_table_density || defaultPreferences.dashboards_table_density) as TableDensity,
notify_on_fail: raw?.notify_on_fail !== false,
show_only_my_dashboards: raw?.show_only_my_dashboards === true,
show_only_slug_dashboards: raw?.show_only_slug_dashboards !== false,
};
}
function applyProfileResponse(response: ProfileResponse | undefined) {
preferences = normalizePreferences(response?.preference);
security = response?.security || { read_only: true, roles: [], permissions: [] };
tokenInput = '';
tokenTouched = false;
setTaskDrawerAutoOpenPreference(preferences.auto_open_task_drawer !== false);
}
function buildSavePayload() {
const payload: Record<string, unknown> = {
superset_username: preferences.superset_username || null,
show_only_my_dashboards: preferences.show_only_my_dashboards,
show_only_slug_dashboards: preferences.show_only_slug_dashboards,
git_username: preferences.git_username || null,
git_email: preferences.git_email || null,
start_page: preferences.start_page,
auto_open_task_drawer: preferences.auto_open_task_drawer,
dashboards_table_density: preferences.dashboards_table_density,
telegram_id: preferences.telegram_id || null,
email_address: preferences.email_address || null,
notify_on_fail: preferences.notify_on_fail,
};
if (tokenTouched) {
payload.git_personal_access_token = tokenInput || null;
}
return payload;
}
function handleTokenInput(event: Event) {
tokenInput = (event.currentTarget as HTMLInputElement).value;
tokenTouched = true;
}
function handleTokenClear() {
tokenInput = '';
tokenTouched = true;
preferences.has_git_personal_access_token = false;
preferences.git_personal_access_token_masked = null;
}
onMount(async () => {
try {
const response = await api.getProfilePreferences();
preferences = response?.preference || {};
const response = await api.getProfilePreferences<ProfileResponse>();
applyProfileResponse(response);
} catch (e) {
addToast(String(e?.message || 'Failed to load preferences'), 'error');
addToast(e instanceof Error ? e.message : 'Failed to load preferences', 'error');
} finally {
loading = false;
}
@@ -32,46 +174,214 @@
async function handleSave() {
saving = true;
try {
await api.postApi('/profile/preferences', { preference: preferences });
addToast($t.profile?.saved || 'Preferences saved', 'success');
const response = await api.updateProfilePreferences<ProfileResponse>(buildSavePayload());
applyProfileResponse(response);
addToast($t.profile?.save_success || $t.profile?.saved || 'Preferences saved', 'success');
} catch (e) {
addToast(String(e?.message || 'Failed to save preferences'), 'error');
addToast(e instanceof Error ? e.message : ($t.profile?.save_error || 'Failed to save preferences'), 'error');
} finally {
saving = false;
}
}
</script>
<div class="max-w-2xl mx-auto px-4 py-8">
<PageHeader title={$t.nav?.profile || 'Profile'} />
<div class="mx-auto max-w-4xl px-4 py-8">
<PageHeader title={$t.profile?.title || $t.nav?.profile || 'Profile'} />
{#if $t.profile?.description}
<p class="-mt-6 mb-8 text-sm text-text-muted">{$t.profile.description}</p>
{/if}
{#if loading}
<div class="animate-pulse space-y-4 mt-6">
<div class="h-10 bg-surface-muted rounded w-full"></div>
<div class="h-10 bg-surface-muted rounded w-full"></div>
<div class="h-10 bg-surface-muted rounded w-1/3"></div>
<div class="mt-6 animate-pulse space-y-4">
<div class="h-40 rounded-lg bg-surface-muted"></div>
<div class="h-56 rounded-lg bg-surface-muted"></div>
<div class="h-40 rounded-lg bg-surface-muted"></div>
</div>
{:else}
<Card title={$t.profile?.preferences || 'Preferences'} padding="lg">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-text mb-1">{$t.profile?.start_page || 'Start page'}</label>
<select
class="w-full px-3 py-2 border border-border-strong rounded-md"
<div class="mt-6 space-y-6">
<Card title={$t.profile?.user_preferences || $t.profile?.preferences || 'User Preferences'} padding="lg">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-text" for="profile-language">
{$t.profile?.language || 'Language'}
</label>
<div id="profile-language">
<LanguageSwitcher />
</div>
</div>
<Select
label={$t.profile?.start_page || 'Start Page'}
bind:value={preferences.start_page}
>
<option value="dashboards">{$t.nav?.dashboards}</option>
<option value="datasets">{$t.nav?.datasets}</option>
<option value="reports">{$t.nav?.reports}</option>
</select>
options={startPageOptions}
id="profile-start-page"
name="start_page"
/>
<Select
label={$t.profile?.table_density || 'Table Density'}
bind:value={preferences.dashboards_table_density}
options={tableDensityOptions}
id="profile-table-density"
name="dashboards_table_density"
/>
<label class="flex min-h-10 items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
bind:checked={preferences.auto_open_task_drawer}
name="auto_open_task_drawer"
/>
<span>{$t.profile?.auto_open_task_drawer || 'Automatically open task drawer for long-running tasks'}</span>
</label>
</div>
<div class="pt-4">
<Button onclick={handleSave} isLoading={saving}>
{$t.common?.save || 'Save'}
</Button>
</Card>
<Card title={$t.profile?.dashboard_preferences || 'Dashboard Preferences'} padding="lg">
<div class="space-y-4">
<Input
label={$t.profile?.superset_account || 'Your Apache Superset Account'}
placeholder={$t.profile?.superset_account_placeholder || 'Enter your Apache Superset username'}
bind:value={preferences.superset_username}
id="profile-superset-username"
name="superset_username"
/>
<div class="grid gap-3 md:grid-cols-2">
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
bind:checked={preferences.show_only_my_dashboards}
name="show_only_my_dashboards"
/>
<span>{$t.profile?.show_only_my_dashboards || 'Show only my dashboards by default'}</span>
</label>
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
bind:checked={preferences.show_only_slug_dashboards}
name="show_only_slug_dashboards"
/>
<span>{$t.profile?.show_only_slug_dashboards || 'Show only dashboards with slug by default'}</span>
</label>
</div>
</div>
</Card>
<Card title={$t.profile?.git_integration || 'Git Integration'} padding="lg">
<div class="grid gap-4 md:grid-cols-2">
<Input
label={$t.profile?.git_username || 'Git Username'}
placeholder={$t.profile?.git_username_placeholder || 'Enter git username'}
bind:value={preferences.git_username}
id="profile-git-username"
name="git_username"
/>
<Input
label={$t.profile?.git_email || 'Git Email'}
placeholder={$t.profile?.git_email_placeholder || 'Enter git email'}
bind:value={preferences.git_email}
id="profile-git-email"
name="git_email"
type="email"
/>
<div class="md:col-span-2 space-y-2">
<Input
label={$t.profile?.git_token || 'GitLab / GitHub Token'}
placeholder={$t.profile?.git_token_placeholder || 'Enter new personal access token'}
value={tokenInput}
oninput={handleTokenInput}
id="profile-git-token"
name="git_personal_access_token"
type="password"
/>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-text-muted">
<span>
{$t.profile?.git_token_hint || 'Token is never returned in plain text. Leave empty to keep current token.'}
</span>
<span class="text-text">
{$t.profile?.git_token_masked_label || 'Current token'}:
{preferences.git_personal_access_token_masked || $t.profile?.git_token_not_set || 'Token is not set'}
</span>
</div>
<Button variant="secondary" size="sm" onclick={handleTokenClear}>
{$t.profile?.git_token_clear || 'Clear token'}
</Button>
</div>
</div>
</Card>
<Card title={$t.profile?.notifications || 'Notifications'} padding="lg">
<div class="grid gap-4 md:grid-cols-2">
<Input
label={$t.profile?.notification_email || 'Notification email'}
placeholder={$t.profile?.notification_email_placeholder || 'Enter notification email'}
bind:value={preferences.email_address}
id="profile-email-address"
name="email_address"
type="email"
/>
<Input
label={$t.profile?.telegram_id || 'Telegram ID'}
placeholder={$t.profile?.telegram_id_placeholder || 'Enter Telegram ID'}
bind:value={preferences.telegram_id}
id="profile-telegram-id"
name="telegram_id"
/>
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text md:col-span-2">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
bind:checked={preferences.notify_on_fail}
name="notify_on_fail"
/>
<span>{$t.profile?.notify_on_fail || 'Notify on validation failure'}</span>
</label>
</div>
</Card>
<Card title={$t.profile?.security_access || 'Security & Access'} padding="lg">
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.current_role || 'Current Role'}</p>
<p class="mt-1 text-sm text-text">{security.current_role || security.roles?.[0] || '-'}</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.role_source || 'Role Source'}</p>
<p class="mt-1 text-sm text-text">{security.role_source || security.auth_source || '-'}</p>
</div>
<div class="md:col-span-2">
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.permissions || 'Permissions'}</p>
{#if security.permissions?.length}
<div class="mt-2 flex flex-wrap gap-2">
{#each security.permissions as permission}
<span class="rounded-full border border-border bg-surface-muted px-2.5 py-1 text-xs text-text-muted">
{permission.key}: {permission.allowed ? 'allowed' : 'denied'}
</span>
{/each}
</div>
{:else}
<p class="mt-1 text-sm text-text-muted">{$t.profile?.permission_none || 'No permissions available'}</p>
{/if}
</div>
<p class="md:col-span-2 text-xs text-text-muted">
{$t.profile?.security_read_only_note || 'This section is read-only. Role changes are managed in Admin -> Users.'}
</p>
</div>
</Card>
<div class="flex justify-end">
<Button onclick={handleSave} isLoading={saving}>{saveLabel}</Button>
</div>
</Card>
</div>
{/if}
</div>
<!-- #endregion ProfilePage -->

View File

@@ -1,7 +1,7 @@
// #region ProfilePreferencesIntegrationTest [C:2] [TYPE Module]
// @COMPLEXITY: 3
// @SEMANTICS: tests, profile, integration, load, save
// @PURPOSE: Verifies profile page loads preferences and saves them.
// @PURPOSE: Verifies profile page loads expanded preferences and saves them through PATCH contract.
// @LAYER UI (Tests)
// @RELATION DEPENDS_ON -> [ProfilePage]
@@ -10,17 +10,49 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import ProfilePage from "../+page.svelte";
import { api } from "$lib/api.js";
import { addToast } from "$lib/toasts.svelte.js";
import { setTaskDrawerAutoOpenPreference } from "$lib/stores/taskDrawer.svelte.js";
const mockedApi = /** @type {any} */ (api);
const mockedAddToast = /** @type {any} */ (addToast);
const mockedSetTaskDrawerAutoOpenPreference = /** @type {any} */ (setTaskDrawerAutoOpenPreference);
const profileResponse = {
status: "success",
preference: {
user_id: "u-1",
superset_username: "superset_admin",
show_only_my_dashboards: true,
show_only_slug_dashboards: true,
git_username: "git-admin",
git_email: "git@example.test",
has_git_personal_access_token: true,
git_personal_access_token_masked: "ghp_***",
start_page: "dashboards",
auto_open_task_drawer: true,
dashboards_table_density: "comfortable",
telegram_id: "12345",
email_address: "notify@example.test",
notify_on_fail: true,
},
security: {
read_only: true,
current_role: "Admin",
role_source: "local",
permissions: [{ key: "admin:settings", allowed: true }],
},
};
vi.mock("$lib/api.js", () => ({
api: {
getProfilePreferences: vi.fn(),
postApi: vi.fn(),
updateProfilePreferences: vi.fn(),
},
}));
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({
setTaskDrawerAutoOpenPreference: vi.fn(),
}));
vi.mock('$lib/toasts.svelte.js', () => ({
addToast: vi.fn(),
}));
@@ -30,15 +62,54 @@ vi.mock("$lib/auth/store.svelte.js", () => ({
}));
vi.mock('$lib/i18n/index.svelte.js', () => ({
locale: {
subscribe: (run) => { run('en'); return () => {}; },
set: vi.fn(),
update: vi.fn(),
},
t: {
subscribe: (run) => {
run({
common: { save: "Save", cancel: "Cancel" },
nav: { profile: "Profile", dashboards: "Dashboards", datasets: "Datasets", reports: "Reports" },
profile: {
title: "Profile",
description: "Manage your profile preferences",
saved: "Preferences saved",
preferences: "Preferences",
start_page: "Start page",
user_preferences: "User Preferences",
dashboard_preferences: "Dashboard Preferences",
git_integration: "Git Integration",
notifications: "Notifications",
security_access: "Security & Access",
start_page: "Start Page",
start_page_dashboards: "Dashboards",
start_page_datasets: "Datasets",
start_page_reports: "Reports / Logs",
language: "Language",
table_density: "Table Density",
table_density_comfortable: "Comfortable",
table_density_compact: "Compact",
auto_open_task_drawer: "Automatically open task drawer for long-running tasks",
superset_account: "Your Apache Superset Account",
show_only_my_dashboards: "Show only my dashboards by default",
show_only_slug_dashboards: "Show only dashboards with slug by default",
git_username: "Git Username",
git_email: "Git Email",
git_token: "GitLab / GitHub Token",
git_token_clear: "Clear token",
git_token_masked_label: "Current token",
git_token_not_set: "Token is not set",
notification_email: "Notification email",
telegram_id: "Telegram ID",
notify_on_fail: "Notify on validation failure",
save_preferences: "Save Preferences",
save_success: "Preferences saved",
current_role: "Current Role",
role_source: "Role Source",
permissions: "Permissions",
permission_none: "No permissions available",
security_read_only_note: "This section is read-only.",
},
});
return () => {};
@@ -50,16 +121,11 @@ vi.mock('$lib/i18n/index.svelte.js', () => ({
describe("profile-preferences.integration", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedApi.getProfilePreferences.mockResolvedValue({
status: "success",
preference: {
user_id: "u-1",
start_page: "dashboards",
},
});
mockedApi.getProfilePreferences.mockResolvedValue(profileResponse);
mockedApi.updateProfilePreferences.mockResolvedValue(profileResponse);
});
it("loads preferences and renders the profile form", async () => {
it("loads preferences and renders expanded profile sections", async () => {
render(ProfilePage);
await waitFor(() => {
@@ -67,30 +133,45 @@ describe("profile-preferences.integration", () => {
});
expect(screen.getByText("Profile")).toBeDefined();
expect(screen.getByText("Preferences")).toBeDefined();
expect(screen.getByText("Start page")).toBeDefined();
const select = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox"));
expect(select.value).toBe("dashboards");
expect(screen.getByText("User Preferences")).toBeDefined();
expect(screen.getByText("Dashboard Preferences")).toBeDefined();
expect(screen.getByText("Git Integration")).toBeDefined();
expect(screen.getByText("Notifications")).toBeDefined();
expect(screen.getByText("Security & Access")).toBeDefined();
const startPage = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox", { name: "Start Page" }));
expect(startPage.value).toBe("dashboards");
expect(screen.getByRole("combobox", { name: "Language" })).toBeDefined();
expect(screen.getByDisplayValue("superset_admin")).toBeDefined();
expect(screen.getByDisplayValue("git@example.test")).toBeDefined();
expect(screen.getAllByText((_content, element) => Boolean(element?.textContent?.includes("ghp_***"))).length).toBeGreaterThan(0);
expect(screen.getByText("Admin")).toBeDefined();
expect(mockedSetTaskDrawerAutoOpenPreference).toHaveBeenCalledWith(true);
});
it("saves preferences via API and shows success toast", async () => {
mockedApi.postApi.mockResolvedValue({ status: "success" });
it("saves preferences via PATCH API and shows success toast", async () => {
render(ProfilePage);
await waitFor(() => {
expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1);
});
const select = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox"));
await fireEvent.change(select, { target: { value: "datasets" } });
const startPage = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox", { name: "Start Page" }));
await fireEvent.change(startPage, { target: { value: "datasets" } });
await fireEvent.click(screen.getByText("Save"));
const autoOpen = /** @type {HTMLInputElement} */ (screen.getByRole("checkbox", { name: "Automatically open task drawer for long-running tasks" }));
await fireEvent.click(autoOpen);
await fireEvent.click(screen.getByRole("button", { name: "Save Preferences" }));
await waitFor(() => {
expect(mockedApi.postApi).toHaveBeenCalledWith("/profile/preferences", {
preference: expect.objectContaining({ start_page: "datasets" }),
});
expect(mockedApi.updateProfilePreferences).toHaveBeenCalledWith(expect.objectContaining({
start_page: "datasets",
auto_open_task_drawer: false,
superset_username: "superset_admin",
git_email: "git@example.test",
notify_on_fail: true,
}));
});
expect(mockedAddToast).toHaveBeenCalledWith("Preferences saved", "success");

View File

@@ -1,7 +1,7 @@
// #region ProfileSettingsStateIntegrationTest [C:2] [TYPE Module]
// @COMPLEXITY: 3
// @SEMANTICS: tests, profile, integration, load, change, save
// @PURPOSE: Verifies profile loads preferences, allows changes, and saves correctly.
// @PURPOSE: Verifies profile state changes for filters, notifications, and token clearing.
// @LAYER UI (Tests)
// @RELATION DEPENDS_ON -> [ProfilePage]
@@ -14,13 +14,38 @@ import { addToast } from "$lib/toasts.svelte.js";
const mockedApi = /** @type {any} */ (api);
const mockedAddToast = /** @type {any} */ (addToast);
const profileResponse = {
status: "success",
preference: {
user_id: "u-1",
superset_username: "",
show_only_my_dashboards: false,
show_only_slug_dashboards: true,
git_username: "",
git_email: "",
has_git_personal_access_token: true,
git_personal_access_token_masked: "***",
start_page: "dashboards",
auto_open_task_drawer: true,
dashboards_table_density: "comfortable",
telegram_id: "",
email_address: "",
notify_on_fail: true,
},
security: { read_only: true, roles: [], permissions: [] },
};
vi.mock("$lib/api.js", () => ({
api: {
getProfilePreferences: vi.fn(),
postApi: vi.fn(),
updateProfilePreferences: vi.fn(),
},
}));
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({
setTaskDrawerAutoOpenPreference: vi.fn(),
}));
vi.mock('$lib/toasts.svelte.js', () => ({
addToast: vi.fn(),
}));
@@ -30,15 +55,54 @@ vi.mock("$lib/auth/store.svelte.js", () => ({
}));
vi.mock('$lib/i18n/index.svelte.js', () => ({
locale: {
subscribe: (run) => { run('en'); return () => {}; },
set: vi.fn(),
update: vi.fn(),
},
t: {
subscribe: (run) => {
run({
common: { save: "Save", cancel: "Cancel" },
nav: { profile: "Profile", dashboards: "Dashboards", datasets: "Datasets", reports: "Reports" },
profile: {
title: "Profile",
description: "Manage your profile preferences",
saved: "Preferences saved",
preferences: "Preferences",
start_page: "Start page",
user_preferences: "User Preferences",
dashboard_preferences: "Dashboard Preferences",
git_integration: "Git Integration",
notifications: "Notifications",
security_access: "Security & Access",
start_page: "Start Page",
start_page_dashboards: "Dashboards",
start_page_datasets: "Datasets",
start_page_reports: "Reports / Logs",
language: "Language",
table_density: "Table Density",
table_density_comfortable: "Comfortable",
table_density_compact: "Compact",
auto_open_task_drawer: "Automatically open task drawer for long-running tasks",
superset_account: "Your Apache Superset Account",
show_only_my_dashboards: "Show only my dashboards by default",
show_only_slug_dashboards: "Show only dashboards with slug by default",
git_username: "Git Username",
git_email: "Git Email",
git_token: "GitLab / GitHub Token",
git_token_clear: "Clear token",
git_token_masked_label: "Current token",
git_token_not_set: "Token is not set",
notification_email: "Notification email",
telegram_id: "Telegram ID",
notify_on_fail: "Notify on validation failure",
save_preferences: "Save Preferences",
save_success: "Preferences saved",
current_role: "Current Role",
role_source: "Role Source",
permissions: "Permissions",
permission_none: "No permissions available",
security_read_only_note: "This section is read-only.",
},
});
return () => {};
@@ -50,45 +114,50 @@ vi.mock('$lib/i18n/index.svelte.js', () => ({
describe("profile-settings-state.integration", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedApi.getProfilePreferences.mockResolvedValue({
status: "success",
preference: {
user_id: "u-1",
start_page: "dashboards",
},
});
mockedApi.getProfilePreferences.mockResolvedValue(profileResponse);
mockedApi.updateProfilePreferences.mockResolvedValue(profileResponse);
});
it("loads saved preferences and displays them", async () => {
it("loads saved preferences and displays them by label", async () => {
render(ProfilePage);
await waitFor(() => {
expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1);
});
const select = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox"));
expect(select.value).toBe("dashboards");
expect(screen.getByText("Profile")).toBeDefined();
const startPage = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox", { name: "Start Page" }));
expect(startPage.value).toBe("dashboards");
expect(screen.getByRole("combobox", { name: "Table Density" })).toBeDefined();
expect(screen.getByText("No permissions available")).toBeDefined();
});
it("saves changed preferences and shows success toast", async () => {
mockedApi.postApi.mockResolvedValue({ status: "success" });
it("saves changed filter, notification, and token-clear state", async () => {
render(ProfilePage);
await waitFor(() => {
expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1);
});
const select = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox"));
await fireEvent.change(select, { target: { value: "reports" } });
await fireEvent.input(screen.getByRole("textbox", { name: "Your Apache Superset Account" }), {
target: { value: "owner_name" },
});
await fireEvent.click(screen.getByRole("checkbox", { name: "Show only my dashboards by default" }));
await fireEvent.input(screen.getByRole("textbox", { name: "Notification email" }), {
target: { value: "owner@example.test" },
});
await fireEvent.click(screen.getByRole("checkbox", { name: "Notify on validation failure" }));
await fireEvent.click(screen.getByRole("button", { name: "Clear token" }));
await fireEvent.click(screen.getByText("Save"));
await fireEvent.click(screen.getByRole("button", { name: "Save Preferences" }));
await waitFor(() => {
expect(mockedApi.postApi).toHaveBeenCalledWith("/profile/preferences", {
preference: expect.objectContaining({ start_page: "reports" }),
});
expect(mockedApi.updateProfilePreferences).toHaveBeenCalledWith(expect.objectContaining({
superset_username: "owner_name",
show_only_my_dashboards: true,
email_address: "owner@example.test",
notify_on_fail: false,
git_personal_access_token: null,
}));
});
expect(mockedAddToast).toHaveBeenCalledWith("Preferences saved", "success");