i18n-dashboard 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,8 @@
66
66
  - **CORS auto-detection** — multiple app URLs per project; all are checked for CORS on `/locale/[lang].json`
67
67
  - **Global loading overlay** — a full-page loading screen prevents interaction before data is ready, including on direct page load (F5) for any route
68
68
  - **Dark mode** — system preference + manual toggle
69
+ - **Cypress E2E test suite** — full test coverage across all pages (auth, dashboard, projects, translations, languages, users, review, settings); CI-ready with GitHub Actions
70
+ - **GitHub Actions CI** — E2E tests run automatically on every push to `develop` and `main`; Cypress screenshots uploaded as artifacts on failure
69
71
 
70
72
  ---
71
73
 
@@ -723,6 +725,7 @@ npx i18n-dashboard sync
723
725
  | [@vitalets/google-translate-api](https://github.com/vitalets/google-translate-api) | 9.x | Google Translate (free tier) |
724
726
  | [Commander.js](https://github.com/tj/commander.js) | 13.x | CLI |
725
727
  | [bcryptjs](https://github.com/dcodeIO/bcrypt.js) | 2.x | Password hashing |
728
+ | [Cypress](https://www.cypress.io/) | 13.x | E2E test suite |
726
729
 
727
730
  ---
728
731
 
@@ -114,11 +114,26 @@ export function useKeys(options: {
114
114
  return Array.isArray(v) ? v[0] : v
115
115
  })
116
116
 
117
- const { data: keyData, pending: detailPending, refresh: detailRefresh } = useAsyncData(
118
- `key-${keyId.value ?? 'none'}`,
119
- () => keyId.value ? keyService.getKey(keyId.value) : Promise.resolve(null),
120
- { watch: [keyId] },
121
- )
117
+ const keyData = ref<Awaited<ReturnType<typeof keyService.getKey>> | null>(null)
118
+ const detailPending = ref(false)
119
+
120
+ async function detailRefresh() {
121
+ const id = keyId.value
122
+ if (!id) { keyData.value = null; return }
123
+ detailPending.value = true
124
+ try {
125
+ keyData.value = await keyService.getKey(id)
126
+ }
127
+ catch {
128
+ keyData.value = null
129
+ }
130
+ finally {
131
+ detailPending.value = false
132
+ }
133
+ }
134
+
135
+ onMounted(detailRefresh)
136
+ watch(keyId, (id) => { if (id) detailRefresh() })
122
137
 
123
138
  const savingLang = ref<string | null>(null)
124
139
  async function saveTranslation(langCode: string, value: string): Promise<void> {
@@ -38,11 +38,24 @@ export function useLanguages() {
38
38
 
39
39
  // ── Project languages (API) ──────────────────────────────────────────────
40
40
 
41
- const { data, pending, refresh } = useAsyncData<LanguageItem[]>(
42
- 'project-languages',
43
- () => languageService.getLanguages(currentProject.value?.id),
44
- { watch: [() => currentProject.value?.id], default: () => [] },
45
- )
41
+ const data = ref<LanguageItem[]>([])
42
+ const pending = ref(false)
43
+
44
+ async function refresh() {
45
+ pending.value = true
46
+ try {
47
+ data.value = await languageService.getLanguages(currentProject.value?.id)
48
+ }
49
+ catch {
50
+ data.value = []
51
+ }
52
+ finally {
53
+ pending.value = false
54
+ }
55
+ }
56
+
57
+ onMounted(refresh)
58
+ watch(() => currentProject.value?.id, (id) => { if (id) refresh() })
46
59
 
47
60
  const projectLanguages = computed(() => data.value ?? [])
48
61
 
@@ -12,13 +12,26 @@ export function useProfile(userId?: MaybeRefOrGetter<number | string>) {
12
12
  const targetId = computed(() => userId ? toValue(userId) : null)
13
13
  const period = ref<ProfilePeriod>('all')
14
14
 
15
- const { data: profile, pending, refresh } = useAsyncData(
16
- () => targetId.value ? `user-profile-${targetId.value}-${period.value}` : `user-profile-${period.value}`,
17
- () => targetId.value
18
- ? profileService.getUserProfile(targetId.value, period.value)
19
- : profileService.getProfile(period.value),
20
- { watch: [targetId, period], server: false },
21
- )
15
+ const profile = ref<any>(null)
16
+ const pending = ref(false)
17
+
18
+ async function refresh() {
19
+ pending.value = true
20
+ try {
21
+ profile.value = targetId.value
22
+ ? await profileService.getUserProfile(targetId.value, period.value)
23
+ : await profileService.getProfile(period.value)
24
+ }
25
+ catch {
26
+ profile.value = null
27
+ }
28
+ finally {
29
+ pending.value = false
30
+ }
31
+ }
32
+
33
+ onMounted(refresh)
34
+ watch([targetId, period], refresh)
22
35
 
23
36
  // ── Own account editing (current logged-in user) ─────────────────────────
24
37
 
@@ -5,11 +5,23 @@ export function useSettings() {
5
5
  const toast = useToast()
6
6
  const { t } = useT()
7
7
 
8
- const { data, pending, refresh } = useAsyncData(
9
- 'settings',
10
- () => settingsService.getSettings(),
11
- { default: () => ({} as Record<string, string>) },
12
- )
8
+ const data = ref<Record<string, string>>({})
9
+ const pending = ref(false)
10
+
11
+ async function refresh() {
12
+ pending.value = true
13
+ try {
14
+ data.value = await settingsService.getSettings()
15
+ }
16
+ catch {
17
+ data.value = {}
18
+ }
19
+ finally {
20
+ pending.value = false
21
+ }
22
+ }
23
+
24
+ onMounted(refresh)
13
25
 
14
26
  const settings = computed(() => data.value ?? {})
15
27
 
@@ -11,11 +11,26 @@ export function useUsers(scope: 'project' | 'global' = 'project') {
11
11
  scope === 'global' ? {} : { project_id: currentProject.value?.id },
12
12
  )
13
13
 
14
- const { data, pending, refresh } = useAsyncData(
15
- scope === 'global' ? 'users-global' : 'users',
16
- () => userService.getUsers(usersQuery.value),
17
- { watch: [usersQuery], default: () => [] },
18
- )
14
+ const data = ref<any[]>([])
15
+ const pending = ref(false)
16
+
17
+ async function refresh() {
18
+ pending.value = true
19
+ try {
20
+ data.value = await userService.getUsers(usersQuery.value)
21
+ }
22
+ catch {
23
+ data.value = []
24
+ }
25
+ finally {
26
+ pending.value = false
27
+ }
28
+ }
29
+
30
+ onMounted(refresh)
31
+ watch(usersQuery, (q) => {
32
+ if (scope === 'global' || q.project_id) refresh()
33
+ })
19
34
 
20
35
  const users = computed(() => data.value ?? [])
21
36
 
@@ -9,7 +9,7 @@
9
9
  <div class="min-h-screen bg-gray-50 dark:bg-gray-950 flex">
10
10
 
11
11
  <!-- ── Sidebar ─────────────────────────────────────────────────────────── -->
12
- <aside class="w-56 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
12
+ <aside data-cy="main-sidebar" class="w-56 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
13
13
 
14
14
  <!-- Logo -->
15
15
  <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
@@ -89,6 +89,7 @@
89
89
  <NuxtLink
90
90
  v-for="project in userProjects"
91
91
  :key="project.id"
92
+ :data-cy="'sidebar-project-' + project.id"
92
93
  :to="`/projects/${project.id}`"
93
94
  class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors text-left"
94
95
  :class="'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'"
@@ -115,6 +116,7 @@
115
116
  <div v-if="isSuperAdmin" class="p-2 border-t border-gray-200 dark:border-gray-800">
116
117
  <p class="text-xs text-gray-400 font-medium px-2 mb-1.5 uppercase tracking-wide">{{ t('nav.administration', 'Administration') }}</p>
117
118
  <NuxtLink
119
+ data-cy="sidebar-all-users-link"
118
120
  to="/users"
119
121
  class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors"
120
122
  :class="isActive('/users')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-dashboard",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A web dashboard to manage vue-i18n translation keys with database persistence",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,9 @@
15
15
  "start:detach": "node bin/cli.mjs start --detach",
16
16
  "stop": "node bin/cli.mjs stop",
17
17
  "init": "node bin/cli.mjs init",
18
- "sync": "node bin/cli.mjs sync"
18
+ "sync": "node bin/cli.mjs sync",
19
+ "cy:open": "cypress open",
20
+ "cy:run": "cypress run"
19
21
  },
20
22
  "dependencies": {
21
23
  "@nuxt/ui": "^3.3.7",
@@ -84,6 +86,7 @@
84
86
  "license": "MIT",
85
87
  "devDependencies": {
86
88
  "@iconify-json/heroicons": "^1.2.3",
87
- "@types/node": "^25.3.5"
89
+ "@types/node": "^25.3.5",
90
+ "cypress": "^15.12.0"
88
91
  }
89
92
  }
package/pages/index.vue CHANGED
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="p-6">
2
+ <div data-cy="dashboard-page" class="p-6">
3
3
  <DashboardWidgetGrid />
4
4
  </div>
5
5
  </template>
package/pages/login.vue CHANGED
@@ -6,7 +6,7 @@
6
6
  <UIcon name="i-heroicons-language" class="text-white text-lg" />
7
7
  </div>
8
8
  <div>
9
- <h1 class="text-lg font-bold text-gray-900 dark:text-white">i18n Dashboard</h1>
9
+ <h1 data-cy="login-title" class="text-lg font-bold text-gray-900 dark:text-white">i18n Dashboard</h1>
10
10
  <p class="text-xs text-gray-400">{{ t('login.title', 'Log in') }}</p>
11
11
  </div>
12
12
  </div>
@@ -21,6 +21,7 @@
21
21
  class="w-full"
22
22
  autocomplete="email"
23
23
  autofocus
24
+ data-cy="login-email"
24
25
  />
25
26
  </UFormField>
26
27
 
@@ -31,15 +32,16 @@
31
32
  placeholder="••••••••"
32
33
  class="w-full"
33
34
  autocomplete="current-password"
35
+ data-cy="login-password"
34
36
  />
35
37
  </UFormField>
36
38
 
37
- <p v-if="error" class="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2">
39
+ <p v-if="error" data-cy="login-error" class="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2">
38
40
  <UIcon name="i-heroicons-exclamation-circle" class="inline mr-1" />
39
41
  {{ error }}
40
42
  </p>
41
43
 
42
- <UButton type="submit" block :loading="loading" class="mt-2">
44
+ <UButton type="submit" block :loading="loading" class="mt-2" data-cy="login-submit">
43
45
  {{ t('login.submit', 'Sign in') }}
44
46
  </UButton>
45
47
  </form>
@@ -2,10 +2,10 @@
2
2
  <div class="p-6">
3
3
  <div class="flex items-center justify-between mb-6">
4
4
  <div>
5
- <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('languages.title', 'Languages') }}</h1>
5
+ <h1 data-cy="languages-title" class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('languages.title', 'Languages') }}</h1>
6
6
  <p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">{{ t('languages.subtitle', 'Manage project languages') }}</p>
7
7
  </div>
8
- <UButton icon="i-heroicons-plus" @click="showAdd = true">{{ t('languages.add', 'Add a language') }}</UButton>
8
+ <UButton data-cy="languages-add-btn" icon="i-heroicons-plus" @click="showAdd = true">{{ t('languages.add', 'Add a language') }}</UButton>
9
9
  </div>
10
10
 
11
11
  <!-- Skeleton -->
@@ -28,6 +28,7 @@
28
28
  <UCard
29
29
  v-for="lang in languages"
30
30
  :key="lang.code"
31
+ :data-cy="'language-card-' + lang.code"
31
32
  class="relative"
32
33
  >
33
34
  <div class="flex items-start justify-between">
@@ -42,7 +43,7 @@
42
43
  </div>
43
44
 
44
45
  <div class="flex items-center gap-2">
45
- <UBadge v-if="lang.is_default" color="primary" size="xs">{{ t('languages.default_badge', 'Default') }}</UBadge>
46
+ <UBadge v-if="lang.is_default" :data-cy="'lang-default-badge-' + lang.code" color="primary" size="xs">{{ t('languages.default_badge', 'Default') }}</UBadge>
46
47
  <UDropdownMenu :items="getLangActions(lang)">
47
48
  <UButton color="neutral" icon="i-heroicons-ellipsis-vertical" size="xs" variant="ghost"/>
48
49
  </UDropdownMenu>
@@ -52,7 +53,7 @@
52
53
  <div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
53
54
  <div class="flex items-center justify-between text-sm">
54
55
  <span class="text-gray-500">{{ t('languages.coverage', 'Coverage') }}</span>
55
- <span class="font-medium text-gray-700 dark:text-gray-300">
56
+ <span :data-cy="'lang-coverage-' + lang.code" class="font-medium text-gray-700 dark:text-gray-300">
56
57
  {{ getCoverage(lang.code) }}%
57
58
  </span>
58
59
  </div>
@@ -98,11 +99,12 @@
98
99
  </div>
99
100
 
100
101
  <!-- Add Language modal -->
101
- <UModal v-model:open="showAdd" :title="t('languages.add_modal_title', 'Add a language')">
102
+ <UModal data-cy="add-language-modal" v-model:open="showAdd" :title="t('languages.add_modal_title', 'Add a language')">
102
103
  <template #body>
103
104
  <div class="space-y-4">
104
105
  <UFormField :label="t('languages.language_label', 'Language')" required>
105
106
  <UInput
107
+ data-cy="lang-search-input"
106
108
  v-model="langSearch"
107
109
  :placeholder="t('onboarding.languages_search', 'Search for a language...')"
108
110
  icon="i-heroicons-magnifying-glass"
@@ -164,7 +166,7 @@
164
166
  </template>
165
167
  <template #footer>
166
168
  <div class="flex justify-end gap-3">
167
- <UButton color="neutral" variant="ghost" @click="showAdd = false">{{ t('common.cancel', 'Cancel') }}</UButton>
169
+ <UButton data-cy="add-language-cancel-btn" color="neutral" variant="ghost" @click="showAdd = false">{{ t('common.cancel', 'Cancel') }}</UButton>
168
170
  <UButton :loading="adding" :disabled="!newLang.code" @click="addLanguage">{{ t('languages.add', 'Add') }}</UButton>
169
171
  </div>
170
172
  </template>
@@ -2,7 +2,7 @@
2
2
  <div class="p-6">
3
3
  <div class="flex items-center justify-between mb-6">
4
4
  <div>
5
- <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('review.title', 'Review queue') }}</h1>
5
+ <h1 data-cy="review-title" class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('review.title', 'Review queue') }}</h1>
6
6
  <p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
