diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg new file mode 100644 index 0000000000..95e4964b53 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg new file mode 100644 index 0000000000..ae93287114 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg new file mode 100644 index 0000000000..e3b6a49a56 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts index a69942e716..2ea447d09e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts @@ -4,6 +4,8 @@ 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'; export function useSelectDatabaseView({ viewId }: { viewId?: string }) { const key = 'v'; @@ -40,6 +42,12 @@ export const DatabaseProvider = DatabaseContext.Provider; export const useDatabase = () => useSnapshot(useContext(DatabaseContext)); +export const useDatabaseVisibilityRows = () => { + const { rowMetas } = useDatabase(); + + return useMemo(() => rowMetas.filter((row) => !row.isHidden), [rowMetas]); +}; + export const useDatabaseVisibilityFields = () => { const database = useDatabase(); @@ -90,6 +98,13 @@ export const useConnectDatabase = (viewId: string) => { [DatabaseNotification.DidUpdateSort]: (changeset) => { sortListeners.didUpdateSort(database, changeset); }, + + [DatabaseNotification.DidUpdateFilter]: (changeset) => { + didUpdateFilter(database, changeset); + }, + [DatabaseNotification.DidUpdateViewRowsVisibility]: (changeset) => { + didUpdateViewRowsVisibility(database, changeset); + }, }, { id: viewId } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx index e8e348cd10..b9aada517d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useViewId } from '$app/hooks/ViewId.hooks'; import { databaseViewService } from './application'; import { DatabaseTabBar } from './components'; @@ -9,6 +9,10 @@ import { PageController } from '$app/stores/effects/workspace/page/page_controll import SwipeableViews from 'react-swipeable-views'; import { TabPanel } from '$app/components/database/components/tab_bar/ViewTabs'; import { useDatabaseResize } from '$app/components/database/Database.hooks'; +import DatabaseSettings from '$app/components/database/components/database_settings/DatabaseSettings'; +import { Portal } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ErrorCode } from '@/services/backend'; interface Props { selectedViewId?: string; @@ -17,14 +21,24 @@ interface Props { export const Database = ({ selectedViewId, setSelectedViewId }: Props) => { const viewId = useViewId(); + const { t } = useTranslation(); + const [notFound, setNotFound] = useState(false); const [childViewIds, setChildViewIds] = useState([]); const { ref, collectionRef, tableHeight } = useDatabaseResize(); + const [openCollections, setOpenCollections] = useState([]); useEffect(() => { const onPageChanged = () => { - void databaseViewService.getDatabaseViews(viewId).then((value) => { - setChildViewIds(value.map((view) => view.id)); - }); + void databaseViewService + .getDatabaseViews(viewId) + .then((value) => { + setChildViewIds(value.map((view) => view.id)); + }) + .catch((err) => { + if (err.code === ErrorCode.RecordNotFound) { + setNotFound(true); + } + }); }; onPageChanged(); @@ -44,8 +58,38 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => { return Math.max(0, childViewIds.indexOf(selectedViewId ?? viewId)); }, [childViewIds, selectedViewId, viewId]); + const onToggleCollection = useCallback( + (id: string, forceOpen?: boolean) => { + if (forceOpen) { + setOpenCollections((prev) => { + if (prev.includes(id)) { + return prev; + } + + return [...prev, id]; + }); + return; + } + + if (openCollections.includes(id)) { + setOpenCollections((prev) => prev.filter((item) => item !== id)); + } else { + setOpenCollections((prev) => [...prev, id]); + } + }, + [openCollections] + ); + + if (notFound) { + return ( + + {t('deletePagePrompt.text')} + + ); + } + return ( - + { {childViewIds.map((id) => ( - - - + {selectedViewId === id && ( + <> + + + onToggleCollection(id, forceOpen)} + /> + + + + + + > + )} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseTitle.tsx index 34e9568c7b..508fb7d5df 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseTitle.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseTitle.tsx @@ -38,7 +38,7 @@ export const DatabaseTitle = () => { ); return ( - + { return [parserViewPBToPage(result.val), ...result.val.child_views.map(parserViewPBToPage)]; } - return Promise.reject(result.err); + return Promise.reject(result.val); } export async function createDatabaseView( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_data.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_data.ts new file mode 100644 index 0000000000..847b1139ab --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_data.ts @@ -0,0 +1,14 @@ +import { TextFilterConditionPB, FieldType } from '@/services/backend'; +import { UndeterminedFilter } from '$app/components/database/application'; + +export function getDefaultFilter(fieldType: FieldType): UndeterminedFilter['data'] | undefined { + switch (fieldType) { + case FieldType.RichText: + return { + condition: TextFilterConditionPB.Contains, + content: '', + }; + default: + return; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_listeners.ts new file mode 100644 index 0000000000..05618ca6b9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_listeners.ts @@ -0,0 +1,34 @@ +import { Database, pbToFilter } from '$app/components/database/application'; +import { FilterChangesetNotificationPB } from '@/services/backend'; + +const deleteFiltersFromChange = (database: Database, changeset: FilterChangesetNotificationPB) => { + const deleteIds = changeset.delete_filters.map((pb) => pb.id); + + if (deleteIds.length) { + database.filters = database.filters.filter((item) => !deleteIds.includes(item.id)); + } +}; + +const insertFiltersFromChange = (database: Database, changeset: FilterChangesetNotificationPB) => { + changeset.insert_filters.forEach((pb) => { + database.filters.push(pbToFilter(pb)); + }); +}; + +const updateFiltersFromChange = (database: Database, changeset: FilterChangesetNotificationPB) => { + changeset.update_filters.forEach((pb) => { + const found = database.filters.find((item) => item.id === pb.filter_id); + + if (found) { + const newFilter = pbToFilter(pb.filter); + + Object.assign(found, newFilter); + } + }); +}; + +export const didUpdateFilter = (database: Database, changeset: FilterChangesetNotificationPB) => { + deleteFiltersFromChange(database, changeset); + insertFiltersFromChange(database, changeset); + updateFiltersFromChange(database, changeset); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_service.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_service.ts index f85768bb6f..64a6ed9c8c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_service.ts @@ -3,10 +3,8 @@ import { DatabaseEventUpdateDatabaseSetting, DatabaseSettingChangesetPB, DatabaseViewIdPB, - DeleteFilterPayloadPB, FieldType, FilterPB, - UpdateFilterPayloadPB, } from '@/services/backend/events/flowy-database2'; import { Filter, filterDataToPB, UndeterminedFilter } from './filter_types'; @@ -15,59 +13,79 @@ export async function getAllFilters(viewId: string): Promise { const result = await DatabaseEventGetAllFilters(payload); - return result.map(value => value.items).unwrap(); + return result.map((value) => value.items).unwrap(); } -export async function insertFilter( - viewId: string, - fieldId: string, - fieldType: FieldType, - data: UndeterminedFilter['data'], -): Promise { +export async function insertFilter({ + viewId, + fieldId, + fieldType, + data, + filterId, +}: { + viewId: string; + fieldId: string; + fieldType: FieldType; + data: UndeterminedFilter['data']; + filterId?: string; +}): Promise { const payload = DatabaseSettingChangesetPB.fromObject({ view_id: viewId, - update_filter: UpdateFilterPayloadPB.fromObject({ + update_filter: { view_id: viewId, field_id: fieldId, field_type: fieldType, + filter_id: filterId, data: filterDataToPB(data, fieldType)?.serialize(), - }), + }, }); const result = await DatabaseEventUpdateDatabaseSetting(payload); - return result.unwrap(); + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; } export async function updateFilter(viewId: string, filter: UndeterminedFilter): Promise { const payload = DatabaseSettingChangesetPB.fromObject({ view_id: viewId, - update_filter: UpdateFilterPayloadPB.fromObject({ + update_filter: { view_id: viewId, filter_id: filter.id, field_id: filter.fieldId, field_type: filter.fieldType, data: filterDataToPB(filter.data, filter.fieldType)?.serialize(), - }), + }, }); const result = await DatabaseEventUpdateDatabaseSetting(payload); - return result.unwrap(); + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; } export async function deleteFilter(viewId: string, filter: Omit): Promise { const payload = DatabaseSettingChangesetPB.fromObject({ view_id: viewId, - delete_filter: DeleteFilterPayloadPB.fromObject({ + delete_filter: { view_id: viewId, filter_id: filter.id, field_id: filter.fieldId, field_type: filter.fieldType, - }), + }, }); const result = await DatabaseEventUpdateDatabaseSetting(payload); - return result.unwrap(); + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/application/row/row_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/row/row_listeners.ts index e45b03a2f3..9405c43028 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/application/row/row_listeners.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/row/row_listeners.ts @@ -1,14 +1,10 @@ -import { - ReorderAllRowsPB, - ReorderSingleRowPB, - RowsChangePB, -} from '@/services/backend'; +import { ReorderAllRowsPB, ReorderSingleRowPB, RowsChangePB, RowsVisibilityChangePB } from '@/services/backend'; import { Database } from '../database'; import { pbToRowMeta, RowMeta } from './row_types'; const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { - changeset.deleted_rows.forEach(rowId => { - const index = database.rowMetas.findIndex(row => row.id === rowId); + changeset.deleted_rows.forEach((rowId) => { + const index = database.rowMetas.findIndex((row) => row.id === rowId); if (index !== -1) { database.rowMetas.splice(index, 1); @@ -24,7 +20,7 @@ const insertRowsFromChangeset = (database: Database, changeset: RowsChangePB) => const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { changeset.updated_rows.forEach(({ row_id: rowId, row_meta: rowMetaPB }) => { - const found = database.rowMetas.find(rowMeta => rowMeta.id === rowId); + const found = database.rowMetas.find((rowMeta) => rowMeta.id === rowId); if (found) { Object.assign(found, pbToRowMeta(rowMetaPB)); @@ -44,18 +40,31 @@ export const didReorderRows = (database: Database, changeset: ReorderAllRowsPB) return prev; }, {}); - database.rowMetas = changeset.row_orders.map(rowId => rowById[rowId]); + database.rowMetas = changeset.row_orders.map((rowId) => rowById[rowId]); }; export const didReorderSingleRow = (database: Database, changeset: ReorderSingleRowPB) => { - const { - row_id: rowId, - new_index: newIndex, - } = changeset; + const { row_id: rowId, new_index: newIndex } = changeset; - const oldIndex = database.rowMetas.findIndex(rowMeta => rowMeta.id === rowId); + const oldIndex = database.rowMetas.findIndex((rowMeta) => rowMeta.id === rowId); if (oldIndex !== -1) { database.rowMetas.splice(newIndex, 0, database.rowMetas.splice(oldIndex, 1)[0]); } }; + +export const didUpdateViewRowsVisibility = (database: Database, changeset: RowsVisibilityChangePB) => { + const { invisible_rows, visible_rows } = changeset; + + database.rowMetas.forEach((rowMeta) => { + if (invisible_rows.includes(rowMeta.id)) { + rowMeta.isHidden = true; + } + + const found = visible_rows.find((visibleRow) => visibleRow.row_meta.id === rowMeta.id); + + if (found) { + rowMeta.isHidden = false; + } + }); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/application/row/row_types.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/row/row_types.ts index ab1f3bfc3b..1b964a6bb5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/application/row/row_types.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/application/row/row_types.ts @@ -5,6 +5,7 @@ export interface RowMeta { documentId?: string; icon?: string; cover?: string; + isHidden?: boolean; } export function pbToRowMeta(pb: RowMetaPB): RowMeta { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.hooks.ts index f456852b0e..143f6936f1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.hooks.ts @@ -1,12 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { DatabaseNotification, FieldType } from '@/services/backend'; import { useNotification, useViewId } from '$app/hooks'; import { cellService, Cell } from '../../application'; -import { debounce } from 'lodash-es'; - -// delay for debounced fetch -// Because we don't want to fetch cell when element is scrolling -const DELAY = 200; export const useCell = (rowId: string, fieldId: string, fieldType: FieldType) => { const viewId = useViewId(); @@ -18,14 +13,9 @@ export const useCell = (rowId: string, fieldId: string, fieldType: FieldType) => }); }, [viewId, rowId, fieldId, fieldType]); - const debouncedFetchCell = useMemo(() => debounce(fetchCell, DELAY), [fetchCell]); - useEffect(() => { - debouncedFetchCell(); - return () => { - debouncedFetchCell.cancel(); - }; - }, [debouncedFetchCell]); + fetchCell(); + }, [fetchCell]); useNotification(DatabaseNotification.DidUpdateCell, fetchCell, { id: `${rowId}:${fieldId}` }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx index 731f658e03..a972b4cf28 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx @@ -1,17 +1,16 @@ -import { Sort } from '../../application'; -import { useDatabase } from '../../Database.hooks'; import { Sorts } from '../sort'; +import Filters from '../filter/Filters'; +import React from 'react'; -export const DatabaseCollection = () => { - const { sorts } = useDatabase(); - - const showSorts = sorts && sorts.length > 0; - - const showCollection = showSorts; +interface Props { + open: boolean; +} +export const DatabaseCollection = ({ open }: Props) => { return ( - - {showSorts && } + + + ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx new file mode 100644 index 0000000000..969daed1b9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import { Stack } from '@mui/material'; +import { TextButton } from '$app/components/database/components/tab_bar/TextButton'; +import { useTranslation } from 'react-i18next'; + +import SortSettings from '$app/components/database/components/database_settings/SortSettings'; +import SettingsMenu from '$app/components/database/components/database_settings/SettingsMenu'; +import FilterSettings from '$app/components/database/components/database_settings/FilterSettings'; + +interface Props { + onToggleCollection: (forceOpen?: boolean) => void; +} + +function DatabaseSettings(props: Props) { + const { t } = useTranslation(); + const [settingAnchorEl, setSettingAnchorEl] = useState(null); + + return ( + + + + setSettingAnchorEl(e.currentTarget)}> + {t('settings.title')} + + setSettingAnchorEl(null)} + /> + + ); +} + +export default React.memo(DatabaseSettings); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx new file mode 100644 index 0000000000..d8dea4f922 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx @@ -0,0 +1,39 @@ +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 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 [filterAnchorEl, setFilterAnchorEl] = useState(null); + const open = Boolean(filterAnchorEl); + + const handleClick = (e: React.MouseEvent) => { + if (highlight) { + onToggleCollection(); + return; + } + + setFilterAnchorEl(e.currentTarget); + }; + + return ( + <> + + {t('grid.settings.filter')} + + onToggleCollection(true)} + open={open} + anchorEl={filterAnchorEl} + onClose={() => setFilterAnchorEl(null)} + /> + > + ); +} + +export default FilterSettings; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx new file mode 100644 index 0000000000..f62294a215 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useDatabase } from '$app/components/database'; +import { Field as FieldType } from '$app/components/database/application'; +import { Field } from '$app/components/database/components/field'; +import { FieldVisibility } from '@/services/backend'; +import { ReactComponent as EyeOpen } from '$app/assets/eye_open.svg'; +import { ReactComponent as EyeClosed } from '$app/assets/eye_close.svg'; +import { MenuItem } from '@mui/material'; + +interface PropertiesProps { + onItemClick: (field: FieldType) => void; +} +function Properties({ onItemClick }: PropertiesProps) { + const { fields } = useDatabase(); + + return ( + + {fields.map((field) => ( + onItemClick(field)} + className={'flex w-full items-center justify-between'} + key={field.id} + > + + {field.visibility !== FieldVisibility.AlwaysHidden ? : } + + ))} + + ); +} + +export default Properties; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx new file mode 100644 index 0000000000..04704ea0bf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { Menu, MenuItem, MenuProps, Popover } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import Properties from '$app/components/database/components/database_settings/Properties'; +import { Field } from '$app/components/database/application'; +import { FieldVisibility } from '@/services/backend'; +import { updateFieldSetting } from '$app/components/database/application/field/field_service'; +import { useViewId } from '$app/hooks'; + +type SettingsMenuProps = MenuProps; + +function SettingsMenu(props: SettingsMenuProps) { + const viewId = useViewId(); + const { t } = useTranslation(); + const [propertiesAnchorElPosition, setPropertiesAnchorElPosition] = useState< + | undefined + | { + top: number; + left: number; + } + >(undefined); + + const openProperties = Boolean(propertiesAnchorElPosition); + + const togglePropertyVisibility = async (field: Field) => { + let visibility = field.visibility; + + if (visibility === FieldVisibility.AlwaysHidden) { + visibility = FieldVisibility.AlwaysShown; + } else { + visibility = FieldVisibility.AlwaysHidden; + } + + await updateFieldSetting(viewId, field.id, { + visibility, + }); + }; + + return ( + <> + + { + const rect = event.currentTarget.getBoundingClientRect(); + + setPropertiesAnchorElPosition({ + top: rect.top, + left: rect.left + rect.width, + }); + props.onClose?.({}, 'backdropClick'); + }} + > + {t('grid.settings.properties')} + + + { + setPropertiesAnchorElPosition(undefined); + }} + anchorReference={'anchorPosition'} + anchorPosition={propertiesAnchorElPosition} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + > + + + > + ); +} + +export default SettingsMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx new file mode 100644 index 0000000000..099f6c7cab --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDatabase } from '$app/components/database'; +import { TextButton } from '$app/components/database/components/tab_bar/TextButton'; +import SortFieldsMenu from '$app/components/database/components/sort/SortFieldsMenu'; + +interface Props { + onToggleCollection: (forceOpen?: boolean) => void; +} + +function SortSettings({ onToggleCollection }: Props) { + const { t } = useTranslation(); + const { sorts } = useDatabase(); + + const highlight = sorts && sorts.length > 0; + + const [sortAnchorEl, setSortAnchorEl] = React.useState(null); + const open = Boolean(sortAnchorEl); + const handleClick = (event: React.MouseEvent) => { + if (highlight) { + onToggleCollection(); + return; + } + + setSortAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setSortAnchorEl(null); + }; + + return ( + <> + + {t('grid.settings.sort')} + + onToggleCollection(true)} + open={open} + anchorEl={sortAnchorEl} + onClose={handleClose} + /> + > + ); +} + +export default SortSettings; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx index 5897e939c4..292689ba14 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx @@ -27,12 +27,16 @@ function EditRecord({ documentId: id, cell, icon }: Props) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (e.code === 3) { - const page = await controller.createOrphanPage({ - name: '', - layout: ViewLayoutPB.Document, - }); + try { + const page = await controller.createOrphanPage({ + name: '', + layout: ViewLayoutPB.Document, + }); - setPage(page); + setPage(page); + } catch (e) { + console.error(e); + } } } }, [id]); @@ -47,7 +51,7 @@ function EditRecord({ documentId: id, cell, icon }: Props) { return ( - + {page && } ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldList.tsx new file mode 100644 index 0000000000..0cd248fd46 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldList.tsx @@ -0,0 +1,60 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Input, MenuItem } from '@mui/material'; +import { Field } from '$app/components/database/components/field/Field'; +import { Field as FieldType } from '../../application'; +import { useDatabase } from '$app/components/database'; + +interface FieldListProps { + searchPlaceholder?: string; + showSearch?: boolean; + onItemClick?: (event: React.MouseEvent, field: FieldType) => void; +} + +function FieldList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) { + const { fields } = useDatabase(); + const [fieldsResult, setFieldsResult] = useState(fields as FieldType[]); + + const onInputChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + const result = fields.filter((field) => field.name.toLowerCase().includes(value.toLowerCase())); + + setFieldsResult(result); + }, + [fields] + ); + + const searchInput = useMemo(() => { + return showSearch ? ( + + + + ) : null; + }, [onInputChange, searchPlaceholder, showSearch]); + + const emptyList = useMemo(() => { + return fieldsResult.length === 0 ? ( + No fields found + ) : null; + }, [fieldsResult]); + + return ( + <> + {searchInput} + {emptyList} + {fieldsResult.map((field) => ( + { + onItemClick?.(event, field); + }} + > + + + ))} + > + ); +} + +export default FieldList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldListMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldListMenu.tsx deleted file mode 100644 index 3051e0cfb1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldListMenu.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Menu, MenuItem, MenuProps } from '@mui/material'; -import { FC, MouseEvent } from 'react'; -import { Field as FieldType } from '../../application'; -import { useDatabaseVisibilityFields } from '../../Database.hooks'; -import { Field } from './Field'; - -export interface FieldsMenuProps extends MenuProps { - onMenuItemClick?: (event: MouseEvent, field: FieldType) => void; -} - -export const FieldListMenu: FC = ({ onMenuItemClick, ...props }) => { - const fields = useDatabaseVisibilityFields(); - - return ( - - {fields.map((field) => ( - { - onMenuItemClick?.(event, field); - props.onClose?.({}, 'backdropClick'); - }} - > - - - ))} - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldSelect.tsx index bb6c899a00..24eb42a78b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldSelect.tsx @@ -1,30 +1,36 @@ import { MenuItem, Select, SelectChangeEvent, SelectProps } from '@mui/material'; import { FC, useCallback } from 'react'; import { Field as FieldType } from '../../application'; -import { useDatabaseVisibilityFields } from '../../Database.hooks'; +import { useDatabase } from '../../Database.hooks'; import { Field } from './Field'; export interface FieldSelectProps extends Omit { - onChange?: (event: SelectChangeEvent, field: FieldType | undefined) => void; + onChange?: (field: FieldType | undefined) => void; } export const FieldSelect: FC = ({ onChange, ...props }) => { - const fields = useDatabaseVisibilityFields(); + const { fields } = useDatabase(); const handleChange = useCallback( (event: SelectChangeEvent) => { const selectedId = event.target.value; - onChange?.( - event, - fields.find((field) => field.id === selectedId) - ); + onChange?.(fields.find((field) => field.id === selectedId)); }, [onChange, fields] ); return ( - + {fields.map((field) => ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/index.ts index 1110126dd4..81a22762c6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/index.ts @@ -1,5 +1,4 @@ export * from './Field'; export * from './FieldSelect'; -export * from './FieldListMenu'; export * from './FieldTypeText'; export * from './FieldTypeSvg'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx new file mode 100644 index 0000000000..200e415a2e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx @@ -0,0 +1,93 @@ +import React, { FC, useState } from 'react'; +import { Filter as FilterType, Field as FieldData, UndeterminedFilter } 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 { 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'; + +interface Props { + filter: FilterType; + field: FieldData; +} + +const getFilterComponent = (field: FieldData) => { + switch (field.type) { + case FieldType.RichText: + return TextFilter as FC<{ + filter: FilterType; + field: FieldData; + onChange: (data: UndeterminedFilter['data']) => void; + }>; + default: + return null; + } +}; + +function Filter({ filter, field }: Props) { + const viewId = useViewId(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = (e: React.MouseEvent) => { + setAnchorEl(e.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const onDataChange = async (data: UndeterminedFilter['data']) => { + const newFilter = { + ...filter, + data, + } as UndeterminedFilter; + + try { + await updateFilter(viewId, newFilter); + } catch (e) { + // toast.error(e.message); + } + }; + + const Component = getFilterComponent(field); + + return ( + <> + + + + + } + onClick={handleClick} + /> + + + {Component && } + + + + > + ); +} + +export default Filter; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx new file mode 100644 index 0000000000..230b97a756 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { IconButton, Menu, MenuItem } from '@mui/material'; +import { ReactComponent as MoreSvg } from '$app/assets/details.svg'; +import { Filter } from '$app/components/database/application'; +import { useTranslation } from 'react-i18next'; +import { deleteFilter } from '$app/components/database/application/filter/filter_service'; +import { useViewId } from '$app/hooks'; + +function FilterActions({ filter }: { filter: Filter }) { + const viewId = useViewId(); + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const onClose = () => { + setAnchorEl(null); + }; + + const onDelete = async () => { + try { + await deleteFilter(viewId, filter); + } catch (e) { + // toast.error(e.message); + } + }; + + return ( + <> + { + setAnchorEl(e.currentTarget); + }} + > + + + + {t('grid.settings.deleteFilter')} + + > + ); +} + +export default FilterActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx new file mode 100644 index 0000000000..d87037cfe9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx @@ -0,0 +1,46 @@ +import React, { MouseEvent, useCallback } from 'react'; +import { Menu, MenuProps } from '@mui/material'; +import FieldList from '$app/components/database/components/field/FieldList'; +import { Field } from '$app/components/database/application'; +import { useViewId } from '$app/hooks'; +import { useTranslation } from 'react-i18next'; +import { insertFilter } from '$app/components/database/application/filter/filter_service'; +import { getDefaultFilter } from '$app/components/database/application/filter/filter_data'; + +function FilterFieldsMenu({ + onInserted, + ...props +}: MenuProps & { + onInserted?: () => void; +}) { + const viewId = useViewId(); + const { t } = useTranslation(); + + const addFilter = useCallback( + async (event: MouseEvent, field: Field) => { + const filterData = getDefaultFilter(field.type); + + if (!filterData) { + return; + } + + await insertFilter({ + viewId, + fieldId: field.id, + fieldType: field.type, + data: filterData, + }); + props.onClose?.({}, 'backdropClick'); + onInserted?.(); + }, + [props, viewId, onInserted] + ); + + return ( + + + + ); +} + +export default FilterFieldsMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx new file mode 100644 index 0000000000..4de49db195 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx @@ -0,0 +1,43 @@ +import React, { useMemo, useState } from 'react'; +import { useDatabase } from '$app/components/database'; +import Filter from '$app/components/database/components/filter/Filter'; +import Button from '@mui/material/Button'; +import FilterFieldsMenu from '$app/components/database/components/filter/FilterFieldsMenu'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; + +function Filters() { + const { t } = useTranslation(); + + const { filters, fields } = useDatabase(); + + const options = useMemo(() => { + return filters.map((filter) => { + const field = fields.find((field) => field.id === filter.fieldId); + + return { + filter, + field, + }; + }); + }, [filters, fields]); + + const [filterAnchorEl, setFilterAnchorEl] = useState(null); + const openAddFilterMenu = Boolean(filterAnchorEl); + + const handleClick = (e: React.MouseEvent) => { + setFilterAnchorEl(e.currentTarget); + }; + + return ( + + {options.map(({ filter, field }) => (field ? : null))} + }> + {t('grid.settings.addFilter')} + + setFilterAnchorEl(null)} /> + + ); +} + +export default React.memo(Filters); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilter.tsx new file mode 100644 index 0000000000..5c238dec2a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilter.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { Field, TextFilter as TextFilterType, TextFilterData } from '$app/components/database/application'; +import TextFilterConditionSelect from '$app/components/database/components/filter/field_filter/TextFilterConditionSelect'; +import { TextField } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +interface Props { + filter: TextFilterType; + field: Field; + onChange: (filterData: TextFilterData) => void; +} +function TextFilter({ filter, field, onChange }: Props) { + const { t } = useTranslation(); + const [selectedCondition, setSelectedCondition] = useState(filter.data.condition); + const [content, setContext] = useState(filter.data.content); + + return ( + + + {field.name} + { + const value = Number(e.target.value); + + setSelectedCondition(value); + onChange({ + condition: value, + content, + }); + }} + value={selectedCondition} + /> + + { + setContext(e.target.value); + }} + onBlur={() => { + onChange({ + condition: selectedCondition, + content, + }); + }} + /> + + ); +} + +export default TextFilter; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilterConditionSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilterConditionSelect.tsx new file mode 100644 index 0000000000..7f54abc429 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilterConditionSelect.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { MenuItem, SelectProps, FormControl } from '@mui/material'; +import Select from '@mui/material/Select'; +import { TextFilterConditionPB } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; + +const TextFilterConditions = Object.values(TextFilterConditionPB).filter( + (item) => typeof item !== 'string' +) as TextFilterConditionPB[]; + +function TextFilterConditionSelect(props: SelectProps) { + const { t } = useTranslation(); + const getText = useCallback( + (type: TextFilterConditionPB) => { + switch (type) { + case TextFilterConditionPB.Contains: + return t('grid.textFilter.contains'); + case TextFilterConditionPB.DoesNotContain: + return t('grid.textFilter.doesNotContain'); + case TextFilterConditionPB.Is: + return t('grid.textFilter.is'); + case TextFilterConditionPB.IsNot: + return t('grid.textFilter.isNot'); + case TextFilterConditionPB.StartsWith: + return t('grid.textFilter.startWith'); + case TextFilterConditionPB.EndsWith: + return t('grid.textFilter.endsWith'); + case TextFilterConditionPB.TextIsEmpty: + return t('grid.textFilter.isEmpty'); + case TextFilterConditionPB.TextIsNotEmpty: + return t('grid.textFilter.isNotEmpty'); + default: + return ''; + } + }, + [t] + ); + + return ( + + + {TextFilterConditions.map((value) => { + return ( + + {getText(value)} + + ); + })} + + + ); +} + +export default TextFilterConditionSelect; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx new file mode 100644 index 0000000000..a7b1d4e621 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx @@ -0,0 +1,36 @@ +import React, { FC, MouseEvent, useCallback } from 'react'; +import { Menu, MenuProps } from '@mui/material'; +import FieldList from '$app/components/database/components/field/FieldList'; +import { Field, sortService } from '$app/components/database/application'; +import { SortConditionPB } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; +import { useViewId } from '$app/hooks'; + +const SortFieldsMenu: FC< + MenuProps & { + onInserted?: () => void; + } +> = ({ onInserted, ...props }) => { + const { t } = useTranslation(); + const viewId = useViewId(); + const addSort = useCallback( + async (event: MouseEvent, field: Field) => { + await sortService.insertSort(viewId, { + fieldId: field.id, + fieldType: field.type, + condition: SortConditionPB.Ascending, + }); + props.onClose?.({}, 'backdropClick'); + onInserted?.(); + }, + [props, viewId, onInserted] + ); + + return ( + + + + ); +}; + +export default SortFieldsMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx index c50179332a..477b169cff 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx @@ -9,55 +9,53 @@ import { SortConditionPB } from '@/services/backend'; export interface SortItemProps { className?: string; - sort: Sort, + sort: Sort; } -export const SortItem: FC = ({ - className, - sort, -}) => { +export const SortItem: FC = ({ className, sort }) => { const viewId = useViewId(); - const handleFieldChange = useCallback((event: SelectChangeEvent, field: Field | undefined) => { - if (field) { + const handleFieldChange = useCallback( + (field: Field | undefined) => { + if (field) { + void sortService.updateSort(viewId, { + ...sort, + fieldId: field.id, + fieldType: field.type, + }); + } + }, + [viewId, sort] + ); + + const handleConditionChange = useCallback( + (event: SelectChangeEvent) => { void sortService.updateSort(viewId, { ...sort, - fieldId: field.id, - fieldType: field.type, + condition: event.target.value as SortConditionPB, }); - } - }, [viewId, sort]); - - const handleConditonChange = useCallback((event: SelectChangeEvent) => { - void sortService.updateSort(viewId, { - ...sort, - condition: event.target.value as SortConditionPB, - }); - }, [viewId, sort]); + }, + [viewId, sort] + ); const handleClick = useCallback(() => { void sortService.deleteSort(viewId, sort); }, [viewId, sort]); return ( - - + + - - - + + + + + ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx index 9383e1561e..23a6fe5d3a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx @@ -1,53 +1,59 @@ import { Menu, MenuItem, MenuProps } from '@mui/material'; -import { FC, MouseEventHandler, useCallback, useState, MouseEvent } from 'react'; +import { FC, MouseEventHandler, useCallback, useState } from 'react'; import { useViewId } from '$app/hooks'; -import { Field, sortService } from '../../application'; +import { sortService } from '../../application'; import { useDatabase } from '../../Database.hooks'; -import { FieldListMenu } from '../field'; import { SortItem } from './SortItem'; -import { SortConditionPB } from '@/services/backend'; + +import { useTranslation } from 'react-i18next'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; +import SortFieldsMenu from '$app/components/database/components/sort/SortFieldsMenu'; export const SortMenu: FC = (props) => { const { onClose } = props; - + const { t } = useTranslation(); const viewId = useViewId(); const { sorts } = useDatabase(); const [anchorEl, setAnchorEl] = useState(null); - + const openFieldListMenu = Boolean(anchorEl); const handleClick = useCallback>((event) => { setAnchorEl(event.currentTarget); }, []); - const handleClose = useCallback(() => { - setAnchorEl(null); - }, []); - const deleteAllSorts = useCallback(() => { void sortService.deleteAllSorts(viewId); onClose?.({}, 'backdropClick'); }, [viewId, onClose]); - const addSort = useCallback( - (event: MouseEvent, field: Field) => { - void sortService.insertSort(viewId, { - fieldId: field.id, - fieldType: field.type, - condition: SortConditionPB.Ascending, - }); - }, - [viewId] - ); - return ( <> - - {sorts.map((sort) => ( - - ))} - Add sort - Delete sort + + + + {sorts.map((sort) => ( + + ))} + + + + + {t('grid.sort.addSort')} + + + + {t('grid.sort.deleteAllSorts')} + + - + + { + setAnchorEl(null); + }} + /> > ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/Sorts.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/Sorts.tsx index 4651d21b5f..1073e9a709 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/Sorts.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/Sorts.tsx @@ -1,37 +1,45 @@ -import { Chip } from '@mui/material'; -import { FC, MouseEventHandler, useCallback, useState } from 'react'; -import { Sort } from '../../application'; +import { Chip, Divider } from '@mui/material'; +import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react'; import { SortMenu } from './SortMenu'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as SortSvg } from '$app/assets/sort.svg'; +import { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg'; +import { useDatabase } from '$app/components/database'; -export interface SortsProps { - sorts: Readonly[]; -} +export const Sorts = () => { + const { t } = useTranslation(); + const { sorts } = useDatabase(); + + const showSorts = sorts && sorts.length > 0; + const [anchorEl, setAnchorEl] = useState(null); -export const Sorts: FC = ({ - sorts, -}) => { - const [ anchorEl, setAnchorEl ] = useState(null); const handleClick = useCallback>((event) => { setAnchorEl(event.currentTarget); }, []); - const label = sorts.length === 1 - ? (1 sort) - : ({sorts.length} sorts); + const label = ( + + + {t('grid.settings.sort')} + + + ); + + const menuOpen = Boolean(anchorEl); + + useEffect(() => { + if (!showSorts) { + setAnchorEl(null); + } + }, [showSorts]); + + if (!showSorts) return null; return ( <> - - setAnchorEl(null)} - /> + + + setAnchorEl(null)} /> > ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/AddViewBtn.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/AddViewBtn.tsx index fff307514e..6145a487f4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/AddViewBtn.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/AddViewBtn.tsx @@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next'; import { ViewLayoutPB } from '@/services/backend'; import { createDatabaseView } from '$app/components/database/application/database_view/database_view_service'; -function AddViewBtn({ pageId }: { pageId: string }) { +function AddViewBtn({ pageId, onCreated }: { pageId: string; onCreated: (id: string) => void }) { const { t } = useTranslation(); const onClick = async () => { try { - await createDatabaseView(pageId, ViewLayoutPB.Grid, t('editor.table')); + const view = await createDatabaseView(pageId, ViewLayoutPB.Grid, t('editor.table')); + + onCreated(view.id); } catch (e) { console.error(e); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx index ef2020bfcf..51ccdaaefa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx @@ -1,4 +1,4 @@ -import { FC, FunctionComponent, SVGProps, useEffect } from 'react'; +import { FC, FunctionComponent, SVGProps, useEffect, useState } from 'react'; import { ViewTabs, ViewTab } from './ViewTabs'; import { useAppSelector } from '$app/stores/store'; import { useTranslation } from 'react-i18next'; @@ -7,6 +7,8 @@ import { ViewLayoutPB } from '@/services/backend'; import { ReactComponent as GridSvg } from '$app/assets/grid.svg'; import { ReactComponent as BoardSvg } from '$app/assets/board.svg'; import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; +import ViewActions from '$app/components/database/components/tab_bar/ViewActions'; +import { Page } from '$app_reducers/pages/slice'; export interface DatabaseTabBarProps { childViewIds: string[]; @@ -26,6 +28,9 @@ const DatabaseIcons: { export const DatabaseTabBar: FC = ({ pageId, childViewIds, selectedViewId, setSelectedViewId }) => { const { t } = useTranslation(); + const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState(null); + const [contextMenuView, setContextMenuView] = useState(null); + const open = Boolean(contextMenuAnchorEl); const views = useAppSelector((state) => { const map = state.pages.pageMap; @@ -43,14 +48,20 @@ export const DatabaseTabBar: FC = ({ pageId, childViewIds, }, [selectedViewId, setSelectedViewId, views]); return ( - - + + {views.map((view) => { const Icon = DatabaseIcons[view.layout]; return ( { + e.preventDefault(); + e.stopPropagation(); + setContextMenuView(view); + setContextMenuAnchorEl(e.currentTarget); + }} key={view.id} icon={} iconPosition='start' @@ -61,8 +72,20 @@ export const DatabaseTabBar: FC = ({ pageId, childViewIds, ); })} - + setSelectedViewId?.(id)} /> + {open && contextMenuView && ( + { + setContextMenuAnchorEl(null); + setContextMenuView(null); + }} + /> + )} ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx new file mode 100644 index 0000000000..9a2cc45021 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; +import { ReactComponent as EditSvg } from '$app/assets/edit.svg'; +import { deleteView, updateView } from '$app/components/database/application/database_view/database_view_service'; +import { MenuItem, MenuProps, Menu } from '@mui/material'; +import RenameDialog from '$app/components/layout/NestedPage/RenameDialog'; +import { Page } from '$app_reducers/pages/slice'; + +enum ViewAction { + Rename, + Delete, +} + +function ViewActions({ view, ...props }: { view: Page } & MenuProps) { + const { t } = useTranslation(); + const viewId = view.id; + const [openRenameDialog, setOpenRenameDialog] = useState(false); + const options = [ + { + id: ViewAction.Rename, + label: t('button.rename'), + icon: , + action: () => { + setOpenRenameDialog(true); + }, + }, + + { + id: ViewAction.Delete, + label: t('button.delete'), + icon: , + action: async () => { + try { + await deleteView(viewId); + props.onClose?.({}, 'backdropClick'); + } catch (e) { + // toast.error(t('error.deleteView')); + } + }, + }, + ]; + + return ( + <> + + {options.map((option) => ( + + {option.icon} + {option.label} + + ))} + + setOpenRenameDialog(false)} + onOk={async (val) => { + try { + await updateView(viewId, { + name: val, + }); + setOpenRenameDialog(false); + props.onClose?.({}, 'backdropClick'); + } catch (e) { + // toast.error(t('error.renameView')); + } + }} + defaultValue={view.name} + /> + > + ); +} + +export default ViewActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCalculate/GridCalculate.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCalculate/GridCalculate.tsx index ecb591955f..13a3506b96 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCalculate/GridCalculate.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridCalculate/GridCalculate.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useDatabase } from '$app/components/database'; +import { useDatabaseVisibilityRows } from '$app/components/database'; import { Field } from '$app/components/database/application'; import { DEFAULT_FIELD_WIDTH } from '$app/components/database/grid/GridRow'; @@ -9,7 +9,7 @@ interface Props { } function GridCalculate({ field, index }: Props) { - const { rowMetas } = useDatabase(); + const rowMetas = useDatabaseVisibilityRows(); const count = rowMetas.length; const width = field.width ?? DEFAULT_FIELD_WIDTH; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks.ts index 0b564e703d..e3dce4d803 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks.ts @@ -1,14 +1,13 @@ import { useGridUIStateDispatcher, useGridUIStateSelector } from '$app/components/database/proxy/grid/ui_state/actions'; -import { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -export function useGridRowActionsDisplay(rowId: string, ref: React.RefObject) { +export function useGridRowActionsDisplay(rowId: string) { const { hoverRowId, isActivated } = useGridUIStateSelector(); const hover = useMemo(() => { return isActivated && hoverRowId === rowId; }, [hoverRowId, rowId, isActivated]); const { setRowHover } = useGridUIStateDispatcher(); - const [actionsStyle, setActionsStyle] = useState(); const onMouseEnter = useCallback(() => { setRowHover(rowId); @@ -20,28 +19,7 @@ export function useGridRowActionsDisplay(rowId: string, ref: React.RefObject { - // Next frame to avoid layout thrashing - requestAnimationFrame(() => { - const element = ref.current; - - if (!hover || !element) { - setActionsStyle(undefined); - return; - } - - const rect = element.getBoundingClientRect(); - - setActionsStyle({ - position: 'absolute', - top: rect.top + 6, - left: rect.left - 50, - }); - }); - }, [ref, hover]); - return { - actionsStyle, onMouseEnter, onMouseLeave, hover, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.tsx index 78612bd12b..875763f6e9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridCellRow/GridCellRow.tsx @@ -33,7 +33,7 @@ export const GridCellRow: FC = ({ rowMeta, virtualizer, getPre const rowId = rowMeta.id; const viewId = useViewId(); const ref = useRef(null); - const { onMouseLeave, onMouseEnter, actionsStyle, hover } = useGridRowActionsDisplay(rowId, ref); + const { onMouseLeave, onMouseEnter, hover } = useGridRowActionsDisplay(rowId); const { isContextMenuOpen, closeContextMenu, @@ -107,7 +107,13 @@ export const GridCellRow: FC = ({ rowMeta, virtualizer, getPre }, [openContextMenu]); return ( - + = ({ rowMeta, virtualizer, getPre /> )} + - {isContextMenuOpen && ( > return ( <> {!isHidden && ( - + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx index 47fedae851..6b9fb2b2bd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRow/GridFieldRow.tsx @@ -9,14 +9,14 @@ export const GridFieldRow = () => { return ( <> - - + + {fields.map((field) => { return ; })} - + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx index 3e36839f87..0c4a255ed5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridTable/GridTable.tsx @@ -1,7 +1,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import React, { FC, useMemo, useRef } from 'react'; import { RowMeta } from '../../application'; -import { useDatabase, useDatabaseVisibilityFields } from '../../Database.hooks'; +import { useDatabaseVisibilityFields, useDatabaseVisibilityRows } from '../../Database.hooks'; import { VirtualizedList } from '../../_shared'; import { DEFAULT_FIELD_WIDTH, GridRow, RenderRow, RenderRowType, rowMetasToRenderRow } from '../GridRow'; @@ -16,7 +16,7 @@ const getRenderRowKey = (row: RenderRow) => { export const GridTable: FC<{ tableHeight: number }> = React.memo(({ tableHeight }) => { const verticalScrollElementRef = useRef(null); const horizontalScrollElementRef = useRef(null); - const { rowMetas } = useDatabase(); + const rowMetas = useDatabaseVisibilityRows(); const renderRows = useMemo(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]); const fields = useDatabaseVisibilityFields(); const rowVirtualizer = useVirtualizer({ @@ -63,7 +63,7 @@ export const GridTable: FC<{ tableHeight: number }> = React.memo(({ tableHeight }} > ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts index 73edd70594..87b947da98 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts @@ -15,6 +15,7 @@ import { RowsVisibilityChangePB, SortChangesetNotificationPB, FieldSettingsPB, + FilterChangesetNotificationPB, } from '@/services/backend'; const NotificationPBMap = { @@ -30,6 +31,7 @@ const NotificationPBMap = { [DatabaseNotification.DidUpdateCell]: null, [DatabaseNotification.DidUpdateSort]: SortChangesetNotificationPB, [DatabaseNotification.DidUpdateFieldSettings]: FieldSettingsPB, + [DatabaseNotification.DidUpdateFilter]: FilterChangesetNotificationPB, }; type NotificationMap = typeof NotificationPBMap; diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx index 1b14c93981..1a68b70237 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx @@ -14,7 +14,7 @@ export const DatabasePage = () => { } return ( - +
{t('deletePagePrompt.text')}