Dev cleaning (#106)
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user