mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
fix: database bugs (#4632)
* fix: database bugs * fix: calendar picker * fix: the position of collapse menu button * fix: modified some style * fix: slash command * fix: selection style * fix: support toggle inline formula * fix: block color effect grid block * fix: if isRange and date is greater than endDate, swap date and endDate * fix: remove sorting before insert row * fix: toggle property visible status * fix: modified tauri window size * fix: placeholder should be hidden when composing * fix: support href shortcut * fix: prevent submit when the formula has error * fix: modified layout selection * fix: add padding for record edit * fix: remove sorts before drag row * fix: modified chip style * fix: if previous node is an embed, merge the current node to another node which is not an embed * fix: modified emoji picker
This commit is contained in:
parent
c9dc24a13c
commit
e2028ac5a0
@ -83,10 +83,12 @@
|
||||
{
|
||||
"fileDropEnabled": false,
|
||||
"fullscreen": false,
|
||||
"height": 1200,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"title": "AppFlowy",
|
||||
"width": 1200
|
||||
"width": 1200,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -10,14 +10,17 @@ import { UserService } from '$app/application/user/user.service';
|
||||
export function useUserSetting() {
|
||||
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();
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -23,6 +23,7 @@ const updateFiltersFromChange = (database: Database, changeset: FilterChangesetN
|
||||
const newFilter = pbToFilter(pb.filter);
|
||||
|
||||
Object.assign(found, newFilter);
|
||||
database.filters = [...database.filters];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -151,6 +151,7 @@ export interface EditorProps {
|
||||
title?: string;
|
||||
onTitleChange?: (title: string) => void;
|
||||
showTitle?: boolean;
|
||||
disableFocus?: boolean;
|
||||
}
|
||||
|
||||
export enum EditorNodeType {
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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;
|
||||
|
@ -44,6 +44,7 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon
|
||||
onClose={() => setAnchorPosition(undefined)}
|
||||
>
|
||||
<EmojiPicker
|
||||
defaultEmoji={icon.value}
|
||||
onEscape={() => {
|
||||
setAnchorPosition(undefined);
|
||||
}}
|
||||
|
@ -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 }
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
||||
|
@ -0,0 +1,47 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
function LinearProgressWithLabel({
|
||||
value,
|
||||
count,
|
||||
selectedCount,
|
||||
}: {
|
||||
value: number;
|
||||
count: number;
|
||||
selectedCount: number;
|
||||
}) {
|
||||
const result = useMemo(() => `${Math.round(value * 100)}%`, [value]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
checked: i < selectedCount,
|
||||
}));
|
||||
}, [count, selectedCount]);
|
||||
|
||||
const isSplit = count < 6;
|
||||
|
||||
return (
|
||||
<div className={'flex w-full items-center'}>
|
||||
<div className={`flex flex-1 items-center justify-between px-1 ${isSplit ? 'gap-0.5' : ''}`}>
|
||||
{options.map((option) => (
|
||||
<span
|
||||
style={{
|
||||
width: `${Math.round(100 / count)}%`,
|
||||
backgroundColor:
|
||||
value < 1
|
||||
? option.checked
|
||||
? 'var(--content-blue-400)'
|
||||
: 'var(--content-blue-100)'
|
||||
: 'var(--function-success)',
|
||||
}}
|
||||
className={`h-[4px] ${isSplit ? 'rounded-full' : ''} `}
|
||||
key={option.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={'w-[30px] text-center text-xs text-text-caption'}>{result}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinearProgressWithLabel;
|
@ -1,16 +1,30 @@
|
||||
import React, { useState, Suspense, useMemo } from 'react';
|
||||
import { 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}
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 />}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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),
|
||||
});
|
||||
}}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -55,6 +55,7 @@ function EditNumberCellInput({
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
spellCheck={false}
|
||||
autoFocus={true}
|
||||
value={value}
|
||||
onInput={handleInput}
|
||||
|
@ -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'} />
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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'}`} />
|
||||
|
@ -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}
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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={{
|
||||
|
@ -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'}
|
||||
|
@ -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,
|
||||
|
@ -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} />;
|
||||
})}
|
||||
|
@ -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'} />
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 ?? '');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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'}
|
||||
/>
|
||||
|
@ -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?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 />
|
||||
|
@ -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 />}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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': {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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={{
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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(() => {
|
||||
|
@ -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={{
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user