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:
Kilu.He 2024-02-26 10:19:21 +08:00 committed by GitHub
parent c9dc24a13c
commit e2028ac5a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
130 changed files with 2757 additions and 1247 deletions

View File

@ -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
} }
] ]
} }

View File

@ -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();

View File

@ -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>
); );
} }

View File

@ -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;
} }

View File

@ -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 {

View File

@ -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];
} }
}); });
}; };

View File

@ -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);
}
}; };

View File

@ -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 {

View File

@ -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);
}); });
} }

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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(

View File

@ -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>
)} )}

View File

@ -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;

View File

@ -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);
}} }}

View File

@ -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 }

View File

@ -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

View File

@ -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();

View File

@ -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;

View File

@ -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}

View File

@ -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>
</> </>

View File

@ -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>

View File

@ -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}
> >

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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 />}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);

View File

@ -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 />

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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);

View File

@ -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>
</> </>
); );

View File

@ -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>
); );
} }

View File

@ -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',

View File

@ -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),
}); });
}} }}

View File

@ -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>
</> </>
); );

View File

@ -55,6 +55,7 @@ function EditNumberCellInput({
padding: 0, padding: 0,
}, },
}} }}
spellCheck={false}
autoFocus={true} autoFocus={true}
value={value} value={value}
onInput={handleInput} onInput={handleInput}

View File

@ -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'} />

View File

@ -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>
); );
} }

View File

@ -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'}`} />

View File

@ -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}

View File

@ -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>
); );

View File

@ -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')}
/>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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={{

View File

@ -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'}

View File

@ -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,

View File

@ -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} />;
})} })}

View File

@ -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'} />

View File

@ -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}

View File

@ -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>
); );
} }

View File

@ -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>
)} )}
</> </>

View File

@ -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>
)}
</> </>
); );
} }

View File

@ -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}
/> />

View File

@ -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>
); );
} }

View File

@ -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

View File

@ -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>
); );
} }

View File

@ -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,
});
}} }}
/> />
); );

View File

@ -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>
); );

View File

@ -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>
); );
} }

View File

@ -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'}
/> />

View File

@ -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?.();
}} }}
/> />
</> </>

View File

@ -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>
); );
}; };

View File

@ -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;

View File

@ -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>
)}
</>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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

View File

@ -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>
)}
</>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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 />

View File

@ -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 />}

View File

@ -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>
); );
}; };

View File

@ -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>
);
}
);

View File

@ -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

View File

@ -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': {

View File

@ -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>
); );
} }

View File

@ -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}

View File

@ -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>
); );
} }

View File

@ -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) => {

View File

@ -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={{

View File

@ -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>
); );
} }

View File

@ -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,

View File

@ -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',

View File

@ -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;
} }

View File

@ -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>
</>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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(() => {

View File

@ -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={{

View File

@ -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';

View File

@ -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>
); );

View File

@ -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;
} }

View File

@ -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;

View File

@ -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;

View File

@ -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