feat: support all fields type and filters in grid of tauri

* feat: support the single select field actions in grid of tauri

* feat: support multiselect

* feat: support number field and number filter

* feat: support url field

* fix: eslint error

* feat: support checkbox filter

* feat: support checklist field

* fix: adjusting keydown event

* fix: edit record ui

* feat: support date field

* fix: url field bugs

* fix: the bug of the type option wasn't update

* chore: make plural tokens compatible with tauri

* fix: plural key

* fix: optimize get cell performance

* fix: update ts error

* fix: update select option bugs

* fix: grid calculate css

* fix: add DidUpdateFieldSettings

---------

Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
This commit is contained in:
Kilu.He 2023-12-04 10:33:31 +08:00 committed by GitHub
parent 5fa441cbf5
commit a070ed2441
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
122 changed files with 4094 additions and 845 deletions

View File

@ -422,8 +422,10 @@ class _UnscheduledEventsButtonState extends State<UnscheduledEventsButton> {
}
},
child: FlowyTooltip(
message: LocaleKeys.calendar_settings_noDateHint
.plural(state.unscheduleEvents.length),
message: LocaleKeys.calendar_settings_noDateHint.plural(
state.unscheduleEvents.length,
namedArgs: {'count': '${state.unscheduleEvents.length}'},
),
child: FlowyText.regular(
"${LocaleKeys.calendar_settings_noDateTitle.tr()} (${state.unscheduleEvents.length})",
fontSize: 10,

View File

@ -307,10 +307,14 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget {
return BlocBuilder<RowDetailBloc, RowDetailState>(
builder: (context, state) {
final text = switch (state.showHiddenFields) {
false => LocaleKeys.grid_rowPage_showHiddenFields
.plural(state.numHiddenFields),
true => LocaleKeys.grid_rowPage_hideHiddenFields
.plural(state.numHiddenFields),
false => LocaleKeys.grid_rowPage_showHiddenFields.plural(
state.numHiddenFields,
namedArgs: {'count': '${state.numHiddenFields}'},
),
true => LocaleKeys.grid_rowPage_hideHiddenFields.plural(
state.numHiddenFields,
namedArgs: {'count': '${state.numHiddenFields}'},
),
};
return SizedBox(

View File

@ -25,6 +25,7 @@
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.12",
"@mui/system": "^5.14.4",
"@mui/x-date-pickers-pro": "^6.18.2",
"@reduxjs/toolkit": "^1.9.2",
"@slate-yjs/core": "^1.0.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
@ -51,6 +52,7 @@
"react-beautiful-dnd": "^13.1.1",
"react-calendar": "^4.1.0",
"react-color": "^2.19.3",
"react-datepicker": "^4.23.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^12.2.0",
@ -66,7 +68,7 @@
"slate-react": "^0.94.2",
"ts-results": "^3.3.0",
"utf8": "^3.0.0",
"valtio": "^1.11.1",
"valtio": "^1.12.1",
"yjs": "^13.5.51"
},
"devDependencies": {
@ -83,6 +85,7 @@
"@types/react": "^18.0.15",
"@types/react-beautiful-dnd": "^13.1.3",
"@types/react-color": "^3.0.6",
"@types/react-datepicker": "^4.19.3",
"@types/react-dom": "^18.0.6",
"@types/react-katex": "^3.0.0",
"@types/react-transition-group": "^4.4.6",

View File

@ -22,6 +22,9 @@ dependencies:
'@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)
'@mui/x-date-pickers-pro':
specifier: ^6.18.2
version: 6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0)
'@reduxjs/toolkit':
specifier: ^1.9.2
version: 1.9.5(react-redux@8.0.5)(react@18.2.0)
@ -100,6 +103,9 @@ dependencies:
react-color:
specifier: ^2.19.3
version: 2.19.3(react@18.2.0)
react-datepicker:
specifier: ^4.23.0
version: 4.23.0(react-dom@18.2.0)(react@18.2.0)
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
@ -146,8 +152,8 @@ dependencies:
specifier: ^3.0.0
version: 3.0.0
valtio:
specifier: ^1.11.1
version: 1.11.1(react@18.2.0)
specifier: ^1.12.1
version: 1.12.1(@types/react@18.2.6)(react@18.2.0)
yjs:
specifier: ^13.5.51
version: 13.6.1
@ -192,6 +198,9 @@ devDependencies:
'@types/react-color':
specifier: ^3.0.6
version: 3.0.6
'@types/react-datepicker':
specifier: ^4.19.3
version: 4.19.3(react-dom@18.2.0)(react@18.2.0)
'@types/react-dom':
specifier: ^18.0.6
version: 18.2.4
@ -585,6 +594,12 @@ packages:
regenerator-runtime: 0.14.0
dev: false
/@babel/runtime@7.23.4:
resolution: {integrity: sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==}
engines: {node: '>=6.9.0'}
dependencies:
regenerator-runtime: 0.14.0
/@babel/template@7.20.7:
resolution: {integrity: sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==}
engines: {node: '>=6.9.0'}
@ -993,6 +1008,34 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@floating-ui/core@1.5.0:
resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==}
dependencies:
'@floating-ui/utils': 0.1.6
dev: false
/@floating-ui/dom@1.5.3:
resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==}
dependencies:
'@floating-ui/core': 1.5.0
'@floating-ui/utils': 0.1.6
dev: false
/@floating-ui/react-dom@2.0.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@floating-ui/dom': 1.5.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@floating-ui/utils@0.1.6:
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
dev: false
/@humanwhocodes/config-array@0.11.8:
resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==}
engines: {node: '>=10.10.0'}
@ -1356,6 +1399,29 @@ packages:
react-is: 18.2.0
dev: false
/@mui/base@5.0.0-beta.24(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-bKt2pUADHGQtqWDZ8nvL2Lvg2GNJyd/ZUgZAJoYzRgmnxBL9j36MSlS3+exEdYkikcnvVafcBtD904RypFKb0w==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.23.4
'@floating-ui/react-dom': 2.0.4(react-dom@18.2.0)(react@18.2.0)
'@mui/types': 7.2.9(@types/react@18.2.6)
'@mui/utils': 5.14.18(@types/react@18.2.6)(react@18.2.0)
'@popperjs/core': 2.11.8
'@types/react': 18.2.6
clsx: 2.0.0
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@mui/core-downloads-tracker@5.13.0:
resolution: {integrity: sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==}
dev: false
@ -1493,6 +1559,17 @@ packages:
'@types/react': 18.2.6
dev: false
/@mui/types@7.2.9(@types/react@18.2.6):
resolution: {integrity: sha512-k1lN/PolaRZfNsRdAqXtcR71sTnv3z/VCCGPxU8HfdftDkzi335MdJ6scZxvofMAd/K/9EbzCZTFBmlNpQVdCg==}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.2.6
dev: false
/@mui/utils@5.12.3(react@18.2.0):
resolution: {integrity: sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==}
engines: {node: '>=12.0.0'}
@ -1507,6 +1584,24 @@ packages:
react-is: 18.2.0
dev: false
/@mui/utils@5.14.18(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-HZDRsJtEZ7WMSnrHV9uwScGze4wM/Y+u6pDVo+grUjt5yXzn+wI8QX/JwTHh9YSw/WpnUL80mJJjgCnWj2VrzQ==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.23.4
'@types/prop-types': 15.7.11
'@types/react': 18.2.6
prop-types: 15.8.1
react: 18.2.0
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'}
@ -1521,6 +1616,130 @@ packages:
react-is: 18.2.0
dev: false
/@mui/x-date-pickers-pro@6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-8lEVEOtCQssKWel4Ey1pRulGPXUQ73TnkHKzHWsjdv03FjiUs3eYB+Ej0Uk5yWPmsqlShWhOzOlOGDpzsYJsUg==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.9.0
'@emotion/styled': ^11.8.1
'@mui/material': ^5.8.6
'@mui/system': ^5.8.0
date-fns: ^2.25.0
date-fns-jalali: ^2.13.0-0
dayjs: ^1.10.7
luxon: ^3.0.2
moment: ^2.29.4
moment-hijri: ^2.1.2
moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@emotion/react':
optional: true
'@emotion/styled':
optional: true
date-fns:
optional: true
date-fns-jalali:
optional: true
dayjs:
optional: true
luxon:
optional: true
moment:
optional: true
moment-hijri:
optional: true
moment-jalaali:
optional: true
dependencies:
'@babel/runtime': 7.23.4
'@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/base': 5.0.0-beta.24(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@mui/material': 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': 5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0)
'@mui/utils': 5.14.18(@types/react@18.2.6)(react@18.2.0)
'@mui/x-date-pickers': 6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0)
'@mui/x-license-pro': 6.10.2(@types/react@18.2.6)(react@18.2.0)
clsx: 2.0.0
dayjs: 1.11.9
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/@mui/x-date-pickers@6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-HJq4uoFQSu5isa/mesWw2BKh8KBRYUQb+KaSlVlWfJNgP3YhPvWZ6yqCNYyxOAiPMxb0n3nBjS9ErO27OHjFMA==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.9.0
'@emotion/styled': ^11.8.1
'@mui/material': ^5.8.6
'@mui/system': ^5.8.0
date-fns: ^2.25.0
date-fns-jalali: ^2.13.0-0
dayjs: ^1.10.7
luxon: ^3.0.2
moment: ^2.29.4
moment-hijri: ^2.1.2
moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@emotion/react':
optional: true
'@emotion/styled':
optional: true
date-fns:
optional: true
date-fns-jalali:
optional: true
dayjs:
optional: true
luxon:
optional: true
moment:
optional: true
moment-hijri:
optional: true
moment-jalaali:
optional: true
dependencies:
'@babel/runtime': 7.23.4
'@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/base': 5.0.0-beta.24(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@mui/material': 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': 5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0)
'@mui/utils': 5.14.18(@types/react@18.2.6)(react@18.2.0)
'@types/react-transition-group': 4.4.9
clsx: 2.0.0
dayjs: 1.11.9
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/@mui/x-license-pro@6.10.2(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-Baw3shilU+eHgU+QYKNPFUKvfS5rSyNJ98pQx02E0gKA22hWp/XAt88K1qUfUMPlkPpvg/uci6gviQSSLZkuKw==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: ^17.0.0 || ^18.0.0
dependencies:
'@babel/runtime': 7.23.4
'@mui/utils': 5.14.18(@types/react@18.2.6)(react@18.2.0)
react: 18.2.0
transitivePeerDependencies:
- '@types/react'
dev: false
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -1546,6 +1765,9 @@ packages:
resolution: {integrity: sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==}
dev: false
/@popperjs/core@2.11.8:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
/@reduxjs/toolkit@1.9.5(react-redux@8.0.5)(react@18.2.0):
resolution: {integrity: sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==}
peerDependencies:
@ -1998,6 +2220,10 @@ packages:
resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==}
dev: true
/@types/prop-types@15.7.11:
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
dev: false
/@types/prop-types@15.7.5:
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
@ -2021,6 +2247,18 @@ packages:
'@types/reactcss': 1.2.6
dev: true
/@types/react-datepicker@4.19.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-85F9eKWu9fGiD9r4KVVMPYAdkJJswR3Wci9PvqplmB6T+D+VbUqPeKtifg96NZ4nEhufjehW+SX4JLrEWVplWw==}
dependencies:
'@popperjs/core': 2.11.8
'@types/react': 18.2.6
date-fns: 2.30.0
react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0)
transitivePeerDependencies:
- react
- react-dom
dev: true
/@types/react-dom@18.2.4:
resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==}
dependencies:
@ -2064,6 +2302,12 @@ packages:
dependencies:
'@types/react': 18.2.6
/@types/react-transition-group@4.4.9:
resolution: {integrity: sha512-ZVNmWumUIh5NhH8aMD9CR2hdW0fNuYInlocZHaZ+dgk/1K49j1w/HoAuK1ki+pgscQrOFRTlXeoURtuzEkV3dg==}
dependencies:
'@types/react': 18.2.6
dev: false
/@types/react@17.0.59:
resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==}
dependencies:
@ -2671,6 +2915,10 @@ packages:
/cjs-module-lexer@1.2.2:
resolution: {integrity: sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==}
/classnames@2.3.2:
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
dev: false
/cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@ -2870,6 +3118,12 @@ packages:
whatwg-url: 11.0.0
dev: true
/date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
dependencies:
'@babel/runtime': 7.23.4
/dayjs@1.11.9:
resolution: {integrity: sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==}
dev: false
@ -2923,6 +3177,14 @@ packages:
engines: {node: '>=0.4.0'}
dev: true
/derive-valtio@0.1.0(valtio@1.12.1):
resolution: {integrity: sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==}
peerDependencies:
valtio: '*'
dependencies:
valtio: 1.12.1(@types/react@18.2.6)(react@18.2.0)
dev: false
/detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
@ -5330,6 +5592,22 @@ packages:
tinycolor2: 1.6.0
dev: false
/react-datepicker@4.23.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-w+msqlOZ14v6H1UknTKtZw/dw9naFMgAOspf59eY130gWpvy5dvKj/bgsFICDdvxB7PtKWxDcbGlAqCloY1d2A==}
peerDependencies:
react: ^16.9.0 || ^17 || ^18
react-dom: ^16.9.0 || ^17 || ^18
dependencies:
'@popperjs/core': 2.11.8
classnames: 2.3.2
date-fns: 2.30.0
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-onclickoutside: 6.13.0(react-dom@18.2.0)(react@18.2.0)
react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0)
dev: false
/react-dom@18.2.0(react@18.2.0):
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies:
@ -5338,7 +5616,6 @@ packages:
loose-envify: 1.4.0
react: 18.2.0
scheduler: 0.23.0
dev: false
/react-error-boundary@3.1.4(react@18.2.0):
resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
@ -5361,6 +5638,9 @@ packages:
warning: 4.0.3
dev: false
/react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
/react-i18next@12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-KBB6buBmVKXUWNxXHdnthp+38gPyBT46hJCAIQ8rX19NFL/m2ahte2KARfIDf2tMnSAL7wwck6eDOd/9zn6aFg==}
peerDependencies:
@ -5402,6 +5682,29 @@ packages:
react: 18.2.0
dev: false
/react-onclickoutside@6.13.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==}
peerDependencies:
react: ^15.5.x || ^16.x || ^17.x || ^18.x
react-dom: ^15.5.x || ^16.x || ^17.x || ^18.x
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==}
peerDependencies:
'@popperjs/core': ^2.0.0
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
dependencies:
'@popperjs/core': 2.11.8
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-fast-compare: 3.2.2
warning: 4.0.3
/react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
peerDependencies:
@ -5551,7 +5854,6 @@ packages:
engines: {node: '>=0.10.0'}
dependencies:
loose-envify: 1.4.0
dev: false
/reactcss@1.2.3(react@18.2.0):
resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
@ -5599,7 +5901,6 @@ packages:
/regenerator-runtime@0.14.0:
resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==}
dev: false
/regexp.prototype.flags@1.5.0:
resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==}
@ -5718,7 +6019,6 @@ packages:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
dependencies:
loose-envify: 1.4.0
dev: false
/scroll-into-view-if-needed@2.2.31:
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
@ -6375,15 +6675,20 @@ packages:
'@types/istanbul-lib-coverage': 2.0.4
convert-source-map: 1.9.0
/valtio@1.11.1(react@18.2.0):
resolution: {integrity: sha512-sTKWY1e1AVUu4sY9CimoSZpufAsAXO+fzZrw0X5xtijEmDDQaPPLHZxlpONUpTLtvxPjpQURCSdUuUyBszoEOg==}
/valtio@1.12.1(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-R0V4H86Xi2Pp7pmxN/EtV4Q6jr6PMN3t1IwxEvKUp6160r8FimvPh941oWyeK1iec/DTsh9Jb3Q+GputMS8SYg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=16.8'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
dependencies:
'@types/react': 18.2.6
derive-valtio: 0.1.0(valtio@1.12.1)
proxy-compare: 2.5.1
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
@ -6457,7 +6762,6 @@ packages:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
dependencies:
loose-envify: 1.4.0
dev: false
/webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}

View File

