feat: parity features for flutter grid (#4082)

* feat: parity features of flutter grid

* feat: replace another virtual scroll component

* fix: fix eslint error

* fix: modify the drag style

* fix: remove log

* fix: add css style for row when context menu display
This commit is contained in:
Kilu.He 2023-12-06 14:54:41 +08:00 committed by GitHub
parent fe5ce75ea8
commit d765806337
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 2042 additions and 1356 deletions

View File

@ -68,5 +68,5 @@ module.exports = {
]
},
ignorePatterns: ['src/**/*.test.ts'],
ignorePatterns: ['src/**/*.test.ts', 'package.json'],
};

View File

@ -61,6 +61,9 @@
"react-router-dom": "^6.8.0",
"react-swipeable-views": "^0.14.0",
"react-transition-group": "^4.4.5",
"react-virtualized-auto-sizer": "^1.0.20",
"react-vtree": "^2.0.4",
"react-window": "^1.8.10",
"react18-input-otp": "^1.1.2",
"redux": "^4.2.1",
"rxjs": "^7.8.0",
@ -89,6 +92,7 @@
"@types/react-dom": "^18.0.6",
"@types/react-katex": "^3.0.0",
"@types/react-transition-group": "^4.4.6",
"@types/react-window": "^1.8.8",
"@types/utf8": "^3.0.1",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.51.0",

View File

@ -130,6 +130,15 @@ dependencies:
react-transition-group:
specifier: ^4.4.5
version: 4.4.5(react-dom@18.2.0)(react@18.2.0)
react-virtualized-auto-sizer:
specifier: ^1.0.20
version: 1.0.20(react-dom@18.2.0)(react@18.2.0)
react-vtree:
specifier: ^2.0.4
version: 2.0.4(@types/react-window@1.8.8)(react-dom@18.2.0)(react-window@1.8.10)(react@18.2.0)
react-window:
specifier: ^1.8.10
version: 1.8.10(react-dom@18.2.0)(react@18.2.0)
react18-input-otp:
specifier: ^1.1.2
version: 1.1.3(react-dom@18.2.0)(react@18.2.0)
@ -210,6 +219,9 @@ devDependencies:
'@types/react-transition-group':
specifier: ^4.4.6
version: 4.4.6
'@types/react-window':
specifier: ^1.8.8
version: 1.8.8
'@types/utf8':
specifier: ^3.0.1
version: 3.0.1
@ -2308,6 +2320,11 @@ packages:
'@types/react': 18.2.6
dev: false
/@types/react-window@1.8.8:
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
dependencies:
'@types/react': 18.2.6
/@types/react@17.0.59:
resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==}
dependencies:
@ -2733,7 +2750,7 @@ packages:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
dependencies:
'@babel/runtime': 7.22.10
'@babel/runtime': 7.23.4
cosmiconfig: 7.1.0
resolve: 1.22.2
dev: false
@ -3235,7 +3252,7 @@ packages:
/dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dependencies:
'@babel/runtime': 7.21.5
'@babel/runtime': 7.23.4
csstype: 3.1.2
dev: false
@ -5632,7 +5649,7 @@ packages:
peerDependencies:
react: ^16.3.0
dependencies:
'@babel/runtime': 7.22.10
'@babel/runtime': 7.23.4
prop-types: 15.8.1
react: 18.2.0
warning: 4.0.3
@ -5717,7 +5734,7 @@ packages:
react-native:
optional: true
dependencies:
'@babel/runtime': 7.21.5
'@babel/runtime': 7.23.4
'@types/react-redux': 7.1.25
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
@ -5839,6 +5856,44 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-virtualized-auto-sizer@1.0.20(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA==}
peerDependencies:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-vtree@2.0.4(@types/react-window@1.8.8)(react-dom@18.2.0)(react-window@1.8.10)(react@18.2.0):
resolution: {integrity: sha512-UOld0VqyAZrryF06K753X4bcEVN6/wW831exvVlMZeZAVHk9KXnlHs4rpqDAeoiBgUwJqoW/rtn0hwsokRRxPA==}
peerDependencies:
'@types/react-window': ^1.8.2
react: ^16.13.1
react-dom: ^16.13.1
react-window: ^1.8.5
dependencies:
'@babel/runtime': 7.23.4
'@types/react-window': 1.8.8
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-window: 1.8.10(react-dom@18.2.0)(react@18.2.0)
dev: false
/react-window@1.8.10(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
dependencies:
'@babel/runtime': 7.23.4
memoize-one: 5.2.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react18-input-otp@1.1.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-55dZMVX61In2ngUhA4Fv0NMY4j5RZjxrJaSOAnJGJmkAhxKB6puVHYEmipyy2+W2CPydFF7pv+0NKzPUA03EVg==}
peerDependencies:

View File

