omgkit 2.2.0 → 2.3.1
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 +3 -3
- package/package.json +1 -1
- package/plugin/skills/databases/database-management/SKILL.md +288 -0
- package/plugin/skills/databases/database-migration/SKILL.md +285 -0
- package/plugin/skills/databases/database-schema-design/SKILL.md +195 -0
- package/plugin/skills/databases/mongodb/SKILL.md +60 -776
- package/plugin/skills/databases/prisma/SKILL.md +53 -744
- package/plugin/skills/databases/redis/SKILL.md +53 -860
- package/plugin/skills/databases/supabase/SKILL.md +283 -0
- package/plugin/skills/devops/aws/SKILL.md +68 -672
- package/plugin/skills/devops/github-actions/SKILL.md +54 -657
- package/plugin/skills/devops/kubernetes/SKILL.md +67 -602
- package/plugin/skills/devops/performance-profiling/SKILL.md +59 -863
- package/plugin/skills/frameworks/django/SKILL.md +87 -853
- package/plugin/skills/frameworks/express/SKILL.md +95 -1301
- package/plugin/skills/frameworks/fastapi/SKILL.md +90 -1198
- package/plugin/skills/frameworks/laravel/SKILL.md +87 -1187
- package/plugin/skills/frameworks/nestjs/SKILL.md +106 -973
- package/plugin/skills/frameworks/react/SKILL.md +94 -962
- package/plugin/skills/frameworks/vue/SKILL.md +95 -1242
- package/plugin/skills/frontend/accessibility/SKILL.md +91 -1056
- package/plugin/skills/frontend/frontend-design/SKILL.md +69 -1262
- package/plugin/skills/frontend/responsive/SKILL.md +76 -799
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +73 -921
- package/plugin/skills/frontend/tailwindcss/SKILL.md +60 -788
- package/plugin/skills/frontend/threejs/SKILL.md +72 -1266
- package/plugin/skills/languages/javascript/SKILL.md +106 -849
- package/plugin/skills/methodology/brainstorming/SKILL.md +70 -576
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +79 -831
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +81 -654
- package/plugin/skills/methodology/executing-plans/SKILL.md +86 -529
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +95 -586
- package/plugin/skills/methodology/problem-solving/SKILL.md +67 -681
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +70 -533
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +70 -610
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +70 -646
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +70 -478
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +66 -559
- package/plugin/skills/methodology/test-driven-development/SKILL.md +91 -752
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +78 -687
- package/plugin/skills/methodology/token-optimization/SKILL.md +72 -602
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +108 -529
- package/plugin/skills/methodology/writing-plans/SKILL.md +79 -566
- package/plugin/skills/omega/omega-architecture/SKILL.md +91 -752
- package/plugin/skills/omega/omega-coding/SKILL.md +161 -552
- package/plugin/skills/omega/omega-sprint/SKILL.md +132 -777
- package/plugin/skills/omega/omega-testing/SKILL.md +157 -845
- package/plugin/skills/omega/omega-thinking/SKILL.md +165 -606
- package/plugin/skills/security/better-auth/SKILL.md +46 -1034
- package/plugin/skills/security/oauth/SKILL.md +80 -934
- package/plugin/skills/security/owasp/SKILL.md +78 -862
- package/plugin/skills/testing/playwright/SKILL.md +77 -700
- package/plugin/skills/testing/pytest/SKILL.md +73 -811
- package/plugin/skills/testing/vitest/SKILL.md +60 -920
- package/plugin/skills/tools/document-processing/SKILL.md +111 -838
- package/plugin/skills/tools/image-processing/SKILL.md +126 -659
- package/plugin/skills/tools/mcp-development/SKILL.md +85 -758
- package/plugin/skills/tools/media-processing/SKILL.md +118 -735
- package/plugin/stdrules/SKILL_STANDARDS.md +490 -0
- package/plugin/skills/SKILL_STANDARDS.md +0 -743
|
@@ -1,429 +1,118 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: vue
|
|
3
|
-
description:
|
|
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
|
|
2
|
+
name: building-vue-apps
|
|
3
|
+
description: Builds production Vue 3 applications with Composition API, TypeScript, Pinia, and Vue Router. Use when creating Vue.js SPAs, component libraries, or reactive web interfaces.
|
|
15
4
|
---
|
|
16
5
|
|
|
17
6
|
# Vue.js
|
|
18
7
|
|
|
19
|
-
|
|
8
|
+
## Quick Start
|
|
20
9
|
|
|
21
|
-
|
|
10
|
+
```vue
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
import { ref, computed } from 'vue';
|
|
22
13
|
|
|
23
|
-
|
|
14
|
+
const count = ref(0);
|
|
15
|
+
const doubled = computed(() => count.value * 2);
|
|
16
|
+
</script>
|
|
24
17
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- Build performant applications
|
|
18
|
+
<template>
|
|
19
|
+
<button @click="count++">
|
|
20
|
+
Count: {{ count }} (doubled: {{ doubled }})
|
|
21
|
+
</button>
|
|
22
|
+
</template>
|
|
23
|
+
```
|
|
32
24
|
|
|
33
25
|
## Features
|
|
34
26
|
|
|
35
|
-
|
|
27
|
+
| Feature | Description | Guide |
|
|
28
|
+
|---------|-------------|-------|
|
|
29
|
+
| Composition API | Reactive state, composables, lifecycle | [COMPOSITION.md](COMPOSITION.md) |
|
|
30
|
+
| TypeScript | Props, emits, refs typing | [TYPESCRIPT.md](TYPESCRIPT.md) |
|
|
31
|
+
| Pinia | State management, stores, plugins | [PINIA.md](PINIA.md) |
|
|
32
|
+
| Vue Router | Navigation, guards, lazy loading | [ROUTER.md](ROUTER.md) |
|
|
33
|
+
| Testing | Vitest, Vue Test Utils patterns | [TESTING.md](TESTING.md) |
|
|
34
|
+
| Performance | Lazy loading, memoization, virtual lists | [PERFORMANCE.md](PERFORMANCE.md) |
|
|
35
|
+
|
|
36
|
+
## Common Patterns
|
|
37
|
+
|
|
38
|
+
### Component with Props and Emits
|
|
36
39
|
|
|
37
40
|
```vue
|
|
38
|
-
<!-- src/components/UserCard.vue -->
|
|
39
41
|
<script setup lang="ts">
|
|
40
|
-
import { computed, ref, watch, onMounted } from 'vue';
|
|
41
|
-
import type { User, UserRole } from '@/types';
|
|
42
|
-
|
|
43
|
-
// Props with TypeScript
|
|
44
42
|
interface Props {
|
|
45
|
-
user:
|
|
43
|
+
user: { id: string; name: string; email: string };
|
|
46
44
|
showActions?: boolean;
|
|
47
|
-
variant?: 'default' | 'compact' | 'detailed';
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
const props = withDefaults(defineProps<Props>(), {
|
|
51
48
|
showActions: true,
|
|
52
|
-
variant: 'default',
|
|
53
|
-
});
|
|
54
|
-
|
|
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;
|
|
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
49
|
});
|
|
101
50
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
});
|
|
51
|
+
const emit = defineEmits<{
|
|
52
|
+
(e: 'edit', user: Props['user']): void;
|
|
53
|
+
(e: 'delete', id: string): void;
|
|
54
|
+
}>();
|
|
120
55
|
</script>
|
|
121
56
|
|
|
122
57
|
<template>
|
|
123
|
-
<div
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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>
|
|
58
|
+
<div class="user-card">
|
|
59
|
+
<h3>{{ user.name }}</h3>
|
|
60
|
+
<p>{{ user.email }}</p>
|
|
61
|
+
<div v-if="showActions">
|
|
62
|
+
<button @click="emit('edit', user)">Edit</button>
|
|
63
|
+
<button @click="emit('delete', user.id)">Delete</button>
|
|
147
64
|
</div>
|
|
148
|
-
|
|
149
|
-
<slot name="footer" :user="user" :selected="isSelected" />
|
|
150
65
|
</div>
|
|
151
66
|
</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>
|
|
178
67
|
```
|
|
179
68
|
|
|
180
|
-
###
|
|
69
|
+
### Composable for Reusable Logic
|
|
181
70
|
|
|
182
71
|
```typescript
|
|
183
|
-
//
|
|
184
|
-
import { ref,
|
|
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;
|
|
72
|
+
// composables/useFetch.ts
|
|
73
|
+
import { ref, watch, type Ref } from 'vue';
|
|
195
74
|
|
|
196
|
-
|
|
75
|
+
export function useFetch<T>(url: Ref<string>) {
|
|
76
|
+
const data = ref<T | null>(null);
|
|
197
77
|
const loading = ref(false);
|
|
198
78
|
const error = ref<Error | null>(null);
|
|
199
79
|
|
|
200
|
-
|
|
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
|
-
}
|
|
210
|
-
|
|
80
|
+
async function fetchData() {
|
|
211
81
|
loading.value = true;
|
|
212
82
|
error.value = null;
|
|
213
|
-
|
|
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
83
|
try {
|
|
229
|
-
|
|
84
|
+
const response = await fetch(url.value);
|
|
85
|
+
data.value = await response.json();
|
|
230
86
|
} catch (e) {
|
|
231
87
|
error.value = e as Error;
|
|
232
|
-
throw e;
|
|
233
88
|
} finally {
|
|
234
89
|
loading.value = false;
|
|
235
90
|
}
|
|
236
91
|
}
|
|
237
92
|
|
|
238
|
-
watch(
|
|
239
|
-
|
|
240
|
-
return {
|
|
241
|
-
user,
|
|
242
|
-
loading,
|
|
243
|
-
error,
|
|
244
|
-
isLoaded,
|
|
245
|
-
fullName,
|
|
246
|
-
fetchUser,
|
|
247
|
-
updateUser,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
93
|
+
watch(url, fetchData, { immediate: true });
|
|
251
94
|
|
|
252
|
-
|
|
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
|
-
};
|
|
95
|
+
return { data, loading, error, refetch: fetchData };
|
|
356
96
|
}
|
|
357
97
|
```
|
|
358
98
|
|
|
359
|
-
###
|
|
99
|
+
### Pinia Store
|
|
360
100
|
|
|
361
101
|
```typescript
|
|
362
|
-
//
|
|
102
|
+
// stores/user.ts
|
|
363
103
|
import { defineStore } from 'pinia';
|
|
364
104
|
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
105
|
|
|
369
106
|
export const useUserStore = defineStore('user', () => {
|
|
370
|
-
// State
|
|
371
107
|
const user = ref<User | null>(null);
|
|
372
|
-
const token =
|
|
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
|
-
);
|
|
108
|
+
const token = ref<string | null>(null);
|
|
382
109
|
|
|
383
|
-
|
|
384
|
-
async function login(credentials: LoginCredentials) {
|
|
385
|
-
loading.value = true;
|
|
386
|
-
error.value = null;
|
|
110
|
+
const isAuthenticated = computed(() => !!user.value);
|
|
387
111
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
}
|
|
112
|
+
async function login(credentials: { email: string; password: string }) {
|
|
113
|
+
const res = await authService.login(credentials);
|
|
114
|
+
user.value = res.user;
|
|
115
|
+
token.value = res.token;
|
|
427
116
|
}
|
|
428
117
|
|
|
429
118
|
function logout() {
|
|
@@ -431,901 +120,65 @@ export const useUserStore = defineStore('user', () => {
|
|
|
431
120
|
token.value = null;
|
|
432
121
|
}
|
|
433
122
|
|
|
434
|
-
|
|
435
|
-
user.value = null;
|
|
436
|
-
token.value = null;
|
|
437
|
-
loading.value = false;
|
|
438
|
-
error.value = null;
|
|
439
|
-
}
|
|
440
|
-
|
|
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
|
-
};
|
|
123
|
+
return { user, token, isAuthenticated, login, logout };
|
|
458
124
|
});
|
|
125
|
+
```
|
|
459
126
|
|
|
127
|
+
## Workflows
|
|
460
128
|
|
|
461
|
-
|
|
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
|
-
}
|
|
129
|
+
### Component Development
|
|
514
130
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
error,
|
|
521
|
-
warning,
|
|
522
|
-
info,
|
|
523
|
-
};
|
|
524
|
-
});
|
|
525
|
-
```
|
|
131
|
+
1. Define props interface with TypeScript
|
|
132
|
+
2. Define emits with typed events
|
|
133
|
+
3. Use `<script setup>` syntax
|
|
134
|
+
4. Create composables for reusable logic
|
|
135
|
+
5. Write tests with Vitest + Vue Test Utils
|
|
526
136
|
|
|
527
|
-
###
|
|
137
|
+
### Router Setup
|
|
528
138
|
|
|
529
139
|
```typescript
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
},
|
|
140
|
+
const routes = [
|
|
141
|
+
{ path: '/', component: () => import('./views/Home.vue') },
|
|
583
142
|
{
|
|
584
|
-
path: '
|
|
585
|
-
|
|
586
|
-
|
|
143
|
+
path: '/dashboard',
|
|
144
|
+
component: () => import('./views/Dashboard.vue'),
|
|
145
|
+
meta: { requiresAuth: true },
|
|
587
146
|
},
|
|
588
147
|
];
|
|
589
148
|
|
|
590
|
-
|
|
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) => {
|
|
149
|
+
router.beforeEach((to, from, next) => {
|
|
602
150
|
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
151
|
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
|
|
611
|
-
|
|
152
|
+
next({ path: '/login', query: { redirect: to.fullPath } });
|
|
153
|
+
} else {
|
|
154
|
+
next();
|
|
612
155
|
}
|
|
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
156
|
});
|
|
629
|
-
|
|
630
|
-
export default router;
|
|
631
157
|
```
|
|
632
158
|
|
|
633
|
-
|
|
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>
|
|
159
|
+
## Best Practices
|
|
1154
160
|
|
|
1155
|
-
|
|
161
|
+
| Do | Avoid |
|
|
162
|
+
|----|-------|
|
|
163
|
+
| Use Composition API with `<script setup>` | Options API in new code |
|
|
164
|
+
| Type props/emits with interfaces | `any` types |
|
|
165
|
+
| Create composables for shared logic | Duplicating reactive logic |
|
|
166
|
+
| Use Pinia for global state | Overusing provide/inject |
|
|
167
|
+
| Lazy load routes and components | Bundling everything upfront |
|
|
1156
168
|
|
|
1157
|
-
|
|
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>
|
|
169
|
+
## Project Structure
|
|
1166
170
|
|
|
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
171
|
```
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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>
|
|
172
|
+
src/
|
|
173
|
+
├── App.vue
|
|
174
|
+
├── main.ts
|
|
175
|
+
├── components/ # Reusable components
|
|
176
|
+
├── composables/ # Composition functions
|
|
177
|
+
├── views/ # Page components
|
|
178
|
+
├── stores/ # Pinia stores
|
|
179
|
+
├── router/ # Route definitions
|
|
180
|
+
├── services/ # API services
|
|
181
|
+
└── types/ # TypeScript types
|
|
1295
182
|
```
|
|
1296
183
|
|
|
1297
|
-
|
|
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/)
|
|
184
|
+
For detailed examples and patterns, see reference files above.
|