@ -36,17 +36,25 @@ languages.forEach(language => {
console.error(res);
}
})
})
});
function flattenJSON(obj, prefix = '') {
let result = {};
const pluralsKey = ["one", "other", "few", "many", "two", "zero"];
for (let key in obj) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
const nestedKeys = flattenJSON(obj[key], `${prefix}${key}.`);
result = { ...result, ...nestedKeys };
} else {
result[`${prefix}${key}`] = obj[key].replaceAll('{', '{{').replaceAll('}', '}}');
let newKey = `${prefix}${key}`;
let replaceChar = '{'
if (pluralsKey.includes(key)) {
newKey = `${prefix.slice(0, -1)}_${key}`;
}
result[newKey] = obj[key].replaceAll('{', '{{').replaceAll('}', '}}');
}
}

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4L6 8L10 12" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 4L10 8L6 12" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@ -1,4 +1,4 @@
import React, { FormEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import React, { FormEventHandler, useCallback, useRef, useState } from 'react';
import ViewBanner from '$app/components/_shared/ViewTitle/ViewBanner';
import { Page, PageIcon } from '$app_reducers/pages/slice';
import { ViewIconTypePB } from '@/services/backend';
@ -37,13 +37,6 @@ function ViewTitle({ view, onTitleChange: onTitleChangeProp, onUpdateIcon: onUpd
[onUpdateIconProp]
);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus();
// set the cursor to the end of the text
textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
}
}, []);
return (
<div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<ViewBanner icon={icon} hover={hover} onUpdateIcon={onUpdateIcon} />

View File

@ -1,6 +1,6 @@
import React from 'react';
import DialogContent from '@mui/material/DialogContent';
import { Button, DialogActions } from '@mui/material';
import { Button, DialogActions, Divider } from '@mui/material';
import Dialog from '@mui/material/Dialog';
import { useTranslation } from 'react-i18next';
@ -18,10 +18,11 @@ function ConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) {
return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogContent className={'flex w-[540px] flex-col items-center justify-center'}>
<div className={'text-md m-2 font-bold'}>{title}</div>
<div className={'m-1 text-sm text-text-caption'}>{subtitle}</div>
<div className={'text-md font-medium'}>{title}</div>
{subtitle && <div className={'m-1 text-sm text-text-caption'}>{subtitle}</div>}
</DialogContent>
<DialogActions>
<Divider className={'mb-4'} />
<DialogActions className={'p-4 pt-0'}>
<Button variant={'outlined'} onClick={onClose}>
{t('button.cancel')}
</Button>

View File

@ -1,11 +1,19 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { proxy, useSnapshot } from 'valtio';
import { DatabaseLayoutPB, DatabaseNotification, FieldVisibility } from '@/services/backend';
import { subscribeNotifications } from '$app/hooks';
import { Database, databaseService, fieldListeners, fieldService, rowListeners, sortListeners } from './application';
import { didUpdateFilter } from '$app/components/database/application/filter/filter_listeners';
import { didUpdateViewRowsVisibility } from '$app/components/database/application/row/row_listeners';
import {
Cell,
Database,
databaseService,
cellListeners,
fieldListeners,
rowListeners,
sortListeners,
filterListeners,
} from './application';
export function useSelectDatabaseView({ viewId }: { viewId?: string }) {
const key = 'v';
@ -36,16 +44,73 @@ const DatabaseContext = createContext<Database>({
sorts: [],
groupSettings: [],
groups: [],
typeOptions: {},
cells: {},
});
export const DatabaseProvider = DatabaseContext.Provider;
export const useDatabase = () => useSnapshot(useContext(DatabaseContext));
export const useSelectorCell = (rowId: string, fieldId: string) => {
const database = useContext(DatabaseContext);
const cells = useSnapshot(database.cells);
return cells[`${rowId}:${fieldId}`];
};
export const useDispatchCell = () => {
const database = useContext(DatabaseContext);
const setCell = useCallback(
(cell: Cell) => {
const id = `${cell.rowId}:${cell.fieldId}`;
database.cells[id] = cell;
},
[database]
);
const deleteCells = useCallback(
({ rowId, fieldId }: { rowId: string; fieldId?: string }) => {
cellListeners.didDeleteCells({ database, rowId, fieldId });
},
[database]
);
return {
deleteCells,
setCell,
};
};
export const useTypeOptions = () => {
const context = useContext(DatabaseContext);
return useSnapshot(context.typeOptions);
};
export const useFiltersCount = () => {
const { filters, fields } = useDatabase();
// filter fields: if the field is deleted, it will not be displayed
return useMemo(
() => filters?.map((filter) => fields.find((field) => field.id === filter.fieldId)).filter(Boolean).length,
[filters, fields]
);
};
export function useTypeOption<T>(fieldId: string) {
const context = useContext(DatabaseContext);
const typeOptions = useSnapshot(context.typeOptions);
return typeOptions[fieldId] as T;
}
export const useDatabaseVisibilityRows = () => {
const { rowMetas } = useDatabase();
return useMemo(() => rowMetas.filter((row) => !row.isHidden), [rowMetas]);
return useMemo(() => rowMetas.filter((row) => row && !row.isHidden), [rowMetas]);
};
export const useDatabaseVisibilityFields = () => {
@ -69,6 +134,8 @@ export const useConnectDatabase = (viewId: string) => {
sorts: [],
groupSettings: [],
groups: [],
typeOptions: {},
cells: {},
});
void databaseService.openDatabase(viewId).then((value) => Object.assign(proxyDatabase, value));
@ -79,10 +146,10 @@ export const useConnectDatabase = (viewId: string) => {
useEffect(() => {
const unsubscribePromise = subscribeNotifications(
{
[DatabaseNotification.DidUpdateFields]: async () => {
database.fields = await fieldService.getFields(viewId);
[DatabaseNotification.DidUpdateFields]: async (changeset) => {
await fieldListeners.didUpdateFields(viewId, database, changeset);
},
[DatabaseNotification.DidUpdateFieldSettings]: async (changeset) => {
[DatabaseNotification.DidUpdateFieldSettings]: (changeset) => {
fieldListeners.didUpdateFieldSettings(database, changeset);
},
[DatabaseNotification.DidUpdateViewRows]: (changeset) => {
@ -100,10 +167,10 @@ export const useConnectDatabase = (viewId: string) => {
},
[DatabaseNotification.DidUpdateFilter]: (changeset) => {
didUpdateFilter(database, changeset);
filterListeners.didUpdateFilter(database, changeset);
},
[DatabaseNotification.DidUpdateViewRowsVisibility]: (changeset) => {
didUpdateViewRowsVisibility(database, changeset);
rowListeners.didUpdateViewRowsVisibility(database, changeset);
},
},
{ id: viewId }
@ -115,9 +182,10 @@ export const useConnectDatabase = (viewId: string) => {
return database;
};
export function useDatabaseResize() {
export function useDatabaseResize(selectedViewId?: string) {
const ref = useRef<HTMLDivElement>(null);
const collectionRef = useRef<HTMLDivElement>(null);
const [openCollections, setOpenCollections] = useState<string[]>([]);
const [tableHeight, setTableHeight] = useState(0);
@ -140,23 +208,23 @@ export function useDatabaseResize() {
};
handleResize();
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(element);
if (collectionElement) {
resizeObserver.observe(collectionRef.current);
resizeObserver.observe(collectionElement);
}
return () => {
resizeObserver.disconnect();
};
}, []);
}, [selectedViewId]);
return {
ref,
collectionRef,
tableHeight,
openCollections,
setOpenCollections,
};
}

View File

@ -24,8 +24,7 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
const { t } = useTranslation();
const [notFound, setNotFound] = useState(false);
const [childViewIds, setChildViewIds] = useState<string[]>([]);
const { ref, collectionRef, tableHeight } = useDatabaseResize();
const [openCollections, setOpenCollections] = useState<string[]>([]);
const { ref, collectionRef, tableHeight, openCollections, setOpenCollections } = useDatabaseResize(selectedViewId);
useEffect(() => {
const onPageChanged = () => {
@ -54,7 +53,7 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
};
}, [viewId]);
const index = useMemo(() => {
const value = useMemo(() => {
return Math.max(0, childViewIds.indexOf(selectedViewId ?? viewId));
}, [childViewIds, selectedViewId, viewId]);
@ -77,7 +76,7 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
setOpenCollections((prev) => [...prev, id]);
}
},
[openCollections]
[openCollections, setOpenCollections]
);
if (notFound) {
@ -102,10 +101,10 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
}}
className={'flex-1 overflow-hidden'}
axis={'x'}
index={index}
index={value}
>
{childViewIds.map((id) => (
<TabPanel key={id} index={index} value={index}>
{childViewIds.map((id, index) => (
<TabPanel key={id} index={index} value={value}>
<DatabaseLoader viewId={id}>
{selectedViewId === id && (
<>

View File

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

View File

@ -33,10 +33,11 @@ export const VirtualizedList: FC<VirtualizedListProps> = ({
return (
<div
key={key}
ref={virtualizer.measureElement}
className={itemClassName}
style={{
[sizeProp]: size,
...getItemStyle?.(index),
...(horizontal ? { [sizeProp]: size } : undefined),
}}
data-key={key}
data-index={index}

View File

@ -0,0 +1,48 @@
import { Database } from '$app/components/database/application';
import { getCell } from './cell_service';
export function didDeleteCells({ database, rowId, fieldId }: { database: Database; rowId?: string; fieldId?: string }) {
const ids = Object.keys(database.cells);
ids.forEach((id) => {
const cell = database.cells[id];
if (rowId && cell.rowId !== rowId) return;
if (fieldId && cell.fieldId !== fieldId) return;
delete database.cells[id];
});
}
export async function didUpdateCells({
viewId,
database,
rowId,
fieldId,
}: {
viewId: string;
database: Database;
rowId?: string;
fieldId?: string;
}) {
const field = database.fields.find((field) => field.id === fieldId);
if (!field) {
delete database.cells[`${rowId}:${fieldId}`];
return;
}
const ids = Object.keys(database.cells);
ids.forEach((id) => {
const cell = database.cells[id];
if (rowId && cell.rowId !== rowId) return;
if (fieldId && cell.fieldId !== fieldId) return;
void getCell(viewId, cell.rowId, cell.fieldId, field.type).then((data) => {
// cache cell
database.cells[id] = data;
});
});
}

View File

@ -102,10 +102,17 @@ export async function updateDateCell(
rowId: string,
fieldId: string,
data: {
// 10-digit timestamp
date?: number;
// time string in format HH:mm
time?: string;
// 10-digit timestamp
endDate?: number;
// time string in format HH:mm
endTime?: string;
includeTime?: boolean;
clearFlag?: boolean;
isRange?: boolean;
}
): Promise<void> {
const payload = DateChangesetPB.fromObject({
@ -118,6 +125,9 @@ export async function updateDateCell(
time: data.time,
include_time: data.includeTime,
clear_flag: data.clearFlag,
end_date: data.endDate,
end_time: data.endTime,
is_range: data.isRange,
});
const result = await DatabaseEventUpdateDateCell(payload);

View File

@ -4,8 +4,13 @@ import {
DateCellDataPB,
FieldType,
SelectOptionCellDataPB,
TimestampCellDataPB,
URLCellDataPB,
} from '@/services/backend';
import {
SelectOption,
pbToSelectOption,
} from '$app/components/database/application/field/select_option/select_option_types';
export interface Cell {
rowId: string;
@ -53,11 +58,25 @@ export interface DateTimeCell extends Cell {
data: DateTimeCellData;
}
export interface TimeStampCell extends Cell {
fieldType: FieldType.LastEditedTime | FieldType.CreatedTime;
data: TimestampCellData;
}
export interface DateTimeCellData {
date?: string;
time?: string;
timestamp?: number;
includeTime?: boolean;
endDate?: string;
endTime?: string;
endTimestamp?: number;
isRange?: boolean;
}
export interface TimestampCellData {
dataTime?: string;
timestamp?: number;
}
export interface ChecklistCell extends Cell {
@ -71,20 +90,37 @@ export interface ChecklistCellData {
*/
selectedOptions?: string[];
percentage?: number;
options?: SelectOption[];
}
export type UndeterminedCell = TextCell | NumberCell | DateTimeCell | SelectCell | CheckboxCell | UrlCell | ChecklistCell;
export type UndeterminedCell =
| TextCell
| NumberCell
| DateTimeCell
| SelectCell
| CheckboxCell
| UrlCell
| ChecklistCell;
const pbToDateCellData = (pb: DateCellDataPB): DateTimeCellData => ({
const pbToDateTimeCellData = (pb: DateCellDataPB): DateTimeCellData => ({
date: pb.date,
time: pb.time,
timestamp: pb.timestamp,
includeTime: pb.include_time,
endDate: pb.end_date,
endTime: pb.end_time,
endTimestamp: pb.end_timestamp,
isRange: pb.is_range,
});
const pbToTimestampCellData = (pb: TimestampCellDataPB): TimestampCellData => ({
dataTime: pb.date_time,
timestamp: pb.timestamp,
});
export const pbToSelectCellData = (pb: SelectOptionCellDataPB): SelectCellData => {
return {
selectedOptionIds: pb.select_options.map(option => option.id),
selectedOptionIds: pb.select_options.map((option) => option.id),
};
};
@ -96,6 +132,7 @@ const pbToURLCellData = (pb: URLCellDataPB): UrlCellData => ({
export const pbToChecklistCellData = (pb: ChecklistCellDataPB): ChecklistCellData => ({
selectedOptions: pb.selected_options.map(({ id }) => id),
percentage: pb.percentage,
options: pb.options.map(pbToSelectOption),
});
function bytesToCellData(bytes: Uint8Array, fieldType: FieldType) {
@ -105,9 +142,10 @@ function bytesToCellData(bytes: Uint8Array, fieldType: FieldType) {
case FieldType.Checkbox:
return new TextDecoder().decode(bytes);
case FieldType.DateTime:
return pbToDateTimeCellData(DateCellDataPB.deserialize(bytes));
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return pbToDateCellData(DateCellDataPB.deserialize(bytes));
return pbToTimestampCellData(TimestampCellDataPB.deserialize(bytes));
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return pbToSelectCellData(SelectOptionCellDataPB.deserialize(bytes));

View File

@ -1,2 +1,3 @@
export * from './cell_types';
export * as cellService from './cell_service';
export * as cellListeners from './cell_listeners';

View File

@ -16,10 +16,9 @@ export async function getDatabaseId(viewId: string): Promise<string> {
const result = await DatabaseEventGetDatabaseId(payload);
return result.map(value => value.value).unwrap();
return result.map((value) => value.value).unwrap();
}
export async function getDatabase(viewId: string) {
const payload = DatabaseViewIdPB.fromObject({
value: viewId,
@ -27,15 +26,17 @@ export async function getDatabase(viewId: string) {
const result = await DatabaseEventGetDatabase(payload);
return result.map(value => {
return {
id: value.id,
isLinked: value.is_linked,
layoutType: value.layout_type,
fieldIds: value.fields.map(field => field.field_id),
rowMetas: value.rows.map(pbToRowMeta),
};
}).unwrap();
return result
.map((value) => {
return {
id: value.id,
isLinked: value.is_linked,
layoutType: value.layout_type,
fieldIds: value.fields.map((field) => field.field_id),
rowMetas: value.rows.map(pbToRowMeta),
};
})
.unwrap();
}
export async function getDatabaseSetting(viewId: string) {
@ -45,31 +46,23 @@ export async function getDatabaseSetting(viewId: string) {
const result = await DatabaseEventGetDatabaseSetting(payload);
return result.map(value => {
return {
filters: value.filters.items.map(pbToFilter),
sorts: value.sorts.items.map(pbToSort),
groupSettings: value.group_settings.items.map(pbToGroupSetting),
};
}).unwrap();
return result
.map((value) => {
return {
filters: value.filters.items.map(pbToFilter),
sorts: value.sorts.items.map(pbToSort),
groupSettings: value.group_settings.items.map(pbToGroupSetting),
};
})
.unwrap();
}
export async function openDatabase(viewId: string): Promise<Database> {
const {
id,
isLinked,
layoutType,
fieldIds,
rowMetas,
} = await getDatabase(viewId);
const { id, isLinked, layoutType, fieldIds, rowMetas } = await getDatabase(viewId);
const {
filters,
sorts,
groupSettings,
} = await getDatabaseSetting(viewId);
const { filters, sorts, groupSettings } = await getDatabaseSetting(viewId);
const fields = await fieldService.getFields(viewId, fieldIds);
const { fields, typeOptions } = await fieldService.getFields(viewId, fieldIds);
const groups = await groupService.getGroups(viewId);
@ -83,5 +76,7 @@ export async function openDatabase(viewId: string): Promise<Database> {
sorts,
groups,
groupSettings,
typeOptions,
cells: {},
};
}

View File

@ -1,18 +1,21 @@
import { DatabaseLayoutPB } from '@/services/backend';
import { Field } from '../field';
import { Field, UndeterminedTypeOptionData } from '../field';
import { Filter } from '../filter';
import { GroupSetting, Group } from '../group';
import { RowMeta } from '../row';
import { Sort } from '../sort';
import { Cell } from '../cell';
export interface Database {
id: string;
isLinked: boolean;
layoutType: DatabaseLayoutPB,
layoutType: DatabaseLayoutPB;
fields: Field[];
rowMetas: RowMeta[];
filters: Filter[];
sorts: Sort[];
groupSettings: GroupSetting[];
groups: Group[];
typeOptions: Record<string, UndeterminedTypeOptionData>;
cells: Record<string, Cell>;
}

View File

@ -1,5 +1,6 @@
import { FieldSettingsPB } from '@/services/backend';
import { Database } from '$app/components/database/application';
import { DatabaseFieldChangesetPB, FieldSettingsPB, FieldVisibility } from '@/services/backend';
import { Database, fieldService } from '$app/components/database/application';
import { didDeleteCells, didUpdateCells } from '$app/components/database/application/cell/cell_listeners';
export function didUpdateFieldSettings(database: Database, settings: FieldSettingsPB) {
const { field_id: fieldId, visibility, width } = settings;
@ -8,4 +9,32 @@ export function didUpdateFieldSettings(database: Database, settings: FieldSettin
if (!field) return;
field.visibility = visibility;
field.width = width;
// delete cells if field is hidden
if (visibility === FieldVisibility.AlwaysHidden) {
didDeleteCells({ database, fieldId });
}
}
export async function didUpdateFields(viewId: string, database: Database, changeset: DatabaseFieldChangesetPB) {
const { fields, typeOptions } = await fieldService.getFields(viewId);
database.fields = fields;
const deletedFieldIds = Object.keys(changeset.deleted_fields);
const updatedFieldIds = changeset.updated_fields.map((field) => field.id);
Object.assign(database.typeOptions, typeOptions);
deletedFieldIds.forEach(
(fieldId) => {
// delete cache cells
didDeleteCells({ database, fieldId });
// delete cache type options
delete database.typeOptions[fieldId];
},
[database.typeOptions]
);
updatedFieldIds.forEach((fieldId) => {
// delete cache cells
void didUpdateCells({ viewId, database, fieldId });
});
}

View File

@ -24,9 +24,16 @@ import {
DatabaseEventGetAllFieldSettings,
} from '@/services/backend/events/flowy-database2';
import { Field, pbToField } from './field_types';
import { bytesToTypeOption, getTypeOption } from './type_option';
import { getTypeOption } from './type_option';
import { Database } from '$app/components/database/application';
export async function getFields(viewId: string, fieldIds?: string[]): Promise<Field[]> {
export async function getFields(
viewId: string,
fieldIds?: string[]
): Promise<{
fields: Field[];
typeOptions: Database['typeOptions'];
}> {
const payload = GetFieldPayloadPB.fromObject({
view_id: viewId,
field_ids: fieldIds
@ -48,22 +55,29 @@ export async function getFields(viewId: string, fieldIds?: string[]): Promise<Fi
return Promise.reject('Failed to get fields');
}
const typeOptions: Database['typeOptions'] = {};
const fields = await Promise.all(
result.val.items.map(async (item) => {
const setting = settings.val.items.find((setting) => setting.field_id === item.id);
const field = pbToField(item);
const typeOption = await getTypeOption(viewId, field.id, field.type);
const typeOption = await getTypeOption(viewId, item.id, item.field_type);
if (typeOption) {
typeOptions[item.id] = typeOption;
}
return {
...field,
visibility: setting?.visibility,
width: setting?.width,
typeOption,
};
})
);
return fields;
return { fields, typeOptions };
}
export async function createField(viewId: string, fieldType?: FieldType, data?: Uint8Array): Promise<Field> {
@ -79,10 +93,7 @@ export async function createField(viewId: string, fieldType?: FieldType, data?:
return Promise.reject('Failed to create field');
}
const field = pbToField(result.val.field);
field.typeOption = bytesToTypeOption(result.val.type_option_data, field.type);
return field;
return pbToField(result.val.field);
}
export async function duplicateField(viewId: string, fieldId: string): Promise<void> {

View File

@ -1,11 +1,9 @@
import { FieldPB, FieldType, FieldVisibility } from '@/services/backend';
import { DateTimeTypeOption, NumberTypeOption, SelectTypeOption } from './type_option/type_option_types';
export interface Field {
id: string;
name: string;
type: FieldType;
typeOption?: unknown;
visibility?: FieldVisibility;
width?: number;
isPrimary: boolean;
@ -13,24 +11,41 @@ export interface Field {
export interface NumberField extends Field {
type: FieldType.Number;
typeOption: NumberTypeOption;
}
export interface DateTimeField extends Field {
type: FieldType.DateTime;
typeOption: DateTimeTypeOption;
}
export interface LastEditedTimeField extends Field {
type: FieldType.LastEditedTime;
}
export interface CreatedTimeField extends Field {
type: FieldType.CreatedTime;
}
export type UndeterminedDateField = DateTimeField | CreatedTimeField | LastEditedTimeField;
export interface SelectField extends Field {
type: FieldType.SingleSelect | FieldType.MultiSelect;
typeOption: SelectTypeOption;
}
export interface ChecklistField extends Field {
type: FieldType.Checklist;
}
export interface DateTimeField extends Field {
type: FieldType.DateTime;
}
export type UndeterminedField = NumberField | DateTimeField | SelectField | Field;
export const pbToField = (pb: FieldPB): Field => ({
id: pb.id,
name: pb.name,
type: pb.field_type,
isPrimary: pb.is_primary,
});
export const pbToField = (pb: FieldPB): Field => {
return {
id: pb.id,
name: pb.name,
type: pb.field_type,
isPrimary: pb.is_primary,
};
};

View File

@ -1,7 +1,4 @@
import {
CreateSelectOptionPayloadPB,
RepeatedSelectOptionPayload,
} from '@/services/backend';
import { CreateSelectOptionPayloadPB, RepeatedSelectOptionPayload } from '@/services/backend';
import {
DatabaseEventCreateSelectOption,
DatabaseEventInsertOrUpdateSelectOption,
@ -28,7 +25,7 @@ export async function insertOrUpdateSelectOption(
viewId: string,
fieldId: string,
items: Partial<SelectOption>[],
rowId?: string,
rowId?: string
): Promise<void> {
const payload = RepeatedSelectOptionPayload.fromObject({
view_id: viewId,
@ -46,13 +43,13 @@ export async function deleteSelectOption(
viewId: string,
fieldId: string,
items: Partial<SelectOption>[],
rowId?: string,
rowId?: string
): Promise<void> {
const payload = RepeatedSelectOptionPayload.fromObject({
view_id: viewId,
field_id: fieldId,
row_id: rowId,
items: items,
items,
});
const result = await DatabaseEventDeleteSelectOption(payload);

View File

@ -1,9 +1,9 @@
import { FieldType, TypeOptionPathPB, TypeOptionChangesetPB } from '@/services/backend';
import {
FieldType,
TypeOptionPathPB,
} from '@/services/backend';
import { DatabaseEventGetTypeOption } from '@/services/backend/events/flowy-database2';
import { bytesToTypeOption } from './type_option_types';
DatabaseEventGetTypeOption,
DatabaseEventUpdateFieldTypeOption,
} from '@/services/backend/events/flowy-database2';
import { bytesToTypeOption, UndeterminedTypeOptionData, typeOptionDataToPB } from './type_option_types';
export async function getTypeOption(viewId: string, fieldId: string, fieldType: FieldType) {
const payload = TypeOptionPathPB.fromObject({
@ -14,5 +14,32 @@ export async function getTypeOption(viewId: string, fieldId: string, fieldType:
const result = await DatabaseEventGetTypeOption(payload);
return result.map(value => bytesToTypeOption(value.type_option_data, fieldType)).unwrap();
if (!result.ok) {
return Promise.reject(result.val);
}
const value = result.val;
return bytesToTypeOption(value.type_option_data, fieldType);
}
export async function updateTypeOption(
viewId: string,
fieldId: string,
fieldType: FieldType,
data: UndeterminedTypeOptionData
) {
const payload = TypeOptionChangesetPB.fromObject({
view_id: viewId,
field_id: fieldId,
type_option_data: typeOptionDataToPB(data, fieldType)?.serialize(),
});
const result = await DatabaseEventUpdateFieldTypeOption(payload);
if (!result.ok) {
return Promise.reject(result.val);
}
return;
}

View File

@ -8,6 +8,9 @@ import {
RichTextTypeOptionPB,
SingleSelectTypeOptionPB,
TimeFormatPB,
ChecklistTypeOptionPB,
DateTypeOptionPB,
TimestampTypeOptionPB,
} from '@/services/backend';
import { pbToSelectOption, SelectOption } from '../select_option';
@ -26,6 +29,9 @@ export interface DateTimeTypeOption {
dateFormat?: DateFormatPB;
timeFormat?: TimeFormatPB;
timezoneId?: string;
}
export interface TimeStampTypeOption extends DateTimeTypeOption {
includeTime?: boolean;
fieldType?: FieldType;
}
@ -38,6 +44,50 @@ export interface CheckboxTypeOption {
isSelected?: boolean;
}
export interface ChecklistTypeOption {
config?: string;
}
export type UndeterminedTypeOptionData =
| TextTypeOption
| NumberTypeOption
| SelectTypeOption
| CheckboxTypeOption
| ChecklistTypeOption
| DateTimeTypeOption
| TimeStampTypeOption;
export function typeOptionDataToPB(data: UndeterminedTypeOptionData, fieldType: FieldType) {
switch (fieldType) {
case FieldType.Number:
return NumberTypeOptionPB.fromObject(data as NumberTypeOption);
case FieldType.DateTime:
return dateTimeTypeOptionToPB(data as DateTimeTypeOption);
case FieldType.CreatedTime:
case FieldType.LastEditedTime:
return timestampTypeOptionToPB(data as TimeStampTypeOption);
default:
return null;
}
}
function dateTimeTypeOptionToPB(data: DateTimeTypeOption): DateTypeOptionPB {
return DateTypeOptionPB.fromObject({
time_format: data.timeFormat,
date_format: data.dateFormat,
timezone_id: data.timezoneId,
});
}
function timestampTypeOptionToPB(data: TimeStampTypeOption): TimestampTypeOptionPB {
return TimestampTypeOptionPB.fromObject({
include_time: data.includeTime,
date_format: data.dateFormat,
time_format: data.timeFormat,
field_type: data.fieldType,
});
}
function pbToSelectTypeOption(pb: SingleSelectTypeOptionPB | MultiSelectTypeOptionPB): SelectTypeOption {
return {
@ -52,6 +102,29 @@ function pbToCheckboxTypeOption(pb: CheckboxTypeOptionPB): CheckboxTypeOption {
};
}
function pbToChecklistTypeOption(pb: ChecklistTypeOptionPB): ChecklistTypeOption {
return {
config: pb.config,
};
}
function pbToDateTypeOption(pb: DateTypeOptionPB): DateTimeTypeOption {
return {
dateFormat: pb.date_format,
timezoneId: pb.timezone_id,
timeFormat: pb.time_format,
};
}
function pbToTimeStampTypeOption(pb: TimestampTypeOptionPB): TimeStampTypeOption {
return {
includeTime: pb.include_time,
dateFormat: pb.date_format,
timeFormat: pb.time_format,
fieldType: pb.field_type,
};
}
export function bytesToTypeOption(data: Uint8Array, fieldType: FieldType) {
switch (fieldType) {
case FieldType.RichText:
@ -64,5 +137,12 @@ export function bytesToTypeOption(data: Uint8Array, fieldType: FieldType) {
return pbToSelectTypeOption(MultiSelectTypeOptionPB.deserialize(data));
case FieldType.Checkbox:
return pbToCheckboxTypeOption(CheckboxTypeOptionPB.deserialize(data));
case FieldType.Checklist:
return pbToChecklistTypeOption(ChecklistTypeOptionPB.deserialize(data));
case FieldType.DateTime:
return pbToDateTypeOption(DateTypeOptionPB.deserialize(data));
case FieldType.CreatedTime:
case FieldType.LastEditedTime:
return pbToTimeStampTypeOption(TimestampTypeOptionPB.deserialize(data));
}
}

View File

@ -1,13 +1,32 @@
import { TextFilterConditionPB, FieldType } from '@/services/backend';
import {
CheckboxFilterConditionPB,
ChecklistFilterConditionPB,
FieldType,
NumberFilterConditionPB,
TextFilterConditionPB,
} from '@/services/backend';
import { UndeterminedFilter } from '$app/components/database/application';
export function getDefaultFilter(fieldType: FieldType): UndeterminedFilter['data'] | undefined {
switch (fieldType) {
case FieldType.RichText:
case FieldType.URL:
return {
condition: TextFilterConditionPB.Contains,
content: '',
};
case FieldType.Number:
return {
condition: NumberFilterConditionPB.NumberIsNotEmpty,
};
case FieldType.Checkbox:
return {
condition: CheckboxFilterConditionPB.IsUnChecked,
};
case FieldType.Checklist:
return {
condition: ChecklistFilterConditionPB.IsIncomplete,
};
default:
return;
}

View File

@ -26,7 +26,7 @@ export async function insertFilter({
viewId: string;
fieldId: string;
fieldType: FieldType;
data: UndeterminedFilter['data'];
data?: UndeterminedFilter['data'];
filterId?: string;
}): Promise<void> {
const payload = DatabaseSettingChangesetPB.fromObject({
@ -36,7 +36,7 @@ export async function insertFilter({
field_id: fieldId,
field_type: fieldType,
filter_id: filterId,
data: filterDataToPB(data, fieldType)?.serialize(),
data: data ? filterDataToPB(data, fieldType)?.serialize() : undefined,
},
});

View File

@ -1,10 +1,18 @@
import {
CheckboxFilterConditionPB,
CheckboxFilterPB,
FieldType,
TextFilterConditionPB,
SelectOptionConditionPB,
TextFilterPB,
SelectOptionFilterPB,
FilterPB,
NumberFilterConditionPB,
NumberFilterPB,
SelectOptionConditionPB,
SelectOptionFilterPB,
TextFilterConditionPB,
TextFilterPB,
ChecklistFilterConditionPB,
ChecklistFilterPB,
DateFilterConditionPB,
DateFilterPB,
} from '@/services/backend';
export interface Filter {
@ -29,16 +37,63 @@ export interface SelectFilter extends Filter {
data: SelectFilterData;
}
export interface NumberFilter extends Filter {
fieldType: FieldType.Number;
data: NumberFilterData;
}
export interface CheckboxFilter extends Filter {
fieldType: FieldType.Checkbox;
data: CheckboxFilterData;
}
export interface CheckboxFilterData {
condition?: CheckboxFilterConditionPB;
}
export interface ChecklistFilter extends Filter {
fieldType: FieldType.Checklist;
data: ChecklistFilterData;
}
export interface DateFilter extends Filter {
fieldType: FieldType.DateTime | FieldType.CreatedTime | FieldType.LastEditedTime;
data: DateFilterData;
}
export interface ChecklistFilterData {
condition?: ChecklistFilterConditionPB;
}
export interface SelectFilterData {
condition?: SelectOptionConditionPB;
optionIds?: string[];
}
export type UndeterminedFilter = TextFilter | SelectFilter;
export interface NumberFilterData {
condition: NumberFilterConditionPB;
content?: string;
}
export interface DateFilterData {
condition: DateFilterConditionPB;
start?: number;
end?: number;
timestamp?: number;
}
export type UndeterminedFilter =
| TextFilter
| SelectFilter
| NumberFilter
| CheckboxFilter
| ChecklistFilter
| DateFilter;
export function filterDataToPB(data: UndeterminedFilter['data'], fieldType: FieldType) {
switch (fieldType) {
case FieldType.RichText:
case FieldType.URL:
return TextFilterPB.fromObject({
condition: (data as TextFilterData).condition,
content: (data as TextFilterData).content,
@ -49,6 +104,28 @@ export function filterDataToPB(data: UndeterminedFilter['data'], fieldType: Fiel
condition: (data as SelectFilterData).condition,
option_ids: (data as SelectFilterData).optionIds,
});
case FieldType.Number:
return NumberFilterPB.fromObject({
condition: (data as NumberFilterData).condition,
content: (data as NumberFilterData).content,
});
case FieldType.Checkbox:
return CheckboxFilterPB.fromObject({
condition: (data as CheckboxFilterData).condition,
});
case FieldType.Checklist:
return ChecklistFilterPB.fromObject({
condition: (data as ChecklistFilterData).condition,
});
case FieldType.DateTime:
case FieldType.CreatedTime:
case FieldType.LastEditedTime:
return DateFilterPB.fromObject({
condition: (data as DateFilterData).condition,
start: (data as DateFilterData).start,
end: (data as DateFilterData).end,
timestamp: (data as DateFilterData).timestamp,
});
}
}
@ -66,13 +143,52 @@ export function pbToSelectFilterData(pb: SelectOptionFilterPB): SelectFilterData
};
}
export function pbToNumberFilterData(pb: NumberFilterPB): NumberFilterData {
return {
condition: pb.condition,
content: pb.content,
};
}
export function pbToCheckboxFilterData(pb: CheckboxFilterPB): CheckboxFilterData {
return {
condition: pb.condition,
};
}
export function pbToChecklistFilterData(pb: ChecklistFilterPB): ChecklistFilterData {
return {
condition: pb.condition,
};
}
export function pbToDateFilterData(pb: DateFilterPB): DateFilterData {
return {
condition: pb.condition,
start: pb.start,
end: pb.end,
timestamp: pb.timestamp,
};
}
export function bytesToFilterData(bytes: Uint8Array, fieldType: FieldType) {
switch (fieldType) {
case FieldType.RichText:
case FieldType.URL:
return pbToTextFilterData(TextFilterPB.deserialize(bytes));
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return pbToSelectFilterData(SelectOptionFilterPB.deserialize(bytes));
case FieldType.Number:
return pbToNumberFilterData(NumberFilterPB.deserialize(bytes));
case FieldType.Checkbox:
return pbToCheckboxFilterData(CheckboxFilterPB.deserialize(bytes));
case FieldType.Checklist:
return pbToChecklistFilterData(ChecklistFilterPB.deserialize(bytes));
case FieldType.DateTime:
case FieldType.CreatedTime:
case FieldType.LastEditedTime:
return pbToDateFilterData(DateFilterPB.deserialize(bytes));
}
}

View File

@ -1,2 +1,3 @@
export * from './filter_types';
export * as filterService from './filter_service';
export * as filterListeners from './filter_listeners';

View File

@ -1,6 +1,7 @@
import { ReorderAllRowsPB, ReorderSingleRowPB, RowsChangePB, RowsVisibilityChangePB } from '@/services/backend';
import { Database } from '../database';
import { pbToRowMeta, RowMeta } from './row_types';
import { didDeleteCells } from '$app/components/database/application/cell/cell_listeners';
const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => {
changeset.deleted_rows.forEach((rowId) => {
@ -8,6 +9,8 @@ const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) =>
if (index !== -1) {
database.rowMetas.splice(index, 1);
// delete cells
didDeleteCells({ database, rowId });
}
});
};
@ -23,7 +26,7 @@ const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) =>
const found = database.rowMetas.find((rowMeta) => rowMeta.id === rowId);
if (found) {
Object.assign(found, pbToRowMeta(rowMetaPB));
Object.assign(found, rowMetaPB ? pbToRowMeta(rowMetaPB) : {});
}
});
};

View File

@ -1,23 +1,63 @@
import { useCallback, useEffect, useState } from 'react';
import { DatabaseNotification, FieldType } from '@/services/backend';
import { DatabaseNotification } from '@/services/backend';
import { useNotification, useViewId } from '$app/hooks';
import { cellService, Cell } from '../../application';
import { cellService, Cell, Field } from '../../application';
import { useDispatchCell, useSelectorCell } from '$app/components/database';
export const useCell = (rowId: string, fieldId: string, fieldType: FieldType) => {
export const useCell = (rowId: string, field: Field) => {
const viewId = useViewId();
const [cell, setCell] = useState<Cell | undefined>(undefined);
const { setCell } = useDispatchCell();
const [loading, setLoading] = useState(false);
const cell = useSelectorCell(rowId, field.id);
const fetchCell = useCallback(() => {
void cellService.getCell(viewId, rowId, fieldId, fieldType).then((data) => {
setLoading(true);
void cellService.getCell(viewId, rowId, field.id, field.type).then((data) => {
// cache cell
setCell(data);
setLoading(false);
});
}, [viewId, rowId, fieldId, fieldType]);
}, [viewId, rowId, field.id, field.type, setCell]);
useEffect(() => {
fetchCell();
}, [fetchCell]);
// fetch cell if not cached
if (!cell && !loading) {
// fetch cell in next tick to avoid blocking
const timeout = setTimeout(fetchCell, 0);
useNotification(DatabaseNotification.DidUpdateCell, fetchCell, { id: `${rowId}:${fieldId}` });
return () => {
clearTimeout(timeout);
};
}
}, [fetchCell, cell, loading]);
useNotification(DatabaseNotification.DidUpdateCell, fetchCell, { id: `${rowId}:${field.id}` });
return cell;
};
export const useInputCell = (cell?: Cell) => {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState('');
const viewId = useViewId();
const updateCell = useCallback(() => {
if (!cell) return;
const { rowId, fieldId } = cell;
if (editing) {
if (value !== cell.data) {
void cellService.updateCell(viewId, rowId, fieldId, value);
}
setEditing(false);
}
}, [cell, editing, value, viewId]);
return {
updateCell,
editing,
setEditing,
value,
setValue,
};
};

View File

@ -6,6 +6,11 @@ import { useCell } from './Cell.hooks';
import { TextCell } from './TextCell';
import { SelectCell } from './SelectCell';
import { CheckboxCell } from './CheckboxCell';
import NumberCell from '$app/components/database/components/cell/NumberCell';
import URLCell from '$app/components/database/components/cell/URLCell';
import ChecklistCell from '$app/components/database/components/cell/ChecklistCell';
import DateTimeCell from '$app/components/database/components/cell/DateTimeCell';
import TimestampCell from '$app/components/database/components/cell/TimestampCell';
export interface CellProps {
rowId: string;
@ -15,25 +20,44 @@ export interface CellProps {
placeholder?: string;
}
interface CellComponentProps {
field: Field;
cell: CellType;
}
const getCellComponent = (fieldType: FieldType) => {
switch (fieldType) {
case FieldType.RichText:
return TextCell as FC<{ field: Field; cell?: CellType }>;
return TextCell as FC<CellComponentProps>;
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return SelectCell as FC<{ field: Field; cell?: CellType }>;
return SelectCell as FC<CellComponentProps>;
case FieldType.Checkbox:
return CheckboxCell as FC<{ field: Field; cell?: CellType }>;
return CheckboxCell as FC<CellComponentProps>;
case FieldType.Checklist:
return ChecklistCell as FC<CellComponentProps>;
case FieldType.Number:
return NumberCell as FC<CellComponentProps>;
case FieldType.URL:
return URLCell as FC<CellComponentProps>;
case FieldType.DateTime:
return DateTimeCell as FC<CellComponentProps>;
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return TimestampCell as FC<CellComponentProps>;
default:
return null;
}
};
export const Cell: FC<CellProps> = ({ rowId, field, ...props }) => {
const cell = useCell(rowId, field.id, field.type);
const cell = useCell(rowId, field);
const Component = getCellComponent(field.type);
if (!cell) {
return <div className={`h-[36px] w-[${field.width}px]`} />;
}
if (!Component) {
return null;
}

View File

@ -1,5 +1,4 @@
import { Checkbox } from '@mui/material';
import { FC, useCallback } from 'react';
import React, { FC, useCallback } from 'react';
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
import { useViewId } from '$app/hooks';
@ -7,25 +6,18 @@ import { cellService, CheckboxCell as CheckboxCellType, Field } from '../../appl
export const CheckboxCell: FC<{
field: Field;
cell?: CheckboxCellType;
cell: CheckboxCellType;
}> = ({ field, cell }) => {
const viewId = useViewId();
const checked = cell?.data === 'Yes';
const checked = cell.data === 'Yes';
const handleClick = useCallback(() => {
if (!cell) return;
void cellService.updateCell(viewId, cell.rowId, field.id, !checked ? 'Yes' : 'No');
}, [viewId, cell, field.id, checked]);
return (
<div className='flex w-full cursor-pointer items-center px-2' onClick={handleClick}>
<Checkbox
disableRipple
style={{ padding: 0 }}
checked={checked}
icon={<CheckboxUncheckSvg />}
checkedIcon={<CheckboxCheckSvg />}
/>
<div className='relative flex w-full cursor-pointer items-center px-2 text-fill-default' onClick={handleClick}>
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
</div>
);
};

View File

@ -0,0 +1,55 @@
import React, { useState, Suspense, useMemo } from 'react';
import { ChecklistCell as ChecklistCellType, ChecklistField } from '$app/components/database/application';
import Typography from '@mui/material/Typography';
import ChecklistCellActions from '$app/components/database/components/field_types/checklist/ChecklistCellActions';
interface Props {
field: ChecklistField;
cell: ChecklistCellType;
}
function ChecklistCell({ cell }: Props) {
const value = cell?.data.percentage ?? 0;
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | undefined>(undefined);
const open = Boolean(anchorEl);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
setAnchorEl(e.currentTarget);
};
const handleClose = () => {
setAnchorEl(undefined);
};
const result = useMemo(() => `${Math.round(value * 100)}%`, [value]);
return (
<>
<div className='flex w-full cursor-pointer items-center px-2' onClick={handleClick}>
<Typography variant='body2' color='text.secondary'>
{result}
</Typography>
</div>
<Suspense>
{open && (
<ChecklistCellActions
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
cell={cell}
/>
)}
</Suspense>
</>
);
}
export default ChecklistCell;

View File

@ -0,0 +1,55 @@
import React, { Suspense, useRef, useState, useMemo } from 'react';
import { DateTimeCell as DateTimeCellType, DateTimeField } from '$app/components/database/application';
import DateTimeCellActions from '$app/components/database/components/field_types/date/DateTimeCellActions';
interface Props {
field: DateTimeField;
cell: DateTimeCellType;
placeholder?: string;
}
function DateTimeCell({ field, cell, placeholder }: Props) {
const isRange = cell.data.isRange;
const includeTime = cell.data.includeTime;
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const handleClose = () => {
setOpen(false);
};
const handleClick = () => {
setOpen(true);
};
const content = useMemo(() => {
const { date, time, endDate, endTime } = cell.data;
if (date) {
return (
<>
{date}
{includeTime && time ? ' ' + time : ''}
{isRange && endDate ? ' - ' + endDate : ''}
{isRange && includeTime && endTime ? ' ' + endTime : ''}
</>
);
}
return <div className={'text-sm text-text-placeholder'}>{placeholder}</div>;
}, [cell, includeTime, isRange, placeholder]);
return (
<>
<div ref={ref} className={'flex h-full w-full items-center px-2 text-xs font-medium'} onClick={handleClick}>
{content}
</div>
<Suspense>
{open && (
<DateTimeCellActions field={field} onClose={handleClose} anchorEl={ref.current} cell={cell} open={open} />
)}
</Suspense>
</>
);
}
export default DateTimeCell;

View File

@ -21,7 +21,12 @@ function ExpandButton({ cell, documentId, icon, visible }: Props) {
return (
<>
{visible && (
<div className={`mr-4 flex items-center justify-center`}>
<div
style={{
transform: 'translateY(-50%) translateZ(0)',
}}
className={`absolute right-0 top-1/2 mr-4 flex items-center justify-center`}
>
<IconButton onClick={() => setOpen(true)} className={'h-6 w-6 text-sm'}>
<OpenIcon />
</IconButton>

View File

@ -0,0 +1,50 @@
import React, { Suspense, useCallback, useMemo, useRef } from 'react';
import { Field, NumberCell as NumberCellType } from '$app/components/database/application';
import { CellText } from '$app/components/database/_shared';
import EditNumberCellInput from '$app/components/database/components/field_types/number/EditNumberCellInput';
import { useInputCell } from '$app/components/database/components/cell/Cell.hooks';
interface Props {
field: Field;
cell: NumberCellType;
placeholder?: string;
}
function NumberCell({ field, cell, placeholder }: Props) {
const cellRef = useRef<HTMLDivElement>(null);
const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell);
const content = useMemo(() => {
if (typeof cell.data === 'string' && cell.data) {
return cell.data;
}
return <div className={'text-sm text-text-placeholder'}>{placeholder}</div>;
}, [cell, placeholder]);
const handleClick = useCallback(() => {
setValue(cell.data);
setEditing(true);
}, [cell, setEditing, setValue]);
return (
<>
<CellText className={'min-h-[36px]'} ref={cellRef} onClick={handleClick}>
<div className='flex h-full w-full items-center'>{content}</div>
</CellText>
<Suspense>
{editing && (
<EditNumberCellInput
editing={editing}
anchorEl={cellRef.current}
width={field?.width}
onClose={updateCell}
value={value}
onChange={setValue}
/>
)}
</Suspense>
</>
);
}
export default NumberCell;

View File

@ -0,0 +1,76 @@
import { FC, useCallback, useMemo, useState, Suspense, lazy } from 'react';
import { MenuProps, Menu } from '@mui/material';
import { SelectField, SelectCell as SelectCellType, SelectTypeOption } from '../../application';
import { Tag } from '../field_types/select/Tag';
import { useTypeOption } from '$app/components/database';
const SelectCellActions = lazy(
() => import('$app/components/database/components/field_types/select/select_cell_actions/SelectCellActions')
);
const menuProps: Partial<MenuProps> = {
classes: {
list: 'py-5',
},
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
};
export const SelectCell: FC<{
field: SelectField;
cell: SelectCellType;
placeholder?: string;
}> = ({ field, cell, placeholder }) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const selectedIds = useMemo(() => cell.data?.selectedOptionIds ?? [], [cell]);
const open = Boolean(anchorEl);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const typeOption = useTypeOption<SelectTypeOption>(field.id);
const renderSelectedOptions = useCallback(
(selected: string[]) =>
selected
.map((id) => typeOption.options?.find((option) => option.id === id))
.map((option) => option && <Tag key={option.id} size='small' color={option.color} label={option.name} />),
[typeOption]
);
return (
<div className={'relative w-full'}>
<div
onClick={(e) => {
setAnchorEl(e.currentTarget);
}}
className={'flex h-full w-full cursor-pointer items-center gap-2 overflow-x-hidden px-2 py-1'}
>
{selectedIds.length === 0 ? (
<div className={'text-sm text-text-placeholder'}>{placeholder}</div>
) : (
renderSelectedOptions(selectedIds)
)}
</div>
<Suspense>
{open ? (
<Menu
keepMounted={false}
className='h-full w-full'
open={open}
anchorEl={anchorEl}
{...menuProps}
onClose={handleClose}
>
<SelectCellActions field={field} cell={cell} />
</Menu>
) : null}
</Suspense>
</div>
);
};

View File

@ -1,22 +0,0 @@
import { MenuItem, MenuItemProps } from '@mui/material';
import { FC } from 'react';
import { Tag } from './Tag';
export interface CreateOptionProps {
label: React.ReactNode;
onClick?: MenuItemProps['onClick'];
}
export const CreateOption: FC<CreateOptionProps> = ({
label,
onClick,
}) => {
return (
<MenuItem
className="mt-2"
onClick={onClick}
>
<Tag className="ml-2" size="small" label={label} />
</MenuItem>
);
};

View File

@ -1,146 +0,0 @@
import { FC, FormEvent, useCallback, useMemo, useState } from 'react';
import { t } from 'i18next';
import { ListSubheader, Select, OutlinedInput, SelectChangeEvent, InputBase, MenuProps, MenuItem } from '@mui/material';
import { FieldType } from '@/services/backend';
import { useViewId } from '$app/hooks';
import { cellService, SelectField, SelectCell as SelectCellType } from '../../../application';
import { Tag } from './Tag';
import { CreateOption } from './CreateOption';
import { SelectOptionItem } from './SelectOptionItem';
const menuProps: Partial<MenuProps> = {
classes: {
list: 'py-5',
},
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
};
export const SelectCell: FC<{
field: SelectField;
cell?: SelectCellType;
}> = ({ field, cell }) => {
const [open, setOpen] = useState(false);
const rowId = cell?.rowId;
const viewId = useViewId();
const options = useMemo(() => field.typeOption.options ?? [], [field.typeOption.options]);
const selectedIds = useMemo(() => cell?.data.selectedOptionIds ?? [], [cell?.data.selectedOptionIds]);
const [newOptionName, setNewOptionName] = useState('');
const filteredOptions = useMemo(
() =>
options.filter((option) => {
return option.name.toLowerCase().includes(newOptionName.toLowerCase());
}),
[options, newOptionName]
);
const shouldCreateOption = !!newOptionName && filteredOptions.length === 0;
const handleInput = useCallback((event: FormEvent) => {
const value = (event.target as HTMLInputElement).value;
setNewOptionName(value);
}, []);
const handleClose = useCallback(() => {
setNewOptionName('');
setOpen(false);
}, []);
const handleChange = (event: SelectChangeEvent<string | string[]>) => {
if (!cell || !rowId) return;
const {
target: { value },
} = event;
const current = Array.isArray(value) ? value : [value];
const prev = cell.data.selectedOptionIds;
const deleteOptionIds = prev?.filter((id) => current.find((cur) => cur === id) === undefined);
void cellService.updateSelectCell(viewId, rowId, field.id, {
insertOptionIds: current,
deleteOptionIds,
});
};
const handleNewTagClick = async () => {
if (!cell || !rowId) return;
const exist = options.find((option) => option.name.toLowerCase() === newOptionName.toLowerCase());
if (exist) {
return cellService.updateSelectCell(viewId, rowId, field.id, {
insertOptionIds: [exist.id],
});
}
// const option = await cellService.createSelectOption(viewId, field.id, newOptionName);
// await cellService.insertOrUpdateSelectOption(viewId, field.id, [option], rowId);
};
const searchInput = (
<ListSubheader className='flex'>
<OutlinedInput
size='small'
value={newOptionName}
onInput={handleInput}
placeholder={t('grid.selectOption.searchOrCreateOption')}
/>
</ListSubheader>
);
const renderSelectedOptions = useCallback(
(selected: string[]) =>
selected
.map((id) => options.find((option) => option.id === id))
.map((option) => option && <Tag key={option.id} size='small' color={option.color} label={option.name} />),
[options]
);
return (
<div className={'relative w-full'}>
<div
onClick={() => {
setOpen(true);
}}
className={'absolute left-0 top-0 flex h-full w-full items-center gap-2 px-4 py-1'}
>
{renderSelectedOptions(selectedIds)}
</div>
{open ? (
<Select
className='h-full w-full'
size='small'
value={selectedIds}
open={open}
multiple={field.type === FieldType.MultiSelect}
input={<InputBase />}
IconComponent={() => null}
MenuProps={menuProps}
onChange={handleChange}
onClose={handleClose}
>
{searchInput}
<ListSubheader className='mb-2 mt-4 text-xs'>
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
</ListSubheader>
{shouldCreateOption ? (
<CreateOption label={newOptionName} onClick={handleNewTagClick} />
) : (
filteredOptions.map((option, index) => (
<MenuItem className={index === 0 ? '' : 'mt-2'} key={option.id} value={option.id}>
<SelectOptionItem option={option} />
</MenuItem>
))
)}
</Select>
) : null}
</div>
);
};

View File

@ -1,49 +0,0 @@
import { FC, MouseEventHandler, useCallback, useRef, useState } from 'react';
import { IconButton } from '@mui/material';
import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
import { SelectOption } from '../../../application';
import { SelectOptionMenu } from './SelectOptionMenu';
import { Tag } from './Tag';
export interface SelectOptionItemProps {
option: SelectOption;
}
export const SelectOptionItem: FC<SelectOptionItemProps> = ({
option,
}) => {
const [open, setOpen] = useState(false);
const anchorEl = useRef<HTMLButtonElement | null>(null);
const handleClick = useCallback<MouseEventHandler<HTMLButtonElement>>((event) => {
event.stopPropagation();
anchorEl.current = event.target as HTMLButtonElement;
setOpen(true);
}, []);
return (
<>
<div className="flex-1">
<Tag
key={option.id}
size="small"
color={option.color}
label={option.name}
/>
</div>
<IconButton onClick={handleClick}>
<DetailsSvg className="text-base" />
</IconButton>
{open && (
<SelectOptionMenu
open={open}
option={option}
MenuProps={{
anchorEl: anchorEl.current,
onClose: () => setOpen(false),
}}
/>
)}
</>
);
};

View File

@ -1,78 +0,0 @@
import { FC } from 'react';
import { t } from 'i18next';
import {
Divider,
ListSubheader,
Menu,
MenuItem,
MenuProps,
OutlinedInput,
} from '@mui/material';
import { SelectOptionColorPB } from '@/services/backend';
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
import { SelectOption } from '../../../application';
import { SelectOptionColorMap, SelectOptionColorTextMap } from './constants';
interface SelectOptionMenuProps {
option: SelectOption;
open: boolean;
MenuProps?: Partial<MenuProps>;
}
const Colors = [
SelectOptionColorPB.Purple,
SelectOptionColorPB.Pink,
SelectOptionColorPB.LightPink,
SelectOptionColorPB.Orange,
SelectOptionColorPB.Yellow,
SelectOptionColorPB.Lime,
SelectOptionColorPB.Green,
SelectOptionColorPB.Aqua,
SelectOptionColorPB.Blue,
];
export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({
open,
option,
MenuProps: menuProps,
}) => {
return (
<Menu
classes={{
paper: 'w-52',
}}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'center',
horizontal: -32,
}}
{...menuProps}
open={open}
>
<ListSubheader className="leading-tight">
<OutlinedInput size="small" />
</ListSubheader>
<MenuItem>
<DeleteSvg className="mr-2 text-base" />
{t('grid.selectOption.deleteTag')}
</MenuItem>
<Divider />
<MenuItem disabled>{t('grid.selectOption.colorPanelTitle')}</MenuItem>
{Colors.map(color => (
<MenuItem key={color} value={color}>
<span className={`inline-flex w-4 h-4 mr-2 rounded-full ${SelectOptionColorMap[color]}`} />
<span className="flex-1">
{t(`grid.selectOption.${SelectOptionColorTextMap[color]}`)}
</span>
{option.color === color && (
<SelectCheckSvg />
)}
</MenuItem>
))}
</Menu>
);
};

View File

@ -1,57 +1,45 @@
import { FC, FormEventHandler, Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useViewId } from '$app/hooks';
import { cellService, Field, TextCell as TextCellType } from '../../application';
import { FC, FormEventHandler, Suspense, lazy, useCallback, useEffect, useRef, useMemo } from 'react';
import { Field, TextCell as TextCellType } from '../../application';
import { CellText } from '../../_shared';
import { useGridUIStateDispatcher, useGridUIStateSelector } from '$app/components/database/proxy/grid/ui_state/actions';
import { useInputCell } from '$app/components/database/components/cell/Cell.hooks';
const ExpandButton = lazy(() => import('$app/components/database/components/cell/ExpandButton'));
const EditTextCellInput = lazy(() => import('$app/components/database/components/cell/EditTextCellInput'));
const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput'));
export const TextCell: FC<{
field: Field;
cell?: TextCellType;
cell: TextCellType;
documentId?: string;
icon?: string;
placeholder?: string;
}> = ({ field, cell, documentId, icon, placeholder }) => {
}> = ({ field, documentId, icon, placeholder, cell }) => {
const isPrimary = field.isPrimary;
const viewId = useViewId();
const cellRef = useRef<HTMLDivElement>(null);
const [editing, setEditing] = useState(false);
const [text, setText] = useState('');
const [width, setWidth] = useState<number | undefined>(undefined);
const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell);
const { hoverRowId } = useGridUIStateSelector();
const isHover = hoverRowId === cell?.rowId;
const { setRowHover } = useGridUIStateDispatcher();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const showExpandIcon = cell && !editing && isHover && isPrimary;
const handleClose = () => {
if (!cell) return;
if (editing) {
if (text !== cell.data) {
void cellService.updateCell(viewId, cell.rowId, field.id, text);
}
setEditing(false);
}
updateCell();
};
const handleClick = useCallback(() => {
if (!cell) return;
setText(cell.data);
setValue(cell.data);
setEditing(true);
}, [cell]);
}, [cell, setEditing, setValue]);
const handleInput = useCallback<FormEventHandler<HTMLTextAreaElement>>((event) => {
setText((event.target as HTMLTextAreaElement).value);
}, []);
useLayoutEffect(() => {
if (cellRef.current) {
setWidth(cellRef.current.clientWidth);
}
}, [editing]);
const handleInput = useCallback<FormEventHandler<HTMLTextAreaElement>>(
(event) => {
setValue((event.target as HTMLTextAreaElement).value);
},
[setValue]
);
useEffect(() => {
if (editing) {
@ -59,20 +47,27 @@ export const TextCell: FC<{
}
}, [editing, setRowHover]);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus();
// set the cursor to the end of the text
textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
const content = useMemo(() => {
if (cell && typeof cell.data === 'string' && cell.data) {
return cell.data;
}
}, []);
return <div className={'text-text-placeholder'}>{placeholder}</div>;
}, [cell, placeholder]);
return (
<>
<CellText ref={cellRef} onClick={handleClick}>
<div className='flex w-full items-center'>
<div className={'relative h-full'}>
<CellText
style={{
width: `${field.width}px`,
minHeight: 37,
}}
ref={cellRef}
onClick={handleClick}
>
<div className={`flex h-full w-full items-center whitespace-break-spaces break-all`}>
{icon && <div className={'mr-2'}>{icon}</div>}
{cell?.data || <div className={'text-text-placeholder'}>{placeholder}</div>}
{content}
</div>
</CellText>
<Suspense>
@ -81,13 +76,13 @@ export const TextCell: FC<{
<EditTextCellInput
editing={editing}
anchorEl={cellRef.current}
width={width}
width={field.width}
onClose={handleClose}
text={text}
text={value}
onInput={handleInput}
/>
)}
</Suspense>
</>
</div>
);
};

View File

@ -0,0 +1,13 @@
import React from 'react';
import { CreatedTimeField, LastEditedTimeField, TimeStampCell } from '$app/components/database/application';
interface Props {
field: LastEditedTimeField | CreatedTimeField;
cell: TimeStampCell;
}
function TimestampCell({ cell }: Props) {
return <div className={'flex h-full w-full items-center p-2 text-xs font-medium'}>{cell.data.dataTime}</div>;
}
export default TimestampCell;

View File

@ -0,0 +1,92 @@
import React, { FormEventHandler, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useInputCell } from '$app/components/database/components/cell/Cell.hooks';
import { Field, UrlCell as URLCellType } from '$app/components/database/application';
import { CellText } from '$app/components/database/_shared';
const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput'));
const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
interface Props {
field: Field;
cell: URLCellType;
placeholder?: string;
}
function UrlCell({ field, cell, placeholder }: Props) {
const [isUrl, setIsUrl] = useState(false);
const cellRef = useRef<HTMLDivElement>(null);
const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell);
const handleClick = useCallback(() => {
setValue(cell.data.content || '');
setEditing(true);
}, [cell, setEditing, setValue]);
const handleClose = () => {
updateCell();
};
const handleInput = useCallback<FormEventHandler<HTMLTextAreaElement>>(
(event) => {
setValue((event.target as HTMLTextAreaElement).value);
},
[setValue]
);
useEffect(() => {
if (editing) return;
const str = cell.data.content;
if (!str) return;
const isUrl = pattern.test(str);
setIsUrl(isUrl);
}, [cell, editing]);
const content = useMemo(() => {
const str = cell.data.content;
if (str) {
if (isUrl) {
return (
<a href={str} target={'_blank'} className={'cursor-pointer text-content-blue-400 underline'}>
{str}
</a>
);
}
return str;
}
return <div className={'text-sm text-text-placeholder'}>{placeholder}</div>;
}, [isUrl, cell, placeholder]);
return (
<>
<CellText
style={{
width: `${field.width}px`,
minHeight: 37,
}}
ref={cellRef}
onClick={handleClick}
>
<div className={`flex w-full items-center whitespace-break-spaces break-all `}>{content}</div>
</CellText>
<Suspense>
{editing && (
<EditTextCellInput
editing={editing}
anchorEl={cellRef.current}
width={field.width}
onClose={handleClose}
text={value}
onInput={handleInput}
/>
)}
</Suspense>
</>
);
}
export default UrlCell;

View File

@ -31,4 +31,4 @@ function DatabaseSettings(props: Props) {
);
}
export default React.memo(DatabaseSettings);
export default DatabaseSettings;

View File

@ -1,13 +1,13 @@
import React, { useState } from 'react';
import { TextButton } from '$app/components/database/components/tab_bar/TextButton';
import { useTranslation } from 'react-i18next';
import { useDatabase } from '$app/components/database';
import { useFiltersCount } from '$app/components/database';
import FilterFieldsMenu from '$app/components/database/components/filter/FilterFieldsMenu';
function FilterSettings({ onToggleCollection }: { onToggleCollection: (forceOpen?: boolean) => void }) {
const { t } = useTranslation();
const { filters } = useDatabase();
const highlight = filters && filters.length > 0;
const filtersCount = useFiltersCount();
const highlight = filtersCount > 0;
const [filterAnchorEl, setFilterAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(filterAnchorEl);
@ -31,6 +31,10 @@ function FilterSettings({ onToggleCollection }: { onToggleCollection: (forceOpen
open={open}
anchorEl={filterAnchorEl}
onClose={() => setFilterAnchorEl(null)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
/>
</>
);

View File

@ -19,10 +19,13 @@ function Properties({ onItemClick }: PropertiesProps) {
<MenuItem
disabled={field.isPrimary}
onClick={() => onItemClick(field)}
className={'flex w-full items-center justify-between'}
className={'flex w-full items-center justify-between overflow-hidden px-1.5'}
key={field.id}
>
<Field field={field} />
<div className={'w-[100px] overflow-hidden text-ellipsis'}>
<Field field={field} />
</div>
<div className={'ml-2'}>{field.visibility !== FieldVisibility.AlwaysHidden ? <EyeOpen /> : <EyeClosed />}</div>
</MenuItem>
))}

View File

@ -39,6 +39,10 @@ function SortSettings({ onToggleCollection }: Props) {
open={open}
anchorEl={sortAnchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
/>
</>
);

View File

@ -4,7 +4,8 @@ import RecordDocument from '$app/components/database/components/edit_record/Reco
import RecordHeader from '$app/components/database/components/edit_record/RecordHeader';
import { Page } from '$app_reducers/pages/slice';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { ViewLayoutPB } from '@/services/backend';
import { ErrorCode, ViewLayoutPB } from '@/services/backend';
import { Log } from '$app/utils/log';
interface Props {
cell: TextCell;
@ -26,7 +27,7 @@ function EditRecord({ documentId: id, cell, icon }: Props) {
// Record not found
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (e.code === 3) {
if (e.code === ErrorCode.RecordNotFound) {
try {
const page = await controller.createOrphanPage({
name: '',
@ -35,7 +36,7 @@ function EditRecord({ documentId: id, cell, icon }: Props) {
setPage(page);
} catch (e) {
console.error(e);
Log.error(e);
}
}
}

View File

@ -1,25 +1,28 @@
import React, { MouseEvent, useCallback, useState } from 'react';
import { Field, fieldService } from '$app/components/database/application';
import React, { MouseEvent, useCallback, useMemo, useState } from 'react';
import { fieldService } from '$app/components/database/application';
import { FieldType } from '@/services/backend';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useViewId } from '$app/hooks';
import { FieldMenu } from '$app/components/database/components/field/FieldMenu';
import { useDatabase } from '$app/components/database';
function NewProperty() {
const viewId = useViewId();
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const [updateField, setUpdateField] = useState<Field | null>(null);
const [updateFieldId, setUpdateFieldId] = useState<string>('');
const { fields } = useDatabase();
const updateField = useMemo(() => fields.find((field) => field.id === updateFieldId), [fields, updateFieldId]);
const handleClick = useCallback(
async (e: MouseEvent<HTMLButtonElement>) => {
try {
const field = await fieldService.createField(viewId, FieldType.RichText);
setUpdateField(field);
setUpdateFieldId(field.id);
setAnchorEl(e.target as HTMLButtonElement);
} catch (e) {
// toast.error(t('grid.field.newPropertyFail'));
@ -39,7 +42,7 @@ function NewProperty() {
anchorEl={anchorEl}
open={open}
onClose={() => {
setUpdateField(null);
setUpdateFieldId('');
setAnchorEl(null);
}}
/>

View File

@ -32,7 +32,7 @@ function Property({ field, rowId, ishovered, onHover, ...props }: Props, ref: Re
onMouseLeave={() => {
onHover(null);
}}
className={'relative flex gap-6 rounded hover:bg-content-blue-50'}
className={'relative flex items-start gap-6 rounded hover:bg-content-blue-50'}
key={field.id}
{...props}
>

View File

@ -21,7 +21,7 @@ function PropertyName({ field, openMenu, onOpenMenu, onCloseMenu }: Props) {
e.preventDefault();
onOpenMenu();
}}
className={'flex w-[200px] cursor-pointer items-center'}
className={'flex min-h-[36px] w-[200px] cursor-pointer items-center'}
onClick={onOpenMenu}
>
<Field field={field} />
@ -31,4 +31,4 @@ function PropertyName({ field, openMenu, onOpenMenu, onCloseMenu }: Props) {
);
}
export default React.memo(PropertyName);
export default PropertyName;

View File

@ -1,14 +1,31 @@
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Cell } from '$app/components/database/components';
import { Field } from '$app/components/database/application';
import { useTranslation } from 'react-i18next';
function PropertyValue(props: { rowId: string; field: Field }) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(props.field.width);
useEffect(() => {
const el = ref.current;
if (!el) return;
const width = el.getBoundingClientRect().width;
setWidth(width);
}, []);
return (
<div className={'flex h-9 flex-1 items-center'}>
<Cell placeholder={t('grid.row.textPlaceholder')} {...props} />
<div ref={ref} className={'flex min-h-[36px] flex-1 items-center'}>
<Cell
placeholder={t('grid.row.textPlaceholder')}
{...props}
field={{
...props.field,
width,
}}
/>
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Field, fieldService, TextCell } from '$app/components/database/application';
import { useDatabase } from '$app/components/database';
import { FieldVisibility } from '@/services/backend';
@ -11,6 +11,7 @@ import PropertyList from '$app/components/database/components/edit_record/record
import NewProperty from '$app/components/database/components/edit_record/record_properties/NewProperty';
import { useViewId } from '$app/hooks';
import { DragDropContext, Droppable, DropResult, OnDragEndResponder } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next';
interface Props {
documentId?: string;
@ -28,6 +29,7 @@ const reorder = (list: Field[], startIndex: number, endIndex: number) => {
};
function RecordProperties({ documentId, cell }: Props) {
const { t } = useTranslation();
const viewId = useViewId();
const { fieldId, rowId } = cell;
const { fields } = useDatabase();
@ -41,8 +43,18 @@ function RecordProperties({ documentId, cell }: Props) {
});
}, [fieldId, fields, showHiddenFields]);
const hiddenFieldsCount = useMemo(() => {
return fields.filter((field) => {
return field.visibility === FieldVisibility.AlwaysHidden;
}).length;
}, [fields]);
const [state, setState] = useState<Field[]>(properties);
useEffect(() => {
setState(properties);
}, [properties]);
// move the field in the database
const onMoveProperty = useCallback(
async (fieldId: string, prevId?: string) => {
@ -103,19 +115,31 @@ function RecordProperties({ documentId, cell }: Props) {
)}
</Droppable>
</DragDropContext>
<Button
onClick={() => {
setShowHiddenFields((prev) => !prev);
}}
className={'w-full justify-start'}
startIcon={showHiddenFields ? <EyeClosedSvg /> : <EyeOpenSvg />}
color={'inherit'}
>
{showHiddenFields ? 'Hide hidden fields' : 'Show hidden fields'}
</Button>
{
// show the button only if there are hidden fields
hiddenFieldsCount > 0 && (
<Button
onClick={() => {
setShowHiddenFields((prev) => !prev);
}}
className={'w-full justify-start'}
startIcon={showHiddenFields ? <EyeClosedSvg /> : <EyeOpenSvg />}
color={'inherit'}
>
{showHiddenFields
? t('grid.rowPage.hideHiddenFields', {
count: hiddenFieldsCount,
})
: t('grid.rowPage.showHiddenFields', {
count: hiddenFieldsCount,
})}
</Button>
)
}
<NewProperty />
</div>
);
}
export default React.memo(RecordProperties);
export default RecordProperties;

View File

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

View File

@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Input, MenuItem } from '@mui/material';
import { OutlinedInput, MenuItem, MenuList } from '@mui/material';
import { Field } from '$app/components/database/components/field/Field';
import { Field as FieldType } from '../../application';
import { useDatabase } from '$app/components/database';
@ -26,34 +26,37 @@ function FieldList({ showSearch, onItemClick, searchPlaceholder }: FieldListProp
const searchInput = useMemo(() => {
return showSearch ? (
<div className={'w-full px-8 py-2'}>
<Input placeholder={searchPlaceholder} onChange={onInputChange} />
<div className={'w-[220px] px-4 pt-2'}>
<OutlinedInput size={'small'} autoFocus={true} placeholder={searchPlaceholder} onChange={onInputChange} />
</div>
) : null;
}, [onInputChange, searchPlaceholder, showSearch]);
const emptyList = useMemo(() => {
return fieldsResult.length === 0 ? (
<div className={'px-8 py-4 text-center text-gray-500'}>No fields found</div>
<div className={'px-4 pt-3 text-center text-sm font-medium text-gray-500'}>No fields found</div>
) : null;
}, [fieldsResult]);
return (
<>
<div className={'pt-2'}>
{searchInput}
{emptyList}
{fieldsResult.map((field) => (
<MenuItem
key={field.id}
value={field.id}
onClick={(event) => {
onItemClick?.(event, field);
}}
>
<Field field={field} />
</MenuItem>
))}
</>
<MenuList className={'max-h-[300px] overflow-y-auto overflow-x-hidden'}>
{fieldsResult.map((field) => (
<MenuItem
className={'overflow-hidden text-ellipsis px-1'}
key={field.id}
value={field.id}
onClick={(event) => {
onItemClick?.(event, field);
}}
>
<Field field={field} />
</MenuItem>
))}
</MenuList>
</div>
);
}

View File

@ -1,10 +1,14 @@
import { Divider, Menu, MenuItem, MenuProps, OutlinedInput } from '@mui/material';
import { Divider, MenuList, MenuProps } from '@mui/material';
import { ChangeEventHandler, FC, useCallback, useState } from 'react';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { useViewId } from '$app/hooks';
import { Field, fieldService } from '../../application';
import { FieldMenuActions } from './FieldMenuActions';
import { FieldTypeText, FieldTypeSvg } from '$app/components/database/components/field/index';
import FieldTypeMenuExtension from '$app/components/database/components/field/FieldTypeMenuExtension';
import FieldTypeSelect from '$app/components/database/components/field/FieldTypeSelect';
import { FieldType } from '@/services/backend';
import { Log } from '$app/utils/log';
import TextField from '@mui/material/TextField';
import Popover from '@mui/material/Popover';
export interface GridFieldMenuProps {
field: Field;
@ -29,44 +33,60 @@ export const FieldMenu: FC<GridFieldMenuProps> = ({ field, anchorEl, open, onClo
});
} catch (e) {
// TODO
console.error(`change field ${field.id} name from '${field.name}' to ${inputtingName} fail`, e);
Log.error(`change field ${field.id} name from '${field.name}' to ${inputtingName} fail`, e);
}
}
}, [viewId, field, inputtingName]);
const fieldNameInput = (
<OutlinedInput
className='mx-3 mb-5 mt-1 !rounded-[10px]'
size='small'
value={inputtingName}
onChange={handleInput}
onBlur={handleBlur}
/>
);
const fieldTypeSelect = (
<MenuItem dense>
<FieldTypeSvg type={field.type} className='mr-2 text-base' />
<span className='flex-1 text-xs font-medium'>
<FieldTypeText type={field.type} />
</span>
<MoreSvg className='text-base' />
</MenuItem>
);
const isPrimary = field.isPrimary;
return (
<Menu keepMounted={false} anchorEl={anchorEl} open={open} onClose={onClose}>
{fieldNameInput}
{!isPrimary && (
<div>
{fieldTypeSelect}
<Divider />
</div>
)}
const onUpdateFieldType = useCallback(
async (type: FieldType) => {
try {
await fieldService.updateFieldType(viewId, field.id, type);
} catch (e) {
// TODO
Log.error(`change field ${field.id} type from '${field.type}' to ${type} fail`, e);
}
},
[viewId, field]
);
<FieldMenuActions isPrimary={isPrimary} onMenuItemClick={() => onClose()} fieldId={field.id} />
</Menu>
return (
<Popover
transformOrigin={{
vertical: -4,
horizontal: 'left',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
keepMounted={false}
anchorEl={anchorEl}
open={open}
onClose={onClose}
>
<TextField
className='mx-3 mt-3 rounded-[10px]'
size='small'
autoFocus={true}
value={inputtingName}
onChange={handleInput}
onBlur={handleBlur}
/>
<MenuList>
<div>
{!isPrimary && (
<>
<FieldTypeSelect field={field} onUpdateFieldType={onUpdateFieldType} />
<Divider className={'my-2'} />
</>
)}
<FieldTypeMenuExtension field={field} />
<FieldMenuActions isPrimary={isPrimary} onMenuItemClick={() => onClose()} fieldId={field.id} />
</div>
</MenuList>
</Popover>
);
};

View File

@ -30,9 +30,12 @@ export const FieldSelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
alignItems: 'center',
},
}}
MenuProps={{
className: 'max-w-[150px]',
}}
>
{fields.map((field) => (
<MenuItem key={field.id} value={field.id}>
<MenuItem className={'overflow-hidden text-ellipsis px-1.5'} key={field.id} value={field.id}>
<Field field={field} />
</MenuItem>
))}

View File

@ -2,6 +2,8 @@ import { Divider, Menu, MenuItem, MenuProps } from '@mui/material';
import { FC, useMemo } from 'react';
import { FieldType } from '@/services/backend';
import { FieldTypeText, FieldTypeSvg } from '$app/components/database/components/field/index';
import { Field } from '$app/components/database/application';
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
const FieldTypeGroup = [
{
@ -14,15 +16,21 @@ const FieldTypeGroup = [
FieldType.DateTime,
FieldType.Checkbox,
FieldType.Checklist,
FieldType.URL,
],
},
{
name: 'Advanced',
types: [FieldType.LastEditedTime],
types: [FieldType.LastEditedTime, FieldType.CreatedTime],
},
];
export const FieldTypeMenu: FC<MenuProps> = (props) => {
export const FieldTypeMenu: FC<
MenuProps & {
field: Field;
onClickItem?: (type: FieldType) => void;
}
> = ({ field, onClickItem, ...props }) => {
const PopoverClasses = useMemo(
() => ({
...props.PopoverClasses,
@ -38,11 +46,12 @@ export const FieldTypeMenu: FC<MenuProps> = (props) => {
{group.name}
</MenuItem>,
group.types.map((type) => (
<MenuItem key={type} dense>
<MenuItem onClick={() => onClickItem?.(type)} key={type} dense className={'flex justify-between'}>
<FieldTypeSvg className='mr-2 text-base' type={type} />
<span className='font-medium'>
<span className='flex-1 font-medium'>
<FieldTypeText type={type} />
</span>
{type === field.type && <SelectCheckSvg />}
</MenuItem>
)),
index < FieldTypeGroup.length - 1 && <Divider key={`Divider-${group.name}`} />,

View File

@ -0,0 +1,26 @@
import React, { useMemo } from 'react';
import { FieldType } from '@/services/backend';
import { DateTimeField, Field, NumberField, SelectField } from '$app/components/database/application';
import SelectFieldActions from '$app/components/database/components/field_types/select/select_field_actions/SelectFieldActions';
import NumberFieldActions from '$app/components/database/components/field_types/number/NumberFieldActions';
import DateTimeFieldActions from '$app/components/database/components/field_types/date/DateTimeFieldActions';
function FieldTypeMenuExtension({ field }: { field: Field }) {
return useMemo(() => {
switch (field.type) {
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return <SelectFieldActions field={field as SelectField} />;
case FieldType.Number:
return <NumberFieldActions field={field as NumberField} />;
case FieldType.DateTime:
case FieldType.CreatedTime:
case FieldType.LastEditedTime:
return <DateTimeFieldActions field={field as DateTimeField} />;
default:
return null;
}
}, [field]);
}
export default FieldTypeMenuExtension;

View File

@ -0,0 +1,57 @@
import React, { useRef, useState } from 'react';
import { FieldTypeSvg } from '$app/components/database/components/field/FieldTypeSvg';
import { MenuItem } from '@mui/material';
import { Field } from '$app/components/database/application';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { FieldTypeMenu } from '$app/components/database/components/field/FieldTypeMenu';
import { FieldType } from '@/services/backend';
import { FieldTypeText } from '$app/components/database/components/field/FieldTypeText';
interface Props {
field: Field;
onUpdateFieldType: (type: FieldType) => void;
}
function FieldTypeSelect({ field, onUpdateFieldType }: Props) {
const [expanded, setExpanded] = useState(false);
const ref = useRef<HTMLLIElement>(null);
return (
<div className={'px-1'}>
<MenuItem
ref={ref}
onClick={() => {
setExpanded(!expanded);
}}
className={'px-23 mx-0'}
>
<FieldTypeSvg type={field.type} className='mr-2 text-base' />
<span className='flex-1 text-xs font-medium'>
<FieldTypeText type={field.type} />
</span>
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
</MenuItem>
{expanded && (
<FieldTypeMenu
keepMounted={false}
field={field}
onClickItem={onUpdateFieldType}
open={expanded}
anchorEl={ref.current}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
onClose={() => {
setExpanded(false);
}}
/>
)}
</div>
);
}
export default FieldTypeSelect;

View File

@ -1,39 +1,26 @@
import { FieldType } from '@/services/backend';
import { useTranslation } from 'react-i18next';
import { useCallback } from 'react';
import { useMemo } from 'react';
export const FieldTypeText = ({ type }: { type: FieldType }) => {
const { t } = useTranslation();
const getText = useCallback(
(type: FieldType) => {
switch (type) {
case FieldType.RichText:
return t('grid.field.textFieldName');
case FieldType.Number:
return t('grid.field.numberFieldName');
case FieldType.DateTime:
return t('grid.field.dateFieldName');
case FieldType.SingleSelect:
return t('grid.field.singleSelectFieldName');
case FieldType.MultiSelect:
return t('grid.field.multiSelectFieldName');
case FieldType.Checkbox:
return t('grid.field.checkboxFieldName');
case FieldType.URL:
return t('grid.field.urlFieldName');
case FieldType.Checklist:
return t('grid.field.checklistFieldName');
case FieldType.LastEditedTime:
return t('grid.field.updatedAtFieldName');
case FieldType.CreatedTime:
return t('grid.field.createdAtFieldName');
default:
return '';
}
},
[t]
);
const text = useMemo(() => {
const map = {
[FieldType.RichText]: t('grid.field.textFieldName'),
[FieldType.Number]: t('grid.field.numberFieldName'),
[FieldType.DateTime]: t('grid.field.dateFieldName'),
[FieldType.SingleSelect]: t('grid.field.singleSelectFieldName'),
[FieldType.MultiSelect]: t('grid.field.multiSelectFieldName'),
[FieldType.Checkbox]: t('grid.field.checkboxFieldName'),
[FieldType.URL]: t('grid.field.urlFieldName'),
[FieldType.Checklist]: t('grid.field.checklistFieldName'),
[FieldType.LastEditedTime]: t('grid.field.updatedAtFieldName'),
[FieldType.CreatedTime]: t('grid.field.createdAtFieldName'),
};
return <>{getText(type)}</>;
return map[type] || 'unknown';
}, [t, type]);
return <div>{text}</div>;
};

View File

@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { updateChecklistCell } from '$app/components/database/application/cell/cell_service';
import { useViewId } from '$app/hooks';
import { ReactComponent as AddIcon } from '$app/assets/add.svg';
import { IconButton } from '@mui/material';
import { useTranslation } from 'react-i18next';
function AddNewOption({ rowId, fieldId }: { rowId: string; fieldId: string }) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const viewId = useViewId();
const createOption = async () => {
await updateChecklistCell(viewId, rowId, fieldId, {
insertOptions: [value],
});
setValue('');
};
return (
<div className={'flex items-center justify-between p-2 px-4 text-sm'}>
<input
placeholder={t('grid.checklist.addNew')}
className={'flex-1'}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void createOption();
}
}}
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
/>
<IconButton size={'small'} disabled={!value} onClick={createOption}>
<AddIcon />
</IconButton>
</div>
);
}
export default AddNewOption;

View File

@ -0,0 +1,41 @@
import React from 'react';
import Popover, { PopoverProps } from '@mui/material/Popover';
import { LinearProgressWithLabel } from '$app/components/database/components/field_types/checklist/LinearProgressWithLabel';
import { Divider } from '@mui/material';
import { ChecklistCell as ChecklistCellType } from '$app/components/database/application';
import ChecklistItem from '$app/components/database/components/field_types/checklist/ChecklistItem';
import AddNewOption from '$app/components/database/components/field_types/checklist/AddNewOption';
function ChecklistCellActions({
cell,
...props
}: PopoverProps & {
cell: ChecklistCellType;
}) {
const { fieldId, rowId } = cell;
const { percentage, selectedOptions = [], options } = cell.data;
return (
<Popover {...props}>
<LinearProgressWithLabel className={'m-4'} value={percentage || 0} />
<div className={'p-1'}>
{options?.map((option) => {
return (
<ChecklistItem
fieldId={fieldId}
rowId={rowId}
key={option.id}
option={option}
checked={selectedOptions?.includes(option.id) || false}
/>
);
})}
</div>
<Divider />
<AddNewOption fieldId={fieldId} rowId={rowId} />
</Popover>
);
}
export default ChecklistCellActions;

View File

@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { SelectOption } from '$app/components/database/application';
import { Checkbox, IconButton } from '@mui/material';
import { updateChecklistCell } from '$app/components/database/application/cell/cell_service';
import { useViewId } from '$app/hooks';
import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg';
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
function ChecklistItem({
checked,
option,
rowId,
fieldId,
}: {
checked: boolean;
option: SelectOption;
rowId: string;
fieldId: string;
}) {
const [hover, setHover] = useState(false);
const [value, setValue] = useState(option.name);
const viewId = useViewId();
const updateText = async () => {
await updateChecklistCell(viewId, rowId, fieldId, {
updateOptions: [
{
...option,
name: value,
},
],
});
};
const onCheckedChange = async () => {
void updateChecklistCell(viewId, rowId, fieldId, {
selectedOptionIds: [option.id],
});
};
const deleteOption = async () => {
await updateChecklistCell(viewId, rowId, fieldId, {
deleteOptionIds: [option.id],
});
};
return (
<div
onMouseEnter={() => {
setHover(true);
}}
onMouseLeave={() => {
setHover(false);
}}
className={`flex items-center justify-between gap-2 rounded p-1 text-sm ${hover ? 'bg-fill-list-hover' : ''}`}
>
<Checkbox
onClick={onCheckedChange}
checked={checked}
disableRipple
style={{ padding: 4 }}
icon={<CheckboxUncheckSvg />}
checkedIcon={<CheckboxCheckSvg />}
/>
<input
className={'flex-1'}
onBlur={updateText}
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
/>
<IconButton size={'small'} className={`mx-2 ${hover ? 'visible' : 'invisible'}`} onClick={deleteOption}>
<DeleteIcon />
</IconButton>
</div>
);
}
export default ChecklistItem;

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import LinearProgress, { LinearProgressProps } from '@mui/material/LinearProgress';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
export function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress variant='determinate' {...props} value={props.value * 100} />
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography variant='body2' color='text.secondary'>{`${Math.round(props.value * 100)}%`}</Typography>
</Box>
</Box>
);
}

View File

@ -0,0 +1,87 @@
import React, { useEffect, useState } from 'react';
import DatePicker, { ReactDatePickerCustomHeaderProps } from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
import { ReactComponent as LeftSvg } from '$app/assets/arrow-left.svg';
import { ReactComponent as RightSvg } from '$app/assets/arrow-right.svg';
import { IconButton } from '@mui/material';
function CustomCalendar({
handleChange,
isRange,
timestamp,
endTimestamp,
}: {
handleChange: (params: { date?: number; endDate?: number }) => void;
isRange: boolean;
timestamp: number;
endTimestamp: number;
}) {
const [startDate, setStartDate] = useState<Date | null>(new Date(timestamp * 1000));
const [endDate, setEndDate] = useState<Date | null>(new Date(endTimestamp * 1000));
useEffect(() => {
if (!isRange) return;
setEndDate(new Date(endTimestamp * 1000));
}, [isRange, endTimestamp]);
useEffect(() => {
setStartDate(new Date(timestamp * 1000));
}, [timestamp]);
return (
<div className={'flex w-full items-center justify-center'}>
<DatePicker
calendarClassName={
'appflowy-date-picker-calendar bg-bg-body h-full border-none rounded-none flex w-full items-center justify-center'
}
renderCustomHeader={(props: ReactDatePickerCustomHeaderProps) => {
return (
<div className={'flex w-full justify-between pb-1.5 pt-0'}>
<div className={'flex-1 px-4 text-left text-sm font-medium text-text-title'}>
{dayjs(props.date).format('MMMM YYYY')}
</div>
<div className={'flex items-center gap-[10px] pr-2'}>
<IconButton size={'small'} onClick={props.decreaseMonth}>
<LeftSvg />
</IconButton>
<IconButton size={'small'} onClick={props.increaseMonth}>
<RightSvg />
</IconButton>
</div>
</div>
);
}}
selected={startDate}
onChange={(dates) => {
if (!dates) return;
if (isRange) {
const [start, end] = dates as [Date | null, Date | null];
setStartDate(start);
setEndDate(end);
if (!start || !end) return;
handleChange({
date: start.getTime() / 1000,
endDate: end.getTime() / 1000,
});
} else {
const date = dates as Date;
setStartDate(date);
handleChange({
date: date.getTime() / 1000,
});
}
}}
startDate={isRange ? startDate : null}
endDate={isRange ? endDate : null}
selectsRange={isRange}
inline
/>
</div>
);
}
export default CustomCalendar;

View File

@ -0,0 +1,72 @@
import React, { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MenuItem, Menu } from '@mui/material';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
import { DateFormatPB } from '@/services/backend';
interface Props {
value: DateFormatPB;
onChange: (value: DateFormatPB) => void;
}
function DateFormat({ value, onChange }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLLIElement>(null);
const dateFormatMap = useMemo(
() => ({
[DateFormatPB.Friendly]: t('grid.field.dateFormatFriendly'),
[DateFormatPB.ISO]: t('grid.field.dateFormatISO'),
[DateFormatPB.US]: t('grid.field.dateFormatUS'),
[DateFormatPB.Local]: t('grid.field.dateFormatLocal'),
[DateFormatPB.DayMonthYear]: t('grid.field.dateFormatDayMonthYear'),
}),
[t]
);
const handleClick = (option: DateFormatPB) => {
onChange(option);
setOpen(false);
};
return (
<>
<MenuItem
className={'mx-0 flex w-full justify-between text-xs font-medium'}
ref={ref}
onClick={() => setOpen(true)}
>
{t('grid.field.dateFormat')}
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
anchorEl={ref.current}
onClose={() => setOpen(false)}
>
{Object.keys(dateFormatMap).map((option) => {
const optionValue = Number(option) as DateFormatPB;
return (
<MenuItem
className={'min-w-[180px] justify-between'}
key={optionValue}
onClick={() => handleClick(optionValue)}
>
{dateFormatMap[optionValue]}
{value === optionValue && <SelectCheckSvg />}
</MenuItem>
);
})}
</Menu>
</>
);
}
export default DateFormat;

View File

@ -0,0 +1,153 @@
import React, { useCallback, useMemo } from 'react';
import Popover, { PopoverProps } from '@mui/material/Popover';
import { DateTimeCell, DateTimeField, DateTimeTypeOption } from '$app/components/database/application';
import { useViewId } from '$app/hooks';
import { useTranslation } from 'react-i18next';
import { updateDateCell } from '$app/components/database/application/cell/cell_service';
import { Divider, MenuItem, MenuList } from '@mui/material';
import dayjs from 'dayjs';
import RangeSwitch from '$app/components/database/components/field_types/date/RangeSwitch';
import CustomCalendar from '$app/components/database/components/field_types/date/CustomCalendar';
import IncludeTimeSwitch from '$app/components/database/components/field_types/date/IncludeTimeSwitch';
import DateTimeFormatSelect from '$app/components/database/components/field_types/date/DateTimeFormatSelect';
import DateTimeSet from '$app/components/database/components/field_types/date/DateTimeSet';
import { useTypeOption } from '$app/components/database';
import { getDateFormat, getTimeFormat } from '$app/components/database/components/field_types/date/utils';
function DateTimeCellActions({
cell,
field,
...props
}: PopoverProps & {
field: DateTimeField;
cell: DateTimeCell;
}) {
const typeOption = useTypeOption<DateTimeTypeOption>(field.id);
const timeFormat = useMemo(() => {
return getTimeFormat(typeOption.timeFormat);
}, [typeOption.timeFormat]);
const dateFormat = useMemo(() => {
return getDateFormat(typeOption.dateFormat);
}, [typeOption.dateFormat]);
const { includeTime } = cell.data;
const timestamp = useMemo(() => cell.data.timestamp || dayjs().unix(), [cell.data.timestamp]);
const endTimestamp = useMemo(() => cell.data.endTimestamp || dayjs().unix(), [cell.data.endTimestamp]);
const time = useMemo(() => cell.data.time || dayjs().format(timeFormat), [cell.data.time, timeFormat]);
const endTime = useMemo(() => cell.data.endTime || dayjs().format(timeFormat), [cell.data.endTime, timeFormat]);
const viewId = useViewId();
const { t } = useTranslation();
const handleChange = useCallback(
async (params: {
includeTime?: boolean;
date?: number;
endDate?: number;
time?: string;
endTime?: string;
isRange?: boolean;
clearFlag?: boolean;
}) => {
try {
const isRange = params.isRange ?? cell.data.isRange;
await updateDateCell(viewId, cell.rowId, cell.fieldId, {
date: params.date ?? timestamp,
endDate: isRange ? params.endDate ?? endTimestamp : undefined,
time: params.time ?? time,
endTime: isRange ? params.endTime ?? endTime : undefined,
includeTime: params.includeTime ?? includeTime,
isRange,
clearFlag: params.clearFlag,
});
} catch (e) {
// toast.error(e.message);
}
},
[cell, endTime, endTimestamp, includeTime, time, timestamp, viewId]
);
const isRange = cell.data.isRange || false;
return (
<Popover
keepMounted={false}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
{...props}
PaperProps={{
className: 'pt-4 transform transition-all',
}}
>
<DateTimeSet
date={timestamp}
endTime={endTime}
endDate={endTimestamp}
dateFormat={dateFormat}
time={time}
timeFormat={timeFormat}
onChange={handleChange}
isRange={isRange}
includeTime={includeTime}
/>
<CustomCalendar isRange={isRange} timestamp={timestamp} endTimestamp={endTimestamp} handleChange={handleChange} />
<Divider className={'my-0'} />
<div className={'flex flex-col gap-1 px-4 py-2'}>
<RangeSwitch
onIsRangeChange={(val) => {
void handleChange({
isRange: val,
// reset endTime when isRange is changed
endTime: time,
endDate: timestamp,
});
}}
checked={isRange}
/>
<IncludeTimeSwitch
onIncludeTimeChange={(val) => {
void handleChange({
includeTime: val,
// reset time when includeTime is changed
time: val ? dayjs().format(timeFormat) : undefined,
endTime: val && isRange ? dayjs().format(timeFormat) : undefined,
});
}}
checked={includeTime}
/>
</div>
<Divider className={'my-0'} />
<MenuList>
<DateTimeFormatSelect field={field} />
<MenuItem
className={'text-xs font-medium'}
onClick={async () => {
await handleChange({
clearFlag: true,
});
props.onClose?.({}, 'backdropClick');
}}
>
{t('grid.field.clearDate')}
</MenuItem>
</MenuList>
</Popover>
);
}
export default DateTimeCellActions;

View File

@ -0,0 +1,17 @@
import React from 'react';
import { UndeterminedDateField } from '$app/components/database/application';
import DateTimeFormat from '$app/components/database/components/field_types/date/DateTimeFormat';
import { Divider } from '@mui/material';
function DateTimeFieldActions({ field }: { field: UndeterminedDateField }) {
return (
<>
<div className={'px-1'}>
<DateTimeFormat field={field} />
</div>
<Divider className={'my-2'} />
</>
);
}
export default DateTimeFieldActions;

View File

@ -0,0 +1,75 @@
import React, { useCallback } from 'react';
import DateFormat from '$app/components/database/components/field_types/date/DateFormat';
import TimeFormat from '$app/components/database/components/field_types/date/TimeFormat';
import { TimeStampTypeOption, UndeterminedDateField, updateTypeOption } from '$app/components/database/application';
import { DateFormatPB, FieldType, TimeFormatPB } from '@/services/backend';
import { useViewId } from '$app/hooks';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import IncludeTimeSwitch from '$app/components/database/components/field_types/date/IncludeTimeSwitch';
import { useTypeOption } from '$app/components/database';
interface Props {
field: UndeterminedDateField;
showLabel?: boolean;
}
function DateTimeFormat({ field, showLabel = true }: Props) {
const viewId = useViewId();
const { t } = useTranslation();
const showIncludeTime = field.type === FieldType.CreatedTime || field.type === FieldType.LastEditedTime;
const typeOption = useTypeOption<TimeStampTypeOption>(field.id);
const { timeFormat = TimeFormatPB.TwentyFourHour, dateFormat = DateFormatPB.Friendly, includeTime } = typeOption;
const handleChange = useCallback(
async (params: { timeFormat?: TimeFormatPB; dateFormat?: DateFormatPB; includeTime?: boolean }) => {
try {
await updateTypeOption(viewId, field.id, field.type, {
timeFormat: params.timeFormat ?? timeFormat,
dateFormat: params.dateFormat ?? dateFormat,
includeTime: params.includeTime ?? includeTime,
fieldType: field.type,
});
} catch (e) {
// toast.error(e.message);
}
},
[dateFormat, field.id, field.type, includeTime, timeFormat, viewId]
);
return (
<div>
{showLabel && (
<Typography className={'py-1 pl-3'} color={'text.secondary'}>
{t('grid.field.format')}
</Typography>
)}
<DateFormat
value={dateFormat}
onChange={(val) => {
void handleChange({ dateFormat: val });
}}
/>
<TimeFormat
value={timeFormat}
onChange={(val) => {
void handleChange({ timeFormat: val });
}}
/>
{showIncludeTime && (
<div className={'px-3 py-1'}>
<IncludeTimeSwitch
size={'small'}
checked={includeTime}
onIncludeTimeChange={(checked) => {
void handleChange({ includeTime: checked });
}}
/>
</div>
)}
</div>
);
}
export default DateTimeFormat;

View File

@ -0,0 +1,47 @@
import React, { useState, useRef } from 'react';
import { Menu, MenuItem } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { DateTimeField } from '$app/components/database/application';
import DateTimeFormat from '$app/components/database/components/field_types/date/DateTimeFormat';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
interface Props {
field: DateTimeField;
}
function DateTimeFormatSelect({ field }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLLIElement>(null);
return (
<>
<MenuItem ref={ref} onClick={() => setOpen(true)} className={'text-xs font-medium'}>
<div className={'flex-1'}>
{t('grid.field.dateFormat')} & {t('grid.field.timeFormat')}
</div>
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
open={open}
anchorEl={ref.current}
onClose={() => setOpen(false)}
MenuListProps={{
className: 'px-2',
}}
>
<DateTimeFormat showLabel={false} field={field} />
</Menu>
</>
);
}
export default DateTimeFormatSelect;

View File

@ -0,0 +1,81 @@
import React, { useMemo } from 'react';
import { DateField, TimeField } from '@mui/x-date-pickers-pro';
import dayjs from 'dayjs';
import { Divider } from '@mui/material';
interface Props {
onChange: (params: { date?: number; time?: string }) => void;
date?: number;
time?: string;
timeFormat: string;
dateFormat: string;
includeTime?: boolean;
}
const sx = {
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'& .MuiOutlinedInput-input': {
padding: '0',
},
};
function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) {
const date = useMemo(() => {
return dayjs.unix(props.date || dayjs().unix());
}, [props.date]);
const time = useMemo(() => {
return dayjs(dayjs().format('YYYY/MM/DD ') + props.time);
}, [props.time]);
return (
<div
className={
'flex transform items-center justify-between rounded-lg border border-line-divider px-1 py-2 transition-all'
}
>
<DateField
value={date}
onChange={(date) => {
if (!date) return;
props.onChange({
date: date.unix(),
});
}}
inputProps={{
className: 'text-[12px]',
}}
format={dateFormat}
size={'small'}
sx={sx}
className={'flex-1 pl-2'}
/>
{includeTime && (
<>
<Divider orientation={'vertical'} className={'mx-3'} flexItem />
<TimeField
value={time}
inputProps={{
className: 'text-[12px]',
}}
onChange={(time) => {
if (!time) return;
props.onChange({
time: time.format(timeFormat),
});
}}
format={timeFormat}
size={'small'}
sx={sx}
className={'w-[70px] pl-1'}
/>
</>
)}
</div>
);
}
export default DateTimeInput;

View File

@ -0,0 +1,54 @@
import React from 'react';
import { LocalizationProvider } from '@mui/x-date-pickers-pro';
import { AdapterDayjs } from '@mui/x-date-pickers-pro/AdapterDayjs';
import DateTimeInput from '$app/components/database/components/field_types/date/DateTimeInput';
interface Props {
onChange: (params: { date?: number; endDate?: number; time?: string; endTime?: string }) => void;
date?: number;
endDate?: number;
time?: string;
endTime?: string;
isRange?: boolean;
timeFormat: string;
dateFormat: string;
includeTime?: boolean;
}
function DateTimeSet({ onChange, date, endDate, time, endTime, isRange, timeFormat, dateFormat, includeTime }: Props) {
return (
<div className={'mx-4 flex w-[216px] transform flex-col gap-2 transition-all'}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimeInput
onChange={({ date, time }) => {
onChange({
date,
time,
});
}}
date={date}
time={time}
timeFormat={timeFormat}
dateFormat={dateFormat}
includeTime={includeTime}
/>
{isRange && (
<DateTimeInput
date={endDate}
time={endTime}
onChange={({ date, time }) => {
onChange({
endDate: date,
endTime: time,
});
}}
timeFormat={timeFormat}
dateFormat={dateFormat}
includeTime={includeTime}
/>
)}
</LocalizationProvider>
</div>
);
}
export default DateTimeSet;

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Switch, SwitchProps } from '@mui/material';
import { ReactComponent as TimeSvg } from '$app/assets/database/field-type-last-edited-time.svg';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
function IncludeTimeSwitch({
checked,
onIncludeTimeChange,
...props
}: SwitchProps & {
onIncludeTimeChange: (checked: boolean) => void;
}) {
const { t } = useTranslation();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onIncludeTimeChange(event.target.checked);
};
return (
<div className={'flex w-full items-center justify-between gap-[20px]'}>
<div className={'flex flex-1 justify-start gap-1.5'}>
<TimeSvg />
<Typography className={'flex-1 text-xs font-medium'}>{t('grid.field.includeTime')}</Typography>
</div>
<Switch {...props} size={'small'} checked={checked} onChange={handleChange} />
</div>
);
}
export default IncludeTimeSwitch;

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Switch, SwitchProps } from '@mui/material';
import { useTranslation } from 'react-i18next';
import Typography from '@mui/material/Typography';
import { ReactComponent as DateSvg } from '$app/assets/database/field-type-date.svg';
function RangeSwitch({
checked,
onIsRangeChange,
...props
}: SwitchProps & {
onIsRangeChange: (checked: boolean) => void;
}) {
const { t } = useTranslation();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onIsRangeChange(event.target.checked);
};
return (
<div className={'flex w-full items-center justify-between gap-[20px]'}>
<div className={'flex flex-1 justify-start gap-1.5'}>
<DateSvg />
<Typography className={'flex-1 text-xs font-medium'}>{t('grid.field.isRange')}</Typography>
</div>
<Switch {...props} size={'small'} checked={checked} onChange={handleChange} />
</div>
);
}
export default RangeSwitch;

View File

@ -0,0 +1,67 @@
import React, { useMemo, useRef, useState } from 'react';
import { TimeFormatPB } from '@/services/backend';
import { useTranslation } from 'react-i18next';
import { Menu, MenuItem } from '@mui/material';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
interface Props {
value: TimeFormatPB;
onChange: (value: TimeFormatPB) => void;
}
function TimeFormat({ value, onChange }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLLIElement>(null);
const timeFormatMap = useMemo(
() => ({
[TimeFormatPB.TwelveHour]: t('grid.field.timeFormatTwelveHour'),
[TimeFormatPB.TwentyFourHour]: t('grid.field.timeFormatTwentyFourHour'),
}),
[t]
);
const handleClick = (option: TimeFormatPB) => {
onChange(option);
setOpen(false);
};
return (
<>
<MenuItem
className={'mx-0 flex w-full justify-between text-xs font-medium'}
ref={ref}
onClick={() => setOpen(true)}
>
{t('grid.field.timeFormat')}
<MoreSvg className={`transform text-base ${open ? '' : 'rotate-90'}`} />
</MenuItem>
<Menu
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
anchorEl={ref.current}
onClose={() => setOpen(false)}
>
{Object.keys(timeFormatMap).map((option) => {
const optionValue = Number(option) as TimeFormatPB;
return (
<MenuItem
className={'min-w-[120px] justify-between'}
key={optionValue}
onClick={() => handleClick(optionValue)}
>
{timeFormatMap[optionValue]}
{value === optionValue && <SelectCheckSvg />}
</MenuItem>
);
})}
</Menu>
</>
);
}
export default TimeFormat;

View File

@ -0,0 +1,29 @@
import { DateFormatPB, TimeFormatPB } from '@/services/backend';
export function getTimeFormat(timeFormat?: TimeFormatPB) {
switch (timeFormat) {
case TimeFormatPB.TwelveHour:
return 'h:mm A';
case TimeFormatPB.TwentyFourHour:
return 'HH:mm';
default:
return 'HH:mm';
}
}
export function getDateFormat(dateFormat?: DateFormatPB) {
switch (dateFormat) {
case DateFormatPB.Friendly:
return 'MMM DD, YYYY';
case DateFormatPB.ISO:
return 'YYYY-MMM-DD';
case DateFormatPB.US:
return 'YYYY/MMM/DD';
case DateFormatPB.Local:
return 'MMM/DD/YYYY';
case DateFormatPB.DayMonthYear:
return 'DD/MMM/YYYY';
default:
return 'YYYY-MMM-DD';
}
}

View File

@ -0,0 +1,66 @@
import React, { useCallback } from 'react';
import { Popover } from '@mui/material';
import InputBase from '@mui/material/InputBase';
function EditNumberCellInput({
editing,
anchorEl,
width,
onClose,
value,
onChange,
}: {
editing: boolean;
anchorEl: HTMLDivElement | null;
width: number | undefined;
onClose: () => void;
value: string;
onChange: (value: string) => void;
}) {
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
const value = (e.target as HTMLInputElement).value;
onChange(value);
};
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
onClose();
}
},
[onClose]
);
return (
<Popover
keepMounted={false}
open={editing}
anchorEl={anchorEl}
PaperProps={{
className: 'flex p-2 border border-blue-400',
style: { width, minHeight: anchorEl?.offsetHeight, borderRadius: 0, boxShadow: 'none' },
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
transitionDuration={0}
onClose={onClose}
>
<InputBase
inputProps={{
sx: {
padding: 0,
},
}}
autoFocus={true}
value={value}
onInput={handleInput}
onKeyDown={handleKeyDown}
/>
</Popover>
);
}
export default EditNumberCellInput;

View File

@ -0,0 +1,34 @@
import React, { useCallback } from 'react';
import { NumberField, NumberTypeOption, updateTypeOption } from '$app/components/database/application';
import { Divider } from '@mui/material';
import { useTranslation } from 'react-i18next';
import NumberFormatSelect from '$app/components/database/components/field_types/number/NumberFormatSelect';
import { NumberFormatPB } from '@/services/backend';
import { useViewId } from '$app/hooks';
import { useTypeOption } from '$app/components/database';
function NumberFieldActions({ field }: { field: NumberField }) {
const viewId = useViewId();
const { t } = useTranslation();
const typeOption = useTypeOption<NumberTypeOption>(field.id);
const onChange = useCallback(
async (value: NumberFormatPB) => {
await updateTypeOption(viewId, field.id, field.type, {
format: value,
});
},
[field.id, field.type, viewId]
);
return (
<>
<div className={'flex flex-col pr-3 pt-1'}>
<div className={'mb-2 px-5 text-sm text-text-caption'}>{t('grid.field.format')}</div>
<NumberFormatSelect value={typeOption.format || NumberFormatPB.Num} onChange={onChange} />
</div>
<Divider className={'my-2'} />
</>
);
}
export default NumberFieldActions;

View File

@ -0,0 +1,34 @@
import React from 'react';
import { NumberFormatPB } from '@/services/backend';
import { Menu, MenuItem, MenuProps } from '@mui/material';
import { formats } from '$app/components/database/components/field_types/number/const';
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
function NumberFormatMenu({
value,
onChangeFormat,
...props
}: MenuProps & {
value: NumberFormatPB;
onChangeFormat: (value: NumberFormatPB) => void;
}) {
return (
<Menu {...props}>
{formats.map((format) => (
<MenuItem
onClick={() => {
onChangeFormat(format.value as NumberFormatPB);
props.onClose?.({}, 'backdropClick');
}}
className={'flex justify-between text-xs font-medium'}
key={format.value}
>
<div className={'flex-1'}>{format.key}</div>
{value === format.value && <SelectCheckSvg />}
</MenuItem>
))}
</Menu>
);
}
export default NumberFormatMenu;

View File

@ -0,0 +1,49 @@
import React, { useRef, useState } from 'react';
import { MenuItem } from '@mui/material';
import { NumberFormatPB } from '@/services/backend';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { formatText } from '$app/components/database/components/field_types/number/const';
import NumberFormatMenu from '$app/components/database/components/field_types/number/NumberFormatMenu';
function NumberFormatSelect({ value, onChange }: { value: NumberFormatPB; onChange: (value: NumberFormatPB) => void }) {
const ref = useRef<HTMLLIElement>(null);
const [expanded, setExpanded] = useState(false);
return (
<>
<MenuItem
ref={ref}
onClick={() => {
setExpanded(!expanded);
}}
className={'flex w-full justify-between'}
>
<div className='flex-1 text-xs font-medium'>{formatText(value)}</div>
<MoreSvg className={`transform text-base ${expanded ? '' : 'rotate-90'}`} />
</MenuItem>
<NumberFormatMenu
keepMounted={false}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
style: {
maxHeight: 500,
},
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
value={value}
open={expanded}
anchorEl={ref.current}
onClose={() => setExpanded(false)}
onChangeFormat={onChange}
/>
</>
);
}
export default NumberFormatSelect;

View File

@ -0,0 +1,14 @@
import { NumberFormatPB } from '@/services/backend';
export const formats = Object.entries(NumberFormatPB)
.filter(([, value]) => typeof value !== 'string')
.map(([key, value]) => {
return {
key,
value,
};
});
export const formatText = (format: NumberFormatPB) => {
return formats.find((item) => item.value === format)?.key;
};

View File

@ -0,0 +1,130 @@
import { FC, useState } from 'react';
import { t } from 'i18next';
import { Divider, ListSubheader, MenuItem, MenuList, MenuProps, OutlinedInput } from '@mui/material';
import { SelectOptionColorPB } from '@/services/backend';
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
import { SelectOption } from '../../../application';
import { SelectOptionColorMap, SelectOptionColorTextMap } from './constants';
import Button from '@mui/material/Button';
import {
deleteSelectOption,
insertOrUpdateSelectOption,
} from '$app/components/database/application/field/select_option/select_option_service';
import { useViewId } from '$app/hooks';
import Popover from '@mui/material/Popover';
interface SelectOptionMenuProps {
fieldId: string;
option: SelectOption;
MenuProps: MenuProps;
}
const Colors = [
SelectOptionColorPB.Purple,
SelectOptionColorPB.Pink,
SelectOptionColorPB.LightPink,
SelectOptionColorPB.Orange,
SelectOptionColorPB.Yellow,
SelectOptionColorPB.Lime,
SelectOptionColorPB.Green,
SelectOptionColorPB.Aqua,
SelectOptionColorPB.Blue,
];
export const SelectOptionMenu: FC<SelectOptionMenuProps> = ({ fieldId, option, MenuProps: menuProps }) => {
const [tagName, setTagName] = useState(option.name);
const viewId = useViewId();
const updateColor = async (color: SelectOptionColorPB) => {
await insertOrUpdateSelectOption(viewId, fieldId, [
{
...option,
color,
},
]);
};
const updateName = async () => {
if (tagName === option.name) return;
await insertOrUpdateSelectOption(viewId, fieldId, [
{
...option,
name: tagName,
},
]);
};
const onClose = () => {
menuProps.onClose?.({}, 'backdropClick');
};
const deleteOption = async () => {
await deleteSelectOption(viewId, fieldId, [option]);
onClose();
};
return (
<Popover
keepMounted={false}
classes={{
paper: 'w-52',
}}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'center',
horizontal: -32,
}}
{...menuProps}
onClose={onClose}
>
<ListSubheader className='my-2 leading-tight'>
<OutlinedInput
value={tagName}
onChange={(e) => {
setTagName(e.target.value);
}}
onBlur={updateName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void updateName();
}
}}
autoFocus={true}
placeholder={t('grid.selectOption.tagName')}
size='small'
/>
</ListSubheader>
<div className={'mb-2 px-3'}>
<Button
className={'flex w-full justify-start'}
onClick={deleteOption}
color={'inherit'}
startIcon={<DeleteSvg />}
>
{t('grid.selectOption.deleteTag')}
</Button>
</div>
<Divider />
<MenuItem disabled>{t('grid.selectOption.colorPanelTitle')}</MenuItem>
<MenuList className={'max-h-[300px] overflow-y-auto overflow-x-hidden'}>
{Colors.map((color) => (
<MenuItem
onClick={() => {
void updateColor(color);
}}
key={color}
value={color}
>
<span className={`mr-2 inline-flex h-4 w-4 rounded-full ${SelectOptionColorMap[color]}`} />
<span className='flex-1'>{t(`grid.selectOption.${SelectOptionColorTextMap[color]}`)}</span>
{option.color === color && <SelectCheckSvg />}
</MenuItem>
))}
</MenuList>
</Popover>
);
};

View File

@ -0,0 +1,16 @@
import { MenuItem, MenuItemProps } from '@mui/material';
import { FC } from 'react';
import { Tag } from '../Tag';
export interface CreateOptionProps {
label: React.ReactNode;
onClick?: MenuItemProps['onClick'];
}
export const CreateOption: FC<CreateOptionProps> = ({ label, onClick }) => {
return (
<MenuItem className='mt-2' onClick={onClick}>
<Tag className='ml-2' size='small' label={label} />
</MenuItem>
);
};

View File

@ -0,0 +1,41 @@
import React, { FormEvent, useCallback } from 'react';
import { ListSubheader, OutlinedInput } from '@mui/material';
import { t } from 'i18next';
function SearchInput({
setNewOptionName,
newOptionName,
onEnter,
}: {
newOptionName: string;
setNewOptionName: (value: string) => void;
onEnter: () => void;
}) {
const handleInput = useCallback(
(event: FormEvent) => {
const value = (event.target as HTMLInputElement).value;
setNewOptionName(value);
},
[setNewOptionName]
);
return (
<ListSubheader className='flex'>
<OutlinedInput
size='small'
autoFocus={true}
value={newOptionName}
onInput={handleInput}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onEnter();
}
}}
placeholder={t('grid.selectOption.searchOrCreateOption')}
/>
</ListSubheader>
);
}
export default SearchInput;

View File

@ -0,0 +1,152 @@
import React, { useCallback, useMemo, useState } from 'react';
import { MenuItem } from '@mui/material';
import { t } from 'i18next';
import { CreateOption } from '$app/components/database/components/field_types/select/select_cell_actions/CreateOption';
import { SelectOptionItem } from '$app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem';
import {
cellService,
SelectCell as SelectCellType,
SelectField,
SelectTypeOption,
} from '$app/components/database/application';
import { useViewId } from '$app/hooks';
import {
createSelectOption,
insertOrUpdateSelectOption,
} from '$app/components/database/application/field/select_option/select_option_service';
import { FieldType } from '@/services/backend';
import { useTypeOption } from '$app/components/database';
import SearchInput from './SearchInput';
function SelectCellActions({
field,
cell,
onUpdated,
}: {
field: SelectField;
cell: SelectCellType;
onUpdated?: () => void;
}) {
const rowId = cell?.rowId;
const viewId = useViewId();
const typeOption = useTypeOption<SelectTypeOption>(field.id);
const options = useMemo(() => typeOption.options ?? [], [typeOption.options]);
const selectedOptionIds = useMemo(() => cell?.data?.selectedOptionIds ?? [], [cell]);
const [newOptionName, setNewOptionName] = useState('');
const filteredOptions = useMemo(
() =>
options.filter((option) => {
return option.name.toLowerCase().includes(newOptionName.toLowerCase());
}),
[options, newOptionName]
);
const shouldCreateOption = !!newOptionName && filteredOptions.length === 0;
const updateCell = useCallback(
async (optionIds: string[]) => {
if (!cell || !rowId) return;
const prev = selectedOptionIds;
const deleteOptionIds = prev?.filter((id) => optionIds.find((cur) => cur === id) === undefined);
await cellService.updateSelectCell(viewId, rowId, field.id, {
insertOptionIds: optionIds,
deleteOptionIds,
});
onUpdated?.();
},
[cell, field.id, onUpdated, rowId, selectedOptionIds, viewId]
);
const createOption = useCallback(async () => {
const option = await createSelectOption(viewId, field.id, newOptionName);
if (!option) return;
await insertOrUpdateSelectOption(viewId, field.id, [option]);
setNewOptionName('');
return option;
}, [viewId, field.id, newOptionName]);
const handleClickOption = useCallback(
(optionId: string) => {
if (field.type === FieldType.SingleSelect) {
void updateCell([optionId]);
return;
}
const prev = selectedOptionIds;
let newOptionIds = [];
if (!prev) {
newOptionIds.push(optionId);
} else {
const isSelected = prev.includes(optionId);
if (isSelected) {
newOptionIds = prev.filter((id) => id !== optionId);
} else {
newOptionIds = [...prev, optionId];
}
}
void updateCell(newOptionIds);
},
[field.type, selectedOptionIds, updateCell]
);
const handleNewTagClick = useCallback(async () => {
if (!cell || !rowId) return;
const option = await createOption();
if (!option) return;
handleClickOption(option.id);
}, [cell, createOption, handleClickOption, rowId]);
const handleEnter = useCallback(() => {
if (shouldCreateOption) {
void handleNewTagClick();
} else {
if (field.type === FieldType.SingleSelect) {
const firstOption = filteredOptions[0];
if (!firstOption) return;
void updateCell([firstOption.id]);
} else {
void updateCell(filteredOptions.map((option) => option.id));
}
}
setNewOptionName('');
}, [field.type, filteredOptions, handleNewTagClick, shouldCreateOption, updateCell]);
return (
<div className={'text-base'}>
<SearchInput setNewOptionName={setNewOptionName} newOptionName={newOptionName} onEnter={handleEnter} />
<div className='mx-4 mb-2 mt-4 text-xs'>
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
</div>
{shouldCreateOption ? (
<CreateOption label={newOptionName} onClick={handleNewTagClick} />
) : (
<div className={'max-h-[300px] overflow-y-auto overflow-x-hidden'}>
{filteredOptions.map((option) => (
<MenuItem className={'px-2'} key={option.id} value={option.id}>
<SelectOptionItem
onClick={() => {
handleClickOption(option.id);
}}
isSelected={selectedOptionIds?.includes(option.id)}
fieldId={cell?.fieldId || ''}
option={option}
/>
</MenuItem>
))}
</div>
)}
</div>
);
}
export default SelectCellActions;

View File

@ -0,0 +1,57 @@
import { FC, MouseEventHandler, useCallback, useRef, useState } from 'react';
import { IconButton } from '@mui/material';
import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
import { SelectOption } from '../../../../application';
import { SelectOptionMenu } from '../SelectOptionMenu';
import { Tag } from '../Tag';
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
export interface SelectOptionItemProps {
option: SelectOption;
fieldId: string;
isSelected?: boolean;
onClick?: () => void;
}
export const SelectOptionItem: FC<SelectOptionItemProps> = ({ onClick, isSelected, fieldId, option }) => {
const [open, setOpen] = useState(false);
const anchorEl = useRef<HTMLDivElement | null>(null);
const [hovered, setHovered] = useState(false);
const handleClick = useCallback<MouseEventHandler<HTMLButtonElement>>((event) => {
event.stopPropagation();
setOpen(true);
}, []);
return (
<>
<div
onClick={onClick}
ref={anchorEl}
className={'flex w-full items-center justify-between'}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className='flex-1'>
<Tag key={option.id} size='small' color={option.color} label={option.name} />
</div>
{isSelected && !hovered && <SelectCheckSvg />}
{hovered && (
<IconButton onClick={handleClick}>
<DetailsSvg className='text-base' />
</IconButton>
)}
</div>
{open && (
<SelectOptionMenu
fieldId={fieldId}
option={option}
MenuProps={{
open,
anchorEl: anchorEl.current,
onClose: () => setOpen(false),
}}
/>
)}
</>
);
};

View File

@ -0,0 +1,54 @@
import React, { useState } from 'react';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { OutlinedInput } from '@mui/material';
import {
createSelectOption,
insertOrUpdateSelectOption,
} from '$app/components/database/application/field/select_option/select_option_service';
import { useViewId } from '$app/hooks';
function AddAnOption({ fieldId }: { fieldId: string }) {
const viewId = useViewId();
const { t } = useTranslation();
const [edit, setEdit] = useState(false);
const [newOptionName, setNewOptionName] = useState('');
const exitEdit = () => {
setNewOptionName('');
setEdit(false);
};
const createOption = async () => {
const option = await createSelectOption(viewId, fieldId, newOptionName);
if (!option) return;
await insertOrUpdateSelectOption(viewId, fieldId, [option]);
setNewOptionName('');
};
return edit ? (
<OutlinedInput
onBlur={exitEdit}
autoFocus={true}
onChange={(e) => {
setNewOptionName(e.target.value);
}}
value={newOptionName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void createOption();
}
}}
className={'mx-2 mb-1'}
placeholder={t('grid.selectOption.typeANewOption')}
size='small'
/>
) : (
<Button onClick={() => setEdit(true)} color={'inherit'} startIcon={<AddSvg />}>
<div className={'w-full text-left'}>{t('grid.field.addSelectOption')}</div>
</Button>
);
}
export default AddAnOption;

View File

@ -0,0 +1,46 @@
import React, { useRef, useState } from 'react';
import { ReactComponent as MoreIcon } from '$app/assets/more.svg';
import { SelectOption } from '$app/components/database/application';
// import { ReactComponent as DragIcon } from '$app/assets/drag.svg';
import { SelectOptionMenu } from '$app/components/database/components/field_types/select/SelectOptionMenu';
import Button from '@mui/material/Button';
import { SelectOptionColorMap } from '$app/components/database/components/field_types/select/constants';
function Option({ option, fieldId }: { option: SelectOption; fieldId: string }) {
const [expanded, setExpanded] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
return (
<>
<Button
onClick={() => setExpanded(!expanded)}
color={'inherit'}
// startIcon={<DragIcon />}
endIcon={<MoreIcon className={`transform ${expanded ? '' : 'rotate-90'}`} />}
ref={ref}
className={'flex w-full items-center justify-between'}
>
<div className={`flex flex-1 justify-start`}>
<div className={`${SelectOptionColorMap[option.color]} rounded-lg px-1.5 py-1`}>{option.name}</div>
</div>
</Button>
<SelectOptionMenu
fieldId={fieldId}
MenuProps={{
anchorEl: ref.current,
onClose: () => setExpanded(false),
open: expanded,
transformOrigin: {
vertical: 'center',
horizontal: 'left',
},
anchorOrigin: { vertical: 'center', horizontal: 'right' },
}}
option={option}
/>
</>
);
}
export default Option;

View File

@ -0,0 +1,19 @@
import React from 'react';
import { SelectOption } from '$app/components/database/application';
import Option from './Option';
interface Props {
options: SelectOption[];
fieldId: string;
}
function Options({ options, fieldId }: Props) {
return (
<div className={'max-h-[300px] overflow-y-auto overflow-x-hidden'}>
{options.map((option) => {
return <Option fieldId={fieldId} key={option.id} option={option} />;
})}
</div>
);
}
export default Options;

View File

@ -0,0 +1,26 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import AddAnOption from '$app/components/database/components/field_types/select/select_field_actions/AddAnOption';
import Options from '$app/components/database/components/field_types/select/select_field_actions/Options';
import { SelectField, SelectTypeOption } from '$app/components/database/application';
import { Divider } from '@mui/material';
import { useTypeOption } from '$app/components/database';
function SelectFieldActions({ field }: { field: SelectField }) {
const typeOption = useTypeOption<SelectTypeOption>(field.id);
const options = useMemo(() => typeOption.options ?? [], [typeOption.options]);
const { t } = useTranslation();
return (
<>
<div className={'flex flex-col px-3 pt-1'}>
<div className={'mb-2 px-2 text-sm text-text-caption'}>{t('grid.field.optionTitle')}</div>
<AddAnOption fieldId={field.id} />
<Options fieldId={field.id} options={options} />
</div>
<Divider className={'my-2'} />
</>
);
}
export default SelectFieldActions;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React from 'react';
import { Popover, TextareaAutosize } from '@mui/material';
interface Props {
@ -10,25 +10,24 @@ interface Props {
onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void;
}
function EditTextCellInput({ editing, anchorEl, width, onClose, text, onInput }: Props) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const shift = e.shiftKey;
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus();
// set the cursor to the end of the text
const length = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(length, length);
// If shift is pressed, allow the user to enter a new line, otherwise close the popover
if (!shift && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textareaRef.current]);
};
return (
<Popover
open={editing}
anchorEl={anchorEl}
PaperProps={{
className: 'flex p-2 border border-blue-400',
style: { width, borderRadius: 0, boxShadow: 'none' },
style: { width, minHeight: anchorEl?.offsetHeight, borderRadius: 0, boxShadow: 'none' },
}}
transformOrigin={{
vertical: 1,
@ -36,14 +35,15 @@ function EditTextCellInput({ editing, anchorEl, width, onClose, text, onInput }:
}}
transitionDuration={0}
onClose={onClose}
keepMounted={false}
>
<TextareaAutosize
ref={textareaRef}
className='resize-none text-sm'
className='w-full resize-none whitespace-break-spaces break-all text-sm'
autoFocus
autoCorrect='off'
value={text}
onInput={onInput}
onKeyDown={handleEnter}
/>
</Popover>
);

View File

@ -0,0 +1,40 @@
import React from 'react';
import Select from '@mui/material/Select';
import { FormControl, MenuItem, SelectProps } from '@mui/material';
function ConditionSelect({
conditions,
...props
}: SelectProps & {
conditions: {
value: number;
text: string;
}[];
}) {
return (
<FormControl size={'small'} variant={'outlined'}>
<Select
{...props}
sx={{
'& .MuiSelect-select': {
padding: 0,
fontSize: '12px',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent !important',
},
}}
>
{conditions.map((condition) => {
return (
<MenuItem key={condition.value} value={condition.value}>
{condition.text}
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
export default ConditionSelect;

View File

@ -1,27 +1,54 @@
import React, { FC, useState } from 'react';
import { Filter as FilterType, Field as FieldData, UndeterminedFilter } from '$app/components/database/application';
import React, { FC, useMemo, useState } from 'react';
import {
Filter as FilterType,
Field as FieldData,
UndeterminedFilter,
TextFilterData,
SelectFilterData,
NumberFilterData,
CheckboxFilterData,
ChecklistFilterData,
DateFilterData,
} from '$app/components/database/application';
import { Chip, Popover } from '@mui/material';
import { Field } from '$app/components/database/components/field';
import { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg';
import TextFilter from '$app/components/database/components/filter/field_filter/TextFilter';
import TextFilter from './text_filter/TextFilter';
import { FieldType } from '@/services/backend';
import FilterActions from '$app/components/database/components/filter/FilterActions';
import { updateFilter } from '$app/components/database/application/filter/filter_service';
import { useViewId } from '$app/hooks';
import SelectFilter from './select_filter/SelectFilter';
import DateFilter from '$app/components/database/components/filter/date_filter/DateFilter';
import FilterConditionSelect from '$app/components/database/components/filter/FilterConditionSelect';
interface Props {
filter: FilterType;
field: FieldData;
}
interface FilterComponentProps {
filter: FilterType;
field: FieldData;
onChange: (data: UndeterminedFilter['data']) => void;
}
type FilterComponent = FC<FilterComponentProps>;
const getFilterComponent = (field: FieldData) => {
switch (field.type) {
case FieldType.RichText:
return TextFilter as FC<{
filter: FilterType;
field: FieldData;
onChange: (data: UndeterminedFilter['data']) => void;
}>;
case FieldType.URL:
case FieldType.Number:
return TextFilter as FilterComponent;
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return SelectFilter as FilterComponent;
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return DateFilter as FilterComponent;
default:
return null;
}
@ -54,6 +81,29 @@ function Filter({ filter, field }: Props) {
const Component = getFilterComponent(field);
const condition = useMemo(() => {
switch (field.type) {
case FieldType.RichText:
case FieldType.URL:
return (filter.data as TextFilterData).condition;
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return (filter.data as SelectFilterData).condition;
case FieldType.Number:
return (filter.data as NumberFilterData).condition;
case FieldType.Checkbox:
return (filter.data as CheckboxFilterData).condition;
case FieldType.Checklist:
return (filter.data as ChecklistFilterData).condition;
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return (filter.data as DateFilterData).condition;
default:
return;
}
}, [field, filter]);
return (
<>
<Chip
@ -67,25 +117,37 @@ function Filter({ filter, field }: Props) {
}
onClick={handleClick}
/>
<Popover
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
keepMounted={false}
>
<div className={'flex items-start justify-between p-4'}>
{condition !== undefined && open && (
<Popover
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
keepMounted={false}
>
<div className={'flex items-center justify-between'}>
<FilterConditionSelect
name={field.name}
condition={condition}
fieldType={field.type}
onChange={(condition) => {
void onDataChange({
condition,
});
}}
/>
<FilterActions filter={filter} />
</div>
{Component && <Component filter={filter} field={field} onChange={onDataChange} />}
<FilterActions filter={filter} />
</div>
</Popover>
</Popover>
)}
</>
);
}

View File

@ -29,6 +29,7 @@ function FilterActions({ filter }: { filter: Filter }) {
onClick={(e) => {
setAnchorEl(e.currentTarget);
}}
className={'mx-2 my-1.5'}
>
<MoreSvg />
</IconButton>

Some files were not shown because too many files have changed in this diff Show More