Reaktywny stan aplikacji w Vue Inertia.
// helpers/useHelpers.ts
import { Ref } from 'vue'
export const queryParams: Record<string, any> = {}
export function updateState<T extends Record<string, any>, K extends Record<string, any>>(
tableState: Ref<T>,
filters: K,
): void {
for (const [k, val] of Object.entries(filters)) {
// Czyści puste parametry filtra z url
if (val !== '' && val !== undefined && val !== null) {
tableState.value = {
...tableState.value,
[k]: val,
}
}
}
}<script setup lang="ts">
import AdminLayout from '@/layouts/panel/AdminLayout.vue'
import { ref, computed, onMounted, watch, toValue } from 'vue'
import { LaravelPaginationPayload, RoleFilterOption, TableFilters, TableState } from '@/types/pages'
import { router } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import { updateState } from '@/composables/pages/admin/users/useTableState'
import TestSort from './TestSort.vue'
// Locales
const { t } = useI18n()
// Layout
const routeUrl = '/admin/users'
const description = 'Manage user accounts, email verification, and 2FA security.'
const title = 'Users'
// Propsy, eventy
const props = defineProps<{
payload: LaravelPaginationPayload
roles: RoleFilterOption[] // Zmień na roleFacets
filters: TableFilters
}>()
// Inicjalizacja z serwera
const tableState = ref({
page: props.payload.current_page || 1,
per_page: props.payload.per_page || 10,
sort_by: 'id',
sort_dir: 'asc',
// Można tak ale nulle
// ...props.filters,
} as TableState)
// Update tak lepiej
updateState(tableState, props.filters)
// Dane
const rolesList = ref(props.roles)
const currentUsers = computed(() => props.payload.data)
function updateTableData(baseRoute: string, data: any) {
const rawData = toValue(data)
const cleanParams = Object.fromEntries(
Object.entries(rawData).filter(
([_, value]) => value !== null && value !== undefined && value !== '',
),
) as Record<string, any>
router.get(baseRoute, cleanParams, {
preserveState: true,
preserveScroll: true,
replace: true,
})
}
watch(
() => JSON.parse(JSON.stringify(tableState.value)), // Wymagane dla debouncera na setTimeout
(newState, oldState, onCleanup) => {
if (newState.search !== oldState?.search) {
const searchTimeout = setTimeout(() => {
console.log('Watch z debounce:', newState.search)
tableState.value.page = 1
updateTableData(routeUrl, tableState)
}, 400)
onCleanup(() => clearTimeout(searchTimeout))
} else {
console.log('Watch bez debounce (zmiana filtrów/strony):', newState)
updateTableData(routeUrl, tableState)
}
},
{ deep: true },
)
</script>
<template>
<AdminLayout :title="title" :description="description" :route-url="routeUrl">
<h2>Props Filters</h2>
<pre class="p-6">{{ props.filters }}</pre>
<h2>Parent State</h2>
<pre class="p-6">{{ tableState }}</pre>
<!-- Przekazujesz tylko pole 'search' jako v-model:search, a resztę stanu chronisz -->
<TestSort
:table-state="tableState"
v-model="tableState"
v-model:search="tableState.search"
@update:search="(val) => (tableState.search = val)"
/>
<h2>Data</h2>
{{ payload.data }}
</AdminLayout>
</template><!-- Przekazywanie przez props, można aktualizować stan objektu rodzica, ochrona strukrury, nie zalecane bez emits -->
<TestSort :table-state="tableState" @update:search="val => (tableState.search = val)" />
<!-- Przekazywanie całego tableState można usunąć cały stan, odbierasz przez defineModel() -->
<TestSort v-model="tableState" />
<!-- Przekazujesz tylko pole 'search' jako v-model:search, a resztę stanu chronisz, odbierasz przez defineModel('search') -->
<TestSort v-model:search="tableState.search" />
<!-- Przekazanie tylko do odczytu -->
<TestSort v-model="readonly(tableState)" />
<!-- Przekazanie do testów wszystkiego -->
<TestSort
:table-state="tableState"
v-model="tableState"
v-model:search="tableState.search"
@update:search="(val) => (tableState.search = val)"
/><script setup lang="ts">
import { DeepReadonly, computed } from 'vue'
import { TableState } from '@/types/pages'
// Jeżeli przekazany jest Object to jego parametry są "ref"
// będą sie aktualizowały w rodzicu (ten sam obszar w pamięci, nie zalecane!).
// defineProps<{ tableState: TableState }>()
// Ochona stanu rodzica v-model readonly() dla emit
defineProps<{ tableState: DeepReadonly<TableState> }>()
// Definiujemy model z odpowiednim typem
const tableState = defineModel<TableState>({ required: true })
// Ochrona struktury stanu rodzica
const search = defineModel<string | undefined>('search', { required: false })
// const searchQuery = defineModel<string>('searchQuery', { default: '' })
// Ochrona struktury stanu proxy na v-model tableState
const state = computed({
// Gdy czyta state.search, pobiera to z oryginalnego modelu
get: () => tableState.value,
// Gdy dziecko próbuje zmienić np. state.search = 'user'
set: (newValue: any) => {
// Update v-model tableState
tableState.value = {
// Wymusza zachowanie struktury rodzica
...tableState.value,
// Nadpisuje tylko to, co dziecko faktycznie zmieniło
...newValue,
}
},
})
// Deklaracja eventu
const emit = defineEmits<{ (e: 'update:search', value: string): void }>()
// Aktualizuj stanu lokalnego i rodzica
function changeSearch() {
state.value.search = 'computed'
}
</script>
<template>
<div class="p-6">
<h3>Sort tableState</h3>
<pre>{{ tableState }}</pre>
<h3>Sort search</h3>
<pre>{{ search }}</pre>
<button class="p-6" @click="tableState.search = 'booo'">Change with state</button>
<button class="p-6" @click="search = 'mooo'">Change with search</button>
<button class="p-6" @click="emit('update:search', 'event')">Change with event</button>
<button class="p-6" @click="changeSearch">Change with computed</button>
<div class="p-6">
<input
v-model="tableState.search"
type="text"
placeholder="Search by name or email"
class="border-input mb-2 border px-4 py-2"
/>
<input
v-model="tableState.date_from"
type="text"
placeholder="Date: YYYY-MM-DD"
class="border-input mb-2 border px-4 py-2"
/>
</div>
</div>
</template>export interface LaravelPaginationPayload<T = any> {
current_page: number
data: T[]
first_page_url: string
from: number
last_page: number
last_page_url: string
links: PaginationLink[]
next_page_url: string | null
path: string
per_page: number
prev_page_url: string | null
to: number
total: number
}
export interface RoleFilterOption {
value: string
label: string
count: number
}
// Tu znaki zapytania co by się linter w tableState nie czepiał!
export interface TableFilters {
page?: number
per_page?: 1 | 5 | 10 | 25 | 50 | 100 | number
search?: string
sort_by?: 'id' | 'name' | 'email' | 'created_at' | 'filter_2fa' | 'filter_verified'
sort_dir?: 'asc' | 'desc'
date_from?: string // Format YYYY-MM-DD
date_to?: string // Format YYYY-MM-DD
filter_verified?: 'all' | 'verified' | 'unverified' | boolean
filter_2fa?: 'all' | 'enabled' | 'disabled' | boolean
roles?: number[] | string[] | RoleFilterOption[]
[key: string]: any
}
export interface TableState extends TableFilters {
[key: string]: any
}
export interface PaginationLink {
url: string | null
label: string
active: boolean
}
export interface Role {
id: number
name: string
guard_name: string
created_at?: string
updated_at?: string
}
export interface Permissions {
id: number
name: string
guard_name: string
created_at?: string
updated_at?: string
}
export interface User {
id: number
name: string
email: string
email_verified_at: string | null
created_at: string
updated_at: string
two_factor_confirmed_at: string | null
roles: Role[]
permissions: Permissions[]
}protected $protectedRoles = ['admin', 'superadmin'];
protected $sortingColumns = ['id', 'name', 'created_at', 'email_verified_at', 'two_factor_confirmed_at'];
protected $filterUrlParams = ['filter_verified', 'filter_2fa', 'date_to', 'date_from', 'page', 'per_page', 'roles', 'search', 'sort_by', 'sort_dir'];
public function index(Request $request)
{
// Filterable trait scope
// $users = User::searchFilters($filters)->paginate(15);
$perPage = $request->input('per_page') <= 100 ? $request->input('per_page', 10) : 100;
$sortBy = $request->input('sort_by', 'id');
$sortDir = strtolower($request->input('sort_dir', 'desc')) === 'asc' ? 'asc' : 'desc';
if (!in_array($sortBy, $this->sortingColumns)) {
$sortBy = 'id';
}
// Update request
$request->merge([
'sort_by' => $sortBy,
'sort_dir' => $sortDir,
]);
$usersQuery = User::query()->with('roles')
->when($request->input('search'), function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
})
->when($request->input('filter_verified'), function ($query, $verified) {
if ($verified === 'verified') {
$query->whereNotNull('email_verified_at');
} elseif ($verified === 'unverified') {
$query->whereNull('email_verified_at');
}
})
->when($request->input('filter_2fa'), function ($query, $twoFactor) {
if ($twoFactor === 'enabled') {
$query->whereNotNull('two_factor_confirmed_at');
} elseif ($twoFactor === 'disabled') {
$query->whereNull('two_factor_confirmed_at');
}
})
->when($request->input('date_from'), function ($query, $dateFrom) {
$query->whereDate('created_at', '>=', $dateFrom);
})
->when($request->input('date_to'), function ($query, $dateTo) {
$query->whereDate('created_at', '<=', $dateTo);
})
->when($request->input('roles'), function ($query, $roles) {
$query->role($roles); // Spatie
});
// Reset
$usersQuery->reorder();
// Move nullable rows to the end
if (in_array($sortBy, ['email_verified_at', 'two_factor_confirmed_at'])) {
$usersQuery->orderByRaw("{$sortBy} IS NULL ASC")->orderBy($sortBy, $sortDir);
} else {
$usersQuery->orderBy($sortBy, $sortDir);
}
return Inertia::render('admin/users/Index', [
'payload' => $usersQuery->paginate($perPage)->withQueryString(),
'roles' => $this->roleFacetsWithSearch($request),
'filters' => $request->only($this->filterUrlParams),
]);
}
public function roleFacetsWithSearch(Request $request, $guard = 'web')
{
$search = $request->input('search');
return Role::query()
->withCount(['users' => function ($query) use ($search) {
$query->when(!empty($search), function ($q) use ($search) {
$q->where(function ($sub) use ($search) {
$sub->where('users.name', 'like', "%{$search}%")
->orWhere('users.email', 'like', "%{$search}%");
});
});
}])
->where('roles.guard_name', $guard)
->get(['id', 'name'])->map(fn($role) => [
'value' => $role->name,
'label' => ucfirst($role->name),
'count' => $role->users_count,
]);
}Tak się można jeszcze wygłupiać ale to niepojęte jak sami sobie utrudnić życie w ts potrafią (gdy nie dodasz ? w TableFilters).
// Inicjalizacja z serwera
// const { search, sort_by, sort_dir, ...otherFilters } = props.filters || {}
const { search, sort_by, sort_dir, ...otherFilters } = (props.filters || {}) as Record<string, any>
const tableState = ref<TableState>({
page: props.payload?.current_page || 1,
per_page: props.payload?.per_page || 10,
// Przypisuje wartości z fallbackami
sort_by: sort_by || 'id',
sort_dir: sort_dir || 'asc',
search: search || '',
// Już tylko pozostałę filtry (gdy serwer wycina nulle)
...otherFilters,
})
// Inicjalizacja z serwera
const tableState = ref({
page: props.payload.current_page || 1,
per_page: props.payload.per_page || 10,
sort_by: props.filters?.sort_by || 'id',
sort_dir: props.filters?.sort_dir || 'asc',
search: props.filters?.search || '',
// Nadpisuje filtrami z serwera bez error
...(props.filters as Omit<
TableFilters,
'page' | 'per_page' | 'search' | 'sort_by' | 'sort_dir'
>),
} as TableState)