7
7
  {{ reviewItems.length }} {{ t('review.pending_count', 'translation') }}{{ reviewItems.length > 1 ? t('review.pending_count_plural', 's') : '' }} {{ t('review.pending_label', 'pending review') }}
8
8
  </p>
@@ -23,7 +23,7 @@
23
23
  </div>
24
24
 
25
25
  <!-- Empty -->
26
- <div v-else-if="!reviewItems.length" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 py-16 text-center">
26
+ <div v-else-if="!reviewItems.length" data-cy="review-empty-state" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 py-16 text-center">
27
27
  <UIcon name="i-heroicons-check-badge" class="text-5xl text-green-400 mb-3" />
28
28
  <p class="text-gray-600 dark:text-gray-400 font-medium">{{ t('review.empty_title', 'No translations pending') }}</p>
29
29
  <p class="text-gray-400 text-sm mt-1">{{ t('review.empty_hint', 'All reviewed translations have already been approved.') }}</p>
@@ -34,6 +34,7 @@
34
34
  <div
35
35
  v-for="item in reviewItems"
36
36
  :key="item.id"
37
+ :data-cy="'review-item-' + item.id"
37
38
  class="bg-white dark:bg-gray-900 rounded-xl border border-blue-200 dark:border-blue-800/60 overflow-hidden"