@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { proxy, useSnapshot } from 'valtio';
@ -52,6 +52,26 @@ export const DatabaseProvider = DatabaseContext.Provider;
export const useDatabase = () => useSnapshot(useContext(DatabaseContext));
export const useContextDatabase = () => useContext(DatabaseContext);
export const useGetPrevRowId = () => {
const database = useContextDatabase();
return useCallback(
(id: string) => {
const rowMetas = database.rowMetas;
const index = rowMetas.findIndex((rowMeta) => rowMeta.id === id);
if (index === 0) {
return null;
}
return rowMetas[index - 1].id;
},
[database]
);
};
export const useSelectorCell = (rowId: string, fieldId: string) => {
const database = useContext(DatabaseContext);
const cells = useSnapshot(database.cells);
@ -181,50 +201,3 @@ export const useConnectDatabase = (viewId: string) => {
return database;
};
export function useDatabaseResize(selectedViewId?: string) {
const ref = useRef<HTMLDivElement>(null);
const collectionRef = useRef<HTMLDivElement>(null);
const [openCollections, setOpenCollections] = useState<string[]>([]);
const [tableHeight, setTableHeight] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const collectionElement = collectionRef.current;
const handleResize = () => {
const rect = element.getBoundingClientRect();
const collectionRect = collectionElement?.getBoundingClientRect();
let height = rect.height - 31;
if (collectionRect) {
height -= collectionRect.height;
}
setTableHeight(height);
};
handleResize();
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(element);
if (collectionElement) {
resizeObserver.observe(collectionElement);
}
return () => {
resizeObserver.disconnect();
};
}, [selectedViewId]);
return {
ref,
collectionRef,
tableHeight,
openCollections,
setOpenCollections,
};
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useViewId } from '$app/hooks/ViewId.hooks';
import { databaseViewService } from './application';
import { DatabaseTabBar } from './components';
@ -8,11 +8,11 @@ import { DatabaseCollection } from './components/database_settings';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import SwipeableViews from 'react-swipeable-views';
import { TabPanel } from '$app/components/database/components/tab_bar/ViewTabs';
import { useDatabaseResize } from '$app/components/database/Database.hooks';
import DatabaseSettings from '$app/components/database/components/database_settings/DatabaseSettings';
import { Portal } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { ErrorCode } from '@/services/backend';
import ExpandRecordModal from '$app/components/database/components/edit_record/ExpandRecordModal';
interface Props {
selectedViewId?: string;
@ -20,11 +20,13 @@ interface Props {
}
export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const viewId = useViewId();
const { t } = useTranslation();
const [notFound, setNotFound] = useState(false);
const [childViewIds, setChildViewIds] = useState<string[]>([]);
const { ref, collectionRef, tableHeight, openCollections, setOpenCollections } = useDatabaseResize(selectedViewId);
const [editRecordRowId, setEditRecordRowId] = useState<string | null>(null);
const [openCollections, setOpenCollections] = useState<string[]>([]);
useEffect(() => {
const onPageChanged = () => {
@ -79,6 +81,13 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
[openCollections, setOpenCollections]
);
const onEditRecord = useCallback(
(rowId: string) => {
setEditRecordRowId(rowId);
},
[setEditRecordRowId]
);
if (notFound) {
return (
<div className='mb-2 flex h-full w-full items-center justify-center rounded border border-dashed border-line-divider'>
@ -104,7 +113,7 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
index={value}
>
{childViewIds.map((id, index) => (
<TabPanel key={id} index={index} value={value}>
<TabPanel className={'flex h-full w-full flex-col'} key={id} index={index} value={value}>
<DatabaseLoader viewId={id}>
{selectedViewId === id && (
<>
@ -115,13 +124,20 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
/>
</div>
</Portal>
<div ref={collectionRef}>
<DatabaseCollection open={openCollections.includes(id)} />
</div>
<DatabaseCollection open={openCollections.includes(id)} />
{editRecordRowId && (
<ExpandRecordModal
rowId={editRecordRowId}
open={Boolean(editRecordRowId)}
onClose={() => {
setEditRecordRowId(null);
}}
/>
)}
</>
)}
<DatabaseView isActivated={selectedViewId === id} tableHeight={tableHeight} />
<DatabaseView onEditRecord={onEditRecord} />
</DatabaseLoader>
</TabPanel>
))}

View File

@ -6,8 +6,7 @@ import { Board } from './board';
import { Calendar } from './calendar';
export const DatabaseView: FC<{
tableHeight: number;
isActivated: boolean;
onEditRecord: (rowId: string) => void;
}> = (props) => {
const { layoutType } = useDatabase();

View File

@ -9,8 +9,8 @@ export const CellText = React.forwardRef<HTMLDivElement, PropsWithChildren<HTMLA
const { children, className, ...other } = props;
return (
<div ref={ref} className={['flex h-full w-full p-2', className].join(' ')} {...other}>
<span className='flex-1 truncate text-sm'>{children}</span>
<div ref={ref} className={['flex w-full p-2', className].join(' ')} {...other}>
<span className='inline-block flex-1 truncate text-sm'>{children}</span>
</div>
);
}

View File

@ -10,6 +10,7 @@ export interface UseDraggableOptions {
disabled?: boolean;
scrollOnEdge?: {
direction?: ScrollDirection;
getScrollElement?: () => HTMLElement | null;
edgeGap?: number | Partial<EdgeGap>;
};
}
@ -73,7 +74,8 @@ export const useDraggable = ({
return;
}
const scrollParent: HTMLElement | null = getScrollParent(event.target as HTMLElement, scrollDirection);
const scrollParent: HTMLElement | null =
scrollOnEdge?.getScrollElement?.() ?? getScrollParent(event.target as HTMLElement, scrollDirection);
if (scrollParent) {
autoScrollOnEdge({
@ -83,7 +85,7 @@ export const useDraggable = ({
});
}
},
[context, effectAllowed, scrollDirection, edgeGap]
[context, effectAllowed, scrollDirection, scrollOnEdge, edgeGap]
);
const onDragEnd = useCallback<DragEventHandler>(() => {

View File

@ -11,6 +11,7 @@ import {
FieldSettingsChangesetPB,
FieldVisibility,
DatabaseViewIdPB,
CreateFieldPosition,
} from '@/services/backend';
import {
DatabaseEventDuplicateField,
@ -80,11 +81,25 @@ export async function getFields(
return { fields, typeOptions };
}
export async function createField(viewId: string, fieldType?: FieldType, data?: Uint8Array): Promise<Field> {
export async function createField({
viewId,
targetFieldId,
fieldPosition,
fieldType,
data,
}: {
viewId: string;
targetFieldId?: string;
fieldPosition?: CreateFieldPosition;
fieldType?: FieldType;
data?: Uint8Array;
}): Promise<Field> {
const payload = CreateFieldPayloadPB.fromObject({
view_id: viewId,
field_type: fieldType,
type_option_data: data,
target_field_id: targetFieldId,
field_position: fieldPosition,
});
const result = await DatabaseEventCreateField(payload);
@ -188,3 +203,12 @@ export async function updateFieldSetting(
return result.val;
}
export const reorderFields = (list: Field[], startIndex: number, endIndex: number) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};

View File

@ -29,7 +29,7 @@ export const useCell = (rowId: string, field: Field) => {
clearTimeout(timeout);
};
}
}, [fetchCell, cell, loading]);
}, [fetchCell, cell, loading, rowId, field.id]);
useNotification(DatabaseNotification.DidUpdateCell, fetchCell, { id: `${rowId}:${field.id}` });

View File

@ -1,4 +1,4 @@
import { FC } from 'react';
import React, { FC, HTMLAttributes } from 'react';
import { FieldType } from '@/services/backend';
import { Cell as CellType, Field } from '../../application';
@ -12,18 +12,17 @@ import ChecklistCell from '$app/components/database/components/cell/ChecklistCel
import DateTimeCell from '$app/components/database/components/cell/DateTimeCell';
import TimestampCell from '$app/components/database/components/cell/TimestampCell';
export interface CellProps {
export interface CellProps extends HTMLAttributes<HTMLDivElement> {
rowId: string;
field: Field;
documentId?: string;
icon?: string;
placeholder?: string;
}
interface CellComponentProps {
field: Field;
export interface CellComponentProps extends CellProps {
cell: CellType;
}
const getCellComponent = (fieldType: FieldType) => {
switch (fieldType) {
case FieldType.RichText:
@ -62,5 +61,5 @@ export const Cell: FC<CellProps> = ({ rowId, field, ...props }) => {
return null;
}
return <Component {...props} field={field} cell={cell} />;
return <Component {...props} rowId={rowId} field={field} cell={cell} />;
};

View File

@ -16,7 +16,10 @@ export const CheckboxCell: FC<{
}, [viewId, cell, field.id, checked]);
return (
<div className='relative flex w-full cursor-pointer items-center px-2 text-fill-default' onClick={handleClick}>
<div
className='relative flex w-full cursor-pointer items-center px-2 text-lg text-fill-default'
onClick={handleClick}
>
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
</div>
);

View File

@ -1,43 +0,0 @@
import React, { useState } from 'react';
import { TextCell } from '$app/components/database/application';
import { IconButton } from '@mui/material';
import { ReactComponent as OpenIcon } from '$app/assets/open.svg';
const ExpandCellModal = React.lazy(() => import('$app/components/database/components/cell/expand_type/ExpandCellModal'));
interface Props {
cell: TextCell;
visible?: boolean;
documentId?: string;
icon?: string;
}
function ExpandButton({ cell, documentId, icon, visible }: Props) {
const [open, setOpen] = useState(false);
const onClose = () => {
setOpen(false);
};
return (
<>
{visible && (
<div
style={{
transform: 'translateY(-50%) translateZ(0)',
}}
className={`absolute right-0 top-1/2 mr-4 flex items-center justify-center`}
>
<IconButton onClick={() => setOpen(true)} className={'h-6 w-6 text-sm'}>
<OpenIcon />
</IconButton>
</div>
)}
{open && documentId && (
<ExpandCellModal documentId={documentId} icon={icon} cell={cell} open={open} onClose={onClose} />
)}
</>
);
}
export default ExpandButton;

View File

@ -29,7 +29,7 @@ function NumberCell({ field, cell, placeholder }: Props) {
return (
<>
<CellText className={'min-h-[36px]'} ref={cellRef} onClick={handleClick}>
<div className='flex h-full w-full items-center'>{content}</div>
<div className='flex w-full items-center'>{content}</div>
</CellText>
<Suspense>
{editing && (

View File

@ -1,28 +1,17 @@
import { FC, FormEventHandler, Suspense, lazy, useCallback, useEffect, useRef, useMemo } from 'react';
import { Field, TextCell as TextCellType } from '../../application';
import { FC, FormEventHandler, Suspense, lazy, useCallback, useRef, useMemo } from 'react';
import { TextCell as TextCellType } from '../../application';
import { CellText } from '../../_shared';
import { useGridUIStateDispatcher, useGridUIStateSelector } from '$app/components/database/proxy/grid/ui_state/actions';
import { useInputCell } from '$app/components/database/components/cell/Cell.hooks';
const ExpandButton = lazy(() => import('$app/components/database/components/cell/ExpandButton'));
const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput'));
export const TextCell: FC<{
field: Field;
interface TextCellProps {
cell: TextCellType;
documentId?: string;
icon?: string;
placeholder?: string;
}> = ({ field, documentId, icon, placeholder, cell }) => {
const isPrimary = field.isPrimary;
}
export const TextCell: FC<TextCellProps> = ({ placeholder, cell }) => {
const cellRef = useRef<HTMLDivElement>(null);
const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell);
const { hoverRowId } = useGridUIStateSelector();
const isHover = hoverRowId === cell?.rowId;
const { setRowHover } = useGridUIStateDispatcher();
const showExpandIcon = cell && !editing && isHover && isPrimary;
const handleClose = () => {
if (!cell) return;
updateCell();
@ -41,12 +30,6 @@ export const TextCell: FC<{
[setValue]
);
useEffect(() => {
if (editing) {
setRowHover(null);
}
}, [editing, setRowHover]);
const content = useMemo(() => {
if (cell && typeof cell.data === 'string' && cell.data) {
return cell.data;
@ -56,33 +39,21 @@ export const TextCell: FC<{
}, [cell, placeholder]);
return (
<div className={'relative h-full'}>
<CellText
style={{
width: `${field.width}px`,
minHeight: 37,
}}
ref={cellRef}
onClick={handleClick}
>
<div className={`flex h-full w-full items-center whitespace-break-spaces break-all`}>
{icon && <div className={'mr-2'}>{icon}</div>}
{content}
</div>
<>
<CellText className={`min-h-[36px] w-full`} ref={cellRef} onClick={handleClick}>
{content}
</CellText>
<Suspense>
{cell && <ExpandButton visible={showExpandIcon} icon={icon} documentId={documentId} cell={cell} />}
{editing && (
<EditTextCellInput
editing={editing}
anchorEl={cellRef.current}
width={field.width}
onClose={handleClose}
text={value}
onInput={handleInput}
/>
)}
</Suspense>
</div>
</>
);
};

View File

@ -71,14 +71,13 @@ function UrlCell({ field, cell, placeholder }: Props) {
ref={cellRef}
onClick={handleClick}
>
<div className={`flex w-full items-center whitespace-break-spaces break-all `}>{content}</div>
{content}
</CellText>
<Suspense>
{editing && (
<EditTextCellInput
editing={editing}
anchorEl={cellRef.current}
width={field.width}
onClose={handleClose}
text={value}
onInput={handleInput}

View File

@ -1,35 +1,95 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useDatabase } from '$app/components/database';
import { Field as FieldType } from '$app/components/database/application';
import { Field } from '$app/components/database/components/field';
import { Field as FieldType, fieldService } from '$app/components/database/application';
import { Property } from '$app/components/database/components/property';
import { FieldVisibility } from '@/services/backend';
import { ReactComponent as EyeOpen } from '$app/assets/eye_open.svg';
import { ReactComponent as EyeClosed } from '$app/assets/eye_close.svg';
import { MenuItem } from '@mui/material';
import { IconButton, MenuItem } from '@mui/material';
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
import { useViewId } from '$app/hooks';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
interface PropertiesProps {
onItemClick: (field: FieldType) => void;
}
function Properties({ onItemClick }: PropertiesProps) {
const { fields } = useDatabase();
const [state, setState] = useState<FieldType[]>(fields as FieldType[]);
const viewId = useViewId();
useEffect(() => {
setState(fields as FieldType[]);
}, [fields]);
const handleOnDragEnd = async (result: DropResult) => {
const { destination, draggableId, source } = result;
const newIndex = destination?.index;
const oldIndex = source.index;
if (oldIndex === newIndex) {
return;
}
if (newIndex === undefined || newIndex === null) {
return;
}
const newProperties = fieldService.reorderFields(fields as FieldType[], oldIndex, newIndex ?? 0);
setState(newProperties);
await fieldService.moveField(viewId, draggableId, oldIndex, newIndex);
};
return (
<div className={'max-h-[300px] overflow-y-auto py-2'}>
{fields.map((field) => (
<MenuItem
disabled={field.isPrimary}
onClick={() => onItemClick(field)}
className={'flex w-full items-center justify-between overflow-hidden px-1.5'}
key={field.id}
>
<div className={'w-[100px] overflow-hidden text-ellipsis'}>
<Field field={field} />
</div>
<DragDropContext onDragEnd={handleOnDragEnd}>
<Droppable droppableId='droppable' type='droppableItem'>
{(dropProvided) => (
<div
ref={dropProvided.innerRef}
{...dropProvided.droppableProps}
className={'max-h-[300px] overflow-y-auto py-2'}
>
{state.map((field, index) => (
<Draggable key={field.id} draggableId={field.id} index={index}>
{(provided) => {
return (
<MenuItem
ref={provided.innerRef}
{...provided.draggableProps}
className={'flex w-full items-center justify-between overflow-hidden px-1.5'}
key={field.id}
>
<IconButton
size={'small'}
{...provided.dragHandleProps}
className='mx-1 cursor-grab active:cursor-grabbing'
>
<DragSvg />
</IconButton>
<div className={'w-[100px] overflow-hidden text-ellipsis'}>
<Property field={field} />
</div>
<div className={'ml-2'}>{field.visibility !== FieldVisibility.AlwaysHidden ? <EyeOpen /> : <EyeClosed />}</div>
</MenuItem>
))}
</div>
<IconButton
disabled={field.isPrimary}
size={'small'}
onClick={() => onItemClick(field)}
className={'ml-2'}
>
{field.visibility !== FieldVisibility.AlwaysHidden ? <EyeOpen /> : <EyeClosed />}
</IconButton>
</MenuItem>
);
}}
</Draggable>
))}
{dropProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}

View File

@ -1,19 +1,23 @@
import React, { useCallback, useEffect, useState } from 'react';
import { TextCell } from '$app/components/database/application';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import RecordDocument from '$app/components/database/components/edit_record/RecordDocument';
import RecordHeader from '$app/components/database/components/edit_record/RecordHeader';
import { Page } from '$app_reducers/pages/slice';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { ErrorCode, ViewLayoutPB } from '@/services/backend';
import { Log } from '$app/utils/log';
import { useDatabase } from '$app/components/database';
interface Props {
cell: TextCell;
documentId: string;
icon?: string;
rowId: string;
}
function EditRecord({ documentId: id, cell, icon }: Props) {
function EditRecord({ rowId }: Props) {
const { rowMetas } = useDatabase();
const row = useMemo(() => {
return rowMetas.find((row) => row.id === rowId);
}, [rowMetas, rowId]);
const [page, setPage] = useState<Page | null>(null);
const id = row?.documentId;
const loadPage = useCallback(async () => {
if (!id) return;
@ -47,8 +51,10 @@ function EditRecord({ documentId: id, cell, icon }: Props) {
}, [loadPage]);
const getDocumentTitle = useCallback(() => {
return <RecordHeader page={page} cell={cell} icon={icon} />;
}, [cell, icon, page]);
return row ? <RecordHeader page={page} row={row} /> : null;
}, [row, page]);
if (!id) return null;
return (
<div className={'h-full px-12 py-6'}>

View File

@ -1,23 +1,20 @@
import React, { useState } from 'react';
import { DialogProps, IconButton } from '@mui/material';
import { DialogProps, IconButton, Portal } from '@mui/material';
import DialogContent from '@mui/material/DialogContent';
import Dialog from '@mui/material/Dialog';
import { TextCell } from '$app/components/database/application';
import { ReactComponent as DetailsIcon } from '$app/assets/details.svg';
import RecordActions from '$app/components/database/components/edit_record/RecordActions';
import EditRecord from '$app/components/database/components/edit_record/EditRecord';
interface Props extends DialogProps {
cell: TextCell;
documentId: string;
icon?: string;
rowId: string;
}
function ExpandCellModal({ open, onClose, cell, documentId, icon }: Props) {
function ExpandRecordModal({ open, onClose, rowId }: Props) {
const [detailAnchorEl, setDetailAnchorEl] = useState<HTMLButtonElement | null>(null);
return (
<>
<Portal>
<Dialog
disableAutoFocus={true}
keepMounted={false}
@ -38,17 +35,17 @@ function ExpandCellModal({ open, onClose, cell, documentId, icon }: Props) {
<DetailsIcon />
</IconButton>
<DialogContent>
<EditRecord cell={cell} documentId={documentId} icon={icon} />
<EditRecord rowId={rowId} />
</DialogContent>
</Dialog>
<RecordActions
anchorEl={detailAnchorEl}
cell={cell}
rowId={rowId}
open={!!detailAnchorEl}
onClose={() => setDetailAnchorEl(null)}
/>
</>
</Portal>
);
}
export default ExpandCellModal;
export default ExpandRecordModal;

View File

@ -3,17 +3,16 @@ import { Icon, Menu, MenuProps } from '@mui/material';
import { ReactComponent as DelSvg } from '$app/assets/delete.svg';
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
import { useTranslation } from 'react-i18next';
import { Cell, rowService } from '$app/components/database/application';
import { rowService } from '$app/components/database/application';
import { useViewId } from '$app/hooks';
import MenuItem from '@mui/material/MenuItem';
interface Props extends MenuProps {
cell: Cell;
rowId: string;
onClose?: () => void;
}
function RecordActions({ anchorEl, open, onClose, cell }: Props) {
function RecordActions({ anchorEl, open, onClose, rowId }: Props) {
const viewId = useViewId();
const rowId = cell.rowId;
const { t } = useTranslation();
const handleDelRow = useCallback(() => {

View File

@ -2,15 +2,14 @@ import React, { useEffect, useRef } from 'react';
import RecordTitle from '$app/components/database/components/edit_record/RecordTitle';
import RecordProperties from '$app/components/database/components/edit_record/record_properties/RecordProperties';
import { Divider } from '@mui/material';
import { TextCell } from '$app/components/database/application';
import { RowMeta } from '$app/components/database/application';
import { Page } from '$app_reducers/pages/slice';
interface Props {
page: Page | null;
cell: TextCell;
icon?: string;
row: RowMeta;
}
function RecordHeader({ page, cell, icon }: Props) {
function RecordHeader({ page, row }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -30,8 +29,8 @@ function RecordHeader({ page, cell, icon }: Props) {
return (
<div ref={ref} className={'pb-4'}>
<RecordTitle page={page} cell={cell} icon={icon} />
<RecordProperties documentId={page?.id} cell={cell} />
<RecordTitle page={page} row={row} />
<RecordProperties documentId={page?.id} row={row} />
<Divider />
</div>
);

View File

@ -1,30 +1,38 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { Page, PageIcon } from '$app_reducers/pages/slice';
import ViewTitle from '$app/components/_shared/ViewTitle';
import { ViewIconTypePB } from '@/services/backend';
import { useViewId } from '$app/hooks';
import { updateRowMeta } from '$app/components/database/application/row/row_service';
import { cellService, TextCell } from '$app/components/database/application';
import { cellService, Field, RowMeta, TextCell } from '$app/components/database/application';
import { useDatabase } from '$app/components/database';
import { useCell } from '$app/components/database/components/cell/Cell.hooks';
interface Props {
page: Page | null;
icon?: string;
cell: TextCell;
row: RowMeta;
}
function RecordTitle({ cell, page, icon }: Props) {
const { data: title, fieldId, rowId } = cell;
function RecordTitle({ row, page }: Props) {
const { fields } = useDatabase();
const field = useMemo(() => {
return fields.find((field) => field.isPrimary) as Field;
}, [fields]);
const rowId = row.id;
const cell = useCell(rowId, field) as TextCell;
const title = cell.data;
const viewId = useViewId();
const onTitleChange = useCallback(
async (title: string) => {
try {
await cellService.updateCell(viewId, rowId, fieldId, title);
await cellService.updateCell(viewId, rowId, field.id, title);
} catch (e) {
// toast.error('Failed to update title');
}
},
[fieldId, rowId, viewId]
[field.id, rowId, viewId]
);
const onUpdateIcon = useCallback(
@ -47,10 +55,10 @@ function RecordTitle({ cell, page, icon }: Props) {
view={{
...page,
name: title,
icon: icon
icon: row.icon
? {
ty: ViewIconTypePB.Emoji,
value: icon,
value: row.icon,
}
: undefined,
}}

View File

@ -1,54 +0,0 @@
import React, { MouseEvent, useCallback, useMemo, useState } from 'react';
import { fieldService } from '$app/components/database/application';
import { FieldType } from '@/services/backend';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useViewId } from '$app/hooks';
import { FieldMenu } from '$app/components/database/components/field/FieldMenu';
import { useDatabase } from '$app/components/database';
function NewProperty() {
const viewId = useViewId();
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const [updateFieldId, setUpdateFieldId] = useState<string>('');
const { fields } = useDatabase();
const updateField = useMemo(() => fields.find((field) => field.id === updateFieldId), [fields, updateFieldId]);
const handleClick = useCallback(
async (e: MouseEvent<HTMLButtonElement>) => {
try {
const field = await fieldService.createField(viewId, FieldType.RichText);
setUpdateFieldId(field.id);
setAnchorEl(e.target as HTMLButtonElement);
} catch (e) {
// toast.error(t('grid.field.newPropertyFail'));
}
},
[viewId]
);
return (
<>
<Button onClick={handleClick} className={'h-full w-full justify-start'} startIcon={<AddSvg />} color={'inherit'}>
{t('grid.field.newProperty')}
</Button>
{updateField && (
<FieldMenu
field={updateField}
anchorEl={anchorEl}
open={open}
onClose={() => {
setUpdateFieldId('');
setAnchorEl(null);
}}
/>
)}
</>
);
}
export default NewProperty;

View File

@ -1,26 +1,26 @@
import React, { HTMLAttributes, useCallback, useState } from 'react';
import React, { HTMLAttributes } from 'react';
import PropertyName from '$app/components/database/components/edit_record/record_properties/PropertyName';
import PropertyValue from '$app/components/database/components/edit_record/record_properties/PropertyValue';
import { Field } from '$app/components/database/application';
import PropertyActions from '$app/components/database/components/edit_record/record_properties/PropertyActions';
import { IconButton, Tooltip } from '@mui/material';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
import { useTranslation } from 'react-i18next';
interface Props extends HTMLAttributes<HTMLDivElement> {
field: Field;
rowId: string;
ishovered: boolean;
onHover: (id: string | null) => void;
menuOpened?: boolean;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
}
function Property({ field, rowId, ishovered, onHover, ...props }: Props, ref: React.ForwardedRef<HTMLDivElement>) {
const [openMenu, setOpenMenu] = useState(false);
const handleOpenMenu = useCallback(() => {
setOpenMenu(true);
}, []);
const handleCloseMenu = useCallback(() => {
setOpenMenu(false);
}, []);
function Property(
{ field, rowId, ishovered, onHover, menuOpened, onCloseMenu, onOpenMenu, ...props }: Props,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<>
@ -36,12 +36,20 @@ function Property({ field, rowId, ishovered, onHover, ...props }: Props, ref: Re
key={field.id}
{...props}
>
<PropertyName openMenu={openMenu} onOpenMenu={handleOpenMenu} onCloseMenu={handleCloseMenu} field={field} />
<PropertyName menuOpened={menuOpened} onCloseMenu={onCloseMenu} onOpenMenu={onOpenMenu} field={field} />
<PropertyValue rowId={rowId} field={field} />
{ishovered && <PropertyActions onOpenMenu={handleOpenMenu} />}
{ishovered && (
<div className={`absolute left-[-30px] flex h-full items-center `}>
<Tooltip placement='top' title={t('grid.row.dragAndClick')}>
<IconButton onClick={onOpenMenu} className='mx-1 cursor-grab active:cursor-grabbing'>
<DragSvg />
</IconButton>
</Tooltip>
</div>
)}
</div>
</>
);
}
export default React.memo(React.forwardRef(Property));
export default React.forwardRef(Property);

View File

@ -1,20 +0,0 @@
import React, { forwardRef } from 'react';
import { t } from 'i18next';
import { IconButton, Tooltip } from '@mui/material';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
interface Props {
onOpenMenu: () => void;
}
export default forwardRef<HTMLDivElement, Props>(function PropertyActions({ onOpenMenu }, ref) {
return (
<div ref={ref} className={`absolute left-[-30px] flex h-full items-center `}>
<Tooltip placement='top' title={t('grid.row.dragAndClick')}>
<IconButton onClick={onOpenMenu} className='mx-1 cursor-grab active:cursor-grabbing'>
<DragSvg />
</IconButton>
</Tooltip>
</div>
);
});

View File

@ -8,10 +8,12 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
properties: Field[];
rowId: string;
placeholderNode?: React.ReactNode;
openMenuPropertyId?: string;
setOpenMenuPropertyId?: (id?: string) => void;
}
function PropertyList(
{ documentId, properties, rowId, placeholderNode, ...props }: Props,
{ documentId, properties, rowId, placeholderNode, openMenuPropertyId, setOpenMenuPropertyId, ...props }: Props,
ref: React.ForwardedRef<HTMLDivElement>
) {
const [hoverId, setHoverId] = useState<string | null>(null);
@ -44,6 +46,15 @@ function PropertyList(
ishovered={field.id === hoverId}
field={field}
rowId={rowId}
menuOpened={openMenuPropertyId === field.id}
onOpenMenu={() => {
setOpenMenuPropertyId?.(field.id);
}}
onCloseMenu={() => {
if (openMenuPropertyId === field.id) {
setOpenMenuPropertyId?.(undefined);
}
}}
/>
);
}}
@ -55,4 +66,4 @@ function PropertyList(
);
}
export default React.memo(React.forwardRef(PropertyList));
export default React.forwardRef(PropertyList);

View File

@ -1,15 +1,14 @@
import React, { useRef } from 'react';
import { Field } from '$app/components/database/components/field';
import { Property } from '$app/components/database/components/property';
import { Field as FieldType } from '$app/components/database/application';
import { FieldMenu } from '$app/components/database/components/field/FieldMenu';
interface Props {
field: FieldType;
openMenu: boolean;
onOpenMenu: () => void;
onCloseMenu: () => void;
menuOpened?: boolean;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
}
function PropertyName({ field, openMenu, onOpenMenu, onCloseMenu }: Props) {
function PropertyName({ field, menuOpened = false, onOpenMenu, onCloseMenu }: Props) {
const ref = useRef<HTMLDivElement | null>(null);
return (
@ -19,14 +18,13 @@ function PropertyName({ field, openMenu, onOpenMenu, onCloseMenu }: Props) {
onContextMenu={(e) => {
e.stopPropagation();
e.preventDefault();
onOpenMenu();
onOpenMenu?.();
}}
className={'flex min-h-[36px] w-[200px] cursor-pointer items-center'}
onClick={onOpenMenu}
>
<Field field={field} />
<Property menuOpened={menuOpened} onOpenMenu={onOpenMenu} onCloseMenu={onCloseMenu} field={field} />
</div>
{openMenu && <FieldMenu field={field} open={openMenu} anchorEl={ref.current} onClose={onCloseMenu} />}
</>
);
}

View File

@ -1,38 +1,27 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Field, fieldService, TextCell } from '$app/components/database/application';
import { Field, fieldService, RowMeta } from '$app/components/database/application';
import { useDatabase } from '$app/components/database';
import { FieldVisibility } from '@/services/backend';
import Button from '@mui/material/Button';
import { ReactComponent as EyeClosedSvg } from '$app/assets/eye_close.svg';
import { ReactComponent as EyeOpenSvg } from '$app/assets/eye_open.svg';
import PropertyList from '$app/components/database/components/edit_record/record_properties/PropertyList';
import NewProperty from '$app/components/database/components/edit_record/record_properties/NewProperty';
import NewProperty from '$app/components/database/components/property/NewProperty';
import { useViewId } from '$app/hooks';
import { DragDropContext, Droppable, DropResult, OnDragEndResponder } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next';
import SwitchPropertiesVisible from '$app/components/database/components/edit_record/record_properties/SwitchPropertiesVisible';
interface Props {
documentId?: string;
cell: TextCell;
row: RowMeta;
}
// a little function to help us with reordering the result
const reorder = (list: Field[], startIndex: number, endIndex: number) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
function RecordProperties({ documentId, cell }: Props) {
const { t } = useTranslation();
function RecordProperties({ documentId, row }: Props) {
const viewId = useViewId();
const { fieldId, rowId } = cell;
const { fields } = useDatabase();
const fieldId = useMemo(() => {
return fields.find((field) => field.isPrimary)?.id;
}, [fields]);
const rowId = row.id;
const [openMenuPropertyId, setOpenMenuPropertyId] = useState<string | undefined>(undefined);
const [showHiddenFields, setShowHiddenFields] = useState(false);
const properties = useMemo(() => {
@ -84,7 +73,7 @@ function RecordProperties({ documentId, cell }: Props) {
}
// reorder the properties synchronously to avoid flickering
const newProperties = reorder(properties, oldIndex, newIndex ?? 0);
const newProperties = fieldService.reorderFields(properties, oldIndex, newIndex ?? 0);
setState(newProperties);
@ -111,33 +100,19 @@ function RecordProperties({ documentId, cell }: Props) {
{...dropProvided.droppableProps}
rowId={rowId}
properties={state}
openMenuPropertyId={openMenuPropertyId}
setOpenMenuPropertyId={setOpenMenuPropertyId}
/>
)}
</Droppable>
</DragDropContext>
{
// show the button only if there are hidden fields
hiddenFieldsCount > 0 && (
<Button
onClick={() => {
setShowHiddenFields((prev) => !prev);
}}
className={'w-full justify-start'}
startIcon={showHiddenFields ? <EyeClosedSvg /> : <EyeOpenSvg />}
color={'inherit'}
>
{showHiddenFields
? t('grid.rowPage.hideHiddenFields', {
count: hiddenFieldsCount,
})
: t('grid.rowPage.showHiddenFields', {
count: hiddenFieldsCount,
})}
</Button>
)
}
<SwitchPropertiesVisible
hiddenFieldsCount={hiddenFieldsCount}
showHiddenFields={showHiddenFields}
setShowHiddenFields={setShowHiddenFields}
/>
<NewProperty />
<NewProperty onInserted={setOpenMenuPropertyId} />
</div>
);
}

View File

@ -0,0 +1,38 @@
import React from 'react';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import { ReactComponent as EyeClosedSvg } from '$app/assets/eye_close.svg';
import { ReactComponent as EyeOpenSvg } from '$app/assets/eye_open.svg';
function SwitchPropertiesVisible({
hiddenFieldsCount,
showHiddenFields,
setShowHiddenFields,
}: {
hiddenFieldsCount: number;
showHiddenFields: boolean;
setShowHiddenFields: (showHiddenFields: boolean) => void;
}) {
const { t } = useTranslation();
return hiddenFieldsCount > 0 ? (
<Button
onClick={() => {
setShowHiddenFields(!showHiddenFields);
}}
className={'w-full justify-start'}
startIcon={showHiddenFields ? <EyeClosedSvg /> : <EyeOpenSvg />}
color={'inherit'}
>
{showHiddenFields
? t('grid.rowPage.hideHiddenFields', {
count: hiddenFieldsCount,
})
: t('grid.rowPage.showHiddenFields', {
count: hiddenFieldsCount,
})}
</Button>
) : null;
}
export default SwitchPropertiesVisible;

View File

@ -1,16 +0,0 @@
import { FC } from 'react';
import { Field as FieldType } from '../../application';
import { FieldTypeSvg } from './FieldTypeSvg';
export interface FieldProps {
field: FieldType;
}
export const Field: FC<FieldProps> = ({ field }) => {
return (
<div className='flex w-full items-center px-2'>
<FieldTypeSvg className='mr-1 text-base' type={field.type} />
<span className='flex-1 truncate text-left text-xs'>{field.name}</span>
</div>
);
};

View File

@ -1,92 +0,0 @@
import { Divider, MenuList, MenuProps } from '@mui/material';
import { ChangeEventHandler, FC, useCallback, useState } from 'react';
import { useViewId } from '$app/hooks';
import { Field, fieldService } from '../../application';
import { FieldMenuActions } from './FieldMenuActions';
import FieldTypeMenuExtension from '$app/components/database/components/field/FieldTypeMenuExtension';
import FieldTypeSelect from '$app/components/database/components/field/FieldTypeSelect';
import { FieldType } from '@/services/backend';
import { Log } from '$app/utils/log';
import TextField from '@mui/material/TextField';
import Popover from '@mui/material/Popover';
export interface GridFieldMenuProps {
field: Field;
anchorEl: MenuProps['anchorEl'];
open: boolean;
onClose: () => void;
}
export const FieldMenu: FC<GridFieldMenuProps> = ({ field, anchorEl, open, onClose }) => {
const viewId = useViewId();
const [inputtingName, setInputtingName] = useState(field.name);
const handleInput = useCallback<ChangeEventHandler<HTMLInputElement>>((e) => {
setInputtingName(e.target.value);
}, []);
const handleBlur = useCallback(async () => {
if (inputtingName !== field.name) {
try {
await fieldService.updateField(viewId, field.id, {
name: inputtingName,
});
} catch (e) {
// TODO
Log.error(`change field ${field.id} name from '${field.name}' to ${inputtingName} fail`, e);
}
}
}, [viewId, field, inputtingName]);
const isPrimary = field.isPrimary;
const onUpdateFieldType = useCallback(
async (type: FieldType) => {
try {
await fieldService.updateFieldType(viewId, field.id, type);
} catch (e) {
// TODO
Log.error(`change field ${field.id} type from '${field.type}' to ${type} fail`, e);
}
},
[viewId, field]
);
return (
<Popover
transformOrigin={{
vertical: -4,
horizontal: 'left',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
keepMounted={false}
anchorEl={anchorEl}
open={open}
onClose={onClose}
>
<TextField
className='mx-3 mt-3 rounded-[10px]'
size='small'
autoFocus={true}
value={inputtingName}
onChange={handleInput}
onBlur={handleBlur}
/>
<MenuList>
<div>
{!isPrimary && (
<>
<FieldTypeSelect field={field} onUpdateFieldType={onUpdateFieldType} />
<Divider className={'my-2'} />
</>
)}
<FieldTypeMenuExtension field={field} />
<FieldMenuActions isPrimary={isPrimary} onMenuItemClick={() => onClose()} fieldId={field.id} />
</div>
</MenuList>
</Popover>
);
};

View File

@ -1,108 +0,0 @@
import { Grid, MenuItem } from '@mui/material';
import { t } from 'i18next';
import { ReactComponent as HideSvg } from '$app/assets/hide.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';
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',
Duplicate = 'duplicate',
Delete = 'delete',
InsertLeft = 'insertLeft',
InsertRight = 'insertRight',
}
const FieldActionSvgMap = {
[FieldAction.Hide]: HideSvg,
[FieldAction.Duplicate]: CopySvg,
[FieldAction.Delete]: DeleteSvg,
[FieldAction.InsertLeft]: LeftSvg,
[FieldAction.InsertRight]: RightSvg,
};
const TwoColumnActions: FieldAction[][] = [
[FieldAction.Hide, FieldAction.Duplicate, FieldAction.Delete],
// [FieldAction.InsertLeft, FieldAction.InsertRight],
];
// prevent default actions for primary fields
const primaryPreventDefaultActions = [FieldAction.Delete, FieldAction.Duplicate];
interface GridFieldMenuActionsProps {
fieldId: string;
isPrimary?: boolean;
onMenuItemClick?: (action: FieldAction) => void;
}
export const FieldMenuActions = ({ 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 columns={TwoColumnActions.length} spacing={2}>
{TwoColumnActions.map((column, index) => (
<Grid key={index} item xs={6}>
{column.map((action) => {
const ActionSvg = FieldActionSvgMap[action];
const disabled = isPrimary && primaryPreventDefaultActions.includes(action);
return (
<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

@ -1,4 +0,0 @@
export * from './Field';
export * from './FieldSelect';
export * from './FieldTypeText';
export * from './FieldTypeSvg';

View File

@ -4,12 +4,11 @@ import { Popover, TextareaAutosize } from '@mui/material';
interface Props {
editing: boolean;
anchorEl: HTMLDivElement | null;
width: number | undefined;
onClose: () => void;
text: string;
onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void;
}
function EditTextCellInput({ editing, anchorEl, width, onClose, text, onInput }: Props) {
function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props) {
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const shift = e.shiftKey;
@ -27,7 +26,7 @@ function EditTextCellInput({ editing, anchorEl, width, onClose, text, onInput }:
anchorEl={anchorEl}
PaperProps={{
className: 'flex p-2 border border-blue-400',
style: { width, minHeight: anchorEl?.offsetHeight, borderRadius: 0, boxShadow: 'none' },
style: { width: anchorEl?.offsetWidth, minHeight: anchorEl?.offsetHeight, borderRadius: 0, boxShadow: 'none' },
}}
transformOrigin={{
vertical: 1,

View File

@ -11,7 +11,7 @@ import {
DateFilterData,
} from '$app/components/database/application';
import { Chip, Popover } from '@mui/material';
import { Field } from '$app/components/database/components/field';
import { Property } from '$app/components/database/components/property';
import { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg';
import TextFilter from './text_filter/TextFilter';
import { FieldType } from '@/services/backend';
@ -111,7 +111,7 @@ function Filter({ filter, field }: Props) {
variant='outlined'
label={
<div className={'flex items-center justify-center'}>
<Field field={field} />
<Property field={field} />
<DropDownSvg className={'ml-1.5 h-8 w-8'} />
</div>
}

View File

@ -1,6 +1,6 @@
import React, { MouseEvent, useCallback } from 'react';
import { MenuProps } from '@mui/material';
import FieldList from '$app/components/database/components/field/FieldList';
import PropertiesList from '$app/components/database/components/property/PropertiesList';
import { Field } from '$app/components/database/application';
import { useViewId } from '$app/hooks';
import { useTranslation } from 'react-i18next';
@ -35,7 +35,7 @@ function FilterFieldsMenu({
return (
<Popover {...props}>
<FieldList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
<PropertiesList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
</Popover>
);
}

View File

@ -0,0 +1,36 @@
import React, { useCallback } from 'react';
import { fieldService } from '$app/components/database/application';
import { FieldType } from '@/services/backend';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useViewId } from '$app/hooks';
interface NewPropertyProps {
onInserted?: (id: string) => void;
}
function NewProperty({ onInserted }: NewPropertyProps) {
const viewId = useViewId();
const { t } = useTranslation();
const handleClick = useCallback(async () => {
try {
const field = await fieldService.createField({
viewId,
fieldType: FieldType.RichText,
});
onInserted?.(field.id);
} catch (e) {
// toast.error(t('grid.field.newPropertyFail'));
}
}, [onInserted, viewId]);
return (
<Button onClick={handleClick} className={'h-full w-full justify-start'} startIcon={<AddSvg />} color={'inherit'}>
{t('grid.field.newProperty')}
</Button>
);
}
export default NewProperty;

View File

@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import { OutlinedInput, MenuItem, MenuList } from '@mui/material';
import { Field } from '$app/components/database/components/field/Field';
import { Property } from '$app/components/database/components/property/Property';
import { Field as FieldType } from '../../application';
import { useDatabase } from '$app/components/database';
@ -10,7 +10,7 @@ interface FieldListProps {
onItemClick?: (event: React.MouseEvent<HTMLLIElement>, field: FieldType) => void;
}
function FieldList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) {
function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) {
const { fields } = useDatabase();
const [fieldsResult, setFieldsResult] = useState<FieldType[]>(fields as FieldType[]);
@ -52,7 +52,7 @@ function FieldList({ showSearch, onItemClick, searchPlaceholder }: FieldListProp
onItemClick?.(event, field);
}}
>
<Field field={field} />
<Property field={field} />
</MenuItem>
))}
</MenuList>
@ -60,4 +60,4 @@ function FieldList({ showSearch, onItemClick, searchPlaceholder }: FieldListProp
);
}
export default FieldList;
export default PropertiesList;

View File

@ -0,0 +1,61 @@
import { FC, useEffect, useRef, useState } from 'react';
import { Field as FieldType } from '../../application';
import { ProppertyTypeSvg } from './property_type/ProppertyTypeSvg';
import { PropertyMenu } from '$app/components/database/components/property/PropertyMenu';
export interface FieldProps {
field: FieldType;
menuOpened?: boolean;
onOpenMenu?: (id: string) => void;
onCloseMenu?: (id: string) => void;
}
export const Property: FC<FieldProps> = ({ field, onCloseMenu, menuOpened }) => {
const ref = useRef<HTMLDivElement | null>(null);
const [anchorPosition, setAnchorPosition] = useState<
| {
top: number;
left: number;
}
| undefined
>(undefined);
const open = Boolean(anchorPosition) && menuOpened;
useEffect(() => {
if (menuOpened) {
const rect = ref.current?.getBoundingClientRect();
if (rect) {
setAnchorPosition({
top: rect.top + rect.height,
left: rect.left,
});
return;
}
}
setAnchorPosition(undefined);
}, [menuOpened]);
return (
<>
<div ref={ref} className='flex w-full items-center px-2'>
<ProppertyTypeSvg className='mr-1 text-base' type={field.type} />
<span className='flex-1 truncate text-left text-xs'>{field.name}</span>
</div>
{open && (
<PropertyMenu
field={field}
open={open}
onClose={() => {
onCloseMenu?.(field.id);
}}
anchorPosition={anchorPosition}
anchorReference={'anchorPosition'}
/>
)}
</>
);
};

View File

@ -0,0 +1,143 @@
import React, { 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 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 { useViewId } from '$app/hooks';
import { fieldService } from '$app/components/database/application';
import { CreateFieldPosition, FieldVisibility } from '@/services/backend';
import { MenuItem } from '@mui/material';
import ConfirmDialog from '$app/components/_shared/app-dialog/ConfirmDialog';
import { useTranslation } from 'react-i18next';
export enum FieldAction {
EditProperty,
Hide,
Duplicate,
Delete,
InsertLeft,
InsertRight,
}
const FieldActionSvgMap = {
[FieldAction.EditProperty]: EditSvg,
[FieldAction.Hide]: HideSvg,
[FieldAction.Duplicate]: CopySvg,
[FieldAction.Delete]: DeleteSvg,
[FieldAction.InsertLeft]: LeftSvg,
[FieldAction.InsertRight]: RightSvg,
};
const defaultActions: FieldAction[] = [
FieldAction.EditProperty,
FieldAction.InsertLeft,
FieldAction.InsertRight,
FieldAction.Hide,
FieldAction.Duplicate,
FieldAction.Delete,
];
// prevent default actions for primary fields
const primaryPreventDefaultActions = [FieldAction.Hide, FieldAction.Delete, FieldAction.Duplicate];
interface PropertyActionsProps {
fieldId: string;
actions?: FieldAction[];
isPrimary?: boolean;
onMenuItemClick?: (action: FieldAction, newFieldId?: string) => void;
}
function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaultActions }: PropertyActionsProps) {
const viewId = useViewId();
const { t } = useTranslation();
const [openConfirm, setOpenConfirm] = useState(false);
const menuTextMap = useMemo(
() => ({
[FieldAction.EditProperty]: t('grid.field.editProperty'),
[FieldAction.Hide]: t('grid.field.hide'),
[FieldAction.Duplicate]: t('grid.field.duplicate'),
[FieldAction.Delete]: t('grid.field.delete'),
[FieldAction.InsertLeft]: t('grid.field.insertLeft'),
[FieldAction.InsertRight]: t('grid.field.insertRight'),
}),
[t]
);
const handleOpenConfirm = () => {
setOpenConfirm(true);
};
const handleMenuItemClick = async (action: FieldAction) => {
const preventDefault = isPrimary && primaryPreventDefaultActions.includes(action);
if (preventDefault) {
return;
}
switch (action) {
case FieldAction.EditProperty:
break;
case FieldAction.InsertLeft:
case FieldAction.InsertRight: {
const fieldPosition = action === FieldAction.InsertLeft ? CreateFieldPosition.Before : CreateFieldPosition.After;
const field = await fieldService.createField({
viewId,
fieldPosition,
targetFieldId: fieldId,
});
onMenuItemClick?.(action, field.id);
return;
}
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 (
<>
{actions.map((action) => {
const ActionSvg = FieldActionSvgMap[action];
const disabled = isPrimary && primaryPreventDefaultActions.includes(action);
return (
<MenuItem disabled={disabled} onClick={() => handleMenuItemClick(action)} key={action} dense>
<ActionSvg className='mr-2 text-base' />
{menuTextMap[action]}
</MenuItem>
);
})}
<ConfirmDialog
open={openConfirm}
subtitle={''}
title={t('grid.field.deleteFieldPromptMessage')}
onOk={async () => {
await fieldService.deleteField(viewId, fieldId);
}}
onClose={() => {
setOpenConfirm(false);
onMenuItemClick?.(FieldAction.Delete);
}}
/>
</>
);
}
export default PropertyActions;

View File

@ -0,0 +1,72 @@
import { Divider, MenuList } from '@mui/material';
import { FC, useCallback } from 'react';
import { useViewId } from '$app/hooks';
import { Field, fieldService } from '../../application';
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 { 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 isPrimary = field.isPrimary;
const onUpdateFieldType = useCallback(
async (type: FieldType) => {
try {
await fieldService.updateFieldType(viewId, field.id, type);
} catch (e) {
// TODO
Log.error(`change field ${field.id} type from '${field.type}' to ${type} fail`, e);
}
},
[viewId, field]
);
return (
<Popover
transformOrigin={{
vertical: -10,
horizontal: 'left',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
onClick={(e) => e.stopPropagation()}
keepMounted={false}
{...props}
>
<PropertyNameInput id={field.id} name={field.name} />
<MenuList>
<div>
{!isPrimary && (
<>
<PropertyTypeSelect field={field} onUpdateFieldType={onUpdateFieldType} />
<Divider className={'my-2'} />
</>
)}
<PropertyTypeMenuExtension field={field} />
<PropertyActions
isPrimary={isPrimary}
actions={actions}
onMenuItemClick={() => {
props.onClose?.({}, 'backdropClick');
}}
fieldId={field.id}
/>
</div>
</MenuList>
</Popover>
);
};

View File

@ -0,0 +1,47 @@
import React, { ChangeEventHandler, useCallback, useState } from 'react';
import { useViewId } from '$app/hooks';
import { fieldService } from '$app/components/database/application';
import { Log } from '$app/utils/log';
import TextField from '@mui/material/TextField';
function PropertyNameInput({ id, name }: { id: string; name: string }) {
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) {
try {
await fieldService.updateField(viewId, id, {
name: inputtingName,
});
} catch (e) {
// TODO
Log.error(`change field ${id} name from '${name}' to ${inputtingName} fail`, e);
}
}
}, [viewId, id, name, inputtingName]);
return (
<TextField
className='mx-3 mt-3 rounded-[10px]'
size='small'
autoFocus={true}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
void handleSubmit();
}
}}
value={inputtingName}
onChange={handleInput}
onBlur={handleSubmit}
/>
);
}
export default PropertyNameInput;

View File

@ -2,13 +2,13 @@ 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 { Field } from './Field';
import { Property } from './Property';
export interface FieldSelectProps extends Omit<SelectProps, 'onChange'> {
onChange?: (field: FieldType | undefined) => void;
}
export const FieldSelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
export const PropertySelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
const { fields } = useDatabase();
const handleChange = useCallback(
@ -36,7 +36,7 @@ export const FieldSelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
>
{fields.map((field) => (
<MenuItem className={'overflow-hidden text-ellipsis px-1.5'} key={field.id} value={field.id}>
<Field field={field} />
<Property field={field} />
</MenuItem>
))}
</Select>

View File

@ -0,0 +1,4 @@
export * from './Property';
export * from './PropertySelect';
export * from './property_type/PropertyTypeText';
export * from './property_type/ProppertyTypeSvg';

View File

@ -1,7 +1,7 @@
import { Divider, Menu, MenuItem, MenuProps } from '@mui/material';
import { FC, useMemo } from 'react';
import { FieldType } from '@/services/backend';
import { FieldTypeText, FieldTypeSvg } from '$app/components/database/components/field/index';
import { PropertyTypeText, ProppertyTypeSvg } from '$app/components/database/components/property';
import { Field } from '$app/components/database/application';
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
@ -25,7 +25,7 @@ const FieldTypeGroup = [
},
];
export const FieldTypeMenu: FC<
export const PropertyTypeMenu: FC<
MenuProps & {
field: Field;
onClickItem?: (type: FieldType) => void;
@ -47,9 +47,9 @@ export const FieldTypeMenu: FC<
</MenuItem>,
group.types.map((type) => (
<MenuItem onClick={() => onClickItem?.(type)} key={type} dense className={'flex justify-between'}>
<FieldTypeSvg className='mr-2 text-base' type={type} />
<ProppertyTypeSvg className='mr-2 text-base' type={type} />
<span className='flex-1 font-medium'>
<FieldTypeText type={type} />
<PropertyTypeText type={type} />
</span>
{type === field.type && <SelectCheckSvg />}
</MenuItem>

View File

@ -5,7 +5,7 @@ import SelectFieldActions from '$app/components/database/components/field_types/
import NumberFieldActions from '$app/components/database/components/field_types/number/NumberFieldActions';
import DateTimeFieldActions from '$app/components/database/components/field_types/date/DateTimeFieldActions';
function FieldTypeMenuExtension({ field }: { field: Field }) {
function PropertyTypeMenuExtension({ field }: { field: Field }) {
return useMemo(() => {
switch (field.type) {
case FieldType.SingleSelect:
@ -23,4 +23,4 @@ function FieldTypeMenuExtension({ field }: { field: Field }) {
}, [field]);
}
export default FieldTypeMenuExtension;
export default PropertyTypeMenuExtension;

View File

@ -1,17 +1,17 @@
import React, { useRef, useState } from 'react';
import { FieldTypeSvg } from '$app/components/database/components/field/FieldTypeSvg';
import { ProppertyTypeSvg } from '$app/components/database/components/property/property_type/ProppertyTypeSvg';
import { MenuItem } from '@mui/material';
import { Field } from '$app/components/database/application';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { FieldTypeMenu } from '$app/components/database/components/field/FieldTypeMenu';
import { PropertyTypeMenu } from '$app/components/database/components/property/property_type/PropertyTypeMenu';
import { FieldType } from '@/services/backend';
import { FieldTypeText } from '$app/components/database/components/field/FieldTypeText';
import { PropertyTypeText } from '$app/components/database/components/property/property_type/PropertyTypeText';
interface Props {
field: Field;
onUpdateFieldType: (type: FieldType) => void;
}
function FieldTypeSelect({ field, onUpdateFieldType }: Props) {
function PropertyTypeSelect({ field, onUpdateFieldType }: Props) {
const [expanded, setExpanded] = useState(false);
const ref = useRef<HTMLLIElement>(null);
@ -24,14 +24,14 @@ function FieldTypeSelect({ field, onUpdateFieldType }: Props) {
}}
className={'px-23 mx-0'}
>
<FieldTypeSvg type={field.type} className='mr-2 text-base' />
<ProppertyTypeSvg type={field.type} className='mr-2 text-base' />
<span className='flex-1 text-xs font-medium'>
<FieldTypeText type={field.type} />
<PropertyTypeText type={field.type} />
</span>
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
</MenuItem>
{expanded && (
<FieldTypeMenu
<PropertyTypeMenu
keepMounted={false}
field={field}
onClickItem={onUpdateFieldType}
@ -54,4 +54,4 @@ function FieldTypeSelect({ field, onUpdateFieldType }: Props) {
);
}
export default FieldTypeSelect;
export default PropertyTypeSelect;

View File

@ -2,7 +2,7 @@ import { FieldType } from '@/services/backend';
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
export const FieldTypeText = ({ type }: { type: FieldType }) => {
export const PropertyTypeText = ({ type }: { type: FieldType }) => {
const { t } = useTranslation();
const text = useMemo(() => {

View File

@ -23,7 +23,7 @@ export const FieldTypeSvgMap: Record<FieldType, FC<React.SVGProps<SVGSVGElement>
[FieldType.CreatedTime]: LastEditedTimeSvg,
};
export const FieldTypeSvg: FC<{ type: FieldType, className?: string }> = memo(({ type, ...props }) => {
export const ProppertyTypeSvg: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => {
const Svg = FieldTypeSvgMap[type];
return <Svg {...props} />;

View File

@ -1,6 +1,6 @@
import React, { FC, MouseEvent, useCallback } from 'react';
import { MenuProps } from '@mui/material';
import FieldList from '$app/components/database/components/field/FieldList';
import PropertiesList from '$app/components/database/components/property/PropertiesList';
import { Field, sortService } from '$app/components/database/application';
import { SortConditionPB } from '@/services/backend';
import { useTranslation } from 'react-i18next';
@ -29,7 +29,7 @@ const SortFieldsMenu: FC<
return (
<Popover keepMounted={false} {...props}>
<FieldList showSearch={true} onItemClick={addSort} searchPlaceholder={t('grid.settings.sortBy')} />
<PropertiesList showSearch={true} onItemClick={addSort} searchPlaceholder={t('grid.settings.sortBy')} />
</Popover>
);
};

View File

@ -2,7 +2,7 @@ import { IconButton, SelectChangeEvent, Stack } from '@mui/material';
import { FC, useCallback } from 'react';
import { ReactComponent as CloseSvg } from '$app/assets/close.svg';
import { Field, Sort, sortService } from '../../application';
import { FieldSelect } from '../field';
import { PropertySelect } from '../property';
import { SortConditionSelect } from './SortConditionSelect';
import { useViewId } from '@/appflowy_app/hooks';
import { SortConditionPB } from '@/services/backend';
@ -44,7 +44,7 @@ export const SortItem: FC<SortItemProps> = ({ className, sort }) => {
return (
<Stack className={className} direction='row' spacing={1}>
<FieldSelect className={'w-[150px]'} size='small' value={sort.fieldId} onChange={handleFieldChange} />
<PropertySelect className={'w-[150px]'} size='small' value={sort.fieldId} onChange={handleFieldChange} />
<SortConditionSelect
className={'w-[150px]'}
size='small'

View File

@ -1,4 +1,5 @@
import { styled, Tab, TabProps, Tabs } from '@mui/material';
import { HTMLAttributes } from 'react';
export const ViewTabs = styled(Tabs)({
minHeight: '28px',
@ -20,7 +21,7 @@ export const ViewTab = styled((props: TabProps) => <Tab disableRipple {...props}
},
});
interface TabPanelProps {
interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
index: number;
value: number;

View File

@ -1,11 +1,6 @@
import { FC } from 'react';
import { GridTable } from '../GridTable';
import GridUIProvider from '$app/components/database/proxy/grid/ui_state/Provider';
import { GridTable, GridTableProps } from '../GridTable';
export const Grid: FC<{ isActivated: boolean; tableHeight: number }> = ({ isActivated, tableHeight }) => {
return (
<GridUIProvider isActivated={isActivated}>
<GridTable tableHeight={tableHeight} />
</GridUIProvider>
);
export const Grid: FC<GridTableProps> = (props) => {
return <GridTable {...props} />;
};

View File

@ -1,23 +1,24 @@
import React from 'react';
import { useDatabaseVisibilityRows } from '$app/components/database';
import { Field } from '$app/components/database/application';
import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow';
import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH } from '$app/components/database/grid/constants';
interface Props {
field: Field;
index: number;
getContainerRef?: () => React.RefObject<HTMLDivElement>;
}
function GridCalculate({ field, index }: Props) {
const rowMetas = useDatabaseVisibilityRows();
const count = rowMetas.length;
const width = field.width ?? DEFAULT_FIELD_WIDTH;
const width = index === 0 ? GRID_ACTIONS_WIDTH : field.width ?? DEFAULT_FIELD_WIDTH;
return (
<div
style={{
width,
visibility: index === 0 ? 'visible' : 'hidden',
visibility: index === 1 ? 'visible' : 'hidden',
}}
className={'flex justify-end py-2'}
>

View File

@ -1,6 +1,75 @@
import { FC } from 'react';
import { Cell, CellProps } from '../../components';
import React, { CSSProperties, memo } from 'react';
import { GridColumn, RenderRow, RenderRowType } from '../constants';
import GridNewRow from '$app/components/database/grid/GridNewRow/GridNewRow';
import GridCalculate from '$app/components/database/grid/GridCalculate/GridCalculate';
import { areEqual } from 'react-window';
import { Cell } from '$app/components/database/components';
import PrimaryCell from '$app/components/database/grid/GridCell/PrimaryCell';
export const GridCell: FC<CellProps> = (props) => {
return <Cell {...props} />;
const getRenderRowKey = (row: RenderRow) => {
if (row.type === RenderRowType.Row) {
return `row:${row.data.meta.id}`;
}
return row.type;
};
interface GridCellProps {
row: RenderRow;
column: GridColumn;
columnIndex: number;
style: CSSProperties;
onEditRecord?: (rowId: string) => void;
getContainerRef?: () => React.RefObject<HTMLDivElement>;
}
export const GridCell = memo(({ row, column, columnIndex, style, onEditRecord, getContainerRef }: GridCellProps) => {
const key = getRenderRowKey(row);
const field = column.field;
if (!field) return <div data-key={key} style={style} />;
switch (row.type) {
case RenderRowType.Row: {
const renderRowCell = <Cell rowId={row.data.meta.id} icon={row.data.meta.icon} field={field} />;
return (
<div data-key={key} style={style} className={'grid-cell flex border-b border-r border-line-divider'}>
{field.isPrimary ? (
<PrimaryCell
icon={row.data.meta.icon}
onEditRecord={onEditRecord}
getContainerRef={getContainerRef}
rowId={row.data.meta.id}
>
{renderRowCell}
</PrimaryCell>
) : (
renderRowCell
)}
</div>
);
}
case RenderRowType.NewRow:
return (
<div style={style} className={'flex border-b border-line-divider'}>
<GridNewRow
getContainerRef={getContainerRef}
index={columnIndex}
startRowId={row.data.startRowId}
groupId={row.data.groupId}
/>
</div>
);
case RenderRowType.CalculateRow:
return (
<div className={'flex'} style={style}>
<GridCalculate getContainerRef={getContainerRef} field={field} index={columnIndex} />
</div>
);
default:
return null;
}
}, areEqual);

View File

@ -0,0 +1,51 @@
import React, { Suspense, useMemo, useRef } from 'react';
import { ReactComponent as OpenIcon } from '$app/assets/open.svg';
import { IconButton } from '@mui/material';
import { useGridTableHoverState } from '$app/components/database/grid/GridRowActions/GridRowActions.hooks';
function PrimaryCell({
onEditRecord,
icon,
getContainerRef,
rowId,
children,
}: {
rowId: string;
icon?: string;
onEditRecord?: (rowId: string) => void;
getContainerRef?: () => React.RefObject<HTMLDivElement>;
children?: React.ReactNode;
}) {
const cellRef = useRef<HTMLDivElement>(null);
const containerRef = getContainerRef?.();
const { hoverRowId } = useGridTableHoverState(containerRef);
const showExpandIcon = useMemo(() => {
return hoverRowId === rowId;
}, [hoverRowId, rowId]);
return (
<div ref={cellRef} className={'relative flex w-full items-center'}>
{icon && <div className={'ml-2 mr-1'}>{icon}</div>}
{children}
<Suspense>
{showExpandIcon && (
<div
style={{
transform: 'translateY(-50%) translateZ(0)',
}}
className={`absolute right-0 top-1/2 z-10 mr-4 flex items-center justify-center`}
>
<IconButton onClick={() => onEditRecord?.(rowId)} className={'h-6 w-6 text-sm'}>
<OpenIcon />
</IconButton>
</div>
)}
</Suspense>
</div>
);
}
export default PrimaryCell;

View File

@ -1,140 +1,194 @@
import { Button, Tooltip } from '@mui/material';
import { DragEventHandler, FC, useCallback, useMemo, useState } from 'react';
import { DragEventHandler, FC, HTMLAttributes, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { throttle } from '$app/utils/tool';
import { useViewId } from '$app/hooks';
import { DragItem, DropPosition, DragType, useDraggable, useDroppable, ScrollDirection } from '../../_shared';
import { fieldService, Field } from '../../application';
import { useDatabase } from '../../Database.hooks';
import { FieldTypeSvg } from '$app/components/database/components/field';
import { FieldMenu } from '../../components/field/FieldMenu';
import { Property } from '$app/components/database/components/property';
import GridResizer from '$app/components/database/grid/GridField/GridResizer';
import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow';
import GridFieldMenu from '$app/components/database/grid/GridField/GridFieldMenu';
import { areEqual } from 'react-window';
import { useOpenMenu } from '$app/components/database/grid/GridStickyHeader/GridStickyHeader.hooks';
export interface GridFieldProps {
export interface GridFieldProps extends HTMLAttributes<HTMLDivElement> {
field: Field;
onOpenMenu?: (id: string) => void;
onCloseMenu?: (id: string) => void;
resizeColumnWidth?: (width: number) => void;
getScrollElement?: () => HTMLElement | null;
}
export const GridField: FC<GridFieldProps> = ({ field }) => {
const viewId = useViewId();
const { fields } = useDatabase();
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null);
const [openTooltip, setOpenTooltip] = useState(false);
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
const [fieldWidth, setFieldWidth] = useState(field.width || DEFAULT_FIELD_WIDTH);
const openMenu = Boolean(menuAnchorEl);
const handleClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(e.currentTarget);
}, []);
export const GridField: FC<GridFieldProps> = memo(
({ getScrollElement, resizeColumnWidth, onOpenMenu, onCloseMenu, field, ...props }) => {
const menuOpened = useOpenMenu(field.id);
const viewId = useViewId();
const { fields } = useDatabase();
const [openTooltip, setOpenTooltip] = useState(false);
const [propertyMenuOpened, setPropertyMenuOpened] = useState(false);
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
const handleMenuClose = useCallback(() => {
setMenuAnchorEl(null);
}, []);
const handleTooltipOpen = useCallback(() => {
setOpenTooltip(true);
}, []);
const handleTooltipOpen = useCallback(() => {
setOpenTooltip(true);
}, []);
const handleTooltipClose = useCallback(() => {
setOpenTooltip(false);
}, []);
const handleTooltipClose = useCallback(() => {
setOpenTooltip(false);
}, []);
const draggingData = useMemo(
() => ({
field,
}),
[field]
);
const draggingData = useMemo(
() => ({
field,
}),
[field]
);
const { isDragging, attributes, listeners, setPreviewRef, previewRef } = useDraggable({
type: DragType.Field,
data: draggingData,
scrollOnEdge: {
direction: ScrollDirection.Horizontal,
getScrollElement,
},
});
const { isDragging, attributes, listeners, setPreviewRef, previewRef } = useDraggable({
type: DragType.Field,
data: draggingData,
scrollOnEdge: {
direction: ScrollDirection.Horizontal,
},
});
const onDragOver = useMemo<DragEventHandler>(() => {
return throttle((event) => {
const element = previewRef.current;
const onDragOver = useMemo<DragEventHandler>(() => {
return throttle((event) => {
const element = previewRef.current;
if (!element) {
return;
}
if (!element) {
const { left, right } = element.getBoundingClientRect();
const middle = (left + right) / 2;
setDropPosition(event.clientX < middle ? DropPosition.Before : DropPosition.After);
}, 20);
}, [previewRef]);
const onDrop = useCallback(
({ data }: DragItem) => {
const dragField = data.field as Field;
const fromIndex = fields.findIndex((item) => item.id === dragField.id);
const dropIndex = fields.findIndex((item) => item.id === field.id);
const toIndex = dropIndex + dropPosition + (fromIndex < dropIndex ? -1 : 0);
if (fromIndex === toIndex) {
return;
}
void fieldService.moveField(viewId, dragField.id, fromIndex, toIndex);
},
[viewId, field, fields, dropPosition]
);
const { isOver, listeners: dropListeners } = useDroppable({
accept: DragType.Field,
disabled: isDragging,
onDragOver,
onDrop,
});
const [menuAnchorPosition, setMenuAnchorPosition] = useState<
| {
top: number;
left: number;
}
| undefined
>(undefined);
const open = Boolean(menuAnchorPosition) && menuOpened;
const handleClick = useCallback(() => {
onOpenMenu?.(field.id);
}, [onOpenMenu, field.id]);
const handleMenuClose = useCallback(() => {
onCloseMenu?.(field.id);
}, [onCloseMenu, field.id]);
useEffect(() => {
if (!menuOpened) {
setMenuAnchorPosition(undefined);
return;
}
const { left, right } = element.getBoundingClientRect();
const middle = (left + right) / 2;
const rect = previewRef.current?.getBoundingClientRect();
setDropPosition(event.clientX < middle ? DropPosition.Before : DropPosition.After);
}, 20);
}, [previewRef]);
const onDrop = useCallback(
({ data }: DragItem) => {
const dragField = data.field as Field;
const fromIndex = fields.findIndex((item) => item.id === dragField.id);
const dropIndex = fields.findIndex((item) => item.id === field.id);
const toIndex = dropIndex + dropPosition + (fromIndex < dropIndex ? -1 : 0);
if (fromIndex === toIndex) {
return;
if (rect) {
setMenuAnchorPosition({
top: rect.top + rect.height,
left: rect.left,
});
} else {
setMenuAnchorPosition(undefined);
}
}, [menuOpened, previewRef]);
void fieldService.moveField(viewId, dragField.id, fromIndex, toIndex);
},
[viewId, field, fields, dropPosition]
);
const handlePropertyMenuOpen = useCallback(() => {
setPropertyMenuOpened(true);
}, []);
const { isOver, listeners: dropListeners } = useDroppable({
accept: DragType.Field,
disabled: isDragging,
onDragOver,
onDrop,
});
const handlePropertyMenuClose = useCallback(() => {
setPropertyMenuOpened(false);
}, []);
return (
<div
className={'flex border-r border-line-divider'}
style={{
width: fieldWidth,
}}
>
<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 w-full items-center px-2'
disableRipple
onContextMenu={(event) => {
event.stopPropagation();
event.preventDefault();
handleClick(event);
}}
onClick={handleClick}
{...attributes}
{...listeners}
{...dropListeners}
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}
>
<FieldTypeSvg className='mr-1 text-base' type={field.type} />
<span className='flex-1 truncate text-left text-xs'>{field.name}</span>
{isOver && (
<div
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${
dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
}`}
<Button
color={'inherit'}
ref={setPreviewRef}
className='relative flex h-full w-full items-center px-0'
disableRipple
onContextMenu={(event) => {
event.stopPropagation();
event.preventDefault();
handleClick();
}}
onClick={handleClick}
{...attributes}
{...listeners}
{...dropListeners}
>
<Property
menuOpened={propertyMenuOpened}
onCloseMenu={handlePropertyMenuClose}
onOpenMenu={handlePropertyMenuOpen}
field={field}
/>
)}
<GridResizer field={field} onWidthChange={(width) => setFieldWidth(width)} />
</Button>
</Tooltip>
{openMenu && <FieldMenu field={field} open={openMenu} anchorEl={menuAnchorEl} onClose={handleMenuClose} />}
</div>
);
};
{isOver && (
<div
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${
dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
}`}
/>
)}
<GridResizer field={field} onWidthChange={resizeColumnWidth} />
</Button>
</Tooltip>
{open && (
<GridFieldMenu
anchorPosition={menuAnchorPosition}
anchorReference={'anchorPosition'}
field={field}
open={open}
onClose={handleMenuClose}
onOpenPropertyMenu={handlePropertyMenuOpen}
onOpenMenu={onOpenMenu}
/>
)}
</div>
);
},
areEqual
);

View File

@ -0,0 +1,47 @@
import React from 'react';
import Popover, { PopoverProps } from '@mui/material/Popover';
import { Field } from '$app/components/database/application';
import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput';
import { MenuList, Portal } from '@mui/material';
import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions';
interface Props extends PopoverProps {
field: Field;
onOpenPropertyMenu?: () => void;
onOpenMenu?: (fieldId: string) => void;
}
function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props }: Props) {
return (
<Portal>
<Popover
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
onClick={(e) => e.stopPropagation()}
{...props}
keepMounted={false}
>
<PropertyNameInput id={field.id} name={field.name} />
<MenuList>
<PropertyActions
isPrimary={field.isPrimary}
onMenuItemClick={(action, newFieldId?: string) => {
if (action === FieldAction.EditProperty) {
onOpenPropertyMenu?.();
} else if (newFieldId && (action === FieldAction.InsertLeft || action === FieldAction.InsertRight)) {
onOpenMenu?.(newFieldId);
}
props.onClose?.({}, 'backdropClick');
}}
fieldId={field.id}
/>
</MenuList>
</Popover>
</Portal>
);
}
export default GridFieldMenu;

View File

@ -0,0 +1,35 @@
import React, { useCallback } from 'react';
import { useViewId } from '$app/hooks';
import { useTranslation } from 'react-i18next';
import { fieldService } from '$app/components/database/application';
import { FieldType } from '@/services/backend';
import Button from '@mui/material/Button';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
function GridNewField({ onInserted }: { onInserted?: (id: string) => void }) {
const viewId = useViewId();
const { t } = useTranslation();
const handleClick = useCallback(async () => {
try {
const field = await fieldService.createField({
viewId,
fieldType: FieldType.RichText,
});
onInserted?.(field.id);
} catch (e) {
// toast.error(t('grid.field.newPropertyFail'));
}
}, [onInserted, viewId]);
return (
<>
<Button onClick={handleClick} className={'h-full w-full justify-start'} startIcon={<AddSvg />} color={'inherit'}>
{t('grid.field.newProperty')}
</Button>
</>
);
}
export default GridNewField;

View File

@ -28,14 +28,11 @@ function GridResizer({ field, onWidthChange }: GridResizerProps) {
}
setNewWidth(newWidth);
onWidthChange?.(newWidth);
},
[width]
[width, onWidthChange]
);
useEffect(() => {
onWidthChange?.(newWidth);
}, [newWidth, onWidthChange]);
useEffect(() => {
if (!isResizing && width !== newWidth) {
void fieldService.updateFieldSetting(viewId, fieldId, {

View File

@ -0,0 +1,69 @@
import React, { useCallback } from 'react';
import { rowService } from '$app/components/database/application';
import { useViewId } from '$app/hooks';
import { t } from 'i18next';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
interface Props {
index: number;
startRowId?: string;
groupId?: string;
getContainerRef?: () => React.RefObject<HTMLDivElement>;
}
const CSS_HIGHLIGHT_PROPERTY = 'bg-content-blue-50';
function GridNewRow({ index, startRowId, groupId, getContainerRef }: Props) {
const viewId = useViewId();
const handleClick = useCallback(() => {
void rowService.createRow(viewId, {
startRowId,
groupId,
});
}, [viewId, groupId, startRowId]);
const toggleCssProperty = useCallback(
(status: boolean) => {
const container = getContainerRef?.()?.current;
if (!container) return;
const newRowCells = container.querySelectorAll('.grid-new-row');
newRowCells.forEach((cell) => {
if (status) {
cell.classList.add(CSS_HIGHLIGHT_PROPERTY);
} else {
cell.classList.remove(CSS_HIGHLIGHT_PROPERTY);
}
});
},
[getContainerRef]
);
return (
<div
onMouseEnter={() => {
toggleCssProperty(true);
}}
onMouseLeave={() => {
toggleCssProperty(false);
}}
onClick={handleClick}
className={'grid-new-row flex grow'}
>
<span
style={{
visibility: index === 1 ? 'visible' : 'hidden',
}}
className='sticky left-2 inline-flex items-center'
>
<AddSvg className='mr-1 text-base' />
{t('grid.row.newRow')}
</span>
</div>
);
}
export default GridNewRow;

View File

@ -0,0 +1,44 @@
import React, { useEffect, useState } from 'react';
import GridRowContextMenu from '$app/components/database/grid/GridRowActions/GridRowContextMenu';
import GridRowActions from '$app/components/database/grid/GridRowActions/GridRowActions';
import { useGridTableHoverState } from '$app/components/database/grid/GridRowActions/GridRowActions.hooks';
function GridTableOverlay({
containerRef,
getScrollElement,
}: {
containerRef: React.MutableRefObject<HTMLDivElement | null>;
getScrollElement: () => HTMLDivElement | null;
}) {
const [hoverRowTop, setHoverRowTop] = useState<string | undefined>();
const { hoverRowId } = useGridTableHoverState(containerRef);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const cell = container.querySelector(`[data-key="row:${hoverRowId}"]`);
if (!cell) return;
const top = (cell as HTMLDivElement).style.top;
setHoverRowTop(top);
}, [containerRef, hoverRowId]);
return (
<div className={'absolute left-0 top-0'}>
<GridRowActions
getScrollElement={getScrollElement}
containerRef={containerRef}
rowId={hoverRowId}
rowTop={hoverRowTop}
/>
<GridRowContextMenu containerRef={containerRef} hoverRowId={hoverRowId} />
</div>
);
}
export default GridTableOverlay;

View File

@ -1,17 +0,0 @@
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

@ -1,56 +0,0 @@
import { useGridUIStateDispatcher, useGridUIStateSelector } from '$app/components/database/proxy/grid/ui_state/actions';
import { useCallback, useMemo, useState } from 'react';
export function useGridRowActionsDisplay(rowId: string) {
const { hoverRowId, isActivated } = useGridUIStateSelector();
const hover = useMemo(() => {
return isActivated && hoverRowId === rowId;
}, [hoverRowId, rowId, isActivated]);
const { setRowHover } = useGridUIStateDispatcher();
const onMouseEnter = useCallback(() => {
setRowHover(rowId);
}, [setRowHover, rowId]);
const onMouseLeave = useCallback(() => {
if (hover) {
setRowHover(null);
}
}, [setRowHover, hover]);
return {
onMouseEnter,
onMouseLeave,
hover,
};
}
export const useGridRowContextMenu = () => {
const [position, setPosition] = useState<{ left: number; top: number } | undefined>();
const isContextMenuOpen = useMemo(() => {
return !!position;
}, [position]);
const closeContextMenu = useCallback(() => {
setPosition(undefined);
}, []);
const openContextMenu = useCallback((event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setPosition({
left: event.clientX,
top: event.clientY,
});
}, []);
return {
isContextMenuOpen,
closeContextMenu,
openContextMenu,
position,
};
};

View File

@ -1,165 +0,0 @@
import { Virtualizer } from '@tanstack/react-virtual';
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 { useDatabaseVisibilityFields } from '../../../Database.hooks';
import { rowService, RowMeta } from '../../../application';
import {
DragItem,
DragType,
DropPosition,
VirtualizedList,
useDraggable,
useDroppable,
ScrollDirection,
} from '../../../_shared';
import { GridCell } from '../../GridCell';
import { GridCellRowActions } from './GridCellRowActions';
import {
useGridRowActionsDisplay,
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;
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
getPrevRowId: (id: string) => string | null;
}
export const GridCellRow: FC<GridCellRowProps> = ({ rowMeta, virtualizer, getPrevRowId }) => {
const rowId = rowMeta.id;
const viewId = useViewId();
const ref = useRef<HTMLDivElement | null>(null);
const { onMouseLeave, onMouseEnter, hover } = useGridRowActionsDisplay(rowId);
const {
isContextMenuOpen,
closeContextMenu,
openContextMenu,
position: contextMenuPosition,
} = useGridRowContextMenu();
const fields = useDatabaseVisibilityFields();
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
const dragData = useMemo(
() => ({
rowMeta,
}),
[rowMeta]
);
const {
isDragging,
attributes: dragAttributes,
listeners: dragListeners,
setPreviewRef,
previewRef,
} = useDraggable({
type: DragType.Row,
data: dragData,
scrollOnEdge: {
direction: ScrollDirection.Vertical,
},
});
const onDragOver = useMemo<DragEventHandler>(() => {
return throttle((event) => {
const element = previewRef.current;
if (!element) {
return;
}
const { top, bottom } = element.getBoundingClientRect();
const middle = (top + bottom) / 2;
setDropPosition(event.clientY < middle ? DropPosition.Before : DropPosition.After);
}, 20);
}, [previewRef]);
const onDrop = useCallback(
({ data }: DragItem) => {
void rowService.moveRow(viewId, (data.rowMeta as RowMeta).id, rowMeta.id);
},
[viewId, rowMeta.id]
);
const { isOver, listeners: dropListeners } = useDroppable({
accept: DragType.Row,
disabled: isDragging,
onDragOver,
onDrop,
});
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
element.addEventListener('contextmenu', openContextMenu);
return () => {
element.removeEventListener('contextmenu', openContextMenu);
};
}, [openContextMenu]);
return (
<div
ref={ref}
className='relative -ml-16 flex grow pl-16'
onMouseLeave={onMouseLeave}
onMouseMove={onMouseEnter}
{...dropListeners}
>
<div
ref={setPreviewRef}
className={`relative flex grow border-b border-line-divider ${isDragging ? 'bg-blue-50' : ''}`}
>
<VirtualizedList
className='flex'
itemClassName='flex border-r border-line-divider'
virtualizer={virtualizer}
renderItem={(index) => {
const field = fields[index];
const icon = field.isPrimary ? rowMeta.icon : undefined;
const documentId = field.isPrimary ? rowMeta.documentId : undefined;
return <GridCell rowId={rowMeta.id} documentId={documentId} icon={icon} field={field} />;
}}
/>
<div className={`w-[${DEFAULT_FIELD_WIDTH}px]`} />
{isOver && (
<div
className={`absolute left-0 right-0 z-10 h-0.5 bg-blue-500 ${
dropPosition === DropPosition.Before ? 'top-[-1px]' : 'top-full'
}`}
/>
)}
</div>
<GridCellRowActions
isHidden={!hover}
className={'absolute left-2 top-[6px] z-10'}
dragProps={{
...dragListeners,
...dragAttributes,
}}
rowId={rowMeta.id}
getPrevRowId={getPrevRowId}
/>
<Portal>
{isContextMenuOpen && (
<GridCellRowContextMenu
open={isContextMenuOpen}
onClose={closeContextMenu}
anchorPosition={contextMenuPosition}
rowId={rowId}
getPrevRowId={getPrevRowId}
/>
)}
</Portal>
</div>
);
};

View File

@ -1,97 +0,0 @@
import { IconButton, Tooltip } from '@mui/material';
import { DragEventHandler, FC, HTMLAttributes, PropsWithChildren, useCallback, useRef, useState } from 'react';
import { t } from 'i18next';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useViewId } from '$app/hooks';
import { rowService } from '../../../application';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
import GridCellRowMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu';
import Popover from '@mui/material/Popover';
export interface GridCellRowActionsProps extends HTMLAttributes<HTMLDivElement> {
rowId: string;
getPrevRowId: (id: string) => string | null;
dragProps: {
draggable?: boolean;
onDragStart?: DragEventHandler;
onDragEnd?: DragEventHandler;
};
isHidden?: boolean;
}
export const GridCellRowActions: FC<PropsWithChildren<GridCellRowActionsProps>> = ({
isHidden,
rowId,
getPrevRowId,
className,
dragProps: { draggable, onDragStart, onDragEnd },
...props
}) => {
const viewId = useViewId();
const ref = useRef<HTMLDivElement | null>(null);
const [menuPosition, setMenuPosition] = useState<{
top: number;
left: number;
}>();
const handleInsertRecordBelow = useCallback(() => {
void rowService.createRow(viewId, {
startRowId: rowId,
});
}, [viewId, rowId]);
const handleOpenMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLButtonElement;
const rect = target.getBoundingClientRect();
setMenuPosition({
top: rect.top + rect.height / 2,
left: rect.left + rect.width,
});
};
const handleCloseMenu = () => {
setMenuPosition(undefined);
};
const openMenu = !!menuPosition;
return (
<>
{!isHidden && (
<div ref={ref} className={`inline-flex items-center ${className || ''}`} {...props}>
<Tooltip placement='top' title={t('grid.row.add')}>
<IconButton onClick={handleInsertRecordBelow}>
<AddSvg />
</IconButton>
</Tooltip>
<Tooltip placement='top' title={t('grid.row.dragAndClick')}>
<IconButton
onClick={handleOpenMenu}
className='mx-1 cursor-grab active:cursor-grabbing'
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<DragSvg className='-mx-1' />
</IconButton>
</Tooltip>
</div>
)}
{openMenu && (
<Popover
open={openMenu}
onClose={handleCloseMenu}
transformOrigin={{
vertical: 'center',
horizontal: 'left',
}}
anchorReference={'anchorPosition'}
anchorPosition={menuPosition}
>
<GridCellRowMenu onClickItem={handleCloseMenu} rowId={rowId} getPrevRowId={getPrevRowId} />
</Popover>
)}
</>
);
};

View File

@ -1,36 +0,0 @@
import React from 'react';
import GridCellRowMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu';
import Popover from '@mui/material/Popover';
interface Props {
open: boolean;
onClose: () => void;
anchorPosition?: {
top: number;
left: number;
};
rowId: string;
getPrevRowId: (id: string) => string | null;
}
function GridCellRowContextMenu({ open, anchorPosition, onClose, rowId, getPrevRowId }: Props) {
return (
<Popover
open={open}
onClose={onClose}
anchorPosition={anchorPosition}
anchorReference={'anchorPosition'}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<GridCellRowMenu
rowId={rowId}
getPrevRowId={getPrevRowId}
onClickItem={() => {
onClose();
}}
/>
</Popover>
);
}
export default GridCellRowContextMenu;

View File

@ -1,25 +0,0 @@
import { useDatabaseVisibilityFields } from '../../Database.hooks';
import { GridField } from '../GridField';
import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow/constants';
import React from 'react';
import NewProperty from '$app/components/database/components/edit_record/record_properties/NewProperty';
export const GridFieldRow = () => {
const fields = useDatabaseVisibilityFields();
return (
<>
<div className='z-10 flex border-b border-line-divider '>
<div className={'flex '}>
{fields.map((field) => {
return <GridField key={field.id} field={field} />;
})}
</div>
<div className={` w-[${DEFAULT_FIELD_WIDTH}px]`}>
<NewProperty />
</div>
</div>
</>
);
};

View File

@ -1,33 +0,0 @@
import { FC, useCallback } from 'react';
import { t } from 'i18next';
import { Button } from '@mui/material';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useViewId } from '$app/hooks';
import { rowService } from '../../application';
export interface GridNewRowProps {
startRowId?: string;
groupId?: string;
}
export const GridNewRow: FC<GridNewRowProps> = ({ startRowId, groupId }) => {
const viewId = useViewId();
const handleClick = useCallback(() => {
void rowService.createRow(viewId, {
startRowId,
groupId,
});
}, [viewId, groupId, startRowId]);
return (
<div className='flex grow border-b border-line-divider'>
<Button className='grow justify-start' onClick={handleClick} color={'inherit'}>
<span className='sticky left-2 inline-flex items-center'>
<AddSvg className='mr-1 text-base' />
{t('grid.row.newRow')}
</span>
</Button>
</div>
);
};

View File

@ -1,28 +0,0 @@
import { Virtualizer } from '@tanstack/react-virtual';
import React, { FC } from 'react';
import { RenderRow, RenderRowType } from './constants';
import { GridCellRow } from './GridCellRow';
import { GridNewRow } from './GridNewRow';
import { GridFieldRow } from '$app/components/database/grid/GridRow/GridFieldRow';
import GridCalculateRow from '$app/components/database/grid/GridRow/GridCalculateRow';
export interface GridRowProps {
row: RenderRow;
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
getPrevRowId: (id: string) => string | null;
}
export const GridRow: FC<GridRowProps> = React.memo(({ 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.NewRow:
return <GridNewRow startRowId={row.data.startRowId} groupId={row.data.groupId} />;
case RenderRowType.CalculateRow:
return <GridCalculateRow />;
default:
return null;
}
});

View File

@ -1,2 +0,0 @@
export * from './GridRow';
export * from './constants';

View File

@ -0,0 +1,219 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewId } from '$app/hooks';
import { useGetPrevRowId } from '$app/components/database';
import { rowService } from '$app/components/database/application';
import { autoScrollOnEdge, ScrollDirection } from '$app/components/database/_shared/dnd/utils';
export function getCellsWithRowId(rowId: string, container: HTMLDivElement) {
return Array.from(container.querySelectorAll(`[data-key^="row:${rowId}"]`));
}
const SELECTED_ROW_CSS_PROPERTY = 'bg-content-blue-50';
export function toggleProperty(
container: HTMLDivElement,
rowId: string,
status: boolean,
property = SELECTED_ROW_CSS_PROPERTY
) {
const rowColumns = getCellsWithRowId(rowId, container);
rowColumns.forEach((column, index) => {
if (index === 0) return;
if (status) {
column.classList.add(property);
} else {
column.classList.remove(property);
}
});
}
function createVirtualDragElement(rowId: string, container: HTMLDivElement) {
const cells = getCellsWithRowId(rowId, container);
const cell = cells[0] as HTMLDivElement;
if (!cell) return null;
const rect = cell.getBoundingClientRect();
const row = document.createElement('div');
row.style.display = 'flex';
row.style.position = 'absolute';
row.style.top = `${rect.top}px`;
row.style.left = `${rect.left + 64}px`;
row.style.background = 'var(--content-blue-50)';
cells.forEach((cell) => {
const node = cell.cloneNode(true) as HTMLDivElement;
if (!node.classList.contains('grid-cell')) return;
node.style.top = '';
node.style.position = '';
node.style.left = '';
node.style.width = (cell as HTMLDivElement).style.width;
node.style.height = (cell as HTMLDivElement).style.height;
node.className = 'flex items-center';
row.appendChild(node);
});
document.body.appendChild(row);
return row;
}
export function useDraggableGridRow(
rowId: string,
containerRef: React.RefObject<HTMLDivElement>,
getScrollElement: () => HTMLDivElement | null
) {
const [isDragging, setIsDragging] = useState(false);
const dropRowIdRef = useRef<string | undefined>(undefined);
const previewRef = useRef<HTMLDivElement | undefined>();
const viewId = useViewId();
const getPrevRowId = useGetPrevRowId();
const onDragStart = useCallback(
(e: React.DragEvent<HTMLButtonElement>) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.dropEffect = 'move';
const container = containerRef.current;
if (container) {
const row = createVirtualDragElement(rowId, container);
if (row) {
previewRef.current = row;
e.dataTransfer.setDragImage(row, 0, 0);
}
}
const scrollParent = getScrollElement();
if (scrollParent) {
autoScrollOnEdge({
element: scrollParent,
direction: ScrollDirection.Vertical,
});
}
setIsDragging(true);
},
[containerRef, rowId, getScrollElement]
);
useEffect(() => {
if (!isDragging) {
if (previewRef.current) {
const row = previewRef.current;
previewRef.current = undefined;
row?.remove();
}
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const onDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
const target = e.target as HTMLElement;
const cell = target.closest('[data-key]');
const rowId = cell?.getAttribute('data-key')?.split(':')[1];
const oldRowId = dropRowIdRef.current;
if (oldRowId) {
toggleProperty(container, oldRowId, false);
}
if (!rowId) return;
const rowColumns = getCellsWithRowId(rowId, container);
dropRowIdRef.current = rowId;
if (!rowColumns.length) return;
toggleProperty(container, rowId, true);
};
const onDragEnd = () => {
const oldRowId = dropRowIdRef.current;
if (oldRowId) {
toggleProperty(container, oldRowId, false);
}
dropRowIdRef.current = undefined;
setIsDragging(false);
};
const onDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
const dropRowId = dropRowIdRef.current;
if (dropRowId) {
void rowService.moveRow(viewId, rowId, dropRowId);
}
setIsDragging(false);
container.removeEventListener('dragover', onDragOver);
container.removeEventListener('dragend', onDragEnd);
container.removeEventListener('drop', onDrop);
};
container.addEventListener('dragover', onDragOver);
container.addEventListener('dragend', onDragEnd);
container.addEventListener('drop', onDrop);
}, [containerRef, getPrevRowId, isDragging, rowId, viewId]);
return {
isDragging,
onDragStart,
};
}
export function useGridTableHoverState(containerRef?: React.RefObject<HTMLDivElement>) {
const [hoverRowId, setHoverRowId] = useState<string | undefined>(undefined);
useEffect(() => {
const container = containerRef?.current;
if (!container) return;
const onMouseMove = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const cell = target.closest('[data-key]');
if (!cell) {
return;
}
const hoverRowId = cell.getAttribute('data-key')?.split(':')[1];
setHoverRowId(hoverRowId);
};
const onMouseLeave = () => {
setHoverRowId(undefined);
};
container.addEventListener('mousemove', onMouseMove);
container.addEventListener('mouseleave', onMouseLeave);
return () => {
container.removeEventListener('mousemove', onMouseMove);
container.removeEventListener('mouseleave', onMouseLeave);
};
}, [containerRef]);
return {
hoverRowId,
};
}

View File

@ -0,0 +1,98 @@
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/components/database/application';
import { useViewId } from '$app/hooks';
import GridRowDragButton from '$app/components/database/grid/GridRowActions/GridRowDragButton';
import GridRowMenu from '$app/components/database/grid/GridRowActions/GridRowMenu';
function GridRowActions({
rowId,
rowTop,
containerRef,
getScrollElement,
}: {
rowId?: string;
rowTop?: string;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
getScrollElement: () => HTMLDivElement | null;
}) {
const viewId = useViewId();
const [menuRowId, setMenuRowId] = useState<string | undefined>(undefined);
const [menuPosition, setMenuPosition] = useState<
| {
top: number;
left: number;
}
| undefined
>(undefined);
const openMenu = Boolean(menuPosition);
const handleInsertRecordBelow = useCallback(() => {
void rowService.createRow(viewId, {
startRowId: rowId,
});
}, [viewId, rowId]);
const handleOpenMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLButtonElement;
const rect = target.getBoundingClientRect();
setMenuRowId(rowId);
setMenuPosition({
top: rect.top + rect.height / 2,
left: rect.left + rect.width,
});
};
const handleCloseMenu = useCallback(() => {
setMenuPosition(undefined);
setMenuRowId(undefined);
}, []);
return (
<>
{rowId && rowTop && (
<div
style={{
position: 'absolute',
top: rowTop,
left: GRID_ACTIONS_WIDTH,
transform: 'translateY(4px)',
}}
className={'z-10 flex w-full items-center justify-end'}
>
<Tooltip placement='top' title={t('grid.row.add')}>
<IconButton onClick={handleInsertRecordBelow}>
<AddSvg />
</IconButton>
</Tooltip>
<GridRowDragButton
getScrollElement={getScrollElement}
rowId={rowId}
containerRef={containerRef}
onClick={handleOpenMenu}
/>
</div>
)}
{openMenu && menuRowId && (
<GridRowMenu
open={openMenu}
onClose={handleCloseMenu}
transformOrigin={{
vertical: 'center',
horizontal: 'left',
}}
rowId={menuRowId}
anchorReference={'anchorPosition'}
anchorPosition={menuPosition}
/>
)}
</>
);
}
export default GridRowActions;

View File

@ -0,0 +1,64 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import GridRowMenu from './GridRowMenu';
import { toggleProperty } from './GridRowActions.hooks';
function GridRowContextMenu({
containerRef,
hoverRowId,
}: {
hoverRowId?: string;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
}) {
const [position, setPosition] = useState<{ left: number; top: number } | undefined>();
const [rowId, setRowId] = useState<string | undefined>();
const isContextMenuOpen = useMemo(() => {
return !!position;
}, [position]);
const closeContextMenu = useCallback(() => {
setPosition(undefined);
const container = containerRef.current;
if (!container || !rowId) return;
toggleProperty(container, rowId, false);
setRowId(undefined);
}, [rowId, containerRef]);
const openContextMenu = useCallback(
(event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const container = containerRef.current;
if (!container || !hoverRowId) return;
toggleProperty(container, hoverRowId, true);
setRowId(hoverRowId);
setPosition({
left: event.clientX,
top: event.clientY,
});
},
[containerRef, hoverRowId]
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
container.addEventListener('contextmenu', openContextMenu);
return () => {
container.removeEventListener('contextmenu', openContextMenu);
};
}, [containerRef, openContextMenu]);
return isContextMenuOpen && rowId ? (
<GridRowMenu open={isContextMenuOpen} onClose={closeContextMenu} anchorPosition={position} rowId={rowId} />
) : null;
}
export default GridRowContextMenu;

View File

@ -0,0 +1,36 @@
import React from 'react';
import { useDraggableGridRow } from './GridRowActions.hooks';
import { IconButton, Tooltip } from '@mui/material';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
import { useTranslation } from 'react-i18next';
function GridRowDragButton({
rowId,
containerRef,
onClick,
getScrollElement,
}: {
rowId: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
getScrollElement: () => HTMLDivElement | null;
}) {
const { t } = useTranslation();
const { onDragStart } = useDraggableGridRow(rowId, containerRef, getScrollElement);
return (
<Tooltip placement='top' title={t('grid.row.dragAndClick')}>
<IconButton
onClick={onClick}
draggable={true}
onDragStart={onDragStart}
className='mx-1 cursor-grab active:cursor-grabbing'
>
<DragSvg className='-mx-1' />
</IconButton>
</Tooltip>
);
}
export default GridRowDragButton;

View File

@ -1,18 +1,14 @@
import React, { useCallback } from 'react';
import { MenuList, MenuItem, Icon } from '@mui/material';
import { useTranslation } from 'react-i18next';
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';
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
import Popover, { PopoverProps } from '@mui/material/Popover';
import { useGetPrevRowId } from '$app/components/database';
import { useViewId } from '$app/hooks';
import { useTranslation } from 'react-i18next';
import { rowService } from '$app/components/database/application';
import { useViewId } from '@/appflowy_app/hooks/ViewId.hooks';
interface Props {
rowId: string;
getPrevRowId: (id: string) => string | null;
onClickItem: (label: string) => void;
}
import { Icon, MenuItem, MenuList } from '@mui/material';
interface Option {
label: string;
@ -21,7 +17,13 @@ interface Option {
divider?: boolean;
}
function GridCellRowMenu({ rowId, getPrevRowId, onClickItem }: Props) {
interface Props extends PopoverProps {
rowId: string;
}
function GridRowMenu({ rowId, ...props }: Props) {
const getPrevRowId = useGetPrevRowId();
const viewId = useViewId();
const { t } = useTranslation();
@ -74,23 +76,30 @@ function GridCellRowMenu({ rowId, getPrevRowId, onClickItem }: Props) {
];
return (
<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();
onClickItem(option.label);
}}
>
<Icon className='mr-2'>{option.icon}</Icon>
{option.label}
</MenuItem>
</div>
))}
</MenuList>
<Popover
keepMounted={false}
anchorReference={'anchorPosition'}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
{...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');
}}
>
<Icon className='mr-2'>{option.icon}</Icon>
{option.label}
</MenuItem>
</div>
))}
</MenuList>
</Popover>
);
}
export default GridCellRowMenu;
export default GridRowMenu;

View File

@ -0,0 +1,9 @@
import { createContext, useContext } from 'react';
export const OpenMenuContext = createContext<string | null>(null);
export const useOpenMenu = (id: string) => {
const context = useContext(OpenMenuContext);
return context === id;
};

View File

@ -0,0 +1,102 @@
import React, { useCallback, useState } from 'react';
import { GridChildComponentProps, VariableSizeGrid as Grid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useGridColumn } from '$app/components/database/grid/GridTable/GridTable.hooks';
import { GridField } from '$app/components/database/grid/GridField';
import NewProperty from '$app/components/database/components/property/NewProperty';
import { GridColumn, GridColumnType } from '$app/components/database/grid/constants';
import { OpenMenuContext } from '$app/components/database/grid/GridStickyHeader/GridStickyHeader.hooks';
const GridStickyHeader = React.forwardRef<
Grid<HTMLDivElement> | null,
{ columns: GridColumn[]; getScrollElement?: () => HTMLDivElement | null }
>(({ columns, getScrollElement }, ref) => {
const { columnWidth, resizeColumnWidth } = useGridColumn(
columns,
ref as React.MutableRefObject<Grid<HTMLDivElement> | null>
);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const handleOpenMenu = useCallback((id: string) => {
setOpenMenuId(id);
}, []);
const handleCloseMenu = useCallback((id: string) => {
setOpenMenuId((prev) => {
if (prev === id) {
return null;
}
return prev;
});
}, []);
const Cell = useCallback(
({ columnIndex, style }: GridChildComponentProps) => {
const column = columns[columnIndex];
if (column.type === GridColumnType.NewProperty) {
const width = (style.width || 0) as number;
return (
<div
style={{
...style,
width: width + 8,
}}
className={'border-b border-r border-t border-line-divider'}
>
<NewProperty onInserted={setOpenMenuId} />
</div>
);
}
if (column.type === GridColumnType.Action) {
return <div style={style} />;
}
const field = column.field;
if (!field) return <div style={style} />;
return (
<GridField
className={'border-b border-r border-t border-line-divider'}
style={style}
onCloseMenu={handleCloseMenu}
onOpenMenu={handleOpenMenu}
resizeColumnWidth={(width: number) => resizeColumnWidth(columnIndex, width)}
field={field}
getScrollElement={getScrollElement}
/>
);
},
[columns, handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement]
);
return (
<OpenMenuContext.Provider value={openMenuId}>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => {
return (
<Grid
height={height}
width={width}
rowHeight={() => 36}
rowCount={1}
columnCount={columns.length}
columnWidth={columnWidth}
ref={ref}
style={{ overflowX: 'hidden', overscrollBehavior: 'none' }}
>
{Cell}
</Grid>
);
}}
</AutoSizer>
</OpenMenuContext.Provider>
);
});
export default GridStickyHeader;

View File

@ -0,0 +1,58 @@
import React, { useCallback, useEffect, useState } from 'react';
import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH, GridColumn } from '$app/components/database/grid/constants';
import { VariableSizeGrid as Grid } from 'react-window';
export function useGridRow() {
const rowHeight = useCallback(() => {
return 36;
}, []);
return {
rowHeight,
};
}
export function useGridColumn(columns: GridColumn[], ref: React.RefObject<Grid<HTMLDivElement> | null>) {
const [columnWidths, setColumnWidths] = useState<number[]>([]);
useEffect(() => {
setColumnWidths(
columns.map((field, index) => (index === 0 ? GRID_ACTIONS_WIDTH : field.width || DEFAULT_FIELD_WIDTH))
);
ref.current?.resetAfterColumnIndex(0);
}, [columns, ref]);
const resizeColumnWidth = useCallback(
(index: number, width: number) => {
setColumnWidths((columnWidths) => {
if (columnWidths[index] === width) {
return columnWidths;
}
const newColumnWidths = [...columnWidths];
newColumnWidths[index] = width;
return newColumnWidths;
});
if (ref.current) {
ref.current.resetAfterColumnIndex(index);
}
},
[ref]
);
const columnWidth = useCallback(
(index: number) => {
if (index === 0) return GRID_ACTIONS_WIDTH;
return columnWidths[index] || DEFAULT_FIELD_WIDTH;
},
[columnWidths]
);
return {
columnWidth,
resizeColumnWidth,
};
}

View File

@ -1,85 +1,132 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import React, { FC, useCallback, useMemo, useRef } from 'react';
import { RowMeta } from '../../application';
import { useDatabaseVisibilityFields, useDatabaseVisibilityRows } from '../../Database.hooks';
import { VirtualizedList } from '../../_shared';
import { DEFAULT_FIELD_WIDTH, GridRow, RenderRow, RenderRowType, rowMetasToRenderRow } from '../GridRow';
import { fieldsToColumns, GridColumn, RenderRow, RenderRowType, rowMetasToRenderRow } from '../constants';
import { CircularProgress } from '@mui/material';
import { GridChildComponentProps, GridOnScrollProps, VariableSizeGrid as Grid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GridCell } from '$app/components/database/grid/GridCell';
import { useGridColumn, useGridRow } from '$app/components/database/grid/GridTable/GridTable.hooks';
import GridStickyHeader from '$app/components/database/grid/GridStickyHeader/GridStickyHeader';
import GridTableOverlay from '$app/components/database/grid/GridOverlay/GridTableOverlay';
import ReactDOM from 'react-dom';
const getRenderRowKey = (row: RenderRow) => {
if (row.type === RenderRowType.Row) {
return `row:${row.data.meta.id}`;
}
export interface GridTableProps {
onEditRecord: (rowId: string) => void;
}
return row.type;
};
export const GridTable: FC<{ tableHeight: number }> = React.memo(({ tableHeight }) => {
const verticalScrollElementRef = useRef<HTMLDivElement | null>(null);
const horizontalScrollElementRef = useRef<HTMLDivElement | null>(null);
export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
const rowMetas = useDatabaseVisibilityRows();
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 { rowHeight } = useGridRow();
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: renderRows.length,
overscan: 10,
getItemKey: (i) => getRenderRowKey(renderRows[i]),
getScrollElement: () => verticalScrollElementRef.current,
estimateSize: () => 37,
});
const getItemKey = useCallback(
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
const row = renderRows[rowIndex];
const column = columns[columnIndex];
const columnVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
horizontal: true,
count: fields.length,
overscan: 5,
getItemKey: (i) => fields[i].id,
getScrollElement: () => horizontalScrollElementRef.current,
estimateSize: (i) => {
return fields[i].width ?? DEFAULT_FIELD_WIDTH;
},
});
const field = column.field;
const getPrevRowId = useCallback(
(id: string) => {
const index = rowMetas.findIndex((rowMeta) => rowMeta.id === id);
if (row.type === RenderRowType.Row) {
if (field) {
return `${row.data.meta.id}:${field.id}`;
}
if (index === 0) {
return null;
return `${row.data.meta.id}:${column.type}`;
}
return rowMetas[index - 1].id;
if (field) {
return `${row.type}:${field.id}`;
}
return `${row.type}:${column.type}`;
},
[rowMetas]
[columns, renderRows]
);
const getContainerRef = useCallback(() => {
return containerRef;
}, []);
const Cell = useCallback(
({ columnIndex, rowIndex, style }: GridChildComponentProps) => {
const row = renderRows[rowIndex];
const column = columns[columnIndex];
return (
<GridCell
getContainerRef={getContainerRef}
onEditRecord={onEditRecord}
columnIndex={columnIndex}
style={style}
row={row}
column={column}
/>
);
},
[columns, getContainerRef, renderRows, onEditRecord]
);
const staticGrid = useRef<Grid<HTMLDivElement> | null>(null);
const onScroll = useCallback(({ scrollLeft, scrollUpdateWasRequested }: GridOnScrollProps) => {
if (!scrollUpdateWasRequested) {
staticGrid.current?.scrollTo({ scrollLeft, scrollTop: 0 });
}
}, []);
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const getScrollElement = useCallback(() => {
return scrollElementRef.current;
}, []);
return (
<div
style={{
height: tableHeight,
}}
className={'flex h-full w-full flex-col'}
>
<div className={'flex w-full flex-1 flex-col '}>
{fields.length === 0 && (
<div className={'absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center bg-bg-body'}>
<CircularProgress />
</div>
)}
<div
className={'w-full flex-1 overflow-auto scroll-smooth'}
ref={(e) => {
verticalScrollElementRef.current = e;
horizontalScrollElementRef.current = e;
}}
>
<VirtualizedList
className='flex w-fit basis-full flex-col px-16'
virtualizer={rowVirtualizer}
itemClassName='flex'
renderItem={(index) => (
<GridRow getPrevRowId={getPrevRowId} row={renderRows[index]} virtualizer={columnVirtualizer} />
<div className={'h-[36px]'}>
<GridStickyHeader ref={staticGrid} getScrollElement={getScrollElement} columns={columns} />
</div>
<div className={'flex-1'}>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<Grid
ref={ref}
onScroll={onScroll}
columnCount={columns.length}
columnWidth={columnWidth}
height={height}
rowCount={renderRows.length}
rowHeight={rowHeight}
width={width}
overscanRowCount={10}
itemKey={getItemKey}
style={{
overscrollBehavior: 'none',
}}
outerRef={scrollElementRef}
innerRef={containerRef}
>
{Cell}
</Grid>
)}
/>
</AutoSizer>
{containerRef.current
? ReactDOM.createPortal(
<GridTableOverlay getScrollElement={getScrollElement} containerRef={containerRef} />,
containerRef.current
)
: null}
</div>
</div>
);

View File

@ -1,7 +1,9 @@
import { RowMeta } from '../../application';
import { Field, RowMeta } from '../application';
export const GridCalculateCountHeight = 40;
export const GRID_ACTIONS_WIDTH = 64;
export const DEFAULT_FIELD_WIDTH = 150;
export enum RenderRowType {
@ -36,11 +38,26 @@ export interface NewRenderRow {
export type RenderRow = FieldRenderRow | CellRenderRow | NewRenderRow | CalculateRenderRow;
export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => {
export const fieldsToColumns = (fields: Field[]): GridColumn[] => {
return [
{
type: RenderRowType.Fields,
type: GridColumnType.Action,
width: GRID_ACTIONS_WIDTH,
},
...fields.map<GridColumn>((field) => ({
field,
width: field.width || DEFAULT_FIELD_WIDTH,
type: GridColumnType.Field,
})),
{
type: GridColumnType.NewProperty,
width: DEFAULT_FIELD_WIDTH,
},
];
};
export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => {
return [
...rowMetas.map<RenderRow>((rowMeta) => ({
type: RenderRowType.Row,
data: {
@ -58,3 +75,15 @@ export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => {
},
];
};
export enum GridColumnType {
Action,
Field,
NewProperty,
}
export interface GridColumn {
field?: Field;
width: number;
type: GridColumnType;
}

View File

@ -1,14 +0,0 @@
import React, { useEffect } from 'react';
import { GridUIContext, useProxyGridUIState } from '$app/components/database/proxy/grid/ui_state/actions';
function GridUIProvider({ children, isActivated }: { children: React.ReactNode; isActivated: boolean }) {
const context = useProxyGridUIState();
useEffect(() => {
context.isActivated = isActivated;
}, [isActivated, context]);
return <GridUIContext.Provider value={context}>{children}</GridUIContext.Provider>;
}
export default GridUIProvider;

View File

@ -1,46 +0,0 @@
import { useMemo, useContext, createContext, useCallback } from 'react';
import { proxy, useSnapshot } from 'valtio';
export interface GridUIContextState {
hoverRowId: string | null;
isActivated: boolean;
}
const initialUIState: GridUIContextState = {
hoverRowId: null,
isActivated: false,
};
function proxyGridUIState(state: GridUIContextState) {
return proxy<GridUIContextState>(state);
}
export const GridUIContext = createContext<GridUIContextState>(proxyGridUIState(initialUIState));
export function useProxyGridUIState() {
const context = useMemo<GridUIContextState>(() => {
return proxyGridUIState({
...initialUIState,
});
}, []);
return context;
}
export function useGridUIStateSelector() {
return useSnapshot(useContext(GridUIContext));
}
export function useGridUIStateDispatcher() {
const context = useContext(GridUIContext);
const setRowHover = useCallback(
(rowId: string | null) => {
context.hoverRowId = rowId;
},
[context]
);
return {
setRowHover,
};
}

View File

@ -149,3 +149,6 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
background: var(--fill-hover);
}
.react-swipeable-view-container {
height: 100%;
}