Skip to content

Instantly share code, notes, and snippets.

@atomjoy
Last active September 11, 2026 09:48
Show Gist options
  • Select an option

  • Save atomjoy/bed8a8ab73447ce0c6cf17445c8561a9 to your computer and use it in GitHub Desktop.

Select an option

Save atomjoy/bed8a8ab73447ce0c6cf17445c8561a9 to your computer and use it in GitHub Desktop.
Reaktywny stan aplikacji w Vue Inertia.

Table State Laravel Vue Inertia

Reaktywny stan aplikacji w Vue Inertia.

Funkcje

// 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,
			}
		}
	}
}

Vue Inertia

<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 danych do komponentu

<!-- 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)"
/>

Komponent

<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>

Typy danych

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[]
}

Controller

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,
        ]);
}

Inicjalizacja

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment