✨ Bookmark widget (#890)
* 🚧 Bookmark widget * ✨ Add input type Co-authored-by: Meier Lukas <meierschlumpf@gmail.com> * ✨ Add content display and input fields * 🐛 Fix delete button updating to invalid schema * 🌐 Add translations for options * ✨ Add field for image * ♻️ Refactor IconSelector and add forward ref * 🦺 Add form validation * 🦺 Add validation for icon url and fix state for icon picker * 🌐 PR feedback --------- Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
138
src/components/Dashboard/Tiles/Widgets/Inputs/DraggableList.tsx
Normal file
138
src/components/Dashboard/Tiles/Widgets/Inputs/DraggableList.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Collapse, Flex, Stack, Text, createStyles } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconChevronDown, IconGripVertical } from '@tabler/icons';
|
||||
import { Reorder, useDragControls } from 'framer-motion';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { IDraggableEditableListInputValue } from '../../../../../widgets/widgets';
|
||||
|
||||
interface DraggableListProps {
|
||||
items: {
|
||||
data: { id: string } & any;
|
||||
}[];
|
||||
value: IDraggableEditableListInputValue<any>['defaultValue'];
|
||||
onChange: (value: IDraggableEditableListInputValue<any>['defaultValue']) => void;
|
||||
options: IDraggableEditableListInputValue<any>;
|
||||
}
|
||||
|
||||
export const DraggableList = ({ items, value, onChange, options }: DraggableListProps) => (
|
||||
<div>
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={items.map((x) => x.data.id)}
|
||||
onReorder={(order) => onChange(order.map((id) => value.find((v) => v.id === id)!))}
|
||||
as="div"
|
||||
>
|
||||
{items.map(({ data }) => (
|
||||
<ListItem key={data.id} item={data} label={options.getLabel(data)}>
|
||||
<options.itemComponent
|
||||
data={data}
|
||||
onChange={(data: any) => {
|
||||
onChange(
|
||||
items.map((item) => {
|
||||
if (item.data.id === data.id) return data;
|
||||
return item.data;
|
||||
})
|
||||
);
|
||||
}}
|
||||
delete={() => {
|
||||
onChange(items.filter((item) => item.data.id !== data.id).map((item) => item.data));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ListItem: FC<{
|
||||
item: any;
|
||||
label: string | JSX.Element;
|
||||
}> = ({ item, label, children }) => {
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
const { classes, cx } = useStyles();
|
||||
const controls = useDragControls();
|
||||
|
||||
// Workaround for mobile drag controls not working
|
||||
// https://github.com/framer/motion/issues/1597#issuecomment-1235026724
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const touchHandler: EventListener = (e) => e.preventDefault();
|
||||
|
||||
const dragItem = dragRef.current;
|
||||
|
||||
if (dragItem) {
|
||||
dragItem.addEventListener('touchstart', touchHandler, { passive: false });
|
||||
|
||||
return () => {
|
||||
dragItem.removeEventListener('touchstart', touchHandler);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [dragRef]);
|
||||
|
||||
return (
|
||||
<Reorder.Item value={item.id} dragListener={false} dragControls={controls} as="div">
|
||||
<div className={classes.container}>
|
||||
<div className={classes.row}>
|
||||
<Flex ref={dragRef} onPointerDown={(e) => controls.start(e)}>
|
||||
<IconGripVertical className={classes.clickableIcons} size={18} stroke={1.5} />
|
||||
</Flex>
|
||||
|
||||
<div className={classes.middle}>
|
||||
<Text className={classes.symbol}>{label}</Text>
|
||||
</div>
|
||||
|
||||
<IconChevronDown
|
||||
className={cx(classes.clickableIcons, { [classes.rotate]: opened })}
|
||||
onClick={() => handlers.toggle()}
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapse in={opened}>
|
||||
<Stack className={classes.collapseContent}>{children}</Stack>
|
||||
</Collapse>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: theme.radius.md,
|
||||
border: `1px solid ${
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]
|
||||
}`,
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.white,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
middle: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
symbol: {
|
||||
fontSize: 16,
|
||||
},
|
||||
clickableIcons: {
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[1] : theme.colors.gray[6],
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'transform .3s ease-in-out',
|
||||
},
|
||||
rotate: {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
collapseContent: {
|
||||
padding: '12px 16px',
|
||||
},
|
||||
}));
|
||||
@@ -3,7 +3,7 @@ import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconChevronDown, IconGripVertical } from '@tabler/icons';
|
||||
import { Reorder, useDragControls } from 'framer-motion';
|
||||
import { FC, ReactNode, useEffect, useRef } from 'react';
|
||||
import { IDraggableListInputValue } from '../../../../widgets/widgets';
|
||||
import { IDraggableListInputValue } from '../../../../../widgets/widgets';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
container: {
|
||||
@@ -43,14 +43,14 @@ const useStyles = createStyles((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type DraggableListParams = {
|
||||
type StaticDraggableListParams = {
|
||||
value: IDraggableListInputValue['defaultValue'];
|
||||
onChange: (value: IDraggableListInputValue['defaultValue']) => void;
|
||||
labels: Record<string, string>;
|
||||
children?: Record<string, ReactNode>;
|
||||
};
|
||||
|
||||
export const DraggableList: FC<DraggableListParams> = (props) => {
|
||||
export const StaticDraggableList: FC<StaticDraggableListParams> = (props) => {
|
||||
const keys = props.value.map((v) => v.key);
|
||||
|
||||
return (
|
||||
@@ -64,10 +64,10 @@ export const DraggableList: FC<DraggableListParams> = (props) => {
|
||||
as="div"
|
||||
>
|
||||
{props.value.map((item) => (
|
||||
<ListItem key={item.key} item={item} label={props.labels[item.key]}>
|
||||
{props.children?.[item.key]}
|
||||
</ListItem>
|
||||
))}
|
||||
<ListItem key={item.key} item={item} label={props.labels[item.key]}>
|
||||
{props.children?.[item.key]}
|
||||
</ListItem>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
);
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
@@ -10,9 +12,10 @@ import {
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import { IconAlertTriangle, IconPlaylistX, IconPlus } from '@tabler/icons';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { FC, useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
@@ -22,7 +25,8 @@ import { useColorTheme } from '../../../../tools/color';
|
||||
import Widgets from '../../../../widgets';
|
||||
import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets';
|
||||
import { IWidget } from '../../../../widgets/widgets';
|
||||
import { DraggableList } from './DraggableList';
|
||||
import { DraggableList } from './Inputs/DraggableList';
|
||||
import { StaticDraggableList } from './Inputs/StaticDraggableList';
|
||||
|
||||
export type WidgetEditModalInnerProps = {
|
||||
widgetId: string;
|
||||
@@ -222,7 +226,7 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
<DraggableList
|
||||
<StaticDraggableList
|
||||
value={typedVal}
|
||||
onChange={(v) => handleChange(key, v)}
|
||||
labels={mapObject(option.items, (liName) =>
|
||||
@@ -241,7 +245,7 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</DraggableList>
|
||||
</StaticDraggableList>
|
||||
</Stack>
|
||||
);
|
||||
case 'multiple-text':
|
||||
@@ -263,6 +267,46 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'draggable-editable-list':
|
||||
const { t: translateDraggableList } = useTranslation('widgets/draggable-list');
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
<DraggableList
|
||||
items={Array.from(value).map((v: any) => ({
|
||||
data: v,
|
||||
}))}
|
||||
value={value}
|
||||
onChange={(v) => handleChange(key, v)}
|
||||
options={option}
|
||||
/>
|
||||
|
||||
{Array.from(value).length === 0 && (
|
||||
<Card>
|
||||
<Stack align="center">
|
||||
<IconPlaylistX size="2rem" />
|
||||
<Stack align="center" spacing={0}>
|
||||
<Title order={5}>{translateDraggableList('noEntries.title')}</Title>
|
||||
<Text>{translateDraggableList('noEntries.text')}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Flex gap="md">
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleChange('items', [...value, option.create()]);
|
||||
}}
|
||||
leftIcon={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
fullWidth
|
||||
>
|
||||
{translateDraggableList('buttonAdd')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
/* eslint-enable no-case-declarations */
|
||||
default:
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user