feat: support board preview on web (#5384)

This commit is contained in:
Kilu.He 2024-05-21 17:26:00 +08:00 committed by GitHub
parent aa07393253
commit b7bc847107
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 1179 additions and 357 deletions

View File

@ -107,6 +107,7 @@
"@types/quill": "^2.0.10",
"@types/react": "^18.2.66",
"@types/react-beautiful-dnd": "^13.1.3",
"@types/react-big-calendar": "^1.8.9",
"@types/react-color": "^3.0.6",
"@types/react-custom-scrollbars": "^4.0.13",
"@types/react-datepicker": "^4.19.3",

View File

@ -256,6 +256,9 @@ devDependencies:
'@types/react-beautiful-dnd':
specifier: ^13.1.3
version: 13.1.3
'@types/react-big-calendar':
specifier: ^1.8.9
version: 1.8.9
'@types/react-color':
specifier: ^3.0.6
version: 3.0.6
@ -2618,6 +2621,10 @@ packages:
dependencies:
'@babel/types': 7.24.0
/@types/date-arithmetic@4.1.4:
resolution: {integrity: sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw==}
dev: true
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@ -2723,6 +2730,14 @@ packages:
'@types/react': 18.2.66
dev: true
/@types/react-big-calendar@1.8.9:
resolution: {integrity: sha512-HIHLUxR3PzWHrFdZ00VnCMvDjAh5uzlL0vMC2b7tL3bKaAJsqq9T8h+x0GVeDbZfMfHAd1cs5tZBhVvourNJXQ==}
dependencies:
'@types/date-arithmetic': 4.1.4
'@types/prop-types': 15.7.12
'@types/react': 18.2.66
dev: true
/@types/react-color@3.0.6:
resolution: {integrity: sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==}
dependencies:

View File

@ -241,6 +241,9 @@ export enum YjsDatabaseKey {
condition = 'condition',
format = 'format',
filter_type = 'filter_type',
visible = 'visible',
hide_ungrouped_column = 'hide_ungrouped_column',
collapse_hidden_groups = 'collapse_hidden_groups',
}
export interface YDoc extends Y.Doc {
@ -425,18 +428,48 @@ export type YDatabaseFieldOrders = Y.Array<unknown>; // [ { id: FieldId } ]
export type YDatabaseRowOrders = Y.Array<YDatabaseRowOrder>; // [ { id: RowId, height: number } ]
export type YDatabaseGroups = Y.Array<unknown>;
export type YDatabaseGroups = Y.Array<YDatabaseGroup>;
export type YDatabaseFilters = Y.Array<YDatabaseFilter>;
export type YDatabaseSorts = Y.Array<YDatabaseSort>;
export type YDatabaseLayoutSettings = Y.Map<unknown>;
export type YDatabaseLayoutSettings = Y.Map<YDatabaseLayoutSetting>;
export type YDatabaseCalculations = Y.Array<YDatabaseCalculation>;
export type SortId = string;
export type GroupId = string;
export interface YDatabaseLayoutSetting extends Y.Map<unknown> {
// DatabaseViewLayout.Board
get(key: '2'): YDatabaseBoardLayoutSetting;
}
export interface YDatabaseBoardLayoutSetting extends Y.Map<unknown> {
get(key: YjsDatabaseKey.hide_ungrouped_column | YjsDatabaseKey.collapse_hidden_groups): boolean;
}
export interface YDatabaseGroup extends Y.Map<unknown> {
get(key: YjsDatabaseKey.id): GroupId;
get(key: YjsDatabaseKey.field_id): FieldId;
// eslint-disable-next-line @typescript-eslint/unified-signatures
get(key: YjsDatabaseKey.content): string;
get(key: YjsDatabaseKey.groups): YDatabaseGroupColumns;
}
export type YDatabaseGroupColumns = Y.Array<YDatabaseGroupColumn>;
export interface YDatabaseGroupColumn extends Y.Map<unknown> {
get(key: YjsDatabaseKey.id): string;
get(key: YjsDatabaseKey.visible): boolean;
}
export interface YDatabaseRowOrder extends Y.Map<unknown> {
get(key: YjsDatabaseKey.id): SortId;

View File

@ -1,10 +1,7 @@
import { YDatabase, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { filterBy } from '@/application/database-yjs/filter';
import { Row } from '@/application/database-yjs/selector';
import { sortBy } from '@/application/database-yjs/sort';
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext } from 'react';
import * as Y from 'yjs';
import debounce from 'lodash-es/debounce';
export interface DatabaseContextState {
readOnly: boolean;
@ -56,72 +53,16 @@ export function useDatabaseFields() {
return database.get(YjsDatabaseKey.fields);
}
export interface GridRowsState {
export interface RowsState {
rowOrders: Row[];
}
export const GridRowsContext = createContext<GridRowsState | null>(null);
export const RowsContext = createContext<RowsState | null>(null);
export function useGridRowsContext() {
return useContext(GridRowsContext);
export function useRowsContext() {
return useContext(RowsContext);
}
export function useGridRows() {
return useGridRowsContext()?.rowOrders;
}
export function useGridRowOrders() {
const rows = useContext(DatabaseContext)?.rowDocMap;
const [rowOrders, setRowOrders] = useState<Row[]>();
const view = useDatabaseView();
const sorts = view?.get(YjsDatabaseKey.sorts);
const fields = useDatabaseFields();
const filters = view?.get(YjsDatabaseKey.filters);
useEffect(() => {
const onConditionsChange = () => {
const originalRowOrders = view?.get(YjsDatabaseKey.row_orders).toJSON();
if (!originalRowOrders || !rows) return;
console.log('sort or filter changed');
if (sorts?.length === 0 && filters?.length === 0) {
setRowOrders(originalRowOrders);
return;
}
let rowOrders: Row[] | undefined;
if (sorts?.length) {
rowOrders = sortBy(originalRowOrders, sorts, fields, rows);
}
if (filters?.length) {
rowOrders = filterBy(rowOrders ?? originalRowOrders, filters, fields, rows);
}
if (rowOrders) {
setRowOrders(rowOrders);
} else {
setRowOrders(originalRowOrders);
}
};
const debounceConditionsChange = debounce(onConditionsChange, 200);
onConditionsChange();
sorts?.observeDeep(debounceConditionsChange);
filters?.observeDeep(debounceConditionsChange);
fields?.observeDeep(debounceConditionsChange);
rows?.observeDeep(debounceConditionsChange);
return () => {
sorts?.unobserveDeep(debounceConditionsChange);
filters?.unobserveDeep(debounceConditionsChange);
fields?.unobserveDeep(debounceConditionsChange);
rows?.observeDeep(debounceConditionsChange);
};
}, [fields, rows, sorts, filters, view]);
return rowOrders;
export function useRows() {
return useRowsContext()?.rowOrders;
}

View File

@ -0,0 +1,60 @@
import { YDatabaseField, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { FieldType } from '@/application/database-yjs/database.type';
import { parseSelectOptionTypeOptions } from '@/application/database-yjs/fields';
import { Row } from '@/application/database-yjs/selector';
import * as Y from 'yjs';
export function groupByField(rows: Row[], rowMetas: Y.Map<YDoc>, field: YDatabaseField) {
const fieldType = Number(field.get(YjsDatabaseKey.type));
const isSelectOptionField = [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType);
if (!isSelectOptionField) return;
return groupBySelectOption(rows, rowMetas, field);
}
function getCellData(rowId: string, fieldId: string, rowMetas: Y.Map<YDoc>) {
const rowMeta = rowMetas.get(rowId);
const meta = rowMeta?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow;
return meta?.get(YjsDatabaseKey.cells)?.get(fieldId)?.get(YjsDatabaseKey.data);
}
export function groupBySelectOption(rows: Row[], rowMetas: Y.Map<YDoc>, field: YDatabaseField) {
const fieldId = field.get(YjsDatabaseKey.id);
const result = new Map<string, Row[]>();
const typeOption = parseSelectOptionTypeOptions(field);
if (!typeOption) {
return;
}
if (typeOption.options.length === 0) {
result.set(fieldId, rows);
return result;
}
rows.forEach((row) => {
const cellData = getCellData(row.id, fieldId, rowMetas);
const selectedIds = (cellData as string)?.split(',') ?? [];
if (selectedIds.length === 0) {
const group = result.get(fieldId) ?? [];
group.push(row);
result.set(fieldId, group);
return;
}
selectedIds.forEach((id) => {
const option = typeOption.options.find((option) => option.id === id);
const groupName = option?.id ?? fieldId;
const group = result.get(groupName) ?? [];
group.push(row);
result.set(groupName, group);
});
});
return result;
}

View File

@ -1,9 +1,22 @@
import { FieldId, SortId, YDatabaseField, YjsDatabaseKey } from '@/application/collab.type';
import { MIN_COLUMN_WIDTH } from '@/application/database-yjs/const';
import { useDatabase, useGridRows, useViewId } from '@/application/database-yjs/context';
import { parseFilter } from '@/application/database-yjs/filter';
import {
DatabaseContext,
useDatabase,
useDatabaseFields,
useDatabaseView,
useRowMeta,
useRows,
useViewId,
} from '@/application/database-yjs/context';
import { filterBy, parseFilter } from '@/application/database-yjs/filter';
import { groupByField } from '@/application/database-yjs/group';
import { sortBy } from '@/application/database-yjs/sort';
import { useViewsIdSelector } from '@/application/folder-yjs';
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
import debounce from 'lodash-es/debounce';
import { useContext, useEffect, useMemo, useState } from 'react';
import { FieldType, FieldVisibility, Filter, SortCondition } from './database.type';
import { useEffect, useMemo, useState } from 'react';
export interface Column {
fieldId: string;
@ -19,11 +32,43 @@ export interface Row {
const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty];
export function useGridColumnsSelector(viewId: string, visibilitys: FieldVisibility[] = defaultVisible) {
export function useDatabaseViewsSelector() {
const database = useDatabase();
const { viewsId: visibleViewsId } = useViewsIdSelector();
const views = database?.get(YjsDatabaseKey.views);
const [viewIds, setViewIds] = useState<string[]>([]);
const childViews = useMemo(() => {
return viewIds.map((viewId) => views?.get(viewId));
}, [viewIds, views]);
useEffect(() => {
if (!views) return;
const observerEvent = () => {
setViewIds(Array.from(views.keys()).filter((id) => visibleViewsId.includes(id)));
};
observerEvent();
views.observe(observerEvent);
return () => {
views.unobserve(observerEvent);
};
}, [visibleViewsId, views]);
return {
childViews,
viewIds,
};
}
export function useFieldsSelector(visibilitys: FieldVisibility[] = defaultVisible) {
const viewId = useViewId();
const database = useDatabase();
const [columns, setColumns] = useState<Column[]>([]);
useEffect(() => {
if (!viewId) return;
const view = database?.get(YjsDatabaseKey.views)?.get(viewId);
const fields = database?.get(YjsDatabaseKey.fields);
const fieldsOrder = view?.get(YjsDatabaseKey.field_orders);
@ -39,11 +84,15 @@ export function useGridColumnsSelector(viewId: string, visibilitys: FieldVisibil
return {
fieldId,
width: parseInt(setting?.get(YjsDatabaseKey.width)) || MIN_COLUMN_WIDTH,
visibility: parseInt(setting?.get(YjsDatabaseKey.visibility)) as FieldVisibility,
visibility: Number(
setting?.get(YjsDatabaseKey.visibility) || FieldVisibility.AlwaysShown
) as FieldVisibility,
wrap: setting?.get(YjsDatabaseKey.wrap),
};
})
.filter((column) => visibilitys.includes(column.visibility));
.filter((column) => {
return visibilitys.includes(column.visibility);
});
};
const observerEvent = () => setColumns(getColumns());
@ -62,8 +111,8 @@ export function useGridColumnsSelector(viewId: string, visibilitys: FieldVisibil
return columns;
}
export function useGridRowsSelector() {
const rowOrders = useGridRows();
export function useRowsSelector() {
const rowOrders = useRows();
return useMemo(() => rowOrders ?? [], [rowOrders]);
}
@ -81,10 +130,10 @@ export function useFieldSelector(fieldId: string) {
setField(field || null);
const observerEvent = () => setClock((prev) => prev + 1);
field.observe(observerEvent);
field?.observe(observerEvent);
return () => {
field.unobserve(observerEvent);
field?.unobserve(observerEvent);
};
}, [database, fieldId]);
@ -225,3 +274,207 @@ export function useSortSelector(sortId: SortId) {
return sortValue;
}
export function useGroupsSelector() {
const database = useDatabase();
const viewId = useViewId();
const [groups, setGroups] = useState<string[]>([]);
useEffect(() => {
if (!viewId) return;
const view = database?.get(YjsDatabaseKey.views)?.get(viewId);
const groupOrders = view?.get(YjsDatabaseKey.groups);
if (!groupOrders) return;
const getGroups = () => {
return groupOrders.toJSON().map((item) => item.id);
};
const observerEvent = () => setGroups(getGroups());
setGroups(getGroups());
groupOrders.observe(observerEvent);
return () => {
groupOrders.unobserve(observerEvent);
};
}, [database, viewId]);
return groups;
}
export interface GroupColumn {
id: string;
visible: boolean;
}
export function useGroup(groupId: string) {
const database = useDatabase();
const viewId = useViewId() as string;
const view = database?.get(YjsDatabaseKey.views)?.get(viewId);
const group = view
?.get(YjsDatabaseKey.groups)
?.toArray()
.find((group) => group.get(YjsDatabaseKey.id) === groupId);
const groupColumns = group?.get(YjsDatabaseKey.groups);
const [fieldId, setFieldId] = useState<string | null>(null);
const [columns, setColumns] = useState<GroupColumn[]>([]);
useEffect(() => {
if (!viewId) return;
const observerEvent = () => {
setFieldId(group?.get(YjsDatabaseKey.field_id) as string);
};
observerEvent();
group?.observe(observerEvent);
const observerColumns = () => {
if (!groupColumns) return;
setColumns(groupColumns.toJSON());
};
observerColumns();
groupColumns?.observe(observerColumns);
return () => {
group?.unobserve(observerEvent);
groupColumns?.unobserve(observerColumns);
};
}, [database, viewId, groupId, group, groupColumns]);
return {
columns,
fieldId,
};
}
export function useRowsByGroup(groupId: string) {
const { columns, fieldId } = useGroup(groupId);
const rows = useContext(DatabaseContext)?.rowDocMap;
const rowOrders = useRowOrdersSelector();
const fields = useDatabaseFields();
const [notFound, setNotFound] = useState(false);
const [groupResult, setGroupResult] = useState<Map<string, Row[]>>(new Map());
useEffect(() => {
if (!fieldId || !rowOrders || !rows) return;
const onConditionsChange = () => {
const newResult = new Map<string, Row[]>();
const field = fields.get(fieldId);
if (!field) {
setNotFound(true);
setGroupResult(newResult);
return;
}
const groupResult = groupByField(rowOrders, rows, field);
if (!groupResult) {
setGroupResult(newResult);
return;
}
setGroupResult(groupResult);
};
onConditionsChange();
const debounceConditionsChange = debounce(onConditionsChange, 200);
fields.observeDeep(debounceConditionsChange);
return () => {
fields.unobserveDeep(debounceConditionsChange);
};
}, [fieldId, fields, rowOrders, rows]);
const visibleColumns = columns.filter((column) => column.visible);
return {
fieldId,
groupResult,
columns: visibleColumns,
notFound,
};
}
export function useRowOrdersSelector() {
const rows = useContext(DatabaseContext)?.rowDocMap;
const [rowOrders, setRowOrders] = useState<Row[]>();
const view = useDatabaseView();
const sorts = view?.get(YjsDatabaseKey.sorts);
const fields = useDatabaseFields();
const filters = view?.get(YjsDatabaseKey.filters);
useEffect(() => {
const onConditionsChange = () => {
const originalRowOrders = view?.get(YjsDatabaseKey.row_orders).toJSON();
if (!originalRowOrders || !rows) return;
if (sorts?.length === 0 && filters?.length === 0) {
setRowOrders(originalRowOrders);
return;
}
let rowOrders: Row[] | undefined;
if (sorts?.length) {
rowOrders = sortBy(originalRowOrders, sorts, fields, rows);
}
if (filters?.length) {
rowOrders = filterBy(rowOrders ?? originalRowOrders, filters, fields, rows);
}
if (rowOrders) {
setRowOrders(rowOrders);
} else {
setRowOrders(originalRowOrders);
}
};
const debounceConditionsChange = debounce(onConditionsChange, 200);
onConditionsChange();
sorts?.observeDeep(debounceConditionsChange);
filters?.observeDeep(debounceConditionsChange);
fields?.observeDeep(debounceConditionsChange);
rows?.observeDeep(debounceConditionsChange);
return () => {
sorts?.unobserveDeep(debounceConditionsChange);
filters?.unobserveDeep(debounceConditionsChange);
fields?.unobserveDeep(debounceConditionsChange);
rows?.observeDeep(debounceConditionsChange);
};
}, [fields, rows, sorts, filters, view]);
return rowOrders;
}
export function useCellSelector({ rowId, fieldId }: { rowId: string; fieldId: string }) {
const row = useRowMeta(rowId);
const cell = row?.get(YjsDatabaseKey.cells)?.get(fieldId);
const [cellValue, setCellValue] = useState(() => (cell ? parseYDatabaseCellToCell(cell) : undefined));
useEffect(() => {
if (!cell) return;
setCellValue(parseYDatabaseCellToCell(cell));
const observerEvent = () => setCellValue(parseYDatabaseCellToCell(cell));
cell.observe(observerEvent);
return () => {
cell.unobserve(observerEvent);
};
}, [cell]);
return cellValue;
}

View File

@ -12,11 +12,14 @@ export function useViewsIdSelector() {
const views = folder.get(YjsFolderKey.views);
const trash = folder.get(YjsFolderKey.section)?.get(YjsFolderKey.trash);
const meta = folder.get(YjsFolderKey.meta);
const trashUid = Array.from(trash?.keys())[0];
const userTrash = trash?.get(trashUid);
console.log('folder', folder.toJSON());
const collectIds = () => {
const trashIds = userTrash?.toJSON()?.map((item) => item.id) || [];
return Array.from(views.keys()).filter(
(id) => !trash?.has(id) && id !== meta?.get(YjsFolderKey.current_workspace)
(id) => !trashIds.includes(id) && id !== meta?.get(YjsFolderKey.current_workspace)
);
};
@ -24,9 +27,11 @@ export function useViewsIdSelector() {
const observerEvent = () => setViewsId(collectIds());
folder.observe(observerEvent);
userTrash.observe(observerEvent);
return () => {
folder.unobserve(observerEvent);
userTrash.unobserve(observerEvent);
};
}, [folder]);

View File

@ -1,27 +1,20 @@
import { DatabaseViewLayout, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { YDoc, YjsEditorKey } from '@/application/collab.type';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import RecordNotFound from '@/components/_shared/not-found/RecordNotFound';
import { AFConfigContext } from '@/components/app/AppConfig';
import { Board } from '@/components/database/board';
import { Calendar } from '@/components/database/calendar';
import { DatabaseConditionsContext } from '@/components/database/components/conditions/context';
import { Grid } from '@/components/database/grid';
import { DatabaseTabs, TabPanel } from '@/components/database/components/tabs';
import DatabaseViews from '@/components/database/DatabaseViews';
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
import DatabaseTitle from '@/components/database/DatabaseTitle';
import { Log } from '@/utils/log';
import CircularProgress from '@mui/material/CircularProgress';
import React, { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React, { memo, useCallback, useContext, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import SwipeableViews from 'react-swipeable-views';
import DatabaseConditions from 'src/components/database/components/conditions/DatabaseConditions';
import * as Y from 'yjs';
export const Database = memo(() => {
const { objectId, workspaceId } = useId() || {};
const [search, setSearch] = useSearchParams();
const viewId = search.get('v');
const [doc, setDoc] = useState<YDoc | null>(null);
const [rows, setRows] = useState<Y.Map<YDoc> | null>(null); // Map<rowId, YDoc
const [notFound, setNotFound] = useState<boolean>(false);
@ -48,10 +41,6 @@ export const Database = memo(() => {
void handleOpenDocument();
}, [handleOpenDocument]);
const database = useMemo(() => doc?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase, [doc]);
const views = useMemo(() => database?.get(YjsDatabaseKey.views), [database]);
const handleChangeView = useCallback(
(viewId: string) => {
setSearch({ v: viewId });
@ -59,32 +48,6 @@ export const Database = memo(() => {
[setSearch]
);
const viewIds = useMemo(() => (views ? Array.from(views.keys()) : []), [views]);
const value = useMemo(() => {
return Math.max(
0,
viewIds.findIndex((id) => id === (viewId ?? objectId))
);
}, [viewId, viewIds, objectId]);
const getDatabaseViewComponent = useCallback((layout: DatabaseViewLayout) => {
switch (layout) {
case DatabaseViewLayout.Grid:
return Grid;
case DatabaseViewLayout.Board:
return Board;
case DatabaseViewLayout.Calendar:
return Calendar;
}
}, []);
const [conditionsExpanded, setConditionsExpanded] = useState<boolean>(false);
const toggleExpanded = useCallback(() => {
setConditionsExpanded((prev) => !prev);
}, []);
console.log('viewId', viewId, 'objectId', doc, objectId, database);
if (!objectId) return null;
if (!doc) {
@ -104,41 +67,7 @@ export const Database = memo(() => {
<DatabaseTitle viewId={objectId} />
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<DatabaseContextProvider viewId={viewId || objectId} doc={doc} rowDocMap={rows} readOnly={true}>
<DatabaseConditionsContext.Provider
value={{
expanded: conditionsExpanded,
toggleExpanded,
}}
>
<DatabaseTabs selectedViewId={viewId || objectId} setSelectedViewId={handleChangeView} viewIds={viewIds} />
<DatabaseConditions />
</DatabaseConditionsContext.Provider>
<SwipeableViews
slideStyle={{
overflow: 'hidden',
}}
className={'h-full w-full flex-1 overflow-hidden'}
axis={'x'}
index={value}
containerStyle={{ height: '100%' }}
>
{viewIds.map((viewId, index) => {
const layout = Number(views.get(viewId)?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
const Component = getDatabaseViewComponent(layout);
return (
<TabPanel
data-view-id={viewId}
className={'flex h-full w-full flex-col'}
key={viewId}
index={index}
value={value}
>
<Component />
</TabPanel>
);
})}
</SwipeableViews>
<DatabaseViews onChangeView={handleChangeView} currentViewId={viewId || objectId} />
</DatabaseContextProvider>
</div>
</div>

View File

@ -0,0 +1,86 @@
import { DatabaseViewLayout, YjsDatabaseKey } from '@/application/collab.type';
import { useDatabaseViewsSelector } from '@/application/database-yjs';
import { Board } from '@/components/database/board';
import { Calendar } from '@/components/database/calendar';
import { DatabaseConditionsContext } from '@/components/database/components/conditions/context';
import { DatabaseTabs, TabPanel } from '@/components/database/components/tabs';
import { Grid } from '@/components/database/grid';
import React, { useCallback, useMemo, useState } from 'react';
import SwipeableViews from 'react-swipeable-views';
import DatabaseConditions from 'src/components/database/components/conditions/DatabaseConditions';
function DatabaseViews({
onChangeView,
currentViewId,
}: {
onChangeView: (viewId: string) => void;
currentViewId: string;
}) {
const { childViews, viewIds } = useDatabaseViewsSelector();
const value = useMemo(() => {
return Math.max(
0,
viewIds.findIndex((id) => id === currentViewId)
);
}, [currentViewId, viewIds]);
const getDatabaseViewComponent = useCallback((layout: DatabaseViewLayout) => {
switch (layout) {
case DatabaseViewLayout.Grid:
return Grid;
case DatabaseViewLayout.Board:
return Board;
case DatabaseViewLayout.Calendar:
return Calendar;
}
}, []);
const [conditionsExpanded, setConditionsExpanded] = useState<boolean>(false);
const toggleExpanded = useCallback(() => {
setConditionsExpanded((prev) => !prev);
}, []);
return (
<>
<DatabaseConditionsContext.Provider
value={{
expanded: conditionsExpanded,
toggleExpanded,
}}
>
<DatabaseTabs selectedViewId={currentViewId} setSelectedViewId={onChangeView} viewIds={viewIds} />
<DatabaseConditions />
</DatabaseConditionsContext.Provider>
<SwipeableViews
slideStyle={{
overflow: 'hidden',
}}
className={'h-full w-full flex-1 overflow-hidden'}
axis={'x'}
index={value}
containerStyle={{ height: '100%' }}
>
{childViews.map((view, index) => {
const layout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
const Component = getDatabaseViewComponent(layout);
const viewId = viewIds[index];
return (
<TabPanel
data-view-id={viewId}
className={'flex h-full w-full flex-col'}
key={viewId}
index={index}
value={value}
>
<Component />
</TabPanel>
);
})}
</SwipeableViews>
</>
);
}
export default DatabaseViews;

View File

@ -1,7 +1,34 @@
import { useDatabase, useGroupsSelector } from '@/application/database-yjs';
import { Group } from '@/components/database/components/board';
import { CircularProgress } from '@mui/material';
import React from 'react';
import { DragDropContext } from 'react-beautiful-dnd';
export function Board() {
return <div>Board</div>;
const database = useDatabase();
const groups = useGroupsSelector();
if (!database) {
return (
<div className={'flex w-full flex-1 flex-col items-center justify-center'}>
<CircularProgress />
</div>
);
}
return (
<DragDropContext
onDragEnd={() => {
//
}}
>
<div className={'grid-board flex w-full flex-1 flex-col'}>
{groups.map((groupId) => (
<Group key={groupId} groupId={groupId} />
))}
</div>
</DragDropContext>
);
}
export default Board;

View File

@ -1,7 +1,16 @@
import React from 'react';
import { Calendar as BigCalendar, dayjsLocalizer } from 'react-big-calendar';
import dayjs from 'dayjs';
import './calendar.scss';
const localizer = dayjsLocalizer(dayjs);
export function Calendar() {
return <div>Calendar</div>;
return (
<div className={'max-ms:px-4 px-24 py-4'}>
<BigCalendar localizer={localizer} startAccessor='start' endAccessor='end' style={{ height: 500 }} />
</div>
);
}
export default Calendar;

View File

@ -0,0 +1,2 @@
@import 'react-big-calendar/lib/sass/styles';
@import 'react-big-calendar/lib/addons/dragAndDrop/styles'; // if using DnD

View File

@ -0,0 +1,50 @@
import { useFieldsSelector } from '@/application/database-yjs';
import CardField from '@/components/database/components/board/card/CardField';
import React, { useEffect, useMemo } from 'react';
export interface CardProps {
groupFieldId: string;
rowId: string;
onResize?: (height: number) => void;
isDragging?: boolean;
}
export function Card({ groupFieldId, rowId, onResize, isDragging }: CardProps) {
const fields = useFieldsSelector();
const showFields = useMemo(() => fields.filter((field) => field.fieldId !== groupFieldId), [fields, groupFieldId]);
const ref = React.useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (isDragging) return;
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver(() => {
onResize?.(el.offsetHeight);
});
observer.observe(el);
return () => {
observer.disconnect();
};
}, [onResize, isDragging]);
return (
<div
ref={ref}
style={{
minHeight: '38px',
}}
className='flex cursor-pointer flex-col rounded-lg border border-line-divider p-3 shadow-sm hover:bg-fill-list-active hover:shadow'
>
{showFields.map((field, index) => {
return <CardField index={index} key={field.fieldId} rowId={rowId} fieldId={field.fieldId} />;
})}
</div>
);
}
export default Card;

View File

@ -0,0 +1,48 @@
import { YjsDatabaseKey } from '@/application/collab.type';
import { useCellSelector, useFieldSelector } from '@/application/database-yjs';
import Cell from '@/components/database/components/cell/Cell';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function CardField({ rowId, fieldId, index }: { rowId: string; fieldId: string; index: number }) {
const { t } = useTranslation();
const { field } = useFieldSelector(fieldId);
const cell = useCellSelector({
rowId,
fieldId,
});
const isPrimary = field?.get(YjsDatabaseKey.is_primary);
const style = useMemo(() => {
const styleProperties = {
fontSize: '12px',
};
if (isPrimary) {
Object.assign(styleProperties, {
fontSize: '14px',
fontWeight: 500,
});
}
if (index !== 0) {
Object.assign(styleProperties, {
marginTop: '8px',
});
}
return styleProperties;
}, [index, isPrimary]);
if (isPrimary && !cell?.data) {
return (
<div className={'text-text-caption'} style={style}>
{t('grid.row.titlePlaceholder')}
</div>
);
}
return <Cell style={style} readOnly cell={cell} rowId={rowId} fieldId={fieldId} />;
}
export default CardField;

View File

@ -0,0 +1 @@
export * from './Card';

View File

@ -0,0 +1,130 @@
import { Row } from '@/application/database-yjs';
import { AFScroller } from '@/components/_shared/scroller';
import { Tag } from '@/components/_shared/tag';
import ListItem from '@/components/database/components/board/column/ListItem';
import { useRenderColumn } from '@/components/database/components/board/column/useRenderColumn';
import { useMeasureHeight } from '@/components/database/components/cell/useMeasure';
import React, { useCallback, useEffect, useMemo } from 'react';
import { Draggable, DraggableProvided, Droppable } from 'react-beautiful-dnd';
import AutoSizer from 'react-virtualized-auto-sizer';
import { VariableSizeList } from 'react-window';
export interface ColumnProps {
id: string;
rows?: Row[];
fieldId: string;
provided: DraggableProvided;
}
export function Column({ id, rows, fieldId, provided }: ColumnProps) {
const { header } = useRenderColumn(id, fieldId);
const ref = React.useRef<VariableSizeList | null>(null);
const forceUpdate = useCallback((index: number) => {
ref.current?.resetAfterIndex(index, true);
}, []);
useEffect(() => {
forceUpdate(0);
}, [rows, forceUpdate]);
const measureRows = useMemo(
() =>
rows?.map((row) => {
return {
rowId: row.id,
};
}) || [],
[rows]
);
const { rowHeight, onResize } = useMeasureHeight({ forceUpdate, rows: measureRows });
const Row = useCallback(
({ index, style, data }: { index: number; style: React.CSSProperties; data: Row[] }) => {
const item = data[index];
// We are rendering an extra item for the placeholder
if (!item) {
return null;
}
const onResizeCallback = (height: number) => {
onResize(index, 0, {
width: 0,
height: height + 8,
});
};
return (
<Draggable isDragDisabled draggableId={item.id} index={index} key={item.id}>
{(provided) => (
<ListItem fieldId={fieldId} onResize={onResizeCallback} provided={provided} item={item} style={style} />
)}
</Draggable>
);
},
[fieldId, onResize]
);
const getItemSize = useCallback(
(index: number) => {
if (!rows || index >= rows.length) return 0;
const row = rows[index];
if (!row) return 0;
return rowHeight(index);
},
[rowHeight, rows]
);
if (!rows) return <div ref={provided.innerRef} />;
return (
<div key={id} className='column flex w-[230px] flex-col gap-4' {...provided.draggableProps} ref={provided.innerRef}>
<div className='column-header flex h-[24px] items-center text-xs font-medium' {...provided.dragHandleProps}>
<Tag label={header?.name} color={header?.color} />
</div>
<div className={'w-full flex-1 overflow-hidden'}>
<Droppable
droppableId={`column-${id}`}
mode='virtual'
renderClone={(provided, snapshot, rubric) => (
<ListItem
provided={provided}
isDragging={snapshot.isDragging}
item={rows[rubric.source.index]}
fieldId={fieldId}
/>
)}
>
{(provided, snapshot) => {
// Add an extra item to our list to make space for a dragging item
// Usually the DroppableProvided.placeholder does this, but that won't
// work in a virtual list
const itemCount = snapshot.isUsingPlaceholder ? rows.length + 1 : rows.length;
return (
<AutoSizer>
{({ height, width }: { height: number; width: number }) => {
return (
<VariableSizeList
ref={ref}
height={height}
itemCount={itemCount}
itemSize={getItemSize}
width={width}
outerElementType={AFScroller}
outerRef={provided.innerRef}
itemData={rows}
>
{Row}
</VariableSizeList>
);
}}
</AutoSizer>
);
}}
</Droppable>
</div>
</div>
);
}

View File

@ -0,0 +1,74 @@
import { Row } from '@/application/database-yjs';
import React from 'react';
import { DraggableProvided, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
import Card from 'src/components/database/components/board/card/Card';
export const ListItem = ({
provided,
item,
style,
onResize,
fieldId,
isDragging,
}: {
provided: DraggableProvided;
item: Row;
style?: React.CSSProperties;
fieldId: string;
onResize?: (height: number) => void;
isDragging?: boolean;
}) => {
return (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getStyle({
draggableStyle: provided.draggableProps.style,
virtualStyle: style,
isDragging,
})}
className={`w-full bg-bg-body ${isDragging ? 'is-dragging' : ''}`}
>
<Card onResize={onResize} rowId={item.id} groupFieldId={fieldId} />
</div>
);
};
function getStyle({
draggableStyle,
virtualStyle,
isDragging,
}: {
draggableStyle?: DraggingStyle | NotDraggingStyle;
virtualStyle?: React.CSSProperties;
isDragging?: boolean;
}) {
// If you don't want any spacing between your items
// then you could just return this.
// I do a little bit of magic to have some nice visual space
// between the row items
const combined = {
...virtualStyle,
...draggableStyle,
} as {
height: number;
left: number;
width: number;
};
// Being lazy: this is defined in our css file
const grid = 1;
// when dragging we want to use the draggable style for placement, otherwise use the virtual style
return {
...combined,
height: isDragging ? combined.height : combined.height - grid,
left: isDragging ? combined.left : combined.left + grid,
width: isDragging ? (draggableStyle as DraggingStyle)?.width : `calc(${combined.width} - ${grid * 2}px)`,
marginBottom: grid,
};
}
export default ListItem;

View File

@ -0,0 +1 @@
export * from './Column';

View File

@ -0,0 +1,31 @@
import { YjsDatabaseKey } from '@/application/collab.type';
import { FieldType, parseSelectOptionTypeOptions, useFieldSelector } from '@/application/database-yjs';
import { SelectOptionColorMap } from '@/components/database/components/cell/cell.const';
import { useMemo } from 'react';
export function useRenderColumn(id: string, fieldId: string) {
const { field } = useFieldSelector(fieldId);
const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType;
const fieldName = field?.get(YjsDatabaseKey.name) || '';
const header = useMemo(() => {
if (!field) return null;
switch (fieldType) {
case FieldType.SingleSelect:
case FieldType.MultiSelect: {
const option = parseSelectOptionTypeOptions(field)?.options.find((option) => option.id === id);
return {
name: option?.name || `No ${fieldName}`,
color: option?.color ? SelectOptionColorMap[option?.color] : 'transparent',
};
}
default:
return null;
}
}, [field, fieldName, fieldType, id]);
return {
header,
};
}

View File

@ -0,0 +1,71 @@
import { useRowsByGroup } from '@/application/database-yjs';
import { AFScroller } from '@/components/_shared/scroller';
import React from 'react';
import { Draggable, Droppable } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next';
import { Column } from '../column';
export interface GroupProps {
groupId: string;
}
export const Group = ({ groupId }: GroupProps) => {
const { columns, groupResult, fieldId, notFound } = useRowsByGroup(groupId);
const { t } = useTranslation();
if (notFound) {
return (
<div className={'mt-[10%] flex h-full w-full flex-col items-center gap-2 px-24 text-text-caption max-md:px-4'}>
<div className={'text-sm font-medium'}>{t('board.noGroup')}</div>
<div className={'text-xs'}>{t('board.noGroupDesc')}</div>
</div>
);
}
if (columns.length === 0 || !fieldId) return null;
return (
<AFScroller overflowYHidden className={'relative px-24 max-md:px-4'}>
<Droppable
droppableId={`group-${groupId}`}
direction='horizontal'
type='column'
renderClone={(provided, snapshot, rubric) => {
// we have a transform: * on one of the parents of a <Draggable /> then the positioning logic will be incorrect while dragging
// https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/reparenting.md
const id = columns[rubric.source.index].id;
return <Column key={id} rows={groupResult.get(id)} provided={provided} id={id} fieldId={fieldId} />;
}}
>
{(provided) => {
return (
<div
className='columns flex h-full w-fit gap-4 border-t border-line-divider py-4'
{...provided.droppableProps}
ref={provided.innerRef}
>
{columns.map((data, index) => (
<Draggable isDragDisabled key={data.id} draggableId={`column-${data.id}`} index={index}>
{(provided) => {
return (
<Column
provided={provided}
key={data.id}
id={data.id}
fieldId={fieldId}
rows={groupResult.get(data.id)}
/>
);
}}
</Draggable>
))}
</div>
);
}}
</Droppable>
</AFScroller>
);
};
export default Group;

View File

@ -0,0 +1 @@
export * from './Group';

View File

@ -0,0 +1 @@
export * from './group';

View File

@ -1,8 +0,0 @@
import { CalculationType } from '@/application/database-yjs/database.type';
export interface CalulationCell {
value: string;
fieldId: string;
id: string;
type: CalculationType;
}

View File

@ -26,7 +26,7 @@ export function useDateTypeCellDispatcher(fieldId: string) {
const getDateTimeStr = useCallback(
(timeStamp: string, includeTime?: boolean) => {
if (!typeOptionValue) return null;
if (!typeOptionValue || !timeStamp) return null;
const timeFormat = getTimeFormat(typeOptionValue.timeFormat);
const dateFormat = getDateFormat(typeOptionValue.dateFormat);
const format = [dateFormat];

View File

@ -1,31 +1,26 @@
import { FieldId, YjsDatabaseKey } from '@/application/collab.type';
import { YjsDatabaseKey } from '@/application/collab.type';
import { FieldType } from '@/application/database-yjs/database.type';
import { useFieldSelector } from '@/application/database-yjs/selector';
import RowCreateModifiedTime from '@/components/database/components/cell/RowCreateModifiedTime';
import { RowCreateModifiedTime } from '@/components/database/components/cell/created-modified';
import React, { FC, useMemo } from 'react';
import RichTextCell from '@/components/database/components/cell/TextCell';
import UrlCell from '@/components/database/components/cell/UrlCell';
import NumberCell from '@/components/database/components/cell/NumberCell';
import CheckboxCell from '@/components/database/components/cell/CheckboxCell';
import SelectCell from '@/components/database/components/cell/SelectionCell';
import DateTimeCell from '@/components/database/components/cell/DateTimeCell';
import ChecklistCell from '@/components/database/components/cell/ChecklistCell';
import { Cell as CellValue } from '@/components/database/components/cell/cell.type';
import RelationCell from '@/components/database/components/cell/RelationCell';
import { TextCell } from '@/components/database/components/cell/text';
import { UrlCell } from '@/components/database/components/cell/url';
import { NumberCell } from '@/components/database/components/cell/number';
import { CheckboxCell } from '@/components/database/components/cell/checkbox';
import { SelectOptionCell } from '@/components/database/components/cell/select-option';
import { DateTimeCell } from '@/components/database/components/cell/date';
import { ChecklistCell } from '@/components/database/components/cell/checklist';
import { CellProps, Cell as CellType } from '@/components/database/components/cell/cell.type';
import { RelationCell } from '@/components/database/components/cell/relation';
export interface CellProps {
rowId: string;
fieldId: FieldId;
cell?: CellValue;
}
export function Cell({ cell, rowId, fieldId }: CellProps) {
export function Cell(props: CellProps<CellType>) {
const { cell, rowId, fieldId, style } = props;
const { field } = useFieldSelector(fieldId);
const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType;
const Component = useMemo(() => {
switch (fieldType) {
case FieldType.RichText:
return RichTextCell;
return TextCell;
case FieldType.URL:
return UrlCell;
case FieldType.Number:
@ -34,7 +29,7 @@ export function Cell({ cell, rowId, fieldId }: CellProps) {
return CheckboxCell;
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return SelectCell;
return SelectOptionCell;
case FieldType.DateTime:
return DateTimeCell;
case FieldType.Checklist:
@ -42,21 +37,21 @@ export function Cell({ cell, rowId, fieldId }: CellProps) {
case FieldType.Relation:
return RelationCell;
default:
return RichTextCell;
return TextCell;
}
}, [fieldType]) as FC<{ cell?: CellValue; rowId: string; fieldId: FieldId }>;
}, [fieldType]) as FC<CellProps<CellType>>;
if (fieldType === FieldType.CreatedTime || fieldType === FieldType.LastEditedTime) {
const attrName = fieldType === FieldType.CreatedTime ? YjsDatabaseKey.created_at : YjsDatabaseKey.last_modified;
return <RowCreateModifiedTime rowId={rowId} fieldId={fieldId} attrName={attrName} />;
return <RowCreateModifiedTime style={style} rowId={rowId} fieldId={fieldId} attrName={attrName} />;
}
if (cell?.fieldType !== fieldType) {
if (cell && cell.fieldType !== fieldType) {
return null;
}
return <Component cell={cell} rowId={rowId} fieldId={fieldId} />;
return <Component {...props} />;
}
export default Cell;

View File

@ -1,14 +0,0 @@
import { FieldId } from '@/application/collab.type';
import { ReactComponent as CheckboxCheckSvg } from '$icons/16x/check_filled.svg';
import { ReactComponent as CheckboxUncheckSvg } from '$icons/16x/uncheck.svg';
import { CheckboxCell } from '@/components/database/components/cell/cell.type';
export default function ({ cell }: { cell?: CheckboxCell; rowId: string; fieldId: FieldId }) {
const checked = cell?.data;
return (
<div className='relative flex w-full cursor-pointer items-center text-lg text-fill-default'>
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
</div>
);
}

View File

@ -1,12 +0,0 @@
import { FieldId } from '@/application/collab.type';
import { useReadOnly } from '@/application/database-yjs';
import { TextCell } from '@/components/database/components/cell/cell.type';
import React from 'react';
function TextCellComponent({ cell }: { cell?: TextCell; rowId: string; fieldId: FieldId }) {
const readOnly = useReadOnly();
return <div className={`cursor-text ${readOnly ? 'select-text' : ''}`}>{cell?.data}</div>;
}
export default TextCellComponent;

View File

@ -1,6 +1,7 @@
import { RowId } from '@/application/collab.type';
import { DateFormat, SelectOption, TimeFormat } from '@/application/database-yjs';
import { FieldId, RowId } from '@/application/collab.type';
import { DateFormat, TimeFormat } from '@/application/database-yjs';
import { FieldType } from '@/application/database-yjs/database.type';
import React from 'react';
import { YArray } from 'yjs/dist/src/types/YArray';
export interface Cell {
@ -32,7 +33,7 @@ export interface UrlCell extends Cell {
export type SelectionId = string;
export interface SelectCell extends Cell {
export interface SelectOptionCell extends Cell {
fieldType: FieldType.SingleSelect | FieldType.MultiSelect;
data: SelectionId;
}
@ -51,11 +52,6 @@ export interface DateTimeCell extends Cell {
reminderId?: string;
}
export interface TimeStampCell extends Cell {
fieldType: FieldType.LastEditedTime | FieldType.CreatedTime;
data: TimestampCellData;
}
export interface DateTimeCellData {
date?: string;
time?: string;
@ -67,11 +63,6 @@ export interface DateTimeCellData {
isRange?: boolean;
}
export interface TimestampCellData {
dataTime?: string;
timestamp?: number;
}
export interface ChecklistCell extends Cell {
fieldType: FieldType.Checklist;
data: string;
@ -84,7 +75,10 @@ export interface RelationCell extends Cell {
export type RelationCellData = RowId[];
export interface ChecklistCellData {
selected_option_ids?: string[];
options?: SelectOption[];
export interface CellProps<T extends Cell> {
cell?: T;
rowId: string;
fieldId: FieldId;
style?: React.CSSProperties;
readOnly?: boolean;
}

View File

@ -0,0 +1,13 @@
import { ReactComponent as CheckboxCheckSvg } from '$icons/16x/check_filled.svg';
import { ReactComponent as CheckboxUncheckSvg } from '$icons/16x/uncheck.svg';
import { CellProps, CheckboxCell as CheckboxCellType } from '@/components/database/components/cell/cell.type';
export function CheckboxCell({ cell, style }: CellProps<CheckboxCellType>) {
const checked = cell?.data;
return (
<div style={style} className='relative flex w-full cursor-pointer items-center text-lg text-fill-default'>
{checked ? <CheckboxCheckSvg className={'h-4 w-4'} /> : <CheckboxUncheckSvg className={'h-4 w-4'} />}
</div>
);
}

View File

@ -0,0 +1 @@
export * from './CheckboxCell';

View File

@ -1,10 +1,9 @@
import { FieldId } from '@/application/collab.type';
import { parseChecklistData } from '@/application/database-yjs';
import { ChecklistCell } from '@/components/database/components/cell/cell.type';
import { CellProps, ChecklistCell as ChecklistCellType } from '@/components/database/components/cell/cell.type';
import LinearProgressWithLabel from '@/components/_shared/progress/LinearProgressWithLabel';
import React, { useMemo } from 'react';
export default function ({ cell }: { cell?: ChecklistCell; rowId: string; fieldId: FieldId }) {
export function ChecklistCell({ cell, style }: CellProps<ChecklistCellType>) {
const data = useMemo(() => {
return parseChecklistData(cell?.data ?? '');
}, [cell?.data]);
@ -14,7 +13,7 @@ export default function ({ cell }: { cell?: ChecklistCell; rowId: string; fieldI
if (!data || !options || !selectedOptions) return null;
return (
<div className={'cursor-pointer'}>
<div style={style} className={'cursor-pointer'}>
<LinearProgressWithLabel value={data?.percentage} count={options.length} selectedCount={selectedOptions.length} />
</div>
);

View File

@ -0,0 +1 @@
export * from './ChecklistCell';

View File

@ -3,13 +3,15 @@ import { useRowMeta } from '@/application/database-yjs';
import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks';
import React, { useEffect, useMemo, useState } from 'react';
function RowCreateModifiedTime({
export function RowCreateModifiedTime({
rowId,
fieldId,
attrName,
style,
}: {
rowId: string;
fieldId: string;
style?: React.CSSProperties;
attrName: YjsDatabaseKey.last_modified | YjsDatabaseKey.created_at;
}) {
const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId);
@ -37,7 +39,8 @@ function RowCreateModifiedTime({
return getDateTimeStr(value, false);
}, [value, getDateTimeStr]);
return <div>{time}</div>;
if (!time) return null;
return <div style={style}>{time}</div>;
}
export default RowCreateModifiedTime;

View File

@ -0,0 +1 @@
export * from './RowCreateModifiedTime';

View File

@ -1,10 +1,9 @@
import { FieldId } from '@/application/collab.type';
import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks';
import { DateTimeCell } from '@/components/database/components/cell/cell.type';
import { CellProps, DateTimeCell as DateTimeCellType } from '@/components/database/components/cell/cell.type';
import React, { useMemo } from 'react';
import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg';
export default function ({ cell, fieldId }: { cell?: DateTimeCell; rowId: string; fieldId: FieldId }) {
export function DateTimeCell({ cell, fieldId, style }: CellProps<DateTimeCellType>) {
const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId);
const startDateTime = useMemo(() => {
@ -26,8 +25,9 @@ export default function ({ cell, fieldId }: { cell?: DateTimeCell; rowId: string
const hasReminder = !!cell?.reminderId;
if (!cell?.data) return null;
return (
<div className={'flex cursor-text items-center gap-1'}>
<div style={style} className={'flex cursor-text items-center gap-1'}>
{hasReminder && <ReminderSvg className={'h-4 w-4'} />}
{dateStr}
</div>

View File

@ -0,0 +1 @@
export * from './DateTimeCell';

View File

@ -1,10 +1,9 @@
import { FieldId } from '@/application/collab.type';
import { currencyFormaterMap, NumberFormat, useFieldSelector, parseNumberTypeOptions } from '@/application/database-yjs';
import { UrlCell } from '@/components/database/components/cell/cell.type';
import { CellProps, NumberCell as NumberCellType } from '@/components/database/components/cell/cell.type';
import React, { useMemo } from 'react';
import Decimal from 'decimal.js';
export default function ({ cell, fieldId }: { cell?: UrlCell; rowId: string; fieldId: FieldId }) {
export function NumberCell({ cell, fieldId, style }: CellProps<NumberCellType>) {
const { field } = useFieldSelector(fieldId);
const format = useMemo(() => (field ? parseNumberTypeOptions(field).format : NumberFormat.Num), [field]);
@ -23,5 +22,10 @@ export default function ({ cell, fieldId }: { cell?: UrlCell; rowId: string; fie
return numberFormater(new Decimal(cell.data).toNumber());
}, [cell, format]);
return <div className={className}>{value}</div>;
if (value === undefined) return null;
return (
<div style={style} className={className}>
{value}
</div>
);
}

View File

@ -0,0 +1 @@
export * from './NumberCell';

View File

@ -0,0 +1,7 @@
import { CellProps, RelationCell as RelationCellType } from '@/components/database/components/cell/cell.type';
import RelationItems from '@/components/database/components/cell/relation/RelationItems';
export function RelationCell({ cell, fieldId, style }: CellProps<RelationCellType>) {
if (!cell?.data) return null;
return <RelationItems cell={cell} fieldId={fieldId} style={style} />;
}

View File

@ -1,24 +1,16 @@
import {
FieldId,
YDatabaseField,
YDatabaseFields,
YDatabaseRow,
YDoc,
YjsDatabaseKey,
YjsEditorKey,
} from '@/application/collab.type';
import { useFieldSelector, parseRelationTypeOption } from '@/application/database-yjs';
import { YDatabaseField, YDatabaseFields, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { parseRelationTypeOption, useFieldSelector } from '@/application/database-yjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { AFConfigContext } from '@/components/app/AppConfig';
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
import { RelationCell, RelationCellData } from '@/components/database/components/cell/cell.type';
import { RelationPrimaryValue } from '@/components/database/components/cell/relation/RelationPrimaryValue';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import * as Y from 'yjs';
export default function ({ cell, fieldId }: { cell?: RelationCell; fieldId: string; rowId: string }) {
function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId: string; style?: React.CSSProperties }) {
const { field } = useFieldSelector(fieldId);
const workspaceId = useId()?.workspaceId;
const rowIds = useMemo(() => (cell?.data.toJSON() as RelationCellData) ?? [], [cell?.data]);
const rowIds = useMemo(() => (cell.data.toJSON() as RelationCellData) ?? [], [cell.data]);
const databaseId = rowIds.length > 0 && field ? parseRelationTypeOption(field).database_id : undefined;
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
const [databasePrimaryFieldId, setDatabasePrimaryFieldId] = useState<string | undefined>(undefined);
@ -43,7 +35,7 @@ export default function ({ cell, fieldId }: { cell?: RelationCell; fieldId: stri
}, [workspaceId, databaseId, databaseService]);
return (
<div className={'flex items-center gap-2'}>
<div style={style} className={'flex items-center gap-2'}>
{rowIds.map((rowId) => {
const rowDoc = rows?.get(rowId);
@ -59,26 +51,4 @@ export default function ({ cell, fieldId }: { cell?: RelationCell; fieldId: stri
);
}
function RelationPrimaryValue({ rowDoc, fieldId }: { rowDoc: YDoc; fieldId: FieldId }) {
const [text, setText] = useState<string | null>(null);
useEffect(() => {
const row = rowDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow;
const cells = row.get(YjsDatabaseKey.cells);
const primaryCell = cells.get(fieldId);
if (!primaryCell) return;
const observeHandler = () => {
setText(parseYDatabaseCellToCell(primaryCell).data as string);
};
observeHandler();
primaryCell.observe(observeHandler);
return () => {
primaryCell.unobserve(observeHandler);
};
}, [rowDoc, fieldId]);
return <div>{text}</div>;
}
export default RelationItems;

View File

@ -0,0 +1,27 @@
import { FieldId, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
import React, { useEffect, useState } from 'react';
export function RelationPrimaryValue({ rowDoc, fieldId }: { rowDoc: YDoc; fieldId: FieldId }) {
const [text, setText] = useState<string | null>(null);
useEffect(() => {
const row = rowDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow;
const cells = row.get(YjsDatabaseKey.cells);
const primaryCell = cells.get(fieldId);
if (!primaryCell) return;
const observeHandler = () => {
setText(parseYDatabaseCellToCell(primaryCell).data as string);
};
observeHandler();
primaryCell.observe(observeHandler);
return () => {
primaryCell.unobserve(observeHandler);
};
}, [rowDoc, fieldId]);
return <div>{text}</div>;
}

View File

@ -0,0 +1 @@
export * from './RelationCell';

View File

@ -1,11 +1,10 @@
import { FieldId } from '@/application/collab.type';
import { useFieldSelector, parseSelectOptionTypeOptions } from '@/application/database-yjs';
import { Tag } from '@/components/_shared/tag';
import { SelectOptionColorMap } from '@/components/database/components/cell/cell.const';
import { SelectCell } from '@/components/database/components/cell/cell.type';
import { CellProps, SelectOptionCell as SelectOptionCellType } from '@/components/database/components/cell/cell.type';
import React, { useCallback, useMemo } from 'react';
export default function ({ cell, fieldId }: { cell?: SelectCell; rowId: string; fieldId: FieldId }) {
export function SelectOptionCell({ cell, fieldId, style }: CellProps<SelectOptionCellType>) {
const selectOptionIds = useMemo(() => cell?.data.split(','), [cell]);
const { field } = useFieldSelector(fieldId);
const typeOption = useMemo(() => {
@ -24,9 +23,11 @@ export default function ({ cell, fieldId }: { cell?: SelectCell; rowId: string;
[typeOption]
);
if (!typeOption || !selectOptionIds?.length) return null;
return (
<div className={'flex h-full w-full cursor-pointer items-center gap-1 overflow-x-hidden'}>
{selectOptionIds ? renderSelectedOptions(selectOptionIds) : null}
<div style={style} className={'flex h-full w-full cursor-pointer items-center gap-1 overflow-x-hidden'}>
{renderSelectedOptions(selectOptionIds)}
</div>
);
}

View File

@ -0,0 +1 @@
export * from './SelectOptionCell';

View File

@ -0,0 +1,14 @@
import { useReadOnly } from '@/application/database-yjs';
import { CellProps, TextCell as TextCellType } from '@/components/database/components/cell/cell.type';
import React from 'react';
export function TextCell({ cell, style }: CellProps<TextCellType>) {
const readOnly = useReadOnly();
if (!cell?.data) return null;
return (
<div style={style} className={`cursor-text leading-[1.2] ${readOnly ? 'select-text' : ''}`}>
{cell?.data}
</div>
);
}

View File

@ -0,0 +1 @@
export * from './TextCell';

View File

@ -1,10 +1,9 @@
import { FieldId } from '@/application/collab.type';
import { useReadOnly } from '@/application/database-yjs';
import { UrlCell } from '@/components/database/components/cell/cell.type';
import { CellProps, UrlCell as UrlCellType } from '@/components/database/components/cell/cell.type';
import { openUrl, processUrl } from '@/utils/url';
import React, { useMemo } from 'react';
export default function ({ cell }: { cell?: UrlCell; rowId: string; fieldId: FieldId }) {
export function UrlCell({ cell, style }: CellProps<UrlCellType>) {
const readOnly = useReadOnly();
const isUrl = useMemo(() => (cell ? processUrl(cell.data) : false), [cell]);
@ -21,8 +20,11 @@ export default function ({ cell }: { cell?: UrlCell; rowId: string; fieldId: Fie
return classList.join(' ');
}, [isUrl]);
if (!cell?.data) return null;
return (
<div
style={style}
onClick={() => {
if (!isUrl || !cell) return;
if (readOnly) {

View File

@ -0,0 +1 @@
export * from './UrlCell';

View File

@ -0,0 +1,53 @@
import { DEFAULT_ROW_HEIGHT } from '@/application/database-yjs';
import { useCallback, useRef } from 'react';
export function useMeasureHeight({
forceUpdate,
rows,
}: {
forceUpdate: (index: number) => void;
rows: {
rowId?: string;
}[];
}) {
const heightRef = useRef<{ [rowId: string]: number }>({});
const rowHeight = useCallback(
(index: number) => {
const row = rows[index];
if (!row || !row.rowId) return DEFAULT_ROW_HEIGHT;
return heightRef.current[row.rowId] || DEFAULT_ROW_HEIGHT;
},
[rows]
);
const setRowHeight = useCallback(
(index: number, height: number) => {
const row = rows[index];
const rowId = row.rowId;
if (!row || !rowId) return;
const oldHeight = heightRef.current[rowId];
heightRef.current[rowId] = Math.max(oldHeight || DEFAULT_ROW_HEIGHT, height);
if (oldHeight !== height) {
forceUpdate(index);
}
},
[forceUpdate, rows]
);
const onResize = useCallback(
(rowIndex: number, columnIndex: number, size: { width: number; height: number }) => {
setRowHeight(rowIndex, size.height);
},
[setRowHeight]
);
return {
rowHeight,
onResize,
};
}

View File

@ -1,2 +0,0 @@
export * from './GridColumn';
export * from './useRenderColumns';

View File

@ -1,11 +1,21 @@
import { CalculationType } from '@/application/database-yjs/database.type';
import { CalulationCell } from '@/components/database/components/calculation-cell/cell.type';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export function CalculationCell({ cell }: { cell?: CalulationCell }) {
export interface ICalculationCell {
value: string;
fieldId: string;
id: string;
type: CalculationType;
}
export interface CalculationCellProps {
cell?: ICalculationCell;
}
export function CalculationCell({ cell }: CalculationCellProps) {
const { t } = useTranslation();
const prefix = useMemo(() => {
if (!cell) return '';

View File

@ -1,9 +1,8 @@
import { FieldId, YjsDatabaseKey } from '@/application/collab.type';
import { useRowMeta } from '@/application/database-yjs';
import { FieldId } from '@/application/collab.type';
import { useCellSelector } from '@/application/database-yjs';
import { useFieldSelector } from '@/application/database-yjs/selector';
import { Cell } from '@/components/database/components/cell';
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
export interface GridCellProps {
rowId: string;
@ -16,21 +15,10 @@ export interface GridCellProps {
export function GridCell({ onResize, rowId, fieldId, columnIndex, rowIndex }: GridCellProps) {
const ref = React.useRef<HTMLDivElement>(null);
const field = useFieldSelector(fieldId);
const row = useRowMeta(rowId);
const cell = row?.get(YjsDatabaseKey.cells)?.get(fieldId);
const [cellValue, setCellValue] = useState(() => (cell ? parseYDatabaseCellToCell(cell) : undefined));
useEffect(() => {
if (!cell) return;
setCellValue(parseYDatabaseCellToCell(cell));
const observerEvent = () => setCellValue(parseYDatabaseCellToCell(cell));
cell.observe(observerEvent);
return () => {
cell.unobserve(observerEvent);
};
}, [cell]);
const cell = useCellSelector({
rowId,
fieldId,
});
useEffect(() => {
const el = ref.current;
@ -56,7 +44,7 @@ export function GridCell({ onResize, rowId, fieldId, columnIndex, rowIndex }: Gr
if (!field) return null;
return (
<div ref={ref} className={'grid-cell w-full cursor-text overflow-hidden text-xs'}>
<Cell cell={cellValue} rowId={rowId} fieldId={fieldId} />
<Cell cell={cell} rowId={rowId} fieldId={fieldId} />
</div>
);
}

View File

@ -0,0 +1,2 @@
export * from './GridColumn';
export * from 'src/components/database/components/grid/grid-column/useRenderFields';

View File

@ -1,6 +1,6 @@
import { FieldId } from '@/application/collab.type';
import { FieldVisibility } from '@/application/database-yjs/database.type';
import { useGridColumnsSelector } from '@/application/database-yjs/selector';
import { useFieldsSelector } from '@/application/database-yjs/selector';
import { useCallback, useMemo } from 'react';
export enum GridColumnType {
@ -9,8 +9,6 @@ export enum GridColumnType {
NewProperty,
}
const defaultVisibilitys = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty];
export type RenderColumn = {
type: GridColumnType;
visibility?: FieldVisibility;
@ -19,12 +17,12 @@ export type RenderColumn = {
wrap?: boolean;
};
export function useRenderColumns(viewId: string) {
const columns = useGridColumnsSelector(viewId, defaultVisibilitys);
export function useRenderFields() {
const fields = useFieldsSelector();
console.log('columns', columns);
console.log('columns', fields);
const renderColumns = useMemo(() => {
const fields = columns.map((column) => ({
const data = fields.map((column) => ({
...column,
type: GridColumnType.Field,
}));
@ -34,7 +32,7 @@ export function useRenderColumns(viewId: string) {
type: GridColumnType.Action,
width: 96,
},
...fields,
...data,
{
type: GridColumnType.NewProperty,
width: 150,
@ -44,7 +42,7 @@ export function useRenderColumns(viewId: string) {
width: 96,
},
].filter(Boolean) as RenderColumn[];
}, [columns]);
}, [fields]);
const columnWidth = useCallback(
(index: number, containerWidth: number) => {
@ -67,7 +65,7 @@ export function useRenderColumns(viewId: string) {
);
return {
columns: renderColumns,
fields: renderColumns,
columnWidth,
};
}

View File

@ -1,8 +1,7 @@
import { YjsDatabaseKey } from '@/application/collab.type';
import { useDatabaseView } from '@/application/database-yjs';
import { CalculationType } from '@/application/database-yjs/database.type';
import { CalculationCell } from '@/components/database/components/calculation-cell';
import { CalulationCell } from '@/components/database/components/calculation-cell/cell.type';
import { CalculationCell, ICalculationCell } from '../grid-calculation-cell';
import React, { useEffect, useState } from 'react';
export interface GridCalculateRowCellProps {
@ -11,7 +10,7 @@ export interface GridCalculateRowCellProps {
export function GridCalculateRowCell({ fieldId }: GridCalculateRowCellProps) {
const calculations = useDatabaseView()?.get(YjsDatabaseKey.calculations);
const [calculation, setCalculation] = useState<CalulationCell>();
const [calculation, setCalculation] = useState<ICalculationCell>();
useEffect(() => {
if (!calculations) return;

View File

@ -1,6 +1,6 @@
import { GridColumnType } from '@/components/database/components/grid-column';
import { GridColumnType } from '../grid-column';
import React from 'react';
import GridCell from 'src/components/database/components/grid-cell/GridCell';
import GridCell from '../grid-cell/GridCell';
export interface GridRowCellProps {
rowId: string;

View File

@ -1,6 +1,5 @@
import { useReadOnly } from '@/application/database-yjs';
import { DEFAULT_ROW_HEIGHT } from '@/application/database-yjs/const';
import { useGridRowsSelector } from '@/application/database-yjs/selector';
import { DEFAULT_ROW_HEIGHT, useReadOnly, useRowsSelector } from '@/application/database-yjs';
import { useMemo } from 'react';
export enum RenderRowType {
@ -16,7 +15,7 @@ export type RenderRow = {
};
export function useRenderRows() {
const rows = useGridRowsSelector();
const rows = useRowsSelector();
const readOnly = useReadOnly();
const renderRows = useMemo(() => {

View File

@ -1,12 +1,7 @@
import { DEFAULT_ROW_HEIGHT } from '@/application/database-yjs/const';
import { AFScroller } from '@/components/_shared/scroller';
import { GridColumnType, RenderColumn } from '@/components/database/components/grid-column';
import {
GridCalculateRowCell,
GridRowCell,
RenderRowType,
useRenderRows,
} from '@/components/database/components/grid-row';
import { GridColumnType, RenderColumn } from '../grid-column';
import { GridCalculateRowCell, GridRowCell, RenderRowType, useRenderRows } from '../grid-row';
import React, { useCallback, useEffect, useRef } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GridChildComponentProps, VariableSizeGrid } from 'react-window';

View File

@ -0,0 +1,3 @@
export * from './grid-table';
export * from './grid-header';
export * from './grid-column';

View File

@ -52,7 +52,10 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
if (viewIds.length === 0) return null;
return (
<div ref={ref} className='mx-24 flex items-center overflow-hidden text-text-title max-md:mx-4'>
<div
ref={ref}
className='mx-24 -mb-[0.5px] flex items-center overflow-hidden border-b border-line-divider text-text-title max-md:mx-4'
>
<div
style={{
width: 'calc(100% - 120px)',

View File

@ -1,16 +1,15 @@
import { GridRowsContext, useDatabase, useGridRowOrders, useViewId } from '@/application/database-yjs';
import { useRenderColumns } from '@/components/database/components/grid-column';
import { RowsContext, useDatabase, useRowOrdersSelector, useViewId } from '@/application/database-yjs';
import { useRenderFields, GridHeader, GridTable } from '@/components/database/components/grid';
import { CircularProgress } from '@mui/material';
import React, { useState } from 'react';
import { GridHeader } from 'src/components/database/components/grid-header';
import { GridTable } from 'src/components/database/components/grid-table';
export function Grid() {
const database = useDatabase();
const [scrollLeft, setScrollLeft] = useState(0);
const viewId = useViewId() || '';
const { columns, columnWidth } = useRenderColumns(viewId);
const rowOrders = useGridRowOrders();
const [scrollLeft, setScrollLeft] = useState(0);
const { fields, columnWidth } = useRenderFields();
const rowOrders = useRowOrdersSelector();
if (!database || !rowOrders) {
return (
@ -21,24 +20,24 @@ export function Grid() {
}
return (
<GridRowsContext.Provider
<RowsContext.Provider
value={{
rowOrders,
}}
>
<div className={'flex w-full flex-1 flex-col'}>
<GridHeader scrollLeft={scrollLeft} columnWidth={columnWidth} columns={columns} onScrollLeft={setScrollLeft} />
<GridHeader scrollLeft={scrollLeft} columnWidth={columnWidth} columns={fields} onScrollLeft={setScrollLeft} />
<div className={'grid-scroll-table w-full flex-1'}>
<GridTable
viewId={viewId}
scrollLeft={scrollLeft}
columnWidth={columnWidth}
columns={columns}
columns={fields}
onScrollLeft={setScrollLeft}
/>
</div>
</div>
</GridRowsContext.Provider>
</RowsContext.Provider>
);
}

View File

@ -45,7 +45,7 @@
opacity: 60%;
}
.workspaces, .database-conditions, .grid-scroll-table {
.workspaces, .database-conditions, .grid-scroll-table, .grid-board {
::-webkit-scrollbar {
width: 0;
height: 0;

View File

@ -1252,7 +1252,9 @@
"showGroup": "Show group",
"showGroupContent": "Are you sure you want to show this group on the board?",
"failedToLoad": "Failed to load board view"
}
},
"noGroup": "No group by property",
"noGroupDesc": "Board views require a property to group by in order to display"
},
"calendar": {
"menuName": "Calendar",