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,
"fullscreen": false,
"height": 1200,
"height": 800,
"resizable": true,
"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() {
const dispatch = useAppDispatch();
const { i18n } = useTranslation();
const {
themeMode = ThemeMode.System,
isDark = false,
theme: themeType = ThemeType.Default,
} = useAppSelector((state) => {
return state.currentUser.userSetting || {};
const { themeMode = ThemeMode.System, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
return {
themeMode: state.currentUser.userSetting.themeMode,
theme: state.currentUser.userSetting.theme,
};
});
const isDark =
themeMode === ThemeMode.Dark ||
(themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches);
useEffect(() => {
void (async () => {
const settings = await UserService.getAppearanceSetting();

View File

@ -7,6 +7,7 @@ import { ThemeProvider } from '@mui/material';
import { useUserSetting } from '$app/AppMain.hooks';
import TrashPage from '$app/views/TrashPage';
import DocumentPage from '$app/views/DocumentPage';
import { Toaster } from 'react-hot-toast';
function AppMain() {
const { muiTheme } = useUserSetting();
@ -20,6 +21,7 @@ function AppMain() {
<Route path={'/trash'} id={'trash'} element={<TrashPage />} />
</Route>
</Routes>
<Toaster />
</ThemeProvider>
);
}

View File

@ -132,5 +132,9 @@ export async function updateDateCell(
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);
if (!result.ok) return Promise.reject('Failed to get database');
return result
.map((value) => {
return {

View File

@ -23,6 +23,7 @@ const updateFiltersFromChange = (database: Database, changeset: FilterChangesetN
const newFilter = pbToFilter(pb.filter);
Object.assign(found, newFilter);
database.filters = [...database.filters];
}
});
};

View File

@ -2,6 +2,7 @@ import { ReorderAllRowsPB, ReorderSingleRowPB, RowsChangePB, RowsVisibilityChang
import { Database } from '../database';
import { pbToRowMeta, RowMeta } from './row_types';
import { didDeleteCells } from '$app/application/database/cell/cell_listeners';
import { getDatabase } from '$app/application/database/database/database_service';
const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => {
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) => {
changeset.updated_rows.forEach(({ row_id: rowId, row_meta: rowMetaPB }) => {
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);
insertRowsFromChangeset(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;
database.rowMetas.forEach((rowMeta) => {
if (invisible_rows.includes(rowMeta.id)) {
let reFetchRows = false;
for (const rowId of invisible_rows) {
const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === rowId);
if (rowMeta) {
rowMeta.isHidden = true;
}
const found = visible_rows.find((visibleRow) => visibleRow.row_meta.id === rowMeta.id);
if (found) {
rowMeta.isHidden = false;
}
});
for (const insertedRow of visible_rows) {
const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === insertedRow.row_meta.id);
if (rowMeta) {
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;
onTitleChange?: (title: string) => void;
showTitle?: boolean;
disableFocus?: boolean;
}
export enum EditorNodeType {

View File

@ -23,6 +23,7 @@ import {
RepeatedTrashPB,
ChildViewUpdatePB,
} from '@/services/backend';
import { AsyncQueue } from '$app/utils/async_queue';
const Notification = {
[DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB,
@ -56,7 +57,9 @@ type NullableInstanceType<K extends (abstract new (...args: any) => any) | null>
any
? InstanceType<K>
: 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.
@ -105,8 +108,7 @@ export function subscribeNotifications(
},
options?: { id?: string }
): Promise<() => void> {
return listen<ReturnType<typeof SubscribeObject.prototype.toObject>>('af-notification', (event) => {
const subject = SubscribeObject.fromObject(event.payload);
const handler = async (subject: SubscribeObject) => {
const { id, ty } = subject;
if (options?.id !== undefined && id !== options.id) {
@ -127,8 +129,20 @@ export function subscribeNotifications(
} else {
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 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 { useTranslation } from 'react-i18next';
import { Log } from '$app/utils/log';
interface Props {
interface Props extends DialogProps {
open: boolean;
title: string;
subtitle: string;
onOk: () => Promise<void>;
subtitle?: string;
onOk?: () => Promise<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 onDone = useCallback(async () => {
try {
await onOk();
await onOk?.();
onClose();
} catch (e) {
Log.error(e);
@ -27,6 +31,7 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
return (
<Dialog
container={container}
keepMounted={false}
onKeyDown={(e) => {
if (e.key === 'Escape') {
@ -44,20 +49,26 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
onMouseDown={(e) => e.stopPropagation()}
open={open}
onClose={onClose}
{...props}
>
<DialogContent className={'flex w-[340px] flex-col items-center justify-center gap-4'}>
<div className={'text-md font-medium'}>{title}</div>
{subtitle && <div className={'m-1 text-sm text-text-caption'}>{subtitle}</div>}
<DialogContent className={'w-[320px]'}>
{title}
<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>
<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>
);
}

View File

@ -7,9 +7,10 @@ import EmojiPickerCategories from './EmojiPickerCategories';
interface Props {
onEmojiSelect: (emoji: string) => 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);
return (
@ -21,7 +22,12 @@ function EmojiPicker({ onEscape, ...props }: Props) {
searchValue={searchValue}
onSearchChange={setSearchValue}
/>
<EmojiPickerCategories onEscape={onEscape} onEmojiSelect={onSelect} emojiCategories={emojiCategories} />
<EmojiPickerCategories
defaultEmoji={defaultEmoji}
onEscape={onEscape}
onEmojiSelect={onSelect}
emojiCategories={emojiCategories}
/>
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import {
EMOJI_SIZE,
EmojiCategory,
@ -14,10 +14,12 @@ function EmojiPickerCategories({
emojiCategories,
onEmojiSelect,
onEscape,
defaultEmoji,
}: {
emojiCategories: EmojiCategory[];
onEmojiSelect: (emoji: string) => void;
onEscape?: () => void;
defaultEmoji?: string;
}) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
@ -28,6 +30,8 @@ function EmojiPickerCategories({
const rows = useMemo(() => {
return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
}, [emojiCategories]);
const mouseY = useRef<number | null>(null);
const mouseX = useRef<number | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
@ -75,6 +79,8 @@ function EmojiPickerCategories({
{item.emojis?.map((emoji, columnIndex) => {
const isSelected = selectCell.row === index && selectCell.column === columnIndex;
const isDefaultEmoji = defaultEmoji === emoji.native;
return (
<div
key={emoji.id}
@ -86,9 +92,24 @@ function EmojiPickerCategories({
onClick={() => {
onEmojiSelect(emoji.native);
}}
className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-active ${
isSelected ? 'bg-fill-list-hover' : ''
}`}
onMouseMove={(e) => {
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}
</div>
@ -98,7 +119,7 @@ function EmojiPickerCategories({
</div>
);
},
[getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row]
[defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row]
);
const getNewColumnIndex = useCallback(

View File

@ -48,6 +48,7 @@ export interface KeyboardNavigationProps<T> {
defaultFocusedKey?: T;
onFocus?: () => void;
onBlur?: () => void;
itemClassName?: string;
}
function KeyboardNavigation<T>({
@ -65,6 +66,7 @@ function KeyboardNavigation<T>({
disableSelect = false,
onBlur,
onFocus,
itemClassName,
}: KeyboardNavigationProps<T>) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
@ -197,7 +199,7 @@ function KeyboardNavigation<T>({
const renderOption = useCallback(
(option: KeyboardNavigationOption<T>, index: number) => {
const hasChildren = option.children && option.children.length > 0;
const hasChildren = option.children;
const isFocused = focusedKey === option.key;
@ -216,6 +218,7 @@ function KeyboardNavigation<T>({
mouseY.current = e.clientY;
}}
onMouseEnter={(e) => {
onFocus?.();
if (mouseY.current === null || mouseY.current !== e.clientY) {
setFocusedKey(option.key);
}
@ -231,7 +234,7 @@ function KeyboardNavigation<T>({
selected={isFocused}
className={`ml-0 flex w-full items-center justify-start rounded-none px-2 py-1 text-xs ${
!isFocused ? 'hover:bg-transparent' : ''
}`}
} ${itemClassName ?? ''}`}
>
{option.content}
</MenuItem>
@ -243,7 +246,7 @@ function KeyboardNavigation<T>({
</div>
);
},
[focusedKey, onConfirm]
[itemClassName, focusedKey, onConfirm, onFocus]
);
useEffect(() => {
@ -290,7 +293,7 @@ function KeyboardNavigation<T>({
{options.length > 0 ? (
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')}
</Typography>
)}

View File

@ -84,7 +84,9 @@ const usePopoverAutoPosition = ({
initialPaperHeight,
marginThreshold = 16,
open,
}: UsePopoverAutoPositionProps): PopoverPosition => {
}: UsePopoverAutoPositionProps): PopoverPosition & {
calculateAnchorSize: () => void;
} => {
const [position, setPosition] = useState<PopoverPosition>({
anchorOrigin: initialAnchorOrigin,
transformOrigin: initialTransformOrigin,
@ -94,7 +96,11 @@ const usePopoverAutoPosition = ({
isEntered: false,
});
const getAnchorOffset = useCallback(() => {
const calculateAnchorSize = useCallback(() => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const getAnchorOffset = () => {
if (anchorPosition) {
return {
...anchorPosition,
@ -103,15 +109,8 @@ const usePopoverAutoPosition = ({
}
return anchorEl ? anchorEl.getBoundingClientRect() : undefined;
}, [anchorEl, anchorPosition]);
};
useEffect(() => {
if (!open) {
return;
}
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const anchorRect = getAnchorOffset();
if (!anchorRect) return;
@ -190,17 +189,24 @@ const usePopoverAutoPosition = ({
// Set new position and set isEntered to true
setPosition({ ...newPosition, isEntered: true });
}, [
anchorPosition,
open,
initialAnchorOrigin,
initialTransformOrigin,
initialPaperWidth,
initialPaperHeight,
marginThreshold,
getAnchorOffset,
anchorEl,
anchorPosition,
]);
return position;
useEffect(() => {
if (!open) return;
calculateAnchorSize();
}, [open, calculateAnchorSize]);
return {
...position,
calculateAnchorSize,
};
};
export default usePopoverAutoPosition;

View File

@ -44,6 +44,7 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon
onClose={() => setAnchorPosition(undefined)}
>
<EmojiPicker
defaultEmoji={icon.value}
onEscape={() => {
setAnchorPosition(undefined);
}}

View File

@ -52,8 +52,6 @@ export const DatabaseProvider = DatabaseContext.Provider;
export const useDatabase = () => useSnapshot(useContext(DatabaseContext));
export const useContextDatabase = () => useContext(DatabaseContext);
export const useSelectorCell = (rowId: string, fieldId: string) => {
const database = useContext(DatabaseContext);
const cells = useSnapshot(database.cells);
@ -86,10 +84,16 @@ export const useDispatchCell = () => {
};
};
export const useTypeOptions = () => {
export const useDatabaseSorts = () => {
const context = useContext(DatabaseContext);
return useSnapshot(context.typeOptions);
return useSnapshot(context.sorts);
};
export const useSortsCount = () => {
const { sorts } = useDatabase();
return sorts?.length;
};
export const useFiltersCount = () => {
@ -154,8 +158,8 @@ export const useConnectDatabase = (viewId: string) => {
[DatabaseNotification.DidUpdateFieldSettings]: (changeset) => {
fieldListeners.didUpdateFieldSettings(database, changeset);
},
[DatabaseNotification.DidUpdateViewRows]: (changeset) => {
rowListeners.didUpdateViewRows(database, changeset);
[DatabaseNotification.DidUpdateViewRows]: async (changeset) => {
await rowListeners.didUpdateViewRows(viewId, database, changeset);
},
[DatabaseNotification.DidReorderRows]: (changeset) => {
rowListeners.didReorderRows(database, changeset);
@ -171,8 +175,8 @@ export const useConnectDatabase = (viewId: string) => {
[DatabaseNotification.DidUpdateFilter]: (changeset) => {
filterListeners.didUpdateFilter(database, changeset);
},
[DatabaseNotification.DidUpdateViewRowsVisibility]: (changeset) => {
rowListeners.didUpdateViewRowsVisibility(database, changeset);
[DatabaseNotification.DidUpdateViewRowsVisibility]: async (changeset) => {
await rowListeners.didUpdateViewRowsVisibility(viewId, database, changeset);
},
},
{ id: viewId }

View File

@ -25,6 +25,7 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
const innerRef = useRef<HTMLDivElement>();
const databaseRef = (ref ?? innerRef) as React.MutableRefObject<HTMLDivElement>;
const viewId = useViewId();
const [settingDom, setSettingDom] = useState<HTMLDivElement | null>(null);
const [page, setPage] = useState<Page | null>(null);
const { t } = useTranslation();
@ -161,12 +162,16 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
}
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
pageId={viewId}
setSelectedViewId={setSelectedViewId}
selectedViewId={selectedViewId}
childViews={childViews}
ref={setSettingDom}
/>
<SwipeableViews
slideStyle={{
@ -181,13 +186,14 @@ export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, set
<DatabaseLoader viewId={view.id}>
{selectedViewId === view.id && (
<>
<Portal container={databaseRef.current}>
<div className={'absolute right-16 top-0 py-1'}>
{settingDom && (
<Portal container={settingDom}>
<DatabaseSettings
onToggleCollection={(forceOpen?: boolean) => onToggleCollection(view.id, forceOpen)}
/>
</div>
</Portal>
)}
<DatabaseCollection open={openCollections.includes(view.id)} />
{editRecordRowId && (
<ExpandRecordModal

View File

@ -6,6 +6,7 @@ import { updatePageName } from '$app_reducers/pages/async_actions';
export const DatabaseTitle = () => {
const viewId = useViewId();
const pageName = useAppSelector((state) => state.pages.pageMap[viewId]?.name || '');
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 { 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 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 {
field: ChecklistField;
cell: ChecklistCellType;
placeholder?: string;
}
function ChecklistCell({ cell }: Props) {
const value = cell?.data.percentage ?? 0;
const initialAnchorOrigin: PopoverOrigin = {
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 open = Boolean(anchorEl);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
@ -21,27 +35,32 @@ function ChecklistCell({ cell }: Props) {
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 (
<>
<div className='flex w-full cursor-pointer items-center px-2' onClick={handleClick}>
<Typography variant='body2' color='text.secondary'>
{result}
</Typography>
{options.length > 0 ? (
<LinearProgressWithLabel value={value} count={options.length} selectedCount={selectedOptions.length} />
) : (
<div className={'text-sm text-text-placeholder'}>{placeholder}</div>
)}
</div>
<Suspense>
{open && (
<ChecklistCellActions
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={open}
transformOrigin={transformOrigin}
anchorOrigin={anchorOrigin}
maxHeight={paperHeight}
maxWidth={paperWidth}
open={open && isEntered}
anchorEl={anchorEl}
onClose={handleClose}
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 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 {
field: DateTimeField;
cell: DateTimeCellType;
placeholder?: string;
}
const initialAnchorOrigin: PopoverOrigin = {
vertical: 'bottom',
horizontal: 'left',
};
const initialTransformOrigin: PopoverOrigin = {
vertical: 'top',
horizontal: 'left',
};
function DateTimeCell({ field, cell, placeholder }: Props) {
const isRange = cell.data.isRange;
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>;
}, [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 (
<>
<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}
</div>
<Suspense>
{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>
</>

View File

@ -1,9 +1,21 @@
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 { Tag } from '../field_types/select/Tag';
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(
() => import('$app/components/database/components/field_types/select/select_cell_actions/SelectCellActions')
);
@ -11,14 +23,6 @@ const menuProps: Partial<MenuProps> = {
classes: {
list: 'py-5',
},
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
};
export const SelectCell: FC<{
@ -43,6 +47,15 @@ export const SelectCell: FC<{
[typeOption]
);
const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({
initialPaperWidth: 369,
initialPaperHeight: 400,
anchorEl,
initialAnchorOrigin,
initialTransformOrigin,
open,
});
return (
<div className={'relative w-full'}>
<div
@ -59,17 +72,35 @@ export const SelectCell: FC<{
</div>
<Suspense>
{open ? (
<Menu
<Popover
keepMounted={false}
disableRestoreFocus={true}
className='h-full w-full'
open={open}
open={open && isEntered}
anchorEl={anchorEl}
{...menuProps}
transformOrigin={transformOrigin}
anchorOrigin={anchorOrigin}
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} />
</Menu>
<SelectCellActions onClose={handleClose} field={field} cell={cell} />
</Popover>
) : null}
</Suspense>
</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 { Field, UrlCell as URLCellType } from '$app/application/database';
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 pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
interface Props {
field: Field;
cell: URLCellType;
@ -14,7 +13,6 @@ interface Props {
}
function UrlCell({ field, cell, placeholder }: Props) {
const [isUrl, setIsUrl] = useState(false);
const cellRef = useRef<HTMLDivElement>(null);
const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell);
const handleClick = useCallback(() => {
@ -33,33 +31,26 @@ function UrlCell({ field, cell, placeholder }: Props) {
[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 str = cell.data.content;
if (str) {
if (isUrl) {
return (
<a href={str} target={'_blank'} className={'cursor-pointer text-content-blue-400 underline'}>
<a
onClick={(e) => {
e.stopPropagation();
openUrl(str);
}}
target={'_blank'}
className={'cursor-pointer text-content-blue-400 underline'}
>
{str}
</a>
);
}
return str;
}
return <div className={'text-sm text-text-placeholder'}>{placeholder}</div>;
}, [isUrl, cell, placeholder]);
return <div className={'cursor-text text-sm text-text-placeholder'}>{placeholder}</div>;
}, [cell, placeholder]);
return (
<>
@ -68,6 +59,7 @@ function UrlCell({ field, cell, placeholder }: Props) {
width: `${field.width}px`,
minHeight: 37,
}}
className={'cursor-text'}
ref={cellRef}
onClick={handleClick}
>

View File

@ -8,7 +8,7 @@ interface Props {
export const DatabaseCollection = ({ open }: Props) => {
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 />
<Filters />
</div>

View File

@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { Stack } from '@mui/material';
import { TextButton } from '$app/components/database/components/tab_bar/TextButton';
import { useTranslation } from 'react-i18next';
@ -16,7 +15,7 @@ function DatabaseSettings(props: Props) {
const [settingAnchorEl, setSettingAnchorEl] = useState<null | HTMLElement>(null);
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} />
<SortSettings {...props} />
<TextButton color='inherit' onClick={(e) => setSettingAnchorEl(e.currentTarget)}>
@ -27,7 +26,7 @@ function DatabaseSettings(props: Props) {
anchorEl={settingAnchorEl}
onClose={() => setSettingAnchorEl(null)}
/>
</Stack>
</div>
);
}

View File

@ -17,6 +17,7 @@ function Properties({ onItemClick }: PropertiesProps) {
const { fields } = useDatabase();
const [state, setState] = useState<FieldType[]>(fields as FieldType[]);
const viewId = useViewId();
const [menuPropertyId, setMenuPropertyId] = useState<string | undefined>();
useEffect(() => {
setState(fields as FieldType[]);
@ -60,7 +61,12 @@ function Properties({ onItemClick }: PropertiesProps) {
<MenuItem
ref={provided.innerRef}
{...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}
>
<IconButton
@ -71,13 +77,22 @@ function Properties({ onItemClick }: PropertiesProps) {
<DragSvg />
</IconButton>
<div className={'w-[100px] overflow-hidden text-ellipsis'}>
<Property field={field} />
<Property
onCloseMenu={() => {
setMenuPropertyId(undefined);
}}
menuOpened={menuPropertyId === field.id}
field={field}
/>
</div>
<IconButton
disabled={field.isPrimary}
size={'small'}
onClick={() => onItemClick(field)}
onClick={(e) => {
e.stopPropagation();
onItemClick(field);
}}
className={'ml-2'}
>
{field.visibility !== FieldVisibility.AlwaysHidden ? <EyeOpen /> : <EyeClosed />}

View File

@ -1,16 +1,18 @@
import React, { useState } from 'react';
import { Menu, MenuItem, MenuProps, Popover } from '@mui/material';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Menu, MenuProps, Popover } from '@mui/material';
import { useTranslation } from 'react-i18next';
import Properties from '$app/components/database/components/database_settings/Properties';
import { Field } from '$app/application/database';
import { FieldVisibility } from '@/services/backend';
import { updateFieldSetting } from '$app/application/database/field/field_service';
import { useViewId } from '$app/hooks';
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
type SettingsMenuProps = MenuProps;
function SettingsMenu(props: SettingsMenuProps) {
const viewId = useViewId();
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [propertiesAnchorElPosition, setPropertiesAnchorElPosition] = useState<
| undefined
@ -36,25 +38,39 @@ function SettingsMenu(props: SettingsMenuProps) {
});
};
return (
<>
<Menu {...props} disableRestoreFocus={true}>
<MenuItem
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
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 (
<>
<Menu {...props} ref={ref} disableRestoreFocus={true}>
<KeyboardNavigation
onConfirm={onConfirm}
onEscape={() => {
props.onClose?.({}, 'escapeKeyDown');
}}
>
{t('grid.settings.properties')}
</MenuItem>
options={options}
/>
</Menu>
<Popover
disableRestoreFocus={true}
keepMounted={false}
open={openProperties}
onClose={() => {
setPropertiesAnchorElPosition(undefined);
@ -65,6 +81,13 @@ function SettingsMenu(props: SettingsMenuProps) {
vertical: 'top',
horizontal: 'right',
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
props.onClose?.({}, 'escapeKeyDown');
}
}}
>
<Properties onItemClick={togglePropertyVisibility} />
</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',
}}
>
<DialogContent className={'appflowy-scroll-container relative p-0'}>
<EditRecord rowId={rowId} />
</DialogContent>
<IconButton
aria-label='close'
className={'absolute right-[8px] top-[8px] text-text-caption'}
@ -34,14 +37,14 @@ function ExpandRecordModal({ open, onClose, rowId }: Props) {
>
<DetailsIcon />
</IconButton>
<DialogContent className={'relative p-0'}>
<EditRecord rowId={rowId} />
</DialogContent>
</Dialog>
<RecordActions
anchorEl={detailAnchorEl}
rowId={rowId}
open={!!detailAnchorEl}
onEscape={() => {
onClose?.({}, 'escapeKeyDown');
}}
onClose={() => setDetailAnchorEl(null)}
/>
</Portal>

View File

@ -9,19 +9,22 @@ import MenuItem from '@mui/material/MenuItem';
interface Props extends MenuProps {
rowId: string;
onEscape?: () => void;
onClose?: () => void;
}
function RecordActions({ anchorEl, open, onClose, rowId }: Props) {
function RecordActions({ anchorEl, open, onEscape, onClose, rowId }: Props) {
const viewId = useViewId();
const { t } = useTranslation();
const handleDelRow = useCallback(() => {
void rowService.deleteRow(viewId, rowId);
}, [viewId, rowId]);
onEscape?.();
}, [viewId, rowId, onEscape]);
const handleDuplicateRow = useCallback(() => {
void rowService.duplicateRow(viewId, rowId);
}, [viewId, rowId]);
onEscape?.();
}, [viewId, rowId, onEscape]);
const menuOptions = [
{
@ -46,6 +49,7 @@ function RecordActions({ anchorEl, open, onClose, rowId }: Props) {
onClick={() => {
option.onClick();
onClose?.();
onEscape?.();
}}
>
<Icon className='mr-2'>{option.icon}</Icon>

View File

@ -6,7 +6,7 @@ interface Props {
}
function RecordDocument({ documentId }: Props) {
return <Editor id={documentId} showTitle={false} />;
return <Editor disableFocus={true} id={documentId} showTitle={false} />;
}
export default React.memo(RecordDocument);

View File

@ -28,7 +28,7 @@ function RecordHeader({ page, row }: Props) {
}, []);
return (
<div ref={ref} className={'px-16 pb-4'}>
<div ref={ref} className={'px-16 py-4'}>
<RecordTitle page={page} row={row} />
<RecordProperties row={row} />
<Divider />

View File

@ -1,11 +1,10 @@
import React, { useState } from 'react';
import { updateChecklistCell } from '$app/application/database/cell/cell_service';
import { useViewId } from '$app/hooks';
import { ReactComponent as AddIcon } from '$app/assets/add.svg';
import { IconButton } from '@mui/material';
import { Button } from '@mui/material';
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 [value, setValue] = useState('');
const viewId = useViewId();
@ -17,23 +16,35 @@ function AddNewOption({ rowId, fieldId }: { rowId: string; fieldId: string }) {
};
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
placeholder={t('grid.checklist.addNew')}
className={'flex-1'}
className={'flex-1 px-2'}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
void createOption();
return;
}
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
onClose();
return;
}
}}
value={value}
spellCheck={false}
onChange={(e) => {
setValue(e.target.value);
}}
/>
<IconButton size={'small'} disabled={!value} onClick={createOption}>
<AddIcon />
</IconButton>
<Button variant={'contained'} className={'text-xs'} size={'small'} disabled={!value} onClick={createOption}>
{t('grid.selectOption.create')}
</Button>
</div>
);
}

View File

@ -1,31 +1,66 @@
import React from 'react';
import React, { useState } from 'react';
import Popover, { PopoverProps } from '@mui/material/Popover';
import { LinearProgressWithLabel } from '$app/components/database/components/field_types/checklist/LinearProgressWithLabel';
import { Divider } from '@mui/material';
import { ChecklistCell as ChecklistCellType } from '$app/application/database';
import ChecklistItem from '$app/components/database/components/field_types/checklist/ChecklistItem';
import AddNewOption from '$app/components/database/components/field_types/checklist/AddNewOption';
import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel';
function ChecklistCellActions({
cell,
maxHeight,
maxWidth,
...props
}: PopoverProps & {
cell: ChecklistCellType;
maxWidth?: number;
maxHeight?: number;
}) {
const { fieldId, rowId } = cell;
const { percentage, selectedOptions = [], options } = cell.data;
const { percentage, selectedOptions = [], options = [] } = cell.data;
const [hoverId, setHoverId] = useState<string | null>(null);
return (
<Popover disableRestoreFocus={true} {...props}>
<LinearProgressWithLabel className={'m-4'} value={percentage || 0} />
<div className={'p-1'}>
<Popover
{...props}
disableRestoreFocus={true}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
props.onClose?.({}, 'escapeKeyDown');
}
}}
>
<div
style={{
maxHeight: maxHeight,
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}
/>
);
@ -33,7 +68,11 @@ function ChecklistCellActions({
</div>
<Divider />
<AddNewOption fieldId={fieldId} rowId={rowId} />
</>
)}
<AddNewOption onClose={() => props.onClose?.({}, 'escapeKeyDown')} fieldId={fieldId} rowId={rowId} />
</div>
</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 { Checkbox, IconButton } from '@mui/material';
import { IconButton } from '@mui/material';
import { updateChecklistCell } from '$app/application/database/cell/cell_service';
import { useViewId } from '$app/hooks';
import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg';
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.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({
checked,
option,
rowId,
fieldId,
onClose,
isHovered,
onMouseEnter,
}: {
checked: boolean;
option: SelectOption;
rowId: string;
fieldId: string;
onClose: () => void;
isHovered: boolean;
onMouseEnter: () => void;
}) {
const [hover, setHover] = useState(false);
const { t } = useTranslation();
const [value, setValue] = useState(option.name);
const viewId = useViewId();
const updateText = async () => {
const updateText = useCallback(async () => {
await updateChecklistCell(viewId, rowId, fieldId, {
updateOptions: [
{
@ -30,50 +41,76 @@ function ChecklistItem({
},
],
});
};
}, [fieldId, option, rowId, value, viewId]);
const onCheckedChange = async () => {
void updateChecklistCell(viewId, rowId, fieldId, {
const onCheckedChange = useMemo(() => {
return debounce(
() =>
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, {
deleteOptionIds: [option.id],
});
};
}, [fieldId, option.id, rowId, viewId]);
return (
<div
onMouseEnter={() => {
setHover(true);
}}
onMouseLeave={() => {
setHover(false);
}}
className={`flex items-center justify-between gap-2 rounded p-1 text-sm ${hover ? 'bg-fill-list-hover' : ''}`}
onMouseEnter={onMouseEnter}
className={`flex items-center justify-between gap-2 rounded p-1 text-sm hover:bg-fill-list-active`}
>
<Checkbox
onClick={onCheckedChange}
checked={checked}
disableRipple
style={{ padding: 4 }}
icon={<CheckboxUncheckSvg />}
checkedIcon={<CheckboxCheckSvg />}
/>
<div className={'cursor-pointer select-none text-content-blue-400'} onClick={onCheckedChange}>
{checked ? <CheckboxCheckSvg className={'h-5 w-5'} /> : <CheckboxUncheckSvg className={'h-5 w-5'} />}
</div>
<input
className={'flex-1'}
className={'flex-1 truncate'}
onBlur={updateText}
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) => {
setValue(e.target.value);
}}
/>
<IconButton size={'small'} className={`mx-2 ${hover ? 'visible' : 'invisible'}`} onClick={deleteOption}>
<div className={'w-10'}>
<IconButton
size={'small'}
style={{
display: isHovered ? 'block' : 'none',
}}
className={`z-10 mx-2`}
onClick={deleteOption}
>
<DeleteIcon />
</IconButton>
</div>
</div>
);
}

View File

@ -14,18 +14,25 @@ function CustomCalendar({
}: {
handleChange: (params: { date?: number; endDate?: number }) => void;
isRange: boolean;
timestamp: number;
endTimestamp: number;
timestamp?: number;
endTimestamp?: number;
}) {
const [startDate, setStartDate] = useState<Date | null>(new Date(timestamp * 1000));
const [endDate, setEndDate] = useState<Date | null>(new Date(endTimestamp * 1000));
const [startDate, setStartDate] = useState<Date | null>(() => {
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(() => {
if (!isRange) return;
if (!isRange || !endTimestamp) return;
setEndDate(new Date(endTimestamp * 1000));
}, [isRange, endTimestamp]);
useEffect(() => {
if (!timestamp) return;
setStartDate(new Date(timestamp * 1000));
}, [timestamp]);
@ -33,7 +40,7 @@ function CustomCalendar({
<div className={'flex w-full items-center justify-center'}>
<DatePicker
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) => {
return (
@ -56,8 +63,30 @@ function CustomCalendar({
selected={startDate}
onChange={(dates) => {
if (!dates) return;
if (isRange) {
const [start, end] = dates as [Date | null, Date | null];
if (isRange && Array.isArray(dates)) {
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);
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 { MenuItem, Menu } from '@mui/material';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
import { DateFormatPB } from '@/services/backend';
import KeyboardNavigation, {
KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
interface Props {
value: DateFormatPB;
@ -15,16 +18,43 @@ function DateFormat({ value, onChange }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLLIElement>(null);
const dateFormatMap = useMemo(
() => ({
[DateFormatPB.Friendly]: t('grid.field.dateFormatFriendly'),
[DateFormatPB.ISO]: t('grid.field.dateFormatISO'),
[DateFormatPB.US]: t('grid.field.dateFormatUS'),
[DateFormatPB.Local]: t('grid.field.dateFormatLocal'),
[DateFormatPB.DayMonthYear]: t('grid.field.dateFormatDayMonthYear'),
}),
[t]
const renderOptionContent = useCallback(
(option: DateFormatPB, title: string) => {
return (
<div className={'flex w-full items-center justify-between gap-2'}>
<div className={'flex-1'}>{title}</div>
{value === option && <SelectCheckSvg />}
</div>
);
},
[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) => {
onChange(option);
@ -42,7 +72,6 @@ function DateFormat({ value, onChange }: Props) {
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
@ -51,20 +80,14 @@ function DateFormat({ value, onChange }: Props) {
anchorEl={ref.current}
onClose={() => setOpen(false)}
>
{Object.keys(dateFormatMap).map((option) => {
const optionValue = Number(option) as DateFormatPB;
return (
<MenuItem
className={'min-w-[180px] justify-between'}
key={optionValue}
onClick={() => handleClick(optionValue)}
>
{dateFormatMap[optionValue]}
{value === optionValue && <SelectCheckSvg />}
</MenuItem>
);
})}
<KeyboardNavigation
onEscape={() => {
setOpen(false);
}}
disableFocus={true}
options={options}
onConfirm={handleClick}
/>
</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 { useTypeOption } from '$app/components/database';
import { getDateFormat, getTimeFormat } from '$app/components/database/components/field_types/date/utils';
import { notify } from '$app/components/_shared/notify';
function DateTimeCellActions({
cell,
field,
maxWidth,
maxHeight,
...props
}: PopoverProps & {
field: DateTimeField;
cell: DateTimeCell;
maxWidth?: number;
maxHeight?: number;
}) {
const typeOption = useTypeOption<DateTimeTypeOption>(field.id);
@ -34,10 +39,10 @@ function DateTimeCellActions({
const { includeTime } = cell.data;
const timestamp = useMemo(() => cell.data.timestamp || dayjs().unix(), [cell.data.timestamp]);
const endTimestamp = useMemo(() => cell.data.endTimestamp || dayjs().unix(), [cell.data.endTimestamp]);
const time = useMemo(() => cell.data.time || dayjs().format(timeFormat), [cell.data.time, timeFormat]);
const endTime = useMemo(() => cell.data.endTime || dayjs().format(timeFormat), [cell.data.endTime, timeFormat]);
const timestamp = useMemo(() => cell.data.timestamp || undefined, [cell.data.timestamp]);
const endTimestamp = useMemo(() => cell.data.endTimestamp || undefined, [cell.data.endTimestamp]);
const time = useMemo(() => cell.data.time || undefined, [cell.data.time]);
const endTime = useMemo(() => cell.data.endTime || undefined, [cell.data.endTime]);
const viewId = useViewId();
const { t } = useTranslation();
@ -55,7 +60,7 @@ function DateTimeCellActions({
try {
const isRange = params.isRange ?? cell.data.isRange;
await updateDateCell(viewId, cell.rowId, cell.fieldId, {
const data = {
date: params.date ?? timestamp,
endDate: isRange ? params.endDate ?? endTimestamp : undefined,
time: params.time ?? time,
@ -63,9 +68,30 @@ function DateTimeCellActions({
includeTime: params.includeTime ?? includeTime,
isRange,
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) {
// toast.error(e.message);
notify.error(String(e));
}
},
[cell, endTime, endTimestamp, includeTime, time, timestamp, viewId]
@ -75,20 +101,26 @@ function DateTimeCellActions({
return (
<Popover
disableRestoreFocus={true}
keepMounted={false}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
disableRestoreFocus={true}
{...props}
PaperProps={{
...props.PaperProps,
className: 'pt-4 transform transition-all',
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
props.onClose?.({}, 'escapeKeyDown');
}
}}
>
<div
style={{
maxWidth: maxWidth,
maxHeight: maxHeight,
}}
>
<DateTimeSet
date={timestamp}
@ -102,7 +134,12 @@ function DateTimeCellActions({
includeTime={includeTime}
/>
<CustomCalendar isRange={isRange} timestamp={timestamp} endTimestamp={endTimestamp} handleChange={handleChange} />
<CustomCalendar
isRange={isRange}
timestamp={timestamp}
endTimestamp={endTimestamp}
handleChange={handleChange}
/>
<Divider className={'my-0'} />
<div className={'flex flex-col gap-1 px-4 py-2'}>
@ -118,6 +155,7 @@ function DateTimeCellActions({
checked={isRange}
/>
<IncludeTimeSwitch
disabled={!timestamp}
onIncludeTimeChange={(val) => {
void handleChange({
includeTime: val,
@ -137,6 +175,10 @@ function DateTimeCellActions({
<MenuItem
className={'text-xs font-medium'}
onClick={async () => {
await handleChange({
isRange: false,
includeTime: false,
});
await handleChange({
clearFlag: true,
});
@ -147,6 +189,7 @@ function DateTimeCellActions({
{t('grid.field.clearDate')}
</MenuItem>
</MenuList>
</div>
</Popover>
);
}

View File

@ -23,7 +23,6 @@ function DateTimeFormatSelect({ field }: Props) {
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
@ -33,7 +32,15 @@ function DateTimeFormatSelect({ field }: Props) {
horizontal: 'left',
}}
open={open}
autoFocus={true}
anchorEl={ref.current}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
setOpen(false);
}
}}
onClose={() => setOpen(false)}
MenuListProps={{
className: 'px-2',

View File

@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { DateField, TimeField } from '@mui/x-date-pickers-pro';
import dayjs from 'dayjs';
import { Divider } from '@mui/material';
import debounce from 'lodash-es/debounce';
interface Props {
onChange: (params: { date?: number; time?: string }) => void;
@ -23,13 +24,17 @@ const sx = {
function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) {
const date = useMemo(() => {
return dayjs.unix(props.date || dayjs().unix());
return props.date ? dayjs.unix(props.date) : undefined;
}, [props.date]);
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]);
const debounceOnChange = useMemo(() => {
return debounce(props.onChange, 500);
}, [props.onChange]);
return (
<div
className={
@ -40,7 +45,7 @@ function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props)
value={date}
onChange={(date) => {
if (!date) return;
props.onChange({
debounceOnChange({
date: date.unix(),
});
}}
@ -63,7 +68,7 @@ function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props)
}}
onChange={(time) => {
if (!time) return;
props.onChange({
debounceOnChange({
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 { useTranslation } from 'react-i18next';
import { Menu, MenuItem } from '@mui/material';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
import KeyboardNavigation, {
KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
interface Props {
value: TimeFormatPB;
@ -13,13 +16,31 @@ function TimeFormat({ value, onChange }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLLIElement>(null);
const timeFormatMap = useMemo(
() => ({
[TimeFormatPB.TwelveHour]: t('grid.field.timeFormatTwelveHour'),
[TimeFormatPB.TwentyFourHour]: t('grid.field.timeFormatTwentyFourHour'),
}),
[t]
const renderOptionContent = useCallback(
(option: TimeFormatPB, title: string) => {
return (
<div className={'flex w-full items-center justify-between gap-2'}>
<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) => {
onChange(option);
@ -37,7 +58,6 @@ function TimeFormat({ value, onChange }: Props) {
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
@ -46,20 +66,14 @@ function TimeFormat({ value, onChange }: Props) {
anchorEl={ref.current}
onClose={() => setOpen(false)}
>
{Object.keys(timeFormatMap).map((option) => {
const optionValue = Number(option) as TimeFormatPB;
return (
<MenuItem
className={'min-w-[120px] justify-between'}
key={optionValue}
onClick={() => handleClick(optionValue)}
>
{timeFormatMap[optionValue]}
{value === optionValue && <SelectCheckSvg />}
</MenuItem>
);
})}
<KeyboardNavigation
onEscape={() => {
setOpen(false);
}}
disableFocus={true}
options={options}
onConfirm={handleClick}
/>
</Menu>
</>
);

View File

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

View File

@ -22,8 +22,8 @@ function NumberFieldActions({ field }: { field: NumberField }) {
return (
<>
<div className={'flex flex-col pr-3 pt-1'}>
<div className={'mb-2 px-5 text-sm text-text-caption'}>{t('grid.field.format')}</div>
<div className={'flex flex-col pt-1'}>
<div className={'mb-2 px-4 text-sm text-text-caption'}>{t('grid.field.format')}</div>
<NumberFormatSelect value={typeOption.format || NumberFormatPB.Num} onChange={onChange} />
</div>
<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 { Menu, MenuItem, MenuProps } from '@mui/material';
import { formats } from '$app/components/database/components/field_types/number/const';
import { Menu, MenuProps } from '@mui/material';
import { formats, formatText } from '$app/components/database/components/field_types/number/const';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
import KeyboardNavigation, {
KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
function NumberFormatMenu({
value,
@ -12,21 +15,48 @@ function NumberFormatMenu({
value: NumberFormatPB;
onChangeFormat: (value: NumberFormatPB) => void;
}) {
return (
<Menu {...props} disableRestoreFocus={true}>
{formats.map((format) => (
<MenuItem
onClick={() => {
onChangeFormat(format.value as NumberFormatPB);
const scrollRef = useRef<HTMLDivElement | null>(null);
const onConfirm = useCallback(
(format: NumberFormatPB) => {
onChangeFormat(format);
props.onClose?.({}, 'backdropClick');
}}
className={'flex justify-between text-xs font-medium'}
key={format.value}
>
<div className={'flex-1'}>{format.key}</div>
{value === format.value && <SelectCheckSvg />}
</MenuItem>
))}
},
[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 (
<Menu {...props}>
<div ref={scrollRef} className={'max-h-[360px] overflow-y-auto overflow-x-hidden'}>
<KeyboardNavigation
defaultFocusedKey={value}
scrollRef={scrollRef}
options={options}
onConfirm={onConfirm}
disableFocus={true}
onEscape={() => props.onClose?.({}, 'escapeKeyDown')}
/>
</div>
</Menu>
);
}

View File

@ -16,7 +16,7 @@ function NumberFormatSelect({ value, onChange }: { value: NumberFormatPB; onChan
onClick={() => {
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>
<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 { Divider, ListSubheader, MenuItem, MenuList, MenuProps, OutlinedInput } from '@mui/material';
import { SelectOptionColorPB } from '@/services/backend';
@ -13,6 +13,7 @@ import {
} from '$app/application/database/field/select_option/select_option_service';
import { useViewId } from '$app/hooks';
import Popover from '@mui/material/Popover';
import debounce from 'lodash-es/debounce';
interface SelectOptionMenuProps {
fieldId: string;
@ -32,9 +33,10 @@ const Colors = [
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 viewId = useViewId();
const inputRef = useRef<HTMLInputElement>(null);
const updateColor = async (color: SelectOptionColorPB) => {
await insertOrUpdateSelectOption(viewId, fieldId, [
{
@ -44,15 +46,18 @@ export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, M
]);
};
const updateName = async () => {
const updateName = useMemo(() => {
return debounce(async (tagName) => {
if (tagName === option.name) return;
await insertOrUpdateSelectOption(viewId, fieldId, [
{
...option,
name: tagName,
},
]);
};
}, 500);
}, [option, viewId, fieldId]);
const onClose = () => {
menuProps.onClose?.({}, 'backdropClick');
@ -79,18 +84,28 @@ export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, M
}}
{...menuProps}
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'>
<OutlinedInput
inputRef={inputRef}
value={tagName}
onChange={(e) => {
setTagName(e.target.value);
void updateName(e.target.value);
}}
onBlur={updateName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void updateName();
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
void updateName(tagName);
onClose();
}
}}
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'}>
{Colors.map((color) => (
<MenuItem
onClick={() => {
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
void updateColor(color);
}}
key={color}

View File

@ -9,7 +9,7 @@ export interface CreateOptionProps {
export const CreateOption: FC<CreateOptionProps> = ({ label, onClick }) => {
return (
<MenuItem className='mt-2' onClick={onClick}>
<MenuItem className='px-2' onClick={onClick}>
<Tag className='ml-2' size='small' label={label} />
</MenuItem>
);

View File

@ -1,15 +1,17 @@
import React, { FormEvent, useCallback } from 'react';
import { ListSubheader, OutlinedInput } from '@mui/material';
import { OutlinedInput } from '@mui/material';
import { t } from 'i18next';
function SearchInput({
setNewOptionName,
newOptionName,
onEnter,
onEscape,
}: {
newOptionName: string;
setNewOptionName: (value: string) => void;
onEnter: () => void;
onEscape?: () => void;
}) {
const handleInput = useCallback(
(event: FormEvent) => {
@ -21,20 +23,26 @@ function SearchInput({
);
return (
<ListSubheader className='flex'>
<OutlinedInput
size='small'
className={'mx-4'}
autoFocus={true}
value={newOptionName}
onInput={handleInput}
spellCheck={false}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onEnter();
}
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
onEscape?.();
}
}}
placeholder={t('grid.selectOption.searchOrCreateOption')}
/>
</ListSubheader>
);
}

View File

@ -17,10 +17,12 @@ function SelectCellActions({
field,
cell,
onUpdated,
onClose,
}: {
field: SelectField;
cell: SelectCellType;
onUpdated?: () => void;
onClose?: () => void;
}) {
const rowId = cell?.rowId;
const viewId = useViewId();
@ -117,15 +119,22 @@ function SelectCellActions({
}, [field.type, filteredOptions, handleNewTagClick, shouldCreateOption, updateCell]);
return (
<div className={'text-base'}>
<SearchInput setNewOptionName={setNewOptionName} newOptionName={newOptionName} onEnter={handleEnter} />
<div className={'flex h-full flex-col overflow-hidden'}>
<SearchInput
onEscape={onClose}
setNewOptionName={setNewOptionName}
newOptionName={newOptionName}
onEnter={handleEnter}
/>
<div className='mx-4 mb-2 mt-4 text-xs'>
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
</div>
<div className={'mx-1 flex-1 overflow-y-auto overflow-x-hidden'}>
{shouldCreateOption ? (
<CreateOption label={newOptionName} onClick={handleNewTagClick} />
) : (
<div className={'max-h-[300px] overflow-y-auto overflow-x-hidden px-2'}>
<div className={' px-2'}>
{filteredOptions.map((option) => (
<MenuItem className={'px-2'} key={option.id} value={option.id}>
<SelectOptionItem
@ -141,6 +150,7 @@ function SelectCellActions({
</div>
)}
</div>
</div>
);
}

View File

@ -2,7 +2,7 @@ import { FC, MouseEventHandler, useCallback, useRef, useState } from 'react';
import { IconButton } from '@mui/material';
import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
import { SelectOption } from '$app/application/database';
import { SelectOptionMenu } from '../SelectOptionMenu';
import { SelectOptionModifyMenu } from '../SelectOptionModifyMenu';
import { Tag } from '../Tag';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
@ -42,7 +42,7 @@ export const SelectOptionItem: FC<SelectOptionItemProps> = ({ onClick, isSelecte
)}
</div>
{open && (
<SelectOptionMenu
<SelectOptionModifyMenu
fieldId={fieldId}
option={option}
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 { useTranslation } from 'react-i18next';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
@ -8,8 +8,10 @@ import {
insertOrUpdateSelectOption,
} from '$app/application/database/field/select_option/select_option_service';
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 { t } = useTranslation();
const [edit, setEdit] = useState(false);
@ -19,7 +21,17 @@ function AddAnOption({ fieldId }: { fieldId: string }) {
setEdit(false);
};
const isOptionExist = useMemo(() => {
return options.some((option) => option.name === newOptionName);
}, [options, newOptionName]);
const createOption = async () => {
if (!newOptionName) return;
if (isOptionExist) {
notify.error(t('grid.field.optionAlreadyExist'));
return;
}
const option = await createSelectOption(viewId, fieldId, newOptionName);
if (!option) return;
@ -31,13 +43,23 @@ function AddAnOption({ fieldId }: { fieldId: string }) {
<OutlinedInput
onBlur={exitEdit}
autoFocus={true}
spellCheck={false}
onChange={(e) => {
setNewOptionName(e.target.value);
}}
value={newOptionName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
void createOption();
return;
}
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
exitEdit();
}
}}
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 { 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 { 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>
</Button>
<SelectOptionMenu
<SelectOptionModifyMenu
fieldId={fieldId}
MenuProps={{
anchorEl: ref.current,

View File

@ -8,7 +8,7 @@ interface Props {
}
function Options({ options, fieldId }: Props) {
return (
<div className={'max-h-[300px] overflow-y-auto overflow-x-hidden'}>
<div>
{options.map((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={'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} />
</div>
<Divider className={'my-2'} />

View File

@ -22,9 +22,9 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
return (
<Popover
disableRestoreFocus={true}
open={editing}
anchorEl={anchorEl}
disableRestoreFocus={true}
PaperProps={{
className: 'flex p-2 border border-blue-400',
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}
onClose={onClose}
keepMounted={false}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
onClose();
}
}}
>
<TextareaAutosize
className='w-full resize-none whitespace-break-spaces break-all text-sm'
autoFocus
spellCheck={false}
autoCorrect='off'
value={text}
onInput={onInput}

View File

@ -1,39 +1,77 @@
import React from 'react';
import Select from '@mui/material/Select';
import { FormControl, MenuItem, SelectProps } from '@mui/material';
import React, { useCallback, useMemo, useState } from 'react';
import KeyboardNavigation, {
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({
conditions,
...props
}: SelectProps & {
value,
onChange,
}: {
conditions: {
value: number;
text: string;
}[];
value: number;
onChange: (condition: number) => void;
}) {
return (
<FormControl size={'small'} variant={'outlined'}>
<Select
{...props}
sx={{
'& .MuiSelect-select': {
padding: 0,
fontSize: '12px',
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);
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent !important',
},
}}
>
{conditions.map((condition) => {
return (
<MenuItem key={condition.value} value={condition.value}>
{condition.text}
</MenuItem>
[onChange]
);
})}
</Select>
</FormControl>
const valueText = useMemo(() => {
return conditions.find((condition) => condition.value === value)?.text;
}, [conditions, value]);
const open = Boolean(anchorEl);
return (
<div>
<div
onClick={(e) => {
setAnchorEl(e.currentTarget);
}}
className={'flex cursor-pointer select-none items-center gap-2 py-2 text-xs'}
>
<div className={'flex-1'}>{valueText}</div>
<MoreSvg className={`h-4 w-4 transform ${open ? 'rotate-90' : ''}`} />
</div>
<Popover
open={open}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
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;
field: FieldData;
onChange: (data: UndeterminedFilter['data']) => void;
onClose?: () => void;
}
type FilterComponent = FC<FilterComponentProps>;
@ -110,16 +111,15 @@ function Filter({ filter, field }: Props) {
clickable
variant='outlined'
label={
<div className={'flex items-center justify-center'}>
<div className={'flex items-center justify-center gap-1'}>
<Property field={field} />
<DropDownSvg className={'ml-1.5 h-8 w-8'} />
<DropDownSvg className={'h-6 w-6'} />
</div>
}
onClick={handleClick}
/>
{condition !== undefined && open && (
<Popover
disableRestoreFocus={true}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
@ -132,6 +132,13 @@ function Filter({ filter, field }: Props) {
anchorEl={anchorEl}
onClose={handleClose}
keepMounted={false}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
handleClose();
}
}}
>
<div className={'flex items-center justify-between'}>
<FilterConditionSelect
@ -146,7 +153,7 @@ function Filter({ filter, field }: Props) {
/>
<FilterActions filter={filter} />
</div>
{Component && <Component filter={filter} field={field} onChange={onDataChange} />}
{Component && <Component onClose={handleClose} filter={filter} field={field} onChange={onDataChange} />}
</Popover>
)}
</>

View File

@ -1,17 +1,22 @@
import React, { useState } from 'react';
import { IconButton, Menu, MenuItem } from '@mui/material';
import React, { useMemo, useState } from 'react';
import { IconButton, Menu } from '@mui/material';
import { ReactComponent as MoreSvg } from '$app/assets/details.svg';
import { Filter } from '$app/application/database';
import { useTranslation } from 'react-i18next';
import { deleteFilter } from '$app/application/database/filter/filter_service';
import { useViewId } from '$app/hooks';
import KeyboardNavigation, {
KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
function FilterActions({ filter }: { filter: Filter }) {
const viewId = useViewId();
const { t } = useTranslation();
const [disableSelect, setDisableSelect] = useState(true);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const onClose = () => {
setDisableSelect(true);
setAnchorEl(null);
};
@ -21,8 +26,20 @@ function FilterActions({ filter }: { filter: Filter }) {
} catch (e) {
// toast.error(e.message);
}
setDisableSelect(true);
};
const options: KeyboardNavigationOption[] = useMemo(
() => [
{
key: 'delete',
content: t('grid.settings.deleteFilter'),
},
],
[t]
);
return (
<>
<IconButton
@ -33,9 +50,33 @@ function FilterActions({ filter }: { filter: Filter }) {
>
<MoreSvg />
</IconButton>
<Menu disableRestoreFocus={true} keepMounted={false} open={open} anchorEl={anchorEl} onClose={onClose}>
<MenuItem onClick={onDelete}>{t('grid.settings.deleteFilter')}</MenuItem>
{open && (
<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]);
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>
<ConditionSelect
conditions={conditions}
onChange={(e) => {
const value = Number(e.target.value);
onChange(value);
onChange(e);
}}
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 PropertiesList from '$app/components/database/components/property/PropertiesList';
import { Field } from '$app/application/database';
@ -18,7 +18,7 @@ function FilterFieldsMenu({
const { t } = useTranslation();
const addFilter = useCallback(
async (event: MouseEvent, field: Field) => {
async (field: Field) => {
const filterData = getDefaultFilter(field.type);
await insertFilter({
@ -34,8 +34,24 @@ function FilterFieldsMenu({
);
return (
<Popover disableRestoreFocus={true} {...props}>
<PropertiesList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
<Popover
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>
);
}

View File

@ -29,9 +29,9 @@ function Filters() {
};
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))}
<Button onClick={handleClick} color={'inherit'} startIcon={<AddSvg />}>
<Button size={'small'} onClick={handleClick} color={'inherit'} startIcon={<AddSvg />}>
{t('grid.settings.addFilter')}
</Button>
<FilterFieldsMenu

View File

@ -1,26 +1,44 @@
import React, { useMemo } from 'react';
import React, { useMemo, useRef } from 'react';
import {
SelectField,
SelectFilter as SelectFilterType,
SelectFilterData,
SelectTypeOption,
} from '$app/application/database';
import { MenuItem, MenuList } from '@mui/material';
import { Tag } from '$app/components/database/components/field_types/select/Tag';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
import { SelectOptionConditionPB } from '@/services/backend';
import { useTypeOption } from '$app/components/database';
import KeyboardNavigation, {
KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
interface Props {
filter: SelectFilterType;
field: SelectField;
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 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 =
options.length > 0 &&
@ -65,22 +83,9 @@ function SelectFilter({ filter, field, onChange }: Props) {
if (!showOptions) return null;
return (
<MenuList>
{options?.map((option) => {
const isSelected = filter.data.optionIds?.includes(option.id);
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>
<div ref={scrollRef}>
<KeyboardNavigation onEscape={onClose} scrollRef={scrollRef} options={options} onConfirm={handleSelectOption} />
</div>
);
}

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 { TextField } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { TextFilterConditionPB } from '@/services/backend';
import debounce from 'lodash-es/debounce';
interface Props {
filter: TextFilterType;
onChange: (filterData: TextFilterData) => void;
}
const DELAY = 500;
function TextFilter({ filter, onChange }: Props) {
const { t } = useTranslation();
const [content, setContext] = useState(filter.data.content);
@ -15,21 +19,29 @@ function TextFilter({ filter, onChange }: Props) {
const showField =
condition !== TextFilterConditionPB.TextIsEmpty && condition !== TextFilterConditionPB.TextIsNotEmpty;
const onConditionChange = useMemo(() => {
return debounce((content: string) => {
onChange({
content,
condition,
});
}, DELAY);
}, [condition, onChange]);
if (!showField) return null;
return (
<TextField
className={'p-2'}
spellCheck={false}
className={'p-2 pt-0'}
inputProps={{
className: 'text-xs p-1.5',
}}
size={'small'}
value={content}
placeholder={t('grid.settings.typeAValue')}
onChange={(e) => {
setContext(e.target.value);
}}
onBlur={() => {
onChange({
content,
condition,
});
onConditionChange(e.target.value ?? '');
}}
/>
);

View File

@ -27,7 +27,12 @@ function NewProperty({ onInserted }: NewPropertyProps) {
}, [onInserted, viewId]);
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')}
</Button>
);

View File

@ -1,16 +1,18 @@
import React, { useCallback, useMemo, useState } from 'react';
import { OutlinedInput, MenuItem, MenuList } from '@mui/material';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { OutlinedInput } from '@mui/material';
import { Property } from '$app/components/database/components/property/Property';
import { Field as FieldType } from '$app/application/database';
import { useDatabase } from '$app/components/database';
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
interface FieldListProps {
searchPlaceholder?: string;
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 [fieldsResult, setFieldsResult] = useState<FieldType[]>(fields as FieldType[]);
@ -24,38 +26,65 @@ function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldLis
[fields]
);
const inputRef = useRef<HTMLInputElement>(null);
const searchInput = useMemo(() => {
return showSearch ? (
<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>
) : null;
}, [onInputChange, searchPlaceholder, showSearch]);
const emptyList = useMemo(() => {
return fieldsResult.length === 0 ? (
<div className={'px-4 pt-3 text-center text-sm font-medium text-gray-500'}>No fields found</div>
) : null;
const scrollRef = useRef<HTMLDivElement>(null);
const options = useMemo(() => {
return fieldsResult.map((field) => {
return {
key: field.id,
content: (
<div className={'truncate'}>
<Property field={field} />
</div>
),
};
});
}, [fieldsResult]);
const onConfirm = useCallback(
(key: string) => {
const field = fields.find((field) => field.id === key);
onItemClick?.(field as FieldType);
},
[fields, onItemClick]
);
return (
<div className={'pt-2'}>
{searchInput}
{emptyList}
<MenuList className={'max-h-[300px] overflow-y-auto overflow-x-hidden px-2'}>
{fieldsResult.map((field) => (
<MenuItem
className={'overflow-hidden text-ellipsis px-1'}
key={field.id}
value={field.id}
onClick={(event) => {
onItemClick?.(event, field);
}}
>
<Property field={field} />
</MenuItem>
))}
</MenuList>
<div ref={scrollRef} className={'my-2 max-h-[300px] overflow-y-auto overflow-x-hidden'}>
<KeyboardNavigation
disableFocus={true}
scrollRef={scrollRef}
focusRef={inputRef}
options={options}
onConfirm={onConfirm}
onEscape={onClose}
/>
</div>
</div>
);
}

View File

@ -2,6 +2,8 @@ import { FC, useEffect, useRef, useState } from 'react';
import { Field as FieldType } from '$app/application/database';
import { ProppertyTypeSvg } from './property_type/ProppertyTypeSvg';
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 {
field: FieldType;
@ -10,17 +12,28 @@ export interface FieldProps {
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 }) => {
const ref = useRef<HTMLDivElement | null>(null);
const [anchorPosition, setAnchorPosition] = useState<
| {
top: number;
left: number;
height: number;
}
| undefined
>(undefined);
const open = Boolean(anchorPosition) && menuOpened;
const open = Boolean(anchorPosition && menuOpened);
useEffect(() => {
if (menuOpened) {
@ -28,8 +41,9 @@ export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) =>
if (rect) {
setAnchorPosition({
top: rect.top + rect.height,
top: rect.top + 28,
left: rect.left,
height: rect.height,
});
return;
}
@ -38,6 +52,15 @@ export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) =>
setAnchorPosition(undefined);
}, [menuOpened]);
const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({
initialPaperWidth: 369,
initialPaperHeight: 400,
anchorPosition,
initialAnchorOrigin,
initialTransformOrigin,
open,
});
return (
<>
<div ref={ref} className='flex w-full items-center px-2'>
@ -48,10 +71,20 @@ export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) =>
{open && (
<PropertyMenu
field={field}
open={open}
open={open && isEntered}
onClose={() => {
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}
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 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 DeleteSvg } from '$app/assets/delete.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 { fieldService } from '$app/application/database';
import { OrderObjectPositionTypePB, FieldVisibility } from '@/services/backend';
import { MenuItem } from '@mui/material';
import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog';
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 {
EditProperty,
Hide,
Show,
Duplicate,
Delete,
InsertLeft,
@ -25,6 +31,7 @@ export enum FieldAction {
const FieldActionSvgMap = {
[FieldAction.EditProperty]: EditSvg,
[FieldAction.Hide]: HideSvg,
[FieldAction.Show]: ShowSvg,
[FieldAction.Duplicate]: CopySvg,
[FieldAction.Delete]: DeleteSvg,
[FieldAction.InsertLeft]: LeftSvg,
@ -47,18 +54,28 @@ interface PropertyActionsProps {
fieldId: string;
actions?: FieldAction[];
isPrimary?: boolean;
inputRef?: RefObject<HTMLElement>;
onClose?: () => 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 { t } = useTranslation();
const [openConfirm, setOpenConfirm] = useState(false);
const [focusMenu, setFocusMenu] = useState<boolean>(false);
const menuTextMap = useMemo(
() => ({
[FieldAction.EditProperty]: t('grid.field.editProperty'),
[FieldAction.Hide]: t('grid.field.hide'),
[FieldAction.Show]: t('grid.field.show'),
[FieldAction.Duplicate]: t('grid.field.duplicate'),
[FieldAction.Delete]: t('grid.field.delete'),
[FieldAction.InsertLeft]: t('grid.field.insertLeft'),
@ -101,6 +118,11 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul
visibility: FieldVisibility.AlwaysHidden,
});
break;
case FieldAction.Show:
await fieldService.updateFieldSetting(viewId, fieldId, {
visibility: FieldVisibility.AlwaysShown,
});
break;
case FieldAction.Duplicate:
await fieldService.duplicateField(viewId, fieldId);
break;
@ -112,19 +134,124 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul
onMenuItemClick?.(action);
};
return (
<>
{actions.map((action) => {
const ActionSvg = FieldActionSvgMap[action];
const disabled = isPrimary && primaryPreventDefaultActions.includes(action);
const renderActionContent = useCallback((item: { text: string; Icon: React.FC<React.SVGProps<SVGSVGElement>> }) => {
const { Icon, text } = item;
return (
<MenuItem disabled={disabled} onClick={() => handleMenuItemClick(action)} key={action} dense>
<ActionSvg className='mr-2 text-base' />
{menuTextMap[action]}
</MenuItem>
<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 (
<>
<KeyboardNavigation
disableFocus={!focusMenu}
disableSelect={!focusMenu}
onEscape={onClose}
focusRef={inputRef}
options={options}
onFocus={() => {
setFocusMenu(true);
}}
onBlur={() => {
setFocusMenu(false);
}}
onKeyDown={handleKeyDown}
onConfirm={handleMenuItemClick}
/>
<DeleteConfirmDialog
open={openConfirm}
subtitle={''}
@ -134,7 +261,7 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul
}}
onClose={() => {
setOpenConfirm(false);
onMenuItemClick?.(FieldAction.Delete);
onClose?.();
}}
/>
</>

View File

@ -1,25 +1,35 @@
import { Divider, MenuList } from '@mui/material';
import { FC, useCallback } from 'react';
import { Divider } from '@mui/material';
import { FC, useCallback, useMemo, useRef } from 'react';
import { useViewId } from '$app/hooks';
import { Field, fieldService } from '$app/application/database';
import PropertyTypeMenuExtension from '$app/components/database/components/property/property_type/PropertyTypeMenuExtension';
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 Popover, { PopoverProps } from '@mui/material/Popover';
import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput';
import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions';
const actions = [FieldAction.Hide, FieldAction.Duplicate, FieldAction.Delete];
export interface GridFieldMenuProps extends PopoverProps {
field: Field;
}
export const PropertyMenu: FC<GridFieldMenuProps> = ({ field, ...props }) => {
const viewId = useViewId();
const inputRef = useRef<HTMLInputElement>(null);
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(
async (type: FieldType) => {
@ -35,30 +45,37 @@ export const PropertyMenu: FC<GridFieldMenuProps> = ({ field, ...props }) => {
return (
<Popover
disableRestoreFocus={true}
transformOrigin={{
vertical: -10,
horizontal: 'left',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
onClick={(e) => e.stopPropagation()}
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}
>
<PropertyNameInput id={field.id} name={field.name} />
<MenuList>
<div>
<PropertyNameInput ref={inputRef} id={field.id} name={field.name} />
<div className={'flex-1 overflow-y-auto overflow-x-hidden py-1'}>
{!isPrimary && (
<>
<div className={'pt-2'}>
<PropertyTypeSelect field={field} onUpdateFieldType={onUpdateFieldType} />
<Divider className={'my-2'} />
</>
</div>
)}
<PropertyTypeMenuExtension field={field} />
<PropertyActions
inputRef={inputRef}
onClose={() => props.onClose?.({}, 'backdropClick')}
isPrimary={isPrimary}
actions={actions}
onMenuItemClick={() => {
@ -67,7 +84,6 @@ export const PropertyMenu: FC<GridFieldMenuProps> = ({ field, ...props }) => {
fieldId={field.id}
/>
</div>
</MenuList>
</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 { fieldService } from '$app/application/database';
import { Log } from '$app/utils/log';
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 [inputtingName, setInputtingName] = useState(name);
const handleInput = useCallback<ChangeEventHandler<HTMLInputElement>>((e) => {
setInputtingName(e.target.value);
}, []);
const handleSubmit = useCallback(async () => {
if (inputtingName !== name) {
const handleSubmit = useCallback(
async (newName: string) => {
if (newName !== name) {
try {
await fieldService.updateField(viewId, id, {
name: inputtingName,
name: newName,
});
} catch (e) {
// TODO
Log.error(`change field ${id} name from '${name}' to ${inputtingName} fail`, e);
Log.error(`change field ${id} name from '${name}' to ${newName} 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 (
<TextField
className='mx-3 mt-3 rounded-[10px]'
size='small'
inputRef={ref}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
void handleSubmit();
}
}}
value={inputtingName}
onChange={handleInput}
onBlur={handleSubmit}
/>
);
}
});
export default PropertyNameInput;

View File

@ -1,44 +1,84 @@
import { MenuItem, Select, SelectChangeEvent, SelectProps } from '@mui/material';
import { FC, useCallback } from 'react';
import { FC, useCallback, useMemo, useRef, useState } from 'react';
import { Field as FieldType } from '$app/application/database';
import { useDatabase } from '../../Database.hooks';
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;
value?: string;
}
export const PropertySelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
export const PropertySelect: FC<FieldSelectProps> = ({ value, onChange }) => {
const { fields } = useDatabase();
const handleChange = useCallback(
(event: SelectChangeEvent<unknown>) => {
const selectedId = event.target.value;
const scrollRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLDivElement>(null);
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]
);
const selectedField = useMemo(() => fields.find((field) => field.id === value), [fields, value]);
return (
<Select
onChange={handleChange}
{...props}
sx={{
'& .MuiInputBase-input': {
display: 'flex',
alignItems: 'center',
},
<>
<div
ref={ref}
style={{
borderColor: open ? 'var(--fill-default)' : undefined,
}}
MenuProps={{
className: 'max-w-[150px]',
className={
'flex w-[150px] cursor-pointer items-center justify-between gap-2 rounded border border-line-border p-2 hover:border-text-title'
}
onClick={() => {
setOpen(true);
}}
>
{fields.map((field) => (
<MenuItem className={'overflow-hidden text-ellipsis px-1.5'} key={field.id} value={field.id}>
<Property field={field} />
</MenuItem>
))}
</Select>
<div className={'flex-1'}>{selectedField ? <Property field={selectedField} /> : null}</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 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 { FC, useMemo } from 'react';
import { Menu, MenuProps } from '@mui/material';
import { FC, useCallback, useMemo } from 'react';
import { FieldType } from '@/services/backend';
import { PropertyTypeText, ProppertyTypeSvg } from '$app/components/database/components/property';
import { Field } from '$app/application/database';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
const FieldTypeGroup = [
{
name: 'Basic',
types: [
FieldType.RichText,
FieldType.Number,
FieldType.SingleSelect,
FieldType.MultiSelect,
FieldType.DateTime,
FieldType.Checkbox,
FieldType.Checklist,
FieldType.URL,
],
},
{
name: 'Advanced',
types: [FieldType.LastEditedTime, FieldType.CreatedTime],
},
];
import KeyboardNavigation, {
KeyboardNavigationOption,
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
import Typography from '@mui/material/Typography';
export const PropertyTypeMenu: FC<
MenuProps & {
@ -39,23 +23,99 @@ export const PropertyTypeMenu: FC<
[props.PopoverClasses]
);
const renderGroupContent = useCallback((title: string) => {
return (
<Menu {...props} disableRestoreFocus={true} PopoverClasses={PopoverClasses}>
{FieldTypeGroup.map((group, index) => [
<MenuItem key={group.name} dense disabled>
{group.name}
</MenuItem>,
group.types.map((type) => (
<MenuItem onClick={() => onClickItem?.(type)} key={type} dense className={'flex justify-between'}>
<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 />}
</MenuItem>
)),
index < FieldTypeGroup.length - 1 && <Divider key={`Divider-${group.name}`} />,
])}
</>
);
},
[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 (
<Menu {...props} PopoverClasses={PopoverClasses}>
<KeyboardNavigation
onEscape={() => props?.onClose?.({}, 'escapeKeyDown')}
options={options}
disableFocus={true}
onConfirm={onClickItem}
/>
</Menu>
);
};

View File

@ -16,19 +16,21 @@ function PropertyTypeSelect({ field, onUpdateFieldType }: Props) {
const ref = useRef<HTMLLIElement>(null);
return (
<div className={'px-1'}>
<div>
<MenuItem
ref={ref}
onClick={() => {
setExpanded(!expanded);
}}
className={'px-23 mx-0'}
className={'mx-0 rounded-none px-0'}
>
<div className={'flex w-full items-center px-3'}>
<ProppertyTypeSvg type={field.type} className='mr-2 text-base' />
<span className='flex-1 text-xs font-medium'>
<PropertyTypeText type={field.type} />
</span>
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
</div>
</MenuItem>
{expanded && (
<PropertyTypeMenu

View File

@ -1,13 +1,77 @@
import { t } from 'i18next';
import { FC } from 'react';
import { MenuItem, Select, SelectProps } from '@mui/material';
import { FC, useMemo, useRef, useState } from 'react';
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 (
<Select {...props}>
<MenuItem value={SortConditionPB.Ascending}>{t('grid.sort.ascending')}</MenuItem>
<MenuItem value={SortConditionPB.Descending}>{t('grid.sort.descending')}</MenuItem>
</Select>
<>
<div
ref={ref}
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 PropertiesList from '$app/components/database/components/property/PropertiesList';
import { Field, sortService } from '$app/application/database';
@ -15,7 +15,7 @@ const SortFieldsMenu: FC<
const { t } = useTranslation();
const viewId = useViewId();
const addSort = useCallback(
async (event: MouseEvent, field: Field) => {
async (field: Field) => {
await sortService.insertSort(viewId, {
fieldId: field.id,
condition: SortConditionPB.Ascending,
@ -27,8 +27,25 @@ const SortFieldsMenu: FC<
);
return (
<Popover disableRestoreFocus={true} keepMounted={false} {...props}>
<PropertiesList showSearch={true} onItemClick={addSort} searchPlaceholder={t('grid.settings.sortBy')} />
<Popover
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>
);
};

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 { ReactComponent as CloseSvg } from '$app/assets/close.svg';
import { Field, Sort, sortService } from '$app/application/database';
@ -28,10 +28,10 @@ export const SortItem: FC<SortItemProps> = ({ className, sort }) => {
);
const handleConditionChange = useCallback(
(event: SelectChangeEvent<SortConditionPB>) => {
(value: SortConditionPB) => {
void sortService.updateSort(viewId, {
...sort,
condition: event.target.value as SortConditionPB,
condition: value,
});
},
[viewId, sort]
@ -43,13 +43,8 @@ export const SortItem: FC<SortItemProps> = ({ className, sort }) => {
return (
<Stack className={className} direction='row' spacing={1}>
<PropertySelect className={'w-[150px]'} size='small' value={sort.fieldId} onChange={handleFieldChange} />
<SortConditionSelect
className={'w-[150px]'}
size='small'
value={sort.condition}
onChange={handleConditionChange}
/>
<PropertySelect value={sort.fieldId} onChange={handleFieldChange} />
<SortConditionSelect value={sort.condition} onChange={handleConditionChange} />
<div className={'flex items-center justify-center'}>
<IconButton size={'small'} onClick={handleClick}>
<CloseSvg />

View File

@ -2,7 +2,7 @@ import { Menu, MenuProps } from '@mui/material';
import { FC, MouseEventHandler, useCallback, useState } from 'react';
import { useViewId } from '$app/hooks';
import { sortService } from '$app/application/database';
import { useDatabase } from '../../Database.hooks';
import { useDatabaseSorts } from '../../Database.hooks';
import { SortItem } from './SortItem';
import { useTranslation } from 'react-i18next';
@ -15,7 +15,7 @@ export const SortMenu: FC<MenuProps> = (props) => {
const { onClose } = props;
const { t } = useTranslation();
const viewId = useViewId();
const { sorts } = useDatabase();
const sorts = useDatabaseSorts();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const openFieldListMenu = Boolean(anchorEl);
const handleClick = useCallback<MouseEventHandler<HTMLElement>>((event) => {
@ -30,25 +30,31 @@ export const SortMenu: FC<MenuProps> = (props) => {
return (
<>
<Menu
disableRestoreFocus={true}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
props.onClose?.({}, 'escapeKeyDown');
}
}}
keepMounted={false}
MenuListProps={{
className: 'py-1',
className: 'py-1 w-[360px]',
}}
{...props}
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'}>
{sorts.map((sort) => (
<SortItem key={sort.id} className='m-2' sort={sort} />
))}
</div>
<div className={'mx-1'}>
<div className={'mx-2 flex flex-col'}>
<Button
onClick={handleClick}
className={'w-full justify-start'}
className={'justify-start px-1.5'}
variant={'text'}
color={'inherit'}
startIcon={<AddSvg />}
@ -57,7 +63,7 @@ export const SortMenu: FC<MenuProps> = (props) => {
</Button>
<Button
onClick={deleteAllSorts}
className={'w-full justify-start'}
className={'justify-start px-1.5'}
variant={'text'}
color={'inherit'}
startIcon={<DeleteSvg />}

View File

@ -18,10 +18,10 @@ export const Sorts = () => {
}, []);
const label = (
<div className={'flex items-center justify-center'}>
<SortSvg className={'mr-1.5 h-4 w-4'} />
<div className={'flex items-center justify-center gap-1'}>
<SortSvg className={'h-4 w-4'} />
{t('grid.settings.sort')}
<DropDownSvg className={'ml-1.5 h-6 w-6'} />
<DropDownSvg className={'h-5 w-5'} />
</div>
);
@ -36,10 +36,10 @@ export const Sorts = () => {
if (!showSorts) return null;
return (
<>
<div className={'text-text-title'}>
<Chip clickable variant='outlined' label={label} onClick={handleClick} />
<Divider className={'mx-2'} orientation='vertical' flexItem />
<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 { useTranslation } from 'react-i18next';
import AddViewBtn from '$app/components/database/components/tab_bar/AddViewBtn';
@ -25,7 +25,8 @@ const DatabaseIcons: {
[ViewLayoutPB.Calendar]: GridSvg,
};
export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViews, selectedViewId, setSelectedViewId }) => {
export const DatabaseTabBar = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
({ pageId, childViews, selectedViewId, setSelectedViewId }, ref) => {
const { t } = useTranslation();
const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState<HTMLElement | null>(null);
const [contextMenuView, setContextMenuView] = useState<Page | null>(null);
@ -50,13 +51,27 @@ export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViews, se
};
};
const isSelected = useMemo(() => childViews.some((view) => view.id === selectedViewId), [childViews, selectedViewId]);
const isSelected = useMemo(
() => childViews.some((view) => view.id === selectedViewId),
[childViews, selectedViewId]
);
if (childViews.length === 0) return null;
return (
<div className='-mb-px flex items-center px-16'>
<div className='flex flex-1 items-center border-b border-line-divider'>
<ViewTabs value={isSelected ? selectedViewId : childViews[0].id} onChange={handleChange}>
<div ref={ref} className='-mb-px flex w-full items-center overflow-hidden px-16 text-text-title'>
<div
style={{
width: 'calc(100% - 120px)',
}}
className='flex items-center border-b border-line-divider'
>
<ViewTabs
scrollButtons={false}
variant='scrollable'
allowScrollButtonsMobile
value={isSelected ? selectedViewId : childViews[0].id}
onChange={handleChange}
>
{childViews.map((view) => {
const Icon = DatabaseIcons[view.layout];
@ -91,4 +106,5 @@ export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViews, se
)}
</div>
);
};
}
);

View File

@ -1,13 +1,14 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
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 { Page } from '$app_reducers/pages/slice';
import { useAppDispatch } from '$app/stores/store';
import { updatePageName } from '$app_reducers/pages/async_actions';
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
enum ViewAction {
Rename,
@ -19,41 +20,59 @@ function ViewActions({ view, pageId, ...props }: { pageId: string; view: Page }
const viewId = view.id;
const dispatch = useAppDispatch();
const [openRenameDialog, setOpenRenameDialog] = useState(false);
const options = [
{
id: ViewAction.Rename,
label: t('button.rename'),
icon: <EditSvg />,
action: () => {
setOpenRenameDialog(true);
},
},
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>
);
}, []);
{
id: ViewAction.Delete,
disabled: viewId === pageId,
label: t('button.delete'),
icon: <DeleteSvg />,
action: async () => {
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 = [
{
key: ViewAction.Rename,
content: renderContent(t('button.rename'), EditSvg),
},
{
key: ViewAction.Delete,
content: renderContent(t('button.delete'), DeleteSvg),
disabled: viewId === pageId,
},
];
return (
<>
<Menu keepMounted={false} disableRestoreFocus={true} {...props}>
{options.map((option) => (
<MenuItem disabled={option.disabled} key={option.id} onClick={option.action}>
<div className={'mr-1.5'}>{option.icon}</div>
{option.label}
</MenuItem>
))}
<KeyboardNavigation
options={options}
onConfirm={onConfirm}
onEscape={() => {
props.onClose?.({}, 'escapeKeyDown');
}}
/>
</Menu>
{openRenameDialog && (
<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';
export const ViewTabs = styled(Tabs)({
export const ViewTabs = styled((props: TabsProps) => <Tabs {...props} />)({
minHeight: '28px',
'& .MuiTabs-scroller': {

View File

@ -18,12 +18,15 @@ export function GridCalculate({ field, index }: Props) {
<div
style={{
width,
visibility: index === 1 ? 'visible' : 'hidden',
}}
className={'flex justify-end py-2'}
className={'flex justify-end py-2 text-text-title'}
>
{field.isPrimary ? (
<>
<span className={'mr-2 text-text-caption'}>Count</span>
<span>{count}</span>
</>
) : null}
</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 { useViewId } from '$app/hooks';
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 }) => {
const menuOpened = useOpenMenu(field.id);
const viewId = useViewId();
const [openTooltip, setOpenTooltip] = useState(false);
const [propertyMenuOpened, setPropertyMenuOpened] = useState(false);
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
const handleTooltipOpen = useCallback(() => {
setOpenTooltip(true);
}, []);
const handleTooltipClose = useCallback(() => {
setOpenTooltip(false);
}, []);
const draggingData = useMemo(
() => ({
field,
@ -109,16 +100,21 @@ export const GridField: FC<GridFieldProps> = memo(
return;
}
const rect = previewRef.current?.getBoundingClientRect();
const anchorElement = previewRef.current;
if (!anchorElement) {
setMenuAnchorPosition(undefined);
return;
}
anchorElement.scrollIntoView({ block: 'nearest' });
const rect = anchorElement.getBoundingClientRect();
if (rect) {
setMenuAnchorPosition({
top: rect.top + rect.height,
left: rect.left,
});
} else {
setMenuAnchorPosition(undefined);
}
}, [menuOpened, previewRef]);
const handlePropertyMenuOpen = useCallback(() => {
@ -131,19 +127,10 @@ export const GridField: FC<GridFieldProps> = memo(
return (
<div className={'flex w-full border-r border-line-divider bg-bg-body'} {...props}>
<Tooltip
open={openTooltip && !isDragging}
title={field.name}
placement='right'
enterDelay={1000}
enterNextDelay={1000}
onOpen={handleTooltipOpen}
onClose={handleTooltipClose}
>
<Button
color={'inherit'}
ref={setPreviewRef}
className='relative flex h-full w-full items-center px-0'
className='relative flex h-full w-full items-center rounded-none px-0'
disableRipple
onContextMenu={(event) => {
event.stopPropagation();
@ -170,7 +157,6 @@ export const GridField: FC<GridFieldProps> = memo(
)}
<GridResizer field={field} onWidthChange={resizeColumnWidth} />
</Button>
</Tooltip>
{open && (
<GridFieldMenu
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 { Field } from '$app/application/database';
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';
interface Props extends PopoverProps {
@ -11,9 +11,10 @@ interface Props extends PopoverProps {
onOpenMenu?: (fieldId: string) => void;
}
export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props }: Props) {
export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, onClose, ...props }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<Portal>
<Popover
disableRestoreFocus={true}
transformOrigin={{
@ -22,12 +23,23 @@ export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props
}}
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 id={field.id} name={field.name} />
<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?.();
@ -35,13 +47,12 @@ export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props
onOpenMenu?.(newFieldId);
}
props.onClose?.({}, 'backdropClick');
onClose?.({}, 'backdropClick');
}}
fieldId={field.id}
/>
</MenuList>
</Popover>
</Portal>
);
}

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 { useViewId } from '$app/hooks';
@ -7,17 +7,16 @@ interface GridResizerProps {
onWidthChange?: (width: number) => void;
}
const minWidth = 100;
const minWidth = 150;
export function GridResizer({ field, onWidthChange }: GridResizerProps) {
const viewId = useViewId();
const fieldId = field.id;
const width = field.width || 0;
const [isResizing, setIsResizing] = useState(false);
const [newWidth, setNewWidth] = useState(width);
const [hover, setHover] = useState(false);
const startX = useRef(0);
const newWidthRef = useRef(width);
const onResize = useCallback(
(e: MouseEvent) => {
const diff = e.clientX - startX.current;
@ -27,25 +26,21 @@ export function GridResizer({ field, onWidthChange }: GridResizerProps) {
return;
}
setNewWidth(newWidth);
newWidthRef.current = newWidth;
onWidthChange?.(newWidth);
},
[width, onWidthChange]
);
useEffect(() => {
if (!isResizing && width !== newWidth) {
void fieldService.updateFieldSetting(viewId, fieldId, {
width: newWidth,
});
}
}, [fieldId, isResizing, newWidth, viewId, width]);
const onResizeEnd = useCallback(() => {
setIsResizing(false);
void fieldService.updateFieldSetting(viewId, fieldId, {
width: newWidthRef.current,
});
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onResizeEnd);
}, [onResize]);
}, [fieldId, onResize, viewId]);
const onResizeStart = useCallback(
(e: React.MouseEvent) => {

View File

@ -49,7 +49,7 @@ function GridNewRow({ index, groupId, getContainerRef }: Props) {
toggleCssProperty(false);
}}
onClick={handleClick}
className={'grid-new-row flex grow cursor-pointer'}
className={'grid-new-row flex grow cursor-pointer text-text-title'}
>
<span
style={{

View File

@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import {
GridRowContextMenu,
GridRowActions,
useGridTableHoverState,
} from '$app/components/database/grid/grid_row_actions';
import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog';
import { useTranslation } from 'react-i18next';
function GridTableOverlay({
containerRef,
@ -14,8 +16,23 @@ function GridTableOverlay({
}) {
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 handleOpenConfirm = useCallback((onOk: () => Promise<void>, onCancel: () => void) => {
setOpenConfirm(true);
setConfirmModalProps({ onOk, onCancel });
}, []);
useEffect(() => {
const container = containerRef.current;
@ -32,12 +49,25 @@ function GridTableOverlay({
return (
<div className={'absolute left-0 top-0'}>
<GridRowActions
onOpenConfirm={handleOpenConfirm}
getScrollElement={getScrollElement}
containerRef={containerRef}
rowId={hoverRowId}
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>
);
}

View File

@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewId } from '$app/hooks';
import { rowService } from '$app/application/database';
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) {
return Array.from(container.querySelectorAll(`[data-key^="row:${rowId}"]`));
@ -64,12 +66,16 @@ function createVirtualDragElement(rowId: string, container: HTMLDivElement) {
export function useDraggableGridRow(
rowId: string,
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 dropRowIdRef = useRef<string | undefined>(undefined);
const previewRef = useRef<HTMLDivElement | undefined>();
const viewId = useViewId();
const onDragStart = useCallback(
(e: React.DragEvent<HTMLButtonElement>) => {
e.dataTransfer.effectAllowed = 'move';
@ -100,6 +106,13 @@ export function useDraggableGridRow(
[containerRef, rowId, getScrollElement]
);
const moveRowTo = useCallback(
async (toRowId: string) => {
return rowService.moveRow(viewId, rowId, toRowId);
},
[viewId, rowId]
);
useEffect(() => {
if (!isDragging) {
if (previewRef.current) {
@ -156,8 +169,23 @@ export function useDraggableGridRow(
e.stopPropagation();
const dropRowId = dropRowIdRef.current;
toggleProperty(container, rowId, false);
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);
@ -169,7 +197,7 @@ export function useDraggableGridRow(
container.addEventListener('dragover', onDragOver);
container.addEventListener('dragend', onDragEnd);
container.addEventListener('drop', onDrop);
}, [containerRef, isDragging, rowId, viewId]);
}, [isDragging, containerRef, moveRowTo, onOpenConfirm, rowId, sortsCount, viewId]);
return {
isDragging,

View File

@ -1,25 +1,31 @@
import React, { useCallback, useState } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import { t } from 'i18next';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { GRID_ACTIONS_WIDTH } from '$app/components/database/grid/constants';
import { rowService } from '$app/application/database';
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 { useSortsCount } from '$app/components/database';
import { useTranslation } from 'react-i18next';
import { deleteAllSorts } from '$app/application/database/sort/sort_service';
export function GridRowActions({
rowId,
rowTop,
containerRef,
getScrollElement,
onOpenConfirm,
}: {
onOpenConfirm: (onOk: () => Promise<void>, onCancel: () => void) => void;
rowId?: string;
rowTop?: string;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
getScrollElement: () => HTMLDivElement | null;
}) {
const { t } = useTranslation();
const viewId = useViewId();
const sortsCount = useSortsCount();
const [menuRowId, setMenuRowId] = useState<string | undefined>(undefined);
const [menuPosition, setMenuPosition] = useState<
| {
@ -31,17 +37,32 @@ export function GridRowActions({
const openMenu = Boolean(menuPosition);
const handleInsertRecordBelow = useCallback(() => {
void rowService.createRow(viewId, {
const handleCloseMenu = useCallback(() => {
setMenuPosition(undefined);
if (containerRef.current && menuRowId) {
toggleProperty(containerRef.current, menuRowId, false);
}
}, [containerRef, menuRowId]);
const handleInsertRecordBelow = useCallback(
async (rowId: string) => {
await rowService.createRow(viewId, {
position: OrderObjectPositionTypePB.After,
rowId: rowId,
});
}, [viewId, rowId]);
handleCloseMenu();
},
[viewId, handleCloseMenu]
);
const handleOpenMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLButtonElement;
const rect = target.getBoundingClientRect();
if (containerRef.current && rowId) {
toggleProperty(containerRef.current, rowId, true);
}
setMenuRowId(rowId);
setMenuPosition({
top: rect.top + rect.height / 2,
@ -49,11 +70,6 @@ export function GridRowActions({
});
};
const handleCloseMenu = useCallback(() => {
setMenuPosition(undefined);
setMenuRowId(undefined);
}, []);
return (
<>
{rowId && rowTop && (
@ -64,10 +80,28 @@ export function GridRowActions({
left: GRID_ACTIONS_WIDTH,
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' disableInteractive={true} title={t('grid.row.add')}>
<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);
}
}}
>
<Tooltip placement='top' title={t('grid.row.add')}>
<IconButton onClick={handleInsertRecordBelow}>
<AddSvg />
</IconButton>
</Tooltip>
@ -76,12 +110,14 @@ export function GridRowActions({
rowId={rowId}
containerRef={containerRef}
onClick={handleOpenMenu}
onOpenConfirm={onOpenConfirm}
/>
</div>
)}
{openMenu && menuRowId && (
{menuRowId && (
<GridRowMenu
open={openMenu}
onOpenConfirm={onOpenConfirm}
onClose={handleCloseMenu}
transformOrigin={{
vertical: 'center',

View File

@ -5,8 +5,10 @@ import { toggleProperty } from './GridRowActions.hooks';
export function GridRowContextMenu({
containerRef,
hoverRowId,
onOpenConfirm,
}: {
hoverRowId?: string;
onOpenConfirm: (onOk: () => Promise<void>, onCancel: () => void) => void;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
}) {
const [position, setPosition] = useState<{ left: number; top: number } | undefined>();
@ -23,7 +25,7 @@ export function GridRowContextMenu({
if (!container || !rowId) return;
toggleProperty(container, rowId, false);
setRowId(undefined);
// setRowId(undefined);
}, [rowId, containerRef]);
const openContextMenu = useCallback(
@ -56,8 +58,14 @@ export function GridRowContextMenu({
};
}, [containerRef, openContextMenu]);
return isContextMenuOpen && rowId ? (
<GridRowMenu open={isContextMenuOpen} onClose={closeContextMenu} anchorPosition={position} rowId={rowId} />
return rowId ? (
<GridRowMenu
onOpenConfirm={onOpenConfirm}
open={isContextMenuOpen}
onClose={closeContextMenu}
anchorPosition={position}
rowId={rowId}
/>
) : null;
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useDraggableGridRow } from './GridRowActions.hooks';
import { IconButton, Tooltip } from '@mui/material';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
@ -9,7 +9,9 @@ export function GridRowDragButton({
containerRef,
onClick,
getScrollElement,
onOpenConfirm,
}: {
onOpenConfirm: (onOk: () => Promise<void>, onCancel: () => void) => void;
rowId: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
@ -17,19 +19,40 @@ export function GridRowDragButton({
}) {
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 (
<Tooltip placement='top' title={t('grid.row.dragAndClick')}>
<>
<Tooltip
open={openTooltip}
onOpen={() => {
setOpenTooltip(true);
}}
onClose={() => {
setOpenTooltip(false);
}}
placement='top'
disableInteractive={true}
title={t('grid.row.dragAndClick')}
>
<IconButton
size={'small'}
onClick={onClick}
draggable={true}
onDragStart={onDragStart}
className='mx-1 cursor-grab active:cursor-grabbing'
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 AddSvg } from '$app/assets/add.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 { useTranslation } from 'react-i18next';
import { rowService } from '$app/application/database';
import { Icon, MenuItem, MenuList } from '@mui/material';
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 {
label: string;
icon: JSX.Element;
onClick: () => void;
divider?: boolean;
enum RowAction {
InsertAbove,
InsertBelow,
Duplicate,
Delete,
}
interface Props extends PopoverProps {
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 sortsCount = useSortsCount();
const { t } = useTranslation();
@ -48,56 +53,107 @@ export function GridRowMenu({ rowId, ...props }: Props) {
void rowService.duplicateRow(viewId, rowId);
}, [viewId, rowId]);
const options: Option[] = [
const renderContent = useCallback((title: string, Icon: React.FC<React.SVGProps<SVGSVGElement>>) => {
return (
<div className={'flex w-full items-center gap-1 px-1'}>
<Icon className={'h-4 w-4'} />
<div className={'flex-1'}>{title}</div>
</div>
);
}, []);
const handleAction = useCallback(
(confirmKey?: RowAction) => {
switch (confirmKey) {
case RowAction.InsertAbove:
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(
() => [
{
label: t('grid.row.insertRecordAbove'),
icon: <UpSvg />,
onClick: handleInsertRecordAbove,
key: RowAction.InsertAbove,
content: renderContent(t('grid.row.insertRecordAbove'), UpSvg),
},
{
label: t('grid.row.insertRecordBelow'),
icon: <AddSvg />,
onClick: handleInsertRecordBelow,
key: RowAction.InsertBelow,
content: renderContent(t('grid.row.insertRecordBelow'), AddSvg),
},
{
label: t('grid.row.duplicate'),
icon: <CopySvg />,
onClick: handleDuplicateRow,
key: RowAction.Duplicate,
content: renderContent(t('grid.row.duplicate'), CopySvg),
},
{
label: t('grid.row.delete'),
icon: <DelSvg />,
onClick: handleDelRow,
divider: true,
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 (
<>
<Popover
disableRestoreFocus={true}
keepMounted={false}
anchorReference={'anchorPosition'}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
onClose={onClose}
{...props}
>
<MenuList>
{options.map((option) => (
<div className={'w-full'} key={option.label}>
{option.divider && <div className='mx-2 my-1.5 h-[1px] bg-line-divider' />}
<MenuItem
onClick={() => {
option.onClick();
props.onClose?.({}, 'backdropClick');
<div className={'py-2'}>
<KeyboardNavigation
options={options}
onConfirm={onConfirm}
onEscape={() => {
onClose?.({}, 'escapeKeyDown');
}}
>
<Icon className='mr-2'>{option.icon}</Icon>
{option.label}
</MenuItem>
/>
</div>
))}
</MenuList>
</Popover>
</>
);
}

View File

@ -1,19 +1,29 @@
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 { useGridColumn } from '$app/components/database/grid/grid_table';
import { GridField } from 'src/appflowy_app/components/database/grid/grid_field';
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';
const GridStickyHeader = React.forwardRef<
Grid<HTMLDivElement> | null,
{ columns: GridColumn[]; getScrollElement?: () => HTMLDivElement | null }
>(({ columns, getScrollElement }, ref) => {
Grid<GridColumn[]> | null,
{
columns: GridColumn[];
getScrollElement?: () => HTMLDivElement | null;
onScroll?: (props: GridOnScrollProps) => void;
}
>(({ onScroll, columns, getScrollElement }, ref) => {
const { columnWidth, resizeColumnWidth } = useGridColumn(
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);
@ -33,9 +43,10 @@ const GridStickyHeader = React.forwardRef<
}, []);
const Cell = useCallback(
({ columnIndex, style }: GridChildComponentProps) => {
const column = columns[columnIndex];
({ columnIndex, style, data }: GridChildComponentProps) => {
const column = data[columnIndex];
if (!column || column.type === GridColumnType.Action) return <div style={style} />;
if (column.type === GridColumnType.NewProperty) {
const width = (style.width || 0) as number;
@ -43,7 +54,7 @@ const GridStickyHeader = React.forwardRef<
<div
style={{
...style,
width: width + 8,
width,
}}
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;
if (!field) return <div style={style} />;
@ -72,7 +79,7 @@ const GridStickyHeader = React.forwardRef<
/>
);
},
[columns, handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement]
[handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement]
);
return (
@ -81,6 +88,7 @@ const GridStickyHeader = React.forwardRef<
{({ height, width }: { height: number; width: number }) => {
return (
<Grid
className={'grid-sticky-header w-full text-text-title'}
height={height}
width={width}
rowHeight={() => 36}
@ -88,7 +96,9 @@ const GridStickyHeader = React.forwardRef<
columnCount={columns.length}
columnWidth={columnWidth}
ref={ref}
style={{ overflowX: 'hidden', overscrollBehavior: 'none' }}
onScroll={onScroll}
itemData={columns}
style={{ overscrollBehavior: 'none' }}
>
{Cell}
</Grid>

View File

@ -1,5 +1,5 @@
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';
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[]>([]);
useEffect(() => {

View File

@ -20,8 +20,16 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
const fields = useDatabaseVisibilityFields();
const renderRows = useMemo<RenderRow[]>(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]);
const columns = useMemo<GridColumn[]>(() => fieldsToColumns(fields), [fields]);
const ref = useRef<Grid<HTMLDivElement>>(null);
const { columnWidth } = useGridColumn(columns, ref);
const ref = useRef<
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 onRendered = useDatabaseRendered();
@ -54,9 +62,9 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
}, []);
const Cell = useCallback(
({ columnIndex, rowIndex, style }: GridChildComponentProps) => {
const row = renderRows[rowIndex];
const column = columns[columnIndex];
({ columnIndex, rowIndex, style, data }: GridChildComponentProps) => {
const row = data.renderRows[rowIndex];
const column = data.columns[columnIndex];
return (
<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) => {
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 scrollElementRef = useRef<HTMLDivElement | null>(null);
@ -95,7 +107,12 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
</div>
)}
<div className={'h-[36px]'}>
<GridStickyHeader ref={staticGrid} getScrollElement={getScrollElement} columns={columns} />
<GridStickyHeader
ref={staticGrid}
onScroll={onHeaderScroll}
getScrollElement={getScrollElement}
columns={columns}
/>
</div>
<div className={'flex-1'}>
@ -110,6 +127,10 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
rowCount={renderRows.length}
rowHeight={rowHeight}
width={width}
itemData={{
columns,
renderRows,
}}
overscanRowCount={10}
itemKey={getItemKey}
style={{

View File

@ -1,5 +1,5 @@
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 { useAppDispatch, useAppSelector } from '$app/stores/store';
import { updatePageName } from '$app_reducers/pages/async_actions';

View File

@ -1,7 +1,6 @@
import React, { memo } from 'react';
import { EditorProps } from '../../application/document/document.types';
import { Toaster } from 'react-hot-toast';
import { CollaborativeEditor } from '$app/components/editor/components/editor';
import { EditorIdProvider } from '$app/components/editor/Editor.hooks';
import './editor.scss';
@ -12,7 +11,6 @@ export function Editor(props: EditorProps) {
<div className={'appflowy-editor relative'}>
<EditorIdProvider value={props.id}>
<CollaborativeEditor {...props} />
<Toaster />
</EditorIdProvider>
</div>
);

View File

@ -1,6 +1,7 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate';
import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types';
import { isMarkActive } from '$app/components/editor/command/mark';
export function insertFormula(editor: ReactEditor, formula?: string) {
if (editor.selection) {
@ -79,9 +80,5 @@ export function unwrapFormula(editor: ReactEditor) {
}
export function isFormulaActive(editor: ReactEditor) {
const [node] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorInlineNodeType.Formula,
});
return !!node;
return isMarkActive(editor, EditorInlineNodeType.Formula);
}

View File

@ -1,6 +1,6 @@
import { ReactEditor } from 'slate-react';
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(
editor: ReactEditor,
@ -25,7 +25,7 @@ export function toggleMark(
* @param editor
* @param format
*/
export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) {
export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat | EditorInlineNodeType) {
const selection = editor.selection;
if (!selection) return false;

View File

@ -31,6 +31,10 @@ export function tabForward(editor: ReactEditor) {
const [node, path] = match as NodeEntry<Element>;
const hasPrevious = Path.hasPrevious(path);
if (!hasPrevious) return;
const previousPath = Path.previous(path);
const previous = editor.node(previousPath);
@ -40,6 +44,7 @@ export function tabForward(editor: ReactEditor) {
const type = previousNode.type as EditorNodeType;
if (type === EditorNodeType.Page) return;
// the previous node is not a list
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);
// placeholder should be hidden when composing
editorDom.addEventListener('compositionstart', handleCompositionStart);
editorDom.addEventListener('compositionend', handleCompositionEnd);
editorDom.addEventListener('compositionupdate', handleCompositionStart);
return () => {
editorDom.removeEventListener('compositionstart', handleCompositionStart);
editorDom.removeEventListener('compositionend', handleCompositionEnd);
editorDom.removeEventListener('compositionupdate', handleCompositionStart);
};
}, [editor, selected]);

Some files were not shown because too many files have changed in this diff Show More