feat: support the operations of field in the grid of tauri (#3906)

* feat: support the operations of field in the grid of tauri

* fix: performance optimizate
This commit is contained in:
Kilu.He 2023-11-13 14:16:32 +08:00 committed by GitHub
parent 251c6d22b2
commit 7867f0366e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 512 additions and 151 deletions

View File

@ -0,0 +1,16 @@
<svg width='100%' height='100%' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M12.8 2H3.2C2.53726 2 2 2.55964 2 3.25V5.75C2 6.44036 2.53726 7 3.2 7H12.8C13.4627 7 14 6.44036 14 5.75V3.25C14 2.55964 13.4627 2 12.8 2Z'
stroke='currentColor'
stroke-linecap='round'
stroke-linejoin='round'
/>
<path
d='M12.8 9H3.2C2.53726 9 2 9.55964 2 10.25V12.75C2 13.4404 2.53726 14 3.2 14H12.8C13.4627 14 14 13.4404 14 12.75V10.25C14 9.55964 13.4627 9 12.8 9Z'
stroke='currentColor'
stroke-linecap='round'
stroke-linejoin='round'
/>
<circle cx='4.5' cy='4.5' r='0.5' fill='currentColor' />
<circle cx='4.5' cy='11.5' r='0.5' fill='currentColor' />
</svg>

After

Width:  |  Height:  |  Size: 788 B

View File

@ -0,0 +1,14 @@
<svg width='100%' height='100%' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M10.5 3H11.5C11.8315 3 12.1495 3.12877 12.3839 3.35798C12.6183 3.58719 12.75 3.89807 12.75 4.22222V12.7778C12.75 13.1019 12.6183 13.4128 12.3839 13.642C12.1495 13.8712 11.8315 14 11.5 14H4.5C4.16848 14 3.85054 13.8712 3.61612 13.642C3.3817 13.4128 3.25 13.1019 3.25 12.7778V4.22222C3.25 3.89807 3.3817 3.58719 3.61612 3.35798C3.85054 3.12877 4.16848 3 4.5 3H5.5'
stroke='currentColor'
stroke-linecap='round'
stroke-linejoin='round'
/>
<path
d='M9.5 2H6.5C6.22386 2 6 2.22386 6 2.5V3.5C6 3.77614 6.22386 4 6.5 4H9.5C9.77614 4 10 3.77614 10 3.5V2.5C10 2.22386 9.77614 2 9.5 2Z'
stroke='currentColor'
stroke-linecap='round'
stroke-linejoin='round'
/>
</svg>

After

Width:  |  Height:  |  Size: 875 B

View File

@ -0,0 +1,6 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.25 1.75H2.625C2.14175 1.75 1.75 2.14175 1.75 2.625V5.25C1.75 5.73325 2.14175 6.125 2.625 6.125H5.25C5.73325 6.125 6.125 5.73325 6.125 5.25V2.625C6.125 2.14175 5.73325 1.75 5.25 1.75Z" stroke="#333333" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.375 1.75H8.75C8.26675 1.75 7.875 2.14175 7.875 2.625V5.25C7.875 5.73325 8.26675 6.125 8.75 6.125H11.375C11.8582 6.125 12.25 5.73325 12.25 5.25V2.625C12.25 2.14175 11.8582 1.75 11.375 1.75Z" stroke="#333333" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.375 7.875H8.75C8.26675 7.875 7.875 8.26675 7.875 8.75V11.375C7.875 11.8582 8.26675 12.25 8.75 12.25H11.375C11.8582 12.25 12.25 11.8582 12.25 11.375V8.75C12.25 8.26675 11.8582 7.875 11.375 7.875Z" stroke="#333333" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.25 7.875H2.625C2.14175 7.875 1.75 8.26675 1.75 8.75V11.375C1.75 11.8582 2.14175 12.25 2.625 12.25H5.25C5.73325 12.25 6.125 11.8582 6.125 11.375V8.75C6.125 8.26675 5.73325 7.875 5.25 7.875Z" stroke="#333333" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,9 +1,9 @@
import { createContext, useContext, useCallback, useMemo, useEffect, useState, useRef } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { proxy, useSnapshot } from 'valtio';
import { DatabaseLayoutPB, DatabaseNotification } from '@/services/backend';
import { DatabaseLayoutPB, DatabaseNotification, FieldVisibility } from '@/services/backend';
import { subscribeNotifications } from '$app/hooks';
import { Database, databaseService, fieldService, rowListeners, sortListeners } from './application';
import { Database, databaseService, fieldListeners, fieldService, rowListeners, sortListeners } from './application';
export function useSelectDatabaseView({ viewId }: { viewId?: string }) {
const key = 'v';
@ -40,6 +40,15 @@ export const DatabaseProvider = DatabaseContext.Provider;
export const useDatabase = () => useSnapshot(useContext(DatabaseContext));
export const useDatabaseVisibilityFields = () => {
const database = useDatabase();
return useMemo(
() => database.fields.filter((field) => field.visibility !== FieldVisibility.AlwaysHidden),
[database.fields]
);
};
export const useConnectDatabase = (viewId: string) => {
const database = useMemo(() => {
const proxyDatabase = proxy<Database>({
@ -65,6 +74,9 @@ export const useConnectDatabase = (viewId: string) => {
[DatabaseNotification.DidUpdateFields]: async () => {
database.fields = await fieldService.getFields(viewId);
},
[DatabaseNotification.DidUpdateFieldSettings]: async (changeset) => {
fieldListeners.didUpdateFieldSettings(database, changeset);
},
[DatabaseNotification.DidUpdateViewRows]: (changeset) => {
rowListeners.didUpdateViewRows(database, changeset);
},

View File

@ -52,7 +52,14 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
selectedViewId={selectedViewId}
childViewIds={childViewIds}
/>
<SwipeableViews className={'flex-1 overflow-hidden'} axis={'x'} index={index}>
<SwipeableViews
slideStyle={{
overflow: 'hidden',
}}
className={'flex-1 overflow-hidden'}
axis={'x'}
index={index}
>
{childViewIds.map((id) => (
<TabPanel key={id} index={index} value={index}>
<DatabaseLoader viewId={id}>

View File

@ -0,0 +1,11 @@
import { FieldSettingsPB } from '@/services/backend';
import { Database } from '$app/components/database/application';
export function didUpdateFieldSettings(database: Database, settings: FieldSettingsPB) {
const { field_id: fieldId, visibility, width } = settings;
const field = database.fields.find((field) => field.id === fieldId);
if (!field) return;
field.visibility = visibility;
field.width = width;
}

View File

@ -8,6 +8,9 @@ import {
MoveFieldPayloadPB,
RepeatedFieldIdPB,
UpdateFieldTypePayloadPB,
FieldSettingsChangesetPB,
FieldVisibility,
DatabaseViewIdPB,
} from '@/services/backend';
import {
DatabaseEventDuplicateField,
@ -17,6 +20,8 @@ import {
DatabaseEventGetFields,
DatabaseEventDeleteField,
DatabaseEventCreateTypeOption,
DatabaseEventUpdateFieldSettings,
DatabaseEventGetAllFieldSettings,
} from '@/services/backend/events/flowy-database2';
import { Field, pbToField } from './field_types';
import { bytesToTypeOption, getTypeOption } from './type_option';
@ -24,20 +29,39 @@ import { bytesToTypeOption, getTypeOption } from './type_option';
export async function getFields(viewId: string, fieldIds?: string[]): Promise<Field[]> {
const payload = GetFieldPayloadPB.fromObject({
view_id: viewId,
field_ids: fieldIds ? RepeatedFieldIdPB.fromObject({
items: fieldIds.map(fieldId => ({ field_id: fieldId })),
}) : undefined,
field_ids: fieldIds
? RepeatedFieldIdPB.fromObject({
items: fieldIds.map((fieldId) => ({ field_id: fieldId })),
})
: undefined,
});
const result = await DatabaseEventGetFields(payload);
const fields = result.map((value) => value.items.map(pbToField)).unwrap();
const getSettingsPayload = DatabaseViewIdPB.fromObject({
value: viewId,
});
await Promise.all(fields.map(async field => {
const typeOption = await getTypeOption(viewId, field.id, field.type);
const settings = await DatabaseEventGetAllFieldSettings(getSettingsPayload);
field.typeOption = typeOption;
}));
if (settings.ok === false || result.ok === false) {
return Promise.reject('Failed to get fields');
}
const fields = await Promise.all(
result.val.items.map(async (item) => {
const setting = settings.val.items.find((setting) => setting.field_id === item.id);
const field = pbToField(item);
const typeOption = await getTypeOption(viewId, field.id, field.type);
return {
...field,
visibility: setting?.visibility,
width: setting?.width,
typeOption,
};
})
);
return fields;
}
@ -51,13 +75,14 @@ export async function createField(viewId: string, fieldType?: FieldType, data?:
const result = await DatabaseEventCreateTypeOption(payload);
return result.map(value => {
const field = pbToField(value.field);
if (result.ok === false) {
return Promise.reject('Failed to create field');
}
field.typeOption = bytesToTypeOption(value.type_option_data, field.type);
const field = pbToField(result.val.field);
return field;
}).unwrap();
field.typeOption = bytesToTypeOption(result.val.type_option_data, field.type);
return field;
}
export async function duplicateField(viewId: string, fieldId: string): Promise<void> {
@ -68,16 +93,21 @@ export async function duplicateField(viewId: string, fieldId: string): Promise<v
const result = await DatabaseEventDuplicateField(payload);
return result.unwrap();
if (result.ok === false) {
return Promise.reject('Failed to duplicate field');
}
return result.val;
}
export async function updateField(viewId: string, fieldId: string, data: {
name?: string;
desc?: string;
frozen?: boolean;
visibility?: boolean;
width?: number;
}): Promise<void> {
export async function updateField(
viewId: string,
fieldId: string,
data: {
name?: string;
desc?: string;
}
): Promise<void> {
const payload = FieldChangesetPB.fromObject({
view_id: viewId,
field_id: fieldId,
@ -124,3 +154,26 @@ export async function deleteField(viewId: string, fieldId: string): Promise<void
return result.unwrap();
}
export async function updateFieldSetting(
viewId: string,
fieldId: string,
settings: {
visibility?: FieldVisibility;
width?: number;
}
): Promise<void> {
const payload = FieldSettingsChangesetPB.fromObject({
view_id: viewId,
field_id: fieldId,
...settings,
});
const result = await DatabaseEventUpdateFieldSettings(payload);
if (result.ok === false) {
return Promise.reject('Failed to update field settings');
}
return result.val;
}

View File

@ -1,7 +1,4 @@
import {
FieldPB,
FieldType,
} from '@/services/backend';
import { FieldPB, FieldType, FieldVisibility } from '@/services/backend';
import { DateTimeTypeOption, NumberTypeOption, SelectTypeOption } from './type_option/type_option_types';
export interface Field {
@ -9,8 +6,8 @@ export interface Field {
name: string;
type: FieldType;
typeOption?: unknown;
visibility: boolean;
width: number;
visibility?: FieldVisibility;
width?: number;
isPrimary: boolean;
}
@ -35,7 +32,5 @@ export const pbToField = (pb: FieldPB): Field => ({
id: pb.id,
name: pb.name,
type: pb.field_type,
visibility: pb.visibility,
width: pb.width,
isPrimary: pb.is_primary,
});

View File

@ -2,3 +2,4 @@ export * from './select_option';
export * from './type_option';
export * from './field_types';
export * as fieldService from './field_service';
export * as fieldListeners from './field_listeners';

View File

@ -1,8 +1,9 @@
import { Popover, TextareaAutosize } from '@mui/material';
import { FC, FormEventHandler, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { FC, FormEventHandler, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useViewId } from '$app/hooks';
import { cellService, Field, TextCell as TextCellType } from '../../application';
import { CellText } from '../../_shared';
import { useGridUIStateDispatcher } from '$app/components/database/proxy/grid/ui_state/actions';
export const TextCell: FC<{
field: Field;
@ -13,7 +14,7 @@ export const TextCell: FC<{
const [editing, setEditing] = useState(false);
const [text, setText] = useState('');
const [width, setWidth] = useState<number | undefined>(undefined);
const { setRowHover } = useGridUIStateDispatcher();
const handleClose = () => {
if (!cell) return;
if (editing) {
@ -41,6 +42,12 @@ export const TextCell: FC<{
}
}, [editing]);
useEffect(() => {
if (editing) {
setRowHover(null);
}
}, [editing, setRowHover]);
return (
<>
<CellText ref={cellRef} className='w-full' onClick={handleClick}>

View File

@ -1,28 +1,31 @@
import { MenuItem, Select, SelectChangeEvent, SelectProps } from '@mui/material';
import { FC, useCallback } from 'react';
import { Field as FieldType } from '../../application';
import { useDatabase } from '../../Database.hooks';
import { useDatabaseVisibilityFields } from '../../Database.hooks';
import { Field } from './Field';
export interface FieldSelectProps extends Omit<SelectProps, 'onChange'> {
onChange?: (event: SelectChangeEvent<unknown>, field: FieldType | undefined) => void;
}
export const FieldSelect: FC<FieldSelectProps> = ({
onChange,
...props
}) => {
const { fields } = useDatabase();
export const FieldSelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
const fields = useDatabaseVisibilityFields();
const handleChange = useCallback((event: SelectChangeEvent<unknown>) => {
const selectedId = event.target.value;
const handleChange = useCallback(
(event: SelectChangeEvent<unknown>) => {
const selectedId = event.target.value;
onChange?.(event, fields.find(field => field.id === selectedId));
}, [onChange, fields]);
onChange?.(
event,
fields.find((field) => field.id === selectedId)
);
},
[onChange, fields]
);
return (
<Select onChange={handleChange} {...props}>
{fields.map(field => (
{fields.map((field) => (
<MenuItem key={field.id} value={field.id}>
<Field field={field} />
</MenuItem>

View File

@ -1,7 +1,7 @@
import { Menu, MenuItem, MenuProps } from '@mui/material';
import { FC, MouseEvent } from 'react';
import { Field as FieldType } from '../../application';
import { useDatabase } from '../../Database.hooks';
import { useDatabaseVisibilityFields } from '../../Database.hooks';
import { Field } from './Field';
export interface FieldsMenuProps extends MenuProps {
@ -9,7 +9,7 @@ export interface FieldsMenuProps extends MenuProps {
}
export const FieldsMenu: FC<FieldsMenuProps> = ({ onMenuItemClick, ...props }) => {
const { fields } = useDatabase();
const fields = useDatabaseVisibilityFields();
return (
<Menu {...props}>

View File

@ -1,8 +1,12 @@
import { FC, useEffect } from 'react';
import { FC, FunctionComponent, SVGProps, useEffect } from 'react';
import { ViewTabs, ViewTab } from './ViewTabs';
import { useAppSelector } from '$app/stores/store';
import { useTranslation } from 'react-i18next';
import AddViewBtn from '$app/components/database/components/tab_bar/AddViewBtn';
import { ViewLayoutPB } from '@/services/backend';
import { ReactComponent as GridSvg } from '$app/assets/grid.svg';
import { ReactComponent as BoardSvg } from '$app/assets/board.svg';
import { ReactComponent as DocumentSvg } from '$app/assets/document.svg';
export interface DatabaseTabBarProps {
childViewIds: string[];
@ -11,6 +15,15 @@ export interface DatabaseTabBarProps {
pageId: string;
}
const DatabaseIcons: {
[key in ViewLayoutPB]: FunctionComponent<SVGProps<SVGSVGElement> & { title?: string | undefined }>;
} = {
[ViewLayoutPB.Document]: DocumentSvg,
[ViewLayoutPB.Grid]: GridSvg,
[ViewLayoutPB.Board]: BoardSvg,
[ViewLayoutPB.Calendar]: GridSvg,
};
export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViewIds, selectedViewId, setSelectedViewId }) => {
const { t } = useTranslation();
const views = useAppSelector((state) => {
@ -33,16 +46,20 @@ export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViewIds,
<div className='-mb-px flex items-center border-b border-line-divider'>
<div className='flex flex-1 items-center'>
<ViewTabs value={selectedViewId} onChange={handleChange}>
{views.map((view) => (
<ViewTab
key={view.id}
icon={undefined}
iconPosition='start'
color='inherit'
label={view.name || t('grid.title.placeholder')}
value={view.id}
/>
))}
{views.map((view) => {
const Icon = DatabaseIcons[view.layout];
return (
<ViewTab
key={view.id}
icon={<Icon />}
iconPosition='start'
color='inherit'
label={view.name || t('grid.title.placeholder')}
value={view.id}
/>
);
})}
</ViewTabs>
<AddViewBtn pageId={pageId} />
</div>

View File

@ -0,0 +1,30 @@
import React from 'react';
import { useDatabase } from '$app/components/database';
import { Field } from '$app/components/database/application';
import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow';
interface Props {
field: Field;
index: number;
}
function GridCalculate({ field, index }: Props) {
const { rowMetas } = useDatabase();
const count = rowMetas.length;
const width = field.width ?? DEFAULT_FIELD_WIDTH;
return (
<div
style={{
width,
visibility: index === 0 ? 'visible' : 'hidden',
}}
className={'flex justify-end'}
>
<span className={'mr-2 text-text-caption'}>Count</span>
<span>{count}</span>
</div>
);
}
export default GridCalculate;

View File

@ -0,0 +1 @@
export * from './GridCalculate';

View File

@ -7,6 +7,8 @@ import { fieldService, Field } from '../../application';
import { useDatabase } from '../../Database.hooks';
import { FieldTypeSvg } from './FieldTypeSvg';
import { GridFieldMenu } from './GridFieldMenu';
import GridResizer from '$app/components/database/grid/GridField/GridResizer';
import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow';
export interface GridFieldProps {
field: Field;
@ -18,6 +20,7 @@ export const GridField: FC<GridFieldProps> = ({ field }) => {
const [openMenu, setOpenMenu] = useState(false);
const [openTooltip, setOpenTooltip] = useState(false);
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
const [fieldWidth, setFieldWidth] = useState(field.width || DEFAULT_FIELD_WIDTH);
const handleClick = useCallback(() => {
setOpenMenu(true);
@ -89,7 +92,12 @@ export const GridField: FC<GridFieldProps> = ({ field }) => {
});
return (
<>
<div
className={'flex border-r border-line-divider'}
style={{
width: fieldWidth,
}}
>
<Tooltip
open={openTooltip && !isDragging}
title={field.name}
@ -104,6 +112,11 @@ export const GridField: FC<GridFieldProps> = ({ field }) => {
ref={setPreviewRef}
className='relative flex w-full items-center px-2'
disableRipple
onContextMenu={(event) => {
event.stopPropagation();
event.preventDefault();
handleClick();
}}
onClick={handleClick}
{...attributes}
{...listeners}
@ -118,11 +131,12 @@ export const GridField: FC<GridFieldProps> = ({ field }) => {
}`}
/>
)}
<GridResizer field={field} onWidthChange={(width) => setFieldWidth(width)} />
</Button>
</Tooltip>
{openMenu && (
<GridFieldMenu field={field} open={openMenu} anchorEl={previewRef.current} onClose={handleMenuClose} />
)}
</>
</div>
);
};

View File

@ -7,20 +7,14 @@ import { FieldTypeSvg } from './FieldTypeSvg';
import { FieldTypeText } from './FieldTypeText';
import { GridFieldMenuActions } from './GridFieldMenuActions';
export interface GridFieldMenuProps {
field: Field;
anchorEl: MenuProps['anchorEl'];
open: boolean;
onClose: MenuProps['onClose'];
onClose: () => void;
}
export const GridFieldMenu: FC<GridFieldMenuProps> = ({
field,
anchorEl,
open,
onClose,
}) => {
export const GridFieldMenu: FC<GridFieldMenuProps> = ({ field, anchorEl, open, onClose }) => {
const viewId = useViewId();
const [inputtingName, setInputtingName] = useState(field.name);
@ -43,8 +37,8 @@ export const GridFieldMenu: FC<GridFieldMenuProps> = ({
const fieldNameInput = (
<OutlinedInput
className="mx-3 mt-1 mb-5 !rounded-[10px]"
size="small"
className='mx-3 mb-5 mt-1 !rounded-[10px]'
size='small'
value={inputtingName}
onChange={handleInput}
onBlur={handleBlur}
@ -53,24 +47,27 @@ export const GridFieldMenu: FC<GridFieldMenuProps> = ({
const fieldTypeSelect = (
<MenuItem dense>
<FieldTypeSvg type={field.type} className="text-base mr-2" />
<span className="flex-1 text-xs font-medium">
{FieldTypeText(field.type)}
</span>
<MoreSvg className="text-base" />
<FieldTypeSvg type={field.type} className='mr-2 text-base' />
<span className='flex-1 text-xs font-medium'>{FieldTypeText(field.type)}</span>
<MoreSvg className='text-base' />
</MenuItem>
);
const isPrimary = field.isPrimary;
return (
<Menu
anchorEl={anchorEl}
open={open}
onClose={onClose}
>
{fieldNameInput}
{fieldTypeSelect}
<Divider />
<GridFieldMenuActions />
</Menu>
<>
<Menu anchorEl={anchorEl} open={open} onClose={onClose}>
{fieldNameInput}
{!isPrimary && (
<>
{fieldTypeSelect}
<Divider />
</>
)}
<GridFieldMenuActions isPrimary={isPrimary} onMenuItemClick={() => onClose()} fieldId={field.id} />
</Menu>
</>
);
};

View File

@ -6,6 +6,11 @@ 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';
import { ReactComponent as RightSvg } from '$app/assets/right.svg';
import { fieldService } from '$app/components/database/application';
import { FieldVisibility } from '@/services/backend';
import { useViewId } from '$app/hooks';
import ConfirmDialog from '$app/components/_shared/app-dialog/ConfirmDialog';
import { useState } from 'react';
enum FieldAction {
Hide = 'hide',
@ -25,26 +30,79 @@ const FieldActionSvgMap = {
const TwoColumnActions: FieldAction[][] = [
[FieldAction.Hide, FieldAction.Duplicate, FieldAction.Delete],
[FieldAction.InsertLeft, FieldAction.InsertRight],
// [FieldAction.InsertLeft, FieldAction.InsertRight],
];
export const GridFieldMenuActions = () => {
// prevent default actions for primary fields
const primaryPreventDefaultActions = [FieldAction.Delete, FieldAction.Duplicate];
interface GridFieldMenuActionsProps {
fieldId: string;
isPrimary?: boolean;
onMenuItemClick?: (action: FieldAction) => void;
}
export const GridFieldMenuActions = ({ fieldId, onMenuItemClick, isPrimary }: GridFieldMenuActionsProps) => {
const viewId = useViewId();
const [openConfirm, setOpenConfirm] = useState(false);
const handleOpenConfirm = () => {
setOpenConfirm(true);
};
const handleMenuItemClick = async (action: FieldAction) => {
const preventDefault = isPrimary && primaryPreventDefaultActions.includes(action);
if (preventDefault) {
return;
}
switch (action) {
case FieldAction.Hide:
await fieldService.updateFieldSetting(viewId, fieldId, {
visibility: FieldVisibility.AlwaysHidden,
});
break;
case FieldAction.Duplicate:
await fieldService.duplicateField(viewId, fieldId);
break;
case FieldAction.Delete:
handleOpenConfirm();
return;
}
onMenuItemClick?.(action);
};
return (
<Grid container spacing={2}>
<Grid container columns={TwoColumnActions.length} spacing={2}>
{TwoColumnActions.map((column, index) => (
<Grid key={index} item xs={6}>
{column.map(action => {
{column.map((action) => {
const ActionSvg = FieldActionSvgMap[action];
const disabled = isPrimary && primaryPreventDefaultActions.includes(action);
return (
<MenuItem key={action} dense>
<ActionSvg className="mr-2 text-base" />
<MenuItem disabled={disabled} onClick={() => handleMenuItemClick(action)} key={action} dense>
<ActionSvg className='mr-2 text-base' />
{t(`grid.field.${action}`)}
</MenuItem>
);
})}
</Grid>
))}
<ConfirmDialog
open={openConfirm}
subtitle={''}
title={t('grid.field.deleteFieldPromptMessage')}
onOk={async () => {
await fieldService.deleteField(viewId, fieldId);
}}
onClose={() => {
setOpenConfirm(false);
onMenuItemClick?.(FieldAction.Delete);
}}
/>
</Grid>
);
};
};

View File

@ -0,0 +1,92 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Field, fieldService } from '$app/components/database/application';
import { useViewId } from '$app/hooks';
interface GridResizerProps {
field: Field;
onWidthChange?: (width: number) => void;
}
const minWidth = 100;
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 onResize = useCallback(
(e: MouseEvent) => {
const diff = e.clientX - startX.current;
const newWidth = width + diff;
if (newWidth < minWidth) {
return;
}
setNewWidth(newWidth);
},
[width]
);
useEffect(() => {
onWidthChange?.(newWidth);
}, [newWidth, onWidthChange]);
useEffect(() => {
if (!isResizing && width !== newWidth) {
void fieldService.updateFieldSetting(viewId, fieldId, {
width: newWidth,
});
}
}, [fieldId, isResizing, newWidth, viewId, width]);
const onResizeEnd = useCallback(() => {
setIsResizing(false);
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onResizeEnd);
}, [onResize]);
const onResizeStart = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
startX.current = e.clientX;
setIsResizing(true);
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', onResizeEnd);
},
[onResize, onResizeEnd]
);
return (
<div
onMouseDown={onResizeStart}
onClick={(e) => {
e.stopPropagation();
}}
onMouseEnter={() => {
setHover(true);
}}
onMouseLeave={() => {
setHover(false);
}}
style={{
right: `-3px`,
}}
className={'absolute top-0 z-10 h-full cursor-col-resize'}
>
<div
className={'h-full w-[6px] select-none bg-transparent'}
style={{
backgroundColor: hover || isResizing ? 'var(--content-on-fill-hover)' : 'transparent',
}}
></div>
</div>
);
}
export default GridResizer;

View File

@ -1,3 +1,17 @@
export const GridCalculateRow = () => {
return null;
};
import React from 'react';
import { useDatabaseVisibilityFields } from '$app/components/database';
import GridCalculate from '$app/components/database/grid/GridCalculate/GridCalculate';
function GridCalculateRow() {
const fields = useDatabaseVisibilityFields();
return (
<div className='flex grow items-center'>
{fields.map((field, index) => {
return <GridCalculate index={index} key={field.id} field={field} />;
})}
</div>
);
}
export default GridCalculateRow;

View File

@ -14,6 +14,12 @@ export function useGridRowActionsDisplay(rowId: string, ref: React.RefObject<HTM
setRowHover(rowId);
}, [setRowHover, rowId]);
const onMouseLeave = useCallback(() => {
if (hover) {
setRowHover(null);
}
}, [setRowHover, hover]);
useEffect(() => {
// Next frame to avoid layout thrashing
requestAnimationFrame(() => {
@ -37,6 +43,7 @@ export function useGridRowActionsDisplay(rowId: string, ref: React.RefObject<HTM
return {
actionsStyle,
onMouseEnter,
onMouseLeave,
hover,
};
}

View File

@ -3,7 +3,7 @@ import { Portal } from '@mui/material';
import { DragEventHandler, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { throttle } from '$app/utils/tool';
import { useViewId } from '$app/hooks';
import { useDatabase } from '../../../Database.hooks';
import { useDatabaseVisibilityFields } from '../../../Database.hooks';
import { rowService, RowMeta } from '../../../application';
import {
DragItem,
@ -21,6 +21,7 @@ import {
useGridRowContextMenu,
} from '$app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks';
import GridCellRowContextMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowContextMenu';
import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow';
export interface GridCellRowProps {
rowMeta: RowMeta;
@ -32,14 +33,14 @@ export const GridCellRow: FC<GridCellRowProps> = ({ rowMeta, virtualizer, getPre
const rowId = rowMeta.id;
const viewId = useViewId();
const ref = useRef<HTMLDivElement | null>(null);
const { onMouseEnter, actionsStyle, hover } = useGridRowActionsDisplay(rowId, ref);
const { onMouseLeave, onMouseEnter, actionsStyle, hover } = useGridRowActionsDisplay(rowId, ref);
const {
isContextMenuOpen,
closeContextMenu,
openContextMenu,
position: contextMenuPosition,
} = useGridRowContextMenu();
const { fields } = useDatabase();
const fields = useDatabaseVisibilityFields();
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
const dragData = useMemo(
@ -106,7 +107,7 @@ export const GridCellRow: FC<GridCellRowProps> = ({ rowMeta, virtualizer, getPre
}, [openContextMenu]);
return (
<div ref={ref} className='flex grow' onMouseEnter={onMouseEnter} {...dropListeners}>
<div ref={ref} className='flex grow' onMouseLeave={onMouseLeave} onMouseEnter={onMouseEnter} {...dropListeners}>
<div
ref={setPreviewRef}
className={`relative flex grow border-b border-line-divider ${isDragging ? 'bg-blue-50' : ''}`}
@ -117,7 +118,7 @@ export const GridCellRow: FC<GridCellRowProps> = ({ rowMeta, virtualizer, getPre
virtualizer={virtualizer}
renderItem={(index) => <GridCell rowId={rowMeta.id} field={fields[index]} />}
/>
<div className='min-w-20 grow' />
<div className={`w-[${DEFAULT_FIELD_WIDTH}px]`} />
{isOver && (
<div
className={`absolute left-0 right-0 z-10 h-0.5 bg-blue-500 ${
@ -137,13 +138,15 @@ export const GridCellRow: FC<GridCellRowProps> = ({ rowMeta, virtualizer, getPre
rowId={rowMeta.id}
getPrevRowId={getPrevRowId}
/>
<GridCellRowContextMenu
open={isContextMenuOpen}
onClose={closeContextMenu}
anchorPosition={contextMenuPosition}
rowId={rowId}
getPrevRowId={getPrevRowId}
/>
{isContextMenuOpen && (
<GridCellRowContextMenu
open={isContextMenuOpen}
onClose={closeContextMenu}
anchorPosition={contextMenuPosition}
rowId={rowId}
getPrevRowId={getPrevRowId}
/>
)}
</Portal>
</div>
);

View File

@ -1,36 +1,31 @@
import { Virtualizer } from '@tanstack/react-virtual';
import { FC } from 'react';
import { Button } from '@mui/material';
import { FieldType } from '@/services/backend';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { fieldService } from '../../application';
import { useDatabase } from '../../Database.hooks';
import { VirtualizedList } from '../../_shared';
import { useDatabaseVisibilityFields } from '../../Database.hooks';
import { GridField } from '../GridField';
import { useViewId } from '@/appflowy_app/hooks';
import { useTranslation } from 'react-i18next';
import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow/constants';
export interface GridFieldRowProps {
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
}
export const GridFieldRow: FC<GridFieldRowProps> = ({ virtualizer }) => {
export const GridFieldRow = () => {
const { t } = useTranslation();
const viewId = useViewId();
const { fields } = useDatabase();
const fields = useDatabaseVisibilityFields();
const handleClick = async () => {
await fieldService.createField(viewId, FieldType.RichText);
};
return (
<div className='z-10 flex border-b border-line-divider'>
<VirtualizedList
className='flex'
virtualizer={virtualizer}
itemClassName='flex border-r border-line-divider'
renderItem={(index) => <GridField field={fields[index]} />}
/>
<div className='min-w-20 grow'>
<div className={'flex'}>
{fields.map((field) => {
return <GridField key={field.id} field={field} />;
})}
</div>
<div className={`w-[${DEFAULT_FIELD_WIDTH}px]`}>
<Button
color={'inherit'}
className='flex h-full w-full items-center justify-start whitespace-nowrap text-left'

View File

@ -2,9 +2,9 @@ import { Virtualizer } from '@tanstack/react-virtual';
import { FC } from 'react';
import { RenderRow, RenderRowType } from './constants';
import { GridCellRow } from './GridCellRow';
import { GridFieldRow } from './GridFieldRow';
import { GridNewRow } from './GridNewRow';
import { GridCalculateRow } from './GridCalculateRow';
import { GridFieldRow } from '$app/components/database/grid/GridRow/GridFieldRow';
import GridCalculateRow from '$app/components/database/grid/GridRow/GridCalculateRow';
export interface GridRowProps {
row: RenderRow;
@ -14,13 +14,13 @@ export interface GridRowProps {
export const GridRow: FC<GridRowProps> = ({ row, virtualizer, getPrevRowId }) => {
switch (row.type) {
case RenderRowType.Fields:
return <GridFieldRow />;
case RenderRowType.Row:
return <GridCellRow rowMeta={row.data.meta} virtualizer={virtualizer} getPrevRowId={getPrevRowId} />;
case RenderRowType.Fields:
return <GridFieldRow virtualizer={virtualizer} />;
case RenderRowType.NewRow:
return <GridNewRow startRowId={row.data.startRowId} groupId={row.data.groupId} />;
case RenderRowType.Calculate:
case RenderRowType.CalculateRow:
return <GridCalculateRow />;
default:
return null;

View File

@ -1,10 +1,18 @@
import { RowMeta } from '../../application';
export const GridCalculateCountHeight = 40;
export const DEFAULT_FIELD_WIDTH = 150;
export enum RenderRowType {
Fields = 'fields',
Row = 'row',
NewRow = 'new-row',
Calculate = 'calculate',
CalculateRow = 'calculate-row',
}
export interface CalculateRenderRow {
type: RenderRowType.CalculateRow;
}
export interface FieldRenderRow {
@ -26,10 +34,6 @@ export interface NewRenderRow {
};
}
export interface CalculateRenderRow {
type: RenderRowType.Calculate;
}
export type RenderRow = FieldRenderRow | CellRenderRow | NewRenderRow | CalculateRenderRow;
export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => {
@ -50,7 +54,7 @@ export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => {
},
},
{
type: RenderRowType.Calculate,
type: RenderRowType.CalculateRow,
},
];
};

View File

@ -1,9 +1,9 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { FC, useMemo, useRef } from 'react';
import { RowMeta } from '../../application';
import { useDatabase } from '../../Database.hooks';
import { useDatabase, useDatabaseVisibilityFields } from '../../Database.hooks';
import { VirtualizedList } from '../../_shared';
import { GridRow, RenderRow, RenderRowType, rowMetasToRenderRow } from '../GridRow';
import { DEFAULT_FIELD_WIDTH, GridRow, RenderRow, RenderRowType, rowMetasToRenderRow } from '../GridRow';
const getRenderRowKey = (row: RenderRow) => {
if (row.type === RenderRowType.Row) {
@ -16,9 +16,9 @@ const getRenderRowKey = (row: RenderRow) => {
export const GridTable: FC<{ tableHeight: number }> = ({ tableHeight }) => {
const verticalScrollElementRef = useRef<HTMLDivElement | null>(null);
const horizontalScrollElementRef = useRef<HTMLDivElement | null>(null);
const { rowMetas, fields } = useDatabase();
const { rowMetas } = useDatabase();
const renderRows = useMemo<RenderRow[]>(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]);
const fields = useDatabaseVisibilityFields();
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: renderRows.length,
overscan: 20,
@ -33,7 +33,9 @@ export const GridTable: FC<{ tableHeight: number }> = ({ tableHeight }) => {
overscan: 5,
getItemKey: (i) => fields[i].id,
getScrollElement: () => horizontalScrollElementRef.current,
estimateSize: (i) => fields[i].width ?? 201,
estimateSize: (i) => {
return fields[i].width ?? DEFAULT_FIELD_WIDTH;
},
});
const getPrevRowId = (id: string) => {

View File

@ -8,7 +8,7 @@ import { useSelection } from '$app/components/document/_shared/EditorHooks/useSe
import { useAppSelector } from '$app/stores/store';
import { ThemeMode } from '$app/interfaces';
export default function CodeBlock({
export default React.memo(function CodeBlock({
node,
placeholder,
...props
@ -40,4 +40,4 @@ export default function CodeBlock({
/>
</div>
);
}
});

View File

@ -84,4 +84,4 @@ function EquationBlock({ node }: { node: NestedBlock<BlockType.EquationBlock> })
);
}
export default EquationBlock;
export default React.memo(EquationBlock);

View File

@ -33,4 +33,4 @@ function GridBlock({ node }: { node: NestedBlock<BlockType.GridBlock> }) {
);
}
export default GridBlock;
export default React.memo(GridBlock);

View File

@ -77,4 +77,4 @@ function ImageBlock({ node }: { node: NestedBlock<BlockType.ImageBlock> }) {
);
}
export default ImageBlock;
export default React.memo(ImageBlock);

View File

@ -11,4 +11,4 @@ function NodeChildren({ childIds, ...props }: { childIds?: string[] } & React.HT
) : null;
}
export default NodeChildren;
export default React.memo(NodeChildren);

View File

@ -96,7 +96,7 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
);
}
const NodeWithErrorBoundary = withErrorBoundary(NodeComponent, {
const NodeWithErrorBoundary = withErrorBoundary(React.memo(NodeComponent), {
FallbackComponent: ErrorBoundaryFallbackComponent,
});
@ -110,4 +110,4 @@ const UnSupportedBlock = () => {
);
};
export default React.memo(NodeWithErrorBoundary);
export default NodeWithErrorBoundary;

View File

@ -27,8 +27,8 @@ function Root({ documentData }: { documentData: DocumentData }) {
);
}
const RootWithErrorBoundary = withErrorBoundary(Root, {
const RootWithErrorBoundary = withErrorBoundary(React.memo(Root), {
FallbackComponent: ErrorBoundaryFallbackComponent,
});
export default React.memo(RootWithErrorBoundary);
export default RootWithErrorBoundary;

View File

@ -14,6 +14,7 @@ import {
RowsChangePB,
RowsVisibilityChangePB,
SortChangesetNotificationPB,
FieldSettingsPB,
} from '@/services/backend';
const NotificationPBMap = {
@ -28,6 +29,7 @@ const NotificationPBMap = {
[DatabaseNotification.DidUpdateField]: FieldPB,
[DatabaseNotification.DidUpdateCell]: null,
[DatabaseNotification.DidUpdateSort]: SortChangesetNotificationPB,
[DatabaseNotification.DidUpdateFieldSettings]: FieldSettingsPB,
};
type NotificationMap = typeof NotificationPBMap;