diff --git a/src/localization/messages/ch.json b/src/localization/messages/ch.json index 72ea6693..3acf6cf4 100644 --- a/src/localization/messages/ch.json +++ b/src/localization/messages/ch.json @@ -1904,6 +1904,7 @@ "showExtensionsFolder": "显示扩展程序文件夹", "reinstalledSuccessfully": "重新安装成功", "reusedDependencies": "重复使用了 {count} 个现有依赖", + "themesInstalled": "主题已安装", "noLongerAvailable": "不再可用", "removedBecauseUnapproved": "此扩展已被移除,因为它已不在已批准的注册表中。", "enabled": "Enabled", diff --git a/src/localization/messages/de.json b/src/localization/messages/de.json index 0e0405cd..e71468ba 100644 --- a/src/localization/messages/de.json +++ b/src/localization/messages/de.json @@ -1904,6 +1904,7 @@ "showExtensionsFolder": "Erweiterungsordner anzeigen", "reinstalledSuccessfully": "Erneut erfolgreich installiert", "reusedDependencies": "{count} vorhandene Abhängigkeiten wiederverwendet", + "themesInstalled": "Designs wurden installiert", "noLongerAvailable": "Nicht mehr verfügbar", "removedBecauseUnapproved": "Diese Erweiterung wurde entfernt, weil sie nicht mehr in der genehmigten Registry enthalten ist.", "enabled": "Enabled", diff --git a/src/localization/messages/en.json b/src/localization/messages/en.json index 857fd8e1..e54cdfcc 100644 --- a/src/localization/messages/en.json +++ b/src/localization/messages/en.json @@ -1853,6 +1853,7 @@ "reinstall": "Re-install", "reinstalledSuccessfully": "Re-installed successfully", "reusedDependencies": "Reused {count} existing dependencies", + "themesInstalled": "Themes were installed", "noLongerAvailable": "No longer available", "removedBecauseUnapproved": "This extension was removed because it is no longer in the approved registry.", "update": "Update", diff --git a/src/localization/messages/es.json b/src/localization/messages/es.json index da302e7f..ff905c77 100644 --- a/src/localization/messages/es.json +++ b/src/localization/messages/es.json @@ -1904,6 +1904,7 @@ "showExtensionsFolder": "Mostrar carpeta de extensiones", "reinstalledSuccessfully": "Reinstalado correctamente", "reusedDependencies": "Reutilizadas {count} dependencias existentes", + "themesInstalled": "Los temas se instalaron", "noLongerAvailable": "Ya no está disponible", "removedBecauseUnapproved": "Esta extensión se eliminó porque ya no está en el registro aprobado.", "enabled": "Enabled", diff --git a/src/localization/messages/fa.json b/src/localization/messages/fa.json index da80a85f..867e33ee 100644 --- a/src/localization/messages/fa.json +++ b/src/localization/messages/fa.json @@ -1903,6 +1903,7 @@ "showExtensionsFolder": "نمایش پوشه افزونه‌ها", "reinstalledSuccessfully": "با موفقیت نصب مجدد شد", "reusedDependencies": "{count} وابستگی موجود مجدداً استفاده شد", + "themesInstalled": "تم‌ها نصب شدند", "noLongerAvailable": "دیگر در دسترس نیست", "removedBecauseUnapproved": "این افزونه حذف شد چون دیگر در رجیستری مورد تأیید وجود ندارد.", "enabled": "Enabled", diff --git a/src/localization/messages/fr.json b/src/localization/messages/fr.json index be04e2fb..22f53a41 100644 --- a/src/localization/messages/fr.json +++ b/src/localization/messages/fr.json @@ -1904,6 +1904,7 @@ "showExtensionsFolder": "Afficher le dossier des extensions", "reinstalledSuccessfully": "Réinstallation réussie", "reusedDependencies": "{count} dépendances existantes réutilisées", + "themesInstalled": "Les thèmes ont été installés", "noLongerAvailable": "N'est plus disponible", "removedBecauseUnapproved": "Cette extension a été supprimée, car elle ne figure plus dans le registre approuvé.", "enabled": "Enabled", diff --git a/src/localization/messages/hi.json b/src/localization/messages/hi.json index 55762a72..41d4503c 100644 --- a/src/localization/messages/hi.json +++ b/src/localization/messages/hi.json @@ -1853,6 +1853,7 @@ "reinstall": "पुन: इंस्टॉल", "reinstalledSuccessfully": "सफलतापूर्वक पुनः स्थापित किया गया", "reusedDependencies": "पुन: उपयोग किया गया {count} मौजूदा निर्भरताएँ", + "themesInstalled": "थीम इंस्टॉल हो गईं", "noLongerAvailable": "अब उपलब्ध नहीं है", "removedBecauseUnapproved": "यह एक्सटेंशन हटा दिया गया क्योंकि यह अब स्वीकृत रजिस्ट्री में नहीं है।", "update": "अद्यतन", diff --git a/src/localization/messages/it.json b/src/localization/messages/it.json index f4e8fc3f..d8ff702c 100644 --- a/src/localization/messages/it.json +++ b/src/localization/messages/it.json @@ -1904,6 +1904,7 @@ "showExtensionsFolder": "Mostra cartella estensioni", "reinstalledSuccessfully": "Reinstallato con successo", "reusedDependencies": "Riusate {count} dipendenze esistenti", + "themesInstalled": "I temi sono stati installati", "noLongerAvailable": "Non è più disponibile", "removedBecauseUnapproved": "Questa estensione è stata rimossa perché non è più presente nel registro approvato.", "enabled": "Enabled", diff --git a/src/localization/messages/ja.json b/src/localization/messages/ja.json index e6c4e327..5fd1f95b 100644 --- a/src/localization/messages/ja.json +++ b/src/localization/messages/ja.json @@ -1904,6 +1904,7 @@ "showExtensionsFolder": "拡張機能フォルダーを表示", "reinstalledSuccessfully": "再インストールが完了しました", "reusedDependencies": "既存の依存関係 {count} 件を再利用しました", + "themesInstalled": "テーマがインストールされました", "noLongerAvailable": "利用できなくなりました", "removedBecauseUnapproved": "この拡張機能は承認済みレジストリに存在しなくなったため削除されました。", "enabled": "有効", diff --git a/src/localization/messages/pt.json b/src/localization/messages/pt.json index 76ffb33d..42bb63c8 100644 --- a/src/localization/messages/pt.json +++ b/src/localization/messages/pt.json @@ -1915,6 +1915,7 @@ "showExtensionsFolder": "Mostrar pasta de extensões", "reinstalledSuccessfully": "Reinstalado com sucesso", "reusedDependencies": "Reutilizadas {count} dependências existentes", + "themesInstalled": "Os temas foram instalados", "noLongerAvailable": "Não está mais disponível", "removedBecauseUnapproved": "Esta extensão foi removida porque não está mais no registro aprovado.", "enabled": "Enabled", diff --git a/src/localization/messages/ru.json b/src/localization/messages/ru.json index 07455726..7953b047 100644 --- a/src/localization/messages/ru.json +++ b/src/localization/messages/ru.json @@ -1923,6 +1923,7 @@ "showExtensionsFolder": "Показать папку расширений", "reinstalledSuccessfully": "Успешно переустановлено", "reusedDependencies": "Использовано {count} существующих зависимостей", + "themesInstalled": "Темы установлены", "noLongerAvailable": "Больше недоступно", "removedBecauseUnapproved": "Это расширение было удалено, потому что его больше нет в одобренном реестре.", "enabled": "Enabled", diff --git a/src/localization/messages/sl.json b/src/localization/messages/sl.json index 265e20d6..e53ebf77 100644 --- a/src/localization/messages/sl.json +++ b/src/localization/messages/sl.json @@ -1953,6 +1953,7 @@ "reinstall": "Re-install", "reinstalledSuccessfully": "Ponovno uspešno nameščeno", "reusedDependencies": "Ponovno uporabljenih {count} obstoječih odvisnosti", + "themesInstalled": "Teme so bile nameščene", "noLongerAvailable": "Ni več na voljo", "removedBecauseUnapproved": "Ta razširitev je bila odstranjena, ker je ni več v odobrenem registru.", "update": "Update", diff --git a/src/localization/messages/tr.json b/src/localization/messages/tr.json index 3463604c..3d85dc61 100644 --- a/src/localization/messages/tr.json +++ b/src/localization/messages/tr.json @@ -1904,6 +1904,7 @@ "showExtensionsFolder": "Uzantılar klasörünü göster", "reinstalledSuccessfully": "Başarıyla yeniden yüklendi", "reusedDependencies": "{count} mevcut bağımlılık yeniden kullanıldı", + "themesInstalled": "Temalar yüklendi", "noLongerAvailable": "Artık kullanılamıyor", "removedBecauseUnapproved": "Bu uzantı, artık onaylı kayıt listesinde olmadığı için kaldırıldı.", "enabled": "Enabled", diff --git a/src/localization/messages/ur.json b/src/localization/messages/ur.json index 95f91dfc..4eeb3f41 100644 --- a/src/localization/messages/ur.json +++ b/src/localization/messages/ur.json @@ -1853,6 +1853,7 @@ "reinstall": "دوبارہ انسٹال", "reinstalledSuccessfully": "دوبارہ انسٹال ہو گیا", "reusedDependencies": "{count} موجودہ وابستگیاں دوبارہ استعمال", + "themesInstalled": "تھیمز انسٹال ہو گئے", "noLongerAvailable": "اب دستیاب نہیں", "removedBecauseUnapproved": "یہ ایکسٹینشن ہٹا دیا گیا کیونکہ منظور شدہ رجسٹری میں نہیں۔", "update": "اپ ڈیٹ", diff --git a/src/localization/messages/vi.json b/src/localization/messages/vi.json index 8884e2b5..ddfa2d1f 100644 --- a/src/localization/messages/vi.json +++ b/src/localization/messages/vi.json @@ -1904,6 +1904,7 @@ "showExtensionsFolder": "Hiển thị thư mục tiện ích mở rộng", "reinstalledSuccessfully": "Cài đặt lại thành công", "reusedDependencies": "Đã tái sử dụng {count} phụ thuộc hiện có", + "themesInstalled": "Đã cài đặt giao diện", "noLongerAvailable": "Không còn khả dụng", "removedBecauseUnapproved": "Tiện ích mở rộng này đã bị gỡ vì không còn trong danh sách đăng ký đã được phê duyệt.", "enabled": "Enabled", diff --git a/src/modules/extensions/utils/__tests__/toast-utils.test.ts b/src/modules/extensions/utils/__tests__/toast-utils.test.ts new file mode 100644 index 00000000..03b80194 --- /dev/null +++ b/src/modules/extensions/utils/__tests__/toast-utils.test.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// License: GNU GPLv3 or later. See the license file in the project root for more information. +// Copyright © 2021 - present Aleksey Hoffman. All rights reserved. + +import { + beforeEach, describe, expect, it, vi, +} from 'vitest'; +import type { ExtensionManifest, InstalledExtensionData } from '@/types/extension'; + +const { + installedExtensions, + openSettingsTabMock, + toastCustomMock, + toastDismissMock, +} = vi.hoisted(() => ({ + installedExtensions: {} as Record, + openSettingsTabMock: vi.fn(), + toastCustomMock: vi.fn(), + toastDismissMock: vi.fn(), +})); + +vi.mock('@/modules/settings/utils/open-settings', () => ({ + openSettingsTab: openSettingsTabMock, +})); + +vi.mock('@/stores/storage/extensions', () => ({ + useExtensionsStorageStore: () => ({ + extensionsData: { + installedExtensions, + }, + }), +})); + +vi.mock('@/components/ui/toaster', () => ({ + toast: { + custom: toastCustomMock, + dismiss: toastDismissMock, + }, + ToastStatic: { + name: 'ToastStatic', + }, +})); + +vi.mock('@/localization', () => ({ + i18n: { + global: { + locale: { + value: 'en', + }, + t: (key: string) => ({ + 'featureExtension': 'Extension', + 'openSettings': 'Open settings', + 'extensions.themesInstalled': 'Themes were installed', + })[key] ?? key, + }, + }, +})); + +import { showThemesInstalledToast } from '@/modules/extensions/utils/toast-utils'; + +function createThemeManifest(): ExtensionManifest { + return { + id: 'test.palette', + name: 'Test Palette', + version: '1.0.0', + repository: 'https://github.com/example/test-palette', + license: 'MIT', + extensionType: 'api', + permissions: [], + contributes: { + themes: [ + { + id: 'midnight', + title: 'Midnight', + baseTheme: 'dark', + variables: { + '--background': '230 20% 10%', + }, + }, + ], + }, + engines: { + sigmaFileManager: '>=2.0.0', + }, + }; +} + +describe('extension toast utilities', () => { + beforeEach(() => { + Object.keys(installedExtensions).forEach((extensionId) => { + delete installedExtensions[extensionId]; + }); + openSettingsTabMock.mockReset(); + toastCustomMock.mockReset(); + toastDismissMock.mockReset(); + }); + + it('shows a persistent settings action toast for contributed themes', () => { + const manifest = createThemeManifest(); + installedExtensions[manifest.id] = { + version: manifest.version, + enabled: true, + autoUpdate: true, + installedAt: 1, + manifest: { + ...manifest, + icon: 'icon.png', + }, + settings: { + scopedDirectories: [], + customSettings: {}, + }, + }; + + showThemesInstalledToast(manifest.id, manifest); + + expect(toastCustomMock).toHaveBeenCalledTimes(1); + const toastOptions = toastCustomMock.mock.calls[0]?.[1]; + expect(toastOptions).toMatchObject({ + id: 'ext-themes-installed-test.palette', + duration: Infinity, + componentProps: { + data: { + title: 'Extension | Test Palette', + subtitle: 'Themes were installed', + actionText: 'Open settings', + extensionId: 'test.palette', + extensionIconPath: 'icon.png', + }, + }, + }); + + toastOptions.componentProps.data.onAction(); + + expect(toastDismissMock).toHaveBeenCalledWith('ext-themes-installed-test.palette'); + expect(openSettingsTabMock).toHaveBeenCalledWith('appearance'); + + toastOptions.componentProps.data.onDismiss(); + + expect(toastDismissMock).toHaveBeenCalledTimes(2); + expect(toastDismissMock).toHaveBeenLastCalledWith('ext-themes-installed-test.palette'); + }); + + it('does not show a toast when the extension contributes no themes', () => { + const manifest = { + ...createThemeManifest(), + contributes: {}, + }; + + showThemesInstalledToast(manifest.id, manifest); + + expect(toastCustomMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/extensions/utils/toast-utils.ts b/src/modules/extensions/utils/toast-utils.ts index 11965aa2..465ef307 100644 --- a/src/modules/extensions/utils/toast-utils.ts +++ b/src/modules/extensions/utils/toast-utils.ts @@ -6,6 +6,8 @@ import { markRaw } from 'vue'; import { toast, ToastStatic } from '@/components/ui/toaster'; import { i18n } from '@/localization'; import { useExtensionsStorageStore } from '@/stores/storage/extensions'; +import { openSettingsTab } from '@/modules/settings/utils/open-settings'; +import type { ExtensionManifest } from '@/types/extension'; import { getBinaryDownloadCount, clearBinaryDownloadCount, getBinaryReuseCount, clearBinaryReuseCount, @@ -70,6 +72,36 @@ export function showDependenciesInstalledToast(extensionId: string): void { }, 3000); } +export function manifestContributesThemes(manifest: ExtensionManifest): boolean { + return (manifest.contributes?.themes?.length ?? 0) > 0; +} + +export function showThemesInstalledToast(extensionId: string, manifest: ExtensionManifest): void { + if (!manifestContributesThemes(manifest)) return; + + const { t } = i18n.global; + const toastId = `ext-themes-installed-${extensionId}`; + + toast.custom(markRaw(ToastStatic), { + id: toastId, + duration: Infinity, + componentProps: { + data: { + title: getExtensionToastTitle(extensionId), + subtitle: t('extensions.themesInstalled'), + actionText: t('openSettings'), + onAction: () => { + toast.dismiss(toastId); + void openSettingsTab('appearance'); + }, + onDismiss: () => toast.dismiss(toastId), + extensionId, + extensionIconPath: getExtensionToastIconPath(extensionId), + }, + }, + }); +} + export function showExtensionNoLongerAvailableToast(extensionId: string, extensionName: string): void { const { t } = i18n.global; const toastId = `ext-removed-${extensionId}`; diff --git a/src/modules/global-search/global-search-view.vue b/src/modules/global-search/global-search-view.vue index 8d20e3b7..58143f08 100644 --- a/src/modules/global-search/global-search-view.vue +++ b/src/modules/global-search/global-search-view.vue @@ -11,7 +11,7 @@ import { ref, watch, } from 'vue'; -import { useRouter, useRoute } from 'vue-router'; +import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { ChevronDownIcon, HardDriveIcon, LoaderCircleIcon, SearchIcon, SettingsIcon, SlidersHorizontalIcon, UsbIcon, XIcon, @@ -28,7 +28,7 @@ import { } from '@/components/ui/number-field'; import { useGlobalSearchStore } from '@/stores/runtime/global-search'; import { useUserSettingsStore } from '@/stores/storage/user-settings'; -import { useSettingsStore } from '@/stores/runtime/settings'; +import { openSettingsTab } from '@/modules/settings/utils/open-settings'; import { getDriveByPath } from '@/modules/home/composables/use-drives'; import type { DirEntry } from '@/types/dir-entry'; import type { DriveInfo } from '@/types/drive-info'; @@ -43,11 +43,9 @@ const emit = defineEmits<{ 'update:selectedEntries': [entries: DirEntry[]]; }>(); -const router = useRouter(); const route = useRoute(); const globalSearchStore = useGlobalSearchStore(); const userSettingsStore = useUserSettingsStore(); -const settingsStore = useSettingsStore(); const { t } = useI18n(); const inputRef = ref | null>(null); @@ -61,8 +59,7 @@ const typoTolerance = computed(() => userSettingsStore.userSettings.globalSearch const scanDepth = computed(() => userSettingsStore.userSettings.globalSearch.scanDepth); function openSearchSettings() { - settingsStore.setCurrentTab('search'); - router.push({ name: 'settings' }); + void openSettingsTab('search'); } function setResultLimit(value: string | number | undefined) { diff --git a/src/modules/settings/utils/__tests__/open-settings.test.ts b/src/modules/settings/utils/__tests__/open-settings.test.ts new file mode 100644 index 00000000..6b2561df --- /dev/null +++ b/src/modules/settings/utils/__tests__/open-settings.test.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// License: GNU GPLv3 or later. See the license file in the project root for more information. +// Copyright © 2021 - present Aleksey Hoffman. All rights reserved. + +import { + beforeEach, describe, expect, it, vi, +} from 'vitest'; + +const { + routerPushMock, + settingsStoreMock, +} = vi.hoisted(() => ({ + routerPushMock: vi.fn(), + settingsStoreMock: { + clearSearch: vi.fn(), + setCurrentTab: vi.fn(), + }, +})); + +vi.mock('@/router', () => ({ + default: { + push: routerPushMock, + }, +})); + +vi.mock('@/stores/runtime/settings', () => ({ + useSettingsStore: () => settingsStoreMock, +})); + +import { openSettingsTab } from '@/modules/settings/utils/open-settings'; + +describe('openSettingsTab', () => { + beforeEach(() => { + routerPushMock.mockReset(); + settingsStoreMock.clearSearch.mockReset(); + settingsStoreMock.setCurrentTab.mockReset(); + }); + + it('clears settings search, selects a tab, and routes to settings', async () => { + await openSettingsTab('appearance'); + + expect(settingsStoreMock.clearSearch).toHaveBeenCalledTimes(1); + expect(settingsStoreMock.setCurrentTab).toHaveBeenCalledWith('appearance'); + expect(routerPushMock).toHaveBeenCalledWith({ name: 'settings' }); + }); +}); diff --git a/src/modules/settings/utils/open-settings.ts b/src/modules/settings/utils/open-settings.ts new file mode 100644 index 00000000..5c1883af --- /dev/null +++ b/src/modules/settings/utils/open-settings.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// License: GNU GPLv3 or later. See the license file in the project root for more information. +// Copyright © 2021 - present Aleksey Hoffman. All rights reserved. + +import router from '@/router'; +import { useSettingsStore } from '@/stores/runtime/settings'; + +export async function openSettingsTab(tabName?: string): Promise { + const settingsStore = useSettingsStore(); + settingsStore.clearSearch(); + + if (tabName) { + settingsStore.setCurrentTab(tabName); + } + + await router.push({ name: 'settings' }); +} diff --git a/src/stores/runtime/__tests__/extensions.test.ts b/src/stores/runtime/__tests__/extensions.test.ts index 62bbc32b..f0a7dc7d 100644 --- a/src/stores/runtime/__tests__/extensions.test.ts +++ b/src/stores/runtime/__tests__/extensions.test.ts @@ -585,6 +585,18 @@ describe('extensions runtime store', () => { 'test.video', 'https://github.com/example/test-video', ); + manifest.contributes = { + themes: [ + { + id: 'midnight', + title: 'Midnight', + baseTheme: 'dark', + variables: { + '--background': '230 20% 10%', + }, + }, + ], + }; const registryEntry = createRegistryEntry('test.video', manifest.repository); const extensionsStore = useExtensionsStore(); extensionsStore.registry = { @@ -628,6 +640,13 @@ describe('extensions runtime store', () => { cancellationId: expect.any(String), }), ); + expect(toastCustomMock).toHaveBeenCalledTimes(1); + expect(toastCustomMock.mock.calls[0]?.[1]?.componentProps?.data).toMatchObject({ + title: 'Extension | test.video', + subtitle: 'Themes were installed', + actionText: 'Open settings', + extensionId: 'test.video', + }); }); it('passes install cancellation ids to extension archive updates', async () => { diff --git a/src/stores/runtime/extensions.ts b/src/stores/runtime/extensions.ts index 4e78c221..9ee761dd 100644 --- a/src/stores/runtime/extensions.ts +++ b/src/stores/runtime/extensions.ts @@ -60,6 +60,7 @@ import { import { getExtensionDisplayName, showDependenciesInstalledToast, + showThemesInstalledToast, showExtensionNoLongerAvailableToast, showExtensionBusyToast, } from '@/modules/extensions/utils/toast-utils'; @@ -723,6 +724,7 @@ export const useExtensionsStore = defineStore('extensions', () => { await storageStore.completeExtensionInstall(extensionId); showDependenciesInstalledToast(extensionId); + showThemesInstalledToast(extensionId, manifest); } catch (error) { if (persistedInstall) { @@ -815,6 +817,7 @@ export const useExtensionsStore = defineStore('extensions', () => { await storageStore.completeExtensionInstall(extensionId); showDependenciesInstalledToast(extensionId); + showThemesInstalledToast(extensionId, manifest); } catch (error) { if (persistedInstall) { @@ -899,6 +902,8 @@ export const useExtensionsStore = defineStore('extensions', () => { if (extensionData.enabled && shouldActivateOnStartup(manifest)) { await loadExtension(extensionId, 'onStartup'); } + + showThemesInstalledToast(extensionId, manifest); } catch (error) { await storageStore.restoreExtensionSnapshot( @@ -1059,6 +1064,7 @@ export const useExtensionsStore = defineStore('extensions', () => { } showDependenciesInstalledToast(extensionId); + showThemesInstalledToast(extensionId, manifest); } catch (error) { await storageStore.restoreExtensionSnapshot( diff --git a/src/stores/runtime/settings.ts b/src/stores/runtime/settings.ts index 1715ee7f..160106b1 100644 --- a/src/stores/runtime/settings.ts +++ b/src/stores/runtime/settings.ts @@ -10,8 +10,8 @@ import { markRaw, type Component, } from 'vue'; -import { useI18n } from 'vue-i18n'; import { messages } from '@/localization/data'; +import { i18n } from '@/localization'; import { useUserSettingsStore } from '@/stores/storage/user-settings'; import { usePlatformStore } from '@/stores/runtime/platform'; @@ -107,7 +107,6 @@ function matchesAnyLocale(key: string, searchTerm: string): boolean { } export const useSettingsStore = defineStore('settings', () => { - const { t } = useI18n(); const userSettingsStore = useUserSettingsStore(); const platformStore = usePlatformStore(); @@ -127,7 +126,7 @@ export const useSettingsStore = defineStore('settings', () => { const tabs = computed(() => settingsTabs.map(tab => ({ name: tab.name, - label: t(tab.labelKey), + label: i18n.global.t(tab.labelKey), })), );