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-router-dom": "^6.8.0",
|
||||||
"react-swipeable-views": "^0.14.0",
|
"react-swipeable-views": "^0.14.0",
|
||||||
"react-transition-group": "^4.4.5",
|
"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",
|
"react18-input-otp": "^1.1.2",
|
||||||
"redux": "^4.2.1",
|
"redux": "^4.2.1",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
@ -89,6 +92,7 @@
|
|||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@types/react-katex": "^3.0.0",
|
"@types/react-katex": "^3.0.0",
|
||||||
"@types/react-transition-group": "^4.4.6",
|
"@types/react-transition-group": "^4.4.6",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"@types/utf8": "^3.0.1",
|
"@types/utf8": "^3.0.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||||
|
@ -130,6 +130,15 @@ dependencies:
|
|||||||
react-transition-group:
|
react-transition-group:
|
||||||
specifier: ^4.4.5
|
specifier: ^4.4.5
|
||||||
version: 4.4.5(react-dom@18.2.0)(react@18.2.0)
|
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:
|
react18-input-otp:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.3(react-dom@18.2.0)(react@18.2.0)
|
version: 1.1.3(react-dom@18.2.0)(react@18.2.0)
|
||||||
@ -210,6 +219,9 @@ devDependencies:
|
|||||||
'@types/react-transition-group':
|
'@types/react-transition-group':
|
||||||
specifier: ^4.4.6
|
specifier: ^4.4.6
|
||||||
version: 4.4.6
|
version: 4.4.6
|
||||||
|
'@types/react-window':
|
||||||
|
specifier: ^1.8.8
|
||||||
|
version: 1.8.8
|
||||||
'@types/utf8':
|
'@types/utf8':
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
@ -2308,6 +2320,11 @@ packages:
|
|||||||
'@types/react': 18.2.6
|
'@types/react': 18.2.6
|
||||||
dev: false
|
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:
|
/@types/react@17.0.59:
|
||||||
resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==}
|
resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -2733,7 +2750,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
|
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
|
||||||
engines: {node: '>=10', npm: '>=6'}
|
engines: {node: '>=10', npm: '>=6'}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.22.10
|
'@babel/runtime': 7.23.4
|
||||||
cosmiconfig: 7.1.0
|
cosmiconfig: 7.1.0
|
||||||
resolve: 1.22.2
|
resolve: 1.22.2
|
||||||
dev: false
|
dev: false
|
||||||
@ -3235,7 +3252,7 @@ packages:
|
|||||||
/dom-helpers@5.2.1:
|
/dom-helpers@5.2.1:
|
||||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.21.5
|
'@babel/runtime': 7.23.4
|
||||||
csstype: 3.1.2
|
csstype: 3.1.2
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
@ -5632,7 +5649,7 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.3.0
|
react: ^16.3.0
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.22.10
|
'@babel/runtime': 7.23.4
|
||||||
prop-types: 15.8.1
|
prop-types: 15.8.1
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
warning: 4.0.3
|
warning: 4.0.3
|
||||||
@ -5717,7 +5734,7 @@ packages:
|
|||||||
react-native:
|
react-native:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.21.5
|
'@babel/runtime': 7.23.4
|
||||||
'@types/react-redux': 7.1.25
|
'@types/react-redux': 7.1.25
|
||||||
hoist-non-react-statics: 3.3.2
|
hoist-non-react-statics: 3.3.2
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@ -5839,6 +5856,44 @@ packages:
|
|||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
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):
|
/react18-input-otp@1.1.3(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-55dZMVX61In2ngUhA4Fv0NMY4j5RZjxrJaSOAnJGJmkAhxKB6puVHYEmipyy2+W2CPydFF7pv+0NKzPUA03EVg==}
|
resolution: {integrity: sha512-55dZMVX61In2ngUhA4Fv0NMY4j5RZjxrJaSOAnJGJmkAhxKB6puVHYEmipyy2+W2CPydFF7pv+0NKzPUA03EVg==}
|
||||||
peerDependencies:
|
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 { useSearchParams } from 'react-router-dom';
|
||||||
import { proxy, useSnapshot } from 'valtio';
|
import { proxy, useSnapshot } from 'valtio';
|
||||||
|
|
||||||
@ -52,6 +52,26 @@ export const DatabaseProvider = DatabaseContext.Provider;
|
|||||||
|
|
||||||
export const useDatabase = () => useSnapshot(useContext(DatabaseContext));
|
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) => {
|
export const useSelectorCell = (rowId: string, fieldId: string) => {
|
||||||
const database = useContext(DatabaseContext);
|
const database = useContext(DatabaseContext);
|
||||||
const cells = useSnapshot(database.cells);
|
const cells = useSnapshot(database.cells);
|
||||||
@ -181,50 +201,3 @@ export const useConnectDatabase = (viewId: string) => {
|
|||||||
|
|
||||||
return database;
|
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 { useViewId } from '$app/hooks/ViewId.hooks';
|
||||||
import { databaseViewService } from './application';
|
import { databaseViewService } from './application';
|
||||||
import { DatabaseTabBar } from './components';
|
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 { PageController } from '$app/stores/effects/workspace/page/page_controller';
|
||||||
import SwipeableViews from 'react-swipeable-views';
|
import SwipeableViews from 'react-swipeable-views';
|
||||||
import { TabPanel } from '$app/components/database/components/tab_bar/ViewTabs';
|
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 DatabaseSettings from '$app/components/database/components/database_settings/DatabaseSettings';
|
||||||
import { Portal } from '@mui/material';
|
import { Portal } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ErrorCode } from '@/services/backend';
|
import { ErrorCode } from '@/services/backend';
|
||||||
|
import ExpandRecordModal from '$app/components/database/components/edit_record/ExpandRecordModal';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedViewId?: string;
|
selectedViewId?: string;
|
||||||
@ -20,11 +20,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
const [childViewIds, setChildViewIds] = useState<string[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
const onPageChanged = () => {
|
const onPageChanged = () => {
|
||||||
@ -79,6 +81,13 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
|||||||
[openCollections, setOpenCollections]
|
[openCollections, setOpenCollections]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onEditRecord = useCallback(
|
||||||
|
(rowId: string) => {
|
||||||
|
setEditRecordRowId(rowId);
|
||||||
|
},
|
||||||
|
[setEditRecordRowId]
|
||||||
|
);
|
||||||
|
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return (
|
return (
|
||||||
<div className='mb-2 flex h-full w-full items-center justify-center rounded border border-dashed border-line-divider'>
|
<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}
|
index={value}
|
||||||
>
|
>
|
||||||
{childViewIds.map((id, index) => (
|
{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}>
|
<DatabaseLoader viewId={id}>
|
||||||
{selectedViewId === id && (
|
{selectedViewId === id && (
|
||||||
<>
|
<>
|
||||||
@ -115,13 +124,20 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
<div ref={collectionRef}>
|
<DatabaseCollection open={openCollections.includes(id)} />
|
||||||
<DatabaseCollection open={openCollections.includes(id)} />
|
{editRecordRowId && (
|
||||||
</div>
|
<ExpandRecordModal
|
||||||
|
rowId={editRecordRowId}
|
||||||
|
open={Boolean(editRecordRowId)}
|
||||||
|
onClose={() => {
|
||||||
|
setEditRecordRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DatabaseView isActivated={selectedViewId === id} tableHeight={tableHeight} />
|
<DatabaseView onEditRecord={onEditRecord} />
|
||||||
</DatabaseLoader>
|
</DatabaseLoader>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
))}
|
))}
|
||||||
|
@ -6,8 +6,7 @@ import { Board } from './board';
|
|||||||
import { Calendar } from './calendar';
|
import { Calendar } from './calendar';
|
||||||
|
|
||||||
export const DatabaseView: FC<{
|
export const DatabaseView: FC<{
|
||||||
tableHeight: number;
|
onEditRecord: (rowId: string) => void;
|
||||||
isActivated: boolean;
|
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const { layoutType } = useDatabase();
|
const { layoutType } = useDatabase();
|
||||||
|
|
||||||
|
@ -9,8 +9,8 @@ export const CellText = React.forwardRef<HTMLDivElement, PropsWithChildren<HTMLA
|
|||||||
const { children, className, ...other } = props;
|
const { children, className, ...other } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={['flex h-full w-full p-2', className].join(' ')} {...other}>
|
<div ref={ref} className={['flex w-full p-2', className].join(' ')} {...other}>
|
||||||
<span className='flex-1 truncate text-sm'>{children}</span>
|
<span className='inline-block flex-1 truncate text-sm'>{children}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ export interface UseDraggableOptions {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
scrollOnEdge?: {
|
scrollOnEdge?: {
|
||||||
direction?: ScrollDirection;
|
direction?: ScrollDirection;
|
||||||
|
getScrollElement?: () => HTMLElement | null;
|
||||||
edgeGap?: number | Partial<EdgeGap>;
|
edgeGap?: number | Partial<EdgeGap>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -73,7 +74,8 @@ export const useDraggable = ({
|
|||||||
return;
|
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) {
|
if (scrollParent) {
|
||||||
autoScrollOnEdge({
|
autoScrollOnEdge({
|
||||||
@ -83,7 +85,7 @@ export const useDraggable = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[context, effectAllowed, scrollDirection, edgeGap]
|
[context, effectAllowed, scrollDirection, scrollOnEdge, edgeGap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDragEnd = useCallback<DragEventHandler>(() => {
|
const onDragEnd = useCallback<DragEventHandler>(() => {
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
FieldSettingsChangesetPB,
|
FieldSettingsChangesetPB,
|
||||||
FieldVisibility,
|
FieldVisibility,
|
||||||
DatabaseViewIdPB,
|
DatabaseViewIdPB,
|
||||||
|
CreateFieldPosition,
|
||||||
} from '@/services/backend';
|
} from '@/services/backend';
|
||||||
import {
|
import {
|
||||||
DatabaseEventDuplicateField,
|
DatabaseEventDuplicateField,
|
||||||
@ -80,11 +81,25 @@ export async function getFields(
|
|||||||
return { fields, typeOptions };
|
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({
|
const payload = CreateFieldPayloadPB.fromObject({
|
||||||
view_id: viewId,
|
view_id: viewId,
|
||||||
field_type: fieldType,
|
field_type: fieldType,
|
||||||
type_option_data: data,
|
type_option_data: data,
|
||||||
|
target_field_id: targetFieldId,
|
||||||
|
field_position: fieldPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await DatabaseEventCreateField(payload);
|
const result = await DatabaseEventCreateField(payload);
|
||||||
@ -188,3 +203,12 @@ export async function updateFieldSetting(
|
|||||||
|
|
||||||
return result.val;
|
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);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [fetchCell, cell, loading]);
|
}, [fetchCell, cell, loading, rowId, field.id]);
|
||||||
|
|
||||||
useNotification(DatabaseNotification.DidUpdateCell, fetchCell, { id: `${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 { FieldType } from '@/services/backend';
|
||||||
|
|
||||||
import { Cell as CellType, Field } from '../../application';
|
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 DateTimeCell from '$app/components/database/components/cell/DateTimeCell';
|
||||||
import TimestampCell from '$app/components/database/components/cell/TimestampCell';
|
import TimestampCell from '$app/components/database/components/cell/TimestampCell';
|
||||||
|
|
||||||
export interface CellProps {
|
export interface CellProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
rowId: string;
|
rowId: string;
|
||||||
field: Field;
|
field: Field;
|
||||||
documentId?: string;
|
|
||||||
icon?: string;
|
icon?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CellComponentProps {
|
export interface CellComponentProps extends CellProps {
|
||||||
field: Field;
|
|
||||||
cell: CellType;
|
cell: CellType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCellComponent = (fieldType: FieldType) => {
|
const getCellComponent = (fieldType: FieldType) => {
|
||||||
switch (fieldType) {
|
switch (fieldType) {
|
||||||
case FieldType.RichText:
|
case FieldType.RichText:
|
||||||
@ -62,5 +61,5 @@ export const Cell: FC<CellProps> = ({ rowId, field, ...props }) => {
|
|||||||
return null;
|
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]);
|
}, [viewId, cell, field.id, checked]);
|
||||||
|
|
||||||
return (
|
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 />}
|
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<CellText className={'min-h-[36px]'} ref={cellRef} onClick={handleClick}>
|
<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>
|
</CellText>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{editing && (
|
{editing && (
|
||||||
|
@ -1,28 +1,17 @@
|
|||||||
import { FC, FormEventHandler, Suspense, lazy, useCallback, useEffect, useRef, useMemo } from 'react';
|
import { FC, FormEventHandler, Suspense, lazy, useCallback, useRef, useMemo } from 'react';
|
||||||
import { Field, TextCell as TextCellType } from '../../application';
|
import { TextCell as TextCellType } from '../../application';
|
||||||
import { CellText } from '../../_shared';
|
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';
|
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'));
|
const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput'));
|
||||||
|
|
||||||
export const TextCell: FC<{
|
interface TextCellProps {
|
||||||
field: Field;
|
|
||||||
cell: TextCellType;
|
cell: TextCellType;
|
||||||
documentId?: string;
|
|
||||||
icon?: string;
|
|
||||||
placeholder?: 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 cellRef = useRef<HTMLDivElement>(null);
|
||||||
const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell);
|
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 = () => {
|
const handleClose = () => {
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
updateCell();
|
updateCell();
|
||||||
@ -41,12 +30,6 @@ export const TextCell: FC<{
|
|||||||
[setValue]
|
[setValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editing) {
|
|
||||||
setRowHover(null);
|
|
||||||
}
|
|
||||||
}, [editing, setRowHover]);
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (cell && typeof cell.data === 'string' && cell.data) {
|
if (cell && typeof cell.data === 'string' && cell.data) {
|
||||||
return cell.data;
|
return cell.data;
|
||||||
@ -56,33 +39,21 @@ export const TextCell: FC<{
|
|||||||
}, [cell, placeholder]);
|
}, [cell, placeholder]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'relative h-full'}>
|
<>
|
||||||
<CellText
|
<CellText className={`min-h-[36px] w-full`} ref={cellRef} onClick={handleClick}>
|
||||||
style={{
|
{content}
|
||||||
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>
|
</CellText>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{cell && <ExpandButton visible={showExpandIcon} icon={icon} documentId={documentId} cell={cell} />}
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<EditTextCellInput
|
<EditTextCellInput
|
||||||
editing={editing}
|
editing={editing}
|
||||||
anchorEl={cellRef.current}
|
anchorEl={cellRef.current}
|
||||||
width={field.width}
|
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
text={value}
|
text={value}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -71,14 +71,13 @@ function UrlCell({ field, cell, placeholder }: Props) {
|
|||||||
ref={cellRef}
|
ref={cellRef}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className={`flex w-full items-center whitespace-break-spaces break-all `}>{content}</div>
|
{content}
|
||||||
</CellText>
|
</CellText>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
{editing && (
|
{editing && (
|
||||||
<EditTextCellInput
|
<EditTextCellInput
|
||||||
editing={editing}
|
editing={editing}
|
||||||
anchorEl={cellRef.current}
|
anchorEl={cellRef.current}
|
||||||
width={field.width}
|
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
text={value}
|
text={value}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
|
@ -1,35 +1,95 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useDatabase } from '$app/components/database';
|
import { useDatabase } from '$app/components/database';
|
||||||
import { Field as FieldType } from '$app/components/database/application';
|
import { Field as FieldType, fieldService } from '$app/components/database/application';
|
||||||
import { Field } from '$app/components/database/components/field';
|
import { Property } from '$app/components/database/components/property';
|
||||||
import { FieldVisibility } from '@/services/backend';
|
import { FieldVisibility } from '@/services/backend';
|
||||||
import { ReactComponent as EyeOpen } from '$app/assets/eye_open.svg';
|
import { ReactComponent as EyeOpen } from '$app/assets/eye_open.svg';
|
||||||
import { ReactComponent as EyeClosed } from '$app/assets/eye_close.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 {
|
interface PropertiesProps {
|
||||||
onItemClick: (field: FieldType) => void;
|
onItemClick: (field: FieldType) => void;
|
||||||
}
|
}
|
||||||
function Properties({ onItemClick }: PropertiesProps) {
|
function Properties({ onItemClick }: PropertiesProps) {
|
||||||
const { fields } = useDatabase();
|
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 (
|
return (
|
||||||
<div className={'max-h-[300px] overflow-y-auto py-2'}>
|
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||||
{fields.map((field) => (
|
<Droppable droppableId='droppable' type='droppableItem'>
|
||||||
<MenuItem
|
{(dropProvided) => (
|
||||||
disabled={field.isPrimary}
|
<div
|
||||||
onClick={() => onItemClick(field)}
|
ref={dropProvided.innerRef}
|
||||||
className={'flex w-full items-center justify-between overflow-hidden px-1.5'}
|
{...dropProvided.droppableProps}
|
||||||
key={field.id}
|
className={'max-h-[300px] overflow-y-auto py-2'}
|
||||||
>
|
>
|
||||||
<div className={'w-[100px] overflow-hidden text-ellipsis'}>
|
{state.map((field, index) => (
|
||||||
<Field field={field} />
|
<Draggable key={field.id} draggableId={field.id} index={index}>
|
||||||
</div>
|
{(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>
|
<IconButton
|
||||||
</MenuItem>
|
disabled={field.isPrimary}
|
||||||
))}
|
size={'small'}
|
||||||
</div>
|
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 React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { TextCell } from '$app/components/database/application';
|
|
||||||
import RecordDocument from '$app/components/database/components/edit_record/RecordDocument';
|
import RecordDocument from '$app/components/database/components/edit_record/RecordDocument';
|
||||||
import RecordHeader from '$app/components/database/components/edit_record/RecordHeader';
|
import RecordHeader from '$app/components/database/components/edit_record/RecordHeader';
|
||||||
import { Page } from '$app_reducers/pages/slice';
|
import { Page } from '$app_reducers/pages/slice';
|
||||||
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
|
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
|
||||||
import { ErrorCode, ViewLayoutPB } from '@/services/backend';
|
import { ErrorCode, ViewLayoutPB } from '@/services/backend';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
|
import { useDatabase } from '$app/components/database';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cell: TextCell;
|
rowId: string;
|
||||||
documentId: string;
|
|
||||||
icon?: 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 [page, setPage] = useState<Page | null>(null);
|
||||||
|
const id = row?.documentId;
|
||||||
|
|
||||||
const loadPage = useCallback(async () => {
|
const loadPage = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@ -47,8 +51,10 @@ function EditRecord({ documentId: id, cell, icon }: Props) {
|
|||||||
}, [loadPage]);
|
}, [loadPage]);
|
||||||
|
|
||||||
const getDocumentTitle = useCallback(() => {
|
const getDocumentTitle = useCallback(() => {
|
||||||
return <RecordHeader page={page} cell={cell} icon={icon} />;
|
return row ? <RecordHeader page={page} row={row} /> : null;
|
||||||
}, [cell, icon, page]);
|
}, [row, page]);
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'h-full px-12 py-6'}>
|
<div className={'h-full px-12 py-6'}>
|
||||||
|
@ -1,23 +1,20 @@
|
|||||||
import React, { useState } from 'react';
|
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 DialogContent from '@mui/material/DialogContent';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import { TextCell } from '$app/components/database/application';
|
|
||||||
import { ReactComponent as DetailsIcon } from '$app/assets/details.svg';
|
import { ReactComponent as DetailsIcon } from '$app/assets/details.svg';
|
||||||
import RecordActions from '$app/components/database/components/edit_record/RecordActions';
|
import RecordActions from '$app/components/database/components/edit_record/RecordActions';
|
||||||
import EditRecord from '$app/components/database/components/edit_record/EditRecord';
|
import EditRecord from '$app/components/database/components/edit_record/EditRecord';
|
||||||
|
|
||||||
interface Props extends DialogProps {
|
interface Props extends DialogProps {
|
||||||
cell: TextCell;
|
rowId: string;
|
||||||
documentId: string;
|
|
||||||
icon?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExpandCellModal({ open, onClose, cell, documentId, icon }: Props) {
|
function ExpandRecordModal({ open, onClose, rowId }: Props) {
|
||||||
const [detailAnchorEl, setDetailAnchorEl] = useState<HTMLButtonElement | null>(null);
|
const [detailAnchorEl, setDetailAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Portal>
|
||||||
<Dialog
|
<Dialog
|
||||||
disableAutoFocus={true}
|
disableAutoFocus={true}
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
@ -38,17 +35,17 @@ function ExpandCellModal({ open, onClose, cell, documentId, icon }: Props) {
|
|||||||
<DetailsIcon />
|
<DetailsIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<EditRecord cell={cell} documentId={documentId} icon={icon} />
|
<EditRecord rowId={rowId} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<RecordActions
|
<RecordActions
|
||||||
anchorEl={detailAnchorEl}
|
anchorEl={detailAnchorEl}
|
||||||
cell={cell}
|
rowId={rowId}
|
||||||
open={!!detailAnchorEl}
|
open={!!detailAnchorEl}
|
||||||
onClose={() => setDetailAnchorEl(null)}
|
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 DelSvg } from '$app/assets/delete.svg';
|
||||||
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
|
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useViewId } from '$app/hooks';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
|
||||||
interface Props extends MenuProps {
|
interface Props extends MenuProps {
|
||||||
cell: Cell;
|
rowId: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
function RecordActions({ anchorEl, open, onClose, cell }: Props) {
|
function RecordActions({ anchorEl, open, onClose, rowId }: Props) {
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const rowId = cell.rowId;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleDelRow = useCallback(() => {
|
const handleDelRow = useCallback(() => {
|
||||||
|
@ -2,15 +2,14 @@ import React, { useEffect, useRef } from 'react';
|
|||||||
import RecordTitle from '$app/components/database/components/edit_record/RecordTitle';
|
import RecordTitle from '$app/components/database/components/edit_record/RecordTitle';
|
||||||
import RecordProperties from '$app/components/database/components/edit_record/record_properties/RecordProperties';
|
import RecordProperties from '$app/components/database/components/edit_record/record_properties/RecordProperties';
|
||||||
import { Divider } from '@mui/material';
|
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';
|
import { Page } from '$app_reducers/pages/slice';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: Page | null;
|
page: Page | null;
|
||||||
cell: TextCell;
|
row: RowMeta;
|
||||||
icon?: string;
|
|
||||||
}
|
}
|
||||||
function RecordHeader({ page, cell, icon }: Props) {
|
function RecordHeader({ page, row }: Props) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -30,8 +29,8 @@ function RecordHeader({ page, cell, icon }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={'pb-4'}>
|
<div ref={ref} className={'pb-4'}>
|
||||||
<RecordTitle page={page} cell={cell} icon={icon} />
|
<RecordTitle page={page} row={row} />
|
||||||
<RecordProperties documentId={page?.id} cell={cell} />
|
<RecordProperties documentId={page?.id} row={row} />
|
||||||
<Divider />
|
<Divider />
|
||||||
</div>
|
</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 { Page, PageIcon } from '$app_reducers/pages/slice';
|
||||||
import ViewTitle from '$app/components/_shared/ViewTitle';
|
import ViewTitle from '$app/components/_shared/ViewTitle';
|
||||||
import { ViewIconTypePB } from '@/services/backend';
|
import { ViewIconTypePB } from '@/services/backend';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { updateRowMeta } from '$app/components/database/application/row/row_service';
|
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 {
|
interface Props {
|
||||||
page: Page | null;
|
page: Page | null;
|
||||||
icon?: string;
|
row: RowMeta;
|
||||||
cell: TextCell;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordTitle({ cell, page, icon }: Props) {
|
function RecordTitle({ row, page }: Props) {
|
||||||
const { data: title, fieldId, rowId } = cell;
|
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 viewId = useViewId();
|
||||||
|
|
||||||
const onTitleChange = useCallback(
|
const onTitleChange = useCallback(
|
||||||
async (title: string) => {
|
async (title: string) => {
|
||||||
try {
|
try {
|
||||||
await cellService.updateCell(viewId, rowId, fieldId, title);
|
await cellService.updateCell(viewId, rowId, field.id, title);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// toast.error('Failed to update title');
|
// toast.error('Failed to update title');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fieldId, rowId, viewId]
|
[field.id, rowId, viewId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onUpdateIcon = useCallback(
|
const onUpdateIcon = useCallback(
|
||||||
@ -47,10 +55,10 @@ function RecordTitle({ cell, page, icon }: Props) {
|
|||||||
view={{
|
view={{
|
||||||
...page,
|
...page,
|
||||||
name: title,
|
name: title,
|
||||||
icon: icon
|
icon: row.icon
|
||||||
? {
|
? {
|
||||||
ty: ViewIconTypePB.Emoji,
|
ty: ViewIconTypePB.Emoji,
|
||||||
value: icon,
|
value: row.icon,
|
||||||
}
|
}
|
||||||
: undefined,
|
: 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 PropertyName from '$app/components/database/components/edit_record/record_properties/PropertyName';
|
||||||
import PropertyValue from '$app/components/database/components/edit_record/record_properties/PropertyValue';
|
import PropertyValue from '$app/components/database/components/edit_record/record_properties/PropertyValue';
|
||||||
import { Field } from '$app/components/database/application';
|
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> {
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||||
field: Field;
|
field: Field;
|
||||||
rowId: string;
|
rowId: string;
|
||||||
ishovered: boolean;
|
ishovered: boolean;
|
||||||
onHover: (id: string | null) => void;
|
onHover: (id: string | null) => void;
|
||||||
|
menuOpened?: boolean;
|
||||||
|
onOpenMenu?: () => void;
|
||||||
|
onCloseMenu?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Property({ field, rowId, ishovered, onHover, ...props }: Props, ref: React.ForwardedRef<HTMLDivElement>) {
|
function Property(
|
||||||
const [openMenu, setOpenMenu] = useState(false);
|
{ field, rowId, ishovered, onHover, menuOpened, onCloseMenu, onOpenMenu, ...props }: Props,
|
||||||
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
const handleOpenMenu = useCallback(() => {
|
) {
|
||||||
setOpenMenu(true);
|
const { t } = useTranslation();
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseMenu = useCallback(() => {
|
|
||||||
setOpenMenu(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -36,12 +36,20 @@ function Property({ field, rowId, ishovered, onHover, ...props }: Props, ref: Re
|
|||||||
key={field.id}
|
key={field.id}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PropertyName openMenu={openMenu} onOpenMenu={handleOpenMenu} onCloseMenu={handleCloseMenu} field={field} />
|
<PropertyName menuOpened={menuOpened} onCloseMenu={onCloseMenu} onOpenMenu={onOpenMenu} field={field} />
|
||||||
<PropertyValue rowId={rowId} 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>
|
</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[];
|
properties: Field[];
|
||||||
rowId: string;
|
rowId: string;
|
||||||
placeholderNode?: React.ReactNode;
|
placeholderNode?: React.ReactNode;
|
||||||
|
openMenuPropertyId?: string;
|
||||||
|
setOpenMenuPropertyId?: (id?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyList(
|
function PropertyList(
|
||||||
{ documentId, properties, rowId, placeholderNode, ...props }: Props,
|
{ documentId, properties, rowId, placeholderNode, openMenuPropertyId, setOpenMenuPropertyId, ...props }: Props,
|
||||||
ref: React.ForwardedRef<HTMLDivElement>
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
) {
|
) {
|
||||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||||
@ -44,6 +46,15 @@ function PropertyList(
|
|||||||
ishovered={field.id === hoverId}
|
ishovered={field.id === hoverId}
|
||||||
field={field}
|
field={field}
|
||||||
rowId={rowId}
|
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 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 { Field as FieldType } from '$app/components/database/application';
|
||||||
import { FieldMenu } from '$app/components/database/components/field/FieldMenu';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: FieldType;
|
field: FieldType;
|
||||||
openMenu: boolean;
|
menuOpened?: boolean;
|
||||||
onOpenMenu: () => void;
|
onOpenMenu?: () => void;
|
||||||
onCloseMenu: () => void;
|
onCloseMenu?: () => void;
|
||||||
}
|
}
|
||||||
function PropertyName({ field, openMenu, onOpenMenu, onCloseMenu }: Props) {
|
function PropertyName({ field, menuOpened = false, onOpenMenu, onCloseMenu }: Props) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -19,14 +18,13 @@ function PropertyName({ field, openMenu, onOpenMenu, onCloseMenu }: Props) {
|
|||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onOpenMenu();
|
onOpenMenu?.();
|
||||||
}}
|
}}
|
||||||
className={'flex min-h-[36px] w-[200px] cursor-pointer items-center'}
|
className={'flex min-h-[36px] w-[200px] cursor-pointer items-center'}
|
||||||
onClick={onOpenMenu}
|
onClick={onOpenMenu}
|
||||||
>
|
>
|
||||||
<Field field={field} />
|
<Property menuOpened={menuOpened} onOpenMenu={onOpenMenu} onCloseMenu={onCloseMenu} field={field} />
|
||||||
</div>
|
</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 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 { useDatabase } from '$app/components/database';
|
||||||
import { FieldVisibility } from '@/services/backend';
|
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 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 { useViewId } from '$app/hooks';
|
||||||
import { DragDropContext, Droppable, DropResult, OnDragEndResponder } from 'react-beautiful-dnd';
|
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 {
|
interface Props {
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
cell: TextCell;
|
row: RowMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
// a little function to help us with reordering the result
|
function RecordProperties({ documentId, row }: Props) {
|
||||||
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();
|
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
const { fieldId, rowId } = cell;
|
|
||||||
const { fields } = useDatabase();
|
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 [showHiddenFields, setShowHiddenFields] = useState(false);
|
||||||
|
|
||||||
const properties = useMemo(() => {
|
const properties = useMemo(() => {
|
||||||
@ -84,7 +73,7 @@ function RecordProperties({ documentId, cell }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reorder the properties synchronously to avoid flickering
|
// reorder the properties synchronously to avoid flickering
|
||||||
const newProperties = reorder(properties, oldIndex, newIndex ?? 0);
|
const newProperties = fieldService.reorderFields(properties, oldIndex, newIndex ?? 0);
|
||||||
|
|
||||||
setState(newProperties);
|
setState(newProperties);
|
||||||
|
|
||||||
@ -111,33 +100,19 @@ function RecordProperties({ documentId, cell }: Props) {
|
|||||||
{...dropProvided.droppableProps}
|
{...dropProvided.droppableProps}
|
||||||
rowId={rowId}
|
rowId={rowId}
|
||||||
properties={state}
|
properties={state}
|
||||||
|
openMenuPropertyId={openMenuPropertyId}
|
||||||
|
setOpenMenuPropertyId={setOpenMenuPropertyId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
{
|
<SwitchPropertiesVisible
|
||||||
// show the button only if there are hidden fields
|
hiddenFieldsCount={hiddenFieldsCount}
|
||||||
hiddenFieldsCount > 0 && (
|
showHiddenFields={showHiddenFields}
|
||||||
<Button
|
setShowHiddenFields={setShowHiddenFields}
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<NewProperty />
|
<NewProperty onInserted={setOpenMenuPropertyId} />
|
||||||
</div>
|
</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 {
|
interface Props {
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
anchorEl: HTMLDivElement | null;
|
anchorEl: HTMLDivElement | null;
|
||||||
width: number | undefined;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
text: string;
|
text: string;
|
||||||
onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void;
|
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 handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
const shift = e.shiftKey;
|
const shift = e.shiftKey;
|
||||||
|
|
||||||
@ -27,7 +26,7 @@ function EditTextCellInput({ editing, anchorEl, width, onClose, text, onInput }:
|
|||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
PaperProps={{
|
PaperProps={{
|
||||||
className: 'flex p-2 border border-blue-400',
|
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={{
|
transformOrigin={{
|
||||||
vertical: 1,
|
vertical: 1,
|
||||||
|
@ -11,7 +11,7 @@ import {
|
|||||||
DateFilterData,
|
DateFilterData,
|
||||||
} from '$app/components/database/application';
|
} from '$app/components/database/application';
|
||||||
import { Chip, Popover } from '@mui/material';
|
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 { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg';
|
||||||
import TextFilter from './text_filter/TextFilter';
|
import TextFilter from './text_filter/TextFilter';
|
||||||
import { FieldType } from '@/services/backend';
|
import { FieldType } from '@/services/backend';
|
||||||
@ -111,7 +111,7 @@ function Filter({ filter, field }: Props) {
|
|||||||
variant='outlined'
|
variant='outlined'
|
||||||
label={
|
label={
|
||||||
<div className={'flex items-center justify-center'}>
|
<div className={'flex items-center justify-center'}>
|
||||||
<Field field={field} />
|
<Property field={field} />
|
||||||
<DropDownSvg className={'ml-1.5 h-8 w-8'} />
|
<DropDownSvg className={'ml-1.5 h-8 w-8'} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { MouseEvent, useCallback } from 'react';
|
import React, { MouseEvent, useCallback } from 'react';
|
||||||
import { MenuProps } from '@mui/material';
|
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 { Field } from '$app/components/database/application';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -35,7 +35,7 @@ function FilterFieldsMenu({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover {...props}>
|
<Popover {...props}>
|
||||||
<FieldList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
|
<PropertiesList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
|
||||||
</Popover>
|
</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 React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { OutlinedInput, MenuItem, MenuList } from '@mui/material';
|
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 { Field as FieldType } from '../../application';
|
||||||
import { useDatabase } from '$app/components/database';
|
import { useDatabase } from '$app/components/database';
|
||||||
|
|
||||||
@ -10,7 +10,7 @@ interface FieldListProps {
|
|||||||
onItemClick?: (event: React.MouseEvent<HTMLLIElement>, field: FieldType) => void;
|
onItemClick?: (event: React.MouseEvent<HTMLLIElement>, field: FieldType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) {
|
function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) {
|
||||||
const { fields } = useDatabase();
|
const { fields } = useDatabase();
|
||||||
const [fieldsResult, setFieldsResult] = useState<FieldType[]>(fields as FieldType[]);
|
const [fieldsResult, setFieldsResult] = useState<FieldType[]>(fields as FieldType[]);
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ function FieldList({ showSearch, onItemClick, searchPlaceholder }: FieldListProp
|
|||||||
onItemClick?.(event, field);
|
onItemClick?.(event, field);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field field={field} />
|
<Property field={field} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</MenuList>
|
</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 { FC, useCallback } from 'react';
|
||||||
import { Field as FieldType } from '../../application';
|
import { Field as FieldType } from '../../application';
|
||||||
import { useDatabase } from '../../Database.hooks';
|
import { useDatabase } from '../../Database.hooks';
|
||||||
import { Field } from './Field';
|
import { Property } from './Property';
|
||||||
|
|
||||||
export interface FieldSelectProps extends Omit<SelectProps, 'onChange'> {
|
export interface FieldSelectProps extends Omit<SelectProps, 'onChange'> {
|
||||||
onChange?: (field: FieldType | undefined) => void;
|
onChange?: (field: FieldType | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FieldSelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
|
export const PropertySelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
|
||||||
const { fields } = useDatabase();
|
const { fields } = useDatabase();
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
@ -36,7 +36,7 @@ export const FieldSelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
|
|||||||
>
|
>
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<MenuItem className={'overflow-hidden text-ellipsis px-1.5'} key={field.id} value={field.id}>
|
<MenuItem className={'overflow-hidden text-ellipsis px-1.5'} key={field.id} value={field.id}>
|
||||||
<Field field={field} />
|
<Property field={field} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</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 { Divider, Menu, MenuItem, MenuProps } from '@mui/material';
|
||||||
import { FC, useMemo } from 'react';
|
import { FC, useMemo } from 'react';
|
||||||
import { FieldType } from '@/services/backend';
|
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 { Field } from '$app/components/database/application';
|
||||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
|
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 & {
|
MenuProps & {
|
||||||
field: Field;
|
field: Field;
|
||||||
onClickItem?: (type: FieldType) => void;
|
onClickItem?: (type: FieldType) => void;
|
||||||
@ -47,9 +47,9 @@ export const FieldTypeMenu: FC<
|
|||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
group.types.map((type) => (
|
group.types.map((type) => (
|
||||||
<MenuItem onClick={() => onClickItem?.(type)} key={type} dense className={'flex justify-between'}>
|
<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'>
|
<span className='flex-1 font-medium'>
|
||||||
<FieldTypeText type={type} />
|
<PropertyTypeText type={type} />
|
||||||
</span>
|
</span>
|
||||||
{type === field.type && <SelectCheckSvg />}
|
{type === field.type && <SelectCheckSvg />}
|
||||||
</MenuItem>
|
</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 NumberFieldActions from '$app/components/database/components/field_types/number/NumberFieldActions';
|
||||||
import DateTimeFieldActions from '$app/components/database/components/field_types/date/DateTimeFieldActions';
|
import DateTimeFieldActions from '$app/components/database/components/field_types/date/DateTimeFieldActions';
|
||||||
|
|
||||||
function FieldTypeMenuExtension({ field }: { field: Field }) {
|
function PropertyTypeMenuExtension({ field }: { field: Field }) {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case FieldType.SingleSelect:
|
case FieldType.SingleSelect:
|
||||||
@ -23,4 +23,4 @@ function FieldTypeMenuExtension({ field }: { field: Field }) {
|
|||||||
}, [field]);
|
}, [field]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FieldTypeMenuExtension;
|
export default PropertyTypeMenuExtension;
|
@ -1,17 +1,17 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
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 { MenuItem } from '@mui/material';
|
||||||
import { Field } from '$app/components/database/application';
|
import { Field } from '$app/components/database/application';
|
||||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
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 { 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 {
|
interface Props {
|
||||||
field: Field;
|
field: Field;
|
||||||
onUpdateFieldType: (type: FieldType) => void;
|
onUpdateFieldType: (type: FieldType) => void;
|
||||||
}
|
}
|
||||||
function FieldTypeSelect({ field, onUpdateFieldType }: Props) {
|
function PropertyTypeSelect({ field, onUpdateFieldType }: Props) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const ref = useRef<HTMLLIElement>(null);
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
|
|
||||||
@ -24,14 +24,14 @@ function FieldTypeSelect({ field, onUpdateFieldType }: Props) {
|
|||||||
}}
|
}}
|
||||||
className={'px-23 mx-0'}
|
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'>
|
<span className='flex-1 text-xs font-medium'>
|
||||||
<FieldTypeText type={field.type} />
|
<PropertyTypeText type={field.type} />
|
||||||
</span>
|
</span>
|
||||||
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
|
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<FieldTypeMenu
|
<PropertyTypeMenu
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
field={field}
|
field={field}
|
||||||
onClickItem={onUpdateFieldType}
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
export const FieldTypeText = ({ type }: { type: FieldType }) => {
|
export const PropertyTypeText = ({ type }: { type: FieldType }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const text = useMemo(() => {
|
const text = useMemo(() => {
|
@ -23,7 +23,7 @@ export const FieldTypeSvgMap: Record<FieldType, FC<React.SVGProps<SVGSVGElement>
|
|||||||
[FieldType.CreatedTime]: LastEditedTimeSvg,
|
[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];
|
const Svg = FieldTypeSvgMap[type];
|
||||||
|
|
||||||
return <Svg {...props} />;
|
return <Svg {...props} />;
|
@ -1,6 +1,6 @@
|
|||||||
import React, { FC, MouseEvent, useCallback } from 'react';
|
import React, { FC, MouseEvent, useCallback } from 'react';
|
||||||
import { MenuProps } from '@mui/material';
|
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 { Field, sortService } from '$app/components/database/application';
|
||||||
import { SortConditionPB } from '@/services/backend';
|
import { SortConditionPB } from '@/services/backend';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -29,7 +29,7 @@ const SortFieldsMenu: FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover keepMounted={false} {...props}>
|
<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>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,7 @@ import { IconButton, SelectChangeEvent, Stack } from '@mui/material';
|
|||||||
import { FC, useCallback } from 'react';
|
import { FC, useCallback } from 'react';
|
||||||
import { ReactComponent as CloseSvg } from '$app/assets/close.svg';
|
import { ReactComponent as CloseSvg } from '$app/assets/close.svg';
|
||||||
import { Field, Sort, sortService } from '../../application';
|
import { Field, Sort, sortService } from '../../application';
|
||||||
import { FieldSelect } from '../field';
|
import { PropertySelect } from '../property';
|
||||||
import { SortConditionSelect } from './SortConditionSelect';
|
import { SortConditionSelect } from './SortConditionSelect';
|
||||||
import { useViewId } from '@/appflowy_app/hooks';
|
import { useViewId } from '@/appflowy_app/hooks';
|
||||||
import { SortConditionPB } from '@/services/backend';
|
import { SortConditionPB } from '@/services/backend';
|
||||||
@ -44,7 +44,7 @@ export const SortItem: FC<SortItemProps> = ({ className, sort }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className={className} direction='row' spacing={1}>
|
<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
|
<SortConditionSelect
|
||||||
className={'w-[150px]'}
|
className={'w-[150px]'}
|
||||||
size='small'
|
size='small'
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { styled, Tab, TabProps, Tabs } from '@mui/material';
|
import { styled, Tab, TabProps, Tabs } from '@mui/material';
|
||||||
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
export const ViewTabs = styled(Tabs)({
|
export const ViewTabs = styled(Tabs)({
|
||||||
minHeight: '28px',
|
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;
|
children?: React.ReactNode;
|
||||||
index: number;
|
index: number;
|
||||||
value: number;
|
value: number;
|
||||||
|
@ -1,11 +1,6 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { GridTable } from '../GridTable';
|
import { GridTable, GridTableProps } from '../GridTable';
|
||||||
import GridUIProvider from '$app/components/database/proxy/grid/ui_state/Provider';
|
|
||||||
|
|
||||||
export const Grid: FC<{ isActivated: boolean; tableHeight: number }> = ({ isActivated, tableHeight }) => {
|
export const Grid: FC<GridTableProps> = (props) => {
|
||||||
return (
|
return <GridTable {...props} />;
|
||||||
<GridUIProvider isActivated={isActivated}>
|
|
||||||
<GridTable tableHeight={tableHeight} />
|
|
||||||
</GridUIProvider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
@ -1,23 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDatabaseVisibilityRows } from '$app/components/database';
|
import { useDatabaseVisibilityRows } from '$app/components/database';
|
||||||
import { Field } from '$app/components/database/application';
|
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 {
|
interface Props {
|
||||||
field: Field;
|
field: Field;
|
||||||
index: number;
|
index: number;
|
||||||
|
getContainerRef?: () => React.RefObject<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GridCalculate({ field, index }: Props) {
|
function GridCalculate({ field, index }: Props) {
|
||||||
const rowMetas = useDatabaseVisibilityRows();
|
const rowMetas = useDatabaseVisibilityRows();
|
||||||
const count = rowMetas.length;
|
const count = rowMetas.length;
|
||||||
const width = field.width ?? DEFAULT_FIELD_WIDTH;
|
const width = index === 0 ? GRID_ACTIONS_WIDTH : field.width ?? DEFAULT_FIELD_WIDTH;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width,
|
width,
|
||||||
visibility: index === 0 ? 'visible' : 'hidden',
|
visibility: index === 1 ? 'visible' : 'hidden',
|
||||||
}}
|
}}
|
||||||
className={'flex justify-end py-2'}
|
className={'flex justify-end py-2'}
|
||||||
>
|
>
|
||||||
|
@ -1,6 +1,75 @@
|
|||||||
import { FC } from 'react';
|
import React, { CSSProperties, memo } from 'react';
|
||||||
import { Cell, CellProps } from '../../components';
|
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) => {
|
const getRenderRowKey = (row: RenderRow) => {
|
||||||
return <Cell {...props} />;
|
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 { 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 { throttle } from '$app/utils/tool';
|
||||||
import { useViewId } from '$app/hooks';
|
import { useViewId } from '$app/hooks';
|
||||||
import { DragItem, DropPosition, DragType, useDraggable, useDroppable, ScrollDirection } from '../../_shared';
|
import { DragItem, DropPosition, DragType, useDraggable, useDroppable, ScrollDirection } from '../../_shared';
|
||||||
import { fieldService, Field } from '../../application';
|
import { fieldService, Field } from '../../application';
|
||||||
import { useDatabase } from '../../Database.hooks';
|
import { useDatabase } from '../../Database.hooks';
|
||||||
import { FieldTypeSvg } from '$app/components/database/components/field';
|
import { Property } from '$app/components/database/components/property';
|
||||||
import { FieldMenu } from '../../components/field/FieldMenu';
|
|
||||||
import GridResizer from '$app/components/database/grid/GridField/GridResizer';
|
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;
|
field: Field;
|
||||||
|
onOpenMenu?: (id: string) => void;
|
||||||
|
onCloseMenu?: (id: string) => void;
|
||||||
|
resizeColumnWidth?: (width: number) => void;
|
||||||
|
getScrollElement?: () => HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GridField: FC<GridFieldProps> = ({ field }) => {
|
export const GridField: FC<GridFieldProps> = memo(
|
||||||
const viewId = useViewId();
|
({ getScrollElement, resizeColumnWidth, onOpenMenu, onCloseMenu, field, ...props }) => {
|
||||||
const { fields } = useDatabase();
|
const menuOpened = useOpenMenu(field.id);
|
||||||
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null);
|
const viewId = useViewId();
|
||||||
const [openTooltip, setOpenTooltip] = useState(false);
|
const { fields } = useDatabase();
|
||||||
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
|
const [openTooltip, setOpenTooltip] = useState(false);
|
||||||
const [fieldWidth, setFieldWidth] = useState(field.width || DEFAULT_FIELD_WIDTH);
|
const [propertyMenuOpened, setPropertyMenuOpened] = useState(false);
|
||||||
const openMenu = Boolean(menuAnchorEl);
|
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
|
||||||
const handleClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
setMenuAnchorEl(e.currentTarget);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMenuClose = useCallback(() => {
|
const handleTooltipOpen = useCallback(() => {
|
||||||
setMenuAnchorEl(null);
|
setOpenTooltip(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTooltipOpen = useCallback(() => {
|
const handleTooltipClose = useCallback(() => {
|
||||||
setOpenTooltip(true);
|
setOpenTooltip(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTooltipClose = useCallback(() => {
|
const draggingData = useMemo(
|
||||||
setOpenTooltip(false);
|
() => ({
|
||||||
}, []);
|
field,
|
||||||
|
}),
|
||||||
|
[field]
|
||||||
|
);
|
||||||
|
|
||||||
const draggingData = useMemo(
|
const { isDragging, attributes, listeners, setPreviewRef, previewRef } = useDraggable({
|
||||||
() => ({
|
type: DragType.Field,
|
||||||
field,
|
data: draggingData,
|
||||||
}),
|
scrollOnEdge: {
|
||||||
[field]
|
direction: ScrollDirection.Horizontal,
|
||||||
);
|
getScrollElement,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { isDragging, attributes, listeners, setPreviewRef, previewRef } = useDraggable({
|
const onDragOver = useMemo<DragEventHandler>(() => {
|
||||||
type: DragType.Field,
|
return throttle((event) => {
|
||||||
data: draggingData,
|
const element = previewRef.current;
|
||||||
scrollOnEdge: {
|
|
||||||
direction: ScrollDirection.Horizontal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDragOver = useMemo<DragEventHandler>(() => {
|
if (!element) {
|
||||||
return throttle((event) => {
|
return;
|
||||||
const element = previewRef.current;
|
}
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { left, right } = element.getBoundingClientRect();
|
const rect = previewRef.current?.getBoundingClientRect();
|
||||||
const middle = (left + right) / 2;
|
|
||||||
|
|
||||||
setDropPosition(event.clientX < middle ? DropPosition.Before : DropPosition.After);
|
if (rect) {
|
||||||
}, 20);
|
setMenuAnchorPosition({
|
||||||
}, [previewRef]);
|
top: rect.top + rect.height,
|
||||||
|
left: rect.left,
|
||||||
const onDrop = useCallback(
|
});
|
||||||
({ data }: DragItem) => {
|
} else {
|
||||||
const dragField = data.field as Field;
|
setMenuAnchorPosition(undefined);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}, [menuOpened, previewRef]);
|
||||||
|
|
||||||
void fieldService.moveField(viewId, dragField.id, fromIndex, toIndex);
|
const handlePropertyMenuOpen = useCallback(() => {
|
||||||
},
|
setPropertyMenuOpened(true);
|
||||||
[viewId, field, fields, dropPosition]
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
const { isOver, listeners: dropListeners } = useDroppable({
|
const handlePropertyMenuClose = useCallback(() => {
|
||||||
accept: DragType.Field,
|
setPropertyMenuOpened(false);
|
||||||
disabled: isDragging,
|
}, []);
|
||||||
onDragOver,
|
|
||||||
onDrop,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={'flex w-full border-r border-line-divider bg-bg-body'} {...props}>
|
||||||
className={'flex border-r border-line-divider'}
|
<Tooltip
|
||||||
style={{
|
open={openTooltip && !isDragging}
|
||||||
width: fieldWidth,
|
title={field.name}
|
||||||
}}
|
placement='right'
|
||||||
>
|
enterDelay={1000}
|
||||||
<Tooltip
|
enterNextDelay={1000}
|
||||||
open={openTooltip && !isDragging}
|
onOpen={handleTooltipOpen}
|
||||||
title={field.name}
|
onClose={handleTooltipClose}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
<FieldTypeSvg className='mr-1 text-base' type={field.type} />
|
<Button
|
||||||
<span className='flex-1 truncate text-left text-xs'>{field.name}</span>
|
color={'inherit'}
|
||||||
{isOver && (
|
ref={setPreviewRef}
|
||||||
<div
|
className='relative flex h-full w-full items-center px-0'
|
||||||
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${
|
disableRipple
|
||||||
dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
|
onContextMenu={(event) => {
|
||||||
}`}
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
handleClick();
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
{...dropListeners}
|
||||||
|
>
|
||||||
|
<Property
|
||||||
|
menuOpened={propertyMenuOpened}
|
||||||
|
onCloseMenu={handlePropertyMenuClose}
|
||||||
|
onOpenMenu={handlePropertyMenuOpen}
|
||||||
|
field={field}
|
||||||
/>
|
/>
|
||||||
)}
|
{isOver && (
|
||||||
<GridResizer field={field} onWidthChange={(width) => setFieldWidth(width)} />
|
<div
|
||||||
</Button>
|
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${
|
||||||
</Tooltip>
|
dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
|
||||||
{openMenu && <FieldMenu field={field} open={openMenu} anchorEl={menuAnchorEl} onClose={handleMenuClose} />}
|
}`}
|
||||||
</div>
|
/>
|
||||||
);
|
)}
|
||||||
};
|
<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);
|
setNewWidth(newWidth);
|
||||||
|
onWidthChange?.(newWidth);
|
||||||
},
|
},
|
||||||
[width]
|
[width, onWidthChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onWidthChange?.(newWidth);
|
|
||||||
}, [newWidth, onWidthChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isResizing && width !== newWidth) {
|
if (!isResizing && width !== newWidth) {
|
||||||
void fieldService.updateFieldSetting(viewId, fieldId, {
|
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 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 UpSvg } from '$app/assets/up.svg';
|
||||||
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
||||||
import { ReactComponent as DelSvg } from '$app/assets/delete.svg';
|
import { ReactComponent as DelSvg } from '$app/assets/delete.svg';
|
||||||
import { ReactComponent as CopySvg } from '$app/assets/copy.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 { rowService } from '$app/components/database/application';
|
||||||
import { useViewId } from '@/appflowy_app/hooks/ViewId.hooks';
|
import { Icon, MenuItem, MenuList } from '@mui/material';
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rowId: string;
|
|
||||||
getPrevRowId: (id: string) => string | null;
|
|
||||||
onClickItem: (label: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
label: string;
|
label: string;
|
||||||
@ -21,7 +17,13 @@ interface Option {
|
|||||||
divider?: boolean;
|
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 viewId = useViewId();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -74,23 +76,30 @@ function GridCellRowMenu({ rowId, getPrevRowId, onClickItem }: Props) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuList>
|
<Popover
|
||||||
{options.map((option) => (
|
keepMounted={false}
|
||||||
<div className={'w-full'} key={option.label}>
|
anchorReference={'anchorPosition'}
|
||||||
{option.divider && <div className='mx-2 my-1.5 h-[1px] bg-line-divider' />}
|
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||||
<MenuItem
|
{...props}
|
||||||
onClick={() => {
|
>
|
||||||
option.onClick();
|
<MenuList>
|
||||||
onClickItem(option.label);
|
{options.map((option) => (
|
||||||
}}
|
<div className={'w-full'} key={option.label}>
|
||||||
>
|
{option.divider && <div className='mx-2 my-1.5 h-[1px] bg-line-divider' />}
|
||||||
<Icon className='mr-2'>{option.icon}</Icon>
|
<MenuItem
|
||||||
{option.label}
|
onClick={() => {
|
||||||
</MenuItem>
|
option.onClick();
|
||||||
</div>
|
props.onClose?.({}, 'backdropClick');
|
||||||
))}
|
}}
|
||||||
</MenuList>
|
>
|
||||||
|
<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 React, { FC, useCallback, useMemo, useRef } from 'react';
|
||||||
import { RowMeta } from '../../application';
|
import { RowMeta } from '../../application';
|
||||||
import { useDatabaseVisibilityFields, useDatabaseVisibilityRows } from '../../Database.hooks';
|
import { useDatabaseVisibilityFields, useDatabaseVisibilityRows } from '../../Database.hooks';
|
||||||
import { VirtualizedList } from '../../_shared';
|
import { fieldsToColumns, GridColumn, RenderRow, RenderRowType, rowMetasToRenderRow } from '../constants';
|
||||||
import { DEFAULT_FIELD_WIDTH, GridRow, RenderRow, RenderRowType, rowMetasToRenderRow } from '../GridRow';
|
|
||||||
import { CircularProgress } from '@mui/material';
|
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) => {
|
export interface GridTableProps {
|
||||||
if (row.type === RenderRowType.Row) {
|
onEditRecord: (rowId: string) => void;
|
||||||
return `row:${row.data.meta.id}`;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return row.type;
|
export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
||||||
};
|
|
||||||
|
|
||||||
export const GridTable: FC<{ tableHeight: number }> = React.memo(({ tableHeight }) => {
|
|
||||||
const verticalScrollElementRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const horizontalScrollElementRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const rowMetas = useDatabaseVisibilityRows();
|
const rowMetas = useDatabaseVisibilityRows();
|
||||||
const fields = useDatabaseVisibilityFields();
|
const fields = useDatabaseVisibilityFields();
|
||||||
const renderRows = useMemo<RenderRow[]>(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]);
|
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>({
|
const getItemKey = useCallback(
|
||||||
count: renderRows.length,
|
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
|
||||||
overscan: 10,
|
const row = renderRows[rowIndex];
|
||||||
getItemKey: (i) => getRenderRowKey(renderRows[i]),
|
const column = columns[columnIndex];
|
||||||
getScrollElement: () => verticalScrollElementRef.current,
|
|
||||||
estimateSize: () => 37,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columnVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
const field = column.field;
|
||||||
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 getPrevRowId = useCallback(
|
if (row.type === RenderRowType.Row) {
|
||||||
(id: string) => {
|
if (field) {
|
||||||
const index = rowMetas.findIndex((rowMeta) => rowMeta.id === id);
|
return `${row.data.meta.id}:${field.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (index === 0) {
|
return `${row.data.meta.id}:${column.type}`;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div
|
<div className={'flex w-full flex-1 flex-col '}>
|
||||||
style={{
|
|
||||||
height: tableHeight,
|
|
||||||
}}
|
|
||||||
className={'flex h-full w-full flex-col'}
|
|
||||||
>
|
|
||||||
{fields.length === 0 && (
|
{fields.length === 0 && (
|
||||||
<div className={'absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center bg-bg-body'}>
|
<div className={'absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center bg-bg-body'}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className={'h-[36px]'}>
|
||||||
className={'w-full flex-1 overflow-auto scroll-smooth'}
|
<GridStickyHeader ref={staticGrid} getScrollElement={getScrollElement} columns={columns} />
|
||||||
ref={(e) => {
|
</div>
|
||||||
verticalScrollElementRef.current = e;
|
|
||||||
horizontalScrollElementRef.current = e;
|
<div className={'flex-1'}>
|
||||||
}}
|
<AutoSizer>
|
||||||
>
|
{({ height, width }: { height: number; width: number }) => (
|
||||||
<VirtualizedList
|
<Grid
|
||||||
className='flex w-fit basis-full flex-col px-16'
|
ref={ref}
|
||||||
virtualizer={rowVirtualizer}
|
onScroll={onScroll}
|
||||||
itemClassName='flex'
|
columnCount={columns.length}
|
||||||
renderItem={(index) => (
|
columnWidth={columnWidth}
|
||||||
<GridRow getPrevRowId={getPrevRowId} row={renderRows[index]} virtualizer={columnVirtualizer} />
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { RowMeta } from '../../application';
|
import { Field, RowMeta } from '../application';
|
||||||
|
|
||||||
export const GridCalculateCountHeight = 40;
|
export const GridCalculateCountHeight = 40;
|
||||||
|
|
||||||
|
export const GRID_ACTIONS_WIDTH = 64;
|
||||||
|
|
||||||
export const DEFAULT_FIELD_WIDTH = 150;
|
export const DEFAULT_FIELD_WIDTH = 150;
|
||||||
|
|
||||||
export enum RenderRowType {
|
export enum RenderRowType {
|
||||||
@ -36,11 +38,26 @@ export interface NewRenderRow {
|
|||||||
|
|
||||||
export type RenderRow = FieldRenderRow | CellRenderRow | NewRenderRow | CalculateRenderRow;
|
export type RenderRow = FieldRenderRow | CellRenderRow | NewRenderRow | CalculateRenderRow;
|
||||||
|
|
||||||
export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => {
|
export const fieldsToColumns = (fields: Field[]): GridColumn[] => {
|
||||||
return [
|
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) => ({
|
...rowMetas.map<RenderRow>((rowMeta) => ({
|
||||||
type: RenderRowType.Row,
|
type: RenderRowType.Row,
|
||||||
data: {
|
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);
|
background: var(--fill-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-swipeable-view-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user