Add initial project files
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import type { IntlShape } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createWebStudioMenu } from '#core/WebStudio/config/menu';
|
||||
// `useMenu` holds no React state — it returns plain path helpers — so the tests call it
|
||||
// directly. Aliased on import because the `use` prefix otherwise trips the rules-of-hooks lint.
|
||||
import { useMenu as menuFor } from '#shared/hooks/useMenu';
|
||||
|
||||
const intl = { formatMessage: ({ id }: { id: string }) => id } as IntlShape;
|
||||
|
||||
const webStudioMenu = [createWebStudioMenu(intl)];
|
||||
|
||||
describe('useMenu', () => {
|
||||
describe('isActive', () => {
|
||||
it('stays true for a section while a sub-route is open', () => {
|
||||
const { isActive } = menuFor('/web-studio/pages/-/create');
|
||||
|
||||
expect(isActive('/web-studio/pages/-/')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a path that only shares a prefix', () => {
|
||||
const { isActive } = menuFor('/web-studio/pages/listing-archive');
|
||||
|
||||
expect(isActive('/web-studio/pages/listing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSelected', () => {
|
||||
it('drops the ancestor once a deeper entry matches', () => {
|
||||
const { isSelected } = menuFor('/web-studio/pages/-/create');
|
||||
|
||||
expect(isSelected('/web-studio/pages/-/create', webStudioMenu)).toBe(true);
|
||||
expect(isSelected('/web-studio/pages/listing', webStudioMenu)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBreadcrumb', () => {
|
||||
it('resolves the trail for a listing route', () => {
|
||||
const { getBreadcrumb } = menuFor('/web-studio/pages/listing');
|
||||
|
||||
expect(getBreadcrumb(webStudioMenu).map((item) => item.title)).toEqual([
|
||||
'WEB_STUDIO.MENU.TITLE',
|
||||
'WEB_STUDIO.PAGES.TITLE'
|
||||
]);
|
||||
});
|
||||
|
||||
it('reaches the editor entry from a page id route', () => {
|
||||
const { getBreadcrumb } = menuFor('/web-studio/pages/-/42');
|
||||
|
||||
expect(getBreadcrumb(webStudioMenu).map((item) => item.title)).toEqual([
|
||||
'WEB_STUDIO.MENU.TITLE',
|
||||
'WEB_STUDIO.PAGES.TITLE',
|
||||
'WEB_STUDIO.EDITOR.EDIT_TITLE'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveChild', () => {
|
||||
it('is true for the section holding the current route', () => {
|
||||
const { hasActiveChild } = menuFor('/web-studio/settings/footer');
|
||||
const settings = createWebStudioMenu(intl).children?.[1];
|
||||
|
||||
expect(hasActiveChild(settings?.children)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { IntlShape } from 'react-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MenuConfig } from '#app/config/types';
|
||||
import { createDevtoolsMenu } from '#devtools/config/menu';
|
||||
import { createMenuPathMatcher, isMenuPathActive } from '#shared/lib/menuPath';
|
||||
|
||||
const intl = { formatMessage: ({ id }: { id: string }) => id } as IntlShape;
|
||||
|
||||
const devtoolsMenu: MenuConfig = [createDevtoolsMenu(intl)];
|
||||
|
||||
/** Every menu path the matcher marks active for a route — i.e. what the sidebar highlights. */
|
||||
function highlighted(menu: MenuConfig, pathname: string): string[] {
|
||||
const matches = createMenuPathMatcher(menu, pathname);
|
||||
const active: string[] = [];
|
||||
|
||||
const walk = (items: MenuConfig) => {
|
||||
for (const item of items) {
|
||||
if (item.path && matches(item.path)) active.push(item.path);
|
||||
if (item.children) walk(item.children);
|
||||
}
|
||||
};
|
||||
|
||||
walk(menu);
|
||||
return active;
|
||||
}
|
||||
|
||||
describe('isMenuPathActive', () => {
|
||||
it('matches the route itself', () => {
|
||||
expect(isMenuPathActive('/devtools/module-generator', '/devtools/module-generator')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches routes nested under the path', () => {
|
||||
expect(isMenuPathActive('/devtools/module-generator', '/devtools/module-generator/docs')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('does not match a path that merely shares a prefix', () => {
|
||||
expect(isMenuPathActive('/devtools/module-generator', '/devtools/module-generator-v2')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a trailing slash on the menu path', () => {
|
||||
expect(
|
||||
isMenuPathActive(
|
||||
'/file-manager/tools/image-editor/-/',
|
||||
'/file-manager/tools/image-editor/-/8'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the root only exactly', () => {
|
||||
expect(isMenuPathActive('/', '/')).toBe(true);
|
||||
expect(isMenuPathActive('/', '/devtools')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false without a path', () => {
|
||||
expect(isMenuPathActive(undefined, '/devtools')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMenuPathMatcher', () => {
|
||||
it('highlights the section and its index item on the section route', () => {
|
||||
expect(highlighted(devtoolsMenu, '/devtools/module-generator')).toEqual([
|
||||
'/devtools/module-generator',
|
||||
'/devtools/module-generator'
|
||||
]);
|
||||
});
|
||||
|
||||
it('highlights only the sub-page on a sub-route, not its ancestors', () => {
|
||||
expect(highlighted(devtoolsMenu, '/devtools/module-generator/docs')).toEqual([
|
||||
'/devtools/module-generator/docs'
|
||||
]);
|
||||
expect(highlighted(devtoolsMenu, '/devtools/module-generator/version')).toEqual([
|
||||
'/devtools/module-generator/version'
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a sibling section alone', () => {
|
||||
expect(highlighted(devtoolsMenu, '/devtools/sandbox/ui')).toEqual(['/devtools/sandbox/ui']);
|
||||
});
|
||||
|
||||
it('keeps the closest entry highlighted on a route with no entry of its own', () => {
|
||||
const menu: MenuConfig = [
|
||||
{
|
||||
title: 'File manager',
|
||||
children: [
|
||||
{ title: 'Explorer', path: '/file-manager/drive/explorer' },
|
||||
{ title: 'Settings', path: '/file-manager/drive/settings' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
expect(highlighted(menu, '/file-manager/drive/explorer/photos/2026')).toEqual([
|
||||
'/file-manager/drive/explorer'
|
||||
]);
|
||||
});
|
||||
|
||||
it('highlights nothing when no entry matches', () => {
|
||||
expect(highlighted(devtoolsMenu, '/web-studio/pages/listing')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOpenChain } from '#shared/ui/AccordionMenu/openChain';
|
||||
|
||||
/** Stand-ins for the accordion components; only their identity matters to the walk. */
|
||||
const Section = 'section';
|
||||
const Content = 'content';
|
||||
const Group = 'group';
|
||||
const Item = 'item';
|
||||
|
||||
const element = (type: string, props: Record<string, unknown>, ...children: React.ReactNode[]) =>
|
||||
React.createElement(type, props, ...children);
|
||||
|
||||
/** A collapsible section, wrapping its children the way the accordion nests them. */
|
||||
const section = (value: string, ...children: React.ReactNode[]) =>
|
||||
element(
|
||||
Section,
|
||||
{ value },
|
||||
element(Content, { parentValue: value }, element(Group, {}, children))
|
||||
);
|
||||
|
||||
/** A plain item. Its children are content, not further accordion structure. */
|
||||
const item = (value: string) => element(Item, { value }, element('a', { href: '#' }, value));
|
||||
|
||||
const open = (children: React.ReactNode, selected: string) =>
|
||||
resolveOpenChain(children, {
|
||||
isSelected: (value) => value === selected,
|
||||
isSection: (node) => node.type === Section
|
||||
});
|
||||
|
||||
describe('resolveOpenChain', () => {
|
||||
const tree = [
|
||||
section(
|
||||
'outer',
|
||||
section('first', item('first-a'), item('first-b')),
|
||||
section('second', item('second-a'))
|
||||
)
|
||||
];
|
||||
|
||||
it('collects the sections wrapping the selected item', () => {
|
||||
expect(open(tree, 'first-b')).toEqual(['outer', 'first']);
|
||||
});
|
||||
|
||||
it('leaves sibling sections out', () => {
|
||||
expect(open(tree, 'second-a')).toEqual(['outer', 'second']);
|
||||
});
|
||||
|
||||
it('omits the selected item, since an item does not open', () => {
|
||||
expect(open(tree, 'first-a')).not.toContain('first-a');
|
||||
});
|
||||
|
||||
it('opens a selected section rather than only its parent', () => {
|
||||
expect(open(tree, 'first')).toEqual(['outer', 'first']);
|
||||
});
|
||||
|
||||
it('opens the section when it shares its value with an item inside it', () => {
|
||||
const shared = [section('outer', section('shared', item('shared'), item('other')))];
|
||||
|
||||
expect(open(shared, 'shared')).toEqual(['outer', 'shared']);
|
||||
});
|
||||
|
||||
it('handles sections nested three deep', () => {
|
||||
const deep = [section('outer', section('middle', section('inner', item('leaf'))))];
|
||||
|
||||
expect(open(deep, 'leaf')).toEqual(['outer', 'middle', 'inner']);
|
||||
});
|
||||
|
||||
it('returns nothing when no value is selected', () => {
|
||||
expect(open(tree, 'absent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores non-element children', () => {
|
||||
const withText = [section('outer', 'plain text', null, item('leaf'))];
|
||||
|
||||
expect(open(withText, 'leaf')).toEqual(['outer']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user