38
39
  >
39
40
  <div class="flex items-start gap-4 p-4">
@@ -70,6 +71,7 @@
70
71
  />
71
72
  </UTooltip>
72
73
  <UButton
74
+ data-cy="mark-reviewed-btn"
73
75
  icon="i-heroicons-check"
74
76
  color="success"
75
77
  size="sm"
@@ -95,7 +97,7 @@ const { t } = useT()
95
97
  const hasAccess = computed(() =>
96
98
  currentProject.value ? canApprove(currentProject.value.id) : false,
97
99
  )
98
- watch(hasAccess, (ok) => { if (!ok) navigateTo('/') }, { immediate: true })
100
+ watch(hasAccess, (ok) => { if (import.meta.client && !ok) navigateTo('/') }, { immediate: true })
99
101
 
100
102
  const {
101
103
  reviewItems,
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div class="p-6">
3
3
  <div class="mb-6">
4
- <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.title', 'Settings') }}</h1>
4
+ <h1 data-cy="settings-title" class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.title', 'Settings') }}</h1>
5
5
  <p class="text-gray-500 dark:text-gray-400 mt-1">{{ t('settings.subtitle', 'Global dashboard configuration') }}</p>
6
6
  </div>
7
7
 
@@ -87,6 +87,7 @@
87
87
  :label="t('settings.scan_exclude', 'Folders excluded from scan')"
88
88
  >
89
89
  <UInput
90
+ data-cy="settings-scan-exclude"
90
91
  v-model="form.scan_exclude"
91
92
  class="w-full"
92
93
  placeholder="node_modules,dist,.nuxt,.output"
@@ -227,6 +228,7 @@
227
228
  <p class="text-xs text-gray-400">{{ t('settings.export_all_languages_hint', 'A single JSON file with all languages') }}</p>
228
229
  </div>
229
230
  <UButton
231
+ data-cy="settings-export-all-btn"
230
232
  color="neutral"
231
233
  icon="i-heroicons-arrow-down-tray"
232
234
  size="sm"
@@ -251,6 +253,7 @@
251
253
  <UBadge v-if="lang.is_default" color="primary" size="xs" variant="soft">{{ t('languages.default_badge', 'Default') }}</UBadge>
252
254
  </div>
253
255
  <UButton
256
+ :data-cy="'export-lang-btn-' + lang.code"
254
257
  color="neutral"
255
258
  icon="i-heroicons-arrow-down-tray"
256
259
  size="xs"
@@ -339,7 +342,7 @@
339
342
 
340
343
  <!-- Save button -->
341
344
  <div class="flex justify-end">
342
- <UButton :loading="saving || savingProject" icon="i-heroicons-check" @click="onSave">
345
+ <UButton data-cy="settings-save-btn" :loading="saving || savingProject" icon="i-heroicons-check" @click="onSave">
343
346
  {{ t('settings.save', 'Save') }}
344
347
  </UButton>
345
348
  </div>
@@ -417,7 +420,8 @@
417
420
  }
