feat: tauri grid changes and fixes (#2995)

* (feat) implement row drag and drop functionality

* (feat) implement grid row actions options ui

* (feat) add delete row function

* (feat) implemet grid filter and sort popup layout

* chore: move row methods to database controller

* (feat) integrate delete and duplicate row functions

* chore: add option on field popup

* chore: padding on edit row

* fix: change option color

* chore: stick to corner on resize

* fix: stick to corner

* chore: grid row popup changes

* chore: grid title

* chore: add field width

* chore: replace table layout with fixed column size

* chore: resize column WIP

* chore: save column width and draggable WIP

* chore: nav panel resize fix

* chore: database filter store

* chore: filter popups

* chore: filter value options

* chore: remove console

* chore: database filter refactor

* chore: prevent jitter and dont include used fields

* chore: checked field type

* chore: reset operator

* chore: filter icon

* chore: database sort popup

* chore: add icons into ref page

* chore: sort icon in column header

* chore: grid title fix

* chore: change text and border colors

* chore: grid rows dnd and optimise components

* chore: select option color change fix

* chore: filter service and controller

* chore: wire filter UI to service WIP

* chore: show only fields/cells with visiblity set to true

* fix: grid visible column exception

* chore: add update text filter backend

* chore: select option filter save modify get

* fix: filter reload and new filter

* fix: new filter order

* chore: sort backend service

* chore: database sort UI

* chore: field select popover component

* chore: operator select popover

* chore: select options popover

* chore: change text color

* chore: post merge

* chore: sort popover

* chore: bg body

* chore: grid row actions popover

* chore: dragging row change

* chore: new field column fix

* chore: field actions popover and field type popover

* chore: hide and delete field actions

* chore: duplicate field

* fix: pnpm lock file has error and button color update

* fix: tsc error

* chore: add field to left and right

---------

Co-authored-by: Mikias Tilahun Abebe <mikiastilahun@gmail.com>
Co-authored-by: qinluhe <qinluhe.twodog@gmail.com>
This commit is contained in:
Askarbek Zadauly 2023-08-14 15:06:27 +06:00 committed by GitHub
parent 27b1f00e17
commit 6fc8072459
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 2793 additions and 721 deletions

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + TS</title>
<title>AppFlowy: The Open Source Alternative To Notion</title>
</head>
<body>

View File

@ -23,6 +23,7 @@
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.12",
"@mui/system": "^5.14.4",
"@reduxjs/toolkit": "^1.9.2",
"@slate-yjs/core": "^1.0.0",
"@tanstack/react-virtual": "3.0.0-beta.54",

View File

@ -19,6 +19,9 @@ dependencies:
'@mui/material':
specifier: ^5.11.12
version: 5.13.0(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@mui/system':
specifier: ^5.14.4
version: 5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0)
'@reduxjs/toolkit':
specifier: ^1.9.2
version: 1.9.5(react-redux@8.0.5)(react@18.2.0)
@ -544,6 +547,13 @@ packages:
regenerator-runtime: 0.13.11
dev: false
/@babel/runtime@7.22.10:
resolution: {integrity: sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==}
engines: {node: '>=6.9.0'}
dependencies:
regenerator-runtime: 0.14.0
dev: false
/@babel/template@7.20.7:
resolution: {integrity: sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==}
engines: {node: '>=6.9.0'}
@ -1302,7 +1312,7 @@ packages:
'@emotion/styled': 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0)
'@mui/base': 5.0.0-beta.0(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@mui/core-downloads-tracker': 5.13.0
'@mui/system': 5.12.3(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0)
'@mui/system': 5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0)
'@mui/types': 7.2.4(@types/react@18.2.6)
'@mui/utils': 5.12.3(react@18.2.0)
'@types/react': 18.2.6
@ -1316,8 +1326,8 @@ packages:
react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0)
dev: false
/@mui/private-theming@5.12.3(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-o1e7Z1Bp27n4x2iUHhegV4/Jp6H3T6iBKHJdLivS5GbwsuAE/5l4SnZ+7+K+e5u9TuhwcAKZLkjvqzkDe8zqfA==}
/@mui/private-theming@5.14.4(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-ISXsHDiQ3z1XA4IuKn+iXDWvDjcz/UcQBiFZqtdoIsEBt8CB7wgdQf3LwcwqO81dl5ofg/vNQBEnXuKfZHrnYA==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0
@ -1326,15 +1336,15 @@ packages:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.21.5
'@mui/utils': 5.12.3(react@18.2.0)
'@babel/runtime': 7.22.10
'@mui/utils': 5.14.4(react@18.2.0)
'@types/react': 18.2.6
prop-types: 15.8.1
react: 18.2.0
dev: false
/@mui/styled-engine@5.12.3(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(react@18.2.0):
resolution: {integrity: sha512-AhZtiRyT8Bjr7fufxE/mLS+QJ3LxwX1kghIcM2B2dvJzSSg9rnIuXDXM959QfUVIM3C8U4x3mgVoPFMQJvc4/g==}
/@mui/styled-engine@5.13.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(react@18.2.0):
resolution: {integrity: sha512-VCYCU6xVtXOrIN8lcbuPmoG+u7FYuOERG++fpY74hPpEWkyFQG97F+/XfTQVYzlR2m7nPjnwVUgATcTCMEaMvw==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@emotion/react': ^11.4.1
@ -1346,7 +1356,7 @@ packages:
'@emotion/styled':
optional: true
dependencies:
'@babel/runtime': 7.21.5
'@babel/runtime': 7.22.10
'@emotion/cache': 11.11.0
'@emotion/react': 11.11.0(@types/react@18.2.6)(react@18.2.0)
'@emotion/styled': 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0)
@ -1355,8 +1365,8 @@ packages:
react: 18.2.0
dev: false
/@mui/system@5.12.3(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-JB/6sypHqeJCqwldWeQ1MKkijH829EcZAKKizxbU2MJdxGG5KSwZvTBa5D9qiJUA1hJFYYupjiuy9ZdJt6rV6w==}
/@mui/system@5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-oPgfWS97QNfHcDBapdkZIs4G5i85BJt69Hp6wbXF6s7vi3Evcmhdk8AbCRW6n0sX4vTj8oe0mh0RIm1G2A1KDA==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@emotion/react': ^11.5.0
@ -1371,15 +1381,15 @@ packages:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.21.5
'@babel/runtime': 7.22.10
'@emotion/react': 11.11.0(@types/react@18.2.6)(react@18.2.0)
'@emotion/styled': 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0)
'@mui/private-theming': 5.12.3(@types/react@18.2.6)(react@18.2.0)
'@mui/styled-engine': 5.12.3(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(react@18.2.0)
'@mui/private-theming': 5.14.4(@types/react@18.2.6)(react@18.2.0)
'@mui/styled-engine': 5.13.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(react@18.2.0)
'@mui/types': 7.2.4(@types/react@18.2.6)
'@mui/utils': 5.12.3(react@18.2.0)
'@mui/utils': 5.14.4(react@18.2.0)
'@types/react': 18.2.6
clsx: 1.2.1
clsx: 2.0.0
csstype: 3.1.2
prop-types: 15.8.1
react: 18.2.0
@ -1410,6 +1420,20 @@ packages:
react-is: 18.2.0
dev: false
/@mui/utils@5.14.4(react@18.2.0):
resolution: {integrity: sha512-4ANV0txPD3x0IcTCSEHKDWnsutg1K3m6Vz5IckkbLXVYu17oOZCVUdOKsb/txUmaCd0v0PmSRe5PW+Mlvns5dQ==}
engines: {node: '>=12.0.0'}
peerDependencies:
react: ^17.0.0 || ^18.0.0
dependencies:
'@babel/runtime': 7.22.10
'@types/prop-types': 15.7.5
'@types/react-is': 18.2.1
prop-types: 15.8.1
react: 18.2.0
react-is: 18.2.0
dev: false
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -1733,6 +1757,12 @@ packages:
'@types/react': 17.0.59
dev: false
/@types/react-is@18.2.1:
resolution: {integrity: sha512-wyUkmaaSZEzFZivD8F2ftSyAfk6L+DfFliVj/mYdOXbVjRcS87fQJLTnhk6dRZPuJjI+9g6RZJO4PNCngUrmyw==}
dependencies:
'@types/react': 18.2.6
dev: false
/@types/react-katex@3.0.0:
resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==}
dependencies:
@ -2341,6 +2371,11 @@ packages:
engines: {node: '>=6'}
dev: false
/clsx@2.0.0:
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
engines: {node: '>=6'}
dev: false
/co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@ -4860,6 +4895,10 @@ packages:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
dev: false
/regenerator-runtime@0.14.0:
resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==}
dev: false
/regexp.prototype.flags@1.5.0:
resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==}
engines: {node: '>= 0.4'}

View File

