mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
fix: database bugs (#4632)
* fix: database bugs * fix: calendar picker * fix: the position of collapse menu button * fix: modified some style * fix: slash command * fix: selection style * fix: support toggle inline formula * fix: block color effect grid block * fix: if isRange and date is greater than endDate, swap date and endDate * fix: remove sorting before insert row * fix: toggle property visible status * fix: modified tauri window size * fix: placeholder should be hidden when composing * fix: support href shortcut * fix: prevent submit when the formula has error * fix: modified layout selection * fix: add padding for record edit * fix: remove sorts before drag row * fix: modified chip style * fix: if previous node is an embed, merge the current node to another node which is not an embed * fix: modified emoji picker
This commit is contained in:
parent
c9dc24a13c
commit
e2028ac5a0
@ -83,10 +83,12 @@
|
|||||||
{
|
{
|
||||||
"fileDropEnabled": false,
|
"fileDropEnabled": false,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"height": 1200,
|
"height": 800,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"title": "AppFlowy",
|
"title": "AppFlowy",
|
||||||
"width": 1200
|
"width": 1200,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -10,14 +10,17 @@ import { UserService } from '$app/application/user/user.service';
|
|||||||
export function useUserSetting() {
|
export function useUserSetting() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const {
|
const { themeMode = ThemeMode.System, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
|
||||||
themeMode = ThemeMode.System,
|
return {
|
||||||
isDark = false,
|
themeMode: state.currentUser.userSetting.themeMode,
|
||||||
theme: themeType = ThemeType.Default,
|
theme: state.currentUser.userSetting.theme,
|
||||||
} = useAppSelector((state) => {
|
};
|
||||||
return state.currentUser.userSetting || {};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isDark =
|
||||||
|
themeMode === ThemeMode.Dark ||
|
||||||
|
(themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const settings = await UserService.getAppearanceSetting();
|
const settings = await UserService.getAppearanceSetting();
|
||||||
|
@ -7,6 +7,7 @@ import { ThemeProvider } from '@mui/material';
|
|||||||
import { useUserSetting } from '$app/AppMain.hooks';
|
import { useUserSetting } from '$app/AppMain.hooks';
|
||||||
import TrashPage from '$app/views/TrashPage';
|
import TrashPage from '$app/views/TrashPage';
|
||||||
import DocumentPage from '$app/views/DocumentPage';
|
import DocumentPage from '$app/views/DocumentPage';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
function AppMain() {
|
function AppMain() {
|
||||||
const { muiTheme } = useUserSetting();
|
const { muiTheme } = useUserSetting();
|
||||||
@ -20,6 +21,7 @@ function AppMain() {
|
|||||||
<Route path={'/trash'} id={'trash'} element={<TrashPage />} />
|
<Route path={'/trash'} id={'trash'} element={<TrashPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -132,5 +132,9 @@ export async function updateDateCell(
|
|||||||
|
|
||||||
const result = await DatabaseEventUpdateDateCell(payload);
|
const result = await DatabaseEventUpdateDateCell(payload);
|
||||||
|
|
||||||
return result.unwrap();
|
if (!result.ok) {
|
||||||
|
return Promise.reject(typeof result.val.msg === 'string' ? result.val.msg : 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.val;
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,8 @@ export async function getDatabase(viewId: string) {
|
|||||||
|
|
||||||
const result = await DatabaseEventGetDatabase(payload);
|
const result = await DatabaseEventGetDatabase(payload);
|
||||||
|
|
||||||
|
if (!result.ok) return Promise.reject('Failed to get database');
|
||||||
|
|
||||||
return result
|
return result
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
return {
|
return {
|
||||||
|
@ -23,6 +23,7 @@ const updateFiltersFromChange = (database: Database, changeset: FilterChangesetN
|
|||||||
const newFilter = pbToFilter(pb.filter);
|
const newFilter = pbToFilter(pb.filter);
|
||||||
|
|
||||||
Object.assign(found, newFilter);
|
Object.assign(found, newFilter);
|
||||||
|
database.filters = [...database.filters];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -2,6 +2,7 @@ import { ReorderAllRowsPB, ReorderSingleRowPB, RowsChangePB, RowsVisibilityChang
|
|||||||
import { Database } from '../database';
|
import { Database } from '../database';
|
||||||
import { pbToRowMeta, RowMeta } from './row_types';
|
import { pbToRowMeta, RowMeta } from './row_types';
|
||||||
import { didDeleteCells } from '$app/application/database/cell/cell_listeners';
|
import { didDeleteCells } from '$app/application/database/cell/cell_listeners';
|
||||||
|
import { getDatabase } from '$app/application/database/database/database_service';
|
||||||
|
|
||||||
const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => {
|
const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => {
|
||||||
changeset.deleted_rows.forEach((rowId) => {
|
changeset.deleted_rows.forEach((rowId) => {
|
||||||
@ -15,12 +16,6 @@ const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) =>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertRowsFromChangeset = (database: Database, changeset: RowsChangePB) => {
|
|
||||||
changeset.inserted_rows.forEach(({ index, row_meta: rowMetaPB }) => {
|
|
||||||
database.rowMetas.splice(index, 0, pbToRowMeta(rowMetaPB));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) => {
|
const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) => {
|
||||||
changeset.updated_rows.forEach(({ row_id: rowId, row_meta: rowMetaPB }) => {
|
changeset.updated_rows.forEach(({ row_id: rowId, row_meta: rowMetaPB }) => {
|
||||||
const found = database.rowMetas.find((rowMeta) => rowMeta.id === rowId);
|
const found = database.rowMetas.find((rowMeta) => rowMeta.id === rowId);
|
||||||
@ -31,9 +26,15 @@ const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) =>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const didUpdateViewRows = (database: Database, changeset: RowsChangePB) => {
|
export const didUpdateViewRows = async (viewId: string, database: Database, changeset: RowsChangePB) => {
|
||||||
|
if (changeset.inserted_rows.length > 0) {
|
||||||
|
const { rowMetas } = await getDatabase(viewId);
|
||||||
|
|
||||||
|
database.rowMetas = rowMetas;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
deleteRowsFromChangeset(database, changeset);
|
deleteRowsFromChangeset(database, changeset);
|
||||||
insertRowsFromChangeset(database, changeset);
|
|
||||||
updateRowsFromChangeset(database, changeset);
|
updateRowsFromChangeset(database, changeset);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,18 +57,39 @@ export const didReorderSingleRow = (database: Database, changeset: ReorderSingle
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const didUpdateViewRowsVisibility = (database: Database, changeset: RowsVisibilityChangePB) => {
|
export const didUpdateViewRowsVisibility = async (
|
||||||
|
viewId: string,
|
||||||
|
database: Database,
|
||||||
|
changeset: RowsVisibilityChangePB
|
||||||
|
) => {
|
||||||
const { invisible_rows, visible_rows } = changeset;
|
const { invisible_rows, visible_rows } = changeset;
|
||||||
|
|
||||||
database.rowMetas.forEach((rowMeta) => {
|
let reFetchRows = false;
|
||||||
if (invisible_rows.includes(rowMeta.id)) {
|
|
||||||
|
for (const rowId of invisible_rows) {
|
||||||
|
const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === rowId);
|
||||||
|
|
||||||
|
if (rowMeta) {
|
||||||
rowMeta.isHidden = true;
|
rowMeta.isHidden = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const found = visible_rows.find((visibleRow) => visibleRow.row_meta.id === rowMeta.id);
|
for (const insertedRow of visible_rows) {
|
||||||
|
const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === insertedRow.row_meta.id);
|
||||||
|
|
||||||
if (found) {
|
if (rowMeta) {
|
||||||
rowMeta.isHidden = false;
|
rowMeta.isHidden = false;
|
||||||
|
} else {
|
||||||
|
reFetchRows = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (reFetchRows) {
|
||||||
|
const { rowMetas } = await getDatabase(viewId);
|
||||||
|
|
||||||
|
database.rowMetas = rowMetas;
|
||||||
|
|
||||||
|
await didUpdateViewRowsVisibility(viewId, database, changeset);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@ -151,6 +151,7 @@ export interface EditorProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
onTitleChange?: (title: string) => void;
|
onTitleChange?: (title: string) => void;
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
|
disableFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum EditorNodeType {
|
export enum EditorNodeType {
|
||||||
|
@ -23,6 +23,7 @@ import {
|
|||||||
RepeatedTrashPB,
|
RepeatedTrashPB,
|
||||||
ChildViewUpdatePB,
|
ChildViewUpdatePB,
|
||||||
} from '@/services/backend';
|
} from '@/services/backend';
|
||||||
|
import { AsyncQueue } from '$app/utils/async_queue';
|
||||||
|
|
||||||
const Notification = {
|
const Notification = {
|
||||||
[DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB,
|
[DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB,
|
||||||
@ -56,7 +57,9 @@ type NullableInstanceType<K extends (abstract new (...args: any) => any) | null>
|
|||||||
any
|
any
|
||||||
? InstanceType<K>
|
? InstanceType<K>
|
||||||
: void;
|
: void;
|
||||||
export type NotificationHandler<K extends NotificationEnum> = (result: NullableInstanceType<NotificationMap[K]>) => void;
|
export type NotificationHandler<K extends NotificationEnum> = (
|
||||||
|
result: NullableInstanceType<NotificationMap[K]>
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to a set of notifications.
|
* Subscribes to a set of notifications.
|
||||||
@ -105,8 +108,7 @@ export function subscribeNotifications(
|
|||||||
},
|
},
|
||||||
options?: { id?: string }
|
options?: { id?: string }
|
||||||
): Promise<() => void> {
|
): Promise<() => void> {
|
||||||
return listen<ReturnType<typeof SubscribeObject.prototype.toObject>>('af-notification', (event) => {
|
const handler = async (subject: SubscribeObject) => {
|
||||||
const subject = SubscribeObject.fromObject(event.payload);
|
|
||||||
const { id, ty } = subject;
|
const { id, ty } = subject;
|
||||||
|
|
||||||
if (options?.id !== undefined && id !== options.id) {
|
if (options?.id !== undefined && id !== options.id) {
|
||||||
@ -127,8 +129,20 @@ export function subscribeNotifications(
|
|||||||
} else {
|
} else {
|
||||||
const { payload } = subject;
|
const { payload } = subject;
|
||||||
|
|
||||||
pb ? callback(pb.deserialize(payload)) : callback();
|
if (pb) {
|
||||||
|
await callback(pb.deserialize(payload));
|
||||||
|
} else {
|
||||||
|
await callback();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const queue = new AsyncQueue(handler);
|
||||||
|
|
||||||
|
return listen<ReturnType<typeof SubscribeObject.prototype.toObject>>('af-notification', (event) => {
|
||||||
|
const subject = SubscribeObject.fromObject(event.payload);
|
||||||
|
|
||||||
|
queue.enqueue(subject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,24 +1,28 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import { Button, DialogActions, Divider } from '@mui/material';
|
import { Button, DialogProps } from '@mui/material';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
|
|
||||||
interface Props {
|
interface Props extends DialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle?: string;
|
||||||
onOk: () => Promise<void>;
|
onOk?: () => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
okText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
container?: HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
|
function DeleteConfirmDialog({ open, title, onOk, onCancel, onClose, okText, cancelText, container, ...props }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const onDone = useCallback(async () => {
|
const onDone = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await onOk();
|
await onOk?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error(e);
|
Log.error(e);
|
||||||
@ -27,6 +31,7 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
container={container}
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@ -44,20 +49,26 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
|
|||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<DialogContent className={'flex w-[340px] flex-col items-center justify-center gap-4'}>
|
<DialogContent className={'w-[320px]'}>
|
||||||
<div className={'text-md font-medium'}>{title}</div>
|
{title}
|
||||||
{subtitle && <div className={'m-1 text-sm text-text-caption'}>{subtitle}</div>}
|
<div className={'flex w-full flex-col gap-2 pb-2 pt-4'}>
|
||||||
|
<Button className={'w-full'} variant={'outlined'} color={'error'} onClick={onDone}>
|
||||||
|
{okText ?? t('button.delete')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={'w-full'}
|
||||||
|
variant={'outlined'}
|
||||||
|
onClick={() => {
|
||||||
|
onCancel?.();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelText ?? t('button.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<Divider className={'mb-4'} />
|
|
||||||
<DialogActions className={'p-4 pt-0'}>
|
|
||||||
<Button variant={'outlined'} onClick={onClose}>
|
|
||||||
{t('button.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button variant={'contained'} onClick={onDone}>
|
|
||||||
{t('button.delete')}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -7,9 +7,10 @@ import EmojiPickerCategories from './EmojiPickerCategories';
|
|||||||
interface Props {
|
interface Props {
|
||||||
onEmojiSelect: (emoji: string) => void;
|
onEmojiSelect: (emoji: string) => void;
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
|
defaultEmoji?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiPicker({ onEscape, ...props }: Props) {
|
function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) {
|
||||||
const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
|
const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -21,7 +22,12 @@ function EmojiPicker({ onEscape, ...props }: Props) {
|
|||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
/>
|
/>
|
||||||
<EmojiPickerCategories onEscape={onEscape} onEmojiSelect={onSelect} emojiCategories={emojiCategories} />
|
<EmojiPickerCategories
|
||||||
|
defaultEmoji={defaultEmoji}
|
||||||
|
onEscape={onEscape}
|
||||||
|
onEmojiSelect={onSelect}
|
||||||
|
emojiCategories={emojiCategories}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
EMOJI_SIZE,
|
EMOJI_SIZE,
|
||||||
EmojiCategory,
|
EmojiCategory,
|
||||||
@ -14,10 +14,12 @@ function EmojiPickerCategories({
|
|||||||
emojiCategories,
|
emojiCategories,
|
||||||
onEmojiSelect,
|
onEmojiSelect,
|
||||||
onEscape,
|
onEscape,
|
||||||
|
defaultEmoji,
|
||||||
}: {
|
}: {
|
||||||
emojiCategories: EmojiCategory[];
|
emojiCategories: EmojiCategory[];
|
||||||
onEmojiSelect: (emoji: string) => void;
|
onEmojiSelect: (emoji: string) => void;
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
|
defaultEmoji?: string;
|
||||||
}) {
|
}) {
|
||||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -28,6 +30,8 @@ function EmojiPickerCategories({
|
|||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
|
return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
|
||||||
}, [emojiCategories]);
|
}, [emojiCategories]);
|
||||||
|
const mouseY = useRef<number | null>(null);
|
||||||
|
const mouseX = useRef<number | null>(null);
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -75,6 +79,8 @@ function EmojiPickerCategories({
|
|||||||
{item.emojis?.map((emoji, columnIndex) => {
|
{item.emojis?.map((emoji, columnIndex) => {
|
||||||
const isSelected = selectCell.row === index && selectCell.column === columnIndex;
|
const isSelected = selectCell.row === index && selectCell.column === columnIndex;
|
||||||
|
|
||||||
|
const isDefaultEmoji = defaultEmoji === emoji.native;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={emoji.id}
|
key={emoji.id}
|
||||||
@ -86,9 +92,24 @@ function EmojiPickerCategories({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
onEmojiSelect(emoji.native);
|
onEmojiSelect(emoji.native);
|
||||||
}}
|
}}
|
||||||
className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-active ${
|
onMouseMove={(e) => {
|
||||||
isSelected ? 'bg-fill-list-hover' : ''
|
mouseY.current = e.clientY;
|
||||||
}`}
|
mouseX.current = e.clientX;
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (mouseY.current === null || mouseY.current !== e.clientY || mouseX.current !== e.clientX) {
|
||||||
|
setSelectCell({
|
||||||
|
row: index,
|
||||||
|
column: columnIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseX.current = e.clientX;
|
||||||
|
mouseY.current = e.clientY;
|
||||||
|
}}
|
||||||
|
className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-hover ${
|
||||||
|
isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent'
|
||||||
|
} ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`}
|
||||||
>
|
>
|
||||||
{emoji.native}
|
{emoji.native}
|
||||||
</div>
|
</div>
|
||||||
@ -98,7 +119,7 @@ function EmojiPickerCategories({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row]
|
[defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getNewColumnIndex = useCallback(
|
const getNewColumnIndex = useCallback(
|
||||||
|
@ -48,6 +48,7 @@ export interface KeyboardNavigationProps<T> {
|
|||||||
defaultFocusedKey?: T;
|
defaultFocusedKey?: T;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
itemClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyboardNavigation<T>({
|
function KeyboardNavigation<T>({
|
||||||
@ -65,6 +66,7 @@ function KeyboardNavigation<T>({
|
|||||||
disableSelect = false,
|
disableSelect = false,
|
||||||
onBlur,
|
onBlur,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
itemClassName,
|
||||||
}: KeyboardNavigationProps<T>) {
|
}: KeyboardNavigationProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@ -197,7 +199,7 @@ function KeyboardNavigation<T>({
|
|||||||
|
|
||||||
const renderOption = useCallback(
|
const renderOption = useCallback(
|
||||||
(option: KeyboardNavigationOption<T>, index: number) => {
|
(option: KeyboardNavigationOption<T>, index: number) => {
|
||||||
const hasChildren = option.children && option.children.length > 0;
|
const hasChildren = option.children;
|
||||||
|
|
||||||
const isFocused = focusedKey === option.key;
|
const isFocused = focusedKey === option.key;
|
||||||
|
|
||||||
@ -216,6 +218,7 @@ function KeyboardNavigation<T>({
|
|||||||
mouseY.current = e.clientY;
|
mouseY.current = e.clientY;
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
onFocus?.();
|
||||||
if (mouseY.current === null || mouseY.current !== e.clientY) {
|
if (mouseY.current === null || mouseY.current !== e.clientY) {
|
||||||
setFocusedKey(option.key);
|
setFocusedKey(option.key);
|
||||||
}
|
}
|
||||||
@ -231,7 +234,7 @@ function KeyboardNavigation<T>({
|
|||||||
selected={isFocused}
|
selected={isFocused}
|
||||||
className={`ml-0 flex w-full items-center justify-start rounded-none px-2 py-1 text-xs ${
|
className={`ml-0 flex w-full items-center justify-start rounded-none px-2 py-1 text-xs ${
|
||||||
!isFocused ? 'hover:bg-transparent' : ''
|
!isFocused ? 'hover:bg-transparent' : ''
|
||||||
}`}
|
} ${itemClassName ?? ''}`}
|
||||||
>
|
>
|
||||||
{option.content}
|
{option.content}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -243,7 +246,7 @@ function KeyboardNavigation<T>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[focusedKey, onConfirm]
|
[itemClassName, focusedKey, onConfirm, onFocus]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -290,7 +293,7 @@ function KeyboardNavigation<T>({
|
|||||||
{options.length > 0 ? (
|
{options.length > 0 ? (
|
||||||
options.map(renderOption)
|
options.map(renderOption)
|
||||||
) : (
|
) : (
|
||||||
<Typography variant='body1' className={'p-3 text-text-caption'}>
|
<Typography variant='body1' className={'p-3 text-xs text-text-caption'}>
|
||||||
{t('findAndReplace.noResult')}
|
{t('findAndReplace.noResult')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
@ -84,7 +84,9 @@ const usePopoverAutoPosition = ({
|
|||||||
initialPaperHeight,
|
initialPaperHeight,
|
||||||
marginThreshold = 16,
|
marginThreshold = 16,
|
||||||
open,
|
open,
|
||||||
}: UsePopoverAutoPositionProps): PopoverPosition => {
|
}: UsePopoverAutoPositionProps): PopoverPosition & {
|
||||||
|
calculateAnchorSize: () => void;
|
||||||
|
} => {
|
||||||
const [position, setPosition] = useState<PopoverPosition>({
|
const [position, setPosition] = useState<PopoverPosition>({
|
||||||
anchorOrigin: initialAnchorOrigin,
|
anchorOrigin: initialAnchorOrigin,
|
||||||
transformOrigin: initialTransformOrigin,
|
transformOrigin: initialTransformOrigin,
|
||||||
@ -94,24 +96,21 @@ const usePopoverAutoPosition = ({
|
|||||||
isEntered: false,
|
isEntered: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getAnchorOffset = useCallback(() => {
|
const calculateAnchorSize = useCallback(() => {
|
||||||
if (anchorPosition) {
|
|
||||||
return {
|
|
||||||
...anchorPosition,
|
|
||||||
width: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return anchorEl ? anchorEl.getBoundingClientRect() : undefined;
|
|
||||||
}, [anchorEl, anchorPosition]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
const getAnchorOffset = () => {
|
||||||
|
if (anchorPosition) {
|
||||||
|
return {
|
||||||
|
...anchorPosition,
|
||||||
|
width: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return anchorEl ? anchorEl.getBoundingClientRect() : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const anchorRect = getAnchorOffset();
|
const anchorRect = getAnchorOffset();
|
||||||
|
|
||||||
if (!anchorRect) return;
|
if (!anchorRect) return;
|
||||||
@ -190,17 +189,24 @@ const usePopoverAutoPosition = ({
|
|||||||
// Set new position and set isEntered to true
|
// Set new position and set isEntered to true
|
||||||
setPosition({ ...newPosition, isEntered: true });
|
setPosition({ ...newPosition, isEntered: true });
|
||||||
}, [
|
}, [
|
||||||
anchorPosition,
|
|
||||||
open,
|
|
||||||
initialAnchorOrigin,
|
initialAnchorOrigin,
|
||||||
initialTransformOrigin,
|
initialTransformOrigin,
|
||||||
initialPaperWidth,
|
initialPaperWidth,
|
||||||
initialPaperHeight,
|
initialPaperHeight,
|
||||||
marginThreshold,
|
marginThreshold,
|
||||||
getAnchorOffset,
|
anchorEl,
|
||||||
|
anchorPosition,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return position;
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
calculateAnchorSize();
|
||||||
|
}, [open, calculateAnchorSize]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...position,
|
||||||
|
calculateAnchorSize,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usePopoverAutoPosition;
|
export default usePopoverAutoPosition;
|
||||||
|
@ -44,6 +44,7 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon
|
|||||||
onClose={() => setAnchorPosition(undefined)}
|
onClose={() => setAnchorPosition(undefined)}
|
||||||
>
|
>
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
|
defaultEmoji={icon.value}
|
||||||
onEscape={() => {
|
onEscape={() => {
|
||||||
setAnchorPosition(undefined);
|
setAnchorPosition(undefined);
|
||||||
}}
|
}}
|
||||||
|
@ -52,8 +52,6 @@ export const DatabaseProvider = DatabaseContext.Provider;
|
|||||||
|
|
||||||
export const useDatabase = () => useSnapshot(useContext(DatabaseContext));
|
export const useDatabase = () => useSnapshot(useContext(DatabaseContext));
|
||||||
|
|
||||||
export const useContextDatabase = () => useContext(DatabaseContext);
|
|
||||||
|
|
||||||
export const useSelectorCell = (rowId: string, fieldId: string) => {
|
export const useSelectorCell = (rowId: string, fieldId: string) => {
|
||||||
const database = useContext(DatabaseContext);
|
const database = useContext(DatabaseContext);
|
||||||
const cells = useSnapshot(database.cells);
|
const cells = useSnapshot(database.cells);
|
||||||
@ -86,10 +84,16 @@ export const useDispatchCell = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTypeOptions = () => {
|
export const useDatabaseSorts = () => {
|
||||||
const context = useContext(DatabaseContext);
|
const context = useContext(DatabaseContext);
|
||||||
|
|
||||||
return useSnapshot(context.typeOptions);
|
return useSnapshot(context.sorts);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSortsCount = () => {
|
||||||
|
const { sorts } = useDatabase();
|
||||||
|
|
||||||
|
return sorts?.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFiltersCount = () => {
|
export const useFiltersCount = () => {
|
||||||
@ -154,8 +158,8 @@ export const useConnectDatabase = (viewId: string) => {
|
|||||||
[DatabaseNotification.DidUpdateFieldSettings]: (changeset) => {
|
[DatabaseNotification.DidUpdateFieldSettings]: (changeset) => {
|
||||||
fieldListeners.didUpdateFieldSettings(database, changeset);
|
fieldListeners.didUpdateFieldSettings(database, changeset);
|
||||||
},
|
},
|
||||||
[DatabaseNotification.DidUpdateViewRows]: (changeset) => {
|
[DatabaseNotification.DidUpdateViewRows]: async (changeset) => {
|
||||||
rowListeners.didUpdateViewRows(database, changeset);
|
await rowListeners.didUpdateViewRows(viewId, database, changeset);
|
||||||
},
|
},
|
||||||
[DatabaseNotification.DidReorderRows]: (changeset) => {
|
[DatabaseNotification.DidReorderRows]: (changeset) => {
|
||||||
rowListeners.didReorderRows(database, changeset);
|
rowListeners.didReorderRows(database, changeset);
|
||||||
@ -171,8 +175,8 @@ export const useConnectDatabase = (viewId: string) => {
|
|||||||
[DatabaseNotification.DidUpdateFilter]: (changeset) => {
|
[DatabaseNotification.DidUpdateFilter]: (changeset) => {
|
||||||
filterListeners.didUpdateFilter(database, changeset);
|
filterListeners.didUpdateFilter(database, changeset);
|
||||||
},
|
},
|
||||||
[DatabaseNotification.DidUpdateViewRowsVisibility]: (changeset) => {
|
[DatabaseNotification.DidUpdateViewRowsVisibility]: async (changeset) => {
|
||||||
rowListeners.didUpdateViewRowsVisibility(database, changeset);
|
await rowListeners.didUpdateViewRowsVisibility(viewId, database, changeset);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ id: viewId }
|
{ id: viewId }
|
||||||
|
@ -25,6 +25,7 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
|
|||||||
const innerRef = useRef<HTMLDivElement>();
|
const innerRef = useRef<HTMLDivElement>();
|
||||||
const databaseRef = (ref ?? innerRef) as React.MutableRefObject<HTMLDivElement>;
|
const databaseRef = (ref ?? innerRef) as React.MutableRefObject<HTMLDivElement>;
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
const [settingDom, setSettingDom] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [page, setPage] = useState<Page | null>(null);
|
const [page, setPage] = useState<Page | null>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -161,12 +162,16 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={databaseRef} className='appflowy-database relative flex flex-1 flex-col overflow-y-hidden'>
|
<div
|
||||||
|
ref={databaseRef}
|
||||||
|
className='appflowy-database relative flex w-full flex-1 select-none flex-col overflow-y-hidden'
|
||||||
|
>
|
||||||
<DatabaseTabBar
|
<DatabaseTabBar
|
||||||
pageId={viewId}
|
pageId={viewId}
|
||||||
setSelectedViewId={setSelectedViewId}
|
setSelectedViewId={setSelectedViewId}
|
||||||
selectedViewId={selectedViewId}
|
selectedViewId={selectedViewId}
|
||||||
childViews={childViews}
|
childViews={childViews}
|
||||||
|
ref={setSettingDom}
|
||||||
/>
|
/>
|
||||||
<SwipeableViews
|
<SwipeableViews
|
||||||
slideStyle={{
|
slideStyle={{
|
||||||
@ -181,13 +186,14 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
|
|||||||
<DatabaseLoader viewId={view.id}>
|
<DatabaseLoader viewId={view.id}>
|
||||||
{selectedViewId === view.id && (
|
{selectedViewId === view.id && (
|
||||||
<>
|
<>
|
||||||
<Portal container={databaseRef.current}>
|
{settingDom && (
|
||||||
<div className={'absolute right-16 top-0 py-1'}>
|
<Portal container={settingDom}>
|
||||||
<DatabaseSettings
|
<DatabaseSettings
|
||||||
onToggleCollection={(forceOpen?: boolean) => onToggleCollection(view.id, forceOpen)}
|
onToggleCollection={(forceOpen?: boolean) => onToggleCollection(view.id, forceOpen)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Portal>
|
||||||
</Portal>
|
)}
|
||||||
|
|
||||||
<DatabaseCollection open={openCollections.includes(view.id)} />
|
<DatabaseCollection open={openCollections.includes(view.id)} />
|
||||||
{editRecordRowId && (
|
{editRecordRowId && (
|
||||||
<ExpandRecordModal
|
<ExpandRecordModal
|
||||||
|
@ -6,6 +6,7 @@ import { updatePageName } from '$app_reducers/pages/async_actions';
|
|||||||
|
|
||||||
export const DatabaseTitle = () => {
|
export const DatabaseTitle = () => {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
|
||||||
const pageName = useAppSelector((state) => state.pages.pageMap[viewId]?.name || '');
|
const pageName = useAppSelector((state) => state.pages.pageMap[viewId]?.name || '');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
@ -0,0 +1,47 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
function LinearProgressWithLabel({
|
||||||
|
value,
|
||||||
|
count,
|
||||||
|
selectedCount,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
count: number;
|
||||||
|
selectedCount: number;
|
||||||
|
}) {
|
||||||
|
const result = useMemo(() => `${Math.round(value * 100)}%`, [value]);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
checked: i < selectedCount,
|
||||||
|
}));
|
||||||
|
}, [count, selectedCount]);
|
||||||
|
|
||||||
|
const isSplit = count < 6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex w-full items-center'}>
|
||||||
|
<div className={`flex flex-1 items-center justify-between px-1 ${isSplit ? 'gap-0.5' : ''}`}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: `${Math.round(100 / count)}%`,
|
||||||
|
backgroundColor:
|
||||||
|
value < 1
|
||||||
|
? option.checked
|
||||||
|
? 'var(--content-blue-400)'
|
||||||
|
: 'var(--content-blue-100)'
|
||||||
|
: 'var(--function-success)',
|
||||||
|
}}
|
||||||
|
className={`h-[4px] ${isSplit ? 'rounded-full' : ''} `}
|
||||||
|
key={option.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={'w-[30px] text-center text-xs text-text-caption'}>{result}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinearProgressWithLabel;
|
@ -1,16 +1,30 @@
|
|||||||
import React, { useState, Suspense, useMemo } from 'react';
|
import React, { useState, Suspense, useMemo } from 'react';
|
||||||
import { ChecklistCell as ChecklistCellType, ChecklistField } from '$app/application/database';
|
import { ChecklistCell as ChecklistCellType, ChecklistField } from '$app/application/database';
|
||||||
import Typography from '@mui/material/Typography';
|
|
||||||
import ChecklistCellActions from '$app/components/database/components/field_types/checklist/ChecklistCellActions';
|
import ChecklistCellActions from '$app/components/database/components/field_types/checklist/ChecklistCellActions';
|
||||||
|
import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel';
|
||||||
|
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||||
|
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: ChecklistField;
|
field: ChecklistField;
|
||||||
cell: ChecklistCellType;
|
cell: ChecklistCellType;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChecklistCell({ cell }: Props) {
|
const initialAnchorOrigin: PopoverOrigin = {
|
||||||
const value = cell?.data.percentage ?? 0;
|
vertical: 'bottom',
|
||||||
|
horizontal: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialTransformOrigin: PopoverOrigin = {
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChecklistCell({ cell, placeholder }: Props) {
|
||||||
|
const value = cell?.data.percentage ?? 0;
|
||||||
|
const options = useMemo(() => cell?.data.options ?? [], [cell?.data.options]);
|
||||||
|
const selectedOptions = useMemo(() => cell?.data.selectedOptions ?? [], [cell?.data.selectedOptions]);
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | undefined>(undefined);
|
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | undefined>(undefined);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@ -21,27 +35,32 @@ function ChecklistCell({ cell }: Props) {
|
|||||||
setAnchorEl(undefined);
|
setAnchorEl(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = useMemo(() => `${Math.round(value * 100)}%`, [value]);
|
const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({
|
||||||
|
initialPaperWidth: 369,
|
||||||
|
initialPaperHeight: 300,
|
||||||
|
anchorEl,
|
||||||
|
initialAnchorOrigin,
|
||||||
|
initialTransformOrigin,
|
||||||
|
open,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='flex w-full cursor-pointer items-center px-2' onClick={handleClick}>
|
<div className='flex w-full cursor-pointer items-center px-2' onClick={handleClick}>
|
||||||
<Typography variant='body2' color='text.secondary'>
|
{options.length > 0 ? (
|
||||||
{result}
|
<LinearProgressWithLabel value={value} count={options.length} selectedCount={selectedOptions.length} />
|
||||||
</Typography>
|
) : (
|
||||||
|
<div className={'text-sm text-text-placeholder'}>{placeholder}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{open && (
|
{open && (
|
||||||
<ChecklistCellActions
|
<ChecklistCellActions
|
||||||
transformOrigin={{
|
transformOrigin={transformOrigin}
|
||||||
vertical: 'top',
|
anchorOrigin={anchorOrigin}
|
||||||
horizontal: 'left',
|
maxHeight={paperHeight}
|
||||||
}}
|
maxWidth={paperWidth}
|
||||||
anchorOrigin={{
|
open={open && isEntered}
|
||||||
vertical: 'bottom',
|
|
||||||
horizontal: 'left',
|
|
||||||
}}
|
|
||||||
open={open}
|
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
cell={cell}
|
cell={cell}
|
||||||
|
@ -1,12 +1,25 @@
|
|||||||
import React, { Suspense, useRef, useState, useMemo } from 'react';
|
import React, { Suspense, useRef, useState, useMemo, useEffect } from 'react';
|
||||||
import { DateTimeCell as DateTimeCellType, DateTimeField } from '$app/application/database';
|
import { DateTimeCell as DateTimeCellType, DateTimeField } from '$app/application/database';
|
||||||
import DateTimeCellActions from '$app/components/database/components/field_types/date/DateTimeCellActions';
|
import DateTimeCellActions from '$app/components/database/components/field_types/date/DateTimeCellActions';
|
||||||
|
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||||
|
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: DateTimeField;
|
field: DateTimeField;
|
||||||
cell: DateTimeCellType;
|
cell: DateTimeCellType;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initialAnchorOrigin: PopoverOrigin = {
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialTransformOrigin: PopoverOrigin = {
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
function DateTimeCell({ field, cell, placeholder }: Props) {
|
function DateTimeCell({ field, cell, placeholder }: Props) {
|
||||||
const isRange = cell.data.isRange;
|
const isRange = cell.data.isRange;
|
||||||
const includeTime = cell.data.includeTime;
|
const includeTime = cell.data.includeTime;
|
||||||
@ -38,14 +51,71 @@ function DateTimeCell({ field, cell, placeholder }: Props) {
|
|||||||
return <div className={'text-sm text-text-placeholder'}>{placeholder}</div>;
|
return <div className={'text-sm text-text-placeholder'}>{placeholder}</div>;
|
||||||
}, [cell, includeTime, isRange, placeholder]);
|
}, [cell, includeTime, isRange, placeholder]);
|
||||||
|
|
||||||
|
const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered, calculateAnchorSize } =
|
||||||
|
usePopoverAutoPosition({
|
||||||
|
initialPaperWidth: 248,
|
||||||
|
initialPaperHeight: 500,
|
||||||
|
anchorEl: ref.current,
|
||||||
|
initialAnchorOrigin,
|
||||||
|
initialTransformOrigin,
|
||||||
|
open,
|
||||||
|
marginThreshold: 34,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const anchorEl = ref.current;
|
||||||
|
|
||||||
|
const parent = anchorEl?.parentElement?.parentElement;
|
||||||
|
|
||||||
|
if (!anchorEl || !parent) return;
|
||||||
|
|
||||||
|
let timeout: NodeJS.Timeout;
|
||||||
|
const handleObserve = () => {
|
||||||
|
anchorEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
calculateAnchorSize();
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver(handleObserve);
|
||||||
|
|
||||||
|
observer.observe(parent, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [calculateAnchorSize, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={ref} className={'flex h-full w-full items-center px-2 text-xs font-medium'} onClick={handleClick}>
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`flex h-full min-h-[36px] w-full cursor-pointer items-center overflow-x-hidden truncate px-2 text-xs font-medium ${
|
||||||
|
open ? 'bg-fill-list-active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{open && (
|
{open && (
|
||||||
<DateTimeCellActions field={field} onClose={handleClose} anchorEl={ref.current} cell={cell} open={open} />
|
<DateTimeCellActions
|
||||||
|
field={field}
|
||||||
|
maxWidth={paperWidth}
|
||||||
|
maxHeight={paperHeight}
|
||||||
|
anchorOrigin={anchorOrigin}
|
||||||
|
transformOrigin={transformOrigin}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorEl={ref.current}
|
||||||
|
cell={cell}
|
||||||
|
open={open && isEntered}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
|
@ -1,9 +1,21 @@
|
|||||||
import { FC, useCallback, useMemo, useState, Suspense, lazy } from 'react';
|
import { FC, useCallback, useMemo, useState, Suspense, lazy } from 'react';
|
||||||
import { MenuProps, Menu } from '@mui/material';
|
import { MenuProps } from '@mui/material';
|
||||||
import { SelectField, SelectCell as SelectCellType, SelectTypeOption } from '$app/application/database';
|
import { SelectField, SelectCell as SelectCellType, SelectTypeOption } from '$app/application/database';
|
||||||
import { Tag } from '../field_types/select/Tag';
|
import { Tag } from '../field_types/select/Tag';
|
||||||
import { useTypeOption } from '$app/components/database';
|
import { useTypeOption } from '$app/components/database';
|
||||||
|
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||||
|
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
|
||||||
|
const initialAnchorOrigin: PopoverOrigin = {
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialTransformOrigin: PopoverOrigin = {
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
};
|
||||||
const SelectCellActions = lazy(
|
const SelectCellActions = lazy(
|
||||||
() => import('$app/components/database/components/field_types/select/select_cell_actions/SelectCellActions')
|
() => import('$app/components/database/components/field_types/select/select_cell_actions/SelectCellActions')
|
||||||
);
|
);
|
||||||
@ -11,14 +23,6 @@ const menuProps: Partial<MenuProps> = {
|
|||||||
classes: {
|
classes: {
|
||||||
list: 'py-5',
|
list: 'py-5',
|
||||||
},
|
},
|
||||||
anchorOrigin: {
|
|
||||||
vertical: 'bottom',
|
|
||||||
horizontal: 'left',
|
|
||||||
},
|
|
||||||
transformOrigin: {
|
|
||||||
vertical: 'top',
|
|
||||||
horizontal: 'left',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SelectCell: FC<{
|
export const SelectCell: FC<{
|
||||||
@ -43,6 +47,15 @@ export const SelectCell: FC<{
|
|||||||
[typeOption]
|
[typeOption]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({
|
||||||
|
initialPaperWidth: 369,
|
||||||
|
initialPaperHeight: 400,
|
||||||
|
anchorEl,
|
||||||
|
initialAnchorOrigin,
|
||||||
|
initialTransformOrigin,
|
||||||
|
open,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'relative w-full'}>
|
<div className={'relative w-full'}>
|
||||||
<div
|
<div
|
||||||
@ -59,17 +72,35 @@ export const SelectCell: FC<{
|
|||||||
</div>
|
</div>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{open ? (
|
{open ? (
|
||||||
<Menu
|
<Popover
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
disableRestoreFocus={true}
|
disableRestoreFocus={true}
|
||||||
className='h-full w-full'
|
className='h-full w-full'
|
||||||
open={open}
|
open={open && isEntered}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
{...menuProps}
|
{...menuProps}
|
||||||
|
transformOrigin={transformOrigin}
|
||||||
|
anchorOrigin={anchorOrigin}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
|
PaperProps={{
|
||||||
|
className: 'flex h-full flex-col py-4 overflow-hidden',
|
||||||
|
style: {
|
||||||
|
maxHeight: paperHeight,
|
||||||
|
maxWidth: paperWidth,
|
||||||
|
height: 'auto',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const isInput = (e.target as Element).closest('input');
|
||||||
|
|
||||||
|
if (isInput) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectCellActions field={field} cell={cell} />
|
<SelectCellActions onClose={handleClose} field={field} cell={cell} />
|
||||||
</Menu>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import React, { FormEventHandler, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { FormEventHandler, lazy, Suspense, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useInputCell } from '$app/components/database/components/cell/Cell.hooks';
|
import { useInputCell } from '$app/components/database/components/cell/Cell.hooks';
|
||||||
import { Field, UrlCell as URLCellType } from '$app/application/database';
|
import { Field, UrlCell as URLCellType } from '$app/application/database';
|
||||||
import { CellText } from '$app/components/database/_shared';
|
import { CellText } from '$app/components/database/_shared';
|
||||||
|
import { openUrl } from '$app/utils/open_url';
|
||||||
|
|
||||||
const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput'));
|
const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput'));
|
||||||
|
|
||||||
const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: Field;
|
field: Field;
|
||||||
cell: URLCellType;
|
cell: URLCellType;
|
||||||
@ -14,7 +13,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UrlCell({ field, cell, placeholder }: Props) {
|
function UrlCell({ field, cell, placeholder }: Props) {
|
||||||
const [isUrl, setIsUrl] = useState(false);
|
|
||||||
const cellRef = useRef<HTMLDivElement>(null);
|
const cellRef = useRef<HTMLDivElement>(null);
|
||||||
const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell);
|
const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell);
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
@ -33,33 +31,26 @@ function UrlCell({ field, cell, placeholder }: Props) {
|
|||||||
[setValue]
|
[setValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editing) return;
|
|
||||||
const str = cell.data.content;
|
|
||||||
|
|
||||||
if (!str) return;
|
|
||||||
const isUrl = pattern.test(str);
|
|
||||||
|
|
||||||
setIsUrl(isUrl);
|
|
||||||
}, [cell, editing]);
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
const str = cell.data.content;
|
const str = cell.data.content;
|
||||||
|
|
||||||
if (str) {
|
if (str) {
|
||||||
if (isUrl) {
|
return (
|
||||||
return (
|
<a
|
||||||
<a href={str} target={'_blank'} className={'cursor-pointer text-content-blue-400 underline'}>
|
onClick={(e) => {
|
||||||
{str}
|
e.stopPropagation();
|
||||||
</a>
|
openUrl(str);
|
||||||
);
|
}}
|
||||||
}
|
target={'_blank'}
|
||||||
|
className={'cursor-pointer text-content-blue-400 underline'}
|
||||||
return str;
|
>
|
||||||
|
{str}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={'text-sm text-text-placeholder'}>{placeholder}</div>;
|
return <div className={'cursor-text text-sm text-text-placeholder'}>{placeholder}</div>;
|
||||||
}, [isUrl, cell, placeholder]);
|
}, [cell, placeholder]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -68,6 +59,7 @@ function UrlCell({ field, cell, placeholder }: Props) {
|
|||||||
width: `${field.width}px`,
|
width: `${field.width}px`,
|
||||||
minHeight: 37,
|
minHeight: 37,
|
||||||
}}
|
}}
|
||||||
|
className={'cursor-text'}
|
||||||
ref={cellRef}
|
ref={cellRef}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
|
@ -8,7 +8,7 @@ interface Props {
|
|||||||
|
|
||||||
export const DatabaseCollection = ({ open }: Props) => {
|
export const DatabaseCollection = ({ open }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center px-16 ${!open ? 'hidden' : 'py-3'}`}>
|
<div className={`flex items-center gap-2 px-16 ${!open ? 'hidden' : 'py-3'}`}>
|
||||||
<Sorts />
|
<Sorts />
|
||||||
<Filters />
|
<Filters />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Stack } from '@mui/material';
|
|
||||||
import { TextButton } from '$app/components/database/components/tab_bar/TextButton';
|
import { TextButton } from '$app/components/database/components/tab_bar/TextButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@ -16,7 +15,7 @@ function DatabaseSettings(props: Props) {
|
|||||||
const [settingAnchorEl, setSettingAnchorEl] = useState<null | HTMLElement>(null);
|
const [settingAnchorEl, setSettingAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='text-neutral-500' direction='row' spacing='2px'>
|
<div className='flex h-[39px] items-center gap-2 border-b border-line-divider'>
|
||||||
<FilterSettings {...props} />
|
<FilterSettings {...props} />
|
||||||
<SortSettings {...props} />
|
<SortSettings {...props} />
|
||||||
<TextButton color='inherit' onClick={(e) => setSettingAnchorEl(e.currentTarget)}>
|
<TextButton color='inherit' onClick={(e) => setSettingAnchorEl(e.currentTarget)}>
|
||||||
@ -27,7 +26,7 @@ function DatabaseSettings(props: Props) {
|
|||||||
anchorEl={settingAnchorEl}
|
anchorEl={settingAnchorEl}
|
||||||
onClose={() => setSettingAnchorEl(null)}
|
onClose={() => setSettingAnchorEl(null)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ function Properties({ onItemClick }: PropertiesProps) {
|
|||||||
const { fields } = useDatabase();
|
const { fields } = useDatabase();
|
||||||
const [state, setState] = useState<FieldType[]>(fields as FieldType[]);
|
const [state, setState] = useState<FieldType[]>(fields as FieldType[]);
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
const [menuPropertyId, setMenuPropertyId] = useState<string | undefined>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState(fields as FieldType[]);
|
setState(fields as FieldType[]);
|
||||||
@ -60,7 +61,12 @@ function Properties({ onItemClick }: PropertiesProps) {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
className={'flex w-full items-center justify-between overflow-hidden px-1.5'}
|
className={
|
||||||
|
'flex w-full items-center justify-between overflow-hidden rounded-none px-1.5 hover:bg-fill-list-hover'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuPropertyId(field.id);
|
||||||
|
}}
|
||||||
key={field.id}
|
key={field.id}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
@ -71,13 +77,22 @@ function Properties({ onItemClick }: PropertiesProps) {
|
|||||||
<DragSvg />
|
<DragSvg />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<div className={'w-[100px] overflow-hidden text-ellipsis'}>
|
<div className={'w-[100px] overflow-hidden text-ellipsis'}>
|
||||||
<Property field={field} />
|
<Property
|
||||||
|
onCloseMenu={() => {
|
||||||
|
setMenuPropertyId(undefined);
|
||||||
|
}}
|
||||||
|
menuOpened={menuPropertyId === field.id}
|
||||||
|
field={field}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={field.isPrimary}
|
disabled={field.isPrimary}
|
||||||
size={'small'}
|
size={'small'}
|
||||||
onClick={() => onItemClick(field)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onItemClick(field);
|
||||||
|
}}
|
||||||
className={'ml-2'}
|
className={'ml-2'}
|
||||||
>
|
>
|
||||||
{field.visibility !== FieldVisibility.AlwaysHidden ? <EyeOpen /> : <EyeClosed />}
|
{field.visibility !== FieldVisibility.AlwaysHidden ? <EyeOpen /> : <EyeClosed />}
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { Menu, MenuItem, MenuProps, Popover } from '@mui/material';
|
import { Menu, MenuProps, Popover } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Properties from '$app/components/database/components/database_settings/Properties';
|
import Properties from '$app/components/database/components/database_settings/Properties';
|
||||||
import { Field } from '$app/application/database';
|
import { Field } from '$app/application/database';
|
||||||
import { FieldVisibility } from '@/services/backend';
|
import { FieldVisibility } from '@/services/backend';
|
||||||
import { updateFieldSetting } from '$app/application/database/field/field_service';
|
import { updateFieldSetting } from '$app/application/database/field/field_service';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
|
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
|
||||||
type SettingsMenuProps = MenuProps;
|
type SettingsMenuProps = MenuProps;
|
||||||
|
|
||||||
function SettingsMenu(props: SettingsMenuProps) {
|
function SettingsMenu(props: SettingsMenuProps) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [propertiesAnchorElPosition, setPropertiesAnchorElPosition] = useState<
|
const [propertiesAnchorElPosition, setPropertiesAnchorElPosition] = useState<
|
||||||
| undefined
|
| undefined
|
||||||
@ -36,25 +38,39 @@ function SettingsMenu(props: SettingsMenuProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return [{ key: 'properties', content: <div data-key={'properties'}>{t('grid.settings.properties')}</div> }];
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const onConfirm = useCallback(
|
||||||
|
(optionKey: string) => {
|
||||||
|
if (optionKey === 'properties') {
|
||||||
|
const target = ref.current?.querySelector(`[data-key=${optionKey}]`) as HTMLElement;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
|
||||||
|
setPropertiesAnchorElPosition({
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left + rect.width,
|
||||||
|
});
|
||||||
|
props.onClose?.({}, 'backdropClick');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[props]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu {...props} disableRestoreFocus={true}>
|
<Menu {...props} ref={ref} disableRestoreFocus={true}>
|
||||||
<MenuItem
|
<KeyboardNavigation
|
||||||
onClick={(event) => {
|
onConfirm={onConfirm}
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
onEscape={() => {
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
setPropertiesAnchorElPosition({
|
|
||||||
top: rect.top,
|
|
||||||
left: rect.left + rect.width,
|
|
||||||
});
|
|
||||||
props.onClose?.({}, 'backdropClick');
|
|
||||||
}}
|
}}
|
||||||
>
|
options={options}
|
||||||
{t('grid.settings.properties')}
|
/>
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
</Menu>
|
||||||
<Popover
|
<Popover
|
||||||
disableRestoreFocus={true}
|
keepMounted={false}
|
||||||
open={openProperties}
|
open={openProperties}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setPropertiesAnchorElPosition(undefined);
|
setPropertiesAnchorElPosition(undefined);
|
||||||
@ -65,6 +81,13 @@ function SettingsMenu(props: SettingsMenuProps) {
|
|||||||
vertical: 'top',
|
vertical: 'top',
|
||||||
horizontal: 'right',
|
horizontal: 'right',
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Properties onItemClick={togglePropertyVisibility} />
|
<Properties onItemClick={togglePropertyVisibility} />
|
||||||
</Popover>
|
</Popover>
|
||||||
|
@ -25,6 +25,9 @@ function ExpandRecordModal({ open, onClose, rowId }: Props) {
|
|||||||
className: 'h-[calc(100%-144px)] w-[80%] max-w-[960px] overflow-visible',
|
className: 'h-[calc(100%-144px)] w-[80%] max-w-[960px] overflow-visible',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<DialogContent className={'appflowy-scroll-container relative p-0'}>
|
||||||
|
<EditRecord rowId={rowId} />
|
||||||
|
</DialogContent>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='close'
|
aria-label='close'
|
||||||
className={'absolute right-[8px] top-[8px] text-text-caption'}
|
className={'absolute right-[8px] top-[8px] text-text-caption'}
|
||||||
@ -34,14 +37,14 @@ function ExpandRecordModal({ open, onClose, rowId }: Props) {
|
|||||||
>
|
>
|
||||||
<DetailsIcon />
|
<DetailsIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<DialogContent className={'relative p-0'}>
|
|
||||||
<EditRecord rowId={rowId} />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<RecordActions
|
<RecordActions
|
||||||
anchorEl={detailAnchorEl}
|
anchorEl={detailAnchorEl}
|
||||||
rowId={rowId}
|
rowId={rowId}
|
||||||
open={!!detailAnchorEl}
|
open={!!detailAnchorEl}
|
||||||
|
onEscape={() => {
|
||||||
|
onClose?.({}, 'escapeKeyDown');
|
||||||
|
}}
|
||||||
onClose={() => setDetailAnchorEl(null)}
|
onClose={() => setDetailAnchorEl(null)}
|
||||||
/>
|
/>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
@ -9,19 +9,22 @@ import MenuItem from '@mui/material/MenuItem';
|
|||||||
|
|
||||||
interface Props extends MenuProps {
|
interface Props extends MenuProps {
|
||||||
rowId: string;
|
rowId: string;
|
||||||
|
onEscape?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
function RecordActions({ anchorEl, open, onClose, rowId }: Props) {
|
function RecordActions({ anchorEl, open, onEscape, onClose, rowId }: Props) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleDelRow = useCallback(() => {
|
const handleDelRow = useCallback(() => {
|
||||||
void rowService.deleteRow(viewId, rowId);
|
void rowService.deleteRow(viewId, rowId);
|
||||||
}, [viewId, rowId]);
|
onEscape?.();
|
||||||
|
}, [viewId, rowId, onEscape]);
|
||||||
|
|
||||||
const handleDuplicateRow = useCallback(() => {
|
const handleDuplicateRow = useCallback(() => {
|
||||||
void rowService.duplicateRow(viewId, rowId);
|
void rowService.duplicateRow(viewId, rowId);
|
||||||
}, [viewId, rowId]);
|
onEscape?.();
|
||||||
|
}, [viewId, rowId, onEscape]);
|
||||||
|
|
||||||
const menuOptions = [
|
const menuOptions = [
|
||||||
{
|
{
|
||||||
@ -46,6 +49,7 @@ function RecordActions({ anchorEl, open, onClose, rowId }: Props) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
option.onClick();
|
option.onClick();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
|
onEscape?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className='mr-2'>{option.icon}</Icon>
|
<Icon className='mr-2'>{option.icon}</Icon>
|
||||||
|
@ -6,7 +6,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RecordDocument({ documentId }: Props) {
|
function RecordDocument({ documentId }: Props) {
|
||||||
return <Editor id={documentId} showTitle={false} />;
|
return <Editor disableFocus={true} id={documentId} showTitle={false} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(RecordDocument);
|
export default React.memo(RecordDocument);
|
||||||
|
@ -28,7 +28,7 @@ function RecordHeader({ page, row }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={'px-16 pb-4'}>
|
<div ref={ref} className={'px-16 py-4'}>
|
||||||
<RecordTitle page={page} row={row} />
|
<RecordTitle page={page} row={row} />
|
||||||
<RecordProperties row={row} />
|
<RecordProperties row={row} />
|
||||||
<Divider />
|
<Divider />
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { updateChecklistCell } from '$app/application/database/cell/cell_service';
|
import { updateChecklistCell } from '$app/application/database/cell/cell_service';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { ReactComponent as AddIcon } from '$app/assets/add.svg';
|
import { Button } from '@mui/material';
|
||||||
import { IconButton } from '@mui/material';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function AddNewOption({ rowId, fieldId }: { rowId: string; fieldId: string }) {
|
function AddNewOption({ rowId, fieldId, onClose }: { rowId: string; fieldId: string; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
@ -17,23 +16,35 @@ function AddNewOption({ rowId, fieldId }: { rowId: string; fieldId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex items-center justify-between p-2 px-4 text-sm'}>
|
<div className={'flex items-center justify-between p-2 px-2 text-sm'}>
|
||||||
<input
|
<input
|
||||||
placeholder={t('grid.checklist.addNew')}
|
placeholder={t('grid.checklist.addNew')}
|
||||||
className={'flex-1'}
|
className={'flex-1 px-2'}
|
||||||
|
autoFocus={true}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
void createOption();
|
void createOption();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={value}
|
value={value}
|
||||||
|
spellCheck={false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton size={'small'} disabled={!value} onClick={createOption}>
|
<Button variant={'contained'} className={'text-xs'} size={'small'} disabled={!value} onClick={createOption}>
|
||||||
<AddIcon />
|
{t('grid.selectOption.create')}
|
||||||
</IconButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,39 +1,78 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import Popover, { PopoverProps } from '@mui/material/Popover';
|
import Popover, { PopoverProps } from '@mui/material/Popover';
|
||||||
import { LinearProgressWithLabel } from '$app/components/database/components/field_types/checklist/LinearProgressWithLabel';
|
|
||||||
import { Divider } from '@mui/material';
|
import { Divider } from '@mui/material';
|
||||||
import { ChecklistCell as ChecklistCellType } from '$app/application/database';
|
import { ChecklistCell as ChecklistCellType } from '$app/application/database';
|
||||||
import ChecklistItem from '$app/components/database/components/field_types/checklist/ChecklistItem';
|
import ChecklistItem from '$app/components/database/components/field_types/checklist/ChecklistItem';
|
||||||
import AddNewOption from '$app/components/database/components/field_types/checklist/AddNewOption';
|
import AddNewOption from '$app/components/database/components/field_types/checklist/AddNewOption';
|
||||||
|
import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel';
|
||||||
|
|
||||||
function ChecklistCellActions({
|
function ChecklistCellActions({
|
||||||
cell,
|
cell,
|
||||||
|
maxHeight,
|
||||||
|
maxWidth,
|
||||||
...props
|
...props
|
||||||
}: PopoverProps & {
|
}: PopoverProps & {
|
||||||
cell: ChecklistCellType;
|
cell: ChecklistCellType;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
}) {
|
}) {
|
||||||
const { fieldId, rowId } = cell;
|
const { fieldId, rowId } = cell;
|
||||||
const { percentage, selectedOptions = [], options } = cell.data;
|
const { percentage, selectedOptions = [], options = [] } = cell.data;
|
||||||
|
|
||||||
|
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover disableRestoreFocus={true} {...props}>
|
<Popover
|
||||||
<LinearProgressWithLabel className={'m-4'} value={percentage || 0} />
|
{...props}
|
||||||
<div className={'p-1'}>
|
disableRestoreFocus={true}
|
||||||
{options?.map((option) => {
|
onKeyDown={(e) => {
|
||||||
return (
|
if (e.key === 'Escape') {
|
||||||
<ChecklistItem
|
e.stopPropagation();
|
||||||
fieldId={fieldId}
|
e.preventDefault();
|
||||||
rowId={rowId}
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
key={option.id}
|
}
|
||||||
option={option}
|
}}
|
||||||
checked={selectedOptions?.includes(option.id) || false}
|
>
|
||||||
/>
|
<div
|
||||||
);
|
style={{
|
||||||
})}
|
maxHeight: maxHeight,
|
||||||
</div>
|
maxWidth: maxWidth,
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHoverId(null)}
|
||||||
|
className={'flex h-full w-full flex-col overflow-hidden'}
|
||||||
|
>
|
||||||
|
{options.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={'p-2'}>
|
||||||
|
<LinearProgressWithLabel
|
||||||
|
value={percentage ?? 0}
|
||||||
|
count={options.length}
|
||||||
|
selectedCount={selectedOptions.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={'flex-1 overflow-y-auto overflow-x-hidden p-1'}>
|
||||||
|
{options?.map((option) => {
|
||||||
|
return (
|
||||||
|
<ChecklistItem
|
||||||
|
fieldId={fieldId}
|
||||||
|
rowId={rowId}
|
||||||
|
isHovered={hoverId === option.id}
|
||||||
|
onMouseEnter={() => setHoverId(option.id)}
|
||||||
|
key={option.id}
|
||||||
|
option={option}
|
||||||
|
onClose={() => props.onClose?.({}, 'escapeKeyDown')}
|
||||||
|
checked={selectedOptions?.includes(option.id) || false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<AddNewOption fieldId={fieldId} rowId={rowId} />
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddNewOption onClose={() => props.onClose?.({}, 'escapeKeyDown')} fieldId={fieldId} rowId={rowId} />
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,38 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { SelectOption } from '$app/application/database';
|
import { SelectOption } from '$app/application/database';
|
||||||
import { Checkbox, IconButton } from '@mui/material';
|
import { IconButton } from '@mui/material';
|
||||||
import { updateChecklistCell } from '$app/application/database/cell/cell_service';
|
import { updateChecklistCell } from '$app/application/database/cell/cell_service';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg';
|
import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg';
|
||||||
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
|
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
|
||||||
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
|
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
|
||||||
|
import isHotkey from 'is-hotkey';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const DELAY_CHANGE = 200;
|
||||||
|
|
||||||
function ChecklistItem({
|
function ChecklistItem({
|
||||||
checked,
|
checked,
|
||||||
option,
|
option,
|
||||||
rowId,
|
rowId,
|
||||||
fieldId,
|
fieldId,
|
||||||
|
onClose,
|
||||||
|
isHovered,
|
||||||
|
onMouseEnter,
|
||||||
}: {
|
}: {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
option: SelectOption;
|
option: SelectOption;
|
||||||
rowId: string;
|
rowId: string;
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
isHovered: boolean;
|
||||||
|
onMouseEnter: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [hover, setHover] = useState(false);
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState(option.name);
|
const [value, setValue] = useState(option.name);
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const updateText = async () => {
|
const updateText = useCallback(async () => {
|
||||||
await updateChecklistCell(viewId, rowId, fieldId, {
|
await updateChecklistCell(viewId, rowId, fieldId, {
|
||||||
updateOptions: [
|
updateOptions: [
|
||||||
{
|
{
|
||||||
@ -30,49 +41,75 @@ function ChecklistItem({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
}, [fieldId, option, rowId, value, viewId]);
|
||||||
|
|
||||||
const onCheckedChange = async () => {
|
const onCheckedChange = useMemo(() => {
|
||||||
void updateChecklistCell(viewId, rowId, fieldId, {
|
return debounce(
|
||||||
selectedOptionIds: [option.id],
|
() =>
|
||||||
});
|
updateChecklistCell(viewId, rowId, fieldId, {
|
||||||
};
|
selectedOptionIds: [option.id],
|
||||||
|
}),
|
||||||
|
DELAY_CHANGE
|
||||||
|
);
|
||||||
|
}, [fieldId, option.id, rowId, viewId]);
|
||||||
|
|
||||||
const deleteOption = async () => {
|
const deleteOption = useCallback(async () => {
|
||||||
await updateChecklistCell(viewId, rowId, fieldId, {
|
await updateChecklistCell(viewId, rowId, fieldId, {
|
||||||
deleteOptionIds: [option.id],
|
deleteOptionIds: [option.id],
|
||||||
});
|
});
|
||||||
};
|
}, [fieldId, option.id, rowId, viewId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onMouseEnter={() => {
|
onMouseEnter={onMouseEnter}
|
||||||
setHover(true);
|
className={`flex items-center justify-between gap-2 rounded p-1 text-sm hover:bg-fill-list-active`}
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHover(false);
|
|
||||||
}}
|
|
||||||
className={`flex items-center justify-between gap-2 rounded p-1 text-sm ${hover ? 'bg-fill-list-hover' : ''}`}
|
|
||||||
>
|
>
|
||||||
<Checkbox
|
<div className={'cursor-pointer select-none text-content-blue-400'} onClick={onCheckedChange}>
|
||||||
onClick={onCheckedChange}
|
{checked ? <CheckboxCheckSvg className={'h-5 w-5'} /> : <CheckboxUncheckSvg className={'h-5 w-5'} />}
|
||||||
checked={checked}
|
</div>
|
||||||
disableRipple
|
|
||||||
style={{ padding: 4 }}
|
|
||||||
icon={<CheckboxUncheckSvg />}
|
|
||||||
checkedIcon={<CheckboxCheckSvg />}
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
className={'flex-1'}
|
className={'flex-1 truncate'}
|
||||||
onBlur={updateText}
|
onBlur={updateText}
|
||||||
value={value}
|
value={value}
|
||||||
|
placeholder={t('grid.checklist.taskHint')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
void updateText();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
void updateText();
|
||||||
|
if (isHotkey('mod+enter', e)) {
|
||||||
|
void onCheckedChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton size={'small'} className={`mx-2 ${hover ? 'visible' : 'invisible'}`} onClick={deleteOption}>
|
<div className={'w-10'}>
|
||||||
<DeleteIcon />
|
<IconButton
|
||||||
</IconButton>
|
size={'small'}
|
||||||
|
style={{
|
||||||
|
display: isHovered ? 'block' : 'none',
|
||||||
|
}}
|
||||||
|
className={`z-10 mx-2`}
|
||||||
|
onClick={deleteOption}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -14,18 +14,25 @@ function CustomCalendar({
|
|||||||
}: {
|
}: {
|
||||||
handleChange: (params: { date?: number; endDate?: number }) => void;
|
handleChange: (params: { date?: number; endDate?: number }) => void;
|
||||||
isRange: boolean;
|
isRange: boolean;
|
||||||
timestamp: number;
|
timestamp?: number;
|
||||||
endTimestamp: number;
|
endTimestamp?: number;
|
||||||
}) {
|
}) {
|
||||||
const [startDate, setStartDate] = useState<Date | null>(new Date(timestamp * 1000));
|
const [startDate, setStartDate] = useState<Date | null>(() => {
|
||||||
const [endDate, setEndDate] = useState<Date | null>(new Date(endTimestamp * 1000));
|
if (!timestamp) return null;
|
||||||
|
return new Date(timestamp * 1000);
|
||||||
|
});
|
||||||
|
const [endDate, setEndDate] = useState<Date | null>(() => {
|
||||||
|
if (!endTimestamp) return null;
|
||||||
|
return new Date(endTimestamp * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRange) return;
|
if (!isRange || !endTimestamp) return;
|
||||||
setEndDate(new Date(endTimestamp * 1000));
|
setEndDate(new Date(endTimestamp * 1000));
|
||||||
}, [isRange, endTimestamp]);
|
}, [isRange, endTimestamp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!timestamp) return;
|
||||||
setStartDate(new Date(timestamp * 1000));
|
setStartDate(new Date(timestamp * 1000));
|
||||||
}, [timestamp]);
|
}, [timestamp]);
|
||||||
|
|
||||||
@ -33,7 +40,7 @@ function CustomCalendar({
|
|||||||
<div className={'flex w-full items-center justify-center'}>
|
<div className={'flex w-full items-center justify-center'}>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
calendarClassName={
|
calendarClassName={
|
||||||
'appflowy-date-picker-calendar bg-bg-body h-full border-none rounded-none flex w-full items-center justify-center'
|
'appflowy-date-picker-calendar select-none bg-bg-body h-full border-none rounded-none flex w-full items-center justify-center'
|
||||||
}
|
}
|
||||||
renderCustomHeader={(props: ReactDatePickerCustomHeaderProps) => {
|
renderCustomHeader={(props: ReactDatePickerCustomHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
@ -56,8 +63,30 @@ function CustomCalendar({
|
|||||||
selected={startDate}
|
selected={startDate}
|
||||||
onChange={(dates) => {
|
onChange={(dates) => {
|
||||||
if (!dates) return;
|
if (!dates) return;
|
||||||
if (isRange) {
|
if (isRange && Array.isArray(dates)) {
|
||||||
const [start, end] = dates as [Date | null, Date | null];
|
let start = dates[0] as Date;
|
||||||
|
let end = dates[1] as Date;
|
||||||
|
|
||||||
|
if (!end && start && startDate && endDate) {
|
||||||
|
const currentTime = start.getTime();
|
||||||
|
const startTimeStamp = startDate.getTime();
|
||||||
|
const endTimeStamp = endDate.getTime();
|
||||||
|
const isGreaterThanStart = currentTime > startTimeStamp;
|
||||||
|
const isGreaterThanEnd = currentTime > endTimeStamp;
|
||||||
|
const isLessThanStart = currentTime < startTimeStamp;
|
||||||
|
const isLessThanEnd = currentTime < endTimeStamp;
|
||||||
|
const isEqualsStart = currentTime === startTimeStamp;
|
||||||
|
const isEqualsEnd = currentTime === endTimeStamp;
|
||||||
|
|
||||||
|
if ((isGreaterThanStart && isLessThanEnd) || isGreaterThanEnd) {
|
||||||
|
end = start;
|
||||||
|
start = startDate;
|
||||||
|
} else if (isEqualsStart || isEqualsEnd) {
|
||||||
|
end = start;
|
||||||
|
} else if (isLessThanStart) {
|
||||||
|
end = endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setStartDate(start);
|
setStartDate(start);
|
||||||
setEndDate(end);
|
setEndDate(end);
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import React, { useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { MenuItem, Menu } from '@mui/material';
|
import { MenuItem, Menu } from '@mui/material';
|
||||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||||
|
|
||||||
import { DateFormatPB } from '@/services/backend';
|
import { DateFormatPB } from '@/services/backend';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: DateFormatPB;
|
value: DateFormatPB;
|
||||||
@ -15,17 +18,44 @@ function DateFormat({ value, onChange }: Props) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLLIElement>(null);
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
const dateFormatMap = useMemo(
|
|
||||||
() => ({
|
const renderOptionContent = useCallback(
|
||||||
[DateFormatPB.Friendly]: t('grid.field.dateFormatFriendly'),
|
(option: DateFormatPB, title: string) => {
|
||||||
[DateFormatPB.ISO]: t('grid.field.dateFormatISO'),
|
return (
|
||||||
[DateFormatPB.US]: t('grid.field.dateFormatUS'),
|
<div className={'flex w-full items-center justify-between gap-2'}>
|
||||||
[DateFormatPB.Local]: t('grid.field.dateFormatLocal'),
|
<div className={'flex-1'}>{title}</div>
|
||||||
[DateFormatPB.DayMonthYear]: t('grid.field.dateFormatDayMonthYear'),
|
{value === option && <SelectCheckSvg />}
|
||||||
}),
|
</div>
|
||||||
[t]
|
);
|
||||||
|
},
|
||||||
|
[value]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const options: KeyboardNavigationOption<DateFormatPB>[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: DateFormatPB.Friendly,
|
||||||
|
content: renderOptionContent(DateFormatPB.Friendly, t('grid.field.dateFormatFriendly')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: DateFormatPB.ISO,
|
||||||
|
content: renderOptionContent(DateFormatPB.ISO, t('grid.field.dateFormatISO')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: DateFormatPB.US,
|
||||||
|
content: renderOptionContent(DateFormatPB.US, t('grid.field.dateFormatUS')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: DateFormatPB.Local,
|
||||||
|
content: renderOptionContent(DateFormatPB.Local, t('grid.field.dateFormatLocal')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: DateFormatPB.DayMonthYear,
|
||||||
|
content: renderOptionContent(DateFormatPB.DayMonthYear, t('grid.field.dateFormatDayMonthYear')),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [renderOptionContent, t]);
|
||||||
|
|
||||||
const handleClick = (option: DateFormatPB) => {
|
const handleClick = (option: DateFormatPB) => {
|
||||||
onChange(option);
|
onChange(option);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@ -42,7 +72,6 @@ function DateFormat({ value, onChange }: Props) {
|
|||||||
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Menu
|
<Menu
|
||||||
disableRestoreFocus={true}
|
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'top',
|
vertical: 'top',
|
||||||
horizontal: 'right',
|
horizontal: 'right',
|
||||||
@ -51,20 +80,14 @@ function DateFormat({ value, onChange }: Props) {
|
|||||||
anchorEl={ref.current}
|
anchorEl={ref.current}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
{Object.keys(dateFormatMap).map((option) => {
|
<KeyboardNavigation
|
||||||
const optionValue = Number(option) as DateFormatPB;
|
onEscape={() => {
|
||||||
|
setOpen(false);
|
||||||
return (
|
}}
|
||||||
<MenuItem
|
disableFocus={true}
|
||||||
className={'min-w-[180px] justify-between'}
|
options={options}
|
||||||
key={optionValue}
|
onConfirm={handleClick}
|
||||||
onClick={() => handleClick(optionValue)}
|
/>
|
||||||
>
|
|
||||||
{dateFormatMap[optionValue]}
|
|
||||||
{value === optionValue && <SelectCheckSvg />}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -13,14 +13,19 @@ import DateTimeFormatSelect from '$app/components/database/components/field_type
|
|||||||
import DateTimeSet from '$app/components/database/components/field_types/date/DateTimeSet';
|
import DateTimeSet from '$app/components/database/components/field_types/date/DateTimeSet';
|
||||||
import { useTypeOption } from '$app/components/database';
|
import { useTypeOption } from '$app/components/database';
|
||||||
import { getDateFormat, getTimeFormat } from '$app/components/database/components/field_types/date/utils';
|
import { getDateFormat, getTimeFormat } from '$app/components/database/components/field_types/date/utils';
|
||||||
|
import { notify } from '$app/components/_shared/notify';
|
||||||
|
|
||||||
function DateTimeCellActions({
|
function DateTimeCellActions({
|
||||||
cell,
|
cell,
|
||||||
field,
|
field,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
...props
|
...props
|
||||||
}: PopoverProps & {
|
}: PopoverProps & {
|
||||||
field: DateTimeField;
|
field: DateTimeField;
|
||||||
cell: DateTimeCell;
|
cell: DateTimeCell;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
}) {
|
}) {
|
||||||
const typeOption = useTypeOption<DateTimeTypeOption>(field.id);
|
const typeOption = useTypeOption<DateTimeTypeOption>(field.id);
|
||||||
|
|
||||||
@ -34,10 +39,10 @@ function DateTimeCellActions({
|
|||||||
|
|
||||||
const { includeTime } = cell.data;
|
const { includeTime } = cell.data;
|
||||||
|
|
||||||
const timestamp = useMemo(() => cell.data.timestamp || dayjs().unix(), [cell.data.timestamp]);
|
const timestamp = useMemo(() => cell.data.timestamp || undefined, [cell.data.timestamp]);
|
||||||
const endTimestamp = useMemo(() => cell.data.endTimestamp || dayjs().unix(), [cell.data.endTimestamp]);
|
const endTimestamp = useMemo(() => cell.data.endTimestamp || undefined, [cell.data.endTimestamp]);
|
||||||
const time = useMemo(() => cell.data.time || dayjs().format(timeFormat), [cell.data.time, timeFormat]);
|
const time = useMemo(() => cell.data.time || undefined, [cell.data.time]);
|
||||||
const endTime = useMemo(() => cell.data.endTime || dayjs().format(timeFormat), [cell.data.endTime, timeFormat]);
|
const endTime = useMemo(() => cell.data.endTime || undefined, [cell.data.endTime]);
|
||||||
|
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -55,7 +60,7 @@ function DateTimeCellActions({
|
|||||||
try {
|
try {
|
||||||
const isRange = params.isRange ?? cell.data.isRange;
|
const isRange = params.isRange ?? cell.data.isRange;
|
||||||
|
|
||||||
await updateDateCell(viewId, cell.rowId, cell.fieldId, {
|
const data = {
|
||||||
date: params.date ?? timestamp,
|
date: params.date ?? timestamp,
|
||||||
endDate: isRange ? params.endDate ?? endTimestamp : undefined,
|
endDate: isRange ? params.endDate ?? endTimestamp : undefined,
|
||||||
time: params.time ?? time,
|
time: params.time ?? time,
|
||||||
@ -63,9 +68,30 @@ function DateTimeCellActions({
|
|||||||
includeTime: params.includeTime ?? includeTime,
|
includeTime: params.includeTime ?? includeTime,
|
||||||
isRange,
|
isRange,
|
||||||
clearFlag: params.clearFlag,
|
clearFlag: params.clearFlag,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// if isRange and date is greater than endDate, swap date and endDate
|
||||||
|
if (
|
||||||
|
data.isRange &&
|
||||||
|
data.date &&
|
||||||
|
data.endDate &&
|
||||||
|
dayjs(dayjs.unix(data.date).format('YYYY/MM/DD ') + data.time).unix() >
|
||||||
|
dayjs(dayjs.unix(data.endDate).format('YYYY/MM/DD ') + data.endTime).unix()
|
||||||
|
) {
|
||||||
|
if (params.date || params.time) {
|
||||||
|
data.endDate = data.date;
|
||||||
|
data.endTime = data.time;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.endDate || params.endTime) {
|
||||||
|
data.date = data.endDate;
|
||||||
|
data.time = data.endTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDateCell(viewId, cell.rowId, cell.fieldId, data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// toast.error(e.message);
|
notify.error(String(e));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cell, endTime, endTimestamp, includeTime, time, timestamp, viewId]
|
[cell, endTime, endTimestamp, includeTime, time, timestamp, viewId]
|
||||||
@ -75,78 +101,95 @@ function DateTimeCellActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
disableRestoreFocus={true}
|
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
anchorOrigin={{
|
disableRestoreFocus={true}
|
||||||
vertical: 'bottom',
|
|
||||||
horizontal: 'left',
|
|
||||||
}}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: 'top',
|
|
||||||
horizontal: 'left',
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
PaperProps={{
|
PaperProps={{
|
||||||
|
...props.PaperProps,
|
||||||
className: 'pt-4 transform transition-all',
|
className: 'pt-4 transform transition-all',
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DateTimeSet
|
<div
|
||||||
date={timestamp}
|
style={{
|
||||||
endTime={endTime}
|
maxWidth: maxWidth,
|
||||||
endDate={endTimestamp}
|
maxHeight: maxHeight,
|
||||||
dateFormat={dateFormat}
|
}}
|
||||||
time={time}
|
>
|
||||||
timeFormat={timeFormat}
|
<DateTimeSet
|
||||||
onChange={handleChange}
|
date={timestamp}
|
||||||
isRange={isRange}
|
endTime={endTime}
|
||||||
includeTime={includeTime}
|
endDate={endTimestamp}
|
||||||
/>
|
dateFormat={dateFormat}
|
||||||
|
time={time}
|
||||||
<CustomCalendar isRange={isRange} timestamp={timestamp} endTimestamp={endTimestamp} handleChange={handleChange} />
|
timeFormat={timeFormat}
|
||||||
|
onChange={handleChange}
|
||||||
<Divider className={'my-0'} />
|
isRange={isRange}
|
||||||
<div className={'flex flex-col gap-1 px-4 py-2'}>
|
includeTime={includeTime}
|
||||||
<RangeSwitch
|
|
||||||
onIsRangeChange={(val) => {
|
|
||||||
void handleChange({
|
|
||||||
isRange: val,
|
|
||||||
// reset endTime when isRange is changed
|
|
||||||
endTime: time,
|
|
||||||
endDate: timestamp,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
checked={isRange}
|
|
||||||
/>
|
/>
|
||||||
<IncludeTimeSwitch
|
|
||||||
onIncludeTimeChange={(val) => {
|
<CustomCalendar
|
||||||
void handleChange({
|
isRange={isRange}
|
||||||
includeTime: val,
|
timestamp={timestamp}
|
||||||
// reset time when includeTime is changed
|
endTimestamp={endTimestamp}
|
||||||
time: val ? dayjs().format(timeFormat) : undefined,
|
handleChange={handleChange}
|
||||||
endTime: val && isRange ? dayjs().format(timeFormat) : undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
checked={includeTime}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Divider className={'my-0'} />
|
||||||
|
<div className={'flex flex-col gap-1 px-4 py-2'}>
|
||||||
|
<RangeSwitch
|
||||||
|
onIsRangeChange={(val) => {
|
||||||
|
void handleChange({
|
||||||
|
isRange: val,
|
||||||
|
// reset endTime when isRange is changed
|
||||||
|
endTime: time,
|
||||||
|
endDate: timestamp,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
checked={isRange}
|
||||||
|
/>
|
||||||
|
<IncludeTimeSwitch
|
||||||
|
disabled={!timestamp}
|
||||||
|
onIncludeTimeChange={(val) => {
|
||||||
|
void handleChange({
|
||||||
|
includeTime: val,
|
||||||
|
// reset time when includeTime is changed
|
||||||
|
time: val ? dayjs().format(timeFormat) : undefined,
|
||||||
|
endTime: val && isRange ? dayjs().format(timeFormat) : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
checked={includeTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider className={'my-0'} />
|
||||||
|
|
||||||
|
<MenuList>
|
||||||
|
<DateTimeFormatSelect field={field} />
|
||||||
|
<MenuItem
|
||||||
|
className={'text-xs font-medium'}
|
||||||
|
onClick={async () => {
|
||||||
|
await handleChange({
|
||||||
|
isRange: false,
|
||||||
|
includeTime: false,
|
||||||
|
});
|
||||||
|
await handleChange({
|
||||||
|
clearFlag: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
props.onClose?.({}, 'backdropClick');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('grid.field.clearDate')}
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider className={'my-0'} />
|
|
||||||
|
|
||||||
<MenuList>
|
|
||||||
<DateTimeFormatSelect field={field} />
|
|
||||||
<MenuItem
|
|
||||||
className={'text-xs font-medium'}
|
|
||||||
onClick={async () => {
|
|
||||||
await handleChange({
|
|
||||||
clearFlag: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
props.onClose?.({}, 'backdropClick');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('grid.field.clearDate')}
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,6 @@ function DateTimeFormatSelect({ field }: Props) {
|
|||||||
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Menu
|
<Menu
|
||||||
disableRestoreFocus={true}
|
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'top',
|
vertical: 'top',
|
||||||
horizontal: 'right',
|
horizontal: 'right',
|
||||||
@ -33,7 +32,15 @@ function DateTimeFormatSelect({ field }: Props) {
|
|||||||
horizontal: 'left',
|
horizontal: 'left',
|
||||||
}}
|
}}
|
||||||
open={open}
|
open={open}
|
||||||
|
autoFocus={true}
|
||||||
anchorEl={ref.current}
|
anchorEl={ref.current}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
MenuListProps={{
|
MenuListProps={{
|
||||||
className: 'px-2',
|
className: 'px-2',
|
||||||
|
@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
|||||||
import { DateField, TimeField } from '@mui/x-date-pickers-pro';
|
import { DateField, TimeField } from '@mui/x-date-pickers-pro';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { Divider } from '@mui/material';
|
import { Divider } from '@mui/material';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onChange: (params: { date?: number; time?: string }) => void;
|
onChange: (params: { date?: number; time?: string }) => void;
|
||||||
@ -23,13 +24,17 @@ const sx = {
|
|||||||
|
|
||||||
function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) {
|
function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) {
|
||||||
const date = useMemo(() => {
|
const date = useMemo(() => {
|
||||||
return dayjs.unix(props.date || dayjs().unix());
|
return props.date ? dayjs.unix(props.date) : undefined;
|
||||||
}, [props.date]);
|
}, [props.date]);
|
||||||
|
|
||||||
const time = useMemo(() => {
|
const time = useMemo(() => {
|
||||||
return dayjs(dayjs().format('YYYY/MM/DD ') + props.time);
|
return props.time ? dayjs(dayjs().format('YYYY/MM/DD ') + props.time) : undefined;
|
||||||
}, [props.time]);
|
}, [props.time]);
|
||||||
|
|
||||||
|
const debounceOnChange = useMemo(() => {
|
||||||
|
return debounce(props.onChange, 500);
|
||||||
|
}, [props.onChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@ -40,7 +45,7 @@ function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props)
|
|||||||
value={date}
|
value={date}
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
if (!date) return;
|
if (!date) return;
|
||||||
props.onChange({
|
debounceOnChange({
|
||||||
date: date.unix(),
|
date: date.unix(),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@ -63,7 +68,7 @@ function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props)
|
|||||||
}}
|
}}
|
||||||
onChange={(time) => {
|
onChange={(time) => {
|
||||||
if (!time) return;
|
if (!time) return;
|
||||||
props.onChange({
|
debounceOnChange({
|
||||||
time: time.format(timeFormat),
|
time: time.format(timeFormat),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import React, { useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { TimeFormatPB } from '@/services/backend';
|
import { TimeFormatPB } from '@/services/backend';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Menu, MenuItem } from '@mui/material';
|
import { Menu, MenuItem } from '@mui/material';
|
||||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: TimeFormatPB;
|
value: TimeFormatPB;
|
||||||
@ -13,14 +16,32 @@ function TimeFormat({ value, onChange }: Props) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLLIElement>(null);
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
const timeFormatMap = useMemo(
|
|
||||||
() => ({
|
const renderOptionContent = useCallback(
|
||||||
[TimeFormatPB.TwelveHour]: t('grid.field.timeFormatTwelveHour'),
|
(option: TimeFormatPB, title: string) => {
|
||||||
[TimeFormatPB.TwentyFourHour]: t('grid.field.timeFormatTwentyFourHour'),
|
return (
|
||||||
}),
|
<div className={'flex w-full items-center justify-between gap-2'}>
|
||||||
[t]
|
<div className={'flex-1'}>{title}</div>
|
||||||
|
{value === option && <SelectCheckSvg />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[value]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const options: KeyboardNavigationOption<TimeFormatPB>[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: TimeFormatPB.TwelveHour,
|
||||||
|
content: renderOptionContent(TimeFormatPB.TwelveHour, t('grid.field.timeFormatTwelveHour')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TimeFormatPB.TwentyFourHour,
|
||||||
|
content: renderOptionContent(TimeFormatPB.TwentyFourHour, t('grid.field.timeFormatTwentyFourHour')),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [renderOptionContent, t]);
|
||||||
|
|
||||||
const handleClick = (option: TimeFormatPB) => {
|
const handleClick = (option: TimeFormatPB) => {
|
||||||
onChange(option);
|
onChange(option);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@ -37,7 +58,6 @@ function TimeFormat({ value, onChange }: Props) {
|
|||||||
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Menu
|
<Menu
|
||||||
disableRestoreFocus={true}
|
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'top',
|
vertical: 'top',
|
||||||
horizontal: 'right',
|
horizontal: 'right',
|
||||||
@ -46,20 +66,14 @@ function TimeFormat({ value, onChange }: Props) {
|
|||||||
anchorEl={ref.current}
|
anchorEl={ref.current}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
{Object.keys(timeFormatMap).map((option) => {
|
<KeyboardNavigation
|
||||||
const optionValue = Number(option) as TimeFormatPB;
|
onEscape={() => {
|
||||||
|
setOpen(false);
|
||||||
return (
|
}}
|
||||||
<MenuItem
|
disableFocus={true}
|
||||||
className={'min-w-[120px] justify-between'}
|
options={options}
|
||||||
key={optionValue}
|
onConfirm={handleClick}
|
||||||
onClick={() => handleClick(optionValue)}
|
/>
|
||||||
>
|
|
||||||
{timeFormatMap[optionValue]}
|
|
||||||
{value === optionValue && <SelectCheckSvg />}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -55,6 +55,7 @@ function EditNumberCellInput({
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
spellCheck={false}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
value={value}
|
value={value}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
|
@ -22,8 +22,8 @@ function NumberFieldActions({ field }: { field: NumberField }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex flex-col pr-3 pt-1'}>
|
<div className={'flex flex-col pt-1'}>
|
||||||
<div className={'mb-2 px-5 text-sm text-text-caption'}>{t('grid.field.format')}</div>
|
<div className={'mb-2 px-4 text-sm text-text-caption'}>{t('grid.field.format')}</div>
|
||||||
<NumberFormatSelect value={typeOption.format || NumberFormatPB.Num} onChange={onChange} />
|
<NumberFormatSelect value={typeOption.format || NumberFormatPB.Num} onChange={onChange} />
|
||||||
</div>
|
</div>
|
||||||
<Divider className={'my-2'} />
|
<Divider className={'my-2'} />
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useMemo, useRef } from 'react';
|
||||||
import { NumberFormatPB } from '@/services/backend';
|
import { NumberFormatPB } from '@/services/backend';
|
||||||
import { Menu, MenuItem, MenuProps } from '@mui/material';
|
import { Menu, MenuProps } from '@mui/material';
|
||||||
import { formats } from '$app/components/database/components/field_types/number/const';
|
import { formats, formatText } from '$app/components/database/components/field_types/number/const';
|
||||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
|
||||||
function NumberFormatMenu({
|
function NumberFormatMenu({
|
||||||
value,
|
value,
|
||||||
@ -12,21 +15,48 @@ function NumberFormatMenu({
|
|||||||
value: NumberFormatPB;
|
value: NumberFormatPB;
|
||||||
onChangeFormat: (value: NumberFormatPB) => void;
|
onChangeFormat: (value: NumberFormatPB) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const onConfirm = useCallback(
|
||||||
|
(format: NumberFormatPB) => {
|
||||||
|
onChangeFormat(format);
|
||||||
|
props.onClose?.({}, 'backdropClick');
|
||||||
|
},
|
||||||
|
[onChangeFormat, props]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = useCallback(
|
||||||
|
(format: NumberFormatPB) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={'flex-1'}>{formatText(format)}</span>
|
||||||
|
{value === format && <SelectCheckSvg />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: KeyboardNavigationOption<NumberFormatPB>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
formats.map((format) => ({
|
||||||
|
key: format.value as NumberFormatPB,
|
||||||
|
content: renderContent(format.value as NumberFormatPB),
|
||||||
|
})),
|
||||||
|
[renderContent]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu {...props} disableRestoreFocus={true}>
|
<Menu {...props}>
|
||||||
{formats.map((format) => (
|
<div ref={scrollRef} className={'max-h-[360px] overflow-y-auto overflow-x-hidden'}>
|
||||||
<MenuItem
|
<KeyboardNavigation
|
||||||
onClick={() => {
|
defaultFocusedKey={value}
|
||||||
onChangeFormat(format.value as NumberFormatPB);
|
scrollRef={scrollRef}
|
||||||
props.onClose?.({}, 'backdropClick');
|
options={options}
|
||||||
}}
|
onConfirm={onConfirm}
|
||||||
className={'flex justify-between text-xs font-medium'}
|
disableFocus={true}
|
||||||
key={format.value}
|
onEscape={() => props.onClose?.({}, 'escapeKeyDown')}
|
||||||
>
|
/>
|
||||||
<div className={'flex-1'}>{format.key}</div>
|
</div>
|
||||||
{value === format.value && <SelectCheckSvg />}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ function NumberFormatSelect({ value, onChange }: { value: NumberFormatPB; onChan
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExpanded(!expanded);
|
setExpanded(!expanded);
|
||||||
}}
|
}}
|
||||||
className={'flex w-full justify-between'}
|
className={'flex w-full justify-between rounded-none'}
|
||||||
>
|
>
|
||||||
<div className='flex-1 text-xs font-medium'>{formatText(value)}</div>
|
<div className='flex-1 text-xs font-medium'>{formatText(value)}</div>
|
||||||
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
|
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { FC, useState } from 'react';
|
import { FC, useMemo, useRef, useState } from 'react';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { Divider, ListSubheader, MenuItem, MenuList, MenuProps, OutlinedInput } from '@mui/material';
|
import { Divider, ListSubheader, MenuItem, MenuList, MenuProps, OutlinedInput } from '@mui/material';
|
||||||
import { SelectOptionColorPB } from '@/services/backend';
|
import { SelectOptionColorPB } from '@/services/backend';
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from '$app/application/database/field/select_option/select_option_service';
|
} from '$app/application/database/field/select_option/select_option_service';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import Popover from '@mui/material/Popover';
|
import Popover from '@mui/material/Popover';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
|
||||||
interface SelectOptionMenuProps {
|
interface SelectOptionMenuProps {
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
@ -32,9 +33,10 @@ const Colors = [
|
|||||||
SelectOptionColorPB.Blue,
|
SelectOptionColorPB.Blue,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, MenuProps: menuProps }) => {
|
export const SelectOptionModifyMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, MenuProps: menuProps }) => {
|
||||||
const [tagName, setTagName] = useState(option.name);
|
const [tagName, setTagName] = useState(option.name);
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const updateColor = async (color: SelectOptionColorPB) => {
|
const updateColor = async (color: SelectOptionColorPB) => {
|
||||||
await insertOrUpdateSelectOption(viewId, fieldId, [
|
await insertOrUpdateSelectOption(viewId, fieldId, [
|
||||||
{
|
{
|
||||||
@ -44,15 +46,18 @@ export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, M
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateName = async () => {
|
const updateName = useMemo(() => {
|
||||||
if (tagName === option.name) return;
|
return debounce(async (tagName) => {
|
||||||
await insertOrUpdateSelectOption(viewId, fieldId, [
|
if (tagName === option.name) return;
|
||||||
{
|
|
||||||
...option,
|
await insertOrUpdateSelectOption(viewId, fieldId, [
|
||||||
name: tagName,
|
{
|
||||||
},
|
...option,
|
||||||
]);
|
name: tagName,
|
||||||
};
|
},
|
||||||
|
]);
|
||||||
|
}, 500);
|
||||||
|
}, [option, viewId, fieldId]);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
menuProps.onClose?.({}, 'backdropClick');
|
menuProps.onClose?.({}, 'backdropClick');
|
||||||
@ -79,18 +84,28 @@ export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, M
|
|||||||
}}
|
}}
|
||||||
{...menuProps}
|
{...menuProps}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
disableRestoreFocus={true}
|
onMouseDown={(e) => {
|
||||||
|
const isInput = inputRef.current?.contains(e.target as Node);
|
||||||
|
|
||||||
|
if (isInput) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListSubheader className='my-2 leading-tight'>
|
<ListSubheader className='my-2 leading-tight'>
|
||||||
<OutlinedInput
|
<OutlinedInput
|
||||||
|
inputRef={inputRef}
|
||||||
value={tagName}
|
value={tagName}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTagName(e.target.value);
|
setTagName(e.target.value);
|
||||||
|
void updateName(e.target.value);
|
||||||
}}
|
}}
|
||||||
onBlur={updateName}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Escape') {
|
||||||
void updateName();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void updateName(tagName);
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
@ -114,7 +129,12 @@ export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, M
|
|||||||
<MenuList className={'max-h-[300px] overflow-y-auto overflow-x-hidden px-2'}>
|
<MenuList className={'max-h-[300px] overflow-y-auto overflow-x-hidden px-2'}>
|
||||||
{Colors.map((color) => (
|
{Colors.map((color) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
void updateColor(color);
|
void updateColor(color);
|
||||||
}}
|
}}
|
||||||
key={color}
|
key={color}
|
@ -9,7 +9,7 @@ export interface CreateOptionProps {
|
|||||||
|
|
||||||
export const CreateOption: FC<CreateOptionProps> = ({ label, onClick }) => {
|
export const CreateOption: FC<CreateOptionProps> = ({ label, onClick }) => {
|
||||||
return (
|
return (
|
||||||
<MenuItem className='mt-2' onClick={onClick}>
|
<MenuItem className='px-2' onClick={onClick}>
|
||||||
<Tag className='ml-2' size='small' label={label} />
|
<Tag className='ml-2' size='small' label={label} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
import React, { FormEvent, useCallback } from 'react';
|
import React, { FormEvent, useCallback } from 'react';
|
||||||
import { ListSubheader, OutlinedInput } from '@mui/material';
|
import { OutlinedInput } from '@mui/material';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
|
|
||||||
function SearchInput({
|
function SearchInput({
|
||||||
setNewOptionName,
|
setNewOptionName,
|
||||||
newOptionName,
|
newOptionName,
|
||||||
onEnter,
|
onEnter,
|
||||||
|
onEscape,
|
||||||
}: {
|
}: {
|
||||||
newOptionName: string;
|
newOptionName: string;
|
||||||
setNewOptionName: (value: string) => void;
|
setNewOptionName: (value: string) => void;
|
||||||
onEnter: () => void;
|
onEnter: () => void;
|
||||||
|
onEscape?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const handleInput = useCallback(
|
const handleInput = useCallback(
|
||||||
(event: FormEvent) => {
|
(event: FormEvent) => {
|
||||||
@ -21,20 +23,26 @@ function SearchInput({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListSubheader className='flex'>
|
<OutlinedInput
|
||||||
<OutlinedInput
|
size='small'
|
||||||
size='small'
|
className={'mx-4'}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
value={newOptionName}
|
value={newOptionName}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onKeyDown={(e) => {
|
spellCheck={false}
|
||||||
if (e.key === 'Enter') {
|
onKeyDown={(e) => {
|
||||||
onEnter();
|
if (e.key === 'Enter') {
|
||||||
}
|
onEnter();
|
||||||
}}
|
}
|
||||||
placeholder={t('grid.selectOption.searchOrCreateOption')}
|
|
||||||
/>
|
if (e.key === 'Escape') {
|
||||||
</ListSubheader>
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onEscape?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('grid.selectOption.searchOrCreateOption')}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,10 +17,12 @@ function SelectCellActions({
|
|||||||
field,
|
field,
|
||||||
cell,
|
cell,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
field: SelectField;
|
field: SelectField;
|
||||||
cell: SelectCellType;
|
cell: SelectCellType;
|
||||||
onUpdated?: () => void;
|
onUpdated?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const rowId = cell?.rowId;
|
const rowId = cell?.rowId;
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
@ -117,29 +119,37 @@ function SelectCellActions({
|
|||||||
}, [field.type, filteredOptions, handleNewTagClick, shouldCreateOption, updateCell]);
|
}, [field.type, filteredOptions, handleNewTagClick, shouldCreateOption, updateCell]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'text-base'}>
|
<div className={'flex h-full flex-col overflow-hidden'}>
|
||||||
<SearchInput setNewOptionName={setNewOptionName} newOptionName={newOptionName} onEnter={handleEnter} />
|
<SearchInput
|
||||||
|
onEscape={onClose}
|
||||||
|
setNewOptionName={setNewOptionName}
|
||||||
|
newOptionName={newOptionName}
|
||||||
|
onEnter={handleEnter}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className='mx-4 mb-2 mt-4 text-xs'>
|
<div className='mx-4 mb-2 mt-4 text-xs'>
|
||||||
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
|
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
|
||||||
</div>
|
</div>
|
||||||
{shouldCreateOption ? (
|
<div className={'mx-1 flex-1 overflow-y-auto overflow-x-hidden'}>
|
||||||
<CreateOption label={newOptionName} onClick={handleNewTagClick} />
|
{shouldCreateOption ? (
|
||||||
) : (
|
<CreateOption label={newOptionName} onClick={handleNewTagClick} />
|
||||||
<div className={'max-h-[300px] overflow-y-auto overflow-x-hidden px-2'}>
|
) : (
|
||||||
{filteredOptions.map((option) => (
|
<div className={' px-2'}>
|
||||||
<MenuItem className={'px-2'} key={option.id} value={option.id}>
|
{filteredOptions.map((option) => (
|
||||||
<SelectOptionItem
|
<MenuItem className={'px-2'} key={option.id} value={option.id}>
|
||||||
onClick={() => {
|
<SelectOptionItem
|
||||||
handleClickOption(option.id);
|
onClick={() => {
|
||||||
}}
|
handleClickOption(option.id);
|
||||||
isSelected={selectedOptionIds?.includes(option.id)}
|
}}
|
||||||
fieldId={cell?.fieldId || ''}
|
isSelected={selectedOptionIds?.includes(option.id)}
|
||||||
option={option}
|
fieldId={cell?.fieldId || ''}
|
||||||
/>
|
option={option}
|
||||||
</MenuItem>
|
/>
|
||||||
))}
|
</MenuItem>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import { FC, MouseEventHandler, useCallback, useRef, useState } from 'react';
|
|||||||
import { IconButton } from '@mui/material';
|
import { IconButton } from '@mui/material';
|
||||||
import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
|
import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
|
||||||
import { SelectOption } from '$app/application/database';
|
import { SelectOption } from '$app/application/database';
|
||||||
import { SelectOptionMenu } from '../SelectOptionMenu';
|
import { SelectOptionModifyMenu } from '../SelectOptionModifyMenu';
|
||||||
import { Tag } from '../Tag';
|
import { Tag } from '../Tag';
|
||||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ export const SelectOptionItem: FC<SelectOptionItemProps> = ({ onClick, isSelecte
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<SelectOptionMenu
|
<SelectOptionModifyMenu
|
||||||
fieldId={fieldId}
|
fieldId={fieldId}
|
||||||
option={option}
|
option={option}
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
||||||
@ -8,8 +8,10 @@ import {
|
|||||||
insertOrUpdateSelectOption,
|
insertOrUpdateSelectOption,
|
||||||
} from '$app/application/database/field/select_option/select_option_service';
|
} from '$app/application/database/field/select_option/select_option_service';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
|
import { SelectOption } from '$app/application/database';
|
||||||
|
import { notify } from '$app/components/_shared/notify';
|
||||||
|
|
||||||
function AddAnOption({ fieldId }: { fieldId: string }) {
|
function AddAnOption({ fieldId, options }: { fieldId: string; options: SelectOption[] }) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [edit, setEdit] = useState(false);
|
const [edit, setEdit] = useState(false);
|
||||||
@ -19,7 +21,17 @@ function AddAnOption({ fieldId }: { fieldId: string }) {
|
|||||||
setEdit(false);
|
setEdit(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOptionExist = useMemo(() => {
|
||||||
|
return options.some((option) => option.name === newOptionName);
|
||||||
|
}, [options, newOptionName]);
|
||||||
|
|
||||||
const createOption = async () => {
|
const createOption = async () => {
|
||||||
|
if (!newOptionName) return;
|
||||||
|
if (isOptionExist) {
|
||||||
|
notify.error(t('grid.field.optionAlreadyExist'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const option = await createSelectOption(viewId, fieldId, newOptionName);
|
const option = await createSelectOption(viewId, fieldId, newOptionName);
|
||||||
|
|
||||||
if (!option) return;
|
if (!option) return;
|
||||||
@ -31,13 +43,23 @@ function AddAnOption({ fieldId }: { fieldId: string }) {
|
|||||||
<OutlinedInput
|
<OutlinedInput
|
||||||
onBlur={exitEdit}
|
onBlur={exitEdit}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
|
spellCheck={false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setNewOptionName(e.target.value);
|
setNewOptionName(e.target.value);
|
||||||
}}
|
}}
|
||||||
value={newOptionName}
|
value={newOptionName}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
void createOption();
|
void createOption();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
exitEdit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={'mx-2 mb-1'}
|
className={'mx-2 mb-1'}
|
||||||
|
@ -3,7 +3,7 @@ import { ReactComponent as MoreIcon } from '$app/assets/more.svg';
|
|||||||
import { SelectOption } from '$app/application/database';
|
import { SelectOption } from '$app/application/database';
|
||||||
// import { ReactComponent as DragIcon } from '$app/assets/drag.svg';
|
// import { ReactComponent as DragIcon } from '$app/assets/drag.svg';
|
||||||
|
|
||||||
import { SelectOptionMenu } from '$app/components/database/components/field_types/select/SelectOptionMenu';
|
import { SelectOptionModifyMenu } from '$app/components/database/components/field_types/select/SelectOptionModifyMenu';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import { SelectOptionColorMap } from '$app/components/database/components/field_types/select/constants';
|
import { SelectOptionColorMap } from '$app/components/database/components/field_types/select/constants';
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ function Option({ option, fieldId }: { option: SelectOption; fieldId: string })
|
|||||||
<div className={`${SelectOptionColorMap[option.color]} rounded-lg px-1.5 py-1`}>{option.name}</div>
|
<div className={`${SelectOptionColorMap[option.color]} rounded-lg px-1.5 py-1`}>{option.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
<SelectOptionMenu
|
<SelectOptionModifyMenu
|
||||||
fieldId={fieldId}
|
fieldId={fieldId}
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
anchorEl: ref.current,
|
anchorEl: ref.current,
|
||||||
|
@ -8,7 +8,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
function Options({ options, fieldId }: Props) {
|
function Options({ options, fieldId }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={'max-h-[300px] overflow-y-auto overflow-x-hidden'}>
|
<div>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
return <Option fieldId={fieldId} key={option.id} option={option} />;
|
return <Option fieldId={fieldId} key={option.id} option={option} />;
|
||||||
})}
|
})}
|
||||||
|
@ -15,7 +15,7 @@ function SelectFieldActions({ field }: { field: SelectField }) {
|
|||||||
<>
|
<>
|
||||||
<div className={'flex flex-col px-3 pt-1'}>
|
<div className={'flex flex-col px-3 pt-1'}>
|
||||||
<div className={'mb-2 px-2 text-sm text-text-caption'}>{t('grid.field.optionTitle')}</div>
|
<div className={'mb-2 px-2 text-sm text-text-caption'}>{t('grid.field.optionTitle')}</div>
|
||||||
<AddAnOption fieldId={field.id} />
|
<AddAnOption options={options} fieldId={field.id} />
|
||||||
<Options fieldId={field.id} options={options} />
|
<Options fieldId={field.id} options={options} />
|
||||||
</div>
|
</div>
|
||||||
<Divider className={'my-2'} />
|
<Divider className={'my-2'} />
|
||||||
|
@ -22,9 +22,9 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
disableRestoreFocus={true}
|
|
||||||
open={editing}
|
open={editing}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
|
disableRestoreFocus={true}
|
||||||
PaperProps={{
|
PaperProps={{
|
||||||
className: 'flex p-2 border border-blue-400',
|
className: 'flex p-2 border border-blue-400',
|
||||||
style: { width: anchorEl?.offsetWidth, minHeight: anchorEl?.offsetHeight, borderRadius: 0, boxShadow: 'none' },
|
style: { width: anchorEl?.offsetWidth, minHeight: anchorEl?.offsetHeight, borderRadius: 0, boxShadow: 'none' },
|
||||||
@ -36,10 +36,18 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
|
|||||||
transitionDuration={0}
|
transitionDuration={0}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
className='w-full resize-none whitespace-break-spaces break-all text-sm'
|
className='w-full resize-none whitespace-break-spaces break-all text-sm'
|
||||||
autoFocus
|
autoFocus
|
||||||
|
spellCheck={false}
|
||||||
autoCorrect='off'
|
autoCorrect='off'
|
||||||
value={text}
|
value={text}
|
||||||
onInput={onInput}
|
onInput={onInput}
|
||||||
|
@ -1,39 +1,77 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import Select from '@mui/material/Select';
|
import KeyboardNavigation, {
|
||||||
import { FormControl, MenuItem, SelectProps } from '@mui/material';
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
|
||||||
function ConditionSelect({
|
function ConditionSelect({
|
||||||
conditions,
|
conditions,
|
||||||
...props
|
value,
|
||||||
}: SelectProps & {
|
onChange,
|
||||||
|
}: {
|
||||||
conditions: {
|
conditions: {
|
||||||
value: number;
|
value: number;
|
||||||
text: string;
|
text: string;
|
||||||
}[];
|
}[];
|
||||||
|
value: number;
|
||||||
|
onChange: (condition: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const options: KeyboardNavigationOption<number>[] = useMemo(() => {
|
||||||
|
return conditions.map((condition) => {
|
||||||
|
return {
|
||||||
|
key: condition.value,
|
||||||
|
content: condition.text,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [conditions]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onConfirm = useCallback(
|
||||||
|
(key: number) => {
|
||||||
|
onChange(key);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const valueText = useMemo(() => {
|
||||||
|
return conditions.find((condition) => condition.value === value)?.text;
|
||||||
|
}, [conditions, value]);
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl size={'small'} variant={'outlined'}>
|
<div>
|
||||||
<Select
|
<div
|
||||||
{...props}
|
onClick={(e) => {
|
||||||
sx={{
|
setAnchorEl(e.currentTarget);
|
||||||
'& .MuiSelect-select': {
|
|
||||||
padding: 0,
|
|
||||||
fontSize: '12px',
|
|
||||||
},
|
|
||||||
'& .MuiOutlinedInput-notchedOutline': {
|
|
||||||
borderColor: 'transparent !important',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
|
className={'flex cursor-pointer select-none items-center gap-2 py-2 text-xs'}
|
||||||
>
|
>
|
||||||
{conditions.map((condition) => {
|
<div className={'flex-1'}>{valueText}</div>
|
||||||
return (
|
<MoreSvg className={`h-4 w-4 transform ${open ? 'rotate-90' : ''}`} />
|
||||||
<MenuItem key={condition.value} value={condition.value}>
|
</div>
|
||||||
{condition.text}
|
<Popover
|
||||||
</MenuItem>
|
open={open}
|
||||||
);
|
transformOrigin={{
|
||||||
})}
|
vertical: 'top',
|
||||||
</Select>
|
horizontal: 'center',
|
||||||
</FormControl>
|
}}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
keepMounted={false}
|
||||||
|
>
|
||||||
|
<KeyboardNavigation defaultFocusedKey={value} options={options} onConfirm={onConfirm} onEscape={handleClose} />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@ interface FilterComponentProps {
|
|||||||
filter: FilterType;
|
filter: FilterType;
|
||||||
field: FieldData;
|
field: FieldData;
|
||||||
onChange: (data: UndeterminedFilter['data']) => void;
|
onChange: (data: UndeterminedFilter['data']) => void;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterComponent = FC<FilterComponentProps>;
|
type FilterComponent = FC<FilterComponentProps>;
|
||||||
@ -110,16 +111,15 @@ function Filter({ filter, field }: Props) {
|
|||||||
clickable
|
clickable
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
label={
|
label={
|
||||||
<div className={'flex items-center justify-center'}>
|
<div className={'flex items-center justify-center gap-1'}>
|
||||||
<Property field={field} />
|
<Property field={field} />
|
||||||
<DropDownSvg className={'ml-1.5 h-8 w-8'} />
|
<DropDownSvg className={'h-6 w-6'} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
{condition !== undefined && open && (
|
{condition !== undefined && open && (
|
||||||
<Popover
|
<Popover
|
||||||
disableRestoreFocus={true}
|
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'bottom',
|
vertical: 'bottom',
|
||||||
horizontal: 'center',
|
horizontal: 'center',
|
||||||
@ -132,6 +132,13 @@ function Filter({ filter, field }: Props) {
|
|||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className={'flex items-center justify-between'}>
|
<div className={'flex items-center justify-between'}>
|
||||||
<FilterConditionSelect
|
<FilterConditionSelect
|
||||||
@ -146,7 +153,7 @@ function Filter({ filter, field }: Props) {
|
|||||||
/>
|
/>
|
||||||
<FilterActions filter={filter} />
|
<FilterActions filter={filter} />
|
||||||
</div>
|
</div>
|
||||||
{Component && <Component filter={filter} field={field} onChange={onDataChange} />}
|
{Component && <Component onClose={handleClose} filter={filter} field={field} onChange={onDataChange} />}
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -1,17 +1,22 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { IconButton, Menu, MenuItem } from '@mui/material';
|
import { IconButton, Menu } from '@mui/material';
|
||||||
import { ReactComponent as MoreSvg } from '$app/assets/details.svg';
|
import { ReactComponent as MoreSvg } from '$app/assets/details.svg';
|
||||||
import { Filter } from '$app/application/database';
|
import { Filter } from '$app/application/database';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { deleteFilter } from '$app/application/database/filter/filter_service';
|
import { deleteFilter } from '$app/application/database/filter/filter_service';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
|
||||||
function FilterActions({ filter }: { filter: Filter }) {
|
function FilterActions({ filter }: { filter: Filter }) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [disableSelect, setDisableSelect] = useState(true);
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
|
setDisableSelect(true);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -21,8 +26,20 @@ function FilterActions({ filter }: { filter: Filter }) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// toast.error(e.message);
|
// toast.error(e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDisableSelect(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const options: KeyboardNavigationOption[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
content: t('grid.settings.deleteFilter'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
@ -33,9 +50,33 @@ function FilterActions({ filter }: { filter: Filter }) {
|
|||||||
>
|
>
|
||||||
<MoreSvg />
|
<MoreSvg />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Menu disableRestoreFocus={true} keepMounted={false} open={open} anchorEl={anchorEl} onClose={onClose}>
|
{open && (
|
||||||
<MenuItem onClick={onDelete}>{t('grid.settings.deleteFilter')}</MenuItem>
|
<Menu
|
||||||
</Menu>
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
keepMounted={false}
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<KeyboardNavigation
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
setDisableSelect(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disableSelect={disableSelect}
|
||||||
|
options={options}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
onEscape={onClose}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -183,14 +183,12 @@ function FilterConditionSelect({
|
|||||||
}, [fieldType, t]);
|
}, [fieldType, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex justify-between gap-[20px] px-4'}>
|
<div className={'flex items-center justify-between gap-[20px] px-4'}>
|
||||||
<div className={'flex-1 text-sm text-text-caption'}>{name}</div>
|
<div className={'flex-1 text-sm text-text-caption'}>{name}</div>
|
||||||
<ConditionSelect
|
<ConditionSelect
|
||||||
conditions={conditions}
|
conditions={conditions}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = Number(e.target.value);
|
onChange(e);
|
||||||
|
|
||||||
onChange(value);
|
|
||||||
}}
|
}}
|
||||||
value={condition}
|
value={condition}
|
||||||
/>
|
/>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { MouseEvent, useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { MenuProps } from '@mui/material';
|
import { MenuProps } from '@mui/material';
|
||||||
import PropertiesList from '$app/components/database/components/property/PropertiesList';
|
import PropertiesList from '$app/components/database/components/property/PropertiesList';
|
||||||
import { Field } from '$app/application/database';
|
import { Field } from '$app/application/database';
|
||||||
@ -18,7 +18,7 @@ function FilterFieldsMenu({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const addFilter = useCallback(
|
const addFilter = useCallback(
|
||||||
async (event: MouseEvent, field: Field) => {
|
async (field: Field) => {
|
||||||
const filterData = getDefaultFilter(field.type);
|
const filterData = getDefaultFilter(field.type);
|
||||||
|
|
||||||
await insertFilter({
|
await insertFilter({
|
||||||
@ -34,8 +34,24 @@ function FilterFieldsMenu({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover disableRestoreFocus={true} {...props}>
|
<Popover
|
||||||
<PropertiesList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PropertiesList
|
||||||
|
onClose={() => {
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
|
}}
|
||||||
|
showSearch
|
||||||
|
searchPlaceholder={t('grid.settings.filterBy')}
|
||||||
|
onItemClick={addFilter}
|
||||||
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -29,9 +29,9 @@ function Filters() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex items-center justify-center gap-[10px]'}>
|
<div className={'flex items-center justify-center gap-2 text-text-title'}>
|
||||||
{options.map(({ filter, field }) => (field ? <Filter key={filter.id} filter={filter} field={field} /> : null))}
|
{options.map(({ filter, field }) => (field ? <Filter key={filter.id} filter={filter} field={field} /> : null))}
|
||||||
<Button onClick={handleClick} color={'inherit'} startIcon={<AddSvg />}>
|
<Button size={'small'} onClick={handleClick} color={'inherit'} startIcon={<AddSvg />}>
|
||||||
{t('grid.settings.addFilter')}
|
{t('grid.settings.addFilter')}
|
||||||
</Button>
|
</Button>
|
||||||
<FilterFieldsMenu
|
<FilterFieldsMenu
|
||||||
|
@ -1,26 +1,44 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
SelectField,
|
SelectField,
|
||||||
SelectFilter as SelectFilterType,
|
SelectFilter as SelectFilterType,
|
||||||
SelectFilterData,
|
SelectFilterData,
|
||||||
SelectTypeOption,
|
SelectTypeOption,
|
||||||
} from '$app/application/database';
|
} from '$app/application/database';
|
||||||
import { MenuItem, MenuList } from '@mui/material';
|
|
||||||
import { Tag } from '$app/components/database/components/field_types/select/Tag';
|
import { Tag } from '$app/components/database/components/field_types/select/Tag';
|
||||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||||
import { SelectOptionConditionPB } from '@/services/backend';
|
import { SelectOptionConditionPB } from '@/services/backend';
|
||||||
import { useTypeOption } from '$app/components/database';
|
import { useTypeOption } from '$app/components/database';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter: SelectFilterType;
|
filter: SelectFilterType;
|
||||||
field: SelectField;
|
field: SelectField;
|
||||||
onChange: (filterData: SelectFilterData) => void;
|
onChange: (filterData: SelectFilterData) => void;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectFilter({ filter, field, onChange }: Props) {
|
function SelectFilter({ onClose, filter, field, onChange }: Props) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const condition = filter.data.condition;
|
const condition = filter.data.condition;
|
||||||
const typeOption = useTypeOption<SelectTypeOption>(field.id);
|
const typeOption = useTypeOption<SelectTypeOption>(field.id);
|
||||||
const options = useMemo(() => typeOption.options ?? [], [typeOption]);
|
const options: KeyboardNavigationOption[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
typeOption?.options?.map((option) => {
|
||||||
|
return {
|
||||||
|
key: option.id,
|
||||||
|
content: (
|
||||||
|
<div className={'flex w-full items-center justify-between px-2'}>
|
||||||
|
<Tag size='small' color={option.color} label={option.name} />
|
||||||
|
{filter.data.optionIds?.includes(option.id) && <SelectCheckSvg />}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}, [filter.data.optionIds, typeOption?.options]);
|
||||||
|
|
||||||
const showOptions =
|
const showOptions =
|
||||||
options.length > 0 &&
|
options.length > 0 &&
|
||||||
@ -65,22 +83,9 @@ function SelectFilter({ filter, field, onChange }: Props) {
|
|||||||
if (!showOptions) return null;
|
if (!showOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuList>
|
<div ref={scrollRef}>
|
||||||
{options?.map((option) => {
|
<KeyboardNavigation onEscape={onClose} scrollRef={scrollRef} options={options} onConfirm={handleSelectOption} />
|
||||||
const isSelected = filter.data.optionIds?.includes(option.id);
|
</div>
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
className={'flex items-center justify-between px-2'}
|
|
||||||
onClick={() => handleSelectOption(option.id)}
|
|
||||||
key={option.id}
|
|
||||||
>
|
|
||||||
<Tag size='small' color={option.color} label={option.name} />
|
|
||||||
{isSelected && <SelectCheckSvg />}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MenuList>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { TextFilter as TextFilterType, TextFilterData } from '$app/application/database';
|
import { TextFilter as TextFilterType, TextFilterData } from '$app/application/database';
|
||||||
import { TextField } from '@mui/material';
|
import { TextField } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TextFilterConditionPB } from '@/services/backend';
|
import { TextFilterConditionPB } from '@/services/backend';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter: TextFilterType;
|
filter: TextFilterType;
|
||||||
onChange: (filterData: TextFilterData) => void;
|
onChange: (filterData: TextFilterData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DELAY = 500;
|
||||||
|
|
||||||
function TextFilter({ filter, onChange }: Props) {
|
function TextFilter({ filter, onChange }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [content, setContext] = useState(filter.data.content);
|
const [content, setContext] = useState(filter.data.content);
|
||||||
@ -15,21 +19,29 @@ function TextFilter({ filter, onChange }: Props) {
|
|||||||
const showField =
|
const showField =
|
||||||
condition !== TextFilterConditionPB.TextIsEmpty && condition !== TextFilterConditionPB.TextIsNotEmpty;
|
condition !== TextFilterConditionPB.TextIsEmpty && condition !== TextFilterConditionPB.TextIsNotEmpty;
|
||||||
|
|
||||||
|
const onConditionChange = useMemo(() => {
|
||||||
|
return debounce((content: string) => {
|
||||||
|
onChange({
|
||||||
|
content,
|
||||||
|
condition,
|
||||||
|
});
|
||||||
|
}, DELAY);
|
||||||
|
}, [condition, onChange]);
|
||||||
|
|
||||||
if (!showField) return null;
|
if (!showField) return null;
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
className={'p-2'}
|
spellCheck={false}
|
||||||
|
className={'p-2 pt-0'}
|
||||||
|
inputProps={{
|
||||||
|
className: 'text-xs p-1.5',
|
||||||
|
}}
|
||||||
size={'small'}
|
size={'small'}
|
||||||
value={content}
|
value={content}
|
||||||
placeholder={t('grid.settings.typeAValue')}
|
placeholder={t('grid.settings.typeAValue')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setContext(e.target.value);
|
setContext(e.target.value);
|
||||||
}}
|
onConditionChange(e.target.value ?? '');
|
||||||
onBlur={() => {
|
|
||||||
onChange({
|
|
||||||
content,
|
|
||||||
condition,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -27,7 +27,12 @@ function NewProperty({ onInserted }: NewPropertyProps) {
|
|||||||
}, [onInserted, viewId]);
|
}, [onInserted, viewId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={handleClick} className={'h-full w-full justify-start'} startIcon={<AddSvg />} color={'inherit'}>
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={'h-full w-full justify-start rounded-none'}
|
||||||
|
startIcon={<AddSvg />}
|
||||||
|
color={'inherit'}
|
||||||
|
>
|
||||||
{t('grid.field.newProperty')}
|
{t('grid.field.newProperty')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { OutlinedInput, MenuItem, MenuList } from '@mui/material';
|
import { OutlinedInput } from '@mui/material';
|
||||||
import { Property } from '$app/components/database/components/property/Property';
|
import { Property } from '$app/components/database/components/property/Property';
|
||||||
import { Field as FieldType } from '$app/application/database';
|
import { Field as FieldType } from '$app/application/database';
|
||||||
import { useDatabase } from '$app/components/database';
|
import { useDatabase } from '$app/components/database';
|
||||||
|
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
|
||||||
interface FieldListProps {
|
interface FieldListProps {
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
onItemClick?: (event: React.MouseEvent<HTMLLIElement>, field: FieldType) => void;
|
onItemClick?: (field: FieldType) => void;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) {
|
function PropertiesList({ onClose, showSearch, onItemClick, searchPlaceholder }: FieldListProps) {
|
||||||
const { fields } = useDatabase();
|
const { fields } = useDatabase();
|
||||||
const [fieldsResult, setFieldsResult] = useState<FieldType[]>(fields as FieldType[]);
|
const [fieldsResult, setFieldsResult] = useState<FieldType[]>(fields as FieldType[]);
|
||||||
|
|
||||||
@ -24,38 +26,65 @@ function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldLis
|
|||||||
[fields]
|
[fields]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const searchInput = useMemo(() => {
|
const searchInput = useMemo(() => {
|
||||||
return showSearch ? (
|
return showSearch ? (
|
||||||
<div className={'w-[220px] px-4 pt-2'}>
|
<div className={'w-[220px] px-4 pt-2'}>
|
||||||
<OutlinedInput size={'small'} autoFocus={true} placeholder={searchPlaceholder} onChange={onInputChange} />
|
<OutlinedInput
|
||||||
|
inputRef={inputRef}
|
||||||
|
size={'small'}
|
||||||
|
autoFocus={true}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete={'off'}
|
||||||
|
autoCorrect={'off'}
|
||||||
|
inputProps={{
|
||||||
|
className: 'text-xs p-1.5',
|
||||||
|
}}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}, [onInputChange, searchPlaceholder, showSearch]);
|
}, [onInputChange, searchPlaceholder, showSearch]);
|
||||||
|
|
||||||
const emptyList = useMemo(() => {
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
return fieldsResult.length === 0 ? (
|
|
||||||
<div className={'px-4 pt-3 text-center text-sm font-medium text-gray-500'}>No fields found</div>
|
const options = useMemo(() => {
|
||||||
) : null;
|
return fieldsResult.map((field) => {
|
||||||
|
return {
|
||||||
|
key: field.id,
|
||||||
|
content: (
|
||||||
|
<div className={'truncate'}>
|
||||||
|
<Property field={field} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
}, [fieldsResult]);
|
}, [fieldsResult]);
|
||||||
|
|
||||||
|
const onConfirm = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const field = fields.find((field) => field.id === key);
|
||||||
|
|
||||||
|
onItemClick?.(field as FieldType);
|
||||||
|
},
|
||||||
|
[fields, onItemClick]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'pt-2'}>
|
<div className={'pt-2'}>
|
||||||
{searchInput}
|
{searchInput}
|
||||||
{emptyList}
|
<div ref={scrollRef} className={'my-2 max-h-[300px] overflow-y-auto overflow-x-hidden'}>
|
||||||
<MenuList className={'max-h-[300px] overflow-y-auto overflow-x-hidden px-2'}>
|
<KeyboardNavigation
|
||||||
{fieldsResult.map((field) => (
|
disableFocus={true}
|
||||||
<MenuItem
|
scrollRef={scrollRef}
|
||||||
className={'overflow-hidden text-ellipsis px-1'}
|
focusRef={inputRef}
|
||||||
key={field.id}
|
options={options}
|
||||||
value={field.id}
|
onConfirm={onConfirm}
|
||||||
onClick={(event) => {
|
onEscape={onClose}
|
||||||
onItemClick?.(event, field);
|
/>
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
<Property field={field} />
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</MenuList>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@ import { FC, useEffect, useRef, useState } from 'react';
|
|||||||
import { Field as FieldType } from '$app/application/database';
|
import { Field as FieldType } from '$app/application/database';
|
||||||
import { ProppertyTypeSvg } from './property_type/ProppertyTypeSvg';
|
import { ProppertyTypeSvg } from './property_type/ProppertyTypeSvg';
|
||||||
import { PropertyMenu } from '$app/components/database/components/property/PropertyMenu';
|
import { PropertyMenu } from '$app/components/database/components/property/PropertyMenu';
|
||||||
|
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||||
|
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||||
|
|
||||||
export interface FieldProps {
|
export interface FieldProps {
|
||||||
field: FieldType;
|
field: FieldType;
|
||||||
@ -10,17 +12,28 @@ export interface FieldProps {
|
|||||||
onCloseMenu?: (id: string) => void;
|
onCloseMenu?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initialAnchorOrigin: PopoverOrigin = {
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialTransformOrigin: PopoverOrigin = {
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) => {
|
export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const [anchorPosition, setAnchorPosition] = useState<
|
const [anchorPosition, setAnchorPosition] = useState<
|
||||||
| {
|
| {
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
|
height: number;
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
const open = Boolean(anchorPosition) && menuOpened;
|
const open = Boolean(anchorPosition && menuOpened);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (menuOpened) {
|
if (menuOpened) {
|
||||||
@ -28,8 +41,9 @@ export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) =>
|
|||||||
|
|
||||||
if (rect) {
|
if (rect) {
|
||||||
setAnchorPosition({
|
setAnchorPosition({
|
||||||
top: rect.top + rect.height,
|
top: rect.top + 28,
|
||||||
left: rect.left,
|
left: rect.left,
|
||||||
|
height: rect.height,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -38,6 +52,15 @@ export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) =>
|
|||||||
setAnchorPosition(undefined);
|
setAnchorPosition(undefined);
|
||||||
}, [menuOpened]);
|
}, [menuOpened]);
|
||||||
|
|
||||||
|
const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({
|
||||||
|
initialPaperWidth: 369,
|
||||||
|
initialPaperHeight: 400,
|
||||||
|
anchorPosition,
|
||||||
|
initialAnchorOrigin,
|
||||||
|
initialTransformOrigin,
|
||||||
|
open,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={ref} className='flex w-full items-center px-2'>
|
<div ref={ref} className='flex w-full items-center px-2'>
|
||||||
@ -48,10 +71,20 @@ export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) =>
|
|||||||
{open && (
|
{open && (
|
||||||
<PropertyMenu
|
<PropertyMenu
|
||||||
field={field}
|
field={field}
|
||||||
open={open}
|
open={open && isEntered}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
onCloseMenu?.(field.id);
|
onCloseMenu?.(field.id);
|
||||||
}}
|
}}
|
||||||
|
transformOrigin={transformOrigin}
|
||||||
|
anchorOrigin={anchorOrigin}
|
||||||
|
PaperProps={{
|
||||||
|
style: {
|
||||||
|
maxHeight: paperHeight,
|
||||||
|
maxWidth: paperWidth,
|
||||||
|
height: 'auto',
|
||||||
|
},
|
||||||
|
className: 'flex h-full flex-col overflow-hidden',
|
||||||
|
}}
|
||||||
anchorPosition={anchorPosition}
|
anchorPosition={anchorPosition}
|
||||||
anchorReference={'anchorPosition'}
|
anchorReference={'anchorPosition'}
|
||||||
/>
|
/>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { RefObject, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
|
import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
|
||||||
import { ReactComponent as HideSvg } from '$app/assets/hide.svg';
|
import { ReactComponent as HideSvg } from '$app/assets/hide.svg';
|
||||||
|
import { ReactComponent as ShowSvg } from '$app/assets/eye_open.svg';
|
||||||
|
|
||||||
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
|
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
|
||||||
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
||||||
import { ReactComponent as LeftSvg } from '$app/assets/left.svg';
|
import { ReactComponent as LeftSvg } from '$app/assets/left.svg';
|
||||||
@ -9,13 +11,17 @@ import { ReactComponent as RightSvg } from '$app/assets/right.svg';
|
|||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { fieldService } from '$app/application/database';
|
import { fieldService } from '$app/application/database';
|
||||||
import { OrderObjectPositionTypePB, FieldVisibility } from '@/services/backend';
|
import { OrderObjectPositionTypePB, FieldVisibility } from '@/services/backend';
|
||||||
import { MenuItem } from '@mui/material';
|
|
||||||
import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog';
|
import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
import { notify } from 'src/appflowy_app/components/_shared/notify';
|
||||||
|
|
||||||
export enum FieldAction {
|
export enum FieldAction {
|
||||||
EditProperty,
|
EditProperty,
|
||||||
Hide,
|
Hide,
|
||||||
|
Show,
|
||||||
Duplicate,
|
Duplicate,
|
||||||
Delete,
|
Delete,
|
||||||
InsertLeft,
|
InsertLeft,
|
||||||
@ -25,6 +31,7 @@ export enum FieldAction {
|
|||||||
const FieldActionSvgMap = {
|
const FieldActionSvgMap = {
|
||||||
[FieldAction.EditProperty]: EditSvg,
|
[FieldAction.EditProperty]: EditSvg,
|
||||||
[FieldAction.Hide]: HideSvg,
|
[FieldAction.Hide]: HideSvg,
|
||||||
|
[FieldAction.Show]: ShowSvg,
|
||||||
[FieldAction.Duplicate]: CopySvg,
|
[FieldAction.Duplicate]: CopySvg,
|
||||||
[FieldAction.Delete]: DeleteSvg,
|
[FieldAction.Delete]: DeleteSvg,
|
||||||
[FieldAction.InsertLeft]: LeftSvg,
|
[FieldAction.InsertLeft]: LeftSvg,
|
||||||
@ -47,18 +54,28 @@ interface PropertyActionsProps {
|
|||||||
fieldId: string;
|
fieldId: string;
|
||||||
actions?: FieldAction[];
|
actions?: FieldAction[];
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
|
inputRef?: RefObject<HTMLElement>;
|
||||||
|
onClose?: () => void;
|
||||||
onMenuItemClick?: (action: FieldAction, newFieldId?: string) => void;
|
onMenuItemClick?: (action: FieldAction, newFieldId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaultActions }: PropertyActionsProps) {
|
function PropertyActions({
|
||||||
|
onClose,
|
||||||
|
inputRef,
|
||||||
|
fieldId,
|
||||||
|
onMenuItemClick,
|
||||||
|
isPrimary,
|
||||||
|
actions = defaultActions,
|
||||||
|
}: PropertyActionsProps) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [openConfirm, setOpenConfirm] = useState(false);
|
const [openConfirm, setOpenConfirm] = useState(false);
|
||||||
|
const [focusMenu, setFocusMenu] = useState<boolean>(false);
|
||||||
const menuTextMap = useMemo(
|
const menuTextMap = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
[FieldAction.EditProperty]: t('grid.field.editProperty'),
|
[FieldAction.EditProperty]: t('grid.field.editProperty'),
|
||||||
[FieldAction.Hide]: t('grid.field.hide'),
|
[FieldAction.Hide]: t('grid.field.hide'),
|
||||||
|
[FieldAction.Show]: t('grid.field.show'),
|
||||||
[FieldAction.Duplicate]: t('grid.field.duplicate'),
|
[FieldAction.Duplicate]: t('grid.field.duplicate'),
|
||||||
[FieldAction.Delete]: t('grid.field.delete'),
|
[FieldAction.Delete]: t('grid.field.delete'),
|
||||||
[FieldAction.InsertLeft]: t('grid.field.insertLeft'),
|
[FieldAction.InsertLeft]: t('grid.field.insertLeft'),
|
||||||
@ -101,6 +118,11 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul
|
|||||||
visibility: FieldVisibility.AlwaysHidden,
|
visibility: FieldVisibility.AlwaysHidden,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case FieldAction.Show:
|
||||||
|
await fieldService.updateFieldSetting(viewId, fieldId, {
|
||||||
|
visibility: FieldVisibility.AlwaysShown,
|
||||||
|
});
|
||||||
|
break;
|
||||||
case FieldAction.Duplicate:
|
case FieldAction.Duplicate:
|
||||||
await fieldService.duplicateField(viewId, fieldId);
|
await fieldService.duplicateField(viewId, fieldId);
|
||||||
break;
|
break;
|
||||||
@ -112,19 +134,124 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul
|
|||||||
onMenuItemClick?.(action);
|
onMenuItemClick?.(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderActionContent = useCallback((item: { text: string; Icon: React.FC<React.SVGProps<SVGSVGElement>> }) => {
|
||||||
|
const { Icon, text } = item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full items-center gap-2 px-1'>
|
||||||
|
<Icon className={'h-4 w-4'} />
|
||||||
|
<div className={'flex-1'}>{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const options: KeyboardNavigationOption<FieldAction>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: FieldAction.EditProperty,
|
||||||
|
content: renderActionContent({
|
||||||
|
text: menuTextMap[FieldAction.EditProperty],
|
||||||
|
Icon: FieldActionSvgMap[FieldAction.EditProperty],
|
||||||
|
}),
|
||||||
|
disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.EditProperty),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldAction.InsertLeft,
|
||||||
|
content: renderActionContent({
|
||||||
|
text: menuTextMap[FieldAction.InsertLeft],
|
||||||
|
Icon: FieldActionSvgMap[FieldAction.InsertLeft],
|
||||||
|
}),
|
||||||
|
disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.InsertLeft),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldAction.InsertRight,
|
||||||
|
content: renderActionContent({
|
||||||
|
text: menuTextMap[FieldAction.InsertRight],
|
||||||
|
Icon: FieldActionSvgMap[FieldAction.InsertRight],
|
||||||
|
}),
|
||||||
|
disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.InsertRight),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldAction.Hide,
|
||||||
|
content: renderActionContent({
|
||||||
|
text: menuTextMap[FieldAction.Hide],
|
||||||
|
Icon: FieldActionSvgMap[FieldAction.Hide],
|
||||||
|
}),
|
||||||
|
disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Hide),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldAction.Show,
|
||||||
|
content: renderActionContent({
|
||||||
|
text: menuTextMap[FieldAction.Show],
|
||||||
|
Icon: FieldActionSvgMap[FieldAction.Show],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldAction.Duplicate,
|
||||||
|
content: renderActionContent({
|
||||||
|
text: menuTextMap[FieldAction.Duplicate],
|
||||||
|
Icon: FieldActionSvgMap[FieldAction.Duplicate],
|
||||||
|
}),
|
||||||
|
disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Duplicate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldAction.Delete,
|
||||||
|
content: renderActionContent({
|
||||||
|
text: menuTextMap[FieldAction.Delete],
|
||||||
|
Icon: FieldActionSvgMap[FieldAction.Delete],
|
||||||
|
}),
|
||||||
|
disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Delete),
|
||||||
|
},
|
||||||
|
].filter((option) => actions.includes(option.key)),
|
||||||
|
[renderActionContent, menuTextMap, isPrimary, actions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
const isTab = e.key === 'Tab';
|
||||||
|
|
||||||
|
if (!focusMenu && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
notify.clear();
|
||||||
|
notify.info(`Press Tab to focus on the menu`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTab) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (focusMenu) {
|
||||||
|
inputRef?.current?.focus();
|
||||||
|
setFocusMenu(false);
|
||||||
|
} else {
|
||||||
|
inputRef?.current?.blur();
|
||||||
|
setFocusMenu(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[focusMenu, inputRef]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{actions.map((action) => {
|
<KeyboardNavigation
|
||||||
const ActionSvg = FieldActionSvgMap[action];
|
disableFocus={!focusMenu}
|
||||||
const disabled = isPrimary && primaryPreventDefaultActions.includes(action);
|
disableSelect={!focusMenu}
|
||||||
|
onEscape={onClose}
|
||||||
return (
|
focusRef={inputRef}
|
||||||
<MenuItem disabled={disabled} onClick={() => handleMenuItemClick(action)} key={action} dense>
|
options={options}
|
||||||
<ActionSvg className='mr-2 text-base' />
|
onFocus={() => {
|
||||||
{menuTextMap[action]}
|
setFocusMenu(true);
|
||||||
</MenuItem>
|
}}
|
||||||
);
|
onBlur={() => {
|
||||||
})}
|
setFocusMenu(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onConfirm={handleMenuItemClick}
|
||||||
|
/>
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={openConfirm}
|
open={openConfirm}
|
||||||
subtitle={''}
|
subtitle={''}
|
||||||
@ -134,7 +261,7 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul
|
|||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setOpenConfirm(false);
|
setOpenConfirm(false);
|
||||||
onMenuItemClick?.(FieldAction.Delete);
|
onClose?.();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -1,25 +1,35 @@
|
|||||||
import { Divider, MenuList } from '@mui/material';
|
import { Divider } from '@mui/material';
|
||||||
import { FC, useCallback } from 'react';
|
import { FC, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { Field, fieldService } from '$app/application/database';
|
import { Field, fieldService } from '$app/application/database';
|
||||||
import PropertyTypeMenuExtension from '$app/components/database/components/property/property_type/PropertyTypeMenuExtension';
|
import PropertyTypeMenuExtension from '$app/components/database/components/property/property_type/PropertyTypeMenuExtension';
|
||||||
import PropertyTypeSelect from '$app/components/database/components/property/property_type/PropertyTypeSelect';
|
import PropertyTypeSelect from '$app/components/database/components/property/property_type/PropertyTypeSelect';
|
||||||
import { FieldType } from '@/services/backend';
|
import { FieldType, FieldVisibility } from '@/services/backend';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
import Popover, { PopoverProps } from '@mui/material/Popover';
|
import Popover, { PopoverProps } from '@mui/material/Popover';
|
||||||
import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput';
|
import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput';
|
||||||
import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions';
|
import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions';
|
||||||
|
|
||||||
const actions = [FieldAction.Hide, FieldAction.Duplicate, FieldAction.Delete];
|
|
||||||
|
|
||||||
export interface GridFieldMenuProps extends PopoverProps {
|
export interface GridFieldMenuProps extends PopoverProps {
|
||||||
field: Field;
|
field: Field;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PropertyMenu: FC<GridFieldMenuProps> = ({ field, ...props }) => {
|
export const PropertyMenu: FC<GridFieldMenuProps> = ({ field, ...props }) => {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const isPrimary = field.isPrimary;
|
const isPrimary = field.isPrimary;
|
||||||
|
const actions = useMemo(() => {
|
||||||
|
const keys = [FieldAction.Duplicate, FieldAction.Delete];
|
||||||
|
|
||||||
|
if (field.visibility === FieldVisibility.AlwaysHidden) {
|
||||||
|
keys.unshift(FieldAction.Show);
|
||||||
|
} else {
|
||||||
|
keys.unshift(FieldAction.Hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}, [field.visibility]);
|
||||||
|
|
||||||
const onUpdateFieldType = useCallback(
|
const onUpdateFieldType = useCallback(
|
||||||
async (type: FieldType) => {
|
async (type: FieldType) => {
|
||||||
@ -35,39 +45,45 @@ export const PropertyMenu: FC<GridFieldMenuProps> = ({ field, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
disableRestoreFocus={true}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: -10,
|
|
||||||
horizontal: 'left',
|
|
||||||
}}
|
|
||||||
anchorOrigin={{
|
|
||||||
vertical: 'bottom',
|
|
||||||
horizontal: 'left',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const isInput = inputRef.current?.contains(e.target as Node);
|
||||||
|
|
||||||
|
if (isInput) return;
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PropertyNameInput id={field.id} name={field.name} />
|
<PropertyNameInput ref={inputRef} id={field.id} name={field.name} />
|
||||||
<MenuList>
|
<div className={'flex-1 overflow-y-auto overflow-x-hidden py-1'}>
|
||||||
<div>
|
{!isPrimary && (
|
||||||
{!isPrimary && (
|
<div className={'pt-2'}>
|
||||||
<>
|
<PropertyTypeSelect field={field} onUpdateFieldType={onUpdateFieldType} />
|
||||||
<PropertyTypeSelect field={field} onUpdateFieldType={onUpdateFieldType} />
|
<Divider className={'my-2'} />
|
||||||
<Divider className={'my-2'} />
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
<PropertyTypeMenuExtension field={field} />
|
||||||
<PropertyTypeMenuExtension field={field} />
|
<PropertyActions
|
||||||
<PropertyActions
|
inputRef={inputRef}
|
||||||
isPrimary={isPrimary}
|
onClose={() => props.onClose?.({}, 'backdropClick')}
|
||||||
actions={actions}
|
isPrimary={isPrimary}
|
||||||
onMenuItemClick={() => {
|
actions={actions}
|
||||||
props.onClose?.({}, 'backdropClick');
|
onMenuItemClick={() => {
|
||||||
}}
|
props.onClose?.({}, 'backdropClick');
|
||||||
fieldId={field.id}
|
}}
|
||||||
/>
|
fieldId={field.id}
|
||||||
</div>
|
/>
|
||||||
</MenuList>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,47 +1,49 @@
|
|||||||
import React, { ChangeEventHandler, useCallback, useState } from 'react';
|
import React, { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { fieldService } from '$app/application/database';
|
import { fieldService } from '$app/application/database';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
|
||||||
function PropertyNameInput({ id, name }: { id: string; name: string }) {
|
const PropertyNameInput = React.forwardRef<HTMLInputElement, { id: string; name: string }>(({ id, name }, ref) => {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const [inputtingName, setInputtingName] = useState(name);
|
const [inputtingName, setInputtingName] = useState(name);
|
||||||
|
|
||||||
const handleInput = useCallback<ChangeEventHandler<HTMLInputElement>>((e) => {
|
const handleSubmit = useCallback(
|
||||||
setInputtingName(e.target.value);
|
async (newName: string) => {
|
||||||
}, []);
|
if (newName !== name) {
|
||||||
|
try {
|
||||||
const handleSubmit = useCallback(async () => {
|
await fieldService.updateField(viewId, id, {
|
||||||
if (inputtingName !== name) {
|
name: newName,
|
||||||
try {
|
});
|
||||||
await fieldService.updateField(viewId, id, {
|
} catch (e) {
|
||||||
name: inputtingName,
|
// TODO
|
||||||
});
|
Log.error(`change field ${id} name from '${name}' to ${newName} fail`, e);
|
||||||
} catch (e) {
|
}
|
||||||
// TODO
|
|
||||||
Log.error(`change field ${id} name from '${name}' to ${inputtingName} fail`, e);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, [viewId, id, name, inputtingName]);
|
[viewId, id, name]
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedHandleSubmit = useMemo(() => debounce(handleSubmit, 500), [handleSubmit]);
|
||||||
|
const handleInput = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||||
|
(e) => {
|
||||||
|
setInputtingName(e.target.value);
|
||||||
|
void debouncedHandleSubmit(e.target.value);
|
||||||
|
},
|
||||||
|
[debouncedHandleSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
className='mx-3 mt-3 rounded-[10px]'
|
className='mx-3 mt-3 rounded-[10px]'
|
||||||
size='small'
|
size='small'
|
||||||
|
inputRef={ref}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
void handleSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={inputtingName}
|
value={inputtingName}
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
onBlur={handleSubmit}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default PropertyNameInput;
|
export default PropertyNameInput;
|
||||||
|
@ -1,44 +1,84 @@
|
|||||||
import { MenuItem, Select, SelectChangeEvent, SelectProps } from '@mui/material';
|
import { FC, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { FC, useCallback } from 'react';
|
|
||||||
import { Field as FieldType } from '$app/application/database';
|
import { Field as FieldType } from '$app/application/database';
|
||||||
import { useDatabase } from '../../Database.hooks';
|
import { useDatabase } from '../../Database.hooks';
|
||||||
import { Property } from './Property';
|
import { Property } from './Property';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
import { ReactComponent as DropDownSvg } from '$app/assets/more.svg';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
|
||||||
export interface FieldSelectProps extends Omit<SelectProps, 'onChange'> {
|
export interface FieldSelectProps {
|
||||||
onChange?: (field: FieldType | undefined) => void;
|
onChange?: (field: FieldType | undefined) => void;
|
||||||
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PropertySelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
|
export const PropertySelect: FC<FieldSelectProps> = ({ value, onChange }) => {
|
||||||
const { fields } = useDatabase();
|
const { fields } = useDatabase();
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
(event: SelectChangeEvent<unknown>) => {
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const selectedId = event.target.value;
|
const [open, setOpen] = useState(false);
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
onChange?.(fields.find((field) => field.id === selectedId));
|
const options: KeyboardNavigationOption[] = useMemo(
|
||||||
|
() =>
|
||||||
|
fields.map((field) => {
|
||||||
|
return {
|
||||||
|
key: field.id,
|
||||||
|
content: <Property field={field} />,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[fields]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConfirm = useCallback(
|
||||||
|
(optionKey: string) => {
|
||||||
|
onChange?.(fields.find((field) => field.id === optionKey));
|
||||||
},
|
},
|
||||||
[onChange, fields]
|
[onChange, fields]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedField = useMemo(() => fields.find((field) => field.id === value), [fields, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<>
|
||||||
onChange={handleChange}
|
<div
|
||||||
{...props}
|
ref={ref}
|
||||||
sx={{
|
style={{
|
||||||
'& .MuiInputBase-input': {
|
borderColor: open ? 'var(--fill-default)' : undefined,
|
||||||
display: 'flex',
|
}}
|
||||||
alignItems: 'center',
|
className={
|
||||||
},
|
'flex w-[150px] cursor-pointer items-center justify-between gap-2 rounded border border-line-border p-2 hover:border-text-title'
|
||||||
}}
|
}
|
||||||
MenuProps={{
|
onClick={() => {
|
||||||
className: 'max-w-[150px]',
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{fields.map((field) => (
|
<div className={'flex-1'}>{selectedField ? <Property field={selectedField} /> : null}</div>
|
||||||
<MenuItem className={'overflow-hidden text-ellipsis px-1.5'} key={field.id} value={field.id}>
|
<DropDownSvg className={'h-4 w-4 rotate-90 transform'} />
|
||||||
<Property field={field} />
|
</div>
|
||||||
</MenuItem>
|
{open && (
|
||||||
))}
|
<Popover
|
||||||
</Select>
|
open={open}
|
||||||
|
anchorEl={ref.current}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||||
|
>
|
||||||
|
<div ref={scrollRef} className={'my-2 max-h-[300px] w-[150px] overflow-y-auto'}>
|
||||||
|
<KeyboardNavigation
|
||||||
|
defaultFocusedKey={value}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
options={options}
|
||||||
|
onEscape={handleClose}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,29 +1,13 @@
|
|||||||
import { Divider, Menu, MenuItem, MenuProps } from '@mui/material';
|
import { Menu, MenuProps } from '@mui/material';
|
||||||
import { FC, useMemo } from 'react';
|
import { FC, useCallback, useMemo } from 'react';
|
||||||
import { FieldType } from '@/services/backend';
|
import { FieldType } from '@/services/backend';
|
||||||
import { PropertyTypeText, ProppertyTypeSvg } from '$app/components/database/components/property';
|
import { PropertyTypeText, ProppertyTypeSvg } from '$app/components/database/components/property';
|
||||||
import { Field } from '$app/application/database';
|
import { Field } from '$app/application/database';
|
||||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||||
|
import KeyboardNavigation, {
|
||||||
const FieldTypeGroup = [
|
KeyboardNavigationOption,
|
||||||
{
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
name: 'Basic',
|
import Typography from '@mui/material/Typography';
|
||||||
types: [
|
|
||||||
FieldType.RichText,
|
|
||||||
FieldType.Number,
|
|
||||||
FieldType.SingleSelect,
|
|
||||||
FieldType.MultiSelect,
|
|
||||||
FieldType.DateTime,
|
|
||||||
FieldType.Checkbox,
|
|
||||||
FieldType.Checklist,
|
|
||||||
FieldType.URL,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Advanced',
|
|
||||||
types: [FieldType.LastEditedTime, FieldType.CreatedTime],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PropertyTypeMenu: FC<
|
export const PropertyTypeMenu: FC<
|
||||||
MenuProps & {
|
MenuProps & {
|
||||||
@ -39,23 +23,99 @@ export const PropertyTypeMenu: FC<
|
|||||||
[props.PopoverClasses]
|
[props.PopoverClasses]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderGroupContent = useCallback((title: string) => {
|
||||||
|
return (
|
||||||
|
<Typography variant='subtitle2' className='px-2'>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderContent = useCallback(
|
||||||
|
(type: FieldType) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProppertyTypeSvg className='mr-2 text-base' type={type} />
|
||||||
|
<span className='flex-1 font-medium'>
|
||||||
|
<PropertyTypeText type={type} />
|
||||||
|
</span>
|
||||||
|
{type === field.type && <SelectCheckSvg />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[field.type]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: KeyboardNavigationOption<FieldType>[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 100,
|
||||||
|
content: renderGroupContent('Basic'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: FieldType.RichText,
|
||||||
|
content: renderContent(FieldType.RichText),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldType.Number,
|
||||||
|
content: renderContent(FieldType.Number),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldType.SingleSelect,
|
||||||
|
content: renderContent(FieldType.SingleSelect),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldType.MultiSelect,
|
||||||
|
content: renderContent(FieldType.MultiSelect),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldType.DateTime,
|
||||||
|
content: renderContent(FieldType.DateTime),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldType.Checkbox,
|
||||||
|
content: renderContent(FieldType.Checkbox),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldType.Checklist,
|
||||||
|
content: renderContent(FieldType.Checklist),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldType.URL,
|
||||||
|
content: renderContent(FieldType.URL),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 101,
|
||||||
|
content: <hr className={'h-[1px] w-full bg-line-divider opacity-40'} />,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 102,
|
||||||
|
content: renderGroupContent('Advanced'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: FieldType.LastEditedTime,
|
||||||
|
content: renderContent(FieldType.LastEditedTime),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FieldType.CreatedTime,
|
||||||
|
content: renderContent(FieldType.CreatedTime),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [renderContent, renderGroupContent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu {...props} disableRestoreFocus={true} PopoverClasses={PopoverClasses}>
|
<Menu {...props} PopoverClasses={PopoverClasses}>
|
||||||
{FieldTypeGroup.map((group, index) => [
|
<KeyboardNavigation
|
||||||
<MenuItem key={group.name} dense disabled>
|
onEscape={() => props?.onClose?.({}, 'escapeKeyDown')}
|
||||||
{group.name}
|
options={options}
|
||||||
</MenuItem>,
|
disableFocus={true}
|
||||||
group.types.map((type) => (
|
onConfirm={onClickItem}
|
||||||
<MenuItem onClick={() => onClickItem?.(type)} key={type} dense className={'flex justify-between'}>
|
/>
|
||||||
<ProppertyTypeSvg className='mr-2 text-base' type={type} />
|
|
||||||
<span className='flex-1 font-medium'>
|
|
||||||
<PropertyTypeText type={type} />
|
|
||||||
</span>
|
|
||||||
{type === field.type && <SelectCheckSvg />}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
index < FieldTypeGroup.length - 1 && <Divider key={`Divider-${group.name}`} />,
|
|
||||||
])}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -16,19 +16,21 @@ function PropertyTypeSelect({ field, onUpdateFieldType }: Props) {
|
|||||||
const ref = useRef<HTMLLIElement>(null);
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'px-1'}>
|
<div>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExpanded(!expanded);
|
setExpanded(!expanded);
|
||||||
}}
|
}}
|
||||||
className={'px-23 mx-0'}
|
className={'mx-0 rounded-none px-0'}
|
||||||
>
|
>
|
||||||
<ProppertyTypeSvg type={field.type} className='mr-2 text-base' />
|
<div className={'flex w-full items-center px-3'}>
|
||||||
<span className='flex-1 text-xs font-medium'>
|
<ProppertyTypeSvg type={field.type} className='mr-2 text-base' />
|
||||||
<PropertyTypeText type={field.type} />
|
<span className='flex-1 text-xs font-medium'>
|
||||||
</span>
|
<PropertyTypeText type={field.type} />
|
||||||
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
|
</span>
|
||||||
|
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
|
||||||
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<PropertyTypeMenu
|
<PropertyTypeMenu
|
||||||
|
@ -1,13 +1,77 @@
|
|||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { FC } from 'react';
|
import { FC, useMemo, useRef, useState } from 'react';
|
||||||
import { MenuItem, Select, SelectProps } from '@mui/material';
|
|
||||||
import { SortConditionPB } from '@/services/backend';
|
import { SortConditionPB } from '@/services/backend';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
import { Popover } from '@mui/material';
|
||||||
|
import { ReactComponent as DropDownSvg } from '$app/assets/more.svg';
|
||||||
|
|
||||||
|
export const SortConditionSelect: FC<{
|
||||||
|
onChange?: (value: SortConditionPB) => void;
|
||||||
|
value?: SortConditionPB;
|
||||||
|
}> = ({ onChange, value }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: KeyboardNavigationOption<SortConditionPB>[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: SortConditionPB.Ascending,
|
||||||
|
content: t('grid.sort.ascending'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: SortConditionPB.Descending,
|
||||||
|
content: t('grid.sort.descending'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onConfirm = (optionKey: SortConditionPB) => {
|
||||||
|
onChange?.(optionKey);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedField = useMemo(() => options.find((option) => option.key === value), [options, value]);
|
||||||
|
|
||||||
export const SortConditionSelect: FC<SelectProps<SortConditionPB>> = (props) => {
|
|
||||||
return (
|
return (
|
||||||
<Select {...props}>
|
<>
|
||||||
<MenuItem value={SortConditionPB.Ascending}>{t('grid.sort.ascending')}</MenuItem>
|
<div
|
||||||
<MenuItem value={SortConditionPB.Descending}>{t('grid.sort.descending')}</MenuItem>
|
ref={ref}
|
||||||
</Select>
|
style={{
|
||||||
|
borderColor: open ? 'var(--fill-default)' : undefined,
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex w-[150px] cursor-pointer items-center justify-between gap-2 rounded border border-line-border p-2 text-xs hover:border-text-title'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={'flex-1'}>{selectedField?.content}</div>
|
||||||
|
<DropDownSvg className={'h-4 w-4 rotate-90 transform'} />
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
anchorEl={ref.current}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||||
|
>
|
||||||
|
<div className={'my-2 w-[150px]'}>
|
||||||
|
<KeyboardNavigation
|
||||||
|
defaultFocusedKey={value}
|
||||||
|
options={options}
|
||||||
|
onEscape={handleClose}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { FC, MouseEvent, useCallback } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
import { MenuProps } from '@mui/material';
|
import { MenuProps } from '@mui/material';
|
||||||
import PropertiesList from '$app/components/database/components/property/PropertiesList';
|
import PropertiesList from '$app/components/database/components/property/PropertiesList';
|
||||||
import { Field, sortService } from '$app/application/database';
|
import { Field, sortService } from '$app/application/database';
|
||||||
@ -15,7 +15,7 @@ const SortFieldsMenu: FC<
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const addSort = useCallback(
|
const addSort = useCallback(
|
||||||
async (event: MouseEvent, field: Field) => {
|
async (field: Field) => {
|
||||||
await sortService.insertSort(viewId, {
|
await sortService.insertSort(viewId, {
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
condition: SortConditionPB.Ascending,
|
condition: SortConditionPB.Ascending,
|
||||||
@ -27,8 +27,25 @@ const SortFieldsMenu: FC<
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover disableRestoreFocus={true} keepMounted={false} {...props}>
|
<Popover
|
||||||
<PropertiesList showSearch={true} onItemClick={addSort} searchPlaceholder={t('grid.settings.sortBy')} />
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
keepMounted={false}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PropertiesList
|
||||||
|
onClose={() => {
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
|
}}
|
||||||
|
showSearch={true}
|
||||||
|
onItemClick={addSort}
|
||||||
|
searchPlaceholder={t('grid.settings.sortBy')}
|
||||||
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IconButton, SelectChangeEvent, Stack } from '@mui/material';
|
import { IconButton, Stack } from '@mui/material';
|
||||||
import { FC, useCallback } from 'react';
|
import { FC, useCallback } from 'react';
|
||||||
import { ReactComponent as CloseSvg } from '$app/assets/close.svg';
|
import { ReactComponent as CloseSvg } from '$app/assets/close.svg';
|
||||||
import { Field, Sort, sortService } from '$app/application/database';
|
import { Field, Sort, sortService } from '$app/application/database';
|
||||||
@ -28,10 +28,10 @@ export const SortItem: FC<SortItemProps> = ({ className, sort }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleConditionChange = useCallback(
|
const handleConditionChange = useCallback(
|
||||||
(event: SelectChangeEvent<SortConditionPB>) => {
|
(value: SortConditionPB) => {
|
||||||
void sortService.updateSort(viewId, {
|
void sortService.updateSort(viewId, {
|
||||||
...sort,
|
...sort,
|
||||||
condition: event.target.value as SortConditionPB,
|
condition: value,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[viewId, sort]
|
[viewId, sort]
|
||||||
@ -43,13 +43,8 @@ export const SortItem: FC<SortItemProps> = ({ className, sort }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className={className} direction='row' spacing={1}>
|
<Stack className={className} direction='row' spacing={1}>
|
||||||
<PropertySelect className={'w-[150px]'} size='small' value={sort.fieldId} onChange={handleFieldChange} />
|
<PropertySelect value={sort.fieldId} onChange={handleFieldChange} />
|
||||||
<SortConditionSelect
|
<SortConditionSelect value={sort.condition} onChange={handleConditionChange} />
|
||||||
className={'w-[150px]'}
|
|
||||||
size='small'
|
|
||||||
value={sort.condition}
|
|
||||||
onChange={handleConditionChange}
|
|
||||||
/>
|
|
||||||
<div className={'flex items-center justify-center'}>
|
<div className={'flex items-center justify-center'}>
|
||||||
<IconButton size={'small'} onClick={handleClick}>
|
<IconButton size={'small'} onClick={handleClick}>
|
||||||
<CloseSvg />
|
<CloseSvg />
|
||||||
|
@ -2,7 +2,7 @@ import { Menu, MenuProps } from '@mui/material';
|
|||||||
import { FC, MouseEventHandler, useCallback, useState } from 'react';
|
import { FC, MouseEventHandler, useCallback, useState } from 'react';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { sortService } from '$app/application/database';
|
import { sortService } from '$app/application/database';
|
||||||
import { useDatabase } from '../../Database.hooks';
|
import { useDatabaseSorts } from '../../Database.hooks';
|
||||||
import { SortItem } from './SortItem';
|
import { SortItem } from './SortItem';
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -15,7 +15,7 @@ export const SortMenu: FC<MenuProps> = (props) => {
|
|||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const { sorts } = useDatabase();
|
const sorts = useDatabaseSorts();
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const openFieldListMenu = Boolean(anchorEl);
|
const openFieldListMenu = Boolean(anchorEl);
|
||||||
const handleClick = useCallback<MouseEventHandler<HTMLElement>>((event) => {
|
const handleClick = useCallback<MouseEventHandler<HTMLElement>>((event) => {
|
||||||
@ -30,25 +30,31 @@ export const SortMenu: FC<MenuProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu
|
<Menu
|
||||||
disableRestoreFocus={true}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
|
}
|
||||||
|
}}
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
MenuListProps={{
|
MenuListProps={{
|
||||||
className: 'py-1',
|
className: 'py-1 w-[360px]',
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<div className={'max-h-[300px] overflow-y-auto'}>
|
<div className={'flex max-h-[300px] w-full flex-col overflow-y-auto'}>
|
||||||
<div className={'mb-1 px-1'}>
|
<div className={'mb-1 px-1'}>
|
||||||
{sorts.map((sort) => (
|
{sorts.map((sort) => (
|
||||||
<SortItem key={sort.id} className='m-2' sort={sort} />
|
<SortItem key={sort.id} className='m-2' sort={sort} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'mx-1'}>
|
<div className={'mx-2 flex flex-col'}>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={'w-full justify-start'}
|
className={'justify-start px-1.5'}
|
||||||
variant={'text'}
|
variant={'text'}
|
||||||
color={'inherit'}
|
color={'inherit'}
|
||||||
startIcon={<AddSvg />}
|
startIcon={<AddSvg />}
|
||||||
@ -57,7 +63,7 @@ export const SortMenu: FC<MenuProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteAllSorts}
|
onClick={deleteAllSorts}
|
||||||
className={'w-full justify-start'}
|
className={'justify-start px-1.5'}
|
||||||
variant={'text'}
|
variant={'text'}
|
||||||
color={'inherit'}
|
color={'inherit'}
|
||||||
startIcon={<DeleteSvg />}
|
startIcon={<DeleteSvg />}
|
||||||
|
@ -18,10 +18,10 @@ export const Sorts = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const label = (
|
const label = (
|
||||||
<div className={'flex items-center justify-center'}>
|
<div className={'flex items-center justify-center gap-1'}>
|
||||||
<SortSvg className={'mr-1.5 h-4 w-4'} />
|
<SortSvg className={'h-4 w-4'} />
|
||||||
{t('grid.settings.sort')}
|
{t('grid.settings.sort')}
|
||||||
<DropDownSvg className={'ml-1.5 h-6 w-6'} />
|
<DropDownSvg className={'h-5 w-5'} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -36,10 +36,10 @@ export const Sorts = () => {
|
|||||||
if (!showSorts) return null;
|
if (!showSorts) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={'text-text-title'}>
|
||||||
<Chip clickable variant='outlined' label={label} onClick={handleClick} />
|
<Chip clickable variant='outlined' label={label} onClick={handleClick} />
|
||||||
<Divider className={'mx-2'} orientation='vertical' flexItem />
|
<Divider className={'mx-2'} orientation='vertical' flexItem />
|
||||||
<SortMenu open={menuOpen} anchorEl={anchorEl} onClose={() => setAnchorEl(null)} />
|
<SortMenu open={menuOpen} anchorEl={anchorEl} onClose={() => setAnchorEl(null)} />
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { FC, FunctionComponent, SVGProps, useEffect, useMemo, useState } from 'react';
|
import { forwardRef, FunctionComponent, SVGProps, useEffect, useMemo, useState } from 'react';
|
||||||
import { ViewTabs, ViewTab } from './ViewTabs';
|
import { ViewTabs, ViewTab } from './ViewTabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import AddViewBtn from '$app/components/database/components/tab_bar/AddViewBtn';
|
import AddViewBtn from '$app/components/database/components/tab_bar/AddViewBtn';
|
||||||
@ -25,70 +25,86 @@ const DatabaseIcons: {
|
|||||||
[ViewLayoutPB.Calendar]: GridSvg,
|
[ViewLayoutPB.Calendar]: GridSvg,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViews, selectedViewId, setSelectedViewId }) => {
|
export const DatabaseTabBar = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||||
const { t } = useTranslation();
|
({ pageId, childViews, selectedViewId, setSelectedViewId }, ref) => {
|
||||||
const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState<HTMLElement | null>(null);
|
const { t } = useTranslation();
|
||||||
const [contextMenuView, setContextMenuView] = useState<Page | null>(null);
|
const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const open = Boolean(contextMenuAnchorEl);
|
const [contextMenuView, setContextMenuView] = useState<Page | null>(null);
|
||||||
|
const open = Boolean(contextMenuAnchorEl);
|
||||||
|
|
||||||
const handleChange = (_: React.SyntheticEvent, newValue: string) => {
|
const handleChange = (_: React.SyntheticEvent, newValue: string) => {
|
||||||
setSelectedViewId?.(newValue);
|
setSelectedViewId?.(newValue);
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedViewId === undefined && childViews.length > 0) {
|
|
||||||
setSelectedViewId?.(childViews[0].id);
|
|
||||||
}
|
|
||||||
}, [selectedViewId, setSelectedViewId, childViews]);
|
|
||||||
|
|
||||||
const openMenu = (view: Page) => {
|
|
||||||
return (e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setContextMenuView(view);
|
|
||||||
setContextMenuAnchorEl(e.currentTarget);
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const isSelected = useMemo(() => childViews.some((view) => view.id === selectedViewId), [childViews, selectedViewId]);
|
useEffect(() => {
|
||||||
|
if (selectedViewId === undefined && childViews.length > 0) {
|
||||||
|
setSelectedViewId?.(childViews[0].id);
|
||||||
|
}
|
||||||
|
}, [selectedViewId, setSelectedViewId, childViews]);
|
||||||
|
|
||||||
if (childViews.length === 0) return null;
|
const openMenu = (view: Page) => {
|
||||||
return (
|
return (e: React.MouseEvent<HTMLElement>) => {
|
||||||
<div className='-mb-px flex items-center px-16'>
|
e.preventDefault();
|
||||||
<div className='flex flex-1 items-center border-b border-line-divider'>
|
e.stopPropagation();
|
||||||
<ViewTabs value={isSelected ? selectedViewId : childViews[0].id} onChange={handleChange}>
|
setContextMenuView(view);
|
||||||
{childViews.map((view) => {
|
setContextMenuAnchorEl(e.currentTarget);
|
||||||
const Icon = DatabaseIcons[view.layout];
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const isSelected = useMemo(
|
||||||
<ViewTab
|
() => childViews.some((view) => view.id === selectedViewId),
|
||||||
onContextMenuCapture={openMenu(view)}
|
[childViews, selectedViewId]
|
||||||
onDoubleClick={openMenu(view)}
|
);
|
||||||
key={view.id}
|
|
||||||
icon={<Icon />}
|
if (childViews.length === 0) return null;
|
||||||
iconPosition='start'
|
return (
|
||||||
color='inherit'
|
<div ref={ref} className='-mb-px flex w-full items-center overflow-hidden px-16 text-text-title'>
|
||||||
label={view.name || t('grid.title.placeholder')}
|
<div
|
||||||
value={view.id}
|
style={{
|
||||||
/>
|
width: 'calc(100% - 120px)',
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ViewTabs>
|
|
||||||
<AddViewBtn pageId={pageId} onCreated={(id) => setSelectedViewId?.(id)} />
|
|
||||||
</div>
|
|
||||||
{open && contextMenuView && (
|
|
||||||
<ViewActions
|
|
||||||
pageId={pageId}
|
|
||||||
view={contextMenuView}
|
|
||||||
keepMounted={false}
|
|
||||||
open={open}
|
|
||||||
anchorEl={contextMenuAnchorEl}
|
|
||||||
onClose={() => {
|
|
||||||
setContextMenuAnchorEl(null);
|
|
||||||
setContextMenuView(null);
|
|
||||||
}}
|
}}
|
||||||
/>
|
className='flex items-center border-b border-line-divider'
|
||||||
)}
|
>
|
||||||
</div>
|
<ViewTabs
|
||||||
);
|
scrollButtons={false}
|
||||||
};
|
variant='scrollable'
|
||||||
|
allowScrollButtonsMobile
|
||||||
|
value={isSelected ? selectedViewId : childViews[0].id}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
{childViews.map((view) => {
|
||||||
|
const Icon = DatabaseIcons[view.layout];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ViewTab
|
||||||
|
onContextMenuCapture={openMenu(view)}
|
||||||
|
onDoubleClick={openMenu(view)}
|
||||||
|
key={view.id}
|
||||||
|
icon={<Icon />}
|
||||||
|
iconPosition='start'
|
||||||
|
color='inherit'
|
||||||
|
label={view.name || t('grid.title.placeholder')}
|
||||||
|
value={view.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ViewTabs>
|
||||||
|
<AddViewBtn pageId={pageId} onCreated={(id) => setSelectedViewId?.(id)} />
|
||||||
|
</div>
|
||||||
|
{open && contextMenuView && (
|
||||||
|
<ViewActions
|
||||||
|
pageId={pageId}
|
||||||
|
view={contextMenuView}
|
||||||
|
keepMounted={false}
|
||||||
|
open={open}
|
||||||
|
anchorEl={contextMenuAnchorEl}
|
||||||
|
onClose={() => {
|
||||||
|
setContextMenuAnchorEl(null);
|
||||||
|
setContextMenuView(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
||||||
import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
|
import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
|
||||||
import { deleteView } from '$app/application/database/database_view/database_view_service';
|
import { deleteView } from '$app/application/database/database_view/database_view_service';
|
||||||
import { MenuItem, MenuProps, Menu } from '@mui/material';
|
import { MenuProps, Menu } from '@mui/material';
|
||||||
import RenameDialog from '$app/components/_shared/confirm_dialog/RenameDialog';
|
import RenameDialog from '$app/components/_shared/confirm_dialog/RenameDialog';
|
||||||
import { Page } from '$app_reducers/pages/slice';
|
import { Page } from '$app_reducers/pages/slice';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { updatePageName } from '$app_reducers/pages/async_actions';
|
import { updatePageName } from '$app_reducers/pages/async_actions';
|
||||||
|
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
|
||||||
enum ViewAction {
|
enum ViewAction {
|
||||||
Rename,
|
Rename,
|
||||||
@ -19,41 +20,59 @@ function ViewActions({ view, pageId, ...props }: { pageId: string; view: Page }
|
|||||||
const viewId = view.id;
|
const viewId = view.id;
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [openRenameDialog, setOpenRenameDialog] = useState(false);
|
const [openRenameDialog, setOpenRenameDialog] = useState(false);
|
||||||
|
const renderContent = useCallback((title: string, Icon: React.FC<React.SVGProps<SVGSVGElement>>) => {
|
||||||
|
return (
|
||||||
|
<div className={'flex w-full items-center gap-1'}>
|
||||||
|
<Icon className={'h-4 w-4'} />
|
||||||
|
<div className={'flex-1'}>{title}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onConfirm = useCallback(
|
||||||
|
async (key: ViewAction) => {
|
||||||
|
switch (key) {
|
||||||
|
case ViewAction.Rename:
|
||||||
|
setOpenRenameDialog(true);
|
||||||
|
break;
|
||||||
|
case ViewAction.Delete:
|
||||||
|
try {
|
||||||
|
await deleteView(viewId);
|
||||||
|
props.onClose?.({}, 'backdropClick');
|
||||||
|
} catch (e) {
|
||||||
|
// toast.error(t('error.deleteView'));
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[viewId, props]
|
||||||
|
);
|
||||||
const options = [
|
const options = [
|
||||||
{
|
{
|
||||||
id: ViewAction.Rename,
|
key: ViewAction.Rename,
|
||||||
label: t('button.rename'),
|
content: renderContent(t('button.rename'), EditSvg),
|
||||||
icon: <EditSvg />,
|
|
||||||
action: () => {
|
|
||||||
setOpenRenameDialog(true);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: ViewAction.Delete,
|
key: ViewAction.Delete,
|
||||||
|
content: renderContent(t('button.delete'), DeleteSvg),
|
||||||
disabled: viewId === pageId,
|
disabled: viewId === pageId,
|
||||||
label: t('button.delete'),
|
|
||||||
icon: <DeleteSvg />,
|
|
||||||
action: async () => {
|
|
||||||
try {
|
|
||||||
await deleteView(viewId);
|
|
||||||
props.onClose?.({}, 'backdropClick');
|
|
||||||
} catch (e) {
|
|
||||||
// toast.error(t('error.deleteView'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu keepMounted={false} disableRestoreFocus={true} {...props}>
|
<Menu keepMounted={false} disableRestoreFocus={true} {...props}>
|
||||||
{options.map((option) => (
|
<KeyboardNavigation
|
||||||
<MenuItem disabled={option.disabled} key={option.id} onClick={option.action}>
|
options={options}
|
||||||
<div className={'mr-1.5'}>{option.icon}</div>
|
onConfirm={onConfirm}
|
||||||
{option.label}
|
onEscape={() => {
|
||||||
</MenuItem>
|
props.onClose?.({}, 'escapeKeyDown');
|
||||||
))}
|
}}
|
||||||
|
/>
|
||||||
</Menu>
|
</Menu>
|
||||||
{openRenameDialog && (
|
{openRenameDialog && (
|
||||||
<RenameDialog
|
<RenameDialog
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { styled, Tab, TabProps, Tabs } from '@mui/material';
|
import { styled, Tab, TabProps, Tabs, TabsProps } from '@mui/material';
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
export const ViewTabs = styled(Tabs)({
|
export const ViewTabs = styled((props: TabsProps) => <Tabs {...props} />)({
|
||||||
minHeight: '28px',
|
minHeight: '28px',
|
||||||
|
|
||||||
'& .MuiTabs-scroller': {
|
'& .MuiTabs-scroller': {
|
||||||
|
@ -18,12 +18,15 @@ export function GridCalculate({ field, index }: Props) {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width,
|
width,
|
||||||
visibility: index === 1 ? 'visible' : 'hidden',
|
|
||||||
}}
|
}}
|
||||||
className={'flex justify-end py-2'}
|
className={'flex justify-end py-2 text-text-title'}
|
||||||
>
|
>
|
||||||
<span className={'mr-2 text-text-caption'}>Count</span>
|
{field.isPrimary ? (
|
||||||
<span>{count}</span>
|
<>
|
||||||
|
<span className={'mr-2 text-text-caption'}>Count</span>
|
||||||
|
<span>{count}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Button, Tooltip } from '@mui/material';
|
import { Button } from '@mui/material';
|
||||||
import { DragEventHandler, FC, HTMLAttributes, memo, useCallback, useEffect, useMemo, useState } from 'react';
|
import { DragEventHandler, FC, HTMLAttributes, memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { DragItem, DropPosition, DragType, useDraggable, useDroppable, ScrollDirection } from '../../_shared';
|
import { DragItem, DropPosition, DragType, useDraggable, useDroppable, ScrollDirection } from '../../_shared';
|
||||||
@ -21,18 +21,9 @@ export const GridField: FC<GridFieldProps> = memo(
|
|||||||
({ getScrollElement, resizeColumnWidth, onOpenMenu, onCloseMenu, field, ...props }) => {
|
({ getScrollElement, resizeColumnWidth, onOpenMenu, onCloseMenu, field, ...props }) => {
|
||||||
const menuOpened = useOpenMenu(field.id);
|
const menuOpened = useOpenMenu(field.id);
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const [openTooltip, setOpenTooltip] = useState(false);
|
|
||||||
const [propertyMenuOpened, setPropertyMenuOpened] = useState(false);
|
const [propertyMenuOpened, setPropertyMenuOpened] = useState(false);
|
||||||
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
|
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
|
||||||
|
|
||||||
const handleTooltipOpen = useCallback(() => {
|
|
||||||
setOpenTooltip(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTooltipClose = useCallback(() => {
|
|
||||||
setOpenTooltip(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const draggingData = useMemo(
|
const draggingData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
field,
|
field,
|
||||||
@ -109,16 +100,21 @@ export const GridField: FC<GridFieldProps> = memo(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = previewRef.current?.getBoundingClientRect();
|
const anchorElement = previewRef.current;
|
||||||
|
|
||||||
if (rect) {
|
if (!anchorElement) {
|
||||||
setMenuAnchorPosition({
|
|
||||||
top: rect.top + rect.height,
|
|
||||||
left: rect.left,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setMenuAnchorPosition(undefined);
|
setMenuAnchorPosition(undefined);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
anchorElement.scrollIntoView({ block: 'nearest' });
|
||||||
|
|
||||||
|
const rect = anchorElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
setMenuAnchorPosition({
|
||||||
|
top: rect.top + rect.height,
|
||||||
|
left: rect.left,
|
||||||
|
});
|
||||||
}, [menuOpened, previewRef]);
|
}, [menuOpened, previewRef]);
|
||||||
|
|
||||||
const handlePropertyMenuOpen = useCallback(() => {
|
const handlePropertyMenuOpen = useCallback(() => {
|
||||||
@ -131,46 +127,36 @@ export const GridField: FC<GridFieldProps> = memo(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex w-full border-r border-line-divider bg-bg-body'} {...props}>
|
<div className={'flex w-full border-r border-line-divider bg-bg-body'} {...props}>
|
||||||
<Tooltip
|
<Button
|
||||||
open={openTooltip && !isDragging}
|
color={'inherit'}
|
||||||
title={field.name}
|
ref={setPreviewRef}
|
||||||
placement='right'
|
className='relative flex h-full w-full items-center rounded-none px-0'
|
||||||
enterDelay={1000}
|
disableRipple
|
||||||
enterNextDelay={1000}
|
onContextMenu={(event) => {
|
||||||
onOpen={handleTooltipOpen}
|
event.stopPropagation();
|
||||||
onClose={handleTooltipClose}
|
event.preventDefault();
|
||||||
|
handleClick();
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
{...dropListeners}
|
||||||
>
|
>
|
||||||
<Button
|
<Property
|
||||||
color={'inherit'}
|
menuOpened={propertyMenuOpened}
|
||||||
ref={setPreviewRef}
|
onCloseMenu={handlePropertyMenuClose}
|
||||||
className='relative flex h-full w-full items-center px-0'
|
onOpenMenu={handlePropertyMenuOpen}
|
||||||
disableRipple
|
field={field}
|
||||||
onContextMenu={(event) => {
|
/>
|
||||||
event.stopPropagation();
|
{isOver && (
|
||||||
event.preventDefault();
|
<div
|
||||||
handleClick();
|
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${
|
||||||
}}
|
dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
|
||||||
onClick={handleClick}
|
}`}
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
{...dropListeners}
|
|
||||||
>
|
|
||||||
<Property
|
|
||||||
menuOpened={propertyMenuOpened}
|
|
||||||
onCloseMenu={handlePropertyMenuClose}
|
|
||||||
onOpenMenu={handlePropertyMenuOpen}
|
|
||||||
field={field}
|
|
||||||
/>
|
/>
|
||||||
{isOver && (
|
)}
|
||||||
<div
|
<GridResizer field={field} onWidthChange={resizeColumnWidth} />
|
||||||
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${
|
</Button>
|
||||||
dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<GridResizer field={field} onWidthChange={resizeColumnWidth} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
{open && (
|
{open && (
|
||||||
<GridFieldMenu
|
<GridFieldMenu
|
||||||
anchorPosition={menuAnchorPosition}
|
anchorPosition={menuAnchorPosition}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import Popover, { PopoverProps } from '@mui/material/Popover';
|
import Popover, { PopoverProps } from '@mui/material/Popover';
|
||||||
import { Field } from '$app/application/database';
|
import { Field } from '$app/application/database';
|
||||||
import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput';
|
import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput';
|
||||||
import { MenuList, Portal } from '@mui/material';
|
import { MenuList } from '@mui/material';
|
||||||
import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions';
|
import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions';
|
||||||
|
|
||||||
interface Props extends PopoverProps {
|
interface Props extends PopoverProps {
|
||||||
@ -11,37 +11,48 @@ interface Props extends PopoverProps {
|
|||||||
onOpenMenu?: (fieldId: string) => void;
|
onOpenMenu?: (fieldId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props }: Props) {
|
export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, onClose, ...props }: Props) {
|
||||||
return (
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
<Portal>
|
|
||||||
<Popover
|
|
||||||
disableRestoreFocus={true}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: 'top',
|
|
||||||
horizontal: 'left',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
{...props}
|
|
||||||
keepMounted={false}
|
|
||||||
>
|
|
||||||
<PropertyNameInput id={field.id} name={field.name} />
|
|
||||||
<MenuList>
|
|
||||||
<PropertyActions
|
|
||||||
isPrimary={field.isPrimary}
|
|
||||||
onMenuItemClick={(action, newFieldId?: string) => {
|
|
||||||
if (action === FieldAction.EditProperty) {
|
|
||||||
onOpenPropertyMenu?.();
|
|
||||||
} else if (newFieldId && (action === FieldAction.InsertLeft || action === FieldAction.InsertRight)) {
|
|
||||||
onOpenMenu?.(newFieldId);
|
|
||||||
}
|
|
||||||
|
|
||||||
props.onClose?.({}, 'backdropClick');
|
return (
|
||||||
}}
|
<Popover
|
||||||
fieldId={field.id}
|
disableRestoreFocus={true}
|
||||||
/>
|
transformOrigin={{
|
||||||
</MenuList>
|
vertical: 'top',
|
||||||
</Popover>
|
horizontal: 'left',
|
||||||
</Portal>
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
{...props}
|
||||||
|
onClose={onClose}
|
||||||
|
keepMounted={false}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const isInput = inputRef.current?.contains(e.target as Node);
|
||||||
|
|
||||||
|
if (isInput) return;
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PropertyNameInput ref={inputRef} id={field.id} name={field.name} />
|
||||||
|
<MenuList>
|
||||||
|
<PropertyActions
|
||||||
|
inputRef={inputRef}
|
||||||
|
isPrimary={field.isPrimary}
|
||||||
|
onClose={() => onClose?.({}, 'backdropClick')}
|
||||||
|
onMenuItemClick={(action, newFieldId?: string) => {
|
||||||
|
if (action === FieldAction.EditProperty) {
|
||||||
|
onOpenPropertyMenu?.();
|
||||||
|
} else if (newFieldId && (action === FieldAction.InsertLeft || action === FieldAction.InsertRight)) {
|
||||||
|
onOpenMenu?.(newFieldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose?.({}, 'backdropClick');
|
||||||
|
}}
|
||||||
|
fieldId={field.id}
|
||||||
|
/>
|
||||||
|
</MenuList>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
import { Field, fieldService } from '$app/application/database';
|
import { Field, fieldService } from '$app/application/database';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
|
|
||||||
@ -7,17 +7,16 @@ interface GridResizerProps {
|
|||||||
onWidthChange?: (width: number) => void;
|
onWidthChange?: (width: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minWidth = 100;
|
const minWidth = 150;
|
||||||
|
|
||||||
export function GridResizer({ field, onWidthChange }: GridResizerProps) {
|
export function GridResizer({ field, onWidthChange }: GridResizerProps) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const fieldId = field.id;
|
const fieldId = field.id;
|
||||||
const width = field.width || 0;
|
const width = field.width || 0;
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [newWidth, setNewWidth] = useState(width);
|
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const startX = useRef(0);
|
const startX = useRef(0);
|
||||||
|
const newWidthRef = useRef(width);
|
||||||
const onResize = useCallback(
|
const onResize = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
const diff = e.clientX - startX.current;
|
const diff = e.clientX - startX.current;
|
||||||
@ -27,25 +26,21 @@ export function GridResizer({ field, onWidthChange }: GridResizerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setNewWidth(newWidth);
|
newWidthRef.current = newWidth;
|
||||||
onWidthChange?.(newWidth);
|
onWidthChange?.(newWidth);
|
||||||
},
|
},
|
||||||
[width, onWidthChange]
|
[width, onWidthChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isResizing && width !== newWidth) {
|
|
||||||
void fieldService.updateFieldSetting(viewId, fieldId, {
|
|
||||||
width: newWidth,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [fieldId, isResizing, newWidth, viewId, width]);
|
|
||||||
|
|
||||||
const onResizeEnd = useCallback(() => {
|
const onResizeEnd = useCallback(() => {
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
|
|
||||||
|
void fieldService.updateFieldSetting(viewId, fieldId, {
|
||||||
|
width: newWidthRef.current,
|
||||||
|
});
|
||||||
document.removeEventListener('mousemove', onResize);
|
document.removeEventListener('mousemove', onResize);
|
||||||
document.removeEventListener('mouseup', onResizeEnd);
|
document.removeEventListener('mouseup', onResizeEnd);
|
||||||
}, [onResize]);
|
}, [fieldId, onResize, viewId]);
|
||||||
|
|
||||||
const onResizeStart = useCallback(
|
const onResizeStart = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
@ -49,7 +49,7 @@ function GridNewRow({ index, groupId, getContainerRef }: Props) {
|
|||||||
toggleCssProperty(false);
|
toggleCssProperty(false);
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={'grid-new-row flex grow cursor-pointer'}
|
className={'grid-new-row flex grow cursor-pointer text-text-title'}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
GridRowContextMenu,
|
GridRowContextMenu,
|
||||||
GridRowActions,
|
GridRowActions,
|
||||||
useGridTableHoverState,
|
useGridTableHoverState,
|
||||||
} from '$app/components/database/grid/grid_row_actions';
|
} from '$app/components/database/grid/grid_row_actions';
|
||||||
|
import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function GridTableOverlay({
|
function GridTableOverlay({
|
||||||
containerRef,
|
containerRef,
|
||||||
@ -14,8 +16,23 @@ function GridTableOverlay({
|
|||||||
}) {
|
}) {
|
||||||
const [hoverRowTop, setHoverRowTop] = useState<string | undefined>();
|
const [hoverRowTop, setHoverRowTop] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [openConfirm, setOpenConfirm] = useState(false);
|
||||||
|
const [confirmModalProps, setConfirmModalProps] = useState<
|
||||||
|
| {
|
||||||
|
onOk: () => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
const { hoverRowId } = useGridTableHoverState(containerRef);
|
const { hoverRowId } = useGridTableHoverState(containerRef);
|
||||||
|
|
||||||
|
const handleOpenConfirm = useCallback((onOk: () => Promise<void>, onCancel: () => void) => {
|
||||||
|
setOpenConfirm(true);
|
||||||
|
setConfirmModalProps({ onOk, onCancel });
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
||||||
@ -32,12 +49,25 @@ function GridTableOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className={'absolute left-0 top-0'}>
|
<div className={'absolute left-0 top-0'}>
|
||||||
<GridRowActions
|
<GridRowActions
|
||||||
|
onOpenConfirm={handleOpenConfirm}
|
||||||
getScrollElement={getScrollElement}
|
getScrollElement={getScrollElement}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
rowId={hoverRowId}
|
rowId={hoverRowId}
|
||||||
rowTop={hoverRowTop}
|
rowTop={hoverRowTop}
|
||||||
/>
|
/>
|
||||||
<GridRowContextMenu containerRef={containerRef} hoverRowId={hoverRowId} />
|
<GridRowContextMenu onOpenConfirm={handleOpenConfirm} containerRef={containerRef} hoverRowId={hoverRowId} />
|
||||||
|
{openConfirm && (
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={openConfirm}
|
||||||
|
title={t('grid.removeSorting')}
|
||||||
|
okText={t('button.remove')}
|
||||||
|
cancelText={t('button.dontRemove')}
|
||||||
|
onClose={() => {
|
||||||
|
setOpenConfirm(false);
|
||||||
|
}}
|
||||||
|
{...confirmModalProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { rowService } from '$app/application/database';
|
import { rowService } from '$app/application/database';
|
||||||
import { autoScrollOnEdge, ScrollDirection } from '$app/components/database/_shared/dnd/utils';
|
import { autoScrollOnEdge, ScrollDirection } from '$app/components/database/_shared/dnd/utils';
|
||||||
|
import { useSortsCount } from '$app/components/database';
|
||||||
|
import { deleteAllSorts } from '$app/application/database/sort/sort_service';
|
||||||
|
|
||||||
export function getCellsWithRowId(rowId: string, container: HTMLDivElement) {
|
export function getCellsWithRowId(rowId: string, container: HTMLDivElement) {
|
||||||
return Array.from(container.querySelectorAll(`[data-key^="row:${rowId}"]`));
|
return Array.from(container.querySelectorAll(`[data-key^="row:${rowId}"]`));
|
||||||
@ -64,12 +66,16 @@ function createVirtualDragElement(rowId: string, container: HTMLDivElement) {
|
|||||||
export function useDraggableGridRow(
|
export function useDraggableGridRow(
|
||||||
rowId: string,
|
rowId: string,
|
||||||
containerRef: React.RefObject<HTMLDivElement>,
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
getScrollElement: () => HTMLDivElement | null
|
getScrollElement: () => HTMLDivElement | null,
|
||||||
|
onOpenConfirm: (onOk: () => Promise<void>, onCancel: () => void) => void
|
||||||
) {
|
) {
|
||||||
|
const viewId = useViewId();
|
||||||
|
const sortsCount = useSortsCount();
|
||||||
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const dropRowIdRef = useRef<string | undefined>(undefined);
|
const dropRowIdRef = useRef<string | undefined>(undefined);
|
||||||
const previewRef = useRef<HTMLDivElement | undefined>();
|
const previewRef = useRef<HTMLDivElement | undefined>();
|
||||||
const viewId = useViewId();
|
|
||||||
const onDragStart = useCallback(
|
const onDragStart = useCallback(
|
||||||
(e: React.DragEvent<HTMLButtonElement>) => {
|
(e: React.DragEvent<HTMLButtonElement>) => {
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@ -100,6 +106,13 @@ export function useDraggableGridRow(
|
|||||||
[containerRef, rowId, getScrollElement]
|
[containerRef, rowId, getScrollElement]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const moveRowTo = useCallback(
|
||||||
|
async (toRowId: string) => {
|
||||||
|
return rowService.moveRow(viewId, rowId, toRowId);
|
||||||
|
},
|
||||||
|
[viewId, rowId]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
if (previewRef.current) {
|
if (previewRef.current) {
|
||||||
@ -156,8 +169,23 @@ export function useDraggableGridRow(
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const dropRowId = dropRowIdRef.current;
|
const dropRowId = dropRowIdRef.current;
|
||||||
|
|
||||||
|
toggleProperty(container, rowId, false);
|
||||||
if (dropRowId) {
|
if (dropRowId) {
|
||||||
void rowService.moveRow(viewId, rowId, dropRowId);
|
if (sortsCount > 0) {
|
||||||
|
onOpenConfirm(
|
||||||
|
async () => {
|
||||||
|
await deleteAllSorts(viewId);
|
||||||
|
await moveRowTo(dropRowId);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
void moveRowTo(dropRowId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
void moveRowTo(dropRowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleProperty(container, dropRowId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
@ -169,7 +197,7 @@ export function useDraggableGridRow(
|
|||||||
container.addEventListener('dragover', onDragOver);
|
container.addEventListener('dragover', onDragOver);
|
||||||
container.addEventListener('dragend', onDragEnd);
|
container.addEventListener('dragend', onDragEnd);
|
||||||
container.addEventListener('drop', onDrop);
|
container.addEventListener('drop', onDrop);
|
||||||
}, [containerRef, isDragging, rowId, viewId]);
|
}, [isDragging, containerRef, moveRowTo, onOpenConfirm, rowId, sortsCount, viewId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDragging,
|
isDragging,
|
||||||
|
@ -1,25 +1,31 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { IconButton, Tooltip } from '@mui/material';
|
import { IconButton, Tooltip } from '@mui/material';
|
||||||
import { t } from 'i18next';
|
|
||||||
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
||||||
import { GRID_ACTIONS_WIDTH } from '$app/components/database/grid/constants';
|
import { GRID_ACTIONS_WIDTH } from '$app/components/database/grid/constants';
|
||||||
import { rowService } from '$app/application/database';
|
import { rowService } from '$app/application/database';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { GridRowDragButton, GridRowMenu } from '$app/components/database/grid/grid_row_actions';
|
import { GridRowDragButton, GridRowMenu, toggleProperty } from '$app/components/database/grid/grid_row_actions';
|
||||||
import { OrderObjectPositionTypePB } from '@/services/backend';
|
import { OrderObjectPositionTypePB } from '@/services/backend';
|
||||||
|
import { useSortsCount } from '$app/components/database';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { deleteAllSorts } from '$app/application/database/sort/sort_service';
|
||||||
|
|
||||||
export function GridRowActions({
|
export function GridRowActions({
|
||||||
rowId,
|
rowId,
|
||||||
rowTop,
|
rowTop,
|
||||||
containerRef,
|
containerRef,
|
||||||
getScrollElement,
|
getScrollElement,
|
||||||
|
onOpenConfirm,
|
||||||
}: {
|
}: {
|
||||||
|
onOpenConfirm: (onOk: () => Promise<void>, onCancel: () => void) => void;
|
||||||
rowId?: string;
|
rowId?: string;
|
||||||
rowTop?: string;
|
rowTop?: string;
|
||||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
getScrollElement: () => HTMLDivElement | null;
|
getScrollElement: () => HTMLDivElement | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
const sortsCount = useSortsCount();
|
||||||
const [menuRowId, setMenuRowId] = useState<string | undefined>(undefined);
|
const [menuRowId, setMenuRowId] = useState<string | undefined>(undefined);
|
||||||
const [menuPosition, setMenuPosition] = useState<
|
const [menuPosition, setMenuPosition] = useState<
|
||||||
| {
|
| {
|
||||||
@ -31,17 +37,32 @@ export function GridRowActions({
|
|||||||
|
|
||||||
const openMenu = Boolean(menuPosition);
|
const openMenu = Boolean(menuPosition);
|
||||||
|
|
||||||
const handleInsertRecordBelow = useCallback(() => {
|
const handleCloseMenu = useCallback(() => {
|
||||||
void rowService.createRow(viewId, {
|
setMenuPosition(undefined);
|
||||||
position: OrderObjectPositionTypePB.After,
|
if (containerRef.current && menuRowId) {
|
||||||
rowId: rowId,
|
toggleProperty(containerRef.current, menuRowId, false);
|
||||||
});
|
}
|
||||||
}, [viewId, rowId]);
|
}, [containerRef, menuRowId]);
|
||||||
|
|
||||||
|
const handleInsertRecordBelow = useCallback(
|
||||||
|
async (rowId: string) => {
|
||||||
|
await rowService.createRow(viewId, {
|
||||||
|
position: OrderObjectPositionTypePB.After,
|
||||||
|
rowId: rowId,
|
||||||
|
});
|
||||||
|
handleCloseMenu();
|
||||||
|
},
|
||||||
|
[viewId, handleCloseMenu]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenMenu = (e: React.MouseEvent) => {
|
const handleOpenMenu = (e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLButtonElement;
|
const target = e.target as HTMLButtonElement;
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (containerRef.current && rowId) {
|
||||||
|
toggleProperty(containerRef.current, rowId, true);
|
||||||
|
}
|
||||||
|
|
||||||
setMenuRowId(rowId);
|
setMenuRowId(rowId);
|
||||||
setMenuPosition({
|
setMenuPosition({
|
||||||
top: rect.top + rect.height / 2,
|
top: rect.top + rect.height / 2,
|
||||||
@ -49,11 +70,6 @@ export function GridRowActions({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseMenu = useCallback(() => {
|
|
||||||
setMenuPosition(undefined);
|
|
||||||
setMenuRowId(undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rowId && rowTop && (
|
{rowId && rowTop && (
|
||||||
@ -64,10 +80,28 @@ export function GridRowActions({
|
|||||||
left: GRID_ACTIONS_WIDTH,
|
left: GRID_ACTIONS_WIDTH,
|
||||||
transform: 'translateY(4px)',
|
transform: 'translateY(4px)',
|
||||||
}}
|
}}
|
||||||
className={'z-10 flex w-full items-center justify-end'}
|
className={'z-10 flex w-full items-center justify-end py-[3px]'}
|
||||||
>
|
>
|
||||||
<Tooltip placement='top' title={t('grid.row.add')}>
|
<Tooltip placement='top' disableInteractive={true} title={t('grid.row.add')}>
|
||||||
<IconButton onClick={handleInsertRecordBelow}>
|
<IconButton
|
||||||
|
size={'small'}
|
||||||
|
className={'h-5 w-5'}
|
||||||
|
onClick={() => {
|
||||||
|
if (sortsCount > 0) {
|
||||||
|
onOpenConfirm(
|
||||||
|
async () => {
|
||||||
|
await deleteAllSorts(viewId);
|
||||||
|
void handleInsertRecordBelow(rowId);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
void handleInsertRecordBelow(rowId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
void handleInsertRecordBelow(rowId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AddSvg />
|
<AddSvg />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -76,12 +110,14 @@ export function GridRowActions({
|
|||||||
rowId={rowId}
|
rowId={rowId}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
onClick={handleOpenMenu}
|
onClick={handleOpenMenu}
|
||||||
|
onOpenConfirm={onOpenConfirm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{openMenu && menuRowId && (
|
{menuRowId && (
|
||||||
<GridRowMenu
|
<GridRowMenu
|
||||||
open={openMenu}
|
open={openMenu}
|
||||||
|
onOpenConfirm={onOpenConfirm}
|
||||||
onClose={handleCloseMenu}
|
onClose={handleCloseMenu}
|
||||||
transformOrigin={{
|
transformOrigin={{
|
||||||
vertical: 'center',
|
vertical: 'center',
|
||||||
|
@ -5,8 +5,10 @@ import { toggleProperty } from './GridRowActions.hooks';
|
|||||||
export function GridRowContextMenu({
|
export function GridRowContextMenu({
|
||||||
containerRef,
|
containerRef,
|
||||||
hoverRowId,
|
hoverRowId,
|
||||||
|
onOpenConfirm,
|
||||||
}: {
|
}: {
|
||||||
hoverRowId?: string;
|
hoverRowId?: string;
|
||||||
|
onOpenConfirm: (onOk: () => Promise<void>, onCancel: () => void) => void;
|
||||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const [position, setPosition] = useState<{ left: number; top: number } | undefined>();
|
const [position, setPosition] = useState<{ left: number; top: number } | undefined>();
|
||||||
@ -23,7 +25,7 @@ export function GridRowContextMenu({
|
|||||||
|
|
||||||
if (!container || !rowId) return;
|
if (!container || !rowId) return;
|
||||||
toggleProperty(container, rowId, false);
|
toggleProperty(container, rowId, false);
|
||||||
setRowId(undefined);
|
// setRowId(undefined);
|
||||||
}, [rowId, containerRef]);
|
}, [rowId, containerRef]);
|
||||||
|
|
||||||
const openContextMenu = useCallback(
|
const openContextMenu = useCallback(
|
||||||
@ -56,8 +58,14 @@ export function GridRowContextMenu({
|
|||||||
};
|
};
|
||||||
}, [containerRef, openContextMenu]);
|
}, [containerRef, openContextMenu]);
|
||||||
|
|
||||||
return isContextMenuOpen && rowId ? (
|
return rowId ? (
|
||||||
<GridRowMenu open={isContextMenuOpen} onClose={closeContextMenu} anchorPosition={position} rowId={rowId} />
|
<GridRowMenu
|
||||||
|
onOpenConfirm={onOpenConfirm}
|
||||||
|
open={isContextMenuOpen}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
anchorPosition={position}
|
||||||
|
rowId={rowId}
|
||||||
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useDraggableGridRow } from './GridRowActions.hooks';
|
import { useDraggableGridRow } from './GridRowActions.hooks';
|
||||||
import { IconButton, Tooltip } from '@mui/material';
|
import { IconButton, Tooltip } from '@mui/material';
|
||||||
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
|
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
|
||||||
@ -9,7 +9,9 @@ export function GridRowDragButton({
|
|||||||
containerRef,
|
containerRef,
|
||||||
onClick,
|
onClick,
|
||||||
getScrollElement,
|
getScrollElement,
|
||||||
|
onOpenConfirm,
|
||||||
}: {
|
}: {
|
||||||
|
onOpenConfirm: (onOk: () => Promise<void>, onCancel: () => void) => void;
|
||||||
rowId: string;
|
rowId: string;
|
||||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
@ -17,19 +19,40 @@ export function GridRowDragButton({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { onDragStart } = useDraggableGridRow(rowId, containerRef, getScrollElement);
|
const [openTooltip, setOpenTooltip] = useState(false);
|
||||||
|
const { onDragStart, isDragging } = useDraggableGridRow(rowId, containerRef, getScrollElement, onOpenConfirm);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
setOpenTooltip(false);
|
||||||
|
}
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip placement='top' title={t('grid.row.dragAndClick')}>
|
<>
|
||||||
<IconButton
|
<Tooltip
|
||||||
onClick={onClick}
|
open={openTooltip}
|
||||||
draggable={true}
|
onOpen={() => {
|
||||||
onDragStart={onDragStart}
|
setOpenTooltip(true);
|
||||||
className='mx-1 cursor-grab active:cursor-grabbing'
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setOpenTooltip(false);
|
||||||
|
}}
|
||||||
|
placement='top'
|
||||||
|
disableInteractive={true}
|
||||||
|
title={t('grid.row.dragAndClick')}
|
||||||
>
|
>
|
||||||
<DragSvg className='-mx-1' />
|
<IconButton
|
||||||
</IconButton>
|
size={'small'}
|
||||||
</Tooltip>
|
onClick={onClick}
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
className='mx-1 h-5 w-5 cursor-grab active:cursor-grabbing'
|
||||||
|
>
|
||||||
|
<DragSvg className='-mx-1' />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { ReactComponent as UpSvg } from '$app/assets/up.svg';
|
import { ReactComponent as UpSvg } from '$app/assets/up.svg';
|
||||||
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
||||||
import { ReactComponent as DelSvg } from '$app/assets/delete.svg';
|
import { ReactComponent as DelSvg } from '$app/assets/delete.svg';
|
||||||
@ -7,22 +7,27 @@ import Popover, { PopoverProps } from '@mui/material/Popover';
|
|||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { rowService } from '$app/application/database';
|
import { rowService } from '$app/application/database';
|
||||||
import { Icon, MenuItem, MenuList } from '@mui/material';
|
|
||||||
import { OrderObjectPositionTypePB } from '@/services/backend';
|
import { OrderObjectPositionTypePB } from '@/services/backend';
|
||||||
|
import KeyboardNavigation, {
|
||||||
|
KeyboardNavigationOption,
|
||||||
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
|
import { useSortsCount } from '$app/components/database';
|
||||||
|
import { deleteAllSorts } from '$app/application/database/sort/sort_service';
|
||||||
|
|
||||||
interface Option {
|
enum RowAction {
|
||||||
label: string;
|
InsertAbove,
|
||||||
icon: JSX.Element;
|
InsertBelow,
|
||||||
onClick: () => void;
|
Duplicate,
|
||||||
divider?: boolean;
|
Delete,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props extends PopoverProps {
|
interface Props extends PopoverProps {
|
||||||
rowId: string;
|
rowId: string;
|
||||||
|
onOpenConfirm?: (onOk: () => Promise<void>, onCancel: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GridRowMenu({ rowId, ...props }: Props) {
|
export function GridRowMenu({ onOpenConfirm, rowId, onClose, ...props }: Props) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
|
const sortsCount = useSortsCount();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -48,56 +53,107 @@ export function GridRowMenu({ rowId, ...props }: Props) {
|
|||||||
void rowService.duplicateRow(viewId, rowId);
|
void rowService.duplicateRow(viewId, rowId);
|
||||||
}, [viewId, rowId]);
|
}, [viewId, rowId]);
|
||||||
|
|
||||||
const options: Option[] = [
|
const renderContent = useCallback((title: string, Icon: React.FC<React.SVGProps<SVGSVGElement>>) => {
|
||||||
{
|
return (
|
||||||
label: t('grid.row.insertRecordAbove'),
|
<div className={'flex w-full items-center gap-1 px-1'}>
|
||||||
icon: <UpSvg />,
|
<Icon className={'h-4 w-4'} />
|
||||||
onClick: handleInsertRecordAbove,
|
<div className={'flex-1'}>{title}</div>
|
||||||
},
|
</div>
|
||||||
{
|
);
|
||||||
label: t('grid.row.insertRecordBelow'),
|
}, []);
|
||||||
icon: <AddSvg />,
|
|
||||||
onClick: handleInsertRecordBelow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('grid.row.duplicate'),
|
|
||||||
icon: <CopySvg />,
|
|
||||||
onClick: handleDuplicateRow,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
const handleAction = useCallback(
|
||||||
label: t('grid.row.delete'),
|
(confirmKey?: RowAction) => {
|
||||||
icon: <DelSvg />,
|
switch (confirmKey) {
|
||||||
onClick: handleDelRow,
|
case RowAction.InsertAbove:
|
||||||
divider: true,
|
handleInsertRecordAbove();
|
||||||
|
break;
|
||||||
|
case RowAction.InsertBelow:
|
||||||
|
handleInsertRecordBelow();
|
||||||
|
break;
|
||||||
|
case RowAction.Duplicate:
|
||||||
|
handleDuplicateRow();
|
||||||
|
break;
|
||||||
|
case RowAction.Delete:
|
||||||
|
handleDelRow();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
];
|
[handleDelRow, handleDuplicateRow, handleInsertRecordAbove, handleInsertRecordBelow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConfirm = useCallback(
|
||||||
|
(key: RowAction) => {
|
||||||
|
if (sortsCount > 0) {
|
||||||
|
onOpenConfirm?.(
|
||||||
|
async () => {
|
||||||
|
await deleteAllSorts(viewId);
|
||||||
|
handleAction(key);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
handleAction(key);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
handleAction(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose?.({}, 'backdropClick');
|
||||||
|
},
|
||||||
|
[handleAction, onClose, onOpenConfirm, sortsCount, viewId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: KeyboardNavigationOption<RowAction>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: RowAction.InsertAbove,
|
||||||
|
content: renderContent(t('grid.row.insertRecordAbove'), UpSvg),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: RowAction.InsertBelow,
|
||||||
|
content: renderContent(t('grid.row.insertRecordBelow'), AddSvg),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: RowAction.Duplicate,
|
||||||
|
content: renderContent(t('grid.row.duplicate'), CopySvg),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 100,
|
||||||
|
content: <hr className={'h-[1px] w-full bg-line-divider opacity-40'} />,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: RowAction.Delete,
|
||||||
|
content: renderContent(t('grid.row.delete'), DelSvg),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[renderContent, t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<>
|
||||||
disableRestoreFocus={true}
|
<Popover
|
||||||
keepMounted={false}
|
disableRestoreFocus={true}
|
||||||
anchorReference={'anchorPosition'}
|
keepMounted={false}
|
||||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
anchorReference={'anchorPosition'}
|
||||||
{...props}
|
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||||
>
|
onClose={onClose}
|
||||||
<MenuList>
|
{...props}
|
||||||
{options.map((option) => (
|
>
|
||||||
<div className={'w-full'} key={option.label}>
|
<div className={'py-2'}>
|
||||||
{option.divider && <div className='mx-2 my-1.5 h-[1px] bg-line-divider' />}
|
<KeyboardNavigation
|
||||||
<MenuItem
|
options={options}
|
||||||
onClick={() => {
|
onConfirm={onConfirm}
|
||||||
option.onClick();
|
onEscape={() => {
|
||||||
props.onClose?.({}, 'backdropClick');
|
onClose?.({}, 'escapeKeyDown');
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Icon className='mr-2'>{option.icon}</Icon>
|
</div>
|
||||||
{option.label}
|
</Popover>
|
||||||
</MenuItem>
|
</>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</MenuList>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,19 +1,29 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { GridChildComponentProps, VariableSizeGrid as Grid } from 'react-window';
|
import { GridChildComponentProps, GridOnScrollProps, VariableSizeGrid as Grid } from 'react-window';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import { useGridColumn } from '$app/components/database/grid/grid_table';
|
import { useGridColumn } from '$app/components/database/grid/grid_table';
|
||||||
import { GridField } from 'src/appflowy_app/components/database/grid/grid_field';
|
import { GridField } from 'src/appflowy_app/components/database/grid/grid_field';
|
||||||
import NewProperty from '$app/components/database/components/property/NewProperty';
|
import NewProperty from '$app/components/database/components/property/NewProperty';
|
||||||
import { GridColumn, GridColumnType } from '$app/components/database/grid/constants';
|
import { GridColumn, GridColumnType, RenderRow } from '$app/components/database/grid/constants';
|
||||||
import { OpenMenuContext } from '$app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks';
|
import { OpenMenuContext } from '$app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks';
|
||||||
|
|
||||||
const GridStickyHeader = React.forwardRef<
|
const GridStickyHeader = React.forwardRef<
|
||||||
Grid<HTMLDivElement> | null,
|
Grid<GridColumn[]> | null,
|
||||||
{ columns: GridColumn[]; getScrollElement?: () => HTMLDivElement | null }
|
{
|
||||||
>(({ columns, getScrollElement }, ref) => {
|
columns: GridColumn[];
|
||||||
|
getScrollElement?: () => HTMLDivElement | null;
|
||||||
|
onScroll?: (props: GridOnScrollProps) => void;
|
||||||
|
}
|
||||||
|
>(({ onScroll, columns, getScrollElement }, ref) => {
|
||||||
const { columnWidth, resizeColumnWidth } = useGridColumn(
|
const { columnWidth, resizeColumnWidth } = useGridColumn(
|
||||||
columns,
|
columns,
|
||||||
ref as React.MutableRefObject<Grid<HTMLDivElement> | null>
|
ref as React.MutableRefObject<Grid<
|
||||||
|
| GridColumn[]
|
||||||
|
| {
|
||||||
|
columns: GridColumn[];
|
||||||
|
renderRows: RenderRow[];
|
||||||
|
}
|
||||||
|
> | null>
|
||||||
);
|
);
|
||||||
|
|
||||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
@ -33,9 +43,10 @@ const GridStickyHeader = React.forwardRef<
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const Cell = useCallback(
|
const Cell = useCallback(
|
||||||
({ columnIndex, style }: GridChildComponentProps) => {
|
({ columnIndex, style, data }: GridChildComponentProps) => {
|
||||||
const column = columns[columnIndex];
|
const column = data[columnIndex];
|
||||||
|
|
||||||
|
if (!column || column.type === GridColumnType.Action) return <div style={style} />;
|
||||||
if (column.type === GridColumnType.NewProperty) {
|
if (column.type === GridColumnType.NewProperty) {
|
||||||
const width = (style.width || 0) as number;
|
const width = (style.width || 0) as number;
|
||||||
|
|
||||||
@ -43,7 +54,7 @@ const GridStickyHeader = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
width: width + 8,
|
width,
|
||||||
}}
|
}}
|
||||||
className={'border-b border-r border-t border-line-divider'}
|
className={'border-b border-r border-t border-line-divider'}
|
||||||
>
|
>
|
||||||
@ -52,10 +63,6 @@ const GridStickyHeader = React.forwardRef<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (column.type === GridColumnType.Action) {
|
|
||||||
return <div style={style} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const field = column.field;
|
const field = column.field;
|
||||||
|
|
||||||
if (!field) return <div style={style} />;
|
if (!field) return <div style={style} />;
|
||||||
@ -72,7 +79,7 @@ const GridStickyHeader = React.forwardRef<
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[columns, handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement]
|
[handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -81,6 +88,7 @@ const GridStickyHeader = React.forwardRef<
|
|||||||
{({ height, width }: { height: number; width: number }) => {
|
{({ height, width }: { height: number; width: number }) => {
|
||||||
return (
|
return (
|
||||||
<Grid
|
<Grid
|
||||||
|
className={'grid-sticky-header w-full text-text-title'}
|
||||||
height={height}
|
height={height}
|
||||||
width={width}
|
width={width}
|
||||||
rowHeight={() => 36}
|
rowHeight={() => 36}
|
||||||
@ -88,7 +96,9 @@ const GridStickyHeader = React.forwardRef<
|
|||||||
columnCount={columns.length}
|
columnCount={columns.length}
|
||||||
columnWidth={columnWidth}
|
columnWidth={columnWidth}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{ overflowX: 'hidden', overscrollBehavior: 'none' }}
|
onScroll={onScroll}
|
||||||
|
itemData={columns}
|
||||||
|
style={{ overscrollBehavior: 'none' }}
|
||||||
>
|
>
|
||||||
{Cell}
|
{Cell}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH, GridColumn } from '$app/components/database/grid/constants';
|
import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH, GridColumn, RenderRow } from '$app/components/database/grid/constants';
|
||||||
import { VariableSizeGrid as Grid } from 'react-window';
|
import { VariableSizeGrid as Grid } from 'react-window';
|
||||||
|
|
||||||
export function useGridRow() {
|
export function useGridRow() {
|
||||||
@ -12,7 +12,16 @@ export function useGridRow() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGridColumn(columns: GridColumn[], ref: React.RefObject<Grid<HTMLDivElement> | null>) {
|
export function useGridColumn(
|
||||||
|
columns: GridColumn[],
|
||||||
|
ref: React.RefObject<Grid<
|
||||||
|
| GridColumn[]
|
||||||
|
| {
|
||||||
|
columns: GridColumn[];
|
||||||
|
renderRows: RenderRow[];
|
||||||
|
}
|
||||||
|
> | null>
|
||||||
|
) {
|
||||||
const [columnWidths, setColumnWidths] = useState<number[]>([]);
|
const [columnWidths, setColumnWidths] = useState<number[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -20,8 +20,16 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
|||||||
const fields = useDatabaseVisibilityFields();
|
const fields = useDatabaseVisibilityFields();
|
||||||
const renderRows = useMemo<RenderRow[]>(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]);
|
const renderRows = useMemo<RenderRow[]>(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]);
|
||||||
const columns = useMemo<GridColumn[]>(() => fieldsToColumns(fields), [fields]);
|
const columns = useMemo<GridColumn[]>(() => fieldsToColumns(fields), [fields]);
|
||||||
const ref = useRef<Grid<HTMLDivElement>>(null);
|
const ref = useRef<
|
||||||
const { columnWidth } = useGridColumn(columns, ref);
|
Grid<{
|
||||||
|
columns: GridColumn[];
|
||||||
|
renderRows: RenderRow[];
|
||||||
|
}>
|
||||||
|
>(null);
|
||||||
|
const { columnWidth } = useGridColumn(
|
||||||
|
columns,
|
||||||
|
ref as React.MutableRefObject<Grid<GridColumn[] | { columns: GridColumn[]; renderRows: RenderRow[] }> | null>
|
||||||
|
);
|
||||||
const { rowHeight } = useGridRow();
|
const { rowHeight } = useGridRow();
|
||||||
const onRendered = useDatabaseRendered();
|
const onRendered = useDatabaseRendered();
|
||||||
|
|
||||||
@ -54,9 +62,9 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const Cell = useCallback(
|
const Cell = useCallback(
|
||||||
({ columnIndex, rowIndex, style }: GridChildComponentProps) => {
|
({ columnIndex, rowIndex, style, data }: GridChildComponentProps) => {
|
||||||
const row = renderRows[rowIndex];
|
const row = data.renderRows[rowIndex];
|
||||||
const column = columns[columnIndex];
|
const column = data.columns[columnIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCell
|
<GridCell
|
||||||
@ -69,10 +77,10 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[columns, getContainerRef, renderRows, onEditRecord]
|
[getContainerRef, onEditRecord]
|
||||||
);
|
);
|
||||||
|
|
||||||
const staticGrid = useRef<Grid<HTMLDivElement> | null>(null);
|
const staticGrid = useRef<Grid<GridColumn[]> | null>(null);
|
||||||
|
|
||||||
const onScroll = useCallback(({ scrollLeft, scrollUpdateWasRequested }: GridOnScrollProps) => {
|
const onScroll = useCallback(({ scrollLeft, scrollUpdateWasRequested }: GridOnScrollProps) => {
|
||||||
if (!scrollUpdateWasRequested) {
|
if (!scrollUpdateWasRequested) {
|
||||||
@ -80,6 +88,10 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onHeaderScroll = useCallback(({ scrollLeft }: GridOnScrollProps) => {
|
||||||
|
ref.current?.scrollTo({ scrollLeft });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollElementRef = useRef<HTMLDivElement | null>(null);
|
const scrollElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@ -95,7 +107,12 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={'h-[36px]'}>
|
<div className={'h-[36px]'}>
|
||||||
<GridStickyHeader ref={staticGrid} getScrollElement={getScrollElement} columns={columns} />
|
<GridStickyHeader
|
||||||
|
ref={staticGrid}
|
||||||
|
onScroll={onHeaderScroll}
|
||||||
|
getScrollElement={getScrollElement}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'flex-1'}>
|
<div className={'flex-1'}>
|
||||||
@ -110,6 +127,10 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
|||||||
rowCount={renderRows.length}
|
rowCount={renderRows.length}
|
||||||
rowHeight={rowHeight}
|
rowHeight={rowHeight}
|
||||||
width={width}
|
width={width}
|
||||||
|
itemData={{
|
||||||
|
columns,
|
||||||
|
renderRows,
|
||||||
|
}}
|
||||||
overscanRowCount={10}
|
overscanRowCount={10}
|
||||||
itemKey={getItemKey}
|
itemKey={getItemKey}
|
||||||
style={{
|
style={{
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Editor } from 'src/appflowy_app/components/editor';
|
import Editor from '$app/components/editor/Editor';
|
||||||
import { DocumentHeader } from 'src/appflowy_app/components/document/document_header';
|
import { DocumentHeader } from 'src/appflowy_app/components/document/document_header';
|
||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
import { updatePageName } from '$app_reducers/pages/async_actions';
|
import { updatePageName } from '$app_reducers/pages/async_actions';
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { EditorProps } from '../../application/document/document.types';
|
import { EditorProps } from '../../application/document/document.types';
|
||||||
|
|
||||||
import { Toaster } from 'react-hot-toast';
|
|
||||||
import { CollaborativeEditor } from '$app/components/editor/components/editor';
|
import { CollaborativeEditor } from '$app/components/editor/components/editor';
|
||||||
import { EditorIdProvider } from '$app/components/editor/Editor.hooks';
|
import { EditorIdProvider } from '$app/components/editor/Editor.hooks';
|
||||||
import './editor.scss';
|
import './editor.scss';
|
||||||
@ -12,7 +11,6 @@ export function Editor(props: EditorProps) {
|
|||||||
<div className={'appflowy-editor relative'}>
|
<div className={'appflowy-editor relative'}>
|
||||||
<EditorIdProvider value={props.id}>
|
<EditorIdProvider value={props.id}>
|
||||||
<CollaborativeEditor {...props} />
|
<CollaborativeEditor {...props} />
|
||||||
<Toaster />
|
|
||||||
</EditorIdProvider>
|
</EditorIdProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Editor, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate';
|
import { Editor, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate';
|
||||||
import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types';
|
import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types';
|
||||||
|
import { isMarkActive } from '$app/components/editor/command/mark';
|
||||||
|
|
||||||
export function insertFormula(editor: ReactEditor, formula?: string) {
|
export function insertFormula(editor: ReactEditor, formula?: string) {
|
||||||
if (editor.selection) {
|
if (editor.selection) {
|
||||||
@ -79,9 +80,5 @@ export function unwrapFormula(editor: ReactEditor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isFormulaActive(editor: ReactEditor) {
|
export function isFormulaActive(editor: ReactEditor) {
|
||||||
const [node] = Editor.nodes(editor, {
|
return isMarkActive(editor, EditorInlineNodeType.Formula);
|
||||||
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorInlineNodeType.Formula,
|
|
||||||
});
|
|
||||||
|
|
||||||
return !!node;
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Editor, Text, Range } from 'slate';
|
import { Editor, Text, Range } from 'slate';
|
||||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
import { EditorInlineNodeType, EditorMarkFormat } from '$app/application/document/document.types';
|
||||||
|
|
||||||
export function toggleMark(
|
export function toggleMark(
|
||||||
editor: ReactEditor,
|
editor: ReactEditor,
|
||||||
@ -25,7 +25,7 @@ export function toggleMark(
|
|||||||
* @param editor
|
* @param editor
|
||||||
* @param format
|
* @param format
|
||||||
*/
|
*/
|
||||||
export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) {
|
export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat | EditorInlineNodeType) {
|
||||||
const selection = editor.selection;
|
const selection = editor.selection;
|
||||||
|
|
||||||
if (!selection) return false;
|
if (!selection) return false;
|
||||||
|
@ -31,6 +31,10 @@ export function tabForward(editor: ReactEditor) {
|
|||||||
|
|
||||||
const [node, path] = match as NodeEntry<Element>;
|
const [node, path] = match as NodeEntry<Element>;
|
||||||
|
|
||||||
|
const hasPrevious = Path.hasPrevious(path);
|
||||||
|
|
||||||
|
if (!hasPrevious) return;
|
||||||
|
|
||||||
const previousPath = Path.previous(path);
|
const previousPath = Path.previous(path);
|
||||||
|
|
||||||
const previous = editor.node(previousPath);
|
const previous = editor.node(previousPath);
|
||||||
@ -40,6 +44,7 @@ export function tabForward(editor: ReactEditor) {
|
|||||||
|
|
||||||
const type = previousNode.type as EditorNodeType;
|
const type = previousNode.type as EditorNodeType;
|
||||||
|
|
||||||
|
if (type === EditorNodeType.Page) return;
|
||||||
// the previous node is not a list
|
// the previous node is not a list
|
||||||
if (!LIST_TYPES.includes(type)) return;
|
if (!LIST_TYPES.includes(type)) return;
|
||||||
|
|
||||||
|
@ -102,11 +102,14 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
|
|||||||
|
|
||||||
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||||
|
|
||||||
|
// placeholder should be hidden when composing
|
||||||
editorDom.addEventListener('compositionstart', handleCompositionStart);
|
editorDom.addEventListener('compositionstart', handleCompositionStart);
|
||||||
editorDom.addEventListener('compositionend', handleCompositionEnd);
|
editorDom.addEventListener('compositionend', handleCompositionEnd);
|
||||||
|
editorDom.addEventListener('compositionupdate', handleCompositionStart);
|
||||||
return () => {
|
return () => {
|
||||||
editorDom.removeEventListener('compositionstart', handleCompositionStart);
|
editorDom.removeEventListener('compositionstart', handleCompositionStart);
|
||||||
editorDom.removeEventListener('compositionend', handleCompositionEnd);
|
editorDom.removeEventListener('compositionend', handleCompositionEnd);
|
||||||
|
editorDom.removeEventListener('compositionupdate', handleCompositionStart);
|
||||||
};
|
};
|
||||||
}, [editor, selected]);
|
}, [editor, selected]);
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user