mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
fix: adjusting sort UI and support filter and settings (#3971)
* fix: adjusting sort UI and support filter and settings * fix: code review
This commit is contained in:
parent
6973d66263
commit
0427402ba7
frontend/appflowy_tauri/src/appflowy_app
assets
components/database
Database.hooks.tsDatabase.tsxDatabaseTitle.tsx
application
database_view
filter
row
components
cell
database_settings
DatabaseCollection.tsxDatabaseSettings.tsxFilterSettings.tsxProperties.tsxSettingsMenu.tsxSortSettings.tsx
edit_record
field
filter
sort
tab_bar
grid
GridCalculate
GridRow
GridTable
hooks
views
@ -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 |
9
frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg
Normal file
9
frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg
Normal file
@ -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 |
4
frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg
Normal file
4
frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg
Normal file
@ -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(
|
||||
|
14
frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_data.ts
Normal file
14
frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_data.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
34
frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_listeners.ts
Normal file
34
frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_listeners.ts
Normal file
@ -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);
|
||||
};
|
54
frontend/appflowy_tauri/src/appflowy_app/components/database/application/filter/filter_service.ts
54
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<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>
|
||||
);
|
||||
};
|
||||
|
34
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx
Normal file
34
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx
Normal file
@ -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);
|
39
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx
Normal file
39
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx
Normal file
@ -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;
|
33
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx
Normal file
33
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx
Normal file
@ -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;
|
74
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx
Normal file
74
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx
Normal file
@ -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;
|
47
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx
Normal file
47
frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx
Normal file
@ -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;
|
16
frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx
16
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 (
|
||||
<div className={'h-full px-12 py-6'}>
|
||||
<RecordDocument getDocumentTitle={getDocumentTitle} documentId={id} />
|
||||
{page && <RecordDocument getDocumentTitle={getDocumentTitle} documentId={id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
60
frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldList.tsx
Normal file
60
frontend/appflowy_tauri/src/appflowy_app/components/database/components/field/FieldList.tsx
Normal file
@ -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';
|
||||
|
93
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx
Normal file
93
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx
Normal file
@ -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;
|
42
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx
Normal file
42
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx
Normal file
@ -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;
|
46
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx
Normal file
46
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx
Normal file
@ -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;
|
43
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx
Normal file
43
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx
Normal file
@ -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);
|
52
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilter.tsx
Normal file
52
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilter.tsx
Normal file
@ -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;
|
54
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilterConditionSelect.tsx
Normal file
54
frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/field_filter/TextFilterConditionSelect.tsx
Normal file
@ -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;
|
36
frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx
Normal file
36
frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx
Normal file
@ -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);
|
||||
}
|
||||
|
31
frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx
31
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<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>
|
||||
);
|
||||
};
|
||||
|
74
frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx
Normal file
74
frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx
Normal file
@ -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} />
|
||||
|
Loading…
x
Reference in New Issue
Block a user