418
421
  }, { immediate: true })
419
422
 
420
- const apiAddress = document.location.origin
423
+ const reqUrl = useRequestURL()
424
+ const apiAddress = reqUrl.origin
421
425
 
422
426
  const apiExamples = computed(() => [
423
427
  { label: t('settings.api_example_english', 'English translations'), url: `${apiAddress}/locale/en.json` },
@@ -44,10 +44,10 @@
44
44
 
45
45
  <!-- Header -->
46
46
  <div class="flex items-start gap-3">
47
- <UButton icon="i-heroicons-arrow-left" color="neutral" variant="ghost" size="xs" :to="`/projects/${projectId}/translations`" class="mt-0.5 shrink-0" />
47
+ <UButton data-cy="key-back-link" icon="i-heroicons-arrow-left" color="neutral" variant="ghost" size="xs" :to="`/projects/${projectId}/translations`" class="mt-0.5 shrink-0" />
48
48
  <div class="flex-1 min-w-0">
49
49
  <div class="flex items-center gap-2 flex-wrap">
50
- <h1 class="text-lg font-mono font-bold text-gray-900 dark:text-white break-all">{{ keyData.key }}</h1>
50
+ <h1 data-cy="key-title" class="text-lg font-mono font-bold text-gray-900 dark:text-white break-all">{{ keyData.key }}</h1>
51
51
  <UBadge v-if="keyData.is_unused" color="warning" variant="subtle" size="xs">
52
52
  <UIcon name="i-heroicons-exclamation-triangle" class="mr-1" />
53
53
  {{ t('status.unused', 'Unused') }}
@@ -76,7 +76,7 @@
76
76
  <UBadge v-if="lang.is_default" color="primary" variant="soft" size="xs">{{ t('languages.default_badge', 'Default') }}</UBadge>
77
77
  </div>
78
78
  <div class="flex items-center gap-1">
79
- <UBadge :color="statusColor(lang.code)" variant="soft" size="xs">{{ statusLabel(lang.code) }}</UBadge>
79
+ <UBadge :data-cy="'translation-status-' + lang.code" :color="statusColor(lang.code)" variant="soft" size="xs">{{ statusLabel(lang.code) }}</UBadge>
80
80
  <UTooltip :text="sourceText ? `${t('translations.translate_to', 'Translate to')} ${findLanguage(lang.code)?.nativeName || lang.name}` : t('translations.no_source', 'No source available')">
81
81
  <UButton
82
82
  icon="i-heroicons-sparkles"
@@ -111,6 +111,7 @@
111
111
  <!-- Single textarea -->
112
112
  <div v-else :ref="el => activeTextareaWrapper = el as HTMLElement">
113
113
  <UTextarea
114
+ :data-cy="'translation-textarea-' + lang.code"
114
115
  v-model="editValue"
115
116
  :rows="3"
116
117
  autofocus
@@ -167,10 +168,10 @@
167
168
 
168
169
  <!-- Actions row -->
169
170
  <div class="flex items-center gap-2 mt-2 flex-wrap">
170
- <UButton size="xs" :loading="saving === lang.code" @click="saveTranslation(lang.code)">
171
+ <UButton :data-cy="'save-translation-btn-' + lang.code" size="xs" :loading="saving === lang.code" @click="saveTranslation(lang.code)">
171
172
  {{ t('translations.save', 'Save') }}
172
173
  </UButton>
173
- <UButton size="xs" color="neutral" variant="ghost" @click="editingLang = null">
174
+ <UButton :data-cy="'cancel-translation-btn-' + lang.code" size="xs" color="neutral" variant="ghost" @click="editingLang = null">
174
175
  {{ t('translations.cancel', 'Cancel') }}
175
176
  </UButton>
176
177
  <div class="ml-auto flex items-center gap-1.5">
@@ -194,6 +195,7 @@
194
195
  <!-- Plural display -->
195
196
  <div
196
197
  v-if="getTranslationValue(lang.code) && getPluralForms(lang.code).length > 1"
198
+ :data-cy="'translation-value-' + lang.code"
197
199
  class="cursor-pointer group"
198
200
  @click="startEdit(lang)"
199
201
  >
@@ -216,11 +218,13 @@
216
218
  <!-- Single value display -->
217
219
  <p
218
220
  v-else-if="getTranslationValue(lang.code)"
221
+ :data-cy="'translation-value-' + lang.code"
219
222
  class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 whitespace-pre-wrap px-2 py-1.5 rounded hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors"
220
223
  @click="startEdit(lang)"
221
224
  >{{ getTranslationValue(lang.code) }}</p>
222
225
  <button
223
226
  v-else
227
+ :data-cy="'translation-value-' + lang.code"
224
228
  class="text-sm text-gray-400 italic hover:text-primary-500 transition-colors px-2 py-1.5"
225
229
  @click="startEdit(lang)"
226
230
  >
@@ -294,7 +298,7 @@
294
298
  </div>
295
299
  </template>
296
300
  <template v-else>
297
- <p v-if="keyData.description" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer hover:text-primary-500 transition-colors" @click="startEditDescription">
301
+ <p data-cy="key-description" v-if="keyData.description" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer hover:text-primary-500 transition-colors" @click="startEditDescription">
298
302
  {{ keyData.description }}
299
303
  </p>
300
304
  <button v-else class="text-sm text-gray-400 italic hover:text-primary-500 transition-colors" @click="startEditDescription">
@@ -329,12 +333,12 @@
329
333
  <template #header>
330
334
  <div class="flex items-center gap-2">
331
335
  <UIcon name="i-heroicons-code-bracket" class="text-gray-400 shrink-0" />
332
- <p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">
336
+ <p data-cy="history-section" class="text-xs font-semibold text-gray-400 uppercase tracking-wide">
333
337
  {{ keyData.usages.length }} {{ keyData.usages.length > 1 ? t('translations.references_plural', 'references') : t('translations.references', 'reference') }}
334
338
  </p>
335
339
  </div>
336
340
  </template>
337
- <div class="space-y-3">
341
+ <div data-cy="key-usages" class="space-y-3">
338
342
  <div v-for="(usage, i) in keyData.usages" :key="i" class="text-xs">
339
343
  <p class="font-mono text-gray-600 dark:text-gray-400 truncate" :title="usage.file_path">{{ usage.file_path }}</p>
340
344
  <div class="flex items-center gap-2 text-gray-400 mt-0.5">
@@ -3,7 +3,7 @@
3
3
  <!-- Header -->
4
4
  <div class="flex items-center justify-between mb-5">
5
5
  <div>
6
- <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('translations.title', 'Translations') }}</h1>
6
+ <h1 data-cy="translations-title" class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('translations.title', 'Translations') }}</h1>
7
7
  <p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
