fix: adjusting sort UI and support filter and settings ()

* fix: adjusting sort UI and support filter and settings

* fix: code review
This commit is contained in:
Kilu.He 2023-11-23 15:57:08 +08:00 committed by GitHub
parent 6973d66263
commit 0427402ba7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1130 additions and 247 deletions

@ -0,0 +1,6 @@
<svg width='100%' height='100%' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M11.5757 14.5757L8.52426 11.5243C8.14629 11.1463 8.41399 10.5 8.94853 10.5H15.0515C15.586 10.5 15.8537 11.1463 15.4757 11.5243L12.4243 14.5757C12.1899 14.8101 11.8101 14.8101 11.5757 14.5757Z'
fill='currentColor'
/>
</svg>

After

(image error) Size: 363 B

@ -0,0 +1,9 @@
<svg width='100%' height='100%' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M8 13H14' stroke='currentColor' strokeLinecap='round' stroke-linejoin='round' />
<path
d='M10.8849 3.36289C11.1173 3.13054 11.4324 3 11.761 3C11.9237 3 12.0848 3.03205 12.2351 3.09431C12.3855 3.15658 12.5221 3.24784 12.6371 3.36289C12.7522 3.47794 12.8434 3.61453 12.9057 3.76485C12.968 3.91517 13 4.07629 13 4.23899C13 4.4017 12.968 4.56281 12.9057 4.71314C12.8434 4.86346 12.7522 5.00004 12.6371 5.11509L5.33627 12.4159L3 13L3.58407 10.6637L10.8849 3.36289Z'
stroke='currentColor'
stroke-linecap='round'
stroke-linejoin='round'
/>
</svg>

After

(image error) Size: 708 B

@ -0,0 +1,4 @@
<svg width='100%' height='100%' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<rect x='2.5' y='3' width='4' height='10' rx='1' stroke='currentColor' />
<rect x='9.5' y='7' width='4' height='6' rx='1' stroke='currentColor' />
</svg>

After

(image error) Size: 261 B

@ -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 }
);

@ -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<string[]>([]);
const { ref, collectionRef, tableHeight } = useDatabaseResize();
const [openCollections, setOpenCollections] = useState<string[]>([]);
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 (
<div className='mb-2 flex h-full w-full items-center justify-center rounded border border-dashed border-line-divider'>
<p className={'text-xl text-text-caption'}>{t('deletePagePrompt.text')}</p>
</div>
);
}
return (
<div ref={ref} className='appflowy-database flex flex-1 flex-col overflow-y-hidden'>
<div ref={ref} className='appflowy-database relative flex flex-1 flex-col overflow-y-hidden'>
<DatabaseTabBar
pageId={viewId}
setSelectedViewId={setSelectedViewId}
@ -63,9 +107,20 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
{childViewIds.map((id) => (
<TabPanel key={id} index={index} value={index}>
<DatabaseLoader viewId={id}>
<div ref={collectionRef}>
<DatabaseCollection />
</div>
{selectedViewId === id && (
<>
<Portal container={ref.current}>
<div className={'absolute right-16 top-0 py-1'}>
<DatabaseSettings
onToggleCollection={(forceOpen?: boolean) => onToggleCollection(id, forceOpen)}
/>
</div>
</Portal>
<div ref={collectionRef}>
<DatabaseCollection open={openCollections.includes(id)} />
</div>
</>
)}
<DatabaseView isActivated={selectedViewId === id} tableHeight={tableHeight} />
</DatabaseLoader>

@ -38,7 +38,7 @@ export const DatabaseTitle = () => {
);
return (
<div className='mb-6 h-[70px] pt-8'>
<div className='mb-6 h-[70px] px-16 pt-8'>
<input
className='text-3xl font-semibold'
value={title}

@ -17,7 +17,7 @@ export async function getDatabaseViews(viewId: string): Promise<Page[]> {
return [parserViewPBToPage(result.val), ...result.val.child_views.map(parserViewPBToPage)];
}
return Promise.reject(result.err);
return Promise.reject(result.val);
}
export async function createDatabaseView(

@ -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;
}
}

@ -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);
};

@ -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<FilterPB[]> {
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<void> {
export async function insertFilter({
viewId,
fieldId,
fieldType,
data,
filterId,
}: {
viewId: string;
fieldId: string;
fieldType: FieldType;
data: UndeterminedFilter['data'];
filterId?: string;
}): Promise<void> {
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<void> {
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<Filter, 'data'>): Promise<void> {
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;
}

@ -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;
}
});
};

