Enhanced Sidebar to support sub collapsible sections (#80)

* Enhanced Sidebar to support sub collapsible sections
* Data-Table: support getSortedRowModel
* Add missing renderAction; 
* Fix Sidebar on mobile
* Do not initialize Analytics Provider server side
* Do not bind i18Next until it's initialized
* Avoid infinite redirects in the auth path when Supabase emits a SIGNED_OUT event
* Force admin layout to be dynamic
This commit is contained in:
Giancarlo Buomprisco
2024-11-06 16:01:45 +01:00
committed by GitHub
parent 27ef8f7510
commit 465655fdd4
18 changed files with 322 additions and 160 deletions

View File

@@ -17,7 +17,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "^22.8.6"
"@types/node": "^22.8.7"
},
"eslintConfig": {
"root": true,

View File

@@ -19,7 +19,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/wordpress": "workspace:*",
"@types/node": "^22.8.6"
"@types/node": "^22.8.7"
},
"eslintConfig": {
"root": true,

View File

@@ -26,7 +26,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "^22.8.6",
"@types/node": "^22.8.7",
"@types/react": "npm:types-react@19.0.0-rc.1",
"react": "19.0.0-rc-45804af1-20241021",
"zod": "^3.23.8"

View File

@@ -20,7 +20,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "^22.8.6",
"@types/node": "^22.8.7",
"@types/react": "npm:types-react@19.0.0-rc.1",
"wp-types": "^4.66.1"
},

View File