8
8
  {{ data?.total || 0 }} {{ t('translations.keys_count', 'keys') }} · {{ languages.length }} {{ t('translations.langs_count', 'languages') }}
9
9
  </p>
@@ -19,7 +19,7 @@
19
19
  >
20
20
  {{ t('translations.translate_all', 'Translate all') }} ({{ filterLangs[0].toUpperCase() }})
21
21
  </UButton>
22
- <UButton v-if="userCanManage" icon="i-heroicons-plus" @click="showAddKey = true">{{ t('translations.add_key', 'New key') }}</UButton>
22
+ <UButton v-if="userCanManage" data-cy="new-key-btn" icon="i-heroicons-plus" @click="showAddKey = true">{{ t('translations.add_key', 'New key') }}</UButton>
23
23
  </div>
24
24
  </div>
25
25
 
@@ -27,6 +27,7 @@
27
27
  <div class="flex flex-col sm:flex-row gap-3 mb-5">
28
28
  <UInput
29
29
  v-model="search"
30
+ data-cy="translations-search"
30
31
  icon="i-heroicons-magnifying-glass"
31
32
  :placeholder="t('translations.search', 'Search for a key...')"
32
33
  class="flex-1"
@@ -48,6 +49,7 @@
48
49
  <button
49
50
  v-for="s in statusFilters"
