feat: support preview the calendar view on web (#5394)

This commit is contained in:
Kilu.He 2024-05-22 21:00:56 +08:00 committed by GitHub
parent 68c4e19f91
commit acae34836e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 816 additions and 125 deletions

View File

@ -34,7 +34,7 @@ dependencies:
version: 6.0.0-alpha.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@mui/x-date-pickers-pro':
specifier: ^6.18.2
version: 6.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.14.4)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0)
version: 6.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.15.15)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0)
'@reduxjs/toolkit':
specifier: 2.0.0
version: 2.0.0(react-redux@8.0.5)(react@18.2.0)
@ -1763,8 +1763,8 @@ packages:
react: 18.2.0
dev: false
/@mui/system@5.14.4(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react@18.2.0):
resolution: {integrity: sha512-oPgfWS97QNfHcDBapdkZIs4G5i85BJt69Hp6wbXF6s7vi3Evcmhdk8AbCRW6n0sX4vTj8oe0mh0RIm1G2A1KDA==}
/@mui/system@5.15.15(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react@18.2.0):
resolution: {integrity: sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@emotion/react': ^11.5.0
@ -1870,7 +1870,7 @@ packages:
react-is: 18.2.0
dev: false
/@mui/x-date-pickers-pro@6.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.14.4)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0):
/@mui/x-date-pickers-pro@6.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.15.15)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-8lEVEOtCQssKWel4Ey1pRulGPXUQ73TnkHKzHWsjdv03FjiUs3eYB+Ej0Uk5yWPmsqlShWhOzOlOGDpzsYJsUg==}
engines: {node: '>=14.0.0'}
peerDependencies:
@ -1912,9 +1912,9 @@ packages:
'@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@types/react@18.2.66)(react@18.2.0)
'@mui/base': 5.0.0-beta.40(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@mui/material': 6.0.0-alpha.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@mui/system': 5.14.4(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react@18.2.0)
'@mui/system': 5.15.15(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react@18.2.0)
'@mui/utils': 5.15.14(@types/react@18.2.66)(react@18.2.0)
'@mui/x-date-pickers': 6.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.14.4)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0)
'@mui/x-date-pickers': 6.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.15.15)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0)
'@mui/x-license-pro': 6.10.2(@types/react@18.2.66)(react@18.2.0)
clsx: 2.1.0
dayjs: 1.11.9
@ -1926,7 +1926,7 @@ packages:
- '@types/react'
dev: false
/@mui/x-date-pickers@6.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.14.4)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0):
/@mui/x-date-pickers@6.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.15.15)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-HJq4uoFQSu5isa/mesWw2BKh8KBRYUQb+KaSlVlWfJNgP3YhPvWZ6yqCNYyxOAiPMxb0n3nBjS9ErO27OHjFMA==}
engines: {node: '>=14.0.0'}
peerDependencies:
@ -1968,7 +1968,7 @@ packages:
'@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@types/react@18.2.66)(react@18.2.0)
'@mui/base': 5.0.0-beta.40(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@mui/material': 6.0.0-alpha.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@mui/system': 5.14.4(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react@18.2.0)
'@mui/system': 5.15.15(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react@18.2.0)
'@mui/utils': 5.15.14(@types/react@18.2.66)(react@18.2.0)
'@types/react-transition-group': 4.4.10
clsx: 2.1.0

View File

@ -244,6 +244,10 @@ export enum YjsDatabaseKey {
visible = 'visible',
hide_ungrouped_column = 'hide_ungrouped_column',
collapse_hidden_groups = 'collapse_hidden_groups',
first_day_of_week = 'first_day_of_week',
show_week_numbers = 'show_week_numbers',
show_weekends = 'show_weekends',
layout_ty = 'layout_ty',
}
export interface YDoc extends Y.Doc {
@ -434,23 +438,30 @@ export type YDatabaseFilters = Y.Array<YDatabaseFilter>;
export type YDatabaseSorts = Y.Array<YDatabaseSort>;
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> {
export interface YDatabaseLayoutSettings extends Y.Map<unknown> {
// DatabaseViewLayout.Board
get(key: '2'): YDatabaseBoardLayoutSetting;
get(key: '1'): YDatabaseBoardLayoutSetting;
// DatabaseViewLayout.Calendar
get(key: '2'): YDatabaseCalendarLayoutSetting;
}
export interface YDatabaseBoardLayoutSetting extends Y.Map<unknown> {
get(key: YjsDatabaseKey.hide_ungrouped_column | YjsDatabaseKey.collapse_hidden_groups): boolean;
}
export interface YDatabaseCalendarLayoutSetting extends Y.Map<unknown> {
get(key: YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.field_id | YjsDatabaseKey.layout_ty): string;
get(key: YjsDatabaseKey.show_week_numbers | YjsDatabaseKey.show_weekends): boolean;
}
export interface YDatabaseGroup extends Y.Map<unknown> {
get(key: YjsDatabaseKey.id): GroupId;

View File

@ -1,2 +1,16 @@
import { YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import * as Y from 'yjs';
export const DEFAULT_ROW_HEIGHT = 37;
export const MIN_COLUMN_WIDTH = 100;
export const getCell = (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);
};
export const getCellData = (rowId: string, fieldId: string, rowMetas: Y.Map<YDoc>) => {
return getCell(rowId, fieldId, rowMetas)?.get(YjsDatabaseKey.data);
};

View File

@ -49,3 +49,17 @@ export interface Filter {
id: string;
content: string;
}
export enum CalendarLayout {
MonthLayout = 0,
WeekLayout = 1,
DayLayout = 2,
}
export interface CalendarLayoutSetting {
fieldId: string;
firstDayOfWeek: number;
showWeekNumbers: boolean;
showWeekends: boolean;
layout: CalendarLayout;
}

View File

@ -9,7 +9,7 @@ export interface ChecklistCellData {
export function parseChecklistData(data: string): ChecklistCellData | null {
try {
const { options, selected_option_ids } = JSON.parse(data);
const percentage = (selected_option_ids.length / options.length) * 100;
const percentage = selected_option_ids.length / options.length;
return {
percentage,

View File

@ -181,10 +181,10 @@ export function checklistFilterCheck(data: string, content: string, condition: n
const percentage = parseChecklistData(data)?.percentage ?? 0;
if (condition === ChecklistFilterCondition.IsComplete) {
return percentage === 100;
return percentage === 1;
}
return percentage !== 100;
return percentage !== 1;
}
export function selectOptionFilterCheck(data: string, content: string, condition: number) {

View File

@ -1,4 +1,5 @@
import { YDatabaseField, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { YDatabaseField, YDoc, YjsDatabaseKey } from '@/application/collab.type';
import { getCellData } from '@/application/database-yjs/const';
import { FieldType } from '@/application/database-yjs/database.type';
import { parseSelectOptionTypeOptions } from '@/application/database-yjs/fields';
import { Row } from '@/application/database-yjs/selector';
@ -12,13 +13,6 @@ export function groupByField(rows: Row[], rowMetas: Y.Map<YDoc>, field: YDatabas
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[]>();

View File

@ -1,5 +1,5 @@
import { FieldId, SortId, YDatabaseField, YjsDatabaseKey } from '@/application/collab.type';
import { MIN_COLUMN_WIDTH } from '@/application/database-yjs/const';
import { FieldId, SortId, YDatabaseField, YjsDatabaseKey, YjsFolderKey } from '@/application/collab.type';
import { getCell, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const';
import {
DatabaseContext,
useDatabase,
@ -13,10 +13,13 @@ 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 { useId } from '@/components/_shared/context-provider/IdProvider';
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
import { DateTimeCell } from '@/components/database/components/cell/cell.type';
import dayjs from 'dayjs';
import debounce from 'lodash-es/debounce';
import { useContext, useEffect, useMemo, useState } from 'react';
import { FieldType, FieldVisibility, Filter, SortCondition } from './database.type';
import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, SortCondition } from './database.type';
export interface Column {
fieldId: string;
@ -34,7 +37,8 @@ const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmp
export function useDatabaseViewsSelector() {
const database = useDatabase();
const { viewsId: visibleViewsId } = useViewsIdSelector();
const { objectId: currentViewId } = useId();
const { viewsId: visibleViewsId, views: folderViews } = useViewsIdSelector();
const views = database?.get(YjsDatabaseKey.views);
const [viewIds, setViewIds] = useState<string[]>([]);
const childViews = useMemo(() => {
@ -45,7 +49,16 @@ export function useDatabaseViewsSelector() {
if (!views) return;
const observerEvent = () => {
setViewIds(Array.from(views.keys()).filter((id) => visibleViewsId.includes(id)));
setViewIds(
Array.from(views.keys()).filter((id) => {
const view = folderViews?.get(id);
return (
visibleViewsId.includes(id) &&
(view?.get(YjsFolderKey.bid) === currentViewId || view?.get(YjsFolderKey.id) === currentViewId)
);
})
);
};
observerEvent();
@ -54,7 +67,7 @@ export function useDatabaseViewsSelector() {
return () => {
views.unobserve(observerEvent);
};
}, [visibleViewsId, views]);
}, [visibleViewsId, views, folderViews, currentViewId]);
return {
childViews,
@ -478,3 +491,97 @@ export function useCellSelector({ rowId, fieldId }: { rowId: string; fieldId: st
return cellValue;
}
export interface CalendarEvent {
start?: Date;
end?: Date;
id: string;
}
export function useCalendarEventsSelector() {
const setting = useCalendarLayoutSetting();
const filedId = setting.fieldId;
const { field } = useFieldSelector(filedId);
const rowOrders = useRowOrdersSelector();
const rows = useContext(DatabaseContext)?.rowDocMap;
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [emptyEvents, setEmptyEvents] = useState<CalendarEvent[]>([]);
useEffect(() => {
if (!field || !rowOrders || !rows) return;
const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType;
if (fieldType !== FieldType.DateTime) return;
const newEvents: CalendarEvent[] = [];
const emptyEvents: CalendarEvent[] = [];
rowOrders?.forEach((row) => {
const cell = getCell(row.id, filedId, rows);
if (!cell) {
emptyEvents.push({
id: `${row.id}:${filedId}`,
});
return;
}
const value = parseYDatabaseCellToCell(cell) as DateTimeCell;
if (!value || !value.data) {
emptyEvents.push({
id: `${row.id}:${filedId}`,
});
return;
}
const getDate = (timestamp: string) => {
const dayjsResult = timestamp.length === 10 ? dayjs.unix(Number(timestamp)) : dayjs(timestamp);
return dayjsResult.toDate();
};
newEvents.push({
id: `${row.id}:${filedId}`,
start: getDate(value.data),
end: value.endTimestamp && value.isRange ? getDate(value.endTimestamp) : getDate(value.data),
});
});
setEvents(newEvents);
setEmptyEvents(emptyEvents);
}, [field, rowOrders, rows, filedId]);
return { events, emptyEvents };
}
export function useCalendarLayoutSetting() {
const view = useDatabaseView();
const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('2');
const [setting, setSetting] = useState<CalendarLayoutSetting>({
fieldId: '',
firstDayOfWeek: 0,
showWeekNumbers: true,
showWeekends: true,
layout: 0,
});
useEffect(() => {
const observerHandler = () => {
setSetting({
fieldId: layoutSetting?.get(YjsDatabaseKey.field_id) as string,
firstDayOfWeek: Number(layoutSetting?.get(YjsDatabaseKey.first_day_of_week)),
showWeekNumbers: Boolean(layoutSetting?.get(YjsDatabaseKey.show_week_numbers)),
showWeekends: Boolean(layoutSetting?.get(YjsDatabaseKey.show_weekends)),
layout: Number(layoutSetting?.get(YjsDatabaseKey.layout_ty)),
});
};
observerHandler();
layoutSetting?.observe(observerHandler);
return () => {
layoutSetting?.unobserve(observerHandler);
};
}, [layoutSetting]);
return setting;
}

View File

@ -5,38 +5,39 @@ import { useEffect, useState } from 'react';
export function useViewsIdSelector() {
const folder = useFolderContext();
const [viewsId, setViewsId] = useState<string[]>([]);
const views = folder?.get(YjsFolderKey.views);
const trash = folder?.get(YjsFolderKey.section)?.get(YjsFolderKey.trash);
const meta = folder?.get(YjsFolderKey.meta);
useEffect(() => {
if (!folder) return;
if (!views) return;
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);
const trashUid = trash ? Array.from(trash.keys())[0] : null;
const userTrash = trashUid ? trash?.get(trashUid) : null;
const collectIds = () => {
const trashIds = userTrash?.toJSON()?.map((item) => item.id) || [];
return Array.from(views.keys()).filter(
(id) => !trashIds.includes(id) && id !== meta?.get(YjsFolderKey.current_workspace)
);
return Array.from(views.keys()).filter((id) => {
return !trashIds.includes(id) && id !== meta?.get(YjsFolderKey.current_workspace);
});
};
setViewsId(collectIds());
const observerEvent = () => setViewsId(collectIds());
folder.observe(observerEvent);
userTrash.observe(observerEvent);
views.observe(observerEvent);
userTrash?.observe(observerEvent);
return () => {
folder.unobserve(observerEvent);
userTrash.unobserve(observerEvent);
views.unobserve(observerEvent);
userTrash?.unobserve(observerEvent);
};
}, [folder]);
}, [views, trash, meta]);
return {
viewsId,
views,
};
}

View File

@ -0,0 +1,63 @@
import { Box, ClickAwayListener, Fade, Paper, Popper, PopperPlacementType } from '@mui/material';
import React, { ReactElement, useEffect } from 'react';
interface Props {
content: ReactElement;
children: ReactElement;
open: boolean;
onClose: () => void;
placement?: PopperPlacementType;
}
export const RichTooltip = ({ placement = 'top', open, onClose, content, children }: Props) => {
const [childNode, setChildNode] = React.useState<HTMLElement | null>(null);
const [, setTransitioning] = React.useState(false);
useEffect(() => {
if (open) {
setTransitioning(true);
}
}, [open]);
return (
<>
{React.cloneElement(children, { ...children.props, ref: setChildNode })}
<Popper
open={open}
anchorEl={childNode}
placement={placement}
transition
style={{ zIndex: 2000 }}
modifiers={[
{
name: 'flip',
enabled: true,
},
{
name: 'preventOverflow',
enabled: true,
},
]}
>
{({ TransitionProps }) => (
<Fade
{...TransitionProps}
timeout={350}
onTransitionEnd={() => {
setTransitioning(false);
}}
>
<Paper className={'bg-transparent shadow-none'}>
<ClickAwayListener onClickAway={onClose}>
<Paper className={'m-2 rounded-md border border-line-divider bg-bg-body'}>
<Box>{content}</Box>
</Paper>
</ClickAwayListener>
</Paper>
</Fade>
)}
</Popper>
</>
);
};
export default RichTooltip;

View File

@ -1 +1,2 @@
export * from './Popover';
export * from './RichTooltip';

View File

@ -3,10 +3,9 @@ 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 { DatabaseTabs } 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({
@ -25,22 +24,29 @@ function DatabaseViews({
);
}, [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);
}, []);
const activeView = useMemo(() => {
return childViews[value];
}, [childViews, value]);
const view = useMemo(() => {
if (!activeView) return null;
const layout = Number(activeView.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
switch (layout) {
case DatabaseViewLayout.Grid:
return <Grid />;
case DatabaseViewLayout.Board:
return <Board />;
case DatabaseViewLayout.Calendar:
return <Calendar />;
}
}, [activeView]);
return (
<>
<DatabaseConditionsContext.Provider
@ -52,33 +58,7 @@ function DatabaseViews({
<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>
<div className={'flex h-full w-full flex-1 flex-col overflow-hidden'}>{view}</div>
</>
);
}

View File

@ -0,0 +1,41 @@
import { useCalendarEventsSelector, useCalendarLayoutSetting } from '@/application/database-yjs';
import { useCallback, useEffect, useMemo } from 'react';
import { dayjsLocalizer } from 'react-big-calendar';
import dayjs from 'dayjs';
import en from 'dayjs/locale/en';
export function useCalendarSetup() {
const layoutSetting = useCalendarLayoutSetting();
const { events, emptyEvents } = useCalendarEventsSelector();
const dayPropGetter = useCallback((date: Date) => {
const day = date.getDay();
return {
className: `day-${day}`,
};
}, []);
useEffect(() => {
dayjs.locale({
...en,
weekStart: layoutSetting.firstDayOfWeek,
});
}, [layoutSetting]);
const localizer = useMemo(() => dayjsLocalizer(dayjs), []);
const formats = useMemo(() => {
return {
weekdayFormat: 'ddd',
};
}, []);
return {
localizer,
formats,
dayPropGetter,
events,
emptyEvents,
};
}

View File

@ -1,15 +1,32 @@
import { AFScroller } from '@/components/_shared/scroller';
import { useCalendarSetup } from '@/components/database/calendar/Calendar.hooks';
import { Toolbar, Event } from '@/components/database/components/calendar';
import React from 'react';
import { Calendar as BigCalendar, dayjsLocalizer } from 'react-big-calendar';
import dayjs from 'dayjs';
import { Calendar as BigCalendar } from 'react-big-calendar';
import './calendar.scss';
const localizer = dayjsLocalizer(dayjs);
export function Calendar() {
const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup();
return (
<div className={'max-ms:px-4 px-24 py-4'}>
<BigCalendar localizer={localizer} startAccessor='start' endAccessor='end' style={{ height: 500 }} />
</div>
<AFScroller className={'appflowy-calendar'}>
<div className={'h-full max-h-[960px] min-h-[560px] px-24 py-4 max-md:px-4'}>
<BigCalendar
components={{
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,
eventWrapper: Event,
}}
events={events}
views={['month']}
localizer={localizer}
formats={formats}
dayPropGetter={dayPropGetter}
showMultiDayTimes={true}
step={1}
showAllEvents={true}
/>
</div>
</AFScroller>
);
}

View File

@ -1,2 +1,63 @@
$today-highlight-bg: transparent;
@import 'react-big-calendar/lib/sass/styles';
@import 'react-big-calendar/lib/addons/dragAndDrop/styles'; // if using DnD
@import 'react-big-calendar/lib/addons/dragAndDrop/styles'; // if using DnD
.rbc-calendar {
font-size: 12px;
}
.rbc-button-link {
@apply rounded-full w-[20px] h-[20px] my-1.5;
}
.rbc-date-cell {
min-width: 100px;
}
.rbc-date-cell.rbc-now {
color: var(--content-on-fill);
.rbc-button-link {
background-color: var(--function-error);
}
}
.rbc-month-view {
border: none;
.rbc-month-row {
border: 1px solid var(--line-divider);
border-bottom: none;
&:last-child {
border-bottom: 1px solid var(--line-divider);
}
}
}
.rbc-month-header {
height: 40px;
.rbc-header {
border: none;
@apply flex items-end py-2 justify-center font-normal text-text-caption;
}
}
.rbc-month-row .rbc-row-bg {
.rbc-off-range-bg {
background-color: transparent;
color: var(--text-caption);
}
.rbc-day-bg.day-0, .rbc-day-bg.day-6 {
background-color: var(--fill-list-active);
}
}
.rbc-month-row {
display: inline-table !important;
flex: 0 0 0 !important;
min-height: 120px !important;
}

View File

@ -1,5 +1,5 @@
import { useFieldsSelector } from '@/application/database-yjs';
import CardField from '@/components/database/components/board/card/CardField';
import CardField from '@/components/database/components/field/CardField';
import React, { useEffect, useMemo } from 'react';
export interface CardProps {

View File

@ -26,18 +26,7 @@ export const Group = ({ groupId }: GroupProps) => {
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} />;
}}
>
<Droppable droppableId={`group-${groupId}`} direction='horizontal' type='column'>
{(provided) => {
return (
<div
@ -60,6 +49,7 @@ export const Group = ({ groupId }: GroupProps) => {
}}
</Draggable>
))}
{provided.placeholder}
</div>
);
}}

View File

@ -0,0 +1,34 @@
import { CalendarEvent, useFieldsSelector } from '@/application/database-yjs';
import { RichTooltip } from '@/components/_shared/popover';
import EventPaper from '@/components/database/components/calendar/event/EventPaper';
import CardField from '@/components/database/components/field/CardField';
import React, { useMemo } from 'react';
import { EventWrapperProps } from 'react-big-calendar';
export function Event({ event }: EventWrapperProps<CalendarEvent>) {
const { id } = event;
const [rowId, fieldId] = id.split(':');
const fields = useFieldsSelector();
const showFields = useMemo(() => fields.filter((field) => field.fieldId !== fieldId), [fields, fieldId]);
const [open, setOpen] = React.useState(false);
return (
<div className={'px-1 py-0.5'}>
<RichTooltip content={<EventPaper rowId={rowId} />} open={open} placement='right' onClose={() => setOpen(false)}>
<div
onClick={() => setOpen((prev) => !prev)}
className={
'flex min-h-[24px] cursor-pointer flex-col gap-2 rounded-md border border-line-border bg-bg-body p-2 shadow-sm hover:bg-fill-list-active hover:shadow'
}
>
{showFields.map((field) => {
return <CardField index={0} key={field.fieldId} rowId={rowId} fieldId={field.fieldId} />;
})}
</div>
</RichTooltip>
</div>
);
}
export default Event;

View File

@ -0,0 +1,28 @@
import { useFieldsSelector } from '@/application/database-yjs';
import { Property } from '@/components/database/components/property';
import { IconButton } from '@mui/material';
import React from 'react';
import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg';
function EventPaper({ rowId }: { rowId: string }) {
const fields = useFieldsSelector();
return (
<div className={'max-h-[260px] w-[360px] overflow-y-auto'}>
<div className={'flex h-fit w-full flex-col items-center justify-center py-2 px-3'}>
<div className={'flex w-full items-center justify-end'}>
<IconButton size={'small'}>
<ExpandMoreIcon />
</IconButton>
</div>
<div className={'flex w-full flex-1 flex-col gap-4 overflow-y-auto py-2'}>
{fields.map((field) => {
return <Property fieldId={field.fieldId} rowId={rowId} key={field.fieldId} />;
})}
</div>
</div>
</div>
);
}
export default EventPaper;

View File

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

View File

@ -0,0 +1,2 @@
export * from './toolbar';
export * from './event';

View File

@ -0,0 +1,46 @@
import { CalendarEvent } from '@/application/database-yjs';
import { RichTooltip } from '@/components/_shared/popover';
import NoDateRow from '@/components/database/components/calendar/toolbar/NoDateRow';
import Button from '@mui/material/Button';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function NoDate({ emptyEvents }: { emptyEvents: CalendarEvent[] }) {
const [open, setOpen] = React.useState(false);
const { t } = useTranslation();
const content = useMemo(() => {
return (
<div className={'flex w-[260px] flex-col gap-3 p-2 text-xs font-medium'}>
<div className={'text-text-caption'}>{t('calendar.settings.clickToOpen')}</div>
{emptyEvents.map((event) => {
const rowId = event.id.split(':')[0];
return <NoDateRow rowId={rowId} key={event.id} />;
})}
</div>
);
}, [emptyEvents, t]);
return (
<RichTooltip
content={content}
open={open}
placement={'bottom'}
onClose={() => {
setOpen(false);
}}
>
<Button
size={'small'}
variant={'outlined'}
className={'rounded-md border-line-divider '}
color={'inherit'}
onClick={() => setOpen(true)}
>
{`${t('calendar.settings.noDateTitle')} (${emptyEvents.length})`}
</Button>
</RichTooltip>
);
}
export default NoDate;

View File

@ -0,0 +1,44 @@
import { YjsDatabaseKey } from '@/application/collab.type';
import { useCellSelector, useDatabase } from '@/application/database-yjs';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Cell from 'src/components/database/components/cell/Cell';
function NoDateRow({ rowId }: { rowId: string }) {
const database = useDatabase();
const [primaryFieldId, setPrimaryFieldId] = React.useState<string | null>(null);
const cell = useCellSelector({
rowId,
fieldId: primaryFieldId || '',
});
const { t } = useTranslation();
useEffect(() => {
const fields = database?.get(YjsDatabaseKey.fields);
const primaryFieldId = Array.from(fields?.keys() || []).find((fieldId) => {
return fields?.get(fieldId)?.get(YjsDatabaseKey.is_primary);
});
setPrimaryFieldId(primaryFieldId || null);
}, [database]);
if (!primaryFieldId || !cell?.data) {
return <div className={'text-xs text-text-caption'}>{t('grid.row.titlePlaceholder')}</div>;
}
return (
<div className={'w-full hover:text-fill-default'}>
<Cell
style={{
cursor: 'pointer',
}}
readOnly
cell={cell}
rowId={rowId}
fieldId={primaryFieldId}
/>
</div>
);
}
export default NoDateRow;

View File

@ -0,0 +1,59 @@
import { CalendarEvent } from '@/application/database-yjs';
import NoDate from '@/components/database/components/calendar/toolbar/NoDate';
import { IconButton } from '@mui/material';
import Button from '@mui/material/Button';
import dayjs from 'dayjs';
import React, { useMemo } from 'react';
import { ToolbarProps } from 'react-big-calendar';
import { ReactComponent as LeftArrow } from '$icons/16x/arrow_left.svg';
import { ReactComponent as RightArrow } from '$icons/16x/arrow_right.svg';
import { ReactComponent as DownArrow } from '$icons/16x/arrow_down.svg';
import { useTranslation } from 'react-i18next';
export function Toolbar({
onNavigate,
date,
emptyEvents,
}: ToolbarProps & {
emptyEvents: CalendarEvent[];
}) {
const dateStr = useMemo(() => dayjs(date).format('MMM YYYY'), [date]);
const { t } = useTranslation();
return (
<div className={'flex items-center justify-between'}>
<div className={'text-sm font-medium'}>{dateStr}</div>
<div className={'flex items-center justify-end gap-2'}>
<IconButton size={'small'} onClick={() => onNavigate('PREV')}>
<LeftArrow />
</IconButton>
<Button
className={'h-6 font-normal'}
size={'small'}
variant={'text'}
color={'inherit'}
onClick={() => onNavigate('TODAY')}
>
{t('calendar.navigation.today')}
</Button>
<IconButton size={'small'} onClick={() => onNavigate('NEXT')}>
<RightArrow />
</IconButton>
<Button
size={'small'}
variant={'outlined'}
className={'rounded-md border-line-divider'}
color={'inherit'}
onClick={() => onNavigate('TODAY')}
endIcon={<DownArrow className={'h-3 w-3 text-text-caption'} />}
>
{t('calendar.navigation.views.month')}
</Button>
<NoDate emptyEvents={emptyEvents} />
</div>
</div>
);
}
export default Toolbar;

View File

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

View File

@ -81,4 +81,6 @@ export interface CellProps<T extends Cell> {
fieldId: FieldId;
style?: React.CSSProperties;
readOnly?: boolean;
placeholder?: string;
className?: string;
}

View File

@ -13,7 +13,7 @@ export function ChecklistCell({ cell, style }: CellProps<ChecklistCellType>) {
if (!data || !options || !selectedOptions) return null;
return (
<div style={style} className={'cursor-pointer'}>
<div style={style} className={'w-full cursor-pointer'}>
<LinearProgressWithLabel value={data?.percentage} count={options.length} selectedCount={selectedOptions.length} />
</div>
);

View File

@ -3,7 +3,7 @@ import { CellProps, DateTimeCell as DateTimeCellType } from '@/components/databa
import React, { useMemo } from 'react';
import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg';
export function DateTimeCell({ cell, fieldId, style }: CellProps<DateTimeCellType>) {
export function DateTimeCell({ cell, fieldId, style, placeholder }: CellProps<DateTimeCellType>) {
const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId);
const startDateTime = useMemo(() => {
@ -25,7 +25,12 @@ export function DateTimeCell({ cell, fieldId, style }: CellProps<DateTimeCellTyp
const hasReminder = !!cell?.reminderId;
if (!cell?.data) return null;
if (!cell?.data)
return placeholder ? (
<div style={style} className={'text-text-placeholder'}>
{placeholder}
</div>
) : null;
return (
<div style={style} className={'flex cursor-text items-center gap-1'}>
{hasReminder && <ReminderSvg className={'h-4 w-4'} />}

View File

@ -3,7 +3,7 @@ import { CellProps, NumberCell as NumberCellType } from '@/components/database/c
import React, { useMemo } from 'react';
import Decimal from 'decimal.js';
export function NumberCell({ cell, fieldId, style }: CellProps<NumberCellType>) {
export function NumberCell({ cell, fieldId, style, placeholder }: CellProps<NumberCellType>) {
const { field } = useFieldSelector(fieldId);
const format = useMemo(() => (field ? parseNumberTypeOptions(field).format : NumberFormat.Num), [field]);
@ -22,7 +22,12 @@ export function NumberCell({ cell, fieldId, style }: CellProps<NumberCellType>)
return numberFormater(new Decimal(cell.data).toNumber());
}, [cell, format]);
if (value === undefined) return null;
if (value === undefined)
return placeholder ? (
<div style={style} className={'text-text-placeholder'}>
{placeholder}
</div>
) : null;
return (
<div style={style} className={className}>
{value}

View File

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

View File

@ -4,7 +4,7 @@ import { SelectOptionColorMap } from '@/components/database/components/cell/cell
import { CellProps, SelectOptionCell as SelectOptionCellType } from '@/components/database/components/cell/cell.type';
import React, { useCallback, useMemo } from 'react';
export function SelectOptionCell({ cell, fieldId, style }: CellProps<SelectOptionCellType>) {
export function SelectOptionCell({ cell, fieldId, style, placeholder }: CellProps<SelectOptionCellType>) {
const selectOptionIds = useMemo(() => cell?.data.split(','), [cell]);
const { field } = useFieldSelector(fieldId);
const typeOption = useMemo(() => {
@ -18,12 +18,17 @@ export function SelectOptionCell({ cell, fieldId, style }: CellProps<SelectOptio
const option = typeOption?.options?.find((option) => option.id === id);
if (!option) return null;
return <Tag key={option.id} color={SelectOptionColorMap[option.color]} label={option.name} />;
return <Tag key={option.id} size={'small'} color={SelectOptionColorMap[option.color]} label={option.name} />;
}),
[typeOption]
);
if (!typeOption || !selectOptionIds?.length) return null;
if (!typeOption || !selectOptionIds?.length)
return placeholder ? (
<div style={style} className={'text-text-placeholder'}>
{placeholder}
</div>
) : null;
return (
<div style={style} className={'flex h-full w-full cursor-pointer items-center gap-1 overflow-x-hidden'}>

View File

@ -3,13 +3,13 @@ import { CellProps, UrlCell as UrlCellType } from '@/components/database/compone
import { openUrl, processUrl } from '@/utils/url';
import React, { useMemo } from 'react';
export function UrlCell({ cell, style }: CellProps<UrlCellType>) {
export function UrlCell({ cell, style, placeholder }: CellProps<UrlCellType>) {
const readOnly = useReadOnly();
const isUrl = useMemo(() => (cell ? processUrl(cell.data) : false), [cell]);
const className = useMemo(() => {
const classList = ['select-text'];
const classList = ['select-text', 'w-fit'];
if (isUrl) {
classList.push('text-content-blue-400', 'underline', 'cursor-pointer');
@ -20,7 +20,12 @@ export function UrlCell({ cell, style }: CellProps<UrlCellType>) {
return classList.join(' ');
}, [isUrl]);
if (!cell?.data) return null;
if (!cell?.data)
return placeholder ? (
<div style={style} className={'text-text-placeholder'}>
{placeholder}
</div>
) : null;
return (
<div

View File

@ -1,4 +1,5 @@
import { useFiltersSelector, useSortsSelector } from '@/application/database-yjs';
import { DatabaseViewLayout, YjsDatabaseKey } from '@/application/collab.type';
import { useDatabaseView, useFiltersSelector, useSortsSelector } from '@/application/database-yjs';
import { useConditionsContext } from '@/components/database/components/conditions/context';
import { TextButton } from '@/components/database/components/tabs/TextButton';
import React from 'react';
@ -6,10 +7,16 @@ import { useTranslation } from 'react-i18next';
export function DatabaseActions() {
const { t } = useTranslation();
const view = useDatabaseView();
const layout = Number(view?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
const sorts = useSortsSelector();
const filter = useFiltersSelector();
const conditionsContext = useConditionsContext();
if (layout === DatabaseViewLayout.Calendar) {
return null;
}
return (
<div className='flex w-[120px] items-center justify-end gap-1.5'>
<TextButton

View File

@ -0,0 +1,78 @@
import { YjsDatabaseKey } from '@/application/collab.type';
import { FieldType, useCellSelector, useFieldSelector } from '@/application/database-yjs';
import { Cell as CellType, CellProps, TextCell } from '@/components/database/components/cell/cell.type';
import { CheckboxCell } from '@/components/database/components/cell/checkbox';
import { ChecklistCell } from '@/components/database/components/cell/checklist';
import { RowCreateModifiedTime } from '@/components/database/components/cell/created-modified';
import { DateTimeCell } from '@/components/database/components/cell/date';
import { NumberCell } from '@/components/database/components/cell/number';
import { RelationCell } from '@/components/database/components/cell/relation';
import { SelectOptionCell } from '@/components/database/components/cell/select-option';
import { UrlCell } from '@/components/database/components/cell/url';
import PropertyWrapper from '@/components/database/components/property/PropertyWrapper';
import { TextProperty } from '@/components/database/components/property/text';
import React, { FC, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export function Property({ fieldId, rowId }: { fieldId: string; rowId: string }) {
const cell = useCellSelector({
fieldId,
rowId,
});
const { field } = useFieldSelector(fieldId);
const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType;
const { t } = useTranslation();
const Component = useMemo(() => {
switch (fieldType) {
case FieldType.URL:
return UrlCell;
case FieldType.Number:
return NumberCell;
case FieldType.Checkbox:
return CheckboxCell;
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return SelectOptionCell;
case FieldType.DateTime:
return DateTimeCell;
case FieldType.Checklist:
return ChecklistCell;
case FieldType.Relation:
return RelationCell;
default:
return TextProperty;
}
}, [fieldType]) as FC<CellProps<CellType>>;
const style = useMemo(
() => ({
fontSize: '12px',
}),
[]
);
if (fieldType === FieldType.RichText) {
return <TextProperty cell={cell as TextCell} fieldId={fieldId} rowId={rowId} />;
}
if (fieldType === FieldType.CreatedTime || fieldType === FieldType.LastEditedTime) {
const attrName = fieldType === FieldType.CreatedTime ? YjsDatabaseKey.created_at : YjsDatabaseKey.last_modified;
return (
<PropertyWrapper fieldId={fieldId}>
<RowCreateModifiedTime style={style} rowId={rowId} fieldId={fieldId} attrName={attrName} />
</PropertyWrapper>
);
}
return (
<PropertyWrapper fieldId={fieldId}>
<Component cell={cell} style={style} placeholder={t('grid.row.textPlaceholder')} fieldId={fieldId} rowId={rowId} />
</PropertyWrapper>
);
}
export default Property;

View File

@ -0,0 +1,15 @@
import { FieldDisplay } from '@/components/database/components/field';
import React from 'react';
function PropertyWrapper({ fieldId, children }: { fieldId: string; children: React.ReactNode }) {
return (
<div className={'flex w-full items-center gap-2'}>
<div className={'w-[100px] text-text-caption'}>
<FieldDisplay fieldId={fieldId} />
</div>
<div className={'flex flex-1 flex-wrap pr-1'}>{children}</div>
</div>
);
}
export default PropertyWrapper;

View File

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

View File

@ -0,0 +1,29 @@
import { CellProps, TextCell } from '@/components/database/components/cell/cell.type';
import { TextField } from '@mui/material';
import React from 'react';
export function TextProperty({ cell }: CellProps<TextCell>) {
return (
<TextField
value={cell?.data}
inputProps={{
readOnly: true,
}}
fullWidth
size={'small'}
sx={{
'& .MuiInputBase-root': {
fontSize: '0.875rem',
borderRadius: '8px',
},
'& .MuiInputBase-input': {
padding: '4px 8px',
fontWeight: 500,
},
}}
/>
);
}
export default TextProperty;

View File

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

View File

@ -69,7 +69,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
value={isSelected ? selectedViewId : objectId}
onChange={handleChange}
>
{viewIds.map((viewId, index) => {
{viewIds.map((viewId) => {
const view = getFolderView(viewId);
if (!view) return null;
@ -80,9 +80,6 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
return (
<ViewTab
key={viewId}
style={{
borderRight: index === viewIds.length - 1 ? 'none' : '1px solid var(--line-divider)',
}}
icon={<Icon className={'h-4 w-4'} />}
iconPosition='start'
color='inherit'

View File

@ -45,7 +45,7 @@
opacity: 60%;
}
.workspaces, .database-conditions, .grid-scroll-table, .grid-board {
.workspaces, .database-conditions, .grid-scroll-table, .grid-board, .MuiPaper-root, .appflowy-database {
::-webkit-scrollbar {
width: 0;
height: 0;
@ -79,3 +79,22 @@
@apply items-center;
}
}
.tooltip-arrow {
overflow: hidden;
position: absolute;
width: 1em;
height: 0.71em;
color: var(--bg-body);
&:before {
content: '""';
margin: auto;
display: block;
width: 100%;
height: 100%;
boxShadow: var(--shadow);
backgroundColor: var(--bg-body);
transform: rotate(45deg);
}
}

View File

@ -1266,7 +1266,13 @@
"today": "Today",
"jumpToday": "Jump to Today",
"previousMonth": "Previous Month",
"nextMonth": "Next Month"
"nextMonth": "Next Month",
"views": {
"day": "Day",
"week": "Week",
"month": "Month",
"year": "Year"
}
},
"mobileEventScreen": {
"emptyTitle": "No events yet",
@ -1286,7 +1292,8 @@
},
"unscheduledEventsTitle": "Unscheduled events",
"clickToAdd": "Click to add to the calendar",
"name": "Calendar settings"
"name": "Calendar settings",
"clickToOpen": "Click to open the record"
},
"referencedCalendarPrefix": "View of",
"quickJumpYear": "Jump to",