@@ -15,26 +15,52 @@ export async function initializeServerI18n(
const i18nInstance = createInstance();
const loadedNamespaces = new Set<string>();
await i18nInstance
.use(
resourcesToBackend(async (language, namespace, callback) => {
try {
const data = await resolver(language, namespace);
loadedNamespaces.add(namespace);
await new Promise((resolve) => {
void i18nInstance
.use(
resourcesToBackend(async (language, namespace, callback) => {
try {
const data = await resolver(language, namespace);
loadedNamespaces.add(namespace);
return callback(null, data);
} catch (error) {
console.log(
`Error loading i18n file: locales/${language}/${namespace}.json`,
error,
);
return callback(null, data);
} catch (error) {
console.log(
`Error loading i18n file: locales/${language}/${namespace}.json`,
error,
);
return callback(null, {});
}
}),
)
.use(initReactI18next)
.init(settings);
return callback(null, {});
}
}),
)
.use({
type: '3rdParty',
init: async (i18next: typeof i18nInstance) => {
let iterations = 0;
const maxIterations = 100;
// do not bind this to the i18next instance until it's initialized
while (i18next.isInitializing) {
iterations++;
if (iterations > maxIterations) {
console.error(
`i18next is not initialized after ${maxIterations} iterations`,
);
break;
}
await new Promise((resolve) => setTimeout(resolve, 1));
}
initReactI18next.init(i18next);
resolve(i18next);
},
})
.init(settings);
});
const namespaces = settings.ns as string[];

View File

@@ -19,7 +19,7 @@
"@kit/resend": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "^22.8.6",
"@types/node": "^22.8.7",
"zod": "^3.23.8"
},
"eslintConfig": {

View File

@@ -18,7 +18,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "^22.8.6",
"@types/node": "^22.8.7",
"zod": "^3.23.8"
},
"eslintConfig": {

View File

@@ -14,6 +14,12 @@ import { useSupabase } from './use-supabase';
*/
const PRIVATE_PATH_PREFIXES = ['/home', '/admin', '/join', '/update-password'];
/**
* @name AUTH_PATHS
* @description A list of auth paths
*/
const AUTH_PATHS = ['/auth'];
/**
* @name useAuthChangeListener
* @param privatePathPrefixes - A list of private path prefixes
@@ -53,6 +59,12 @@ export function useAuthChangeListener({
// revalidate user session when user signs in or out
if (event === 'SIGNED_OUT') {
// sometimes Supabase sends SIGNED_OUT event
// but in the auth path, so we ignore it
if (AUTH_PATHS.some((path) => pathName.startsWith(path))) {
return;
}
window.location.reload();
}
});

View File

@@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation';
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import type {
@@ -45,6 +46,8 @@ interface ReactTableProps<T extends object> {
pageSize?: number;
pageCount?: number;
onPaginationChange?: (pagination: PaginationState) => void;
manualPagination?: boolean;
manualSorting?: boolean;
tableProps?: React.ComponentProps<typeof Table> &
Record<`data-${string}`, string>;
}
@@ -57,6 +60,8 @@ export function DataTable<T extends object>({
pageCount,
onPaginationChange,
tableProps,
manualPagination = true,
manualSorting = false,
}: ReactTableProps<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: pageIndex ?? 0,
@@ -74,7 +79,9 @@ export function DataTable<T extends object>({
data,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
getSortedRowModel: getSortedRowModel(),
manualPagination,
manualSorting,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,

View File

@@ -9,25 +9,32 @@ const Divider = z.object({
divider: z.literal(true),
});
const RouteChildren = z.array(
z.object({
label: z.string(),
path: z.string(),
Icon: z.custom<React.ReactNode>(),
end: RouteMatchingEnd,
children: z
.array(
z.object({
label: z.string(),
path: z.string(),
Icon: z.custom<React.ReactNode>(),
end: RouteMatchingEnd,
}),
)
.default([])
.optional(),
}),
);
const RouteSubChild = z.object({
label: z.string(),
path: z.string(),
Icon: z.custom<React.ReactNode>().optional(),
end: RouteMatchingEnd,
renderAction: z.custom<React.ReactNode>().optional(),
});
const RouteChild = z.object({
label: z.string(),
path: z.string(),
Icon: z.custom<React.ReactNode>().optional(),
end: RouteMatchingEnd,
children: z.array(RouteSubChild).default([]).optional(),
collapsible: z.boolean().default(false).optional(),
collapsed: z.boolean().default(false).optional(),
renderAction: z.custom<React.ReactNode>().optional(),
});
const RouteGroup = z.object({
label: z.string(),
collapsible: z.boolean().optional(),
collapsed: z.boolean().optional(),
children: z.array(RouteChild),
renderAction: z.custom<React.ReactNode>().optional(),
});
export const NavigationConfigSchema = z.object({
style: z.enum(['custom', 'sidebar', 'header']).default('sidebar'),
@@ -36,16 +43,5 @@ export const NavigationConfigSchema = z.object({
.default('false')
.optional()
.transform((value) => value === `true`),
routes: z.array(
z.union([
z.object({
label: z.string(),
collapsible: z.boolean().optional(),
collapsed: z.boolean().optional(),
children: RouteChildren,
renderAction: z.custom<React.ReactNode>().optional(),
}),
Divider,
]),
),
routes: z.array(z.union([RouteGroup, Divider])),
});

View File

@@ -329,16 +329,24 @@ export function SidebarNavigation({
collapsed={item.collapsed}
>
{item.children.map((child) => {
return (
<SidebarItem
key={child.path}
end={child.end}
path={child.path}
Icon={child.Icon}
>
<Trans i18nKey={child.label} defaults={child.label} />
</SidebarItem>
);
if ('collapsible' in child && child.collapsible) {
throw new Error(
'Collapsible groups are not supported in the old Sidebar. Please migrate to the new Sidebar.',
);
}
if ('path' in child) {
return (
<SidebarItem
key={child.path}
end={child.end}
path={child.path}
Icon={child.Icon}
>
<Trans i18nKey={child.label} defaults={child.label} />
</SidebarItem>
);
}
})}
</SidebarGroup>
);

View File

@@ -910,18 +910,96 @@ export function SidebarNavigation({
</SidebarGroupAction>
</If>
<ContentContainer>
<SidebarGroupContent>
<SidebarMenu>
{item.children.map((child) => {
const isActive = isRouteActive(
child.path,
currentPath,
child.end,
);
<SidebarGroupContent>
<SidebarMenu>
<ContentContainer>
{item.children.map((child, childIndex) => {
if (child.renderAction) {
return (
<SidebarMenuSubItem key={child.path}>
{child.renderAction}
</SidebarMenuSubItem>
);
}
return (
<SidebarMenuItem key={child.path}>
const Container = (props: React.PropsWithChildren) => {
if ('collapsible' in child && child.collapsible) {
return (
<Collapsible
defaultOpen={!child.collapsed}
className={'group/collapsible'}
>
{props.children}
</Collapsible>
);
}
return props.children;
};
const ContentContainer = (
props: React.PropsWithChildren,
) => {
if ('collapsible' in child && child.collapsible) {
return (
<CollapsibleContent>
{props.children}
</CollapsibleContent>
);
}
return props.children;
};
const TriggerItem = () => {
if ('collapsible' in child && child.collapsible) {
return (
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={child.label}>
<div
className={cn('flex items-center gap-2', {
'mx-auto w-full gap-0 [&>svg]:flex-1 [&>svg]:shrink-0':
minimized,
})}
>
{child.Icon}
<span
className={cn(
'w-auto transition-opacity duration-300',
{
'w-0 opacity-0': minimized,
},
)}
>
<Trans
i18nKey={child.label}
defaults={child.label}
/>
</span>
<ChevronDown
className={cn(
'ml-auto size-4 transition-transform group-data-[state=open]/collapsible:rotate-180',
{
'hidden size-0': minimized,
},
)}
/>
</div>
</SidebarMenuButton>
</CollapsibleTrigger>
);
}
const path = 'path' in child ? child.path : '';
const end = 'end' in child ? child.end : false;
const isActive = isRouteActive(
path,
currentPath,
end,
);
return (
<SidebarMenuButton
asChild
isActive={isActive}
@@ -932,10 +1010,9 @@ export function SidebarNavigation({
'mx-auto w-full !gap-0 [&>svg]:flex-1':
minimized,
})}
href={child.path}
href={path}
>
{child.Icon}
<span
className={cn(
'w-auto transition-opacity duration-300',
@@ -951,61 +1028,86 @@ export function SidebarNavigation({
</span>
</Link>
</SidebarMenuButton>
);
};
<If condition={child.children}>
{(children) => (
<SidebarMenuSub
className={cn({ 'mx-0 px-1.5': minimized })}
>
{children.map((child) => {
const isActive = isRouteActive(
child.path,
currentPath,
child.end,
);
return (
<Container key={`group-${index}-${childIndex}`}>
<SidebarMenuItem>
<TriggerItem />
return (
<SidebarMenuSubItem key={child.path}>
<SidebarMenuSubButton
isActive={isActive}
asChild
>
<Link
className={cn('flex items-center', {
'mx-auto w-full !gap-0 [&>svg]:flex-1':
minimized,
})}
href={child.path}
>
{child.Icon}
<span
className={cn(
'w-auto transition-opacity duration-300',
{
'w-0 opacity-0': minimized,
},
)}
<ContentContainer>
<If condition={child.children}>
{(children) => (
<SidebarMenuSub
className={cn({
'mx-0 px-1.5': minimized,
})}
>
{children.map((child) => {
if (child.renderAction) {
return (
<SidebarMenuSubItem
key={child.path}
>
<Trans
i18nKey={child.label}
defaults={child.label}
/>
</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
)}
</If>
</SidebarMenuItem>
{child.renderAction}
</SidebarMenuSubItem>
);
}
const isActive = isRouteActive(
child.path,
currentPath,
child.end,
);
return (
<SidebarMenuSubItem key={child.path}>
<SidebarMenuSubButton
isActive={isActive}
asChild
>
<Link
className={cn(
'flex items-center',
{
'mx-auto w-full !gap-0 [&>svg]:flex-1':
minimized,
},
)}
href={child.path}
>
{child.Icon}
<span
className={cn(
'w-auto transition-opacity duration-300',
{
'w-0 opacity-0':
minimized,
},
)}
>
<Trans
i18nKey={child.label}
defaults={child.label}
/>
</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
)}
</If>
</ContentContainer>
</SidebarMenuItem>
</Container>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</ContentContainer>
</ContentContainer>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<If condition={minimized && !isLast}>