mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
fe5ce75ea8
commit
d765806337
@ -68,5 +68,5 @@ module.exports = {
|
||||
|
||||
]
|
||||
},
|
||||
ignorePatterns: ['src/**/*.test.ts'],
|
||||
ignorePatterns: ['src/**/*.test.ts', 'package.json'],
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
))}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>(() => {
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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}` });
|
||||
|
||||
|
@ -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} />;
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
@ -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 && (
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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'}>
|
||||
|
@ -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;
|
@ -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(() => {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
}}
|
||||
|
@ -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;
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
@ -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);
|
||||
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
export * from './Field';
|
||||
export * from './FieldSelect';
|
||||
export * from './FieldTypeText';
|
||||
export * from './FieldTypeSvg';
|
@ -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,
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
@ -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;
|
@ -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'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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>
|
@ -0,0 +1,4 @@
|
||||
export * from './Property';
|
||||
export * from './PropertySelect';
|
||||
export * from './property_type/PropertyTypeText';
|
||||
export * from './property_type/ProppertyTypeSvg';
|
@ -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>
|
@ -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;
|
@ -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;
|
@ -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(() => {
|
@ -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} />;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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'
|
||||
|
@ -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;
|
||||
|
@ -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} />;
|
||||
};
|
||||
|
@ -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'}
|
||||
>
|
||||
|
@ -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);
|
||||
|
@ -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;
|
@ -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
|
||||
);
|
||||
|
@ -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;
|
@ -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;
|
@ -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, {
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
@ -1 +0,0 @@
|
||||
export * from './GridCellRow';
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
});
|
@ -1,2 +0,0 @@
|
||||
export * from './GridRow';
|
||||
export * from './constants';
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -149,3 +149,6 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
||||
background: var(--fill-hover);
|
||||
}
|
||||
|
||||
.react-swipeable-view-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user