50
51
  :key="s.value"
52
+ :data-cy="'filter-' + s.value"
51
53
  class="flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all"
52
54
  :class="filterStatus === s.value
53
55
  ? `${s.activeBg} ${s.activeText} border-transparent`
@@ -129,7 +131,7 @@
129
131
  <!-- Add Key modal -->
130
132
  <UModal v-model:open="showAddKey" :title="t('translations.add_key_title', 'New translation key')">
131
133
  <template #body>
132
- <div class="space-y-4">
134
+ <div data-cy="add-key-modal" class="space-y-4">
133
135
  <UFormField :label="t('translations.key_label', 'Key')" :hint="t('translations.key_hint', 'Example: home.title or nav.menu.about')" required>
134
136
  <UInput v-model="newKey.key" placeholder="home.title" class="w-full font-mono" />
135
137
  </UFormField>
@@ -140,8 +142,8 @@
140
142
  </template>
141
143
  <template #footer>
142
144
  <div class="flex justify-end gap-3">
143
- <UButton variant="ghost" color="neutral" @click="showAddKey = false">{{ t('common.cancel', 'Cancel') }}</UButton>
144
- <UButton :loading="addingKey" @click="addKey">{{ t('common.create', 'Create') }}</UButton>
145
+ <UButton data-cy="add-key-cancel-btn" variant="ghost" color="neutral" @click="showAddKey = false">{{ t('common.cancel', 'Cancel') }}</UButton>
146
+ <UButton data-cy="add-key-create-btn" :loading="addingKey" @click="addKey">{{ t('common.create', 'Create') }}</UButton>
145
147
  </div>
146
148
  </template>
147
149
  </UModal>