Dev cleaning (#106)

This commit is contained in:
Munawwirul Jamal
2025-10-08 00:03:36 +07:00
committed by GitHub
parent 7fdd5c61f0
commit 3eb9dde21d
892 changed files with 51326 additions and 1 deletions
+73
View File
@@ -0,0 +1,73 @@
# useFormErrors Composable
Composable untuk menangani form validation errors seperti Laravel. Mengkonversi ZodError menjadi format yang mudah digunakan di template.
## Penggunaan
### Basic Usage
```typescript
// Di component parent (entry.vue)
const { errors, setFromZodError, clearErrors } = useFormErrors()
// Validasi dengan Zod
const result = schema.safeParse(data.value)
if (!result.success) {
setFromZodError(result.error)
return
}
```
### Di Template
```vue
<!-- Pass errors ke form component -->
<AppDivisonEntryForm v-model="data" :errors="errors" />
```
### Di Form Component
```vue
<script setup>
import type { FormErrors } from '~/composables/useFormErrors'
const props = defineProps<{
modelValue: any
errors?: FormErrors
}>()
</script>
<template>
<!-- Setiap Field harus memiliki id yang sesuai dengan field name -->
<Field id="name" :errors="errors">
<Input v-model="data.name" />
</Field>
</template>
```
## API Reference
### Methods
- `setFromZodError(zodError)` - Set errors dari ZodError
- `setErrors(errors)` - Set errors manual
- `setError(field, message)` - Set error untuk field tertentu
- `clearError(field)` - Hapus error untuk field tertentu
- `clearErrors()` - Hapus semua errors
- `hasError(field)` - Cek apakah ada error untuk field
- `getError(field)` - Ambil error message untuk field
### Computed Properties
- `hasErrors` - Boolean apakah ada error
- `errorMessages` - Array semua error messages
- `firstError` - Error pertama (untuk alert general)
## Field Component
Field component akan otomatis menampilkan error jika:
1. Field memiliki `id` prop yang sesuai dengan field name
2. Field menerima `errors` prop
3. Ada error untuk field tersebut di dalam errors object
Error akan ditampilkan dengan class `.field-error-info` yang sudah di-style dengan warna merah.
+107
View File
@@ -0,0 +1,107 @@
import type { ZodError } from 'zod'
import type { FormErrors } from '~/types/error'
/**
* Composable untuk menangani form validation errors seperti Laravel
* Mengkonversi ZodError menjadi format yang mudah digunakan di template
*/
export function useFormErrors() {
const errors = ref<FormErrors>({})
/**
* Set errors dari ZodError
*/
function setFromZodError(zodError: ZodError) {
const newErrors: FormErrors = {}
zodError.errors.forEach((error) => {
const field = error.path.join('.')
newErrors[field] = {
message: error.message,
code: error.code,
path: error.path,
}
})
errors.value = newErrors
}
/**
* Set errors manual (untuk error dari API response)
*/
function setErrors(newErrors: FormErrors) {
errors.value = newErrors
}
/**
* Set error untuk field tertentu
*/
function setError(field: string, message: string, extra: Record<string, any> = {}) {
errors.value[field] = {
message,
...extra,
}
}
/**
* Hapus error untuk field tertentu
*/
function clearError(field: string) {
delete errors.value[field]
}
/**
* Hapus semua errors
*/
function clearErrors() {
errors.value = {}
}
/**
* Cek apakah ada error untuk field tertentu
*/
function hasError(field: string): boolean {
return !!errors.value[field]
}
/**
* Ambil error message untuk field tertentu
*/
function getError(field: string): string | null {
return errors.value[field]?.message || null
}
/**
* Cek apakah ada error apapun
*/
const hasErrors = computed(() => Object.keys(errors.value).length > 0)
/**
* Ambil semua error messages sebagai array
*/
const errorMessages = computed(() =>
Object.values(errors.value).map(error => error.message),
)
/**
* Ambil error pertama (untuk menampilkan alert general)
*/
const firstError = computed(() => {
const firstKey = Object.keys(errors.value)[0]
return firstKey ? errors.value[firstKey] : null
})
return {
errors: readonly(errors),
setFromZodError,
setErrors,
setError,
clearError,
clearErrors,
hasError,
getError,
hasErrors,
errorMessages,
firstError,
}
}
+181
View File
@@ -0,0 +1,181 @@
import type { DataTableLoader } from '~/components/pub/my-ui/data-table/type'
import type { PaginationMeta } from '~/components/pub/my-ui/pagination/pagination.type'
import { refDebounced, useUrlSearchParams } from '@vueuse/core'
import * as z from 'zod'
import { is } from "date-fns/locale"
// Default query schema yang bisa digunakan semua list
export const defaultQuerySchema = z.object({
search: z
.union([z.literal(''), z.string().min(3)])
.optional()
.catch(''),
'page-number': z.coerce.number().int().min(1).default(1).catch(1),
'page-size': z.coerce.number().int().min(5).max(20).default(10).catch(10),
})
export const defaultQueryParams: Record<string, any> = {
search: '',
'page-number': 1,
'page-size': 10,
}
export type DefaultQueryParams = z.infer<typeof defaultQuerySchema>
interface UsePaginatedListOptions<T = any> {
// Schema untuk validasi query parameters
querySchema?: z.ZodSchema<any>
defaultQuery?: Record<string, any>
// Fungsi untuk fetch data
fetchFn: (params: any) => Promise<{
success: boolean
body: {
data: T[]
meta: {
record_totalCount: number
}
}
}>
// Nama endpoint untuk logging error
entityName: string
}
export function usePaginatedList<T = any>(options: UsePaginatedListOptions<T>) {
const { querySchema = defaultQuerySchema, defaultQuery = defaultQueryParams, fetchFn, entityName } = options
// State management
const data = ref<T[]>([])
const isLoading = reactive<DataTableLoader>({
isTableLoading: false,
})
// URL state management
const queryParams = useUrlSearchParams('history', {
initialValue: defaultQuery,
removeFalsyValues: true,
})
const params = computed(() => {
const result = querySchema.safeParse(queryParams)
return result.data || defaultQuery
})
// Pagination state - computed from URL params
const paginationMeta = reactive<PaginationMeta>({
recordCount: 0,
page: params.value['page-number'],
pageSize: params.value['page-size'],
totalPage: 0,
hasNext: false,
hasPrev: false,
})
// Search model with debounce
const searchInput = ref(params.value.search || '')
const debouncedSearch = refDebounced(searchInput, 500) // 500ms debounce
// Functions
async function fetchData() {
if (isLoading.isTableLoading) return
isLoading.isTableLoading = true
try {
// Use current params from URL state
const currentParams = params.value
const response = await fetchFn(currentParams)
if (response.success) {
const responseBody = response.body
data.value = responseBody.data || []
const pager = responseBody.meta
// Update pagination meta from response
paginationMeta.recordCount = pager.record_totalCount
paginationMeta.page = currentParams['page-number']
paginationMeta.pageSize = currentParams['page-size']
paginationMeta.totalPage = Math.ceil(pager.record_totalCount / paginationMeta.pageSize)
paginationMeta.hasNext = paginationMeta.page < paginationMeta.totalPage
paginationMeta.hasPrev = paginationMeta.page > 1
}
} catch (error) {
data.value = []
paginationMeta.recordCount = 0
paginationMeta.totalPage = 0
paginationMeta.hasNext = false
paginationMeta.hasPrev = false
} finally {
isLoading.isTableLoading = false
}
}
// Handle pagination page change
function handlePageChange(page: number) {
// Update URL params - this will trigger watcher
queryParams['page-number'] = page
}
// Handle search from header component
function handleSearch(searchValue: string) {
// Update URL params - this will trigger watcher and refetch data
queryParams.search = searchValue
queryParams['page-number'] = 1 // Reset to first page when searching
}
// Watchers
// Watch for URL param changes and trigger refetch
watch(
params,
(newParams) => {
console.log('watch ~ newParams', newParams)
// Sync search input with URL params (for back/forward navigation)
if (newParams.search !== searchInput.value) {
searchInput.value = newParams.search || ''
}
fetchData()
},
{ deep: true },
)
// Watch debounced search and update URL params (keeping for backward compatibility)
watch(debouncedSearch, (newValue) => {
// Only search if 3+ characters or empty (to clear search)
if (newValue.length === 0 || newValue.length >= 3) {
queryParams.search = newValue
queryParams['page-number'] = 1 // Reset to first page when searching
}
})
// Initialize data on mount
onMounted(() => {
fetchData()
})
return {
// State
data,
isLoading,
paginationMeta,
searchInput,
params,
queryParams,
// Functions
fetchData,
handlePageChange,
handleSearch,
}
}
export function transform(endpoint: string ,params: any): string {
const urlParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value) {
urlParams.append(key, value.toString())
}
})
return `${endpoint}?${urlParams.toString()}`
}
+25
View File
@@ -0,0 +1,25 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useQueryMode(key: string = 'mode') {
const route = useRoute()
const router = useRouter()
const mode = computed<'list' | 'add'>({
get: () => (route.query[key] && route.query[key] === 'add' ? 'add' : 'list'),
set: (val) => {
router.replace({
path: route.path,
query: {
...route.query,
[key]: val === 'list' ? undefined : val,
},
})
},
})
const openForm = () => (mode.value = 'add')
const backToList = () => (mode.value = 'list')
return { mode, openForm, backToList }
}
+49
View File
@@ -0,0 +1,49 @@
import type { Permission, RoleAccess } from '~/models/role'
/**
* Check if user has access to a page
*/
export function useRBAC() {
// NOTE: this roles was dummy for testing only, it should taken from the user store
const authStore = useUserStore()
const checkRole = (roleAccess: RoleAccess, _userRoles?: string[]): boolean => {
const roles = authStore.userRole
return roles.some((role: string) => role in roleAccess)
}
const checkPermission = (roleAccess: RoleAccess, permission: Permission, _userRoles?: string[]): boolean => {
const roles = authStore.userRole
// const roles = ['admisi']
return roles.some((role: string) => roleAccess[role]?.includes(permission))
}
const getUserPermissions = (roleAccess: RoleAccess, _userRoles?: string[]): Permission[] => {
const roles = authStore.userRole
// const roles = ['admisi']
const permissions = new Set<Permission>()
roles.forEach((role) => {
if (roleAccess[role]) {
roleAccess[role].forEach((permission) => permissions.add(permission))
}
})
return Array.from(permissions)
}
const hasCreateAccess = (roleAccess: RoleAccess) => checkPermission(roleAccess, 'C')
const hasReadAccess = (roleAccess: RoleAccess) => checkPermission(roleAccess, 'R')
const hasUpdateAccess = (roleAccess: RoleAccess) => checkPermission(roleAccess, 'U')
const hasDeleteAccess = (roleAccess: RoleAccess) => checkPermission(roleAccess, 'D')
return {
checkRole,
checkPermission,
getUserPermissions,
hasCreateAccess,
hasReadAccess,
hasUpdateAccess,
hasDeleteAccess,
}
}
+31
View File
@@ -0,0 +1,31 @@
import { ref, watchEffect } from 'vue'
const THEME_KEY = 'theme-mode'
export function useTheme() {
const theme = ref<'light' | 'dark'>(getInitialTheme())
function getInitialTheme() {
if (typeof window === 'undefined') return 'light'
const persisted = localStorage.getItem(THEME_KEY)
if (persisted === 'dark' || persisted === 'light') return persisted
// fallback: system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function setTheme(newTheme: 'light' | 'dark') {
theme.value = newTheme
localStorage.setItem(THEME_KEY, newTheme)
document.documentElement.classList.toggle('dark', newTheme === 'dark')
}
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
watchEffect(() => {
setTheme(theme.value)
})
return { theme, toggleTheme }
}
+75
View File
@@ -0,0 +1,75 @@
import type { Pinia } from 'pinia'
import type { XError, XErrors } from '~/types/error'
export interface XfetchResult {
success: boolean
status_code: number
body: object | any
errors?: XErrors | undefined
error?: XError | undefined
message?: string
}
export type XfetchMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
export async function xfetch(
url: string,
method: XfetchMethod = 'GET',
input?: object | FormData,
headers?: any,
_type = 'json',
): Promise<XfetchResult> {
let success = false
let body: object | any = {}
let errors: XErrors = {}
let error: XError | undefined = {
code: '',
message: '',
}
let message: string | undefined = ''
if (!headers) {
headers = {}
}
if (input && !(input instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
try {
const res = await $fetch.raw(url, {
method,
headers,
body: input instanceof FormData ? input : JSON.stringify(input),
})
body = res._data
success = true
return { success, status_code: res.status, body, errors, error, message }
} catch (fetchError: any) {
const status = fetchError.response?.status || 500
const resJson = fetchError.data
if (status === 401 && import.meta.client) {
clearStore()
}
if (resJson?.errors) {
errors = resJson.errors
} else if (resJson?.code && resJson?.message) {
error = { code: resJson.code, message: resJson.message }
} else if (resJson?.message) {
message = resJson.message
} else {
message = fetchError.message || 'Something went wrong'
}
return { success, status_code: status, body, errors, error, message }
}
}
function clearStore() {
const { $pinia } = useNuxtApp()
const userStore = useUserStore($pinia as Pinia)
userStore.logout()
navigateTo('/401')
}