This commit is contained in:
phaichayon
2026-04-16 14:06:59 +07:00
parent 1e8d6a9b19
commit 4702150af1
50 changed files with 3815 additions and 3 deletions

View File

@@ -0,0 +1,46 @@
'use client';
import { usePathname } from 'next/navigation';
import { useMemo } from 'react';
type BreadcrumbItem = {
title: string;
link: string;
};
// This allows to add custom title as well
const routeMapping: Record<string, BreadcrumbItem[]> = {
'/dashboard': [{ title: 'Dashboard', link: '/dashboard' }],
'/dashboard/employee': [
{ title: 'Dashboard', link: '/dashboard' },
{ title: 'Employee', link: '/dashboard/employee' }
],
'/dashboard/product': [
{ title: 'Dashboard', link: '/dashboard' },
{ title: 'Product', link: '/dashboard/product' }
]
// Add more custom mappings as needed
};
export function useBreadcrumbs() {
const pathname = usePathname();
const breadcrumbs = useMemo(() => {
// Check if we have a custom mapping for this exact path
if (routeMapping[pathname]) {
return routeMapping[pathname];
}
// If no exact match, fall back to generating breadcrumbs from the path
const segments = pathname.split('/').filter(Boolean);
return segments.map((segment, index) => {
const path = `/${segments.slice(0, index + 1).join('/')}`;
return {
title: segment.charAt(0).toUpperCase() + segment.slice(1),
link: path
};
});
}, [pathname]);
return breadcrumbs;
}

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx
*/
/**
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
* prop or avoid re-executing effects when passed as a dependency
*/
function useCallbackRef<T extends (...args: never[]) => unknown>(callback: T | undefined): T {
const callbackRef = React.useRef(callback);
React.useEffect(() => {
callbackRef.current = callback;
});
// https://github.com/facebook/react/issues/19240
return React.useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}
export { useCallbackRef };

View File

@@ -0,0 +1,65 @@
import * as React from 'react';
import { useCallbackRef } from '@/hooks/use-callback-ref';
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
*/
type UseControllableStateParams<T> = {
prop?: T | undefined;
defaultProp?: T | undefined;
onChange?: (state: T) => void;
};
type SetStateFn<T> = (prevState?: T) => T;
function useControllableState<T>({
prop,
defaultProp,
onChange = () => {}
}: UseControllableStateParams<T>) {
const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
defaultProp,
onChange
});
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledProp;
const handleChange = useCallbackRef(onChange);
const setValue: React.Dispatch<React.SetStateAction<T | undefined>> = React.useCallback(
(nextValue) => {
if (isControlled) {
const setter = nextValue as SetStateFn<T>;
const value = typeof nextValue === 'function' ? setter(prop) : nextValue;
if (value !== prop) handleChange(value as T);
} else {
setUncontrolledProp(nextValue);
}
},
[isControlled, prop, setUncontrolledProp, handleChange]
);
return [value, setValue] as const;
}
function useUncontrolledState<T>({
defaultProp,
onChange
}: Omit<UseControllableStateParams<T>, 'prop'>) {
const uncontrolledState = React.useState<T | undefined>(defaultProp);
const [value] = uncontrolledState;
const prevValueRef = React.useRef(value);
const handleChange = useCallbackRef(onChange);
React.useEffect(() => {
if (prevValueRef.current !== value) {
handleChange(value as T);
prevValueRef.current = value;
}
}, [value, prevValueRef, handleChange]);
return uncontrolledState;
}
export { useControllableState };

284
src/hooks/use-data-table.ts Normal file
View File