@ -105,7 +105,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "appflowy-integrate"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"anyhow",
"collab",
@ -1021,7 +1021,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"anyhow",
"bytes",
@ -1039,7 +1039,7 @@ dependencies = [
[[package]]
name = "collab-client-ws"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"bytes",
"collab-sync",
@ -1057,7 +1057,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"anyhow",
"async-trait",
@ -1084,7 +1084,7 @@ dependencies = [
[[package]]
name = "collab-derive"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"proc-macro2",
"quote",
@ -1096,7 +1096,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"anyhow",
"collab",
@ -1115,7 +1115,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"anyhow",
"chrono",
@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "collab-persistence"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"bincode",
"chrono",
@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"anyhow",
"async-trait",
@ -1185,7 +1185,7 @@ dependencies = [
[[package]]
name = "collab-sync"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=ba963f#ba963fa299d294e5b2cafd940b9eaa8520280b7b"
dependencies = [
"bytes",
"collab",

View File

@ -14,19 +14,19 @@ export const Button = ({
useEffect(() => {
switch (size) {
case 'primary':
setCls('w-[340px] h-[48px] flex items-center justify-center rounded-lg bg-content-default text-content-on-fill');
setCls('w-[340px] h-[48px] flex items-center justify-center rounded-lg bg-fill-default text-content-on-fill');
break;
case 'medium':
setCls('w-[170px] h-[48px] flex items-center justify-center rounded-lg bg-content-default text-content-on-fill');
setCls('w-[170px] h-[48px] flex items-center justify-center rounded-lg bg-fill-default text-content-on-fill');
break;
case 'small':
setCls(
'w-[68px] h-[32px] flex items-center justify-center rounded-lg bg-content-default text-content-on-fill text-xs hover:bg-content-hover'
'w-[68px] h-[32px] flex items-center justify-center rounded-lg bg-fill-default text-content-on-fill text-xs hover:bg-fill-list-hover'
);
break;
case 'medium-transparent':
setCls(
'w-[170px] h-[48px] flex items-center justify-center rounded-lg border border-content-default text-content-default transition-colors duration-300 hover:bg-content-blue-50 hover:text-content-on-fill'
'w-[170px] h-[48px] flex items-center justify-center rounded-lg border border-fill-default text-fill-default transition-colors duration-300 hover:bg-content-blue-50 '
);
break;
case 'box-small-transparent':

View File

@ -1,12 +1,13 @@
import React, { useCallback, useState } from 'react';
import { List, MenuItem, Popover, Portal } from '@mui/material';
import { List, MenuItem, Popover, Portal, Theme } from '@mui/material';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { SxProps } from '@mui/system';
interface ButtonPopoverListProps {
isVisible: boolean;
children: React.ReactNode;
popoverOptions: {
key: string;
key: React.Key;
icon: React.ReactNode;
label: React.ReactNode | string;
onClick: () => void;
@ -15,8 +16,11 @@ interface ButtonPopoverListProps {
anchorOrigin: PopoverOrigin;
transformOrigin: PopoverOrigin;
};
onClose?: () => void;
sx?: SxProps<Theme>;
}
function ButtonPopoverList({ popoverOrigin, isVisible, children, popoverOptions }: ButtonPopoverListProps) {
function ButtonPopoverList({ popoverOrigin, isVisible, children, popoverOptions, onClose, sx }: ButtonPopoverListProps) {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement>();
const open = Boolean(anchorEl);
const visible = isVisible || open;
@ -32,8 +36,16 @@ function ButtonPopoverList({ popoverOrigin, isVisible, children, popoverOptions
<>
{visible && <div onClick={handleClick}>{children}</div>}
<Portal>
<Popover open={open} {...popoverOrigin} anchorEl={anchorEl} onClose={handleClose}>
<List>
<Popover
open={open}
{...popoverOrigin}
anchorEl={anchorEl}
onClose={() => {
handleClose();
onClose?.();
}}
>
<List sx={{ ...sx }}>
{popoverOptions.map((option) => (
<MenuItem
key={option.key}

View File

@ -7,7 +7,7 @@ export const CheckListProgress = ({ completed, max }: { completed: number; max:
{completed > 0 && filledCheckListBars({ amount: completed })}
{max - completed > 0 && emptyCheckListBars({ amount: max - completed })}
</div>
<div className={'text-xs text-shade-4'}>{((100 * completed) / max).toFixed(0)}%</div>
<div className={'text-xs text-text-caption'}>{((100 * completed) / max).toFixed(0)}%</div>
</>
)}
</div>
@ -17,11 +17,11 @@ export const CheckListProgress = ({ completed, max }: { completed: number; max:
const filledCheckListBars = ({ amount }: { amount: number }) => {
return Array(amount)
.fill(0)
.map((item, index) => <div key={index} className={'h-[4px] flex-1 flex-shrink-0 rounded bg-main-accent'}></div>);
.map((item, index) => <div key={index} className={'h-[4px] flex-1 flex-shrink-0 rounded bg-fill-hover'}></div>);
};
const emptyCheckListBars = ({ amount }: { amount: number }) => {
return Array(amount)
.fill(0)
.map((item, index) => <div key={index} className={'h-[4px] flex-1 flex-shrink-0 rounded bg-tint-9'}></div>);
.map((item, index) => <div key={index} className={'bg-tint-9 h-[4px] flex-1 flex-shrink-0 rounded'}></div>);
};

View File

@ -0,0 +1,174 @@
import { FieldType } from '@/services/backend';
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import {
IDatabaseFilter,
ISelectOption,
SupportedOperatorsByType,
TDatabaseOperators,
} from '$app_reducers/database/slice';
import { useAppSelector } from '$app/stores/store';
import React, { useEffect, useMemo, useState } from 'react';
import { FieldSelect } from '$app/components/_shared/DatabaseFilter/FieldSelect';
import { LogicalOperatorSelect } from '$app/components/_shared/DatabaseFilter/LogicalOperatorSelect';
import { OperatorSelect } from '$app/components/_shared/DatabaseFilter/OperatorSelect';
import { FilterValue } from '$app/components/_shared/DatabaseFilter/FilterValue';
export const DatabaseFilterItem = ({
data,
onSave,
onDelete,
index,
}: {
data: IDatabaseFilter | null;
onSave: (filter: IDatabaseFilter) => void;
onDelete?: () => void;
index: number;
}) => {
// stores
const columns = useAppSelector((state) => state.database.columns);
const fields = useAppSelector((state) => state.database.fields);
const filtersStore = useAppSelector((state) => state.database.filters);
// values
const [currentLogicalOperator, setCurrentLogicalOperator] = useState<'and' | 'or'>('and');
const [currentFieldId, setCurrentFieldId] = useState<string | null>(data?.fieldId ?? null);
const [currentOperator, setCurrentOperator] = useState<TDatabaseOperators | null>(data?.operator ?? null);
const [currentValue, setCurrentValue] = useState<string[] | string | boolean | null>(data?.value ?? null);
useEffect(() => {
if (data) {
setCurrentLogicalOperator(data.logicalOperator);
setCurrentFieldId(data.fieldId);
setCurrentOperator(data.operator);
setCurrentValue(data.value);
} else {
setCurrentLogicalOperator('and');
setCurrentFieldId(null);
setCurrentOperator(null);
setCurrentValue(null);
}
}, [data]);
const [textInputActive, setTextInputActive] = useState(false);
// shortcut
const currentFieldType = useMemo(
() => (currentFieldId ? fields[currentFieldId].fieldType : undefined),
[currentFieldId, fields]
);
useEffect(() => {
// if the user is typing in a text input, don't update the filter
if (textInputActive) return;
if (currentFieldId && currentFieldType !== undefined && currentOperator && currentValue !== null) {
if (currentFieldType === FieldType.RichText && (currentValue as string).length === 0) {
return;
}
onSave({
id: data?.id,
logicalOperator: currentLogicalOperator,
fieldId: currentFieldId,
fieldType: currentFieldType,
operator: currentOperator,
value: currentValue,
});
}
}, [currentFieldId, currentFieldType, currentOperator, currentValue, textInputActive]);
// 1. not all field types support filtering
// 2. we don't want to show fields that are already in use
const supportedColumns = useMemo(
() =>
columns
.filter((column) => SupportedOperatorsByType[fields[column.fieldId].fieldType] !== undefined)
.filter((column) => filtersStore.findIndex((filter) => filter?.fieldId === column.fieldId) === -1),
[columns, fields, filtersStore]
);
const onSelectFieldClick = (id: string) => {
setCurrentFieldId(id);
switch (fields[id].fieldType) {
case FieldType.RichText:
setCurrentValue('');
setCurrentOperator(null);
break;
case FieldType.MultiSelect:
case FieldType.SingleSelect:
setCurrentValue([]);
setCurrentOperator(null);
break;
case FieldType.Checkbox:
setCurrentOperator('is');
setCurrentValue(false);
break;
default:
setCurrentOperator(null);
setCurrentValue(null);
}
};
const onSelectOperatorClick = (operator: TDatabaseOperators) => {
setCurrentOperator(operator);
};
const onValueOptionClick = (option: ISelectOption) => {
const value = currentValue as string[];
if (value.findIndex((v) => v === option.selectOptionId) === -1) {
setCurrentValue([...value, option.selectOptionId]);
} else {
setCurrentValue(value.filter((v) => v !== option.selectOptionId));
}
};
return (
<>
<div className='flex items-center gap-4'>
<div className={'w-[88px]'}>
{index === 0 ? (
<span className={'text-sm text-text-caption'}>Where</span>
) : (
<LogicalOperatorSelect></LogicalOperatorSelect>
)}
</div>
<FieldSelect
columns={supportedColumns}
fields={fields}
onSelectFieldClick={onSelectFieldClick}
currentFieldId={currentFieldId}
currentFieldType={currentFieldType}
></FieldSelect>
<OperatorSelect
currentOperator={currentOperator}
currentFieldType={currentFieldType}
onSelectOperatorClick={onSelectOperatorClick}
></OperatorSelect>
<FilterValue
currentFieldId={currentFieldId}
currentFieldType={currentFieldType}
currentValue={currentValue}
setCurrentValue={setCurrentValue}
fields={fields}
textInputActive={textInputActive}
setTextInputActive={setTextInputActive}
onValueOptionClick={onValueOptionClick}
></FilterValue>
<button
onClick={() => onDelete?.()}
className={`rounded p-1 hover:bg-fill-list-hover ${data ? 'opacity-100' : 'opacity-0'}`}
>
<i className={'block h-[16px] w-[16px]'}>
<TrashSvg />
</i>
</button>
</div>
</>
);
};

View File

@ -0,0 +1,183 @@
import { t } from 'i18next';
import AddSvg from '../../_shared/svg/AddSvg';
import { useAppSelector } from '$app/stores/store';
import { MouseEventHandler, useMemo, useState } from 'react';
import { DatabaseFilterItem } from '$app/components/_shared/DatabaseFilter/DatabaseFilterItem';
import { IDatabaseFilter, TDatabaseOperators } from '$app_reducers/database/slice';
import { FilterController } from '$app/stores/effects/database/filter/filter_controller';
import {
CheckboxFilterPB,
FieldType,
SelectOptionConditionPB,
SelectOptionFilterPB,
TextFilterConditionPB,
TextFilterPB,
} from '@/services/backend';
export const DatabaseFilterPopup = ({
filterController,
onOutsideClick,
}: {
filterController: FilterController;
onOutsideClick: () => void;
}) => {
// stores
const filtersStore = useAppSelector((state) => state.database.filters);
// local copy to prevent jitter when adding new filter
const [filters, setFilters] = useState<(IDatabaseFilter | null)[]>(filtersStore);
const [showBlankFilter, setShowBlankFilter] = useState(filtersStore.length === 0);
const onAddClick: MouseEventHandler = () => {
setShowBlankFilter(true);
};
const transformOperator: (
operator: TDatabaseOperators,
type: FieldType
) => TextFilterConditionPB | SelectOptionConditionPB = (operator, type) => {
switch (type) {
case FieldType.RichText:
switch (operator) {
case 'contains':
return TextFilterConditionPB.Contains;
case 'doesNotContain':
return TextFilterConditionPB.DoesNotContain;
case 'endsWith':
return TextFilterConditionPB.EndsWith;
case 'startWith':
return TextFilterConditionPB.StartsWith;
case 'is':
return TextFilterConditionPB.Is;
case 'isNot':
return TextFilterConditionPB.IsNot;
case 'isEmpty':
return TextFilterConditionPB.TextIsEmpty;
case 'isNotEmpty':
return TextFilterConditionPB.TextIsNotEmpty;
default:
return TextFilterConditionPB.Is;
}
case FieldType.SingleSelect:
case FieldType.MultiSelect:
switch (operator) {
case 'is':
case 'contains':
return SelectOptionConditionPB.OptionIs;
case 'isNot':
case 'doesNotContain':
return SelectOptionConditionPB.OptionIsNot;
case 'isEmpty':
return SelectOptionConditionPB.OptionIsEmpty;
case 'isNotEmpty':
return SelectOptionConditionPB.OptionIsNotEmpty;
default:
return SelectOptionConditionPB.OptionIs;
}
default:
return TextFilterConditionPB.Is;
}
};
const onSaveFilterItem = async (filter: IDatabaseFilter) => {
let val: TextFilterPB | SelectOptionFilterPB | CheckboxFilterPB;
switch (filter.fieldType) {
case FieldType.RichText:
val = new TextFilterPB({
condition: transformOperator(filter.operator, filter.fieldType) as TextFilterConditionPB,
content: filter.value as string,
});
break;
case FieldType.SingleSelect:
case FieldType.MultiSelect:
val = new SelectOptionFilterPB({
condition: transformOperator(filter.operator, filter.fieldType) as SelectOptionConditionPB,
option_ids: filter.value as string[],
});
break;
default:
val = new TextFilterPB({
condition: transformOperator('is', FieldType.RichText) as TextFilterConditionPB,
content: '',
});
break;
}
let updatedFilter = filter;
if (filter.id) {
await filterController.updateFilter(filter.id, filter.fieldId, filter.fieldType, val);
} else {
const newId = await filterController.addFilter(filter.fieldId, filter.fieldType, val);
updatedFilter = { ...filter, id: newId };
}
const index = filters.findIndex((f) => f?.fieldId === filter.fieldId);
if (index === -1) {
setFilters([...filters, updatedFilter]);
} else {
setFilters([...filters.slice(0, index), updatedFilter, ...filters.slice(index + 1)]);
}
setShowBlankFilter(false);
};
const onDeleteFilterItem = async (filter: IDatabaseFilter | null) => {
if (!filter || !filter.id || !filter.fieldId) return;
// add blank filter if no filters left
if (filters.length === 1) {
setShowBlankFilter(true);
}
await filterController.removeFilter(filter.fieldId, filter.fieldType, filter.id);
// update local copy
const index = filters.findIndex((f) => f?.fieldId === filter.fieldId);
setFilters([...filters.slice(0, index), ...filters.slice(index + 1)]);
};
// null row represents new filter
const rows = useMemo(() => (showBlankFilter ? filters.concat([null]) : filters), [filters, showBlankFilter]);
return (
<div
className={'fixed inset-0 z-10 flex items-center justify-center overflow-y-auto backdrop-blur-sm'}
onClick={onOutsideClick}
>
<div onClick={(e) => e.stopPropagation()} className='flex flex-col rounded-lg bg-bg-body shadow-md'>
<div className='px-6 pt-6 text-sm text-text-caption'>{t('grid.settings.filter')}</div>
<div className='flex flex-col gap-3 overflow-y-scroll px-6 py-6 text-sm'>
{rows.map((filter, index: number) => (
<DatabaseFilterItem
data={filter}
onSave={onSaveFilterItem}
onDelete={() => onDeleteFilterItem(filter)}
key={index}
index={index}
></DatabaseFilterItem>
))}
</div>
<hr />
<button
onClick={onAddClick}
className='flex cursor-pointer items-center gap-2 px-6 py-6 text-sm text-text-caption'
>
<div className='h-5 w-5'>
<AddSvg />
</div>
{t('grid.settings.addFilter')}
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,79 @@
import { FieldTypeIcon } from '$app/components/_shared/EditRow/FieldTypeIcon';
import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg';
import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
import React, { useState } from 'react';
import { DatabaseFieldMap, IDatabaseColumn } from '$app_reducers/database/slice';
import { FieldType } from '@/services/backend';
interface IFieldSelectProps {
columns: IDatabaseColumn[];
fields: DatabaseFieldMap;
onSelectFieldClick: (fieldId: string) => void;
currentFieldId: string | null;
currentFieldType: FieldType | undefined;
}
const WIDTH = 180;
export const FieldSelect = ({
columns,
fields,
onSelectFieldClick,
currentFieldId,
currentFieldType,
}: IFieldSelectProps) => {
const [showSelect, setShowSelect] = useState(false);
return (
<ButtonPopoverList
isVisible={true}
popoverOptions={columns.map((column) => ({
key: column.fieldId,
icon: (
<i className={'block h-5 w-5'}>
<FieldTypeIcon fieldType={fields[column.fieldId].fieldType}></FieldTypeIcon>
</i>
),
label: fields[column.fieldId].title,
onClick: () => {
onSelectFieldClick(column.fieldId);
setShowSelect(false);
},
}))}
popoverOrigin={{
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
onClose={() => setShowSelect(false)}
sx={{ width: `${WIDTH}px` }}
>
<div
onClick={() => setShowSelect(true)}
className={`flex items-center justify-between rounded-lg border px-2 py-1 ${
showSelect ? 'border-fill-hover' : 'border-line-border'
}`}
style={{ width: `${WIDTH}px` }}
>
{currentFieldType !== undefined && currentFieldId ? (
<div className={'flex items-center gap-2'}>
<i className={'block h-5 w-5'}>
<FieldTypeIcon fieldType={currentFieldType}></FieldTypeIcon>
</i>
<span>{fields[currentFieldId].title}</span>
</div>
) : (
<span className={'text-text-placeholder'}>Select a field</span>
)}
<i className={`h-5 w-5 transition-transform duration-500 ${showSelect ? 'rotate-180' : 'rotate-0'}`}>
<DropDownShowSvg></DropDownShowSvg>
</i>
</div>
</ButtonPopoverList>
);
};

View File

@ -0,0 +1,146 @@
import { FieldType } from '@/services/backend';
import { getBgColor } from '$app/components/_shared/getColor';
import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg';
import { EditorCheckSvg } from '$app/components/_shared/svg/EditorCheckSvg';
import { EditorUncheckSvg } from '$app/components/_shared/svg/EditorUncheckSvg';
import React, { useRef, useState } from 'react';
import { DatabaseFieldMap, ISelectOption, ISelectOptionType } from '$app_reducers/database/slice';
import { CellOption } from '$app/components/_shared/EditRow/Options/CellOption';
import { Popover } from '@mui/material';
interface IFilterValueProps {
currentFieldId: string | null;
currentFieldType: FieldType | undefined;
currentValue: string[] | string | boolean | null;
fields: DatabaseFieldMap;
textInputActive: boolean;
setTextInputActive: (v: boolean) => void;
setCurrentValue: (v: string[] | string | boolean | null) => void;
onValueOptionClick: (option: ISelectOption) => void;
}
const WIDTH = 180;
export const FilterValue = ({
currentFieldId,
currentFieldType,
currentValue,
fields,
textInputActive,
setTextInputActive,
setCurrentValue,
onValueOptionClick,
}: IFilterValueProps) => {
const [showValueOptions, setShowValueOptions] = useState(false);
const refValueOptions = useRef<HTMLDivElement>(null);
const getSelectOption = (optionId: string) => {
if (!currentFieldId) return undefined;
return (fields[currentFieldId].fieldOptions as ISelectOptionType).selectOptions.find(
(option) => option.selectOptionId === optionId
);
};
return currentFieldId ? (
<>
{(currentFieldType === FieldType.MultiSelect || currentFieldType === FieldType.SingleSelect) && (
<>
<div
ref={refValueOptions}
onClick={() => setShowValueOptions(true)}
className={`flex items-center justify-between rounded-lg border px-2 py-1 ${
showValueOptions ? 'border-fill-hover' : 'border-line-border'
}`}
style={{ width: `${WIDTH}px` }}
>
{currentValue ? (
<div className={'flex flex-1 items-center gap-1 overflow-hidden'}>
{(currentValue as string[]).length === 0 && (
<span className={'text-text-placeholder'}>none selected</span>
)}
{(currentValue as string[]).map((option, i) => (
<span className={`${getBgColor(getSelectOption(option)?.color)} rounded px-2 py-0.5 text-xs`} key={i}>
{getSelectOption(option)?.title}
</span>
))}
</div>
) : (
<span className={'text-text-placeholder'}>Select an option</span>
)}
<i className={`h-5 w-5 transition-transform duration-500 ${showValueOptions ? 'rotate-180' : 'rotate-0'}`}>
<DropDownShowSvg></DropDownShowSvg>
</i>
</div>
<Popover
open={showValueOptions}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
anchorEl={refValueOptions.current}
onClose={() => setShowValueOptions(false)}
>
<div style={{ width: `${WIDTH}px` }} className={'flex flex-col gap-2 p-2 text-xs'}>
<div className={'font-medium text-text-caption'}>Value option</div>
<div className={'flex flex-col gap-1'}>
{(fields[currentFieldId].fieldOptions as ISelectOptionType).selectOptions.map((option, index) => (
<CellOption
key={index}
option={option}
checked={(currentValue as string[]).findIndex((o) => o === option.selectOptionId) !== -1}
noSelect={true}
noDetail={true}
onOptionClick={() => onValueOptionClick(option)}
></CellOption>
))}
</div>
</div>
</Popover>
</>
)}
{currentFieldType === FieldType.RichText && (
<div
className={`flex items-center justify-between rounded-lg border px-2 py-1 ${
textInputActive ? 'border-fill-hover' : 'border-line-border'
}`}
style={{ width: `${WIDTH}px` }}
>
<input
placeholder={'Enter value'}
className={'flex-1'}
onFocus={() => setTextInputActive(true)}
onBlur={() => setTextInputActive(false)}
value={currentValue as string}
onChange={(e) => setCurrentValue(e.target.value)}
/>
</div>
)}
{currentFieldType === FieldType.Checkbox && (
<div
onClick={() => setCurrentValue(!currentValue)}
className={`flex cursor-pointer items-center gap-2 rounded-lg border border-line-border px-2 py-1`}
style={{ width: `${WIDTH}px` }}
>
<button className={'h-5 w-5'}>
{currentValue ? <EditorCheckSvg></EditorCheckSvg> : <EditorUncheckSvg></EditorUncheckSvg>}
</button>
<span>{currentValue ? 'Checked' : 'Unchecked'}</span>
</div>
)}
</>
) : (
<div
className={`flex items-center justify-between rounded-lg border border-line-border px-2 py-1`}
style={{ width: `${WIDTH}px` }}
>
<span className={'text-text-placeholder'}>Select field</span>
</div>
);
};

View File

@ -0,0 +1,50 @@
import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg';
import React, { useState } from 'react';
const LogicalOperators: ('and' | 'or')[] = ['and', 'or'];
const WIDTH = 88;
export const LogicalOperatorSelect = () => {
const [showSelect, setShowSelect] = useState(false);
return (
<ButtonPopoverList
isVisible={true}
popoverOptions={LogicalOperators.map((operator) => ({
key: operator,
label: operator,
icon: null,
onClick: () => {
console.log('logical operator: ', operator);
setShowSelect(false);
},
}))}
popoverOrigin={{
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
onClose={() => setShowSelect(false)}
sx={{ width: `${WIDTH}px` }}
>
<div
onClick={() => setShowSelect(true)}
className={`flex items-center justify-between rounded-lg border px-2 py-1 ${
showSelect ? 'border-fill-hover' : 'border-line-border'
}`}
style={{ width: `${WIDTH}px` }}
>
and
<i className={`h-5 w-5 transition-transform duration-500 ${showSelect ? 'rotate-180' : 'rotate-0'}`}>
<DropDownShowSvg></DropDownShowSvg>
</i>
</div>
</ButtonPopoverList>
);
};

View File

@ -0,0 +1,63 @@
import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
import React, { useState } from 'react';
import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg';
import { SupportedOperatorsByType, TDatabaseOperators } from '$app_reducers/database/slice';
import { FieldType } from '@/services/backend';
interface IOperatorSelectProps {
currentOperator: TDatabaseOperators | null;
currentFieldType: FieldType | undefined;
onSelectOperatorClick: (operator: TDatabaseOperators) => void;
}
const WIDTH = 180;
export const OperatorSelect = ({ currentOperator, currentFieldType, onSelectOperatorClick }: IOperatorSelectProps) => {
const [showSelect, setShowSelect] = useState(false);
return (
<ButtonPopoverList
isVisible={true}
popoverOptions={SupportedOperatorsByType[currentFieldType ? currentFieldType : FieldType.RichText].map(
(operatorName, index) => ({
icon: null,
key: index,
label: operatorName,
onClick: () => {
onSelectOperatorClick(operatorName);
setShowSelect(false);
},
})
)}
popoverOrigin={{
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
onClose={() => setShowSelect(false)}
sx={{ width: `${WIDTH}px` }}
>
<div
onClick={() => setShowSelect(true)}
className={`flex items-center justify-between rounded-lg border px-2 py-1 ${
showSelect ? 'border-fill-hover' : 'border-line-border'
}`}
style={{ width: `${WIDTH}px` }}
>
{currentOperator ? (
<span>{currentOperator}</span>
) : (
<span className={'text-text-placeholder'}>Select an option</span>
)}
<i className={`h-5 w-5 transition-transform duration-500 ${showSelect ? 'rotate-180' : 'rotate-0'}`}>
<DropDownShowSvg></DropDownShowSvg>
</i>
</div>
</ButtonPopoverList>
);
};

View File

@ -0,0 +1,97 @@
import { IDatabaseSort } from '$app_reducers/database/slice';
import React, { useEffect, useMemo, useState } from 'react';
import { useAppSelector } from '$app/stores/store';
import { DragElementSvg } from '$app/components/_shared/svg/DragElementSvg';
import { SortConditionPB } from '@/services/backend';
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import { FieldSelect } from '$app/components/_shared/DatabaseFilter/FieldSelect';
import { OrderSelect } from '$app/components/_shared/DatabaseSort/OrderSelect';
export const DatabaseSortItem = ({
data,
onSave,
onDelete,
}: {
data: IDatabaseSort | null;
onSave: (sortItem: IDatabaseSort) => void;
onDelete?: () => void;
}) => {
// stores
const columns = useAppSelector((state) => state.database.columns);
const fields = useAppSelector((state) => state.database.fields);
const sortStore = useAppSelector((state) => state.database.sort);
// values
const [currentFieldId, setCurrentFieldId] = useState<string | null>(data?.fieldId ?? null);
const [currentOrder, setCurrentOrder] = useState<SortConditionPB | null>(data?.order ?? null);
const supportedColumns = useMemo(
() => columns.filter((c) => sortStore.findIndex((s) => s.fieldId === c.fieldId) === -1),
[columns, sortStore]
);
const currentFieldType = useMemo(
() => (currentFieldId ? fields[currentFieldId].fieldType : undefined),
[currentFieldId, fields]
);
useEffect(() => {
if (data) {
setCurrentFieldId(data.fieldId);
setCurrentOrder(data.order);
} else {
setCurrentFieldId(null);
setCurrentOrder(null);
}
}, [data]);
useEffect(() => {
if (currentFieldId && currentOrder !== null) {
onSave({
id: data?.id,
fieldId: currentFieldId,
order: currentOrder,
fieldType: fields[currentFieldId].fieldType,
});
}
}, [currentFieldId, currentOrder]);
const onSelectFieldClick = (id: string) => {
setCurrentFieldId(id);
// set ascending order by default
setCurrentOrder(SortConditionPB.Ascending);
};
const onSelectOrderClick = (order: SortConditionPB) => {
setCurrentOrder(order);
};
return (
<div className={'flex items-center gap-4'}>
<button className={'flex-shrink-0 rounded p-1 hover:bg-fill-list-hover'}>
<i className={'block h-[16px] w-[16px]'}>
<DragElementSvg></DragElementSvg>
</i>
</button>
<FieldSelect
columns={supportedColumns}
fields={fields}
onSelectFieldClick={onSelectFieldClick}
currentFieldId={currentFieldId}
currentFieldType={currentFieldType}
></FieldSelect>
<OrderSelect currentOrder={currentOrder} onSelectOrderClick={onSelectOrderClick}></OrderSelect>
<button
onClick={() => onDelete?.()}
className={`rounded p-1 hover:bg-fill-list-hover ${data ? 'opacity-100' : 'opacity-0'}`}
>
<i className={'block h-[16px] w-[16px]'}>
<TrashSvg />
</i>
</button>
</div>
);
};

View File

@ -0,0 +1,99 @@
import { t } from 'i18next';
import { MouseEventHandler, useMemo, useRef, useState } from 'react';
import { useAppSelector } from '$app/stores/store';
import { IDatabaseSort } from '$app_reducers/database/slice';
import { DatabaseSortItem } from '$app/components/_shared/DatabaseSort/DatabaseSortItem';
import AddSvg from '$app/components/_shared/svg/AddSvg';
import { SortController } from '$app/stores/effects/database/sort/sort_controller';
export const DatabaseSortPopup = ({
sortController,
onOutsideClick,
}: {
sortController: SortController;
onOutsideClick: () => void;
}) => {
// stores
const sortStore = useAppSelector((state) => state.database.sort);
const [sort, setSort] = useState<(IDatabaseSort | null)[]>(sortStore);
const [showBlankSort, setShowBlankSort] = useState(sortStore.length === 0);
const onSaveSortItem = async (sortItem: IDatabaseSort) => {
let updatedSort = sortItem;
if (sortItem.id) {
await sortController.updateSort(sortItem.id, sortItem.fieldId, sortItem.fieldType, sortItem.order);
} else {
const newId = await sortController.addSort(sortItem.fieldId, sortItem.fieldType, sortItem.order);
updatedSort = { ...updatedSort, id: newId };
}
// update local copy
const index = sort.findIndex((s) => s?.fieldId === sortItem.fieldId);
if (index === -1) {
setSort([...sort, updatedSort]);
} else {
setSort([...sort.slice(0, index), updatedSort, ...sort.slice(index + 1)]);
}
setShowBlankSort(false);
};
const onDeleteClick = async (sortItem: IDatabaseSort | null) => {
if (!sortItem || !sortItem.id) return;
// add blank sort if no sorts left
if (sort.length === 1) {
setShowBlankSort(true);
}
await sortController.removeSort(sortItem.fieldId, sortItem.fieldType, sortItem.id);
const index = sort.findIndex((s) => s?.fieldId === sortItem.fieldId);
setSort([...sort.slice(0, index), ...sort.slice(index + 1)]);
};
const onAddClick: MouseEventHandler = () => {
setShowBlankSort(true);
};
const rows = useMemo(() => (showBlankSort ? [...sort, null] : sort), [sort, showBlankSort]);
return (
<div
className={'fixed inset-0 z-10 flex items-center justify-center overflow-y-auto backdrop-blur-sm'}
onClick={onOutsideClick}
>
<div onClick={(e) => e.stopPropagation()} className='flex flex-col rounded-lg bg-white shadow-md'>
<div className='px-6 pt-6 text-sm text-text-caption'>{t('grid.settings.sort')}</div>
<div className='flex flex-col gap-3 overflow-y-scroll px-6 py-6 text-sm'>
{rows.map((sortItem, index) => (
<DatabaseSortItem
key={index}
data={sortItem}
onSave={onSaveSortItem}
onDelete={() => onDeleteClick(sortItem)}
/>
))}
</div>
<hr />
<button
onClick={onAddClick}
className='flex cursor-pointer items-center gap-2 px-6 py-6 text-sm text-text-caption'
>
<div className='h-5 w-5'>
<AddSvg />
</div>
{t('grid.sort.addSort')}
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,97 @@
import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
import React, { useState } from 'react';
import { SortConditionPB } from '@/services/backend';
import { SortAscSvg } from '$app/components/_shared/svg/SortAscSvg';
import { SortDescSvg } from '$app/components/_shared/svg/SortDescSvg';
import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg';
interface IOrderSelectProps {
currentOrder: SortConditionPB | null;
onSelectOrderClick: (order: SortConditionPB) => void;
}
const WIDTH = 180;
export const OrderSelect = ({ currentOrder, onSelectOrderClick }: IOrderSelectProps) => {
const [showSelect, setShowSelect] = useState(false);
return (
<ButtonPopoverList
isVisible={true}
popoverOptions={[
{
icon: (
<i className={'block h-5 w-5'}>
<SortAscSvg></SortAscSvg>
</i>
),
label: 'Ascending',
key: SortConditionPB.Ascending,
onClick: () => {
onSelectOrderClick(SortConditionPB.Ascending);
setShowSelect(false);
},
},
{
icon: (
<i className={'block h-5 w-5'}>
<SortDescSvg></SortDescSvg>
</i>
),
label: 'Descending',
key: SortConditionPB.Descending,
onClick: () => {
onSelectOrderClick(SortConditionPB.Descending);
setShowSelect(false);
},
},
]}
popoverOrigin={{
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
onClose={() => setShowSelect(false)}
sx={{ width: `${WIDTH}px` }}
>
<div
onClick={() => setShowSelect(true)}
className={`flex w-[180px] items-center justify-between rounded-lg border px-2 py-1 ${
showSelect ? 'border-fill-hover' : 'border-line-border'
}`}
>
{currentOrder !== null ? (
<SortLabel value={currentOrder}></SortLabel>
) : (
<span className={'text-text-caption'}>Select order</span>
)}
<i className={`h-5 w-5 transition-transform duration-500 ${showSelect ? 'rotate-180' : 'rotate-0'}`}>
<DropDownShowSvg></DropDownShowSvg>
</i>
</div>
</ButtonPopoverList>
);
};
const SortLabel = ({ value }: { value: SortConditionPB }) => {
return value === SortConditionPB.Ascending ? (
<div className={'flex items-center gap-2'}>
<i className={'block h-5 w-5'}>
<SortAscSvg></SortAscSvg>
</i>
<span>Ascending</span>
</div>
) : (
<div className={'flex items-center gap-2'}>
<i className={'block h-5 w-5'}>
<SortDescSvg></SortDescSvg>
</i>
<span>Descending</span>
</div>
);
};

View File

@ -1,7 +1,7 @@
import { FieldType } from '@/services/backend';
import { FieldTypeIcon } from '$app/components/_shared/EditRow/FieldTypeIcon';
import { FieldTypeName } from '$app/components/_shared/EditRow/FieldTypeName';
import { PopupWindow } from '$app/components/_shared/PopupWindow';
import { Popover } from '@mui/material';
const typesOrder: FieldType[] = [
FieldType.RichText,
@ -15,19 +15,31 @@ const typesOrder: FieldType[] = [
];
export const ChangeFieldTypePopup = ({
top,
left,
open,
anchorEl,
onClick,
onOutsideClick,
}: {
top: number;
left: number;
open: boolean;
anchorEl: HTMLDivElement | null;
onClick: (newType: FieldType) => void;
onOutsideClick: () => void;
}) => {
return (
<PopupWindow className={'p-2 text-xs'} onOutsideClick={onOutsideClick} left={left} top={top}>
<div className={'flex flex-col'}>
<Popover
open={open}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
onClose={onOutsideClick}
>
<div className={'flex flex-col p-2 text-xs'}>
{typesOrder.map((t, i) => (
<button
onClick={() => onClick(t)}
@ -43,6 +55,6 @@ export const ChangeFieldTypePopup = ({
</button>
))}
</div>
</PopupWindow>
</Popover>
);
};

View File

@ -58,7 +58,7 @@ export const CheckListPopup = ({
return (
<PopupWindow className={'text-xs'} onOutsideClick={onOutsideClick} left={left} top={top}>
<div className={'min-w-[320px]'}>
<div className={'px-4 pt-8 pb-4'}>
<div className={'px-4 pb-4 pt-8'}>
<CheckListProgress completed={selectedOptionsCount} max={allOptionsCount} />
</div>
@ -87,7 +87,7 @@ export const CheckListPopup = ({
></NewCheckListOption>
))}
</div>
<div className={'h-[1px] bg-shade-6'}></div>
<div className={'h-[1px] bg-line-divider'}></div>
<div className={'p-2'}>
<NewCheckListButton newOptions={newOptions} setNewOptions={setNewOptions}></NewCheckListButton>
</div>

View File

@ -78,7 +78,7 @@ export const EditCheckListPopup = ({
onKeyDown={onKeyDown}
onBlur={() => onBlur()}
/>
<div className={'text-shade-3 font-mono'}>{value.length}/30</div>
<div className={'font-mono text-text-caption'}>{value.length}/30</div>
</div>
<button
onClick={() => onDeleteOptionClick()}

View File

@ -17,7 +17,7 @@ export const NewCheckListButton = ({
return (
<button
onClick={() => newOptionClick()}
className={'flex w-full items-center gap-2 rounded-lg px-2 py-2 hover:bg-shade-6'}
className={'flex w-full items-center gap-2 rounded-lg px-2 py-2 hover:bg-line-divider'}
>
<i className={'h-5 w-5'}>
<AddSvg></AddSvg>

View File

@ -35,7 +35,7 @@ export const NewCheckListOption = ({
};
return (
<div className={'flex cursor-pointer items-center justify-between rounded-lg px-2 py-1.5 hover:bg-shade-6'}>
<div className={'flex cursor-pointer items-center justify-between rounded-lg px-2 py-1.5 hover:bg-line-divider'}>
<input
onKeyDown={(e) => onNewOptionKeyDown(e as unknown as KeyboardEvent)}
className={'min-w-0 flex-1 pl-7'}
@ -44,7 +44,7 @@ export const NewCheckListOption = ({
/>
<button
onClick={() => onSaveNewOptionClick()}
className={'flex items-center gap-2 rounded-lg bg-main-accent px-4 py-2 text-white hover:bg-main-hovered'}
className={'flex items-center gap-2 rounded-lg bg-fill-hover px-4 py-2 text-white hover:bg-main-hovered'}
>
{t('grid.selectOption.create')}
</button>

View File

@ -87,7 +87,7 @@ export const DateTypeOptions = ({
return (
<div className={'flex flex-col'}>
<hr className={'border-shade-6 -mx-2 my-2'} />
<hr className={'border-line-divider -mx-2 my-2'} />
<button
onClick={_onDateFormatClick}
className={

View File

@ -30,7 +30,7 @@ export const EditCellWrapper = ({
cellIdentifier: CellIdentifier;
cellCache: CellCache;
fieldController: FieldController;
onEditFieldClick: (cell: CellIdentifier, left: number, top: number) => void;
onEditFieldClick: (cell: CellIdentifier, anchorEl: HTMLDivElement) => void;
onEditOptionsClick: (cell: CellIdentifier, left: number, top: number) => void;
onEditDateClick: (cell: CellIdentifier, left: number, top: number) => void;
onEditCheckListClick: (cell: CellIdentifier, left: number, top: number) => void;
@ -41,9 +41,8 @@ export const EditCellWrapper = ({
const onClick = () => {
if (!el.current) return;
const { top, right } = el.current.getBoundingClientRect();
onEditFieldClick(cellIdentifier, right, top);
onEditFieldClick(cellIdentifier, el.current);
};
return (
@ -93,7 +92,13 @@ export const EditCellWrapper = ({
{cellIdentifier.fieldType === FieldType.Checkbox && cellController && (
<EditCheckboxCell
data={data as 'Yes' | 'No' | undefined}
cellController={cellController}
onToggle={async () => {
if (data === 'Yes') {
await cellController?.saveCellData('No');
} else {
await cellController?.saveCellData('Yes');
}
}}
></EditCheckboxCell>
)}
@ -105,15 +110,30 @@ export const EditCellWrapper = ({
)}
{cellIdentifier.fieldType === FieldType.Number && cellController && (
<EditCellNumber data={data as string | undefined} cellController={cellController}></EditCellNumber>
<EditCellNumber
data={data as string | undefined}
onSave={async (value) => {
await cellController?.saveCellData(value);
}}
></EditCellNumber>
)}
{cellIdentifier.fieldType === FieldType.URL && cellController && (
<EditCellUrl data={data as URLCellDataPB} cellController={cellController}></EditCellUrl>
<EditCellUrl
data={data as URLCellDataPB}
onSave={async (value) => {
await cellController?.saveCellData(value);
}}
></EditCellUrl>
)}
{cellIdentifier.fieldType === FieldType.RichText && cellController && (
<EditCellText data={data as string | undefined} cellController={cellController}></EditCellText>
<EditCellText
data={data as string | undefined}
onSave={async (value) => {
await cellController?.saveCellData(value);
}}
></EditCellText>
)}
</div>
</div>

View File

@ -1,47 +1,49 @@
import { FocusEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
import React, { FocusEventHandler, useEffect, useRef, useState } from 'react';
import { FieldTypeIcon } from '$app/components/_shared/EditRow/FieldTypeIcon';
import { FieldTypeName } from '$app/components/_shared/EditRow/FieldTypeName';
import { useTranslation } from 'react-i18next';
import { TypeOptionController } from '$app/stores/effects/database/field/type_option/type_option_controller';
import { Some } from 'ts-results';
import { FieldController, FieldInfo } from '$app/stores/effects/database/field/field_controller';
import { MoreSvg } from '$app/components/_shared/svg/MoreSvg';
import { useAppSelector } from '$app/stores/store';
import { CellIdentifier } from '$app/stores/effects/database/cell/cell_bd_svc';
import { PopupWindow } from '$app/components/_shared/PopupWindow';
import { FieldType } from '@/services/backend';
import { DateTypeOptions } from '$app/components/_shared/EditRow/Date/DateTypeOptions';
import { DatabaseController } from '$app/stores/effects/database/database_controller';
import { EyeClosedSvg } from '$app/components/_shared/svg/EyeClosedSvg';
import { Popover } from '@mui/material';
import { CopySvg } from '$app/components/_shared/svg/CopySvg';
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import { SkipLeftSvg } from '$app/components/_shared/svg/SkipLeftSvg';
import { SkipRightSvg } from '$app/components/_shared/svg/SkipRightSvg';
import { EyeOpenSvg } from '$app/components/_shared/svg/EyeOpenSvg';
export const EditFieldPopup = ({
top,
left,
open,
anchorEl,
cellIdentifier,
viewId,
onOutsideClick,
fieldInfo,
fieldController,
controller,
changeFieldTypeClick,
onNumberFormat,
onDeletePropertyClick,
}: {
top: number;
left: number;
open: boolean;
anchorEl: HTMLDivElement | null;
cellIdentifier: CellIdentifier;
viewId: string;
onOutsideClick: () => void;
fieldInfo: FieldInfo | undefined;
fieldController?: FieldController;
changeFieldTypeClick: (buttonTop: number, buttonRight: number) => void;
onNumberFormat?: (buttonLeft: number, buttonTop: number) => void;
controller: DatabaseController;
changeFieldTypeClick: (el: HTMLDivElement) => void;
onDeletePropertyClick: (fieldId: string) => void;
}) => {
const databaseStore = useAppSelector((state) => state.database);
const fieldsStore = useAppSelector((state) => state.database.fields);
const { t } = useTranslation();
const changeTypeButtonRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState('');
useEffect(() => {
setName(databaseStore.fields[cellIdentifier.fieldId].title);
}, [databaseStore, cellIdentifier]);
setName(fieldsStore[cellIdentifier.fieldId].title);
}, [fieldsStore, cellIdentifier]);
// focus input on mount
useEffect(() => {
@ -55,45 +57,71 @@ export const EditFieldPopup = ({
};
const save = async () => {
if (!controller) return;
const fieldInfo = controller.fieldController.getField(cellIdentifier.fieldId);
if (!fieldInfo) return;
const controller = new TypeOptionController(viewId, Some(fieldInfo));
const typeOptionController = new TypeOptionController(viewId, Some(fieldInfo));
await controller.initialize();
await controller.setFieldName(name);
await typeOptionController.initialize();
await typeOptionController.setFieldName(name);
};
const onChangeFieldTypeClick = () => {
if (!changeTypeButtonRef.current) return;
const { top: buttonTop, right: buttonRight } = changeTypeButtonRef.current.getBoundingClientRect();
changeFieldTypeClick(buttonTop, buttonRight);
changeFieldTypeClick(changeTypeButtonRef.current);
};
const onNumberFormatClick: MouseEventHandler = (e) => {
e.stopPropagation();
let target = e.target as HTMLElement;
const toggleHideProperty = async () => {
// we need to close the popup because after hiding the field, parent element will be removed
onOutsideClick();
const fieldInfo = controller.fieldController.getField(cellIdentifier.fieldId);
while (!(target instanceof HTMLButtonElement)) {
if (target.parentElement === null) return;
target = target.parentElement;
if (fieldInfo) {
const typeController = new TypeOptionController(viewId, Some(fieldInfo));
await typeController.initialize();
if (fieldInfo.field.visibility) {
await typeController.hideField();
} else {
await typeController.showField();
}
}
};
const { right: _left, top: _top } = target.getBoundingClientRect();
const onDuplicatePropertyClick = async () => {
onOutsideClick();
await controller.duplicateField(cellIdentifier.fieldId);
};
onNumberFormat?.(_left, _top);
const onAddToLeftClick = async () => {
onOutsideClick();
await controller.addFieldToLeft(cellIdentifier.fieldId);
};
const onAddToRightClick = async () => {
onOutsideClick();
await controller.addFieldToRight(cellIdentifier.fieldId);
};
return (
<PopupWindow
className={'px-2 py-2 text-xs'}
onOutsideClick={async () => {
<Popover
anchorEl={anchorEl}
open={open}
onClose={async () => {
await save();
onOutsideClick();
}}
left={left}
top={top}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<div className={'flex flex-col gap-2'}>
<div className={'flex flex-col gap-2 p-2 text-xs'}>
<input
ref={inputRef}
onFocus={selectAll}
@ -127,29 +155,75 @@ export const EditFieldPopup = ({
</span>
</div>
{cellIdentifier.fieldType === FieldType.Number && (
<>
<hr className={'-mx-2 border-line-divider'} />
<button
onClick={onNumberFormatClick}
className={
'flex w-full cursor-pointer items-center justify-between rounded-lg py-2 hover:bg-fill-list-hover'
}
>
<span className={'pl-2'}>{t('grid.field.numberFormat')}</span>
<span className={'pr-2'}>
<i className={'block h-5 w-5'}>
<MoreSvg></MoreSvg>
</i>
</span>
</button>
</>
)}
<div className={'-mx-2 h-[1px] bg-line-divider'}></div>
{cellIdentifier.fieldType === FieldType.DateTime && fieldController && (
<DateTypeOptions cellIdentifier={cellIdentifier} fieldController={fieldController}></DateTypeOptions>
)}
<div className={'grid grid-cols-2'}>
<div className={'flex flex-col gap-2'}>
<div
onClick={toggleHideProperty}
className={'flex cursor-pointer items-center gap-2 rounded-lg p-2 pr-8 hover:bg-fill-list-hover'}
>
{fieldsStore[cellIdentifier.fieldId]?.visible ? (
<>
<i className={'block h-5 w-5'}>
<EyeClosedSvg />
</i>
<span>{t('grid.field.hide')}</span>
</>
) : (
<>
<i className={'block h-5 w-5'}>
<EyeOpenSvg />
</i>
<span>Show</span>
</>
)}
</div>
<div
onClick={() => onDuplicatePropertyClick()}
className={'flex cursor-pointer items-center gap-2 rounded-lg p-2 pr-8 hover:bg-fill-list-hover'}
>
<i className={'block h-5 w-5'}>
<CopySvg />
</i>
<span>{t('grid.field.duplicate')}</span>
</div>
<div
onClick={() => {
onOutsideClick();
onDeletePropertyClick(cellIdentifier.fieldId);
}}
className={'flex cursor-pointer items-center gap-2 rounded-lg p-2 pr-8 hover:bg-fill-list-hover'}
>
<i className={'block h-5 w-5'}>
<TrashSvg />
</i>
<span>{t('grid.field.delete')}</span>
</div>
</div>
<div className={'flex flex-col gap-2'}>
<div
onClick={onAddToLeftClick}
className={'flex cursor-pointer items-center gap-2 rounded-lg p-2 pr-8 hover:bg-fill-list-hover'}
>
<i className={'block h-5 w-5'}>
<SkipLeftSvg />
</i>
<span>{t('grid.field.insertLeft')}</span>
</div>
<div
onClick={onAddToRightClick}
className={'flex cursor-pointer items-center gap-2 rounded-lg p-2 pr-8 hover:bg-fill-list-hover'}
>
<i className={'block h-5 w-5'}>
<SkipRightSvg />
</i>
<span>{t('grid.field.insertRight')}</span>
</div>
</div>
</div>
</div>
</PopupWindow>
</Popover>
);
};

View File

@ -35,19 +35,17 @@ export const EditRow = ({
controller: DatabaseController;
rowInfo: RowInfo;
}) => {
const databaseStore = useAppSelector((state) => state.database);
const fieldsStore = useAppSelector((state) => state.database.fields);
const { cells, onNewColumnClick } = useRow(viewId, controller, rowInfo);
const { t } = useTranslation();
const [unveil, setUnveil] = useState(false);
const [editingCell, setEditingCell] = useState<CellIdentifier | null>(null);
const [editFieldAnchorEl, setEditFieldAnchorEl] = useState<HTMLDivElement | null>(null);
const [showFieldEditor, setShowFieldEditor] = useState(false);
const [editFieldTop, setEditFieldTop] = useState(0);
const [editFieldLeft, setEditFieldLeft] = useState(0);
const [showChangeFieldTypePopup, setShowChangeFieldTypePopup] = useState(false);
const [changeFieldTypeTop, setChangeFieldTypeTop] = useState(0);
const [changeFieldTypeLeft, setChangeFieldTypeLeft] = useState(0);
const [changeFieldTypeAnchorEl, setChangeFieldTypeAnchorEl] = useState<HTMLDivElement | null>(null);
const [showChangeOptionsPopup, setShowChangeOptionsPopup] = useState(false);
const [changeOptionsTop, setChangeOptionsTop] = useState(0);
@ -89,22 +87,18 @@ export const EditRow = ({
}, 300);
};
const onEditFieldClick = (cellIdentifier: CellIdentifier, left: number, top: number) => {
const onEditFieldClick = (cellIdentifier: CellIdentifier, anchorEl: HTMLDivElement) => {
setEditFieldAnchorEl(anchorEl);
setEditingCell(cellIdentifier);
setEditFieldTop(top);
setEditFieldLeft(left + 10);
setShowFieldEditor(true);
};
const onOutsideEditFieldClick = () => {
if (!showChangeFieldTypePopup) {
setShowFieldEditor(false);
}
setShowFieldEditor(false);
};
const onChangeFieldTypeClick = (buttonTop: number, buttonRight: number) => {
setChangeFieldTypeTop(buttonTop);
setChangeFieldTypeLeft(buttonRight + 30);
const onChangeFieldTypeClick = (el: HTMLDivElement) => {
setChangeFieldTypeAnchorEl(el);
setShowChangeFieldTypePopup(true);
};
@ -152,12 +146,6 @@ export const EditRow = ({
setEditCheckListTop(_top);
};
const onNumberFormat = (_left: number, _top: number) => {
setShowNumberFormatPopup(true);
setNumberFormatLeft(_left + 10);
setNumberFormatTop(_top);
};
const onEditCheckListClick = (cellIdentifier: CellIdentifier, left: number, top: number) => {
setEditingCell(cellIdentifier);
setShowCheckListPopup(true);
@ -186,6 +174,8 @@ export const EditRow = ({
if (!fieldInfo) return;
const typeController = new TypeOptionController(viewId, Some(fieldInfo));
setEditingCell(null);
await typeController.initialize();
await typeController.deleteField();
setShowDeletePropertyPrompt(false);
@ -228,13 +218,13 @@ export const EditRow = ({
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`flex flex-1 flex-col gap-8 px-8 ${
className={`flex flex-1 flex-col gap-8 px-8 pb-8 ${
showFieldEditor || showChangeOptionsPopup || showDatePicker ? 'overflow-hidden' : 'overflow-auto'
}`}
>
{cells
.filter((cell) => {
return databaseStore.fields[cell.cellIdentifier.fieldId]?.visible;
return fieldsStore[cell.cellIdentifier.fieldId]?.visible;
})
.map((cell, cellIndex) => (
<EditCellWrapper
@ -275,23 +265,22 @@ export const EditRow = ({
></PropertiesPanel>
</div>
{showFieldEditor && editingCell && (
{editingCell && (
<EditFieldPopup
top={editFieldTop}
left={editFieldLeft}
open={showFieldEditor}
anchorEl={editFieldAnchorEl}
cellIdentifier={editingCell}
viewId={viewId}
onOutsideClick={onOutsideEditFieldClick}
fieldInfo={controller.fieldController.getField(editingCell.fieldId)}
fieldController={controller.fieldController}
controller={controller}
changeFieldTypeClick={onChangeFieldTypeClick}
onNumberFormat={onNumberFormat}
onDeletePropertyClick={onDeletePropertyClick}
></EditFieldPopup>
)}
{showChangeFieldTypePopup && (
<ChangeFieldTypePopup
top={changeFieldTypeTop}
left={changeFieldTypeLeft}
open={showChangeFieldTypePopup}
anchorEl={changeFieldTypeAnchorEl}
onClick={(newType) => changeFieldType(newType)}
onOutsideClick={() => setShowChangeFieldTypePopup(false)}
></ChangeFieldTypePopup>
@ -323,6 +312,7 @@ export const EditRow = ({
left={editCellOptionLeft}
cellIdentifier={editingCell}
editingSelectOption={editingSelectOption}
setEditingSelectOption={setEditingSelectOption}
onOutsideClick={() => {
setShowEditCellOption(false);
}}

View File

@ -1,28 +1,21 @@
import { CellController } from '$app/stores/effects/database/cell/cell_controller';
import { useEffect, useState } from 'react';
export const EditCellNumber = ({
data,
cellController,
}: {
data: string | undefined;
cellController: CellController<any, any>;
}) => {
export const EditCellNumber = ({ data, onSave }: { data: string | undefined; onSave: (value: string) => void }) => {
const [value, setValue] = useState('');
useEffect(() => {
setValue(data ?? '');
}, [data]);
const save = async () => {
await cellController?.saveCellData(value);
};
// const save = async () => {
// await cellController?.saveCellData(value);
// };
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => save()}
onBlur={() => onSave(value)}
className={'w-full px-4 py-1'}
></input>
);

View File

@ -1,13 +1,6 @@
import { CellController } from '$app/stores/effects/database/cell/cell_controller';
import { useEffect, useState } from 'react';
export const EditCellText = ({
data,
cellController,
}: {
data: string | undefined;
cellController: CellController<any, any>;
}) => {
export const EditCellText = ({ data, onSave }: { data: string | undefined; onSave: (value: string) => void }) => {
const [value, setValue] = useState('');
const [contentRows, setContentRows] = useState(1);
@ -24,10 +17,6 @@ export const EditCellText = ({
setValue(v);
};
const save = async () => {
await cellController?.saveCellData(value);
};
return (
<div>
<textarea
@ -35,7 +24,7 @@ export const EditCellText = ({
rows={contentRows}
value={value}
onChange={(e) => onTextFieldChange(e.target.value)}
onBlur={() => save()}
onBlur={() => onSave(value)}
/>
</div>
);

View File

@ -1,30 +1,18 @@
import { URLCellDataPB } from '@/services/backend';
import { CellController } from '$app/stores/effects/database/cell/cell_controller';
import { useEffect, useState } from 'react';
import { URLCellController } from '$app/stores/effects/database/cell/controller_builder';
export const EditCellUrl = ({
data,
cellController,
}: {
data: URLCellDataPB | undefined;
cellController: CellController<any, any>;
}) => {
export const EditCellUrl = ({ data, onSave }: { data: URLCellDataPB | undefined; onSave: (value: string) => void }) => {
const [value, setValue] = useState('');
useEffect(() => {
setValue((data as URLCellDataPB)?.url ?? '');
}, [data]);
const save = async () => {
await (cellController as URLCellController)?.saveCellData(value);
};
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => save()}
onBlur={() => onSave(value)}
className={'w-full px-4 py-1'}
></input>
);

View File

@ -2,23 +2,17 @@ import { EditorCheckSvg } from '$app/components/_shared/svg/EditorCheckSvg';
import { EditorUncheckSvg } from '$app/components/_shared/svg/EditorUncheckSvg';
import { CheckboxCellController } from '$app/stores/effects/database/cell/controller_builder';
export const EditCheckboxCell = ({
data,
cellController,
}: {
data: 'Yes' | 'No' | undefined;
cellController: CheckboxCellController;
}) => {
const toggleValue = async () => {
if (data === 'Yes') {
await cellController?.saveCellData('No');
} else {
await cellController?.saveCellData('Yes');
}
};
export const EditCheckboxCell = ({ data, onToggle }: { data: 'Yes' | 'No' | undefined; onToggle: () => void }) => {
// const toggleValue = async () => {
// if (data === 'Yes') {
// await cellController?.saveCellData('No');
// } else {
// await cellController?.saveCellData('Yes');
// }
// };
return (
<div onClick={() => toggleValue()} className={'block px-4 py-1'}>
<div onClick={() => onToggle()} className={'block px-4 py-1'}>
<button className={'h-5 w-5'}>
{data === 'Yes' ? <EditorCheckSvg></EditorCheckSvg> : <EditorUncheckSvg></EditorUncheckSvg>}
</button>

View File

@ -13,12 +13,18 @@ export const CellOption = ({
cellIdentifier,
openOptionDetail,
clearValue,
noSelect,
noDetail,
onOptionClick,
}: {
option: ISelectOption;
checked: boolean;
cellIdentifier: CellIdentifier;
openOptionDetail: (_left: number, _top: number, _select_option: SelectOptionPB) => void;
clearValue: () => void;
cellIdentifier?: CellIdentifier;
openOptionDetail?: (_left: number, _top: number, _select_option: SelectOptionPB) => void;
clearValue?: () => void;
noSelect?: boolean;
noDetail?: boolean;
onOptionClick?: () => void;
}) => {
const onOptionDetailClick: MouseEventHandler = (e) => {
e.stopPropagation();
@ -37,17 +43,19 @@ export const CellOption = ({
const { right: _left, top: _top } = target.getBoundingClientRect();
openOptionDetail(_left, _top, selectOption);
openOptionDetail?.(_left, _top, selectOption);
};
const onToggleOptionClick: MouseEventHandler = async () => {
onOptionClick?.();
if (noSelect || !cellIdentifier) return;
if (checked) {
await new SelectOptionCellBackendService(cellIdentifier).unselectOption([option.selectOptionId]);
} else {
await new SelectOptionCellBackendService(cellIdentifier).selectOption([option.selectOptionId]);
}
clearValue();
clearValue?.();
};
return (
@ -62,9 +70,11 @@ export const CellOption = ({
<CheckmarkSvg></CheckmarkSvg>
</button>
)}
<button onClick={onOptionDetailClick} className={'h-6 w-6 p-1'}>
<Details2Svg></Details2Svg>
</button>
{!noDetail && (
<button onClick={onOptionDetailClick} className={'h-6 w-6 p-1'}>
<Details2Svg></Details2Svg>
</button>
)}
</div>
</div>
);

View File

@ -43,7 +43,7 @@ export const CellOptionsPopup = ({
const onKeyDown: KeyboardEventHandler = async (e) => {
if (e.key === 'Enter' && value.length > 0) {
await new SelectOptionCellBackendService(cellIdentifier).createOption({ name: value });
await new SelectOptionCellBackendService(cellIdentifier).createOption({ name: value, isSelect: true });
setValue('');
}
};
@ -82,7 +82,7 @@ export const CellOptionsPopup = ({
/>
<div className={'font-mono text-text-caption'}>{value.length}/30</div>
</div>
<div className={'-mx-4 h-[1px] bg-line-border'}></div>
<div className={'-mx-4 h-[1px] bg-line-divider'}></div>
<div className={'font-medium text-text-caption'}>{t('grid.selectOption.panelTitle') ?? ''}</div>
<div className={'flex flex-col gap-1'}>
{(databaseStore.fields[cellIdentifier.fieldId]?.fieldOptions as ISelectOptionType).selectOptions.map(

View File

@ -7,23 +7,29 @@ import { SelectOptionCellBackendService } from '$app/stores/effects/database/cel
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import { CheckmarkSvg } from '$app/components/_shared/svg/CheckmarkSvg';
import { PopupWindow } from '$app/components/_shared/PopupWindow';
import { databaseActions, ISelectOptionType } from '$app_reducers/database/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
export const EditCellOptionPopup = ({
left,
top,
cellIdentifier,
editingSelectOption,
setEditingSelectOption,
onOutsideClick,
}: {
left: number;
top: number;
cellIdentifier: CellIdentifier;
editingSelectOption: SelectOptionPB;
setEditingSelectOption: (option: SelectOptionPB) => void;
onOutsideClick: () => void;
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const [value, setValue] = useState('');
const fieldsStore = useAppSelector((state) => state.database.fields);
const dispatch = useAppDispatch();
useEffect(() => {
setValue(editingSelectOption.name);
@ -54,16 +60,43 @@ export const EditCellOptionPopup = ({
);
};
const onUpdateSelectOption = (option: SelectOptionPB) => {
const updatingField = fieldsStore[cellIdentifier.fieldId];
const allOptions = (updatingField.fieldOptions as ISelectOptionType).selectOptions;
dispatch(
databaseActions.updateField({
field: {
...updatingField,
fieldOptions: {
selectOptions: allOptions.map((o) =>
o.selectOptionId === option.id
? {
selectOptionId: option.id,
color: option.color,
title: option.name,
}
: o
),
},
},
})
);
setEditingSelectOption(option);
};
const onColorClick = async (color: SelectOptionColorPB) => {
const svc = new SelectOptionCellBackendService(cellIdentifier);
await svc.updateOption(
new SelectOptionPB({
id: editingSelectOption.id,
color,
name: editingSelectOption.name,
})
);
const updatedOption = new SelectOptionPB({
id: editingSelectOption.id,
color,
name: editingSelectOption.name,
});
await svc.updateOption(updatedOption);
onUpdateSelectOption(updatedOption);
};
const onDeleteOptionClick = async () => {
@ -97,7 +130,7 @@ export const EditCellOptionPopup = ({
onKeyDown={onKeyDown}
onBlur={() => onBlur()}
/>
<div className={'text-shade-3 font-mono'}>{value.length}/30</div>
<div className={'font-mono text-text-caption'}>{value.length}/30</div>
</div>
<button
onClick={() => onDeleteOptionClick()}
@ -110,8 +143,8 @@ export const EditCellOptionPopup = ({
</i>
<span>{t('grid.selectOption.deleteTag')}</span>
</button>
<div className={'bg-shade-6 -mx-4 h-[1px]'}></div>
<div className={'text-shade-3 my-2 font-medium'}>{t('grid.selectOption.colorPanelTitle')}</div>
<div className={'-mx-4 h-[1px] bg-line-divider'}></div>
<div className={'my-2 font-medium text-text-caption'}>{t('grid.selectOption.colorPanelTitle')}</div>
<div className={'flex flex-col'}>
<ColorItem
title={t('grid.selectOption.purpleColor')}

View File

@ -0,0 +1,92 @@
import { CellIdentifier } from '$app/stores/effects/database/cell/cell_bd_svc';
import { ISelectOptionType } from '$app_reducers/database/slice';
import { CellOption } from '$app/components/_shared/EditRow/Options/CellOption';
import { SelectOptionPB } from '@/services/backend';
import { useAppSelector } from '$app/stores/store';
import { KeyboardEventHandler, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectOptionCellBackendService } from '$app/stores/effects/database/cell/select_option_bd_svc';
export const MultiSelectTypeOptions = ({
cellIdentifier,
openOptionDetail,
}: {
cellIdentifier: CellIdentifier;
openOptionDetail?: (_left: number, _top: number, _select_option: SelectOptionPB) => void;
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const fieldsStore = useAppSelector((state) => state.database.fields);
const [value, setValue] = useState('');
const [showInput, setShowInput] = useState(false);
const [newInputWidth, setNewInputWidth] = useState(0);
const onKeyDown: KeyboardEventHandler = async (e) => {
if (e.key === 'Enter' && value.length > 0) {
await new SelectOptionCellBackendService(cellIdentifier).createOption({ name: value, isSelect: false });
setValue('');
}
if (e.key === 'Escape') {
setShowInput(false);
}
};
useEffect(() => {
if (inputRef?.current && showInput) {
inputRef.current.focus();
}
}, [inputRef, showInput, newInputWidth]);
useEffect(() => {
if (inputContainerRef?.current && showInput) {
setNewInputWidth(inputContainerRef.current.getBoundingClientRect().width - 56);
} else {
setNewInputWidth(0);
}
}, [inputContainerRef, showInput]);
return (
<div className={'flex flex-col'}>
<hr className={'-mx-2 my-2 border-line-divider'} />
<div className={'flex flex-col gap-1'}>
<div className={'flex items-center justify-between px-3 py-1.5'}>
<div>Options</div>
{!showInput && <button onClick={() => setShowInput(true)}>Add option</button>}
</div>
{showInput && (
<div
ref={inputContainerRef}
className={`border-shades-3 bg-main-selector flex items-center gap-2 rounded border px-2`}
>
{newInputWidth > 0 && (
<input
ref={inputRef}
style={{ width: newInputWidth }}
className={'py-2'}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => setShowInput(false)}
onKeyDown={onKeyDown}
/>
)}
<div className={'font-mono text-text-caption'}>{value.length}/30</div>
</div>
)}
{(fieldsStore[cellIdentifier.fieldId]?.fieldOptions as ISelectOptionType).selectOptions.map((option, index) => (
<CellOption
key={index}
option={option}
noSelect={true}
checked={false}
cellIdentifier={cellIdentifier}
openOptionDetail={openOptionDetail}
clearValue={() => setValue('')}
></CellOption>
))}
</div>
</div>
);
};

View File

@ -21,8 +21,8 @@ export const SelectedOption = ({
return (
<div className={`${getBgColor(option.color)} flex items-center gap-0.5 rounded px-1 py-0.5 text-content-on-fill`}>
<span>{option?.name ?? ''}</span>
<button onClick={onUnselectOptionClick} className={'h-5 w-5 cursor-pointer'}>
<span className={'text-text-title'}>{option?.name ?? ''}</span>
<button onClick={onUnselectOptionClick} className={'h-5 w-5 cursor-pointer text-text-title'}>
<CloseSvg></CloseSvg>
</button>
</div>

View File

@ -1,4 +1,4 @@
import { MouseEvent, ReactNode, useRef } from 'react';
import { CSSProperties, MouseEvent, ReactNode, useRef } from 'react';
import useOutsideClick from './useOutsideClick';
export interface IPopupItem {
@ -18,7 +18,7 @@ export const PopupSelect = ({
className: string;
onOutsideClick?: () => void;
columns?: 1 | 2 | 3;
style?: any;
style?: CSSProperties;
}) => {
const ref = useRef<HTMLDivElement>(null);

View File

@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import useOutsideClick from '$app/components/_shared/useOutsideClick';
export const PopupWindow = ({
@ -7,12 +7,14 @@ export const PopupWindow = ({
onOutsideClick,
left,
top,
style,
}: {
children: ReactNode;
className?: string;
onOutsideClick: () => void;
left: number;
top: number;
style?: CSSProperties;
}) => {
const ref = useRef<HTMLDivElement>(null);
@ -20,22 +22,30 @@ export const PopupWindow = ({
const [adjustedTop, setAdjustedTop] = useState(-100);
const [adjustedLeft, setAdjustedLeft] = useState(-100);
const [stickToBottom, setStickToBottom] = useState(false);
const [stickToRight, setStickToRight] = useState(false);
useEffect(() => {
if (!ref.current) return;
const { height, width } = ref.current.getBoundingClientRect();
if (top + height > window.innerHeight) {
setAdjustedTop(window.innerHeight - height);
} else {
new ResizeObserver(() => {
if (!ref.current) return;
const { height, width } = ref.current.getBoundingClientRect();
setAdjustedTop(top);
}
if (top + height > window.innerHeight) {
setStickToBottom(true);
} else {
setStickToBottom(false);
}
if (left + width > window.innerWidth) {
setAdjustedLeft(window.innerWidth - width);
} else {
setAdjustedLeft(left);
}
if (left + width > window.innerWidth) {
setStickToRight(true);
} else {
setStickToRight(false);
}
}).observe(ref.current);
}, [ref, left, top]);
return (
@ -46,7 +56,11 @@ export const PopupWindow = ({
(adjustedTop === -100 && adjustedLeft === -100 ? 'opacity-0 ' : 'opacity-100 ') +
(className ?? '')
}
style={{ top: `${adjustedTop}px`, left: `${adjustedLeft}px` }}
style={{
[stickToBottom ? 'bottom' : 'top']: `${stickToBottom ? '0' : adjustedTop}px`,
[stickToRight ? 'right' : 'left']: `${stickToRight ? '0' : adjustedLeft}px`,
...style,
}}
>
{children}
</div>

View File

@ -54,6 +54,7 @@ export default async function (viewId: string, fieldInfo: FieldInfo, dispatch?:
title: field.name,
fieldType: field.field_type,
visible: field.visibility,
width: field.width,
fieldOptions: {
selectOptions,
},
@ -66,6 +67,7 @@ export default async function (viewId: string, fieldInfo: FieldInfo, dispatch?:
fieldId: field.id,
title: field.name,
visible: field.visibility,
width: field.width,
fieldType: field.field_type,
fieldOptions: {
numberFormat: typeOption.format,
@ -79,6 +81,7 @@ export default async function (viewId: string, fieldInfo: FieldInfo, dispatch?:
fieldId: field.id,
title: field.name,
visible: field.visibility,
width: field.width,
fieldType: field.field_type,
fieldOptions: {
dateFormat: typeOption.date_format,
@ -92,6 +95,7 @@ export default async function (viewId: string, fieldInfo: FieldInfo, dispatch?:
fieldId: field.id,
title: field.name,
visible: field.visibility,
width: field.width,
fieldType: field.field_type,
};
}

View File

@ -29,6 +29,7 @@ export const useCell = (cellIdentifier: CellIdentifier, cellCache: CellCache, fi
// update redux store for database field if there are new select options
if (
value instanceof SelectOptionCellDataPB &&
databaseStore.fields[cellIdentifier.fieldId] &&
(databaseStore.fields[cellIdentifier.fieldId].fieldOptions as ISelectOptionType).selectOptions.length !==
value.options.length
) {

View File

@ -1,11 +1,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DatabaseController } from '$app/stores/effects/database/database_controller';
import { databaseActions, DatabaseFieldMap, IDatabaseColumn } from '$app/stores/reducers/database/slice';
import {
databaseActions,
DatabaseFieldMap,
IDatabaseColumn,
IDatabaseFilter,
TDatabaseOperators,
} from '$app/stores/reducers/database/slice';
import { useAppDispatch } from '$app/stores/store';
import loadField from './loadField';
import { FieldInfo } from '$app/stores/effects/database/field/field_controller';
import { RowInfo } from '$app/stores/effects/database/row/row_cache';
import { ViewLayoutPB } from '@/services/backend';
import {
FieldType,
SelectOptionConditionPB,
SelectOptionFilterPB,
TextFilterConditionPB,
TextFilterPB,
ViewLayoutPB,
} from '@/services/backend';
import { DatabaseGroupController } from '$app/stores/effects/database/group/group_controller';
import { OnDragEndResponder } from 'react-beautiful-dnd';
import { AsyncQueue } from '$app/utils/async_queue';
@ -20,6 +33,7 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
useEffect(() => {
if (!viewId.length) return;
const c = new DatabaseController(viewId);
setController(c);
return () => void c.dispose();
@ -29,8 +43,10 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
async (fieldInfos: readonly FieldInfo[]) => {
const fields: DatabaseFieldMap = {};
const columns: IDatabaseColumn[] = [];
for (const fieldInfo of fieldInfos) {
const fieldPB = fieldInfo.field;
columns.push({
fieldId: fieldPB.id,
sort: 'none',
@ -38,8 +54,10 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
});
const field = await loadField(viewId, fieldInfo, dispatch);
fields[field.fieldId] = field;
}
dispatch(databaseActions.updateFields({ fields }));
dispatch(databaseActions.updateColumns({ columns }));
},
@ -50,6 +68,50 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
return new AsyncQueue<readonly FieldInfo[]>(loadFields);
}, [loadFields]);
const transformCondition: (condition: number, fieldType: FieldType) => TDatabaseOperators = (condition, fieldType) => {
switch (fieldType) {
case FieldType.RichText:
switch (condition) {
case TextFilterConditionPB.Contains:
return 'contains';
case TextFilterConditionPB.DoesNotContain:
return 'doesNotContain';
case TextFilterConditionPB.EndsWith:
return 'endsWith';
case TextFilterConditionPB.StartsWith:
return 'startWith';
case TextFilterConditionPB.Is:
return 'is';
case TextFilterConditionPB.IsNot:
return 'isNot';
case TextFilterConditionPB.TextIsEmpty:
return 'isEmpty';
case TextFilterConditionPB.TextIsNotEmpty:
return 'isNotEmpty';
default:
return 'is';
}
case FieldType.SingleSelect:
case FieldType.MultiSelect:
switch (condition) {
case SelectOptionConditionPB.OptionIs:
return 'is';
case SelectOptionConditionPB.OptionIsNot:
return 'isNot';
case SelectOptionConditionPB.OptionIsEmpty:
return 'isEmpty';
case SelectOptionConditionPB.OptionIsNotEmpty:
return 'isNotEmpty';
default:
return 'is';
}
default:
return 'is';
}
};
useEffect(() => {
void (async () => {
if (!controller) return;
@ -61,9 +123,50 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
onFieldsChanged: (fieldInfos) => {
queue.enqueue(fieldInfos);
},
onFiltersChanged: (filters) => {
const reduxFilters = filters.map<IDatabaseFilter>((filter) => {
switch (filter.field_type) {
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return {
logicalOperator: 'and',
fieldType: filter.field_type,
fieldId: filter.field_id,
id: filter.id,
operator: transformCondition((filter.data as SelectOptionFilterPB).condition, filter.field_type),
value: (filter.data as SelectOptionFilterPB).option_ids,
};
case FieldType.RichText:
return {
logicalOperator: 'and',
fieldType: filter.field_type,
fieldId: filter.field_id,
id: filter.id,
operator: transformCondition((filter.data as TextFilterPB).condition, filter.field_type),
value: (filter.data as TextFilterPB).content,
};
default:
return {
logicalOperator: 'and',
fieldType: filter.field_type,
fieldId: filter.field_id,
id: filter.id,
operator: 'is',
value: '',
};
}
});
dispatch(databaseActions.updateFilters({ filters: reduxFilters }));
},
onSortChanged: (sorts) => {
dispatch(databaseActions.updateSorts({ sorts: [...sorts] }));
},
});
const openResult = await controller.open();
if (openResult.ok) {
setRows(
openResult.val.map((pb) => {
@ -74,6 +177,7 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
if (type === ViewLayoutPB.Board) {
const fieldId = await controller.getGroupByFieldId();
setGroupByFieldId(fieldId.unwrap());
setGroups(controller.groups.value);
}
@ -88,6 +192,7 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
if (!groups) return;
if (!controller?.groups) return;
const group = groups[index];
await group.createRow();
setGroups([...controller.groups.value]);
@ -97,6 +202,7 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
if (!controller) return;
const { source, destination } = result;
const group = groups.find((g) => g.groupId === source.droppableId);
if (!group) return;
if (source.droppableId === destination?.droppableId) {

View File

@ -1,4 +1,4 @@
import { SelectOptionColorPB } from '../../../services/backend';
import { SelectOptionColorPB } from '@/services/backend';
export const getBgColor = (color: SelectOptionColorPB | undefined): string => {
switch (color) {

View File

@ -0,0 +1,12 @@
export const DragSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<rect x='9' y='3' width='2' height='2' rx='0.5' fill='#333333' />
<rect x='5' y='3' width='2' height='2' rx='0.5' fill='#333333' />
<rect x='9' y='7' width='2' height='2' rx='0.5' fill='#333333' />
<rect x='5' y='7' width='2' height='2' rx='0.5' fill='#333333' />
<rect x='9' y='11' width='2' height='2' rx='0.5' fill='#333333' />
<rect x='5' y='11' width='2' height='2' rx='0.5' fill='#333333' />
</svg>
);
};

View File

@ -0,0 +1,18 @@
export const ShareSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448'
stroke='#333333'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155'
stroke='#333333'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
};

View File

@ -0,0 +1,22 @@
export const SortAscSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M11.25 7L17.25 7' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
<path d='M11.25 12H18.75' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
<path
d='M11.25 17L20.25 17'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M6.75 6V18L3.75 15'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
};

View File

@ -0,0 +1,22 @@
export const SortDescSvg = () => {
return (
<svg width='100%' height='100%' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M11.25 7L20.25 7' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
<path d='M11.25 12H18.75' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
<path
d='M11.25 17L17.25 17'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M6.75 6V18L3.75 15'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
};

View File

@ -1,16 +1,27 @@
import { useState } from 'react';
import React, { useState } from 'react';
export const useResizer = () => {
export const useResizer = (onEnd?: (final: number) => void) => {
const [movementX, setMovementX] = useState(0);
const [movementY, setMovementY] = useState(0);
const [newSizeX, setNewSizeX] = useState(0);
const [newSizeY, setNewSizeY] = useState(0);
const onMouseDown = () => {
const onMouseMove = (e: MouseEvent) => {
setMovementX(e.movementX);
setMovementY(e.movementY);
const onMouseDown = (e1: React.MouseEvent<HTMLElement>, initial = 0) => {
const startX = e1.screenX;
const startY = e1.screenY;
setNewSizeX(initial);
setNewSizeY(initial);
const onMouseMove = (e2: MouseEvent) => {
setNewSizeX(initial + e2.screenX - startX);
setNewSizeY(initial + e2.screenY - startY);
setMovementX(e2.movementX);
setMovementY(e2.movementY);
};
const onMouseUp = () => {
const onMouseUp = (e2: MouseEvent) => {
onEnd?.(initial + e2.screenX - startX);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
@ -22,6 +33,8 @@ export const useResizer = () => {
return {
movementX,
movementY,
newSizeX,
newSizeY,
onMouseDown,
};
};

View File

@ -30,12 +30,12 @@ export const ConfirmAccount = () => {
numInputs={5}
isInputNum={true}
separator={<span> </span>}
inputStyle='border border-gray-300 rounded-lg h-full !w-14 font-semibold focus:ring-2 focus:ring-main-accent focus:ring-opacity-50'
inputStyle='border border-gray-300 rounded-lg h-full !w-14 font-semibold focus:ring-2 focus:ring-fill-hover focus:ring-opacity-50'
containerStyle='h-full w-full flex justify-around gap-2 '
/>
</div>
<a href='#' className='text-xs text-main-accent hover:text-main-hovered'>
<a href='#' className='hover:text-content-hover text-xs text-fill-hover'>
<span>Send code again</span>
</a>
</div>

View File

@ -104,7 +104,7 @@ export const SignUp = () => {
<span className='text-xs text-gray-500'>
{t('signUp.alreadyHaveAnAccount')}
<Link to={'/auth/login'}>
<span className='ml-2 text-main-accent hover:text-main-hovered'>{t('signIn.buttonText')}</span>
<span className='hover:text-content-hover ml-2 text-fill-hover'>{t('signIn.buttonText')}</span>
</Link>
</span>
</div>

View File

@ -49,7 +49,7 @@ export const BoardGroup = ({
<div className={'flex items-center justify-between p-4'}>
<div className={'flex items-center gap-2'}>
<span>{group.name}</span>
<span className={'text-shade-4'}>({group.rows.length})</span>
<span className={'text-text-caption'}>({group.rows.length})</span>
</div>
<div className={'flex items-center gap-2'}>
<button className={'h-5 w-5 rounded hover:bg-fill-list-hover'}>

View File

@ -17,7 +17,7 @@ export const BoardUrlCell = ({
return (
<>
<a className={'text-main-accent hover:underline'} href={(data as URLCellDataPB)?.url ?? ''} target={'_blank'}>
<a className={'text-fill-hover hover:underline'} href={(data as URLCellDataPB)?.url ?? ''} target={'_blank'}>
{(data as URLCellDataPB)?.content ?? ''}&nbsp;
</a>
</>

View File

@ -22,7 +22,7 @@ function Editor({
return (
<div className={'min-h-[30px]'}>
<div ref={ref} {...props} />
{!editor && <div className={'px-0.5 py-1 text-shade-4'}>{placeholder}</div>}
{!editor && <div className={'px-0.5 py-1 text-text-caption'}>{placeholder}</div>}
</div>
);
}

View File

@ -1,7 +1,6 @@
import { useDatabase } from '$app/components/_shared/database-hooks/useDatabase';
import { GridTableCount } from '../GridTableCount/GridTableCount';
import { GridTableHeader } from '../GridTableHeader/GridTableHeader';
import { GridAddRow } from '../GridTableRows/GridAddRow';
import { GridTableRows } from '../GridTableRows/GridTableRows';
import { GridTitle } from '../GridTitle/GridTitle';
import { GridToolbar } from '../GridToolbar/GridToolbar';
@ -9,35 +8,51 @@ import { EditRow } from '$app/components/_shared/EditRow/EditRow';
import { useState } from 'react';
import { RowInfo } from '$app/stores/effects/database/row/row_cache';
import { ViewLayoutPB } from '@/services/backend';
import { DatabaseFilterPopup } from '$app/components/_shared/DatabaseFilter/DatabaseFilterPopup';
import { DatabaseSortPopup } from '$app/components/_shared/DatabaseSort/DatabaseSortPopup';
export const Grid = ({ viewId }: { viewId: string }) => {
const { controller, rows, groups } = useDatabase(viewId, ViewLayoutPB.Grid);
const [showGridRow, setShowGridRow] = useState(false);
const [boardRowInfo, setBoardRowInfo] = useState<RowInfo>();
const [showFilterPopup, setShowFilterPopup] = useState(false);
const [showSortPopup, setShowSortPopup] = useState(false);
const onOpenRow = (rowInfo: RowInfo) => {
setBoardRowInfo(rowInfo);
setShowGridRow(true);
};
const onShowFilterClick = () => {
setShowFilterPopup(true);
};
const onShowSortClick = () => {
setShowSortPopup(true);
};
return (
<>
{controller && groups && (
<>
<div className='mx-auto mt-8 flex flex-col gap-8 px-8'>
<div className='flex flex-1 flex-col gap-4'>
<div className='flex w-full items-center justify-between'>
<GridTitle />
<GridTitle onShowFilterClick={onShowFilterClick} onShowSortClick={onShowSortClick} viewId={viewId} />
<GridToolbar />
</div>
{/* table component page with text area for td */}
<div className='flex flex-col gap-4'>
<table className='w-full table-fixed text-sm'>
<GridTableHeader controller={controller} />
<GridTableRows onOpenRow={onOpenRow} allRows={rows} viewId={viewId} controller={controller} />
</table>
<GridAddRow controller={controller} />
<div className='flex flex-1 flex-col gap-4'>
<div className='flex flex-1 flex-col overflow-x-auto'>
<GridTableHeader
controller={controller}
onShowFilterClick={onShowFilterClick}
onShowSortClick={onShowSortClick}
/>
<div className={'relative flex-1'}>
<GridTableRows onOpenRow={onOpenRow} allRows={rows} viewId={viewId} controller={controller} />
</div>
</div>
</div>
<GridTableCount rows={rows} />
@ -52,6 +67,15 @@ export const Grid = ({ viewId }: { viewId: string }) => {
)}
</>
)}
{showFilterPopup && controller && controller.filterController && (
<DatabaseFilterPopup
filterController={controller.filterController}
onOutsideClick={() => setShowFilterPopup(false)}
/>
)}
{showSortPopup && controller && controller.sortController && (
<DatabaseSortPopup sortController={controller.sortController} onOutsideClick={() => setShowSortPopup(false)} />
)}
</>
);
};

View File

@ -13,13 +13,15 @@ export const GridCell = ({
cellIdentifier,
cellCache,
fieldController,
width,
}: {
cellIdentifier: CellIdentifier;
cellCache: CellCache;
fieldController: FieldController;
width?: number;
}) => {
return (
<>
<div style={{ width }}>
{cellIdentifier.fieldType === FieldType.MultiSelect ||
cellIdentifier.fieldType === FieldType.Checklist ||
cellIdentifier.fieldType === FieldType.SingleSelect ? (
@ -39,6 +41,6 @@ export const GridCell = ({
) : (
<GridTextCell cellIdentifier={cellIdentifier} cellCache={cellCache} fieldController={fieldController} />
)}
</>
</div>
);
};

View File

@ -16,8 +16,15 @@ export const GridCheckBox = ({
const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController);
return (
<div className='flex w-full justify-start'>
{cellController && <EditCheckboxCell cellController={cellController} data={data as 'Yes' | 'No' | undefined} />}
</div>
<EditCheckboxCell
onToggle={async () => {
if (data === 'Yes') {
await cellController?.saveCellData('No');
} else {
await cellController?.saveCellData('Yes');
}
}}
data={data as 'Yes' | 'No' | undefined}
/>
);
};

View File

@ -29,7 +29,7 @@ export const GridDate = ({
};
return (
<div className='flex w-full cursor-pointer justify-start'>
<>
{cellController && <EditCellDate data={data as DateCellDataPB} onEditClick={onEditDateClick}></EditCellDate>}
{showDatePopup && (
@ -42,6 +42,6 @@ export const GridDate = ({
onOutsideClick={() => setShowDatePopup(false)}
></DatePickerPopup>
)}
</div>
</>
);
};

View File

@ -16,10 +16,11 @@ export const GridNumberCell = ({
const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController);
return (
<div className='w-full'>
{cellController && (
<EditCellNumber data={data as string | undefined} cellController={cellController}></EditCellNumber>
)}
</div>
<EditCellNumber
data={data as string | undefined}
onSave={async (value) => {
await cellController?.saveCellData(value);
}}
></EditCellNumber>
);
};

View File

@ -44,9 +44,7 @@ export default function GridSingleSelectOptions({
return (
<>
<div className='flex w-full cursor-pointer justify-start'>
<CellOptions data={data as SelectOptionCellDataPB} onEditClick={onEditOptionsClick} />
</div>
<CellOptions data={data as SelectOptionCellDataPB} onEditClick={onEditOptionsClick} />
{showOptionsPopup && (
<CellOptionsPopup
@ -55,7 +53,7 @@ export default function GridSingleSelectOptions({
cellIdentifier={cellIdentifier}
cellCache={cellCache}
fieldController={fieldController}
onOutsideClick={() => setShowOptionsPopup(false)}
onOutsideClick={() => !showEditCellOption && setShowOptionsPopup(false)}
openOptionDetail={onOpenOptionDetailClick}
/>
)}
@ -65,6 +63,7 @@ export default function GridSingleSelectOptions({
left={editCellOptionLeft}
cellIdentifier={cellIdentifier}
editingSelectOption={editingSelectOption}
setEditingSelectOption={setEditingSelectOption}
onOutsideClick={() => {
setShowEditCellOption(false);
}}

View File

@ -16,8 +16,11 @@ export default function GridTextCell({
const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController);
return (
<div className='w-full'>
{cellController && <EditCellText data={data as string | undefined} cellController={cellController}></EditCellText>}
</div>
<EditCellText
data={data as string | undefined}
onSave={async (value) => {
await cellController?.saveCellData(value);
}}
></EditCellText>
);
}

View File

@ -17,6 +17,11 @@ export const GridUrl = ({
const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController);
return (
<>{cellController && <EditCellUrl data={data as URLCellDataPB} cellController={cellController}></EditCellUrl>}</>
<EditCellUrl
data={data as URLCellDataPB}
onSave={async (value) => {
await cellController?.saveCellData(value);
}}
></EditCellUrl>
);
};

View File

@ -9,6 +9,7 @@ export const useGridTableHeaderHooks = function (controller: DatabaseController)
const onAddField = async () => {
// TODO: move this to database controller hook
const fieldController = new TypeOptionController(controller.viewId, None);
await fieldController.initialize();
};

View File

@ -1,36 +1,50 @@
import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller';
import AddSvg from '../../_shared/svg/AddSvg';
import { useGridTableHeaderHooks } from './GridTableHeader.hooks';
import { GridTableHeaderItem } from './GridTableHeaderItem';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '$app/stores/store';
export const GridTableHeader = ({ controller }: { controller: DatabaseController }) => {
const { fields, onAddField } = useGridTableHeaderHooks(controller);
export const GridTableHeader = ({
controller,
onShowFilterClick,
onShowSortClick,
}: {
controller: DatabaseController;
onShowFilterClick: () => void;
onShowSortClick: () => void;
}) => {
const columns = useAppSelector((state) => state.database.columns);
const fields = useAppSelector((state) => state.database.fields);
const { onAddField } = useGridTableHeaderHooks(controller);
const { t } = useTranslation();
return (
<>
<thead>
<tr>
{fields.map((field, i) => {
return <GridTableHeaderItem field={field} controller={controller} key={i} />;
})}
<th className='m-0 w-40 border border-r-0 border-line-divider p-0'>
<div
className='flex cursor-pointer items-center px-4 py-2 text-text-caption hover:bg-fill-list-hover hover:text-text-title'
onClick={onAddField}
>
<i className='mr-2 h-5 w-5'>
<AddSvg />
</i>
<span>{t('grid.field.newProperty')}</span>
</div>
</th>
</tr>
</thead>
</>
<div className={'flex select-none text-xs'} style={{ userSelect: 'none' }}>
<div className={'w-7 flex-shrink-0'}></div>
{columns
.filter((column) => column.visible)
.map((column, i) => {
return (
<GridTableHeaderItem
onShowFilterClick={onShowFilterClick}
onShowSortClick={onShowSortClick}
field={fields[column.fieldId]}
controller={controller}
key={i}
index={i}
/>
);
})}
<div
onClick={onAddField}
className='-ml-1.5 flex w-40 flex-shrink-0 cursor-pointer items-center border-b border-t border-line-divider px-4 py-2 text-text-caption hover:bg-fill-list-hover hover:text-text-title'
>
<i className='mr-2 h-5 w-5'>
<AddSvg />
</i>
<span className={'whitespace-nowrap'}>{t('grid.field.newProperty')}</span>
</div>
</div>
);
};

View File

@ -2,45 +2,59 @@ import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell
import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller';
import { TypeOptionController } from '@/appflowy_app/stores/effects/database/field/type_option/type_option_controller';
import { FieldType } from '@/services/backend';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Some } from 'ts-results';
import { ChangeFieldTypePopup } from '../../_shared/EditRow/ChangeFieldTypePopup';
import { EditFieldPopup } from '../../_shared/EditRow/EditFieldPopup';
import { ChecklistTypeSvg } from '../../_shared/svg/ChecklistTypeSvg';
import { DateTypeSvg } from '../../_shared/svg/DateTypeSvg';
import { MultiSelectTypeSvg } from '../../_shared/svg/MultiSelectTypeSvg';
import { NumberTypeSvg } from '../../_shared/svg/NumberTypeSvg';
import { SingleSelectTypeSvg } from '../../_shared/svg/SingleSelectTypeSvg';
import { TextTypeSvg } from '../../_shared/svg/TextTypeSvg';
import { UrlTypeSvg } from '../../_shared/svg/UrlTypeSvg';
import { databaseActions, IDatabaseField } from '$app_reducers/database/slice';
import { FieldTypeIcon } from '$app/components/_shared/EditRow/FieldTypeIcon';
import { useResizer } from '$app/components/_shared/useResizer';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { Details2Svg } from '$app/components/_shared/svg/Details2Svg';
import { FilterSvg } from '$app/components/_shared/svg/FilterSvg';
import { SortAscSvg } from '$app/components/_shared/svg/SortAscSvg';
import { PromptWindow } from '$app/components/_shared/PromptWindow';
const MIN_COLUMN_WIDTH = 100;
export const GridTableHeaderItem = ({
controller,
field,
index,
onShowFilterClick,
onShowSortClick,
}: {
controller: DatabaseController;
field: {
fieldId: string;
name: string;
fieldType: FieldType;
};
field: IDatabaseField;
index: number;
onShowFilterClick: () => void;
onShowSortClick: () => void;
}) => {
const { onMouseDown, newSizeX } = useResizer((final) => {
if (final < MIN_COLUMN_WIDTH) return;
void controller.changeWidth({ fieldId: field.fieldId, width: final });
});
const filtersStore = useAppSelector((state) => state.database.filters);
const sortStore = useAppSelector((state) => state.database.sort);
const dispatch = useAppDispatch();
const [showFieldEditor, setShowFieldEditor] = useState(false);
const [editFieldTop, setEditFieldTop] = useState(0);
const [editFieldRight, setEditFieldRight] = useState(0);
const [showChangeFieldTypePopup, setShowChangeFieldTypePopup] = useState(false);
const [changeFieldTypeTop, setChangeFieldTypeTop] = useState(0);
const [changeFieldTypeRight, setChangeFieldTypeRight] = useState(0);
const [editingField, setEditingField] = useState<{
fieldId: string;
name: string;
fieldType: FieldType;
} | null>(null);
const [changeFieldTypeAnchorEl, setChangeFieldTypeAnchorEl] = useState<HTMLDivElement | null>(null);
const [editingField, setEditingField] = useState<IDatabaseField | null>(null);
const [deletingPropertyId, setDeletingPropertyId] = useState<string | null>(null);
const [showDeletePropertyPrompt, setShowDeletePropertyPrompt] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!newSizeX) return;
if (newSizeX >= MIN_COLUMN_WIDTH) {
dispatch(databaseActions.changeWidth({ fieldId: field.fieldId, width: newSizeX }));
}
}, [newSizeX]);
const changeFieldType = async (newType: FieldType) => {
if (!editingField) return;
@ -60,66 +74,115 @@ export const GridTableHeaderItem = ({
setShowChangeFieldTypePopup(false);
};
const onFieldOptionsClick = () => {
setEditingField(field);
setShowFieldEditor(true);
};
const onDeletePropertyClick = (fieldId: string) => {
setDeletingPropertyId(fieldId);
setShowDeletePropertyPrompt(true);
};
const onDelete = async () => {
if (!deletingPropertyId) return;
const fieldInfo = controller.fieldController.getField(deletingPropertyId);
if (!fieldInfo) return;
const typeController = new TypeOptionController(controller.viewId, Some(fieldInfo));
setEditingField(null);
await typeController.initialize();
await typeController.deleteField();
setShowDeletePropertyPrompt(false);
};
return (
<th key={field.fieldId} className='m-0 border border-l-0 border-line-divider p-0'>
<>
<div
className={'flex w-full cursor-pointer items-center px-4 py-2 hover:bg-fill-list-hover'}
ref={ref}
onClick={() => {
if (!ref.current) return;
const { top, left } = ref.current.getBoundingClientRect();
setEditFieldRight(left - 10);
setEditFieldTop(top + 35);
setEditingField(field);
setShowFieldEditor(true);
}}
// field width minus divider width with padding
style={{ width: `${field.width - (index === 0 ? 7 : 14)}px` }}
className='flex-shrink-0 border-b border-t border-line-divider'
>
<i className={'mr-2 h-5 w-5 text-text-caption'}>
{field.fieldType === FieldType.RichText && <TextTypeSvg></TextTypeSvg>}
{field.fieldType === FieldType.Number && <NumberTypeSvg></NumberTypeSvg>}
{field.fieldType === FieldType.DateTime && <DateTypeSvg></DateTypeSvg>}
{field.fieldType === FieldType.SingleSelect && <SingleSelectTypeSvg></SingleSelectTypeSvg>}
{field.fieldType === FieldType.MultiSelect && <MultiSelectTypeSvg></MultiSelectTypeSvg>}
{field.fieldType === FieldType.Checklist && <ChecklistTypeSvg></ChecklistTypeSvg>}
{field.fieldType === FieldType.Checkbox && <ChecklistTypeSvg></ChecklistTypeSvg>}
{field.fieldType === FieldType.URL && <UrlTypeSvg></UrlTypeSvg>}
</i>
<span>{field.name}</span>
<div className={'flex w-full items-center justify-between py-2 pl-2'} ref={ref}>
<div className={'flex min-w-0 items-center gap-2'}>
<div className={'flex h-5 w-5 flex-shrink-0 items-center justify-center text-text-caption'}>
<FieldTypeIcon fieldType={field.fieldType}></FieldTypeIcon>
</div>
<span className={'overflow-hidden text-ellipsis whitespace-nowrap text-text-caption'}>{field.title}</span>
</div>
<div className={'flex items-center gap-1'}>
{sortStore.findIndex((sort) => sort.fieldId === field.fieldId) !== -1 && (
<button onClick={onShowSortClick} className={'rounded p-1 hover:bg-fill-list-hover'}>
<i className={'block h-[16px] w-[16px]'}>
<SortAscSvg></SortAscSvg>
</i>
</button>
)}
{showFieldEditor && editingField && (
<EditFieldPopup
top={editFieldTop}
left={editFieldRight}
cellIdentifier={
{
fieldId: editingField.fieldId,
fieldType: editingField.fieldType,
viewId: controller.viewId,
} as CellIdentifier
}
viewId={controller.viewId}
onOutsideClick={() => {
setShowFieldEditor(false);
}}
fieldInfo={controller.fieldController.getField(editingField.fieldId)}
changeFieldTypeClick={(buttonTop, buttonRight) => {
setChangeFieldTypeTop(buttonTop);
setChangeFieldTypeRight(buttonRight);
setShowChangeFieldTypePopup(true);
}}
></EditFieldPopup>
)}
{filtersStore.findIndex((filter) => filter.fieldId === field.fieldId) !== -1 && (
<button onClick={onShowFilterClick} className={'rounded p-1 hover:bg-fill-list-hover'}>
<i className={'block h-[16px] w-[16px]'}>
<FilterSvg></FilterSvg>
</i>
</button>
)}
{showChangeFieldTypePopup && (
<ChangeFieldTypePopup
top={changeFieldTypeTop}
left={changeFieldTypeRight}
onClick={(newType) => changeFieldType(newType)}
onOutsideClick={() => setShowChangeFieldTypePopup(false)}
></ChangeFieldTypePopup>
)}
<button className={'rounded p-1 hover:bg-fill-list-hover'} onClick={() => onFieldOptionsClick()}>
<i className={'block h-[16px] w-[16px]'}>
<Details2Svg></Details2Svg>
</i>
</button>
</div>
</div>
</div>
</th>
<div
className={'group h-full cursor-col-resize border-b border-t border-line-divider px-[6px]'}
onMouseDown={(e) => onMouseDown(e, field.width)}
>
<div className={'flex h-full w-[3px] justify-center group-hover:bg-fill-hover'}>
<div className={'h-full w-[1px] bg-line-divider group-hover:bg-fill-hover'}></div>
</div>
</div>
{editingField && (
<EditFieldPopup
open={showFieldEditor}
anchorEl={ref.current}
cellIdentifier={
{
fieldId: editingField.fieldId,
fieldType: editingField.fieldType,
viewId: controller.viewId,
} as CellIdentifier
}
viewId={controller.viewId}
onOutsideClick={() => {
setShowFieldEditor(false);
}}
controller={controller}
changeFieldTypeClick={(el) => {
setChangeFieldTypeAnchorEl(el);
setShowChangeFieldTypePopup(true);
}}
onDeletePropertyClick={onDeletePropertyClick}
></EditFieldPopup>
)}
<ChangeFieldTypePopup
open={showChangeFieldTypePopup}
anchorEl={changeFieldTypeAnchorEl}
onClick={(newType) => changeFieldType(newType)}
onOutsideClick={() => setShowChangeFieldTypePopup(false)}
></ChangeFieldTypePopup>
{showDeletePropertyPrompt && (
<PromptWindow
msg={'Are you sure you want to delete this property?'}
onYes={() => onDelete()}
onCancel={() => setShowDeletePropertyPrompt(false)}
></PromptWindow>
)}
</>
);
};

View File

@ -1,11 +0,0 @@
import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller';
export const useGridAddRow = (controller: DatabaseController) => {
async function addRow() {
await controller.createRow();
}
return {
addRow,
};
};

View File

@ -1,20 +0,0 @@
import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller';
import AddSvg from '../../_shared/svg/AddSvg';
import { useGridAddRow } from './GridAddRow.hooks';
import { useTranslation } from 'react-i18next';
export const GridAddRow = ({ controller }: { controller: DatabaseController }) => {
const { addRow } = useGridAddRow(controller);
const { t } = useTranslation();
return (
<div>
<button className='flex cursor-pointer items-center text-text-caption hover:text-text-title' onClick={addRow}>
<i className='mr-2 h-5 w-5'>
<AddSvg />
</i>
<span>{t('grid.row.newRow')}</span>
</button>
</div>
);
};

View File

@ -0,0 +1,21 @@
import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller';
export const useGridRowActions = (controller: DatabaseController) => {
const deleteRow = async (rowId: string) => {
await controller.deleteRow(rowId);
};
const insertRowAfter = async (rowId: string) => {
await controller.createRowAfter(rowId);
};
const duplicateRow = async (rowId: string) => {
await controller.duplicateRow(rowId);
};
return {
deleteRow,
insertRowAfter,
duplicateRow,
};
};

View File

@ -0,0 +1,106 @@
import AddSvg from '../../_shared/svg/AddSvg';
import { CopySvg } from '../../_shared/svg/CopySvg';
import { TrashSvg } from '../../_shared/svg/TrashSvg';
import { ShareSvg } from '../../_shared/svg/ShareSvg';
import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller';
import { useGridRowActions } from './GridRowActions.hooks';
import { List, Popover } from '@mui/material';
import MenuItem from '@mui/material/MenuItem';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const GridRowActions = ({
controller,
rowId,
isDragging,
children,
}: {
controller: DatabaseController;
rowId: string;
isDragging: boolean;
children: React.ReactNode;
}) => {
const { deleteRow, duplicateRow, insertRowAfter } = useGridRowActions(controller);
const optionsButtonEl = useRef<HTMLButtonElement>(null);
const [showMenu, setShowMenu] = useState(false);
const { t } = useTranslation();
return (
<>
<div className={'flex flex-shrink-0 items-center justify-center'}>
<button
ref={optionsButtonEl}
onClick={() => setShowMenu(true)}
className={`cursor-pointer items-center justify-center rounded p-1 opacity-0 hover:bg-fill-list-hover group-hover/row:opacity-100 ${
isDragging || showMenu ? '!opacity-100' : ''
}`}
>
{children}
</button>
</div>
<Popover
open={showMenu}
anchorEl={optionsButtonEl.current}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
onClose={() => setShowMenu(false)}
>
<List>
<MenuItem
onClick={() => {
void insertRowAfter(rowId);
setShowMenu(false);
}}
>
<span className={'mr-2'}>
<i className={'block h-[16px] w-[16px]'}>
<AddSvg />
</i>
</span>
<span>{t('button.insertBelow')}</span>
</MenuItem>
<MenuItem onClick={() => console.log('copy link')}>
<span className={'mr-2'}>
<i className={'block h-[16px] w-[16px]'}>
<ShareSvg />
</i>
</span>
<span>{t('shareAction.copyLink')}</span>
</MenuItem>
<MenuItem
onClick={() => {
void duplicateRow(rowId);
setShowMenu(false);
}}
>
<span className={'mr-2'}>
<i className={'block h-[16px] w-[16px]'}>
<CopySvg />
</i>
</span>
<span>{t('grid.row.duplicate')}</span>
</MenuItem>
<MenuItem
onClick={() => {
void deleteRow(rowId);
setShowMenu(false);
}}
>
<span className={'mr-2'}>
<i className={'block h-[16px] w-[16px]'}>
<TrashSvg />
</i>
</span>
<span>{t('grid.row.delete')}</span>
</MenuItem>
</List>
</Popover>
</>
);
};

View File

@ -3,44 +3,87 @@ import { RowInfo } from '@/appflowy_app/stores/effects/database/row/row_cache';
import { useRow } from '../../_shared/database-hooks/useRow';
import { FullView } from '../../_shared/svg/FullView';
import { GridCell } from '../GridCell/GridCell';
import { DragSvg } from '../../_shared/svg/DragSvg';
import { Draggable, DraggableProvided, DraggableStateSnapshot } from 'react-beautiful-dnd';
import { GridRowActions } from './GridRowActions';
import { useAppSelector } from '$app/stores/store';
export const GridTableRow = ({
viewId,
controller,
row,
onOpenRow,
index,
}: {
viewId: string;
controller: DatabaseController;
row: RowInfo;
onOpenRow: (rowId: RowInfo) => void;
index: number;
}) => {
const { cells } = useRow(viewId, controller, row);
const fields = useAppSelector((state) => state.database.fields);
return (
<tr className='group'>
{cells.map((cell, cellIndex) => {
return (
<td className='m-0 border border-l-0 border-line-divider p-0 ' key={cellIndex}>
<div className='flex w-full items-center justify-end'>
<GridCell
cellIdentifier={cell.cellIdentifier}
cellCache={controller.databaseViewCache.getRowCache().getCellCache()}
fieldController={controller.fieldController}
/>
// this is needed to prevent DnD from causing exceptions
cells.length ? (
<Draggable draggableId={row.row.id} key={row.row.id} index={index}>
{(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`group/row flex cursor-pointer items-stretch ${snapshot.isDragging ? 'shadow-md' : ''}`}
>
<GridRowActions controller={controller} rowId={row.row.id} isDragging={snapshot.isDragging}>
<i className={`block h-5 w-5`} {...provided.dragHandleProps}>
<DragSvg />
</i>
</GridRowActions>
{cells
// filter out hidden fields
// ?? true is to prevent DnD from causing exceptions
.filter((cell) => fields[cell.fieldId]?.visible ?? true)
.map((cell, cellIndex) => {
return (
<div
className={`group/cell relative flex flex-shrink-0 border-b border-line-divider bg-bg-body ${
snapshot.isDragging ? 'border-t' : ''
}`}
key={cellIndex}
draggable={false}
>
<GridCell
width={fields[cell.fieldId]?.width}
cellIdentifier={cell.cellIdentifier}
cellCache={controller.databaseViewCache.getRowCache().getCellCache()}
fieldController={controller.fieldController}
/>
{cellIndex === 0 && (
<div
onClick={() => onOpenRow(row)}
className='mr-1 hidden h-8 w-8 cursor-pointer rounded p-1.5 text-text-caption hover:bg-fill-list-hover group-hover:block '
>
<FullView />
</div>
)}
</div>
</td>
);
})}
</tr>
{cellIndex === 0 && (
<div
onClick={() => onOpenRow(row)}
className='absolute inset-y-0 right-0 my-auto mr-1 hidden flex-shrink-0 cursor-pointer items-center justify-center rounded p-1 hover:bg-fill-list-hover group-hover/cell:flex'
>
<i className={' block h-5 w-5'}>
<FullView />
</i>
</div>
)}
<div className={'flex h-full justify-center'}>
<div className={'h-full w-[1px] bg-line-divider'}></div>
</div>
</div>
);
})}
<div
className={`-ml-1.5 w-40 border-b border-line-divider bg-bg-body ${snapshot.isDragging ? 'border-t' : ''}`}
></div>
</div>
)}
</Draggable>
) : (
<></>
)
);
};

View File

@ -1,6 +1,8 @@
import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller';
import { RowInfo } from '@/appflowy_app/stores/effects/database/row/row_cache';
import { GridTableRow } from './GridTableRow';
import { DragDropContext, Droppable, DroppableProvided, OnDragEndResponder } from 'react-beautiful-dnd';
export const GridTableRows = ({
viewId,
controller,
@ -12,11 +14,37 @@ export const GridTableRows = ({
allRows: readonly RowInfo[];
onOpenRow: (rowId: RowInfo) => void;
}) => {
const onRowsDragEnd: OnDragEndResponder = async (result) => {
if (!result.destination) return;
if (result.destination.index === result.source.index) return;
await controller.moveRow(result.draggableId, allRows[result.destination.index].row.id);
};
return (
<tbody>
{allRows.map((row, i) => {
return <GridTableRow onOpenRow={onOpenRow} row={row} key={i} viewId={viewId} controller={controller} />;
})}
</tbody>
<DragDropContext onDragEnd={onRowsDragEnd}>
<Droppable droppableId='table'>
{(droppableProvided: DroppableProvided) => (
<div
className={'absolute h-full overflow-y-auto overflow-x-hidden'}
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
{allRows.map((row, i) => {
return (
<GridTableRow
onOpenRow={onOpenRow}
row={row}
key={i}
index={i}
viewId={viewId}
controller={controller}
/>
);
})}
{droppableProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
};

View File

@ -1,28 +0,0 @@
import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
import { useState } from 'react';
export const useGridTitleHooks = function () {
const dispatch = useAppDispatch();
const grid = useAppSelector((state) => state.grid);
const [title, setTitle] = useState(grid.title);
const [changingTitle, setChangingTitle] = useState(false);
const [showOptions, setShowOptions] = useState(false);
const onTitleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setTitle(event.target.value);
};
const onTitleClick = () => {
setChangingTitle(true);
};
return {
title,
onTitleChange,
onTitleClick,
changingTitle,
showOptions,
setShowOptions,
};
};

View File

@ -1,20 +1,35 @@
import { useGridTitleHooks } from './GridTitle.hooks';
import { SettingsSvg } from '../../_shared/svg/SettingsSvg';
import { GridTitleOptionsPopup } from './GridTitleOptionsPopup';
import { useState } from 'react';
import { useAppSelector } from '$app/stores/store';
export const GridTitle = () => {
const { title, showOptions, setShowOptions } = useGridTitleHooks();
export const GridTitle = ({
onShowFilterClick,
onShowSortClick,
viewId,
}: {
onShowFilterClick: () => void;
onShowSortClick: () => void;
viewId: string;
}) => {
const [showOptions, setShowOptions] = useState(false);
const pagesStore = useAppSelector((state) => state.pages.pageMap[viewId]);
return (
<div className={'relative flex items-center '}>
<div>{title}</div>
<div className='flex '>
<div>{pagesStore?.name}</div>
<button className={'ml-2 h-5 w-5 '} onClick={() => setShowOptions(!showOptions)}>
<SettingsSvg></SettingsSvg>
</button>
{showOptions && <GridTitleOptionsPopup onClose={() => setShowOptions(!showOptions)} />}
{showOptions && (
<GridTitleOptionsPopup
onClose={() => setShowOptions(!showOptions)}
onFilterClick={() => onShowFilterClick()}
onSortClick={() => onShowSortClick()}
/>
)}
</div>
</div>
);

View File

@ -4,33 +4,43 @@ import { GroupBySvg } from '../../_shared/svg/GroupBySvg';
import { PropertiesSvg } from '../../_shared/svg/PropertiesSvg';
import { SortSvg } from '../../_shared/svg/SortSvg';
export const GridTitleOptionsPopup = ({ onClose }: { onClose?: () => void }) => {
export const GridTitleOptionsPopup = ({
onClose,
onFilterClick,
onSortClick,
}: {
onClose?: () => void;
onFilterClick: () => void;
onSortClick: () => void;
}) => {
const items: IPopupItem[] = [
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<i className={'h-[16px] w-[16px] flex-shrink-0 text-text-title'}>
<FilterSvg />
</i>
),
onClick: () => {
console.log('filter');
onFilterClick && onFilterClick();
onClose && onClose();
},
title: 'Filter',
},
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<i className={'h-[16px] w-[16px] flex-shrink-0 text-text-title'}>
<SortSvg />
</i>
),
onClick: () => {
console.log('sort');
onSortClick && onSortClick();
onClose && onClose();
},
title: 'Sort',
title: 'Sort By',
},
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<i className={'h-[16px] w-[16px] flex-shrink-0 text-text-title'}>
<PropertiesSvg />
</i>
),
@ -41,7 +51,7 @@ export const GridTitleOptionsPopup = ({ onClose }: { onClose?: () => void }) =>
},
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<i className={'h-[16px] w-[16px] flex-shrink-0 text-text-title'}>
<GroupBySvg />
</i>
),
@ -52,5 +62,5 @@ export const GridTitleOptionsPopup = ({ onClose }: { onClose?: () => void }) =>
},
];
return <PopupSelect items={items} className={'absolute top-full z-10 w-fit'} onOutsideClick={onClose} />;
return <PopupSelect items={items} className={'absolute top-full z-10 w-[140px]'} onOutsideClick={onClose} />;
};

View File

@ -39,6 +39,13 @@ import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import { UrlTypeSvg } from '$app/components/_shared/svg/UrlTypeSvg';
import { DragElementSvg } from '$app/components/_shared/svg/DragElementSvg';
import { ImageSvg } from '$app/components/_shared/svg/ImageSvg';
import { DragSvg } from '$app/components/_shared/svg/DragSvg';
import { FullView } from '$app/components/_shared/svg/FullView';
import { GroupBySvg } from '$app/components/_shared/svg/GroupBySvg';
import { SettingsSvg } from '$app/components/_shared/svg/SettingsSvg';
import { ShareSvg } from '$app/components/_shared/svg/ShareSvg';
import { SortAscSvg } from '$app/components/_shared/svg/SortAscSvg';
import { SortDescSvg } from '$app/components/_shared/svg/SortDescSvg';
export const AllIcons = () => {
return (
@ -88,6 +95,9 @@ export const AllIcons = () => {
<i className={'h-5 w-5'} title={'DragElementSvg'}>
<DragElementSvg></DragElementSvg>
</i>
<i className={'h-5 w-5'} title={'DragSvg'}>
<DragSvg></DragSvg>
</i>
<i className={'h-5 w-5'} title={'DropDownShowSvg'}>
<DropDownShowSvg></DropDownShowSvg>
</i>
@ -112,12 +122,18 @@ export const AllIcons = () => {
<i className={'h-5 w-5'} title={'FilterSvg'}>
<FilterSvg></FilterSvg>
</i>
<i className={'h-5 w-5'} title={'FullView'}>
<FullView></FullView>
</i>
<i className={'h-5 w-5'} title={'GridSvg'}>
<GridSvg></GridSvg>
</i>
<i className={'h-5 w-5'} title={'GroupByFieldSvg'}>
<GroupByFieldSvg></GroupByFieldSvg>
</i>
<i className={'h-5 w-5'} title={'GroupBySvg'}>
<GroupBySvg></GroupBySvg>
</i>
<i className={'h-5 w-5'} title={'HideMenuSvg'}>
<HideMenuSvg></HideMenuSvg>
</i>
@ -145,6 +161,12 @@ export const AllIcons = () => {
<i className={'h-5 w-5'} title={'SearchSvg'}>
<SearchSvg></SearchSvg>
</i>
<i className={'h-5 w-5'} title={'SettingsSvg'}>
<SettingsSvg></SettingsSvg>
</i>
<i className={'h-5 w-5'} title={'ShareSvg'}>
<ShareSvg></ShareSvg>
</i>
<i className={'h-5 w-5'} title={'ShowMenuSvg'}>
<ShowMenuSvg></ShowMenuSvg>
</i>
@ -157,6 +179,12 @@ export const AllIcons = () => {
<i className={'h-5 w-5'} title={'SkipRightSvg'}>
<SkipRightSvg></SkipRightSvg>
</i>
<i className={'h-5 w-5'} title={'SortAscSvg'}>
<SortAscSvg></SortAscSvg>
</i>
<i className={'h-5 w-5'} title={'SortDescSvg'}>
<SortDescSvg></SortDescSvg>
</i>
<i className={'h-5 w-5'} title={'SortSvg'}>
<SortSvg></SortSvg>
</i>

View File

@ -4,9 +4,9 @@ export const ColorPalette = () => {
<h1 className={'mb-4 text-2xl'}>Colors</h1>
<h2 className={'mb-4'}>Main</h2>
<div className={'mb-8 flex flex-wrap items-center'}>
<div title={'main-accent'} className={'m-2 h-[100px] w-[100px] bg-fill-default'}></div>
<div title={'fill-hover'} className={'m-2 h-[100px] w-[100px] bg-fill-default'}></div>
<div title={'main-hovered'} className={'m-2 h-[100px] w-[100px] bg-fill-list-hover'}></div>
<div title={'main-secondary'} className={'m-2 h-[100px] w-[100px] bg-fill-list-hover'}></div>
<div title={'fill-list-hover'} className={'m-2 h-[100px] w-[100px] bg-fill-list-hover'}></div>
<div title={'main-selector'} className={'m-2 h-[100px] w-[100px] bg-fill-selector'}></div>
<div title={'main-alert'} className={'m-2 h-[100px] w-[100px] bg-function-info'}></div>
<div title={'main-warning'} className={'m-2 h-[100px] w-[100px] bg-function-warning'}></div>
@ -31,7 +31,7 @@ export const ColorPalette = () => {
<div title={'shade-3'} className={'bg-shade-3 m-2 h-[100px] w-[100px]'}></div>
<div title={'shade-4'} className={'bg-shade-4 m-2 h-[100px] w-[100px]'}></div>
<div title={'shade-5'} className={'bg-shade-5 m-2 h-[100px] w-[100px]'}></div>
<div title={'shade-6'} className={'bg-shade-6 m-2 h-[100px] w-[100px]'}></div>
<div title={'line-divider'} className={'m-2 h-[100px] w-[100px] bg-line-divider'}></div>
</div>
<h2 className={'mb-4'}>Surface</h2>
<div className={'mb-8 flex flex-wrap items-center'}>

View File

@ -51,19 +51,19 @@ export class SelectOptionCellBackendService {
const payload = RepeatedSelectOptionPayload.fromObject({
view_id: this.cellIdentifier.viewId,
field_id: this.cellIdentifier.fieldId,
row_id: this.cellIdentifier.rowId,
items: [option],
});
payload.items.push(option);
return DatabaseEventInsertOrUpdateSelectOption(payload);
};
updateOption = (option: SelectOptionPB) => {
updateOption = async (option: SelectOptionPB) => {
const payload = RepeatedSelectOptionPayload.fromObject({
view_id: this.cellIdentifier.viewId,
field_id: this.cellIdentifier.fieldId,
row_id: this.cellIdentifier.rowId,
items: [option],
});
payload.items.push(option);
return DatabaseEventInsertOrUpdateSelectOption(payload);
};

View File

@ -11,13 +11,17 @@ import {
DatabaseEventMoveGroup,
DatabaseEventMoveGroupRow,
DatabaseEventMoveRow,
DatabaseEventUpdateField,
DatabaseGroupIdPB,
FieldChangesetPB,
MoveFieldPayloadPB,
MoveGroupPayloadPB,
MoveGroupRowPayloadPB,
MoveRowPayloadPB,
RowIdPB,
DatabaseEventUpdateDatabaseSetting,
DuplicateFieldPayloadPB,
DatabaseEventDuplicateField,
} from '@/services/backend/events/flowy-database2';
import {
GetFieldPayloadPB,
@ -28,6 +32,8 @@ import {
ViewIdPB,
} from '@/services/backend';
import { FolderEventCloseView } from '@/services/backend/events/flowy-folder2';
import { TypeOptionController } from '$app/stores/effects/database/field/type_option/type_option_controller';
import { None } from 'ts-results';
/// A service that wraps the backend service
export class DatabaseBackendService {
@ -84,6 +90,11 @@ export class DatabaseBackendService {
return DatabaseEventDeleteRow(payload);
};
moveRow = async (fromRowId: string, toRowId: string) => {
const payload = MoveRowPayloadPB.fromObject({ view_id: this.viewId, from_row_id: fromRowId, to_row_id: toRowId });
return DatabaseEventMoveRow(payload);
};
/// Move the row from one group to another group
/// [toRowId] is used to locate the moving row location.
moveGroupRow = (fromRowId: string, toGroupId: string, toRowId?: string) => {
@ -139,6 +150,24 @@ export class DatabaseBackendService {
return DatabaseEventMoveField(payload);
};
changeWidth = (params: { fieldId: string; width: number }) => {
const payload = FieldChangesetPB.fromObject({ view_id: this.viewId, field_id: params.fieldId, width: params.width });
return DatabaseEventUpdateField(payload);
};
duplicateField = (fieldId: string) => {
const payload = DuplicateFieldPayloadPB.fromObject({ view_id: this.viewId, field_id: fieldId });
return DatabaseEventDuplicateField(payload);
};
createField = async () => {
const fieldController = new TypeOptionController(this.viewId, None);
await fieldController.initialize();
};
/// Get all groups in database
/// It should only call once after the board open
loadGroups = () => {

View File

@ -8,11 +8,17 @@ import { DatabaseGroupController } from './group/group_controller';
import { BehaviorSubject } from 'rxjs';
import { DatabaseGroupObserver } from './group/group_observer';
import { Log } from '$app/utils/log';
import { FilterController } from '$app/stores/effects/database/filter/filter_controller';
import { FilterParsed } from '$app/stores/effects/database/filter/filter_bd_svc';
import { SortController } from '$app/stores/effects/database/sort/sort_controller';
import { IDatabaseSort } from '$app_reducers/database/slice';
export type DatabaseSubscriberCallbacks = {
onViewChanged?: (data: DatabasePB) => void;
onRowsChanged?: (rowInfos: readonly RowInfo[], reason: RowChangedReason) => void;
onFieldsChanged?: (fieldInfos: readonly FieldInfo[]) => void;
onFiltersChanged?: (filters: readonly FilterParsed[]) => void;
onSortChanged?: (sorts: readonly IDatabaseSort[]) => void;
onGroupByField?: (groups: GroupPB[]) => void;
onNumOfGroupChanged?: {
@ -25,6 +31,8 @@ export type DatabaseSubscriberCallbacks = {
export class DatabaseController {
private readonly backendService: DatabaseBackendService;
fieldController: FieldController;
sortController: SortController;
filterController: FilterController;
databaseViewCache: DatabaseViewCache;
private _callback?: DatabaseSubscriberCallbacks;
public groups: BehaviorSubject<DatabaseGroupController[]>;
@ -33,6 +41,8 @@ export class DatabaseController {
constructor(public readonly viewId: string) {
this.backendService = new DatabaseBackendService(viewId);
this.fieldController = new FieldController(viewId);
this.filterController = new FilterController(viewId);
this.sortController = new SortController(viewId);
this.databaseViewCache = new DatabaseViewCache(viewId, this.fieldController);
this.groups = new BehaviorSubject<DatabaseGroupController[]>([]);
this.groupsObserver = new DatabaseGroupObserver(viewId);
@ -41,6 +51,8 @@ export class DatabaseController {
subscribe = (callbacks: DatabaseSubscriberCallbacks) => {
this._callback = callbacks;
this.fieldController.subscribe({ onNumOfFieldsChanged: callbacks.onFieldsChanged });
this.filterController.subscribe({ onFiltersChanged: callbacks.onFiltersChanged });
this.sortController.subscribe({ onSortChanged: callbacks.onSortChanged });
this.databaseViewCache.getRowCache().subscribe({
onRowsChanged: (reason) => {
this._callback?.onRowsChanged?.(this.databaseViewCache.rowInfos, reason);
@ -50,10 +62,14 @@ export class DatabaseController {
open = async () => {
const openDatabaseResult = await this.backendService.openDatabase();
if (openDatabaseResult.ok) {
const database: DatabasePB = openDatabaseResult.val;
await this.databaseViewCache.initialize();
await this.fieldController.initialize();
await this.filterController.initialize();
await this.sortController.initialize();
// subscriptions
await this.subscribeOnGroupsChanged();
@ -73,12 +89,15 @@ export class DatabaseController {
getGroupByFieldId = async () => {
const settingsResult = await this.backendService.getSettings();
if (settingsResult.ok) {
const settings = settingsResult.val;
const groupConfig = settings.group_settings.items;
if (groupConfig.length === 0) {
return Err(new FlowyError({ msg: 'this database has no groups' }));
}
return Ok(settings.group_settings.items[0].field_id);
} else {
return Err(settingsResult.val);
@ -89,6 +108,10 @@ export class DatabaseController {
return this.backendService.createRow();
};
createRowAfter = (rowId: string) => {
return this.backendService.createRow({ rowId });
};
duplicateRow = async (rowId: string) => {
return this.backendService.duplicateRow(rowId);
};
@ -97,6 +120,10 @@ export class DatabaseController {
return this.backendService.deleteRow(rowId);
};
moveRow = (fromRowId: string, toRowId: string) => {
return this.backendService.moveRow(fromRowId, toRowId);
};
moveGroupRow = (rowId: string, groupId: string) => {
return this.backendService.moveGroupRow(rowId, groupId);
};
@ -114,12 +141,51 @@ export class DatabaseController {
return this.backendService.moveField(params);
};
changeWidth = (params: { fieldId: string; width: number }) => {
return this.backendService.changeWidth(params);
};
duplicateField = (fieldId: string) => {
return this.backendService.duplicateField(fieldId);
};
addFieldToLeft = async (fieldId: string) => {
const index = this.fieldController.fieldInfos.findIndex((fieldInfo) => fieldInfo.field.id === fieldId);
await this.backendService.createField();
const newFieldId = this.fieldController.fieldInfos[this.fieldController.fieldInfos.length - 1].field.id;
await this.moveField({
fieldId: newFieldId,
fromIndex: this.fieldController.fieldInfos.length - 1,
toIndex: index,
});
};
addFieldToRight = async (fieldId: string) => {
const index = this.fieldController.fieldInfos.findIndex((fieldInfo) => fieldInfo.field.id === fieldId);
await this.backendService.createField();
const newFieldId = this.fieldController.fieldInfos[this.fieldController.fieldInfos.length - 1].field.id;
await this.moveField({
fieldId: newFieldId,
fromIndex: this.fieldController.fieldInfos.length - 1,
toIndex: index + 1,
});
};
private loadGroup = async () => {
const result = await this.backendService.loadGroups();
if (result.ok) {
const groups = result.val.items;
await this.initialGroups(groups);
}
return result;
};
@ -129,11 +195,14 @@ export class DatabaseController {
});
const controllers: DatabaseGroupController[] = [];
for (const groupPB of groups) {
const controller = new DatabaseGroupController(groupPB, this.backendService);
await controller.initialize();
controllers.push(controller);
}
this.groups.next(controllers);
this.groups.value;
};
@ -150,14 +219,17 @@ export class DatabaseController {
Log.error(result.val);
return;
}
const changeset = result.val;
let existControllers = [...this.groups.getValue()];
for (const deleteId of changeset.deleted_groups) {
existControllers = existControllers.filter((c) => c.groupId !== deleteId);
}
for (const update of changeset.update_groups) {
const index = existControllers.findIndex((c) => c.groupId === update.group_id);
if (index !== -1) {
existControllers[index].updateGroup(update);
}
@ -165,12 +237,14 @@ export class DatabaseController {
for (const insert of changeset.inserted_groups) {
const controller = new DatabaseGroupController(insert.group, this.backendService);
if (insert.index > existControllers.length) {
existControllers.push(controller);
} else {
existControllers.splice(insert.index, 0, controller);
}
}
this.groups.next(existControllers);
},
});
@ -183,6 +257,7 @@ export class DatabaseController {
await this.groupsObserver.unsubscribe();
await this.backendService.closeDatabase();
await this.fieldController.dispose();
this.filterController.dispose();
await this.databaseViewCache.dispose();
};
}

View File

@ -89,6 +89,14 @@ export class TypeOptionController {
}
};
changeWidth = async (width: number) => {
if (this.fieldBackendSvc) {
void this.fieldBackendSvc.updateField({ width: width });
} else {
throw Error('Unexpected empty field backend service');
}
};
saveTypeOption = async (data: Uint8Array) => {
if (this.typeOptionData.some) {
this.typeOptionData.val.type_option_data = data;

View File

@ -0,0 +1,127 @@
import {
CheckboxFilterPB,
DatabaseSettingChangesetPB,
DatabaseViewIdPB,
DeleteFilterPayloadPB,
FieldType,
FilterPB,
FlowyError,
SelectOptionFilterPB,
TextFilterPB,
UpdateFilterPayloadPB,
} from '@/services/backend';
import {
DatabaseEventGetAllFilters,
DatabaseEventUpdateDatabaseSetting,
} from '@/services/backend/events/flowy-database2';
import { Err, Ok, Result } from 'ts-results';
import { nanoid } from 'nanoid';
export class FilterBackendService {
constructor(public readonly viewId: string) {}
getFilters = async (): Promise<Result<FilterParsed[], FlowyError>> => {
const payload = DatabaseViewIdPB.fromObject({
value: this.viewId,
});
const res = await DatabaseEventGetAllFilters(payload);
if (res.ok) {
return Ok(res.val.items.map<FilterParsed>((f) => new FilterParsed(this.viewId, f)));
} else {
return Err(res.val);
}
};
addFilter = async (
fieldId: string,
fieldType: FieldType,
filter: TextFilterPB | SelectOptionFilterPB | CheckboxFilterPB
) => {
const data = filter.serializeBinary();
const id = nanoid(4);
await DatabaseEventUpdateDatabaseSetting(
DatabaseSettingChangesetPB.fromObject({
view_id: this.viewId,
update_filter: UpdateFilterPayloadPB.fromObject({
filter_id: id,
view_id: this.viewId,
field_id: fieldId,
field_type: fieldType,
data,
}),
})
);
return id;
};
updateFilter = (
filterId: string,
fieldId: string,
fieldType: FieldType,
filter: TextFilterPB | SelectOptionFilterPB | CheckboxFilterPB
) => {
const data = filter.serializeBinary();
return DatabaseEventUpdateDatabaseSetting(
DatabaseSettingChangesetPB.fromObject({
view_id: this.viewId,
update_filter: UpdateFilterPayloadPB.fromObject({
view_id: this.viewId,
field_id: fieldId,
field_type: fieldType,
filter_id: filterId,
data,
}),
})
);
};
removeFilter = (fieldId: string, fieldType: FieldType, filterId: string) => {
return DatabaseEventUpdateDatabaseSetting(
DatabaseSettingChangesetPB.fromObject({
view_id: this.viewId,
delete_filter: DeleteFilterPayloadPB.fromObject({
view_id: this.viewId,
field_id: fieldId,
field_type: fieldType,
filter_id: filterId,
}),
})
);
};
}
export class FilterParsed {
view_id: string;
id: string;
field_id: string;
field_type: FieldType;
data: TextFilterPB | SelectOptionFilterPB | CheckboxFilterPB | Uint8Array;
constructor(view_id: string, filter: FilterPB) {
this.view_id = view_id;
this.id = filter.id;
this.field_id = filter.field_id;
this.field_type = filter.field_type;
switch (filter.field_type) {
case FieldType.RichText:
this.data = TextFilterPB.deserializeBinary(filter.data);
break;
case FieldType.SingleSelect:
case FieldType.MultiSelect:
this.data = SelectOptionFilterPB.deserializeBinary(filter.data);
break;
case FieldType.Checkbox:
this.data = CheckboxFilterPB.deserializeBinary(filter.data);
break;
default:
this.data = filter.data;
break;
}
}
}

View File

@ -0,0 +1,80 @@
import { FilterBackendService, FilterParsed } from '$app/stores/effects/database/filter/filter_bd_svc';
import { CheckboxFilterPB, FieldType, SelectOptionFilterPB, TextFilterPB } from '@/services/backend';
import { ChangeNotifier } from '$app/utils/change_notifier';
export class FilterController {
filterService: FilterBackendService;
notifier: FilterNotifier;
constructor(public readonly viewId: string) {
this.filterService = new FilterBackendService(viewId);
this.notifier = new FilterNotifier();
}
initialize = async () => {
await this.readFilters();
};
readFilters = async () => {
const result = await this.filterService.getFilters();
if (result.ok) {
this.notifier.filters = result.val;
}
};
addFilter = async (
fieldId: string,
fieldType: FieldType,
filter: TextFilterPB | SelectOptionFilterPB | CheckboxFilterPB
) => {
const id = await this.filterService.addFilter(fieldId, fieldType, filter);
await this.readFilters();
return id;
};
updateFilter = async (
filterId: string,
fieldId: string,
fieldType: FieldType,
filter: TextFilterPB | SelectOptionFilterPB | CheckboxFilterPB
) => {
const result = await this.filterService.updateFilter(filterId, fieldId, fieldType, filter);
if (result.ok) {
await this.readFilters();
}
};
removeFilter = async (fieldId: string, fieldType: FieldType, filterId: string) => {
const result = await this.filterService.removeFilter(fieldId, fieldType, filterId);
if (result.ok) {
await this.readFilters();
}
};
subscribe = (callbacks: { onFiltersChanged?: (filters: FilterParsed[]) => void }) => {
if (callbacks.onFiltersChanged) {
this.notifier.observer?.subscribe(callbacks.onFiltersChanged);
}
};
dispose = () => {
this.notifier.unsubscribe();
};
}
class FilterNotifier extends ChangeNotifier<FilterParsed[]> {
private _filters: FilterParsed[] = [];
get filters(): FilterParsed[] {
return this._filters;
}
set filters(value: FilterParsed[]) {
this._filters = value;
this.notify(value);
}
}

View File

@ -0,0 +1,85 @@
import {
DatabaseSettingChangesetPB,
DatabaseViewIdPB,
DeleteSortPayloadPB,
FieldType,
FlowyError,
SortConditionPB,
UpdateSortPayloadPB,
} from '@/services/backend';
import { DatabaseEventGetAllSorts, DatabaseEventUpdateDatabaseSetting } from '@/services/backend/events/flowy-database2';
import { Err, Ok, Result } from 'ts-results';
import { nanoid } from 'nanoid';
import type { IDatabaseSort } from '$app_reducers/database/slice';
export class SortBackendService {
constructor(public readonly viewId: string) {}
getSorts = async (): Promise<Result<IDatabaseSort[], FlowyError>> => {
const payload = DatabaseViewIdPB.fromObject({
value: this.viewId,
});
const res = await DatabaseEventGetAllSorts(payload);
if (res.ok) {
return Ok(
res.val.items.map<IDatabaseSort>((o) => ({
id: o.id,
fieldId: o.field_id,
fieldType: o.field_type,
order: o.condition,
}))
);
} else {
return Err(res.val);
}
};
addSort = async (fieldId: string, fieldType: FieldType, order: SortConditionPB) => {
const id = nanoid(4);
await DatabaseEventUpdateDatabaseSetting(
DatabaseSettingChangesetPB.fromObject({
view_id: this.viewId,
update_sort: UpdateSortPayloadPB.fromObject({
view_id: this.viewId,
field_id: fieldId,
field_type: fieldType,
sort_id: id,
condition: order,
}),
})
);
return id;
};
updateSort = (sortId: string, fieldId: string, fieldType: FieldType, order: SortConditionPB) => {
return DatabaseEventUpdateDatabaseSetting(
DatabaseSettingChangesetPB.fromObject({
view_id: this.viewId,
update_sort: UpdateSortPayloadPB.fromObject({
view_id: this.viewId,
field_id: fieldId,
field_type: fieldType,
sort_id: sortId,
condition: order,
}),
})
);
};
removeSort = (fieldId: string, fieldType: FieldType, sortId: string) => {
return DatabaseEventUpdateDatabaseSetting(
DatabaseSettingChangesetPB.fromObject({
view_id: this.viewId,
delete_sort: DeleteSortPayloadPB.fromObject({
view_id: this.viewId,
field_id: fieldId,
field_type: fieldType,
sort_id: sortId,
}),
})
);
};
}

View File

@ -0,0 +1,72 @@
import { FieldType, SortConditionPB } from '@/services/backend';
import { ChangeNotifier } from '$app/utils/change_notifier';
import { IDatabaseSort } from '$app_reducers/database/slice';
import { SortBackendService } from '$app/stores/effects/database/sort/sort_bd_svc';
export class SortController {
sortService: SortBackendService;
notifier: SortNotifier;
constructor(public readonly viewId: string) {
this.sortService = new SortBackendService(viewId);
this.notifier = new SortNotifier();
}
initialize = async () => {
await this.readSorts();
};
readSorts = async () => {
const result = await this.sortService.getSorts();
if (result.ok) {
this.notifier.sorts = result.val;
}
};
addSort = async (fieldId: string, fieldType: FieldType, sort: SortConditionPB) => {
const id = await this.sortService.addSort(fieldId, fieldType, sort);
await this.readSorts();
return id;
};
updateSort = async (sortId: string, fieldId: string, fieldType: FieldType, sort: SortConditionPB) => {
const result = await this.sortService.updateSort(sortId, fieldId, fieldType, sort);
if (result.ok) {
await this.readSorts();
}
};
removeSort = async (fieldId: string, fieldType: FieldType, sortId: string) => {
const result = await this.sortService.removeSort(fieldId, fieldType, sortId);
if (result.ok) {
await this.readSorts();
}
};
subscribe = (callbacks: { onSortChanged?: (sorts: IDatabaseSort[]) => void }) => {
if (callbacks.onSortChanged) {
this.notifier.observer?.subscribe(callbacks.onSortChanged);
}
};
dispose = () => {
this.notifier.unsubscribe();
};
}
class SortNotifier extends ChangeNotifier<IDatabaseSort[]> {
private _sorts: IDatabaseSort[] = [];
get sorts(): IDatabaseSort[] {
return this._sorts;
}
set sorts(value: IDatabaseSort[]) {
this._sorts = value;
this.notify(value);
}
}

View File

@ -1,11 +1,11 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { FieldType } from '@/services/backend/models/flowy-database2/field_entities';
import { DateFormatPB, NumberFormatPB, SelectOptionColorPB, TimeFormatPB } from '@/services/backend';
import { DateFormatPB, NumberFormatPB, SelectOptionColorPB, SortConditionPB, TimeFormatPB } from '@/services/backend';
export interface ISelectOption {
selectOptionId: string;
title: string;
color?: SelectOptionColorPB;
color: SelectOptionColorPB;
}
export interface ISelectOptionType {
@ -26,6 +26,7 @@ export interface IDatabaseField {
fieldId: string;
title: string;
visible: boolean;
width: number;
fieldType: FieldType;
fieldOptions?: ISelectOptionType | IDateType | INumberType;
}
@ -42,11 +43,51 @@ export interface IDatabaseRow {
export type DatabaseFieldMap = { [keys: string]: IDatabaseField };
export type TDatabaseOperators =
| 'contains'
| 'doesNotContain'
| 'endsWith'
| 'startWith'
| 'is'
| 'isNot'
| 'isEmpty'
| 'isNotEmpty'
| 'isComplete'
| 'isIncomplted';
export type TSupportedOperatorsByType = { [keys: number]: TDatabaseOperators[] };
export const SupportedOperatorsByType: TSupportedOperatorsByType = {
[FieldType.RichText]: ['contains', 'doesNotContain', 'endsWith', 'startWith', 'is', 'isNot', 'isEmpty', 'isNotEmpty'],
[FieldType.SingleSelect]: ['is', 'isNot', 'isEmpty', 'isNotEmpty'],
[FieldType.MultiSelect]: ['contains', 'doesNotContain', 'isEmpty', 'isNotEmpty'],
[FieldType.Checkbox]: ['is'],
[FieldType.Checklist]: ['isComplete', 'isIncomplted'],
};
export interface IDatabaseFilter {
id?: string;
fieldId: string;
fieldType: FieldType;
logicalOperator: 'and' | 'or';
operator: TDatabaseOperators;
value: string[] | string | boolean;
}
export interface IDatabaseSort {
id?: string;
fieldId: string;
fieldType: FieldType;
order: SortConditionPB;
}
export interface IDatabase {
title: string;
fields: DatabaseFieldMap;
rows: IDatabaseRow[];
columns: IDatabaseColumn[];
filters: IDatabaseFilter[];
sort: IDatabaseSort[];
}
const initialState: IDatabase = {
@ -54,6 +95,8 @@ const initialState: IDatabase = {
columns: [],
fields: {},
rows: [],
filters: [],
sort: [],
};
export const databaseSlice = createSlice({
@ -89,87 +132,25 @@ export const databaseSlice = createSlice({
state.title = action.payload.title;
},
/*addField: (state, action: PayloadAction<{ field: IDatabaseField }>) => {
const { field } = action.payload;
state.fields[field.fieldId] = field;
state.columns.push({
fieldId: field.fieldId,
sort: 'none',
visible: true,
});
state.rows = state.rows.map<IDatabaseRow>((r: IDatabaseRow) => {
const cells = r.cells;
cells[field.fieldId] = {
rowId: r.rowId,
fieldId: field.fieldId,
data: [''],
cellId: nanoid(6),
};
return {
rowId: r.rowId,
cells: cells,
};
});
},*/
updateField: (state, action: PayloadAction<{ field: IDatabaseField }>) => {
const { field } = action.payload;
state.fields[field.fieldId] = field;
},
/*addFieldSelectOption: (state, action: PayloadAction<{ fieldId: string; option: ISelectOption }>) => {
const { fieldId, option } = action.payload;
changeWidth: (state, action: PayloadAction<{ fieldId: string; width: number }>) => {
const { fieldId, width } = action.payload;
const field = state.fields[fieldId];
const selectOptions = field.fieldOptions?.selectOptions;
state.fields[fieldId].width = width;
},
if (selectOptions) {
selectOptions.push(option);
} else {
state.fields[field.fieldId].fieldOptions = {
...state.fields[field.fieldId].fieldOptions,
selectOptions: [option],
};
}
},*/
updateFilters: (state, action: PayloadAction<{ filters: IDatabaseFilter[] }>) => {
state.filters = action.payload.filters;
},
/*updateFieldSelectOption: (state, action: PayloadAction<{ fieldId: string; option: ISelectOption }>) => {
const { fieldId, option } = action.payload;
const field = state.fields[fieldId];
const selectOptions = field.fieldOptions?.selectOptions;
if (selectOptions) {
selectOptions[selectOptions.findIndex((o) => o.selectOptionId === option.selectOptionId)] = option;
}
},*/
/*addRow: (state) => {
const rowId = nanoid(6);
const cells: { [keys: string]: ICellData } = {};
Object.keys(state.fields).forEach((id) => {
cells[id] = {
rowId: rowId,
fieldId: id,
data: [''],
cellId: nanoid(6),
};
});
const newRow: IDatabaseRow = {
rowId: rowId,
cells: cells,
};
state.rows.push(newRow);
},*/
/*updateCellValue: (source, action: PayloadAction<{ cell: ICellData }>) => {
const { cell } = action.payload;
const row = source.rows.find((r) => r.rowId === cell.rowId);
if (row) {
row.cells[cell.fieldId] = cell;
}
},*/
updateSorts: (state, action: PayloadAction<{ sorts: IDatabaseSort[] }>) => {
state.sort = action.payload.sorts;
},
},
});

View File

@ -1,141 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { nanoid } from 'nanoid';
import { FieldType } from '@/services/backend/models/flowy-database2/field_entities';
const initialState = {
title: 'My plans on the week',
fields: [
{
fieldId: '1',
fieldType: FieldType.RichText,
fieldOptions: {},
name: 'Todo',
},
{
fieldId: '2',
fieldType: FieldType.SingleSelect,
fieldOptions: [],
name: 'Status',
},
{
fieldId: '3',
fieldType: FieldType.Number,
fieldOptions: [],
name: 'Progress',
},
{
fieldId: '4',
fieldType: FieldType.DateTime,
fieldOptions: [],
name: 'Due Date',
},
],
rows: [
{
rowId: '1',
values: [
{
fieldId: '1',
value: 'Name 1',
cellId: '1',
},
{
fieldId: '2',
value: 'Status 1',
cellId: '2',
},
{
fieldId: '3',
value: 30,
cellId: '3',
},
{
fieldId: '4',
value: 'tomorrow',
cellId: '4',
},
],
},
{
rowId: '2',
values: [
{
fieldId: '1',
value: 'Name 2',
cellId: '5',
},
{
fieldId: '2',
value: 'Status 2',
cellId: '6',
},
{
fieldId: '3',
value: 40,
cellId: '7',
},
{
fieldId: '4',
value: 'tomorrow',
cellId: '8',
},
],
},
],
};
export type field = {
fieldId: string;
fieldType: FieldType;
fieldOptions: any;
name: string;
};
export const gridSlice = createSlice({
name: 'grid',
initialState: initialState,
reducers: {
updateGridTitle: (state, action: PayloadAction<{ title: string }>) => {
state.title = action.payload.title;
},
addField: (state, action: PayloadAction<{ field: field }>) => {
state.fields.push(action.payload.field);
state.rows.map((row) => {
row.values.push({
fieldId: action.payload.field.fieldId,
value: '',
cellId: nanoid(),
});
});
},
addRow: (state) => {
const newRow = {
rowId: nanoid(),
values: state.fields.map((f) => ({
fieldId: f.fieldId,
value: '',
cellId: nanoid(),
})),
};
state.rows.push(newRow);
},
updateRowValue: (state, action: PayloadAction<{ rowId: string; cellId: string; value: string | number }>) => {
console.log('updateRowValue', action.payload);
const row = state.rows.find((r) => r.rowId === action.payload.rowId);
if (row) {
const cell = row.values.find((c) => c.cellId === action.payload.cellId);
if (cell) {
cell.value = action.payload.value;
}
}
},
},
});
export const gridActions = gridSlice.actions;

View File

@ -9,7 +9,6 @@ import {
} from '@reduxjs/toolkit';
import { pagesSlice } from './reducers/pages/slice';
import { currentUserSlice } from './reducers/current-user/slice';
import { gridSlice } from './reducers/grid/slice';
import { workspaceSlice } from './reducers/workspace/slice';
import { databaseSlice } from './reducers/database/slice';
import { documentReducers } from './reducers/document/slice';
@ -27,7 +26,6 @@ const store = configureStore({
reducer: {
[pagesSlice.name]: pagesSlice.reducer,
[currentUserSlice.name]: currentUserSlice.reducer,
[gridSlice.name]: gridSlice.reducer,
[databaseSlice.name]: databaseSlice.reducer,
[boardSlice.name]: boardSlice.reducer,
[workspaceSlice.name]: workspaceSlice.reducer,

View File

@ -6,16 +6,15 @@ import { Grid } from '../components/grid/Grid/Grid';
export const GridPage = () => {
const params = useParams();
const [viewId, setViewId] = useState('');
useEffect(() => {
if (params?.id?.length) {
setViewId(params.id);
// setDatabaseId('testDb');
}
}, [params]);
return (
<div className='flex h-full flex-col gap-8 px-8 pt-8'>
<h1 className='text-4xl font-bold'>Grid: {viewId}</h1>
{viewId?.length && <Grid viewId={viewId} />}
</div>
);

View File

@ -403,7 +403,8 @@
"addOption": "Add option",
"editProperty": "Edit property",
"newProperty": "New property",
"deleteFieldPromptMessage": "Are you sure? This property will be deleted"
"deleteFieldPromptMessage": "Are you sure? This property will be deleted",
"newColumn": "New Column"
},
"sort": {
"ascending": "Ascending",