omgkit 2.1.0 → 2.2.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/package.json +1 -1
- package/plugin/skills/SKILL_STANDARDS.md +743 -0
- package/plugin/skills/databases/mongodb/SKILL.md +797 -28
- package/plugin/skills/databases/postgresql/SKILL.md +494 -18
- package/plugin/skills/databases/prisma/SKILL.md +776 -30
- package/plugin/skills/databases/redis/SKILL.md +885 -25
- package/plugin/skills/devops/aws/SKILL.md +686 -28
- package/plugin/skills/devops/docker/SKILL.md +466 -18
- package/plugin/skills/devops/github-actions/SKILL.md +684 -29
- package/plugin/skills/devops/kubernetes/SKILL.md +621 -24
- package/plugin/skills/frameworks/django/SKILL.md +920 -20
- package/plugin/skills/frameworks/express/SKILL.md +1361 -35
- package/plugin/skills/frameworks/fastapi/SKILL.md +1260 -33
- package/plugin/skills/frameworks/laravel/SKILL.md +1244 -31
- package/plugin/skills/frameworks/nestjs/SKILL.md +1005 -26
- package/plugin/skills/frameworks/nextjs/SKILL.md +407 -44
- package/plugin/skills/frameworks/rails/SKILL.md +594 -28
- package/plugin/skills/frameworks/react/SKILL.md +1006 -32
- package/plugin/skills/frameworks/spring/SKILL.md +528 -35
- package/plugin/skills/frameworks/vue/SKILL.md +1296 -27
- package/plugin/skills/frontend/accessibility/SKILL.md +1108 -34
- package/plugin/skills/frontend/frontend-design/SKILL.md +1304 -26
- package/plugin/skills/frontend/responsive/SKILL.md +847 -21
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +976 -38
- package/plugin/skills/frontend/tailwindcss/SKILL.md +831 -35
- package/plugin/skills/frontend/threejs/SKILL.md +1298 -29
- package/plugin/skills/languages/javascript/SKILL.md +935 -31
- package/plugin/skills/languages/python/SKILL.md +489 -25
- package/plugin/skills/languages/typescript/SKILL.md +379 -30
- package/plugin/skills/methodology/brainstorming/SKILL.md +597 -23
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +832 -34
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +665 -31
- package/plugin/skills/methodology/executing-plans/SKILL.md +556 -24
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +595 -25
- package/plugin/skills/methodology/problem-solving/SKILL.md +429 -61
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +536 -24
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +632 -21
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +641 -30
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +262 -3
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +571 -32
- package/plugin/skills/methodology/test-driven-development/SKILL.md +779 -24
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +691 -29
- package/plugin/skills/methodology/token-optimization/SKILL.md +598 -29
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +543 -22
- package/plugin/skills/methodology/writing-plans/SKILL.md +590 -18
- package/plugin/skills/omega/omega-architecture/SKILL.md +838 -39
- package/plugin/skills/omega/omega-coding/SKILL.md +636 -39
- package/plugin/skills/omega/omega-sprint/SKILL.md +855 -48
- package/plugin/skills/omega/omega-testing/SKILL.md +940 -41
- package/plugin/skills/omega/omega-thinking/SKILL.md +703 -50
- package/plugin/skills/security/better-auth/SKILL.md +1065 -28
- package/plugin/skills/security/oauth/SKILL.md +968 -31
- package/plugin/skills/security/owasp/SKILL.md +894 -33
- package/plugin/skills/testing/playwright/SKILL.md +764 -38
- package/plugin/skills/testing/pytest/SKILL.md +873 -36
- package/plugin/skills/testing/vitest/SKILL.md +980 -35
|
@@ -1,62 +1,1331 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vue
|
|
3
|
-
description: Vue
|
|
3
|
+
description: Modern Vue 3 development with Composition API, TypeScript, Pinia, Vue Router, and testing patterns
|
|
4
|
+
category: frameworks
|
|
5
|
+
triggers:
|
|
6
|
+
- vue
|
|
7
|
+
- vue 3
|
|
8
|
+
- vue.js
|
|
9
|
+
- vuejs
|
|
10
|
+
- composition api
|
|
11
|
+
- pinia
|
|
12
|
+
- vue router
|
|
13
|
+
- nuxt
|
|
14
|
+
- vite vue
|
|
4
15
|
---
|
|
5
16
|
|
|
6
|
-
# Vue.js
|
|
17
|
+
# Vue.js
|
|
7
18
|
|
|
8
|
-
|
|
19
|
+
Modern **Vue 3 development** following industry best practices. This skill covers Composition API, TypeScript integration, Pinia state management, Vue Router, component patterns, testing with Vitest, and production-ready configurations used by top engineering teams.
|
|
20
|
+
|
|
21
|
+
## Purpose
|
|
22
|
+
|
|
23
|
+
Build reactive, maintainable Vue applications with confidence:
|
|
24
|
+
|
|
25
|
+
- Master Composition API and `<script setup>` syntax
|
|
26
|
+
- Implement type-safe components with TypeScript
|
|
27
|
+
- Manage state effectively with Pinia stores
|
|
28
|
+
- Handle routing with Vue Router
|
|
29
|
+
- Create reusable composables
|
|
30
|
+
- Write comprehensive tests with Vitest
|
|
31
|
+
- Build performant applications
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
### 1. Component Architecture with TypeScript
|
|
9
36
|
|
|
10
|
-
### Component
|
|
11
37
|
```vue
|
|
38
|
+
<!-- src/components/UserCard.vue -->
|
|
12
39
|
<script setup lang="ts">
|
|
13
|
-
import { ref,
|
|
40
|
+
import { computed, ref, watch, onMounted } from 'vue';
|
|
41
|
+
import type { User, UserRole } from '@/types';
|
|
14
42
|
|
|
15
|
-
|
|
16
|
-
|
|
43
|
+
// Props with TypeScript
|
|
44
|
+
interface Props {
|
|
45
|
+
user: User;
|
|
46
|
+
showActions?: boolean;
|
|
47
|
+
variant?: 'default' | 'compact' | 'detailed';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
51
|
+
showActions: true,
|
|
52
|
+
variant: 'default',
|
|
53
|
+
});
|
|
17
54
|
|
|
18
|
-
|
|
19
|
-
|
|
55
|
+
// Emits with TypeScript
|
|
56
|
+
interface Emits {
|
|
57
|
+
(e: 'edit', user: User): void;
|
|
58
|
+
(e: 'delete', userId: string): void;
|
|
59
|
+
(e: 'select', user: User, selected: boolean): void;
|
|
20
60
|
}
|
|
61
|
+
|
|
62
|
+
const emit = defineEmits<Emits>();
|
|
63
|
+
|
|
64
|
+
// Expose methods to parent
|
|
65
|
+
defineExpose({
|
|
66
|
+
focus: () => cardRef.value?.focus(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Refs
|
|
70
|
+
const cardRef = ref<HTMLDivElement | null>(null);
|
|
71
|
+
const isSelected = ref(false);
|
|
72
|
+
const isLoading = ref(false);
|
|
73
|
+
|
|
74
|
+
// Computed
|
|
75
|
+
const fullName = computed(() =>
|
|
76
|
+
`${props.user.firstName} ${props.user.lastName}`
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const roleLabel = computed(() => {
|
|
80
|
+
const labels: Record<UserRole, string> = {
|
|
81
|
+
admin: 'Administrator',
|
|
82
|
+
user: 'User',
|
|
83
|
+
guest: 'Guest',
|
|
84
|
+
};
|
|
85
|
+
return labels[props.user.role];
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const cardClasses = computed(() => ({
|
|
89
|
+
'user-card': true,
|
|
90
|
+
[`user-card--${props.variant}`]: true,
|
|
91
|
+
'user-card--selected': isSelected.value,
|
|
92
|
+
'user-card--loading': isLoading.value,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
// Watchers
|
|
96
|
+
watch(() => props.user.id, (newId, oldId) => {
|
|
97
|
+
if (newId !== oldId) {
|
|
98
|
+
isSelected.value = false;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Methods
|
|
103
|
+
function handleEdit() {
|
|
104
|
+
emit('edit', props.user);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function handleDelete() {
|
|
108
|
+
emit('delete', props.user.id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toggleSelect() {
|
|
112
|
+
isSelected.value = !isSelected.value;
|
|
113
|
+
emit('select', props.user, isSelected.value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Lifecycle
|
|
117
|
+
onMounted(() => {
|
|
118
|
+
console.log('UserCard mounted for:', props.user.id);
|
|
119
|
+
});
|
|
21
120
|
</script>
|
|
22
121
|
|
|
23
122
|
<template>
|
|
24
|
-
<
|
|
123
|
+
<div
|
|
124
|
+
ref="cardRef"
|
|
125
|
+
:class="cardClasses"
|
|
126
|
+
tabindex="0"
|
|
127
|
+
@click="toggleSelect"
|
|
128
|
+
>
|
|
129
|
+
<div class="user-card__avatar">
|
|
130
|
+
<img
|
|
131
|
+
:src="user.avatarUrl || '/default-avatar.png'"
|
|
132
|
+
:alt="fullName"
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="user-card__content">
|
|
137
|
+
<h3 class="user-card__name">{{ fullName }}</h3>
|
|
138
|
+
<p class="user-card__email">{{ user.email }}</p>
|
|
139
|
+
<span class="user-card__role" :class="`role--${user.role}`">
|
|
140
|
+
{{ roleLabel }}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div v-if="showActions" class="user-card__actions">
|
|
145
|
+
<button @click.stop="handleEdit">Edit</button>
|
|
146
|
+
<button @click.stop="handleDelete">Delete</button>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<slot name="footer" :user="user" :selected="isSelected" />
|
|
150
|
+
</div>
|
|
25
151
|
</template>
|
|
152
|
+
|
|
153
|
+
<style scoped>
|
|
154
|
+
.user-card {
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 1rem;
|
|
158
|
+
padding: 1rem;
|
|
159
|
+
border: 1px solid var(--border-color);
|
|
160
|
+
border-radius: 8px;
|
|
161
|
+
transition: all 0.2s ease;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.user-card--selected {
|
|
165
|
+
border-color: var(--primary-color);
|
|
166
|
+
background: var(--primary-bg);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.user-card--compact {
|
|
170
|
+
padding: 0.5rem;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.user-card--loading {
|
|
174
|
+
opacity: 0.6;
|
|
175
|
+
pointer-events: none;
|
|
176
|
+
}
|
|
177
|
+
</style>
|
|
26
178
|
```
|
|
27
179
|
|
|
28
|
-
###
|
|
180
|
+
### 2. Composables (Reusable Logic)
|
|
181
|
+
|
|
29
182
|
```typescript
|
|
30
|
-
// composables/useUser.ts
|
|
31
|
-
|
|
183
|
+
// src/composables/useUser.ts
|
|
184
|
+
import { ref, computed, watch, type Ref } from 'vue';
|
|
185
|
+
import type { User, UpdateUserData } from '@/types';
|
|
186
|
+
import { userService } from '@/services/user.service';
|
|
187
|
+
|
|
188
|
+
interface UseUserOptions {
|
|
189
|
+
immediate?: boolean;
|
|
190
|
+
onError?: (error: Error) => void;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function useUser(userId: Ref<string | null>, options: UseUserOptions = {}) {
|
|
194
|
+
const { immediate = true, onError } = options;
|
|
195
|
+
|
|
32
196
|
const user = ref<User | null>(null);
|
|
33
|
-
const loading = ref(
|
|
197
|
+
const loading = ref(false);
|
|
198
|
+
const error = ref<Error | null>(null);
|
|
199
|
+
|
|
200
|
+
const isLoaded = computed(() => !!user.value);
|
|
201
|
+
const fullName = computed(() =>
|
|
202
|
+
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
async function fetchUser() {
|
|
206
|
+
if (!userId.value) {
|
|
207
|
+
user.value = null;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
34
210
|
|
|
35
|
-
watch(id, async (newId) => {
|
|
36
211
|
loading.value = true;
|
|
37
|
-
|
|
38
|
-
loading.value = false;
|
|
39
|
-
}, { immediate: true });
|
|
212
|
+
error.value = null;
|
|
40
213
|
|
|
41
|
-
|
|
214
|
+
try {
|
|
215
|
+
user.value = await userService.getById(userId.value);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
error.value = e as Error;
|
|
218
|
+
onError?.(e as Error);
|
|
219
|
+
} finally {
|
|
220
|
+
loading.value = false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function updateUser(data: UpdateUserData) {
|
|
225
|
+
if (!userId.value || !user.value) return;
|
|
226
|
+
|
|
227
|
+
loading.value = true;
|
|
228
|
+
try {
|
|
229
|
+
user.value = await userService.update(userId.value, data);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
error.value = e as Error;
|
|
232
|
+
throw e;
|
|
233
|
+
} finally {
|
|
234
|
+
loading.value = false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
watch(userId, fetchUser, { immediate });
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
user,
|
|
242
|
+
loading,
|
|
243
|
+
error,
|
|
244
|
+
isLoaded,
|
|
245
|
+
fullName,
|
|
246
|
+
fetchUser,
|
|
247
|
+
updateUser,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
// src/composables/usePagination.ts
|
|
253
|
+
import { ref, computed, watch } from 'vue';
|
|
254
|
+
|
|
255
|
+
interface UsePaginationOptions {
|
|
256
|
+
initialPage?: number;
|
|
257
|
+
initialLimit?: number;
|
|
258
|
+
total?: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function usePagination(options: UsePaginationOptions = {}) {
|
|
262
|
+
const page = ref(options.initialPage ?? 1);
|
|
263
|
+
const limit = ref(options.initialLimit ?? 20);
|
|
264
|
+
const total = ref(options.total ?? 0);
|
|
265
|
+
|
|
266
|
+
const totalPages = computed(() =>
|
|
267
|
+
Math.ceil(total.value / limit.value) || 1
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const hasNextPage = computed(() => page.value < totalPages.value);
|
|
271
|
+
const hasPrevPage = computed(() => page.value > 1);
|
|
272
|
+
|
|
273
|
+
const offset = computed(() => (page.value - 1) * limit.value);
|
|
274
|
+
|
|
275
|
+
function nextPage() {
|
|
276
|
+
if (hasNextPage.value) page.value++;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function prevPage() {
|
|
280
|
+
if (hasPrevPage.value) page.value--;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function goToPage(pageNum: number) {
|
|
284
|
+
if (pageNum >= 1 && pageNum <= totalPages.value) {
|
|
285
|
+
page.value = pageNum;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function setTotal(newTotal: number) {
|
|
290
|
+
total.value = newTotal;
|
|
291
|
+
if (page.value > totalPages.value) {
|
|
292
|
+
page.value = totalPages.value || 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
page,
|
|
298
|
+
limit,
|
|
299
|
+
total,
|
|
300
|
+
totalPages,
|
|
301
|
+
hasNextPage,
|
|
302
|
+
hasPrevPage,
|
|
303
|
+
offset,
|
|
304
|
+
nextPage,
|
|
305
|
+
prevPage,
|
|
306
|
+
goToPage,
|
|
307
|
+
setTotal,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
// src/composables/useAsync.ts
|
|
313
|
+
import { ref, shallowRef, type Ref } from 'vue';
|
|
314
|
+
|
|
315
|
+
interface UseAsyncOptions<T> {
|
|
316
|
+
immediate?: boolean;
|
|
317
|
+
initialData?: T;
|
|
318
|
+
onSuccess?: (data: T) => void;
|
|
319
|
+
onError?: (error: Error) => void;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function useAsync<T, P extends unknown[] = []>(
|
|
323
|
+
asyncFn: (...args: P) => Promise<T>,
|
|
324
|
+
options: UseAsyncOptions<T> = {}
|
|
325
|
+
) {
|
|
326
|
+
const { initialData, onSuccess, onError } = options;
|
|
327
|
+
|
|
328
|
+
const data = shallowRef<T | undefined>(initialData) as Ref<T | undefined>;
|
|
329
|
+
const loading = ref(false);
|
|
330
|
+
const error = ref<Error | null>(null);
|
|
331
|
+
|
|
332
|
+
async function execute(...args: P): Promise<T | undefined> {
|
|
333
|
+
loading.value = true;
|
|
334
|
+
error.value = null;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const result = await asyncFn(...args);
|
|
338
|
+
data.value = result;
|
|
339
|
+
onSuccess?.(result);
|
|
340
|
+
return result;
|
|
341
|
+
} catch (e) {
|
|
342
|
+
error.value = e as Error;
|
|
343
|
+
onError?.(e as Error);
|
|
344
|
+
return undefined;
|
|
345
|
+
} finally {
|
|
346
|
+
loading.value = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
data,
|
|
352
|
+
loading,
|
|
353
|
+
error,
|
|
354
|
+
execute,
|
|
355
|
+
};
|
|
42
356
|
}
|
|
43
357
|
```
|
|
44
358
|
|
|
45
|
-
### Pinia
|
|
359
|
+
### 3. Pinia State Management
|
|
360
|
+
|
|
46
361
|
```typescript
|
|
362
|
+
// src/stores/user.store.ts
|
|
363
|
+
import { defineStore } from 'pinia';
|
|
364
|
+
import { ref, computed } from 'vue';
|
|
365
|
+
import type { User, LoginCredentials, RegisterData } from '@/types';
|
|
366
|
+
import { authService } from '@/services/auth.service';
|
|
367
|
+
import { useStorage } from '@vueuse/core';
|
|
368
|
+
|
|
47
369
|
export const useUserStore = defineStore('user', () => {
|
|
370
|
+
// State
|
|
48
371
|
const user = ref<User | null>(null);
|
|
372
|
+
const token = useStorage<string | null>('auth_token', null);
|
|
373
|
+
const loading = ref(false);
|
|
374
|
+
const error = ref<string | null>(null);
|
|
375
|
+
|
|
376
|
+
// Getters
|
|
377
|
+
const isAuthenticated = computed(() => !!token.value && !!user.value);
|
|
378
|
+
const isAdmin = computed(() => user.value?.role === 'admin');
|
|
379
|
+
const fullName = computed(() =>
|
|
380
|
+
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
|
|
381
|
+
);
|
|
49
382
|
|
|
50
|
-
|
|
51
|
-
|
|
383
|
+
// Actions
|
|
384
|
+
async function login(credentials: LoginCredentials) {
|
|
385
|
+
loading.value = true;
|
|
386
|
+
error.value = null;
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const response = await authService.login(credentials);
|
|
390
|
+
token.value = response.token;
|
|
391
|
+
user.value = response.user;
|
|
392
|
+
} catch (e) {
|
|
393
|
+
error.value = (e as Error).message;
|
|
394
|
+
throw e;
|
|
395
|
+
} finally {
|
|
396
|
+
loading.value = false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function register(data: RegisterData) {
|
|
401
|
+
loading.value = true;
|
|
402
|
+
error.value = null;
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const response = await authService.register(data);
|
|
406
|
+
token.value = response.token;
|
|
407
|
+
user.value = response.user;
|
|
408
|
+
} catch (e) {
|
|
409
|
+
error.value = (e as Error).message;
|
|
410
|
+
throw e;
|
|
411
|
+
} finally {
|
|
412
|
+
loading.value = false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function fetchCurrentUser() {
|
|
417
|
+
if (!token.value) return;
|
|
418
|
+
|
|
419
|
+
loading.value = true;
|
|
420
|
+
try {
|
|
421
|
+
user.value = await authService.getCurrentUser();
|
|
422
|
+
} catch (e) {
|
|
423
|
+
logout();
|
|
424
|
+
} finally {
|
|
425
|
+
loading.value = false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function logout() {
|
|
430
|
+
user.value = null;
|
|
431
|
+
token.value = null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function $reset() {
|
|
435
|
+
user.value = null;
|
|
436
|
+
token.value = null;
|
|
437
|
+
loading.value = false;
|
|
438
|
+
error.value = null;
|
|
52
439
|
}
|
|
53
440
|
|
|
54
|
-
return {
|
|
441
|
+
return {
|
|
442
|
+
// State
|
|
443
|
+
user,
|
|
444
|
+
token,
|
|
445
|
+
loading,
|
|
446
|
+
error,
|
|
447
|
+
// Getters
|
|
448
|
+
isAuthenticated,
|
|
449
|
+
isAdmin,
|
|
450
|
+
fullName,
|
|
451
|
+
// Actions
|
|
452
|
+
login,
|
|
453
|
+
register,
|
|
454
|
+
fetchCurrentUser,
|
|
455
|
+
logout,
|
|
456
|
+
$reset,
|
|
457
|
+
};
|
|
55
458
|
});
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
// src/stores/notification.store.ts
|
|
462
|
+
import { defineStore } from 'pinia';
|
|
463
|
+
import { ref } from 'vue';
|
|
464
|
+
|
|
465
|
+
interface Notification {
|
|
466
|
+
id: string;
|
|
467
|
+
type: 'success' | 'error' | 'warning' | 'info';
|
|
468
|
+
message: string;
|
|
469
|
+
duration?: number;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export const useNotificationStore = defineStore('notification', () => {
|
|
473
|
+
const notifications = ref<Notification[]>([]);
|
|
474
|
+
|
|
475
|
+
function add(notification: Omit<Notification, 'id'>) {
|
|
476
|
+
const id = crypto.randomUUID();
|
|
477
|
+
const newNotification: Notification = {
|
|
478
|
+
...notification,
|
|
479
|
+
id,
|
|
480
|
+
duration: notification.duration ?? 5000,
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
notifications.value.push(newNotification);
|
|
484
|
+
|
|
485
|
+
if (newNotification.duration > 0) {
|
|
486
|
+
setTimeout(() => remove(id), newNotification.duration);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return id;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function remove(id: string) {
|
|
493
|
+
const index = notifications.value.findIndex(n => n.id === id);
|
|
494
|
+
if (index !== -1) {
|
|
495
|
+
notifications.value.splice(index, 1);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function success(message: string) {
|
|
500
|
+
return add({ type: 'success', message });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function error(message: string) {
|
|
504
|
+
return add({ type: 'error', message });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function warning(message: string) {
|
|
508
|
+
return add({ type: 'warning', message });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function info(message: string) {
|
|
512
|
+
return add({ type: 'info', message });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
notifications,
|
|
517
|
+
add,
|
|
518
|
+
remove,
|
|
519
|
+
success,
|
|
520
|
+
error,
|
|
521
|
+
warning,
|
|
522
|
+
info,
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### 4. Vue Router Configuration
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
// src/router/index.ts
|
|
531
|
+
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
|
532
|
+
import { useUserStore } from '@/stores/user.store';
|
|
533
|
+
|
|
534
|
+
const routes: RouteRecordRaw[] = [
|
|
535
|
+
{
|
|
536
|
+
path: '/',
|
|
537
|
+
component: () => import('@/layouts/DefaultLayout.vue'),
|
|
538
|
+
children: [
|
|
539
|
+
{
|
|
540
|
+
path: '',
|
|
541
|
+
name: 'home',
|
|
542
|
+
component: () => import('@/views/HomeView.vue'),
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
path: 'dashboard',
|
|
546
|
+
name: 'dashboard',
|
|
547
|
+
component: () => import('@/views/DashboardView.vue'),
|
|
548
|
+
meta: { requiresAuth: true },
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
path: 'users',
|
|
552
|
+
name: 'users',
|
|
553
|
+
component: () => import('@/views/UsersView.vue'),
|
|
554
|
+
meta: { requiresAuth: true, roles: ['admin'] },
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
path: 'users/:id',
|
|
558
|
+
name: 'user-detail',
|
|
559
|
+
component: () => import('@/views/UserDetailView.vue'),
|
|
560
|
+
props: true,
|
|
561
|
+
meta: { requiresAuth: true },
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
path: '/auth',
|
|
567
|
+
component: () => import('@/layouts/AuthLayout.vue'),
|
|
568
|
+
children: [
|
|
569
|
+
{
|
|
570
|
+
path: 'login',
|
|
571
|
+
name: 'login',
|
|
572
|
+
component: () => import('@/views/auth/LoginView.vue'),
|
|
573
|
+
meta: { guestOnly: true },
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
path: 'register',
|
|
577
|
+
name: 'register',
|
|
578
|
+
component: () => import('@/views/auth/RegisterView.vue'),
|
|
579
|
+
meta: { guestOnly: true },
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
path: '/:pathMatch(.*)*',
|
|
585
|
+
name: 'not-found',
|
|
586
|
+
component: () => import('@/views/NotFoundView.vue'),
|
|
587
|
+
},
|
|
588
|
+
];
|
|
589
|
+
|
|
590
|
+
const router = createRouter({
|
|
591
|
+
history: createWebHistory(import.meta.env.BASE_URL),
|
|
592
|
+
routes,
|
|
593
|
+
scrollBehavior(to, from, savedPosition) {
|
|
594
|
+
if (savedPosition) return savedPosition;
|
|
595
|
+
if (to.hash) return { el: to.hash, behavior: 'smooth' };
|
|
596
|
+
return { top: 0 };
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// Navigation Guards
|
|
601
|
+
router.beforeEach(async (to, from, next) => {
|
|
602
|
+
const userStore = useUserStore();
|
|
603
|
+
|
|
604
|
+
// Initialize user if token exists but user is not loaded
|
|
605
|
+
if (userStore.token && !userStore.user) {
|
|
606
|
+
await userStore.fetchCurrentUser();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Check authentication
|
|
610
|
+
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
|
|
611
|
+
return next({ name: 'login', query: { redirect: to.fullPath } });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Check guest-only routes
|
|
615
|
+
if (to.meta.guestOnly && userStore.isAuthenticated) {
|
|
616
|
+
return next({ name: 'dashboard' });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Check role-based access
|
|
620
|
+
const requiredRoles = to.meta.roles as string[] | undefined;
|
|
621
|
+
if (requiredRoles && userStore.user) {
|
|
622
|
+
if (!requiredRoles.includes(userStore.user.role)) {
|
|
623
|
+
return next({ name: 'dashboard' });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
next();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
export default router;
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### 5. Form Handling with Validation
|
|
634
|
+
|
|
635
|
+
```vue
|
|
636
|
+
<!-- src/views/auth/RegisterView.vue -->
|
|
637
|
+
<script setup lang="ts">
|
|
638
|
+
import { reactive, ref } from 'vue';
|
|
639
|
+
import { useRouter } from 'vue-router';
|
|
640
|
+
import { useUserStore } from '@/stores/user.store';
|
|
641
|
+
import { useNotificationStore } from '@/stores/notification.store';
|
|
642
|
+
|
|
643
|
+
interface FormState {
|
|
644
|
+
email: string;
|
|
645
|
+
password: string;
|
|
646
|
+
confirmPassword: string;
|
|
647
|
+
firstName: string;
|
|
648
|
+
lastName: string;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
interface FormErrors {
|
|
652
|
+
email?: string;
|
|
653
|
+
password?: string;
|
|
654
|
+
confirmPassword?: string;
|
|
655
|
+
firstName?: string;
|
|
656
|
+
lastName?: string;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const router = useRouter();
|
|
660
|
+
const userStore = useUserStore();
|
|
661
|
+
const notifications = useNotificationStore();
|
|
662
|
+
|
|
663
|
+
const form = reactive<FormState>({
|
|
664
|
+
email: '',
|
|
665
|
+
password: '',
|
|
666
|
+
confirmPassword: '',
|
|
667
|
+
firstName: '',
|
|
668
|
+
lastName: '',
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const errors = reactive<FormErrors>({});
|
|
672
|
+
const submitting = ref(false);
|
|
673
|
+
|
|
674
|
+
function validateEmail(email: string): string | undefined {
|
|
675
|
+
if (!email) return 'Email is required';
|
|
676
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Invalid email format';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function validatePassword(password: string): string | undefined {
|
|
680
|
+
if (!password) return 'Password is required';
|
|
681
|
+
if (password.length < 8) return 'Password must be at least 8 characters';
|
|
682
|
+
if (!/[A-Z]/.test(password)) return 'Password must contain uppercase letter';
|
|
683
|
+
if (!/[0-9]/.test(password)) return 'Password must contain a number';
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function validateForm(): boolean {
|
|
687
|
+
errors.email = validateEmail(form.email);
|
|
688
|
+
errors.password = validatePassword(form.password);
|
|
689
|
+
errors.confirmPassword = form.password !== form.confirmPassword
|
|
690
|
+
? 'Passwords do not match'
|
|
691
|
+
: undefined;
|
|
692
|
+
errors.firstName = !form.firstName ? 'First name is required' : undefined;
|
|
693
|
+
errors.lastName = !form.lastName ? 'Last name is required' : undefined;
|
|
694
|
+
|
|
695
|
+
return !Object.values(errors).some(Boolean);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function handleSubmit() {
|
|
699
|
+
if (!validateForm()) return;
|
|
700
|
+
|
|
701
|
+
submitting.value = true;
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
await userStore.register({
|
|
705
|
+
email: form.email,
|
|
706
|
+
password: form.password,
|
|
707
|
+
firstName: form.firstName,
|
|
708
|
+
lastName: form.lastName,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
notifications.success('Registration successful!');
|
|
712
|
+
router.push({ name: 'dashboard' });
|
|
713
|
+
} catch (error) {
|
|
714
|
+
notifications.error((error as Error).message);
|
|
715
|
+
} finally {
|
|
716
|
+
submitting.value = false;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
</script>
|
|
720
|
+
|
|
721
|
+
<template>
|
|
722
|
+
<form @submit.prevent="handleSubmit" class="register-form">
|
|
723
|
+
<h1>Create Account</h1>
|
|
724
|
+
|
|
725
|
+
<div class="form-row">
|
|
726
|
+
<div class="form-group">
|
|
727
|
+
<label for="firstName">First Name</label>
|
|
728
|
+
<input
|
|
729
|
+
id="firstName"
|
|
730
|
+
v-model="form.firstName"
|
|
731
|
+
type="text"
|
|
732
|
+
:class="{ error: errors.firstName }"
|
|
733
|
+
/>
|
|
734
|
+
<span v-if="errors.firstName" class="error-message">
|
|
735
|
+
{{ errors.firstName }}
|
|
736
|
+
</span>
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
<div class="form-group">
|
|
740
|
+
<label for="lastName">Last Name</label>
|
|
741
|
+
<input
|
|
742
|
+
id="lastName"
|
|
743
|
+
v-model="form.lastName"
|
|
744
|
+
type="text"
|
|
745
|
+
:class="{ error: errors.lastName }"
|
|
746
|
+
/>
|
|
747
|
+
<span v-if="errors.lastName" class="error-message">
|
|
748
|
+
{{ errors.lastName }}
|
|
749
|
+
</span>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
|
|
753
|
+
<div class="form-group">
|
|
754
|
+
<label for="email">Email</label>
|
|
755
|
+
<input
|
|
756
|
+
id="email"
|
|
757
|
+
v-model="form.email"
|
|
758
|
+
type="email"
|
|
759
|
+
:class="{ error: errors.email }"
|
|
760
|
+
/>
|
|
761
|
+
<span v-if="errors.email" class="error-message">
|
|
762
|
+
{{ errors.email }}
|
|
763
|
+
</span>
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<div class="form-group">
|
|
767
|
+
<label for="password">Password</label>
|
|
768
|
+
<input
|
|
769
|
+
id="password"
|
|
770
|
+
v-model="form.password"
|
|
771
|
+
type="password"
|
|
772
|
+
:class="{ error: errors.password }"
|
|
773
|
+
/>
|
|
774
|
+
<span v-if="errors.password" class="error-message">
|
|
775
|
+
{{ errors.password }}
|
|
776
|
+
</span>
|
|
777
|
+
</div>
|
|
778
|
+
|
|
779
|
+
<div class="form-group">
|
|
780
|
+
<label for="confirmPassword">Confirm Password</label>
|
|
781
|
+
<input
|
|
782
|
+
id="confirmPassword"
|
|
783
|
+
v-model="form.confirmPassword"
|
|
784
|
+
type="password"
|
|
785
|
+
:class="{ error: errors.confirmPassword }"
|
|
786
|
+
/>
|
|
787
|
+
<span v-if="errors.confirmPassword" class="error-message">
|
|
788
|
+
{{ errors.confirmPassword }}
|
|
789
|
+
</span>
|
|
790
|
+
</div>
|
|
791
|
+
|
|
792
|
+
<button type="submit" :disabled="submitting">
|
|
793
|
+
{{ submitting ? 'Creating...' : 'Create Account' }}
|
|
794
|
+
</button>
|
|
795
|
+
|
|
796
|
+
<p class="login-link">
|
|
797
|
+
Already have an account?
|
|
798
|
+
<RouterLink :to="{ name: 'login' }">Sign in</RouterLink>
|
|
799
|
+
</p>
|
|
800
|
+
</form>
|
|
801
|
+
</template>
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
### 6. Testing with Vitest
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
// src/components/__tests__/UserCard.spec.ts
|
|
808
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
809
|
+
import { mount } from '@vue/test-utils';
|
|
810
|
+
import UserCard from '@/components/UserCard.vue';
|
|
811
|
+
import type { User } from '@/types';
|
|
812
|
+
|
|
813
|
+
const mockUser: User = {
|
|
814
|
+
id: '1',
|
|
815
|
+
email: 'test@example.com',
|
|
816
|
+
firstName: 'John',
|
|
817
|
+
lastName: 'Doe',
|
|
818
|
+
role: 'user',
|
|
819
|
+
avatarUrl: null,
|
|
820
|
+
createdAt: new Date().toISOString(),
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
describe('UserCard', () => {
|
|
824
|
+
it('renders user information correctly', () => {
|
|
825
|
+
const wrapper = mount(UserCard, {
|
|
826
|
+
props: { user: mockUser },
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
expect(wrapper.text()).toContain('John Doe');
|
|
830
|
+
expect(wrapper.text()).toContain('test@example.com');
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('emits edit event when edit button is clicked', async () => {
|
|
834
|
+
const wrapper = mount(UserCard, {
|
|
835
|
+
props: { user: mockUser },
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
await wrapper.find('button:first-child').trigger('click');
|
|
839
|
+
|
|
840
|
+
expect(wrapper.emitted('edit')).toBeTruthy();
|
|
841
|
+
expect(wrapper.emitted('edit')![0]).toEqual([mockUser]);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('emits delete event with user id', async () => {
|
|
845
|
+
const wrapper = mount(UserCard, {
|
|
846
|
+
props: { user: mockUser },
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const deleteButton = wrapper.findAll('button')[1];
|
|
850
|
+
await deleteButton.trigger('click');
|
|
851
|
+
|
|
852
|
+
expect(wrapper.emitted('delete')).toBeTruthy();
|
|
853
|
+
expect(wrapper.emitted('delete')![0]).toEqual(['1']);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('hides actions when showActions is false', () => {
|
|
857
|
+
const wrapper = mount(UserCard, {
|
|
858
|
+
props: { user: mockUser, showActions: false },
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
expect(wrapper.find('.user-card__actions').exists()).toBe(false);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('toggles selection on click', async () => {
|
|
865
|
+
const wrapper = mount(UserCard, {
|
|
866
|
+
props: { user: mockUser },
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
await wrapper.trigger('click');
|
|
870
|
+
|
|
871
|
+
expect(wrapper.emitted('select')).toBeTruthy();
|
|
872
|
+
expect(wrapper.emitted('select')![0]).toEqual([mockUser, true]);
|
|
873
|
+
expect(wrapper.classes()).toContain('user-card--selected');
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
// src/stores/__tests__/user.store.spec.ts
|
|
879
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
880
|
+
import { setActivePinia, createPinia } from 'pinia';
|
|
881
|
+
import { useUserStore } from '@/stores/user.store';
|
|
882
|
+
import { authService } from '@/services/auth.service';
|
|
883
|
+
|
|
884
|
+
vi.mock('@/services/auth.service', () => ({
|
|
885
|
+
authService: {
|
|
886
|
+
login: vi.fn(),
|
|
887
|
+
register: vi.fn(),
|
|
888
|
+
getCurrentUser: vi.fn(),
|
|
889
|
+
},
|
|
890
|
+
}));
|
|
891
|
+
|
|
892
|
+
describe('User Store', () => {
|
|
893
|
+
beforeEach(() => {
|
|
894
|
+
setActivePinia(createPinia());
|
|
895
|
+
vi.clearAllMocks();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('initial state is correct', () => {
|
|
899
|
+
const store = useUserStore();
|
|
900
|
+
|
|
901
|
+
expect(store.user).toBeNull();
|
|
902
|
+
expect(store.isAuthenticated).toBe(false);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('login sets user and token', async () => {
|
|
906
|
+
const mockResponse = {
|
|
907
|
+
user: { id: '1', email: 'test@example.com', role: 'user' },
|
|
908
|
+
token: 'test-token',
|
|
909
|
+
};
|
|
910
|
+
vi.mocked(authService.login).mockResolvedValue(mockResponse);
|
|
911
|
+
|
|
912
|
+
const store = useUserStore();
|
|
913
|
+
await store.login({ email: 'test@example.com', password: 'password' });
|
|
914
|
+
|
|
915
|
+
expect(store.user).toEqual(mockResponse.user);
|
|
916
|
+
expect(store.token).toBe('test-token');
|
|
917
|
+
expect(store.isAuthenticated).toBe(true);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('logout clears user and token', async () => {
|
|
921
|
+
const store = useUserStore();
|
|
922
|
+
store.user = { id: '1', email: 'test@example.com' } as any;
|
|
923
|
+
store.token = 'test-token';
|
|
924
|
+
|
|
925
|
+
store.logout();
|
|
926
|
+
|
|
927
|
+
expect(store.user).toBeNull();
|
|
928
|
+
expect(store.token).toBeNull();
|
|
929
|
+
expect(store.isAuthenticated).toBe(false);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('handles login error', async () => {
|
|
933
|
+
vi.mocked(authService.login).mockRejectedValue(new Error('Invalid credentials'));
|
|
934
|
+
|
|
935
|
+
const store = useUserStore();
|
|
936
|
+
|
|
937
|
+
await expect(
|
|
938
|
+
store.login({ email: 'test@example.com', password: 'wrong' })
|
|
939
|
+
).rejects.toThrow('Invalid credentials');
|
|
940
|
+
|
|
941
|
+
expect(store.error).toBe('Invalid credentials');
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
// src/composables/__tests__/usePagination.spec.ts
|
|
947
|
+
import { describe, it, expect } from 'vitest';
|
|
948
|
+
import { usePagination } from '@/composables/usePagination';
|
|
949
|
+
|
|
950
|
+
describe('usePagination', () => {
|
|
951
|
+
it('initializes with default values', () => {
|
|
952
|
+
const { page, limit, total } = usePagination();
|
|
953
|
+
|
|
954
|
+
expect(page.value).toBe(1);
|
|
955
|
+
expect(limit.value).toBe(20);
|
|
956
|
+
expect(total.value).toBe(0);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('calculates total pages correctly', () => {
|
|
960
|
+
const { totalPages, setTotal } = usePagination({ initialLimit: 10 });
|
|
961
|
+
|
|
962
|
+
setTotal(45);
|
|
963
|
+
|
|
964
|
+
expect(totalPages.value).toBe(5);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it('navigates pages correctly', () => {
|
|
968
|
+
const { page, nextPage, prevPage, setTotal } = usePagination();
|
|
969
|
+
setTotal(100);
|
|
970
|
+
|
|
971
|
+
nextPage();
|
|
972
|
+
expect(page.value).toBe(2);
|
|
973
|
+
|
|
974
|
+
nextPage();
|
|
975
|
+
expect(page.value).toBe(3);
|
|
976
|
+
|
|
977
|
+
prevPage();
|
|
978
|
+
expect(page.value).toBe(2);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('prevents navigation beyond bounds', () => {
|
|
982
|
+
const { page, nextPage, prevPage, setTotal, limit } = usePagination();
|
|
983
|
+
setTotal(30);
|
|
984
|
+
|
|
985
|
+
prevPage();
|
|
986
|
+
expect(page.value).toBe(1);
|
|
987
|
+
|
|
988
|
+
page.value = 2;
|
|
989
|
+
nextPage();
|
|
990
|
+
expect(page.value).toBe(2);
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
### 7. API Service Layer
|
|
996
|
+
|
|
997
|
+
```typescript
|
|
998
|
+
// src/services/api.service.ts
|
|
999
|
+
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios';
|
|
1000
|
+
import { useUserStore } from '@/stores/user.store';
|
|
1001
|
+
|
|
1002
|
+
const api: AxiosInstance = axios.create({
|
|
1003
|
+
baseURL: import.meta.env.VITE_API_URL,
|
|
1004
|
+
timeout: 10000,
|
|
1005
|
+
headers: {
|
|
1006
|
+
'Content-Type': 'application/json',
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// Request interceptor
|
|
1011
|
+
api.interceptors.request.use((config) => {
|
|
1012
|
+
const userStore = useUserStore();
|
|
1013
|
+
if (userStore.token) {
|
|
1014
|
+
config.headers.Authorization = `Bearer ${userStore.token}`;
|
|
1015
|
+
}
|
|
1016
|
+
return config;
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// Response interceptor
|
|
1020
|
+
api.interceptors.response.use(
|
|
1021
|
+
(response) => response,
|
|
1022
|
+
(error) => {
|
|
1023
|
+
if (error.response?.status === 401) {
|
|
1024
|
+
const userStore = useUserStore();
|
|
1025
|
+
userStore.logout();
|
|
1026
|
+
window.location.href = '/auth/login';
|
|
1027
|
+
}
|
|
1028
|
+
return Promise.reject(error);
|
|
1029
|
+
}
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
export { api };
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
// src/services/user.service.ts
|
|
1036
|
+
import { api } from './api.service';
|
|
1037
|
+
import type { User, CreateUserData, UpdateUserData, PaginatedResponse } from '@/types';
|
|
1038
|
+
|
|
1039
|
+
interface UserFilters {
|
|
1040
|
+
search?: string;
|
|
1041
|
+
role?: string;
|
|
1042
|
+
page?: number;
|
|
1043
|
+
limit?: number;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export const userService = {
|
|
1047
|
+
async getAll(filters: UserFilters = {}): Promise<PaginatedResponse<User>> {
|
|
1048
|
+
const { data } = await api.get('/users', { params: filters });
|
|
1049
|
+
return data;
|
|
1050
|
+
},
|
|
1051
|
+
|
|
1052
|
+
async getById(id: string): Promise<User> {
|
|
1053
|
+
const { data } = await api.get(`/users/${id}`);
|
|
1054
|
+
return data;
|
|
1055
|
+
},
|
|
1056
|
+
|
|
1057
|
+
async create(userData: CreateUserData): Promise<User> {
|
|
1058
|
+
const { data } = await api.post('/users', userData);
|
|
1059
|
+
return data;
|
|
1060
|
+
},
|
|
1061
|
+
|
|
1062
|
+
async update(id: string, userData: UpdateUserData): Promise<User> {
|
|
1063
|
+
const { data } = await api.patch(`/users/${id}`, userData);
|
|
1064
|
+
return data;
|
|
1065
|
+
},
|
|
1066
|
+
|
|
1067
|
+
async delete(id: string): Promise<void> {
|
|
1068
|
+
await api.delete(`/users/${id}`);
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
## Use Cases
|
|
1074
|
+
|
|
1075
|
+
### Data Table with Pagination and Filters
|
|
1076
|
+
|
|
1077
|
+
```vue
|
|
1078
|
+
<!-- src/views/UsersView.vue -->
|
|
1079
|
+
<script setup lang="ts">
|
|
1080
|
+
import { ref, watch } from 'vue';
|
|
1081
|
+
import { usePagination } from '@/composables/usePagination';
|
|
1082
|
+
import { useAsync } from '@/composables/useAsync';
|
|
1083
|
+
import { userService } from '@/services/user.service';
|
|
1084
|
+
import UserCard from '@/components/UserCard.vue';
|
|
1085
|
+
import type { User } from '@/types';
|
|
1086
|
+
|
|
1087
|
+
const search = ref('');
|
|
1088
|
+
const roleFilter = ref('');
|
|
1089
|
+
|
|
1090
|
+
const {
|
|
1091
|
+
page,
|
|
1092
|
+
limit,
|
|
1093
|
+
totalPages,
|
|
1094
|
+
hasNextPage,
|
|
1095
|
+
hasPrevPage,
|
|
1096
|
+
nextPage,
|
|
1097
|
+
prevPage,
|
|
1098
|
+
setTotal,
|
|
1099
|
+
} = usePagination({ initialLimit: 10 });
|
|
1100
|
+
|
|
1101
|
+
const { data: users, loading, execute: fetchUsers } = useAsync(
|
|
1102
|
+
() => userService.getAll({
|
|
1103
|
+
search: search.value,
|
|
1104
|
+
role: roleFilter.value,
|
|
1105
|
+
page: page.value,
|
|
1106
|
+
limit: limit.value,
|
|
1107
|
+
})
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
watch([search, roleFilter], () => {
|
|
1111
|
+
page.value = 1;
|
|
1112
|
+
fetchUsers();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
watch(page, fetchUsers);
|
|
1116
|
+
|
|
1117
|
+
watch(users, (response) => {
|
|
1118
|
+
if (response) setTotal(response.total);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
fetchUsers();
|
|
1122
|
+
|
|
1123
|
+
function handleEdit(user: User) {
|
|
1124
|
+
// Navigate to edit page
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function handleDelete(userId: string) {
|
|
1128
|
+
// Confirm and delete
|
|
1129
|
+
}
|
|
1130
|
+
</script>
|
|
1131
|
+
|
|
1132
|
+
<template>
|
|
1133
|
+
<div class="users-view">
|
|
1134
|
+
<header class="users-view__header">
|
|
1135
|
+
<h1>Users</h1>
|
|
1136
|
+
<RouterLink :to="{ name: 'user-create' }" class="btn btn--primary">
|
|
1137
|
+
Add User
|
|
1138
|
+
</RouterLink>
|
|
1139
|
+
</header>
|
|
1140
|
+
|
|
1141
|
+
<div class="users-view__filters">
|
|
1142
|
+
<input
|
|
1143
|
+
v-model="search"
|
|
1144
|
+
type="search"
|
|
1145
|
+
placeholder="Search users..."
|
|
1146
|
+
/>
|
|
1147
|
+
<select v-model="roleFilter">
|
|
1148
|
+
<option value="">All Roles</option>
|
|
1149
|
+
<option value="admin">Admin</option>
|
|
1150
|
+
<option value="user">User</option>
|
|
1151
|
+
<option value="guest">Guest</option>
|
|
1152
|
+
</select>
|
|
1153
|
+
</div>
|
|
1154
|
+
|
|
1155
|
+
<div v-if="loading" class="loading">Loading...</div>
|
|
1156
|
+
|
|
1157
|
+
<div v-else-if="users?.data.length" class="users-view__list">
|
|
1158
|
+
<UserCard
|
|
1159
|
+
v-for="user in users.data"
|
|
1160
|
+
:key="user.id"
|
|
1161
|
+
:user="user"
|
|
1162
|
+
@edit="handleEdit"
|
|
1163
|
+
@delete="handleDelete"
|
|
1164
|
+
/>
|
|
1165
|
+
</div>
|
|
1166
|
+
|
|
1167
|
+
<div v-else class="empty">No users found</div>
|
|
1168
|
+
|
|
1169
|
+
<div class="pagination">
|
|
1170
|
+
<button :disabled="!hasPrevPage" @click="prevPage">Previous</button>
|
|
1171
|
+
<span>Page {{ page }} of {{ totalPages }}</span>
|
|
1172
|
+
<button :disabled="!hasNextPage" @click="nextPage">Next</button>
|
|
1173
|
+
</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
</template>
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
### Modal Component with Teleport
|
|
1179
|
+
|
|
1180
|
+
```vue
|
|
1181
|
+
<!-- src/components/Modal.vue -->
|
|
1182
|
+
<script setup lang="ts">
|
|
1183
|
+
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
|
1184
|
+
|
|
1185
|
+
interface Props {
|
|
1186
|
+
modelValue: boolean;
|
|
1187
|
+
title?: string;
|
|
1188
|
+
persistent?: boolean;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
1192
|
+
persistent: false,
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
const emit = defineEmits<{
|
|
1196
|
+
(e: 'update:modelValue', value: boolean): void;
|
|
1197
|
+
(e: 'close'): void;
|
|
1198
|
+
}>();
|
|
1199
|
+
|
|
1200
|
+
const modalRef = ref<HTMLDivElement | null>(null);
|
|
1201
|
+
|
|
1202
|
+
function close() {
|
|
1203
|
+
emit('update:modelValue', false);
|
|
1204
|
+
emit('close');
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function handleBackdropClick() {
|
|
1208
|
+
if (!props.persistent) close();
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function handleEscape(event: KeyboardEvent) {
|
|
1212
|
+
if (event.key === 'Escape' && props.modelValue && !props.persistent) {
|
|
1213
|
+
close();
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
onMounted(() => {
|
|
1218
|
+
document.addEventListener('keydown', handleEscape);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
onUnmounted(() => {
|
|
1222
|
+
document.removeEventListener('keydown', handleEscape);
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
watch(() => props.modelValue, (isOpen) => {
|
|
1226
|
+
document.body.style.overflow = isOpen ? 'hidden' : '';
|
|
1227
|
+
});
|
|
1228
|
+
</script>
|
|
1229
|
+
|
|
1230
|
+
<template>
|
|
1231
|
+
<Teleport to="body">
|
|
1232
|
+
<Transition name="modal">
|
|
1233
|
+
<div
|
|
1234
|
+
v-if="modelValue"
|
|
1235
|
+
class="modal-backdrop"
|
|
1236
|
+
@click="handleBackdropClick"
|
|
1237
|
+
>
|
|
1238
|
+
<div
|
|
1239
|
+
ref="modalRef"
|
|
1240
|
+
class="modal"
|
|
1241
|
+
role="dialog"
|
|
1242
|
+
aria-modal="true"
|
|
1243
|
+
@click.stop
|
|
1244
|
+
>
|
|
1245
|
+
<header v-if="title || $slots.header" class="modal__header">
|
|
1246
|
+
<slot name="header">
|
|
1247
|
+
<h2>{{ title }}</h2>
|
|
1248
|
+
</slot>
|
|
1249
|
+
<button class="modal__close" @click="close">×</button>
|
|
1250
|
+
</header>
|
|
1251
|
+
|
|
1252
|
+
<div class="modal__body">
|
|
1253
|
+
<slot />
|
|
1254
|
+
</div>
|
|
1255
|
+
|
|
1256
|
+
<footer v-if="$slots.footer" class="modal__footer">
|
|
1257
|
+
<slot name="footer" :close="close" />
|
|
1258
|
+
</footer>
|
|
1259
|
+
</div>
|
|
1260
|
+
</div>
|
|
1261
|
+
</Transition>
|
|
1262
|
+
</Teleport>
|
|
1263
|
+
</template>
|
|
1264
|
+
|
|
1265
|
+
<style scoped>
|
|
1266
|
+
.modal-backdrop {
|
|
1267
|
+
position: fixed;
|
|
1268
|
+
inset: 0;
|
|
1269
|
+
background: rgba(0, 0, 0, 0.5);
|
|
1270
|
+
display: flex;
|
|
1271
|
+
align-items: center;
|
|
1272
|
+
justify-content: center;
|
|
1273
|
+
z-index: 1000;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
.modal {
|
|
1277
|
+
background: white;
|
|
1278
|
+
border-radius: 8px;
|
|
1279
|
+
max-width: 500px;
|
|
1280
|
+
width: 90%;
|
|
1281
|
+
max-height: 90vh;
|
|
1282
|
+
overflow: auto;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
.modal-enter-active,
|
|
1286
|
+
.modal-leave-active {
|
|
1287
|
+
transition: opacity 0.3s ease;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
.modal-enter-from,
|
|
1291
|
+
.modal-leave-to {
|
|
1292
|
+
opacity: 0;
|
|
1293
|
+
}
|
|
1294
|
+
</style>
|
|
56
1295
|
```
|
|
57
1296
|
|
|
58
1297
|
## Best Practices
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
- Use
|
|
1298
|
+
|
|
1299
|
+
### Do's
|
|
1300
|
+
|
|
1301
|
+
- Use Composition API with `<script setup>`
|
|
1302
|
+
- Use TypeScript for type safety
|
|
1303
|
+
- Create composables for reusable logic
|
|
1304
|
+
- Use Pinia for global state management
|
|
1305
|
+
- Use Vue Router for navigation
|
|
1306
|
+
- Implement proper error boundaries
|
|
1307
|
+
- Write unit tests with Vitest
|
|
1308
|
+
- Use lazy loading for routes
|
|
1309
|
+
- Follow single responsibility principle
|
|
1310
|
+
- Use provide/inject for deep prop drilling
|
|
1311
|
+
|
|
1312
|
+
### Don'ts
|
|
1313
|
+
|
|
1314
|
+
- Don't use Options API in new code
|
|
1315
|
+
- Don't mutate props directly
|
|
1316
|
+
- Don't use `this` in Composition API
|
|
1317
|
+
- Don't overuse global state
|
|
1318
|
+
- Don't skip error handling
|
|
1319
|
+
- Don't ignore TypeScript errors
|
|
1320
|
+
- Don't use `any` type
|
|
1321
|
+
- Don't create memory leaks in watchers
|
|
1322
|
+
- Don't skip component testing
|
|
1323
|
+
- Don't mix template refs with reactive refs
|
|
1324
|
+
|
|
1325
|
+
## References
|
|
1326
|
+
|
|
1327
|
+
- [Vue 3 Documentation](https://vuejs.org/)
|
|
1328
|
+
- [Pinia Documentation](https://pinia.vuejs.org/)
|
|
1329
|
+
- [Vue Router Documentation](https://router.vuejs.org/)
|
|
1330
|
+
- [Vitest Documentation](https://vitest.dev/)
|
|
1331
|
+
- [VueUse Composables](https://vueuse.org/)
|