@@ -0,0 +1,284 @@
'use client';
import {
type ColumnFiltersState,
type ColumnPinningState,
type PaginationState,
type RowSelectionState,
type SortingState,
type TableOptions,
type TableState,
type Updater,
type VisibilityState,
getCoreRowModel,
getFacetedMinMaxValues,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from '@tanstack/react-table';
import {
type Parser,
type UseQueryStateOptions,
parseAsArrayOf,
parseAsInteger,
parseAsString,
useQueryState,
useQueryStates
} from 'nuqs';
import * as React from 'react';
import { useDebouncedCallback } from '@/hooks/use-debounced-callback';
import { getSortingStateParser } from '@/lib/parsers';
import type { ExtendedColumnSort } from '@/types/data-table';
const PAGE_KEY = 'page';
const PER_PAGE_KEY = 'perPage';
const SORT_KEY = 'sort';
const ARRAY_SEPARATOR = ',';
const DEBOUNCE_MS = 300;
const THROTTLE_MS = 50;
interface UseDataTableProps<TData>
extends
Omit<
TableOptions<TData>,
| 'state'
| 'pageCount'
| 'getCoreRowModel'
| 'manualFiltering'
| 'manualPagination'
| 'manualSorting'
>,
Required<Pick<TableOptions<TData>, 'pageCount'>> {
initialState?: Omit<Partial<TableState>, 'sorting'> & {
sorting?: ExtendedColumnSort<TData>[];
};
history?: 'push' | 'replace';
debounceMs?: number;
throttleMs?: number;
clearOnDefault?: boolean;
enableAdvancedFilter?: boolean;
scroll?: boolean;
shallow?: boolean;
startTransition?: React.TransitionStartFunction;
}
export function useDataTable<TData>(props: UseDataTableProps<TData>) {
const {
columns,
pageCount = -1,
initialState,
history = 'replace',
debounceMs = DEBOUNCE_MS,
throttleMs = THROTTLE_MS,
clearOnDefault = false,
enableAdvancedFilter = false,
scroll = false,
shallow = true,
startTransition,
...tableProps
} = props;
const queryStateOptions = React.useMemo<Omit<UseQueryStateOptions<string>, 'parse'>>(
() => ({
history,
scroll,
shallow,
throttleMs,
debounceMs,
clearOnDefault,
startTransition
}),
[history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, startTransition]
);
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
initialState?.rowSelection ?? {}
);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(
initialState?.columnVisibility ?? {}
);
const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>(
initialState?.columnPinning ?? {}
);
const [page, setPage] = useQueryState(
PAGE_KEY,
parseAsInteger.withOptions(queryStateOptions).withDefault(1)
);
const [perPage, setPerPage] = useQueryState(
PER_PAGE_KEY,
parseAsInteger
.withOptions(queryStateOptions)
.withDefault(initialState?.pagination?.pageSize ?? 10)
);
const pagination: PaginationState = React.useMemo(() => {
return {
pageIndex: page - 1, // zero-based index -> one-based index
pageSize: perPage
};
}, [page, perPage]);
const onPaginationChange = React.useCallback(
(updaterOrValue: Updater<PaginationState>) => {
if (typeof updaterOrValue === 'function') {
const newPagination = updaterOrValue(pagination);
void setPage(newPagination.pageIndex + 1);
void setPerPage(newPagination.pageSize);
} else {
void setPage(updaterOrValue.pageIndex + 1);
void setPerPage(updaterOrValue.pageSize);
}
},
[pagination, setPage, setPerPage]
);
const columnIds = React.useMemo(() => {
return new Set(columns.map((column) => column.id).filter(Boolean) as string[]);
}, [columns]);
const [sorting, setSorting] = useQueryState(
SORT_KEY,
getSortingStateParser<TData>(columnIds)
.withOptions(queryStateOptions)
.withDefault(initialState?.sorting ?? [])
);
const onSortingChange = React.useCallback(
(updaterOrValue: Updater<SortingState>) => {
if (typeof updaterOrValue === 'function') {
const newSorting = updaterOrValue(sorting);
setSorting(newSorting as ExtendedColumnSort<TData>[]);
} else {
setSorting(updaterOrValue as ExtendedColumnSort<TData>[]);
}
},
[sorting, setSorting]
);
const filterableColumns = React.useMemo(() => {
if (enableAdvancedFilter) return [];
return columns.filter((column) => column.enableColumnFilter);
}, [columns, enableAdvancedFilter]);
const filterParsers = React.useMemo(() => {
if (enableAdvancedFilter) return {};
return filterableColumns.reduce<Record<string, Parser<string> | Parser<string[]>>>(
(acc, column) => {
if (column.meta?.options) {
acc[column.id ?? ''] = parseAsArrayOf(parseAsString, ARRAY_SEPARATOR).withOptions(
queryStateOptions
);
} else {
acc[column.id ?? ''] = parseAsString.withOptions(queryStateOptions);
}
return acc;
},
{}
);
}, [filterableColumns, queryStateOptions, enableAdvancedFilter]);
const [filterValues, setFilterValues] = useQueryStates(filterParsers);
const debouncedSetFilterValues = useDebouncedCallback((values: typeof filterValues) => {
void setPage(1);
void setFilterValues(values);
}, debounceMs);
const initialColumnFilters: ColumnFiltersState = React.useMemo(() => {
if (enableAdvancedFilter) return [];
return Object.entries(filterValues).reduce<ColumnFiltersState>((filters, [key, value]) => {
if (value !== null) {
const processedValue = Array.isArray(value)
? value
: typeof value === 'string' && /[^a-zA-Z0-9]/.test(value)
? value.split(/[^a-zA-Z0-9]+/).filter(Boolean)
: [value];
filters.push({
id: key,
value: processedValue
});
}
return filters;
}, []);
}, [filterValues, enableAdvancedFilter]);
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>(initialColumnFilters);
const onColumnFiltersChange = React.useCallback(
(updaterOrValue: Updater<ColumnFiltersState>) => {
if (enableAdvancedFilter) return;
setColumnFilters((prev) => {
const next = typeof updaterOrValue === 'function' ? updaterOrValue(prev) : updaterOrValue;
const filterUpdates = next.reduce<Record<string, string | string[] | null>>(
(acc, filter) => {
if (filterableColumns.find((column) => column.id === filter.id)) {
acc[filter.id] = filter.value as string | string[];
}
return acc;
},
{}
);
for (const prevFilter of prev) {
if (!next.some((filter) => filter.id === prevFilter.id)) {
filterUpdates[prevFilter.id] = null;
}
}
debouncedSetFilterValues(filterUpdates);
return next;
});
},
[debouncedSetFilterValues, filterableColumns, enableAdvancedFilter]
);
const table = useReactTable({
...tableProps,
columns,
initialState,
pageCount,
state: {
pagination,
sorting,
columnVisibility,
columnPinning,
rowSelection,
columnFilters
},
defaultColumn: {
...tableProps.defaultColumn,
enableColumnFilter: false
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onPaginationChange,
onSortingChange,
onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onColumnPinningChange: setColumnPinning,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
manualPagination: true,
manualSorting: true,
manualFiltering: true
});
return { table, shallow, debounceMs, throttleMs };
}

View File

@@ -0,0 +1,19 @@
'use client';
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { useCallbackRef } from '@/hooks/use-callback-ref';
export function useDebouncedCallback<T extends (...args: never[]) => unknown>(
callback: T,
delay: number
) {
const handleCallback = useCallbackRef(callback);
const debounceTimerRef = React.useRef(0);
React.useEffect(() => () => window.clearTimeout(debounceTimerRef.current), []);
const setValue = React.useCallback(
(...args: Parameters<T>) => {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = window.setTimeout(() => handleCallback(...args), delay);
},
[handleCallback, delay]
);
return setValue;
}

View File

@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
export function useMediaQuery() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 768px)');
setIsOpen(mediaQuery.matches);
const handler = (e: MediaQueryListEvent) => {
setIsOpen(e.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return { isOpen };
}

19
src/hooks/use-mobile.tsx Normal file
View File

@@ -0,0 +1,19 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}

