129
common/resources/client/admin/appearance/sections/menus/add-menu-item-dialog.tsx
Executable file
129
common/resources/client/admin/appearance/sections/menus/add-menu-item-dialog.tsx
Executable file
@@ -0,0 +1,129 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {MenuItemConfig} from '@common/core/settings/settings';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useAvailableRoutes} from '@common/admin/appearance/sections/menus/hooks/available-routes';
|
||||
import {ucFirst} from '@common/utils/string/uc-first';
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {ReactNode} from 'react';
|
||||
import {nanoid} from 'nanoid';
|
||||
|
||||
interface AddMenuItemDialogProps {
|
||||
title?: ReactNode;
|
||||
}
|
||||
export function AddMenuItemDialog({
|
||||
title = <Trans message="Add menu item" />,
|
||||
}: AddMenuItemDialogProps) {
|
||||
const {data} = useValueLists(['menuItemCategories']);
|
||||
const categories = data?.menuItemCategories || [];
|
||||
const routeItems = useAvailableRoutes();
|
||||
|
||||
return (
|
||||
<Dialog size="sm">
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<DialogBody>
|
||||
<Accordion variant="outline">
|
||||
<AccordionItem
|
||||
label={<Trans message="Link" />}
|
||||
bodyClassName="max-h-240 overflow-y-auto"
|
||||
>
|
||||
<AddCustomLink />
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
label={<Trans message="Route" />}
|
||||
bodyClassName="max-h-240 overflow-y-auto"
|
||||
>
|
||||
<AddRoute items={routeItems} />
|
||||
</AccordionItem>
|
||||
{categories.map(category => (
|
||||
<AccordionItem
|
||||
key={category.name}
|
||||
label={<Trans message={category.name} />}
|
||||
>
|
||||
<AddRoute items={category.items} />
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AddCustomLink() {
|
||||
const form = useForm<MenuItemConfig>({
|
||||
defaultValues: {
|
||||
id: nanoid(6),
|
||||
type: 'link',
|
||||
target: '_blank',
|
||||
},
|
||||
});
|
||||
const {close} = useDialogContext();
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={value => {
|
||||
close(value);
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
required
|
||||
name="label"
|
||||
label={<Trans message="Label" />}
|
||||
className="mb-20"
|
||||
/>
|
||||
<FormTextField
|
||||
required
|
||||
type="url"
|
||||
name="action"
|
||||
placeholder="https://"
|
||||
label={<Trans message="Url" />}
|
||||
className="mb-20"
|
||||
/>
|
||||
<div className="text-right">
|
||||
<Button type="submit" variant="flat" color="primary" size="xs">
|
||||
<Trans message="Add to menu" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddRouteProps {
|
||||
items: Partial<MenuItemConfig>[];
|
||||
}
|
||||
function AddRoute({items}: AddRouteProps) {
|
||||
const {close} = useDialogContext();
|
||||
|
||||
return (
|
||||
<List>
|
||||
{items.map(item => {
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
startIcon={<AddIcon size="sm" />}
|
||||
onSelected={() => {
|
||||
if (item.label) {
|
||||
const last = item.label.split('/').pop();
|
||||
item.label = last ? ucFirst(last) : item.label;
|
||||
item.id = nanoid(6);
|
||||
}
|
||||
close(item);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,19 @@
|
||||
import {MenuSectionConfig} from '../../../types/appearance-editor-config';
|
||||
import {MenuItemConfig} from '../../../../../core/settings/settings';
|
||||
import mergedAppearanceConfig from '../../../config/merged-appearance-config';
|
||||
|
||||
export function useAvailableRoutes(): Partial<MenuItemConfig>[] {
|
||||
const menuConfig = mergedAppearanceConfig.sections.menus.config;
|
||||
|
||||
if (!menuConfig) return [];
|
||||
|
||||
return (menuConfig as MenuSectionConfig).availableRoutes.map(route => {
|
||||
return {
|
||||
id: route,
|
||||
label: route,
|
||||
action: route,
|
||||
type: 'route',
|
||||
target: '_self',
|
||||
};
|
||||
});
|
||||
}
|
||||
281
common/resources/client/admin/appearance/sections/menus/menu-editor.tsx
Executable file
281
common/resources/client/admin/appearance/sections/menus/menu-editor.tsx
Executable file
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
FieldArrayWithId,
|
||||
useFieldArray,
|
||||
UseFieldArrayReturn,
|
||||
useFormContext,
|
||||
} from 'react-hook-form';
|
||||
import {Fragment, useEffect, useMemo, useRef} from 'react';
|
||||
import {Link, useNavigate, useParams} from 'react-router-dom';
|
||||
import {MenuSectionConfig} from '../../types/appearance-editor-config';
|
||||
import {MenuItemConfig} from '@common/core/settings/settings';
|
||||
import {
|
||||
appearanceState,
|
||||
AppearanceValues,
|
||||
useAppearanceStore,
|
||||
} from '../../appearance-store';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {AddMenuItemDialog} from '@common/admin/appearance/sections/menus/add-menu-item-dialog';
|
||||
import {AppearanceButton} from '@common/admin/appearance/appearance-button';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {DragIndicatorIcon} from '@common/icons/material/DragIndicator';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
|
||||
import {DeleteIcon} from '@common/icons/material/Delete';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {Option} from '../../../../ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import dropdownMenu from './dropdown-menu.svg';
|
||||
import {FormChipField} from '@common/ui/forms/input-field/chip-field/form-chip-field';
|
||||
import {
|
||||
useSortable,
|
||||
UseSortableProps,
|
||||
} from '@common/ui/interactions/dnd/sortable/use-sortable';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {createSvgIconFromTree} from '@common/icons/create-svg-icon';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
|
||||
export function MenuEditor() {
|
||||
const {menuIndex} = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {getValues} = useFormContext<AppearanceValues>();
|
||||
const formPath = `settings.menus.${menuIndex!}` as 'settings.menus.0';
|
||||
const menu = getValues(formPath);
|
||||
|
||||
useEffect(() => {
|
||||
// go to menu list, if menu can't be found
|
||||
if (!menu) {
|
||||
navigate('/admin/appearance/menus');
|
||||
} else {
|
||||
appearanceState().preview.setHighlight(`[data-menu-id="${menu.id}"]`);
|
||||
}
|
||||
}, [navigate, menu]);
|
||||
|
||||
if (!menu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MenuEditorSection formPath={formPath} />;
|
||||
}
|
||||
|
||||
interface MenuEditorFormProps {
|
||||
formPath: 'settings.menus.0';
|
||||
}
|
||||
function MenuEditorSection({formPath}: MenuEditorFormProps) {
|
||||
const {
|
||||
site: {has_mobile_app},
|
||||
} = useSettings();
|
||||
const menuSectionConfig = useAppearanceStore(
|
||||
s => s.config?.sections.menus.config,
|
||||
) as MenuSectionConfig;
|
||||
|
||||
const menuPositions = useMemo(() => {
|
||||
const positions = [...menuSectionConfig?.positions];
|
||||
if (has_mobile_app) {
|
||||
positions.push('mobile-app-about');
|
||||
}
|
||||
return positions.map(position => ({
|
||||
key: position,
|
||||
name: position.replaceAll('-', ' '),
|
||||
}));
|
||||
}, [menuSectionConfig, has_mobile_app]);
|
||||
|
||||
const fieldArray = useFieldArray<
|
||||
AppearanceValues,
|
||||
`settings.menus.0.items`,
|
||||
'key'
|
||||
>({
|
||||
name: `${formPath}.items`,
|
||||
keyName: 'key',
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="mb-30 border-b pb-30">
|
||||
<FormTextField
|
||||
name={`${formPath}.name`}
|
||||
label={<Trans message="Menu name" />}
|
||||
className="mb-20"
|
||||
autoFocus
|
||||
/>
|
||||
<FormChipField
|
||||
chipSize="sm"
|
||||
name={`${formPath}.positions`}
|
||||
valueKey="id"
|
||||
label={<Trans message="Menu positions" />}
|
||||
description={
|
||||
<Trans message="Where should this menu appear on the site" />
|
||||
}
|
||||
>
|
||||
{menuPositions.map(item => (
|
||||
<Option key={item.key} value={item.key} capitalizeFirst>
|
||||
{item.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormChipField>
|
||||
</div>
|
||||
<MenuItemsManager fieldArray={fieldArray} />
|
||||
<div className="text-right">
|
||||
<DeleteMenuTrigger />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemListProps {
|
||||
fieldArray: UseFieldArrayReturn<
|
||||
AppearanceValues,
|
||||
'settings.menus.0.items',
|
||||
'key'
|
||||
>;
|
||||
}
|
||||
function MenuItemsManager({fieldArray: {append, fields, move}}: ItemListProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="flex flex-shrink-0 items-center justify-between gap-16">
|
||||
<Trans message="Menu items" />
|
||||
<DialogTrigger
|
||||
type="popover"
|
||||
placement="right"
|
||||
offset={20}
|
||||
onClose={(menuItemConfig?: MenuItemConfig) => {
|
||||
if (menuItemConfig) {
|
||||
append({...menuItemConfig});
|
||||
navigate(`items/${fields.length}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="xs"
|
||||
startIcon={<AddIcon />}
|
||||
>
|
||||
<Trans message="Add" />
|
||||
</Button>
|
||||
<AddMenuItemDialog />
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<div className="mt-20 flex-shrink-0">
|
||||
{fields.map((item, index) => (
|
||||
<MenuListItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
items={fields}
|
||||
index={index}
|
||||
onSortEnd={(oldIndex, newIndex) => {
|
||||
move(oldIndex, newIndex);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!fields.length ? (
|
||||
<IllustratedMessage
|
||||
size="xs"
|
||||
className="my-40"
|
||||
image={<SvgImage src={dropdownMenu} />}
|
||||
title={<Trans message="No menu items yet" />}
|
||||
description={
|
||||
<Trans message="Click “add“ button to start adding links, pages, routes and other items to this menu. " />
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteMenuTrigger() {
|
||||
const navigate = useNavigate();
|
||||
const {menuIndex} = useParams();
|
||||
const {fields, remove} = useFieldArray<
|
||||
AppearanceValues,
|
||||
'settings.menus',
|
||||
'key'
|
||||
>({
|
||||
name: 'settings.menus',
|
||||
keyName: 'key',
|
||||
});
|
||||
if (!menuIndex) return null;
|
||||
const menu = fields[+menuIndex];
|
||||
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
const index = fields.findIndex(m => m.id === menu.id);
|
||||
remove(index);
|
||||
navigate('/admin/appearance/menus');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="danger"
|
||||
size="xs"
|
||||
startIcon={<DeleteIcon />}
|
||||
>
|
||||
<Trans message="Delete menu" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete menu" />}
|
||||
body={
|
||||
<Trans
|
||||
message="Are you sure you want to delete “:name“?"
|
||||
values={{name: menu.name}}
|
||||
/>
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuListItemProps {
|
||||
item: MenuItemConfig;
|
||||
items: FieldArrayWithId[];
|
||||
index: number;
|
||||
onSortEnd: UseSortableProps['onSortEnd'];
|
||||
}
|
||||
function MenuListItem({item, items, index, onSortEnd}: MenuListItemProps) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const {sortableProps, dragHandleRef} = useSortable({
|
||||
item,
|
||||
items,
|
||||
type: 'menuEditorSortable',
|
||||
ref,
|
||||
onSortEnd,
|
||||
strategy: 'liveSort',
|
||||
});
|
||||
|
||||
const Icon = item.icon && createSvgIconFromTree(item.icon);
|
||||
const iconOnlyLabel = (
|
||||
<div className="flex items-center gap-4 text-xs text-muted">
|
||||
{Icon && <Icon size="sm" />}
|
||||
(<Trans message="No label..." />)
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<AppearanceButton
|
||||
elementType={Link}
|
||||
to={`items/${index}`}
|
||||
ref={ref}
|
||||
{...sortableProps}
|
||||
>
|
||||
<div className="flex items-center gap-10">
|
||||
<IconButton ref={dragHandleRef} size="sm">
|
||||
<DragIndicatorIcon className="text-muted hover:cursor-move" />
|
||||
</IconButton>
|
||||
<div>{item.label || iconOnlyLabel}</div>
|
||||
</div>
|
||||
</AppearanceButton>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {MenuItemConfig} from '@common/core/settings/settings';
|
||||
|
||||
export interface MenuItemCategory {
|
||||
name: string;
|
||||
type: string;
|
||||
items: MenuItemCategoryItem[];
|
||||
}
|
||||
|
||||
interface MenuItemCategoryItem extends Partial<MenuItemConfig> {
|
||||
label: string;
|
||||
model_id?: number;
|
||||
}
|
||||
100
common/resources/client/admin/appearance/sections/menus/menu-item-editor.tsx
Executable file
100
common/resources/client/admin/appearance/sections/menus/menu-item-editor.tsx
Executable file
@@ -0,0 +1,100 @@
|
||||
import {useFieldArray, useFormContext} from 'react-hook-form';
|
||||
import {Fragment, useEffect} from 'react';
|
||||
import {appearanceState, AppearanceValues} from '../../appearance-store';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {DeleteIcon} from '../../../../icons/material/Delete';
|
||||
import {ConfirmationDialog} from '../../../../ui/overlays/dialog/confirmation-dialog';
|
||||
import {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {useNavigate} from '../../../../utils/hooks/use-navigate';
|
||||
import {MenuItemForm} from '../../../menus/menu-item-form';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {MenuItemConfig} from '../../../../core/settings/settings';
|
||||
|
||||
export function MenuItemEditor() {
|
||||
const {menuIndex, menuItemIndex} = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {getValues} = useFormContext<AppearanceValues>();
|
||||
|
||||
const formPath = `settings.menus.${menuIndex}.items.${menuItemIndex}`;
|
||||
const item = getValues(formPath as any);
|
||||
|
||||
// go to menu editor, if menu item can't be found
|
||||
useEffect(() => {
|
||||
if (!item) {
|
||||
//navigate(`../`);
|
||||
} else {
|
||||
appearanceState().preview.setHighlight(
|
||||
`[data-menu-item-id="${item.id}"]`
|
||||
);
|
||||
}
|
||||
}, [navigate, item]);
|
||||
|
||||
// only render form when menu and item are available to avoid issues with hook form default values
|
||||
if (!item || menuItemIndex == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MenuItemEditorSection formPath={formPath} />;
|
||||
}
|
||||
|
||||
interface MenuItemEditorSectionProps {
|
||||
formPath: string;
|
||||
}
|
||||
function MenuItemEditorSection({formPath}: MenuItemEditorSectionProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<MenuItemForm formPathPrefix={formPath} />
|
||||
<div className="text-right mt-40">
|
||||
<DeleteItemTrigger />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteItemTrigger() {
|
||||
const navigate = useNavigate();
|
||||
const {menuIndex, menuItemIndex} = useParams();
|
||||
const {fields, remove} = useFieldArray<AppearanceValues>({
|
||||
name: `settings.menus.${+menuIndex!}.items`,
|
||||
});
|
||||
|
||||
if (!menuItemIndex) return null;
|
||||
|
||||
const item = fields[+menuItemIndex] as MenuItemConfig;
|
||||
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
if (menuItemIndex) {
|
||||
remove(+menuItemIndex);
|
||||
navigate(`/admin/appearance/menus/${menuIndex}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="danger"
|
||||
size="xs"
|
||||
startIcon={<DeleteIcon />}
|
||||
>
|
||||
<Trans message="Delete this item" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete menu item" />}
|
||||
body={
|
||||
<Trans
|
||||
message="Are you sure you want to delete “:name“?"
|
||||
values={{name: item.label}}
|
||||
/>
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
60
common/resources/client/admin/appearance/sections/menus/menu-list.tsx
Executable file
60
common/resources/client/admin/appearance/sections/menus/menu-list.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {AppearanceValues} from '../../appearance-store';
|
||||
import {Button} from '../../../../ui/buttons/button';
|
||||
import {AddIcon} from '../../../../icons/material/Add';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {useFieldArray} from 'react-hook-form';
|
||||
import {AppearanceButton} from '../../appearance-button';
|
||||
import {nanoid} from 'nanoid';
|
||||
import {useTrans} from '../../../../i18n/use-trans';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {Fragment} from 'react';
|
||||
|
||||
export function MenuList() {
|
||||
const navigate = useNavigate();
|
||||
const {trans} = useTrans();
|
||||
const {fields, append} = useFieldArray<
|
||||
AppearanceValues,
|
||||
'settings.menus',
|
||||
'key'
|
||||
>({
|
||||
name: 'settings.menus',
|
||||
keyName: 'key',
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<AppearanceButton to={`${index}`} key={field.key} elementType={Link}>
|
||||
{field.name}
|
||||
</AppearanceButton>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const id = nanoid(10);
|
||||
append({
|
||||
name: trans(
|
||||
message('New menu :number', {
|
||||
values: {number: fields.length + 1},
|
||||
})
|
||||
),
|
||||
id,
|
||||
positions: [],
|
||||
items: [],
|
||||
});
|
||||
navigate(`${fields.length}`);
|
||||
}}
|
||||
>
|
||||
<Trans message="Create menu" />
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user