@ -5,6 +5,7 @@ export interface RowMeta {
documentId?: string;
icon?: string;
cover?: string;
isHidden?: boolean;
}
export function pbToRowMeta(pb: RowMetaPB): RowMeta {

@ -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}` });

@ -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 (
<div className={`flex items-center ${!showCollection ? 'h-0' : 'border-b border-line-divider py-3'}`}>
{showSorts && <Sorts sorts={sorts as Sort[]} />}
<div className={`flex items-center px-16 ${!open ? 'hidden' : 'py-3'}`}>
<Sorts />
<Filters />
</div>
);
};

@ -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 | HTMLElement>(null);
return (
<Stack className='text-neutral-500' direction='row' spacing='2px'>
<FilterSettings {...props} />
<SortSettings {...props} />
<TextButton color='inherit' onClick={(e) => setSettingAnchorEl(e.currentTarget)}>
{t('settings.title')}
</TextButton>
<SettingsMenu
open={Boolean(settingAnchorEl)}
anchorEl={settingAnchorEl}
onClose={() => setSettingAnchorEl(null)}
/>
</Stack>
);
}
export default React.memo(DatabaseSettings);

@ -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 | HTMLElement>(null);
const open = Boolean(filterAnchorEl);
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
if (highlight) {
onToggleCollection();
return;
}
setFilterAnchorEl(e.currentTarget);
};
return (
<>
<TextButton onClick={handleClick} color={highlight ? 'primary' : 'inherit'}>
{t('grid.settings.filter')}
</TextButton>
<FilterFieldsMenu
onInserted={() => onToggleCollection(true)}
open={open}
anchorEl={filterAnchorEl}
onClose={() => setFilterAnchorEl(null)}
/>
</>
);
}
export default FilterSettings;

@ -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 (
<div className={'max-h-[300px] overflow-y-auto py-2'}>
{fields.map((field) => (
<MenuItem
disabled={field.isPrimary}
onClick={() => onItemClick(field)}
className={'flex w-full items-center justify-between'}
key={field.id}
>
<Field field={field} />
<div className={'ml-2'}>{field.visibility !== FieldVisibility.AlwaysHidden ? <EyeOpen /> : <EyeClosed />}</div>
</MenuItem>
))}
</div>
);
}
export default Properties;

@ -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 (
<>
<Menu {...props}>
<MenuItem
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
setPropertiesAnchorElPosition({
top: rect.top,
left: rect.left + rect.width,
});
props.onClose?.({}, 'backdropClick');
}}
>
{t('grid.settings.properties')}
</MenuItem>
</Menu>
<Popover
open={openProperties}
onClose={() => {
setPropertiesAnchorElPosition(undefined);
}}
anchorReference={'anchorPosition'}
anchorPosition={propertiesAnchorElPosition}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Properties onItemClick={togglePropertyVisibility} />
</Popover>
</>
);
}
export default SettingsMenu;

@ -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 | HTMLElement>(null);
const open = Boolean(sortAnchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (highlight) {
onToggleCollection();
return;
}
setSortAnchorEl(event.currentTarget);
};
const handleClose = () => {
setSortAnchorEl(null);
};
return (
<>
<TextButton className={'p-1'} color={highlight ? 'primary' : 'inherit'} onClick={handleClick}>
{t('grid.settings.sort')}
</TextButton>
<SortFieldsMenu
onInserted={() => onToggleCollection(true)}
open={open}
anchorEl={sortAnchorEl}
onClose={handleClose}
/>
</>
);
}
export default SortSettings;

@ -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 (
<div className={'h-full px-12 py-6'}>
<RecordDocument getDocumentTitle={getDocumentTitle} documentId={id} />
{page && <RecordDocument getDocumentTitle={getDocumentTitle} documentId={id} />}
</div>
);
}

@ -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<HTMLLIElement>, field: FieldType) => void;
}
function FieldList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) {
const { fields } = useDatabase();
const [fieldsResult, setFieldsResult] = useState<FieldType[]>(fields as FieldType[]);
const onInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
const result = fields.filter((field) => field.name.toLowerCase().includes(value.toLowerCase()));
setFieldsResult(result);
},
[fields]
);
const searchInput = useMemo(() => {
return showSearch ? (
<div className={'w-full px-8 py-2'}>
<Input 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>
) : null;
}, [fieldsResult]);
return (
<>
{searchInput}
{emptyList}
{fieldsResult.map((field) => (
<MenuItem
key={field.id}
value={field.id}
onClick={(event) => {
onItemClick?.(event, field);
}}
>
<Field field={field} />
</MenuItem>
))}
</>
);
}
export default FieldList;

@ -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<HTMLLIElement>, field: FieldType) => void;
}
export const FieldListMenu: FC<FieldsMenuProps> = ({ onMenuItemClick, ...props }) => {
const fields = useDatabaseVisibilityFields();
return (
<Menu {...props}>
{fields.map((field) => (
<MenuItem
key={field.id}
value={field.id}
onClick={(event) => {
onMenuItemClick?.(event, field);
props.onClose?.({}, 'backdropClick');
}}
>
<Field field={field} />
</MenuItem>
))}
</Menu>
);
};

@ -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<SelectProps, 'onChange'> {
onChange?: (event: SelectChangeEvent<unknown>, field: FieldType | undefined) => void;
onChange?: (field: FieldType | undefined) => void;
}
export const FieldSelect: FC<FieldSelectProps> = ({ onChange, ...props }) => {
const fields = useDatabaseVisibilityFields();
const { fields } = useDatabase();
const handleChange = useCallback(
(event: SelectChangeEvent<unknown>) => {
const selectedId = event.target.value;
onChange?.(
event,
fields.find((field) => field.id === selectedId)
);
onChange?.(fields.find((field) => field.id === selectedId));
},
[onChange, fields]
);
return (
<Select onChange={handleChange} {...props}>
<Select
onChange={handleChange}
{...props}
sx={{
'& .MuiInputBase-input': {
display: 'flex',
alignItems: 'center',
},
}}
>
{fields.map((field) => (
<MenuItem key={field.id} value={field.id}>
<Field field={field} />

@ -1,5 +1,4 @@
export * from './Field';
export * from './FieldSelect';
export * from './FieldListMenu';
export * from './FieldTypeText';
export * from './FieldTypeSvg';

@ -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 | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
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 (
<>
<Chip
clickable
variant='outlined'
label={
<div className={'flex items-center justify-center'}>
<Field field={field} />
<DropDownSvg className={'ml-1.5 h-8 w-8'} />
</div>
}
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'}>
{Component && <Component filter={filter} field={field} onChange={onDataChange} />}
<FilterActions filter={filter} />
</div>
</Popover>
</>
);
}
export default Filter;

@ -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 | HTMLElement>(null);
const open = Boolean(anchorEl);
const onClose = () => {
setAnchorEl(null);
};
const onDelete = async () => {
try {
await deleteFilter(viewId, filter);
} catch (e) {
// toast.error(e.message);
}
};
return (
<>
<IconButton
onClick={(e) => {
setAnchorEl(e.currentTarget);
}}
>
<MoreSvg />
</IconButton>
<Menu keepMounted={false} open={open} anchorEl={anchorEl} onClose={onClose}>
<MenuItem onClick={onDelete}>{t('grid.settings.deleteFilter')}</MenuItem>
</Menu>
</>
);
}
export default FilterActions;

@ -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 (
<Menu {...props}>
<FieldList showSearch searchPlaceholder={t('grid.settings.filterBy')} onItemClick={addFilter} />
</Menu>
);
}
export default FilterFieldsMenu;

@ -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 | HTMLElement>(null);
const openAddFilterMenu = Boolean(filterAnchorEl);
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
setFilterAnchorEl(e.currentTarget);
};
return (
<div className={'flex items-center justify-center gap-[10px]'}>
{options.map(({ filter, field }) => (field ? <Filter key={filter.id} filter={filter} field={field} /> : null))}
<Button onClick={handleClick} color={'inherit'} startIcon={<AddSvg />}>
{t('grid.settings.addFilter')}
</Button>
<FilterFieldsMenu open={openAddFilterMenu} anchorEl={filterAnchorEl} onClose={() => setFilterAnchorEl(null)} />
</div>
);
}
export default React.memo(Filters);

@ -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 (
<div className={'flex flex-col'}>
<div className={'mb-3 flex gap-[20px]'}>
<div className={'text-text-caption'}>{field.name}</div>
<TextFilterConditionSelect
onChange={(e) => {
const value = Number(e.target.value);
setSelectedCondition(value);
onChange({
condition: value,
content,
});
}}
value={selectedCondition}
/>
</div>
<TextField
size={'small'}
value={content}
placeholder={t('grid.settings.typeAValue')}
onChange={(e) => {
setContext(e.target.value);
}}
onBlur={() => {
onChange({
condition: selectedCondition,
content,
});
}}
/>
</div>
);
}
export default TextFilter;

@ -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 (
<FormControl size={'small'} variant={'standard'}>
<Select {...props}>
{TextFilterConditions.map((value) => {
return (
<MenuItem key={value} value={value}>
{getText(value)}
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
export default TextFilterConditionSelect;

@ -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 (
<Menu keepMounted={false} {...props}>
<FieldList showSearch={true} onItemClick={addSort} searchPlaceholder={t('grid.settings.sortBy')} />
</Menu>
);
};
export default SortFieldsMenu;

@ -9,55 +9,53 @@ import { SortConditionPB } from '@/services/backend';
export interface SortItemProps {
className?: string;
sort: Sort,
sort: Sort;
}
export const SortItem: FC<SortItemProps> = ({
className,
sort,
}) => {
export const SortItem: FC<SortItemProps> = ({ className, sort }) => {
const viewId = useViewId();
const handleFieldChange = useCallback((event: SelectChangeEvent<unknown>, 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<SortConditionPB>) => {
void sortService.updateSort(viewId, {
...sort,
fieldId: field.id,
fieldType: field.type,
condition: event.target.value as SortConditionPB,
});
}
}, [viewId, sort]);
const handleConditonChange = useCallback((event: SelectChangeEvent<SortConditionPB>) => {
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 (
<Stack
className={className}
direction="row"
spacing={2}
>
<FieldSelect
size="small"
value={sort.fieldId}
onChange={handleFieldChange}
/>
<Stack className={className} direction='row' spacing={2}>
<FieldSelect className={'w-[150px]'} size='small' value={sort.fieldId} onChange={handleFieldChange} />
<SortConditionSelect
size="small"
className={'w-[150px]'}
size='small'
value={sort.condition}
onChange={handleConditonChange}
onChange={handleConditionChange}
/>
<IconButton onClick={handleClick}>
<CloseSvg />
</IconButton>
<div className={'flex items-center justify-center'}>
<IconButton className={'h-6 w-6'} onClick={handleClick}>
<CloseSvg />
</IconButton>
</div>
</Stack>
);
};

@ -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<MenuProps> = (props) => {
const { onClose } = props;
const { t } = useTranslation();
const viewId = useViewId();
const { sorts } = useDatabase();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const openFieldListMenu = Boolean(anchorEl);
const handleClick = useCallback<MouseEventHandler<HTMLElement>>((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 (
<>
<Menu {...props}>
{sorts.map((sort) => (
<SortItem key={sort.id} className='mx-2' sort={sort} />
))}
<MenuItem onClick={handleClick}>Add sort</MenuItem>
<MenuItem onClick={deleteAllSorts}>Delete sort</MenuItem>
<Menu keepMounted={false} {...props} onClose={onClose}>
<div className={'max-h-[300px] overflow-y-auto p-2'}>
<div className={'mb-2 px-4'}>
{sorts.map((sort) => (
<SortItem key={sort.id} className='m-2' sort={sort} />
))}
</div>
<MenuItem onClick={handleClick}>
<AddSvg className={'mr-1 h-5 w-5'} />
{t('grid.sort.addSort')}
</MenuItem>
<MenuItem onClick={deleteAllSorts}>
<DeleteSvg className={'mr-1 h-5 w-5'} />
{t('grid.sort.deleteAllSorts')}
</MenuItem>
</div>
</Menu>
<FieldListMenu open={anchorEl !== null} anchorEl={anchorEl} onClose={handleClose} onMenuItemClick={addSort} />
<SortFieldsMenu
open={openFieldListMenu}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null);
}}
/>
</>
);
};

@ -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<Sort>[];
}
export const Sorts = () => {
const { t } = useTranslation();
const { sorts } = useDatabase();
const showSorts = sorts && sorts.length > 0;
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
export const Sorts: FC<SortsProps> = ({
sorts,
}) => {
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
const handleClick = useCallback<MouseEventHandler<HTMLElement>>((event) => {
setAnchorEl(event.currentTarget);
}, []);
const label = sorts.length === 1
? (<div>1 sort</div>)
: (<div>{sorts.length} sorts</div>);
const label = (
<div className={'flex items-center justify-center'}>
<SortSvg className={'mr-1.5 h-4 w-4'} />
{t('grid.settings.sort')}
<DropDownSvg className={'ml-1.5 h-6 w-6'} />
</div>
);
const menuOpen = Boolean(anchorEl);
useEffect(() => {
if (!showSorts) {
setAnchorEl(null);
}
}, [showSorts]);
if (!showSorts) return null;
return (
<>
<Chip
clickable
variant="outlined"
label={label}
onClick={handleClick}
/>
<SortMenu
open={anchorEl !== null}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
/>
<Chip clickable variant='outlined' label={label} onClick={handleClick} />
<Divider className={'mx-2'} orientation='vertical' flexItem />
<SortMenu open={menuOpen} anchorEl={anchorEl} onClose={() => setAnchorEl(null)} />
</>
);
};

@ -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);
}

@ -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<DatabaseTabBarProps> = ({ pageId, childViewIds, selectedViewId, setSelectedViewId }) => {
const { t } = useTranslation();
const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState<HTMLElement | null>(null);
const [contextMenuView, setContextMenuView] = useState<Page | null>(null);
const open = Boolean(contextMenuAnchorEl);
const views = useAppSelector((state) => {
const map = state.pages.pageMap;
@ -43,14 +48,20 @@ export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViewIds,
}, [selectedViewId, setSelectedViewId, views]);
return (
<div className='-mb-px flex items-center border-b border-line-divider'>
<div className='flex flex-1 items-center'>
<div className='-mb-px flex items-center px-16'>
<div className='flex flex-1 items-center border-b border-line-divider'>
<ViewTabs value={selectedViewId} onChange={handleChange}>
{views.map((view) => {
const Icon = DatabaseIcons[view.layout];
return (
<ViewTab
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setContextMenuView(view);
setContextMenuAnchorEl(e.currentTarget);
}}
key={view.id}
icon={<Icon />}
iconPosition='start'
@ -61,8 +72,20 @@ export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViewIds,
);
})}
</ViewTabs>
<AddViewBtn pageId={pageId} />
<AddViewBtn pageId={pageId} onCreated={(id) => setSelectedViewId?.(id)} />
</div>
{open && contextMenuView && (
<ViewActions
view={contextMenuView}
keepMounted={false}
open={open}
anchorEl={contextMenuAnchorEl}
onClose={() => {
setContextMenuAnchorEl(null);
setContextMenuView(null);
}}
/>
)}
</div>
);
};

@ -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: <EditSvg />,
action: () => {
setOpenRenameDialog(true);
},
},
{
id: ViewAction.Delete,
label: t('button.delete'),
icon: <DeleteSvg />,
action: async () => {
try {
await deleteView(viewId);
props.onClose?.({}, 'backdropClick');
} catch (e) {
// toast.error(t('error.deleteView'));
}
},
},
];
return (
<>
<Menu keepMounted={false} {...props}>
{options.map((option) => (
<MenuItem key={option.id} onClick={option.action}>
<div className={'mr-1.5'}>{option.icon}</div>
{option.label}
</MenuItem>
))}
</Menu>
<RenameDialog
open={openRenameDialog}
onClose={() => 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;

@ -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;

@ -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<HTMLDivElement>) {
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<CSSProperties | undefined>();
const onMouseEnter = useCallback(() => {
setRowHover(rowId);
@ -20,28 +19,7 @@ export function useGridRowActionsDisplay(rowId: string, ref: React.RefObject<HTM
}
}, [setRowHover, hover]);
useEffect(() => {
// 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,

@ -33,7 +33,7 @@ export const GridCellRow: FC<GridCellRowProps> = ({ rowMeta, virtualizer, getPre
const rowId = rowMeta.id;
const viewId = useViewId();
const ref = useRef<HTMLDivElement | null>(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<GridCellRowProps> = ({ rowMeta, virtualizer, getPre
}, [openContextMenu]);
return (
<div ref={ref} className='flex grow' onMouseLeave={onMouseLeave} onMouseEnter={onMouseEnter} {...dropListeners}>
<div
ref={ref}
className='relative -ml-16 flex grow pl-16'
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
{...dropListeners}
>
<div
ref={setPreviewRef}
className={`relative flex grow border-b border-line-divider ${isDragging ? 'bg-blue-50' : ''}`}
@ -133,17 +139,17 @@ export const GridCellRow: FC<GridCellRowProps> = ({ rowMeta, virtualizer, getPre
/>
)}
</div>
<GridCellRowActions
isHidden={!hover}
className={'absolute left-2 top-[6px] z-10'}
dragProps={{
...dragListeners,
...dragAttributes,
}}
rowId={rowMeta.id}
getPrevRowId={getPrevRowId}
/>
<Portal>
<GridCellRowActions
isHidden={!hover}
style={actionsStyle}
dragProps={{
...dragListeners,
...dragAttributes,
}}
rowId={rowMeta.id}
getPrevRowId={getPrevRowId}
/>
{isContextMenuOpen && (
<GridCellRowContextMenu
open={isContextMenuOpen}

@ -58,7 +58,7 @@ export const GridCellRowActions: FC<PropsWithChildren<GridCellRowActionsProps>>
return (
<>
{!isHidden && (
<div ref={ref} className={`relative inline-flex items-center ${className || ''}`} {...props}>
<div ref={ref} className={`inline-flex items-center ${className || ''}`} {...props}>
<Tooltip placement='top' title={t('grid.row.add')}>
<IconButton onClick={handleInsertRecordBelow}>
<AddSvg />

@ -9,14 +9,14 @@ export const GridFieldRow = () => {
return (
<>
<div className='z-10 flex border-b border-line-divider'>
<div className={'flex'}>
<div className='z-10 flex border-b border-line-divider '>
<div className={'flex '}>
{fields.map((field) => {
return <GridField key={field.id} field={field} />;
})}
</div>
<div className={`w-[${DEFAULT_FIELD_WIDTH}px]`}>
<div className={` w-[${DEFAULT_FIELD_WIDTH}px]`}>
<NewProperty />
</div>
</div>

@ -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<HTMLDivElement | null>(null);
const horizontalScrollElementRef = useRef<HTMLDivElement | null>(null);
const { rowMetas } = useDatabase();
const rowMetas = useDatabaseVisibilityRows();
const renderRows = useMemo<RenderRow[]>(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]);
const fields = useDatabaseVisibilityFields();
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
@ -63,7 +63,7 @@ export const GridTable: FC<{ tableHeight: number }> = React.memo(({ tableHeight
}}
>
<VirtualizedList
className='flex w-fit basis-full flex-col'
className='flex w-fit basis-full flex-col px-16'
virtualizer={rowVirtualizer}
itemClassName='flex'
renderItem={(index) => (

@ -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;

@ -14,7 +14,7 @@ export const DatabasePage = () => {
}
return (
<div className='flex h-full w-full flex-col overflow-hidden px-16 caret-text-title'>
<div className='flex h-full w-full flex-col overflow-hidden caret-text-title'>
<ViewIdProvider value={viewId}>
<DatabaseTitle />
<Database selectedViewId={selectedViewId} setSelectedViewId={onChange} />