180
src/hooks/use-nav.ts Normal file
View File

@@ -0,0 +1,180 @@
'use client';
/**
* Fully client-side hook for filtering navigation items based on RBAC
*
* This hook uses Clerk's client-side hooks to check permissions, roles, and organization
* without any server calls. This is perfect for navigation visibility (UX only).
*
* Performance:
* - All checks are synchronous (no server calls)
* - Instant filtering
* - No loading states
* - No UI flashing
*
* Note: For actual security (API routes, server actions), always use server-side checks.
* This is only for UI visibility.
*/
import { useMemo } from 'react';
import { useOrganization, useUser } from '@clerk/nextjs';
import type { NavItem, NavGroup } from '@/types';
/**
* Hook to filter navigation items based on RBAC (fully client-side)
*
* @param items - Array of navigation items to filter
* @returns Filtered items
*/
export function useFilteredNavItems(items: NavItem[]) {
const { organization, membership } = useOrganization();
const { user } = useUser();
// Memoize context and permissions
const accessContext = useMemo(() => {
const permissions = membership?.permissions || [];
const role = membership?.role;
return {
organization: organization ?? undefined,
user: user ?? undefined,
permissions: permissions as string[],
role: role ?? undefined,
hasOrg: !!organization
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- using stable primitives to avoid infinite re-renders from unstable Clerk object refs
}, [organization?.id, user?.id, membership?.permissions, membership?.role]);
// Filter items synchronously (all client-side)
const filteredItems = useMemo(() => {
return items
.filter((item) => {
// No access restrictions
if (!item.access) {
return true;
}
// Check requireOrg
if (item.access.requireOrg && !accessContext.hasOrg) {
return false;
}
// Check permission
if (item.access.permission) {
if (!accessContext.hasOrg) {
return false;
}
if (!accessContext.permissions.includes(item.access.permission)) {
return false;
}
}
// Check role
if (item.access.role) {
if (!accessContext.hasOrg) {
return false;
}
if (accessContext.role !== item.access.role) {
return false;
}
}
// Note: Plans and features require server-side checks with Clerk's has() function
// For navigation visibility, you can either:
// 1. Store plan/feature info in organization metadata (client-accessible)
// 2. Use server actions (current approach)
// 3. Skip plan/feature checks for navigation (recommended for performance)
// For now, if plan/feature is specified, we'll need to handle it differently
// Most navigation items won't need plan/feature checks anyway
if (item.access.plan || item.access.feature) {
// Option: Return true and let the page handle it, or use server action
// For now, we'll show it (page-level protection should handle it)
console.warn(
`Plan/feature checks for navigation items require server-side verification. ` +
`Item "${item.title}" will be shown, but page-level protection should be implemented.`
);
}
return true;
})
.map((item) => {
// Recursively filter child items
if (item.items && item.items.length > 0) {
const filteredChildren = item.items.filter((childItem) => {
// No access restrictions
if (!childItem.access) {
return true;
}
// Check requireOrg
if (childItem.access.requireOrg && !accessContext.hasOrg) {
return false;
}
// Check permission
if (childItem.access.permission) {
if (!accessContext.hasOrg) {
return false;
}
if (!accessContext.permissions.includes(childItem.access.permission)) {
return false;
}
}
// Check role
if (childItem.access.role) {
if (!accessContext.hasOrg) {
return false;
}
if (accessContext.role !== childItem.access.role) {
return false;
}
}
// Plan/feature checks (same warning as above)
if (childItem.access.plan || childItem.access.feature) {
console.warn(
`Plan/feature checks for navigation items require server-side verification. ` +
`Item "${childItem.title}" will be shown, but page-level protection should be implemented.`
);
}
return true;
});
return {
...item,
items: filteredChildren
};
}
return item;
});
}, [items, accessContext]);
return filteredItems;
}
/**
* Hook to filter navigation groups based on RBAC (fully client-side)
*
* @param groups - Array of navigation groups to filter
* @returns Filtered groups (empty groups are removed)
*/
export function useFilteredNavGroups(groups: NavGroup[]) {
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
const filteredItems = useFilteredNavItems(allItems);
return useMemo(() => {
const filteredSet = new Set(filteredItems.map((item) => item.title));
return groups
.map((group) => ({
...group,
items: filteredItems.filter((item) =>
group.items.some((gi) => gi.title === item.title && filteredSet.has(gi.title))
)
}))
.filter((group) => group.items.length > 0);
}, [groups, filteredItems]);
}

100
src/hooks/use-stepper.tsx Normal file
View File

@@ -0,0 +1,100 @@
import type { AnyFormApi } from '@tanstack/react-form';
import { useCallback, useState } from 'react';
import type { ZodTypeAny } from 'zod';
/**
* Options for handling cancel/back actions
*/
type HandleCancelOrBackOpts = {
onBack?: VoidFunction;
onCancel?: VoidFunction;
};
/**
* State of the current step
*/
type StepState = {
value: number;
count: number;
goToNextStep: () => void;
goToPrevStep: () => void;
isCompleted: boolean;
};
/**
* Hook for managing multi-step form navigation and validation
*
* @param schemas - Array of Zod schemas for each step
* @returns Object with stepper state and methods
*/
export function useFormStepper(schemas: ZodTypeAny[]) {
const stepCount = schemas.length;
const [currentStep, setCurrentStep] = useState(1); // Start from 1
const goToNextStep = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, stepCount));
}, [stepCount]);
const goToPrevStep = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
}, []);
const step: StepState = {
value: currentStep,
count: stepCount,
goToNextStep,
goToPrevStep,
isCompleted: currentStep === stepCount
};
const currentValidator = schemas[currentStep - 1]; // Convert to 0-based for array access
const isFirstStep = currentStep === 1;
const triggerFormGroup = async (form: AnyFormApi) => {
const result = currentValidator.safeParse(form.state.values);
if (!result.success) {
await form.handleSubmit({ step: String(currentStep) });
return result;
}
return result;
};
const handleNextStepOrSubmit = async (form: AnyFormApi) => {
const result = await triggerFormGroup(form);
if (!result.success) {
return;
}
if (currentStep < stepCount) {
goToNextStep();
return;
}
if (currentStep === stepCount) {
form.handleSubmit();
}
};
const handleCancelOrBack = (opts?: HandleCancelOrBackOpts) => {
if (isFirstStep || step.isCompleted) {
opts?.onCancel?.();
return;
}
if (currentStep > 1) {
opts?.onBack?.();
goToPrevStep();
}
};
return {
step, // Current step state
currentStep, // Current step number (1-based)
isFirstStep, // Whether current step is the first step
currentValidator, // Zod schema for current step
triggerFormGroup, // Validate current step fields
handleNextStepOrSubmit, // Handle next/submit action
handleCancelOrBack // Handle back/cancel action
};
}