commit
This commit is contained in:
46
src/hooks/use-breadcrumbs.tsx
Normal file
46
src/hooks/use-breadcrumbs.tsx
Normal 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;
|
||||
}
|
||||
22
src/hooks/use-callback-ref.tsx
Normal file
22
src/hooks/use-callback-ref.tsx
Normal 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 };
|
||||
65
src/hooks/use-controllable-state.tsx
Normal file
65
src/hooks/use-controllable-state.tsx
Normal 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
284
src/hooks/use-data-table.ts
Normal 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 };
|
||||
}
|
||||
19
src/hooks/use-debounce.tsx
Normal file
19
src/hooks/use-debounce.tsx
Normal 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;
|
||||
}
|
||||
22
src/hooks/use-debounced-callback.ts
Normal file
22
src/hooks/use-debounced-callback.ts
Normal 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;
|
||||
}
|
||||
19
src/hooks/use-media-query.ts
Normal file
19
src/hooks/use-media-query.ts
Normal 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
19
src/hooks/use-mobile.tsx
Normal 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
180
src/hooks/use-nav.ts
Normal 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
100
src/hooks/use-stepper.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user