mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-30 11:27:55 +08:00
chore: get date, time formats and start week on from user metadata (#43)
* chore: get date, time formats and start week on from user metadata * test: fix tests * chore: no need to provide currentUser in context Signed-off-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com> * fix: font sizing Signed-off-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com> * chore: fetch user profile on receive user profile change * fix: only undefined * chore: memoize loadMentionableUsers * chore: adjust dropdown menu style --------- Signed-off-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
This commit is contained in:
@@ -2817,7 +2817,9 @@
|
||||
},
|
||||
"visitOurWebsite": "Visit our official website",
|
||||
"addMessagesToPage": "Add messages to page",
|
||||
"addMessagesToPageDisabled": "No messages available"
|
||||
"addMessagesToPageDisabled": "No messages available",
|
||||
"accountSettings": "Account settings",
|
||||
"startWeekOn": "Start week on"
|
||||
},
|
||||
"globalComment": {
|
||||
"comments": "Comments",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@jest/globals';
|
||||
import { toZonedTime } from 'date-fns-tz';
|
||||
import { DateFormat } from '../types';
|
||||
import {
|
||||
DateFormatType,
|
||||
MetadataDefaults,
|
||||
MetadataKey,
|
||||
MetadataUtils,
|
||||
@@ -18,28 +18,11 @@ describe('User Metadata', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('MetadataKey enum', () => {
|
||||
it('should have correct values', () => {
|
||||
expect(MetadataKey.Timezone).toBe('timezone');
|
||||
expect(MetadataKey.Language).toBe('language');
|
||||
expect(MetadataKey.DateFormat).toBe('date_format');
|
||||
expect(MetadataKey.IconUrl).toBe('icon_url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DateFormatType enum', () => {
|
||||
it('should have correct date format patterns', () => {
|
||||
expect(DateFormatType.US).toBe('MM/DD/YYYY');
|
||||
expect(DateFormatType.EU).toBe('DD/MM/YYYY');
|
||||
expect(DateFormatType.ISO).toBe('YYYY-MM-DD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MetadataDefaults', () => {
|
||||
it('should have default values for all metadata keys', () => {
|
||||
expect(MetadataDefaults[MetadataKey.Timezone]).toBe('UTC');
|
||||
expect(MetadataDefaults[MetadataKey.Language]).toBe('en');
|
||||
expect(MetadataDefaults[MetadataKey.DateFormat]).toBe(DateFormatType.US);
|
||||
expect(MetadataDefaults[MetadataKey.DateFormat]).toBe(DateFormat.US);
|
||||
expect(MetadataDefaults[MetadataKey.IconUrl]).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -62,8 +45,8 @@ describe('User Metadata', () => {
|
||||
});
|
||||
|
||||
it('should set date format', () => {
|
||||
const result = builder.setDateFormat(DateFormatType.EU).build();
|
||||
expect(result[MetadataKey.DateFormat]).toBe('DD/MM/YYYY');
|
||||
const result = builder.setDateFormat(DateFormat.DayMonthYear).build();
|
||||
expect(result[MetadataKey.DateFormat]).toBe(4);
|
||||
});
|
||||
|
||||
it('should set icon URL', () => {
|
||||
@@ -71,24 +54,17 @@ describe('User Metadata', () => {
|
||||
expect(result[MetadataKey.IconUrl]).toBe('https://example.com/icon.png');
|
||||
});
|
||||
|
||||
it('should set custom metadata', () => {
|
||||
const result = builder.setCustom('theme', 'dark').build();
|
||||
expect(result.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('should chain multiple setters', () => {
|
||||
const result = builder
|
||||
.setTimezone('Europe/London')
|
||||
.setLanguage('en-GB')
|
||||
.setDateFormat(DateFormatType.EU)
|
||||
.setCustom('theme', 'light')
|
||||
.setDateFormat(DateFormat.DayMonthYear)
|
||||
.build();
|
||||
|
||||
expect(result).toEqual({
|
||||
[MetadataKey.Timezone]: 'Europe/London',
|
||||
[MetadataKey.Language]: 'en-GB',
|
||||
[MetadataKey.DateFormat]: DateFormatType.EU,
|
||||
theme: 'light',
|
||||
[MetadataKey.DateFormat]: DateFormat.DayMonthYear,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,21 +81,21 @@ describe('User Metadata', () => {
|
||||
describe('MetadataUtils', () => {
|
||||
describe('detectDateFormat', () => {
|
||||
it('should detect US format for US locale', () => {
|
||||
expect(MetadataUtils.detectDateFormat('en-US')).toBe(DateFormatType.US);
|
||||
expect(MetadataUtils.detectDateFormat('en-CA')).toBe(DateFormatType.US);
|
||||
expect(MetadataUtils.detectDateFormat('en-PH')).toBe(DateFormatType.US);
|
||||
expect(MetadataUtils.detectDateFormat('en-US')).toBe(DateFormat.US);
|
||||
expect(MetadataUtils.detectDateFormat('en-CA')).toBe(DateFormat.US);
|
||||
expect(MetadataUtils.detectDateFormat('en-PH')).toBe(DateFormat.US);
|
||||
});
|
||||
|
||||
it('should detect EU format for European locales', () => {
|
||||
expect(MetadataUtils.detectDateFormat('en-GB')).toBe(DateFormatType.EU);
|
||||
expect(MetadataUtils.detectDateFormat('fr-FR')).toBe(DateFormatType.EU);
|
||||
expect(MetadataUtils.detectDateFormat('de-DE')).toBe(DateFormatType.EU);
|
||||
expect(MetadataUtils.detectDateFormat('en-GB')).toBe(DateFormat.DayMonthYear);
|
||||
expect(MetadataUtils.detectDateFormat('fr-FR')).toBe(DateFormat.DayMonthYear);
|
||||
expect(MetadataUtils.detectDateFormat('de-DE')).toBe(DateFormat.DayMonthYear);
|
||||
});
|
||||
|
||||
it('should detect ISO format for specific regions', () => {
|
||||
expect(MetadataUtils.detectDateFormat('sv-SE')).toBe(DateFormatType.ISO);
|
||||
expect(MetadataUtils.detectDateFormat('fi-FI')).toBe(DateFormatType.ISO);
|
||||
expect(MetadataUtils.detectDateFormat('ko-KR')).toBe(DateFormatType.ISO);
|
||||
expect(MetadataUtils.detectDateFormat('sv-SE')).toBe(DateFormat.ISO);
|
||||
expect(MetadataUtils.detectDateFormat('fi-FI')).toBe(DateFormat.ISO);
|
||||
expect(MetadataUtils.detectDateFormat('ko-KR')).toBe(DateFormat.ISO);
|
||||
});
|
||||
|
||||
it('should use browser locale as default', () => {
|
||||
@@ -129,7 +105,7 @@ describe('User Metadata', () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
expect(MetadataUtils.detectDateFormat()).toBe(DateFormatType.US);
|
||||
expect(MetadataUtils.detectDateFormat()).toBe(DateFormat.US);
|
||||
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: originalLanguage,
|
||||
@@ -138,8 +114,8 @@ describe('User Metadata', () => {
|
||||
});
|
||||
|
||||
it('should handle locale without region', () => {
|
||||
expect(MetadataUtils.detectDateFormat('en')).toBe(DateFormatType.EU);
|
||||
expect(MetadataUtils.detectDateFormat('fr')).toBe(DateFormatType.EU);
|
||||
expect(MetadataUtils.detectDateFormat('en')).toBe(DateFormat.DayMonthYear);
|
||||
expect(MetadataUtils.detectDateFormat('fr')).toBe(DateFormat.DayMonthYear);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,14 +198,12 @@ describe('User Metadata', () => {
|
||||
it('should merge multiple metadata objects', () => {
|
||||
const obj1 = { [MetadataKey.Timezone]: 'UTC' };
|
||||
const obj2 = { [MetadataKey.Language]: 'en' };
|
||||
const obj3 = { custom: 'value' };
|
||||
|
||||
const result = MetadataUtils.merge(obj1, obj2, obj3);
|
||||
const result = MetadataUtils.merge(obj1, obj2);
|
||||
|
||||
expect(result).toEqual({
|
||||
[MetadataKey.Timezone]: 'UTC',
|
||||
[MetadataKey.Language]: 'en',
|
||||
custom: 'value',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as Y from 'yjs';
|
||||
|
||||
import { FieldType } from '@/application/database-yjs/database.type';
|
||||
import { getDateCellStr, parseChecklistData, parseSelectOptionTypeOptions } from '@/application/database-yjs/fields';
|
||||
import { YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types';
|
||||
import { User, YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types';
|
||||
|
||||
import { Cell, DateTimeCell, FileMediaCell, FileMediaCellData } from './cell.type';
|
||||
|
||||
@@ -94,7 +94,7 @@ export function parseYDatabaseRelationCellToCell(cell: YDatabaseCell): Cell {
|
||||
};
|
||||
}
|
||||
|
||||
export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField): string {
|
||||
export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField, currentUser?: User): string {
|
||||
const type = parseInt(field.get(YjsDatabaseKey.type));
|
||||
|
||||
switch (type) {
|
||||
@@ -143,7 +143,7 @@ export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField): str
|
||||
case FieldType.DateTime: {
|
||||
const dateCell = parseYDatabaseDateTimeCellToCell(cell);
|
||||
|
||||
return getDateCellStr({ cell: dateCell, field });
|
||||
return getDateCellStr({ cell: dateCell, field, currentUser });
|
||||
}
|
||||
|
||||
case FieldType.CreatedTime:
|
||||
|
||||
@@ -2,8 +2,7 @@ import React from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { FieldType } from '@/application/database-yjs/database.type';
|
||||
import { DateFormat, TimeFormat } from '@/application/database-yjs/index';
|
||||
import { FieldId, RowId } from '@/application/types';
|
||||
import { DateFormat, FieldId, RowId, TimeFormat } from '@/application/types';
|
||||
|
||||
export interface Cell {
|
||||
createdAt: number;
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
SortCondition,
|
||||
} from '@/application/database-yjs/database.type';
|
||||
import {
|
||||
DateFormat,
|
||||
getDateCellStr,
|
||||
getFieldName,
|
||||
isDate,
|
||||
@@ -38,7 +37,6 @@ import {
|
||||
safeParseTimestamp,
|
||||
SelectOption,
|
||||
SelectTypeOption,
|
||||
TimeFormat,
|
||||
} from '@/application/database-yjs/fields';
|
||||
import { createCheckboxCell, getChecked } from '@/application/database-yjs/fields/checkbox/utils';
|
||||
import { EnhancedBigStats } from '@/application/database-yjs/fields/number/EnhancedBigStats';
|
||||
@@ -51,8 +49,10 @@ import { useBoardLayoutSettings, useFieldSelector, useFieldType } from '@/applic
|
||||
import { executeOperations } from '@/application/slate-yjs/utils/yjs';
|
||||
import {
|
||||
DatabaseViewLayout,
|
||||
DateFormat,
|
||||
FieldId,
|
||||
RowId,
|
||||
TimeFormat,
|
||||
UpdatePagePayload,
|
||||
ViewLayout,
|
||||
YDatabase,
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
YMapFieldTypeOption,
|
||||
YSharedRoot,
|
||||
} from '@/application/types';
|
||||
import { useCurrentUser } from '@/components/main/app.hooks';
|
||||
|
||||
export function useResizeColumnWidthDispatch() {
|
||||
const database = useDatabase();
|
||||
@@ -2232,6 +2233,7 @@ export function useSwitchPropertyType() {
|
||||
const database = useDatabase();
|
||||
const sharedRoot = useSharedRoot();
|
||||
const rowDocMap = useRowDocMap();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
return useCallback(
|
||||
(fieldId: string, fieldType: FieldType) => {
|
||||
@@ -2284,8 +2286,6 @@ export function useSwitchPropertyType() {
|
||||
// Set default values for the type option
|
||||
if ([FieldType.CreatedTime, FieldType.LastEditedTime, FieldType.DateTime].includes(fieldType)) {
|
||||
// to DateTime
|
||||
newTypeOption.set(YjsDatabaseKey.time_format, TimeFormat.TwentyFourHour);
|
||||
newTypeOption.set(YjsDatabaseKey.date_format, DateFormat.Friendly);
|
||||
if (oldFieldType !== FieldType.DateTime) {
|
||||
newTypeOption.set(YjsDatabaseKey.include_time, true);
|
||||
}
|
||||
@@ -2455,6 +2455,7 @@ export function useSwitchPropertyType() {
|
||||
newData = getDateCellStr({
|
||||
cell: dateCell,
|
||||
field,
|
||||
currentUser,
|
||||
});
|
||||
|
||||
break;
|
||||
@@ -2545,7 +2546,7 @@ export function useSwitchPropertyType() {
|
||||
'switchPropertyType'
|
||||
);
|
||||
},
|
||||
[database, sharedRoot, rowDocMap]
|
||||
[database, sharedRoot, rowDocMap, currentUser]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import { Filter } from '@/application/database-yjs';
|
||||
|
||||
export enum TimeFormat {
|
||||
TwelveHour = 0,
|
||||
TwentyFourHour = 1,
|
||||
}
|
||||
|
||||
export enum DateFormat {
|
||||
Local = 0,
|
||||
US = 1,
|
||||
ISO = 2,
|
||||
Friendly = 3,
|
||||
DayMonthYear = 4,
|
||||
}
|
||||
|
||||
export enum DateFilterCondition {
|
||||
DateStartsOn = 0,
|
||||
DateStartsBefore = 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getTimeFormat, getDateFormat } from './utils';
|
||||
import { DateFormat, TimeFormat } from '@/application/types';
|
||||
import { getDateFormat, getTimeFormat } from '@/utils/time';
|
||||
import { expect } from '@jest/globals';
|
||||
import { DateFormat, TimeFormat } from '@/application/database-yjs';
|
||||
|
||||
describe('DateFormat', () => {
|
||||
it('should return time format', () => {
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { DateFormat, getTypeOptions, TimeFormat } from '@/application/database-yjs';
|
||||
import { getTypeOptions } from '@/application/database-yjs';
|
||||
import { DateTimeCell } from '@/application/database-yjs/cell.type';
|
||||
import { YDatabaseField, YjsDatabaseKey } from '@/application/types';
|
||||
import { renderDate } from '@/utils/time';
|
||||
|
||||
export function getTimeFormat(timeFormat?: TimeFormat) {
|
||||
switch (timeFormat) {
|
||||
case TimeFormat.TwelveHour:
|
||||
return 'h:mm A';
|
||||
case TimeFormat.TwentyFourHour:
|
||||
return 'HH:mm';
|
||||
default:
|
||||
return 'HH:mm';
|
||||
}
|
||||
}
|
||||
|
||||
export function getDateFormat(dateFormat?: DateFormat) {
|
||||
switch (dateFormat) {
|
||||
case DateFormat.Friendly:
|
||||
return 'MMM DD, YYYY';
|
||||
case DateFormat.ISO:
|
||||
return 'YYYY-MM-DD';
|
||||
case DateFormat.US:
|
||||
return 'YYYY/MM/DD';
|
||||
case DateFormat.Local:
|
||||
return 'MM/DD/YYYY';
|
||||
case DateFormat.DayMonthYear:
|
||||
return 'DD/MM/YYYY';
|
||||
default:
|
||||
return 'YYYY-MM-DD';
|
||||
}
|
||||
}
|
||||
import { DateFormat, TimeFormat, User, YDatabaseField, YjsDatabaseKey, YMapFieldTypeOption } from '@/application/types';
|
||||
import { MetadataKey } from '@/application/user-metadata';
|
||||
import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time';
|
||||
|
||||
function getDateTimeStr({
|
||||
timeStamp,
|
||||
@@ -59,39 +32,45 @@ function getDateTimeStr({
|
||||
|
||||
export const RIGHTWARDS_ARROW = '→';
|
||||
|
||||
export function getRowTimeString(field: YDatabaseField, timeStamp: string) {
|
||||
export function getRowTimeString(field: YDatabaseField, timeStamp: string, currentUser?: User) {
|
||||
const typeOption = getTypeOptions(field);
|
||||
const typeOptionValue = getFieldDateTimeFormats(typeOption, currentUser);
|
||||
|
||||
const timeFormat = parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat;
|
||||
const dateFormat = parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat;
|
||||
const includeTime = typeOption.get(YjsDatabaseKey.include_time);
|
||||
|
||||
|
||||
return getDateTimeStr({
|
||||
timeStamp,
|
||||
includeTime,
|
||||
typeOptionValue: {
|
||||
timeFormat,
|
||||
dateFormat,
|
||||
},
|
||||
typeOptionValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDateCellStr({ cell, field }: { cell: DateTimeCell; field: YDatabaseField }) {
|
||||
export function getFieldDateTimeFormats(typeOption: YMapFieldTypeOption, currentUser?: User) {
|
||||
const typeOptionTimeFormat = typeOption.get(YjsDatabaseKey.time_format);
|
||||
const typeOptionDateFormat = typeOption.get(YjsDatabaseKey.date_format);
|
||||
|
||||
const dateFormat = typeOptionDateFormat
|
||||
? parseInt(typeOptionDateFormat) as DateFormat
|
||||
: currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat ?? DateFormat.Local;
|
||||
const timeFormat = typeOptionTimeFormat
|
||||
? parseInt(typeOptionTimeFormat) as TimeFormat
|
||||
: currentUser?.metadata?.[MetadataKey.TimeFormat] as TimeFormat ?? TimeFormat.TwelveHour;
|
||||
|
||||
return {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
}
|
||||
}
|
||||
|
||||
export function getDateCellStr({ cell, field, currentUser }: { cell: DateTimeCell; field: YDatabaseField, currentUser?: User }) {
|
||||
const typeOptionMap = field.get(YjsDatabaseKey.type_option);
|
||||
const typeOption = typeOptionMap.get(String(cell.fieldType));
|
||||
const timeFormat = parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat;
|
||||
|
||||
const dateFormat = parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat;
|
||||
const typeOptionValue = getFieldDateTimeFormats(typeOption, currentUser);
|
||||
|
||||
const startData = cell.data || '';
|
||||
const includeTime = cell.includeTime;
|
||||
|
||||
const typeOptionValue = {
|
||||
timeFormat,
|
||||
dateFormat,
|
||||
};
|
||||
|
||||
const startDateTime = getDateTimeStr({
|
||||
timeStamp: startData,
|
||||
includeTime,
|
||||
|
||||
@@ -13,15 +13,12 @@ import {
|
||||
useRowDocMap,
|
||||
} from '@/application/database-yjs/context';
|
||||
import {
|
||||
DateFormat,
|
||||
getDateCellStr,
|
||||
getDateFormat,
|
||||
getTimeFormat,
|
||||
getFieldDateTimeFormats,
|
||||
getTypeOptions,
|
||||
parseRelationTypeOption,
|
||||
parseSelectOptionTypeOptions,
|
||||
SelectOption,
|
||||
TimeFormat,
|
||||
} from '@/application/database-yjs/fields';
|
||||
import { filterBy, parseFilter } from '@/application/database-yjs/filter';
|
||||
import { groupByField } from '@/application/database-yjs/group';
|
||||
@@ -38,7 +35,8 @@ import {
|
||||
YjsDatabaseKey,
|
||||
YjsEditorKey,
|
||||
} from '@/application/types';
|
||||
import { renderDate } from '@/utils/time';
|
||||
import { useCurrentUser } from '@/components/main/app.hooks';
|
||||
import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time';
|
||||
|
||||
import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, RowMeta, SortCondition } from './database.type';
|
||||
|
||||
@@ -1145,28 +1143,32 @@ export const usePropertiesSelector = (isFilterHidden?: boolean) => {
|
||||
};
|
||||
|
||||
export const useDateTimeCellString = (cell: DateTimeCell | undefined, fieldId: string) => {
|
||||
const currentUser = useCurrentUser();
|
||||
const { field, clock } = useFieldSelector(fieldId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!cell) return null;
|
||||
return getDateCellStr({ cell, field });
|
||||
return getDateCellStr({ cell, field, currentUser });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cell, field, clock]);
|
||||
}, [cell, field, clock, currentUser]);
|
||||
};
|
||||
|
||||
export const useRowTimeString = (rowId: string, fieldId: string, attrName: string) => {
|
||||
const currentUser = useCurrentUser();
|
||||
const { field, clock } = useFieldSelector(fieldId);
|
||||
|
||||
const typeOptionValue = useMemo(() => {
|
||||
const typeOption = getTypeOptions(field);
|
||||
|
||||
const { dateFormat, timeFormat } = getFieldDateTimeFormats(typeOption, currentUser);
|
||||
|
||||
return {
|
||||
timeFormat: parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat,
|
||||
dateFormat: parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
includeTime: typeOption.get(YjsDatabaseKey.include_time),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [field, clock]);
|
||||
}, [field, clock, currentUser?.metadata]);
|
||||
|
||||
const getDateTimeStr = useCallback(
|
||||
(timeStamp: string, includeTime?: boolean) => {
|
||||
|
||||
@@ -344,8 +344,8 @@ export enum YjsDatabaseKey {
|
||||
include_time = 'include_time',
|
||||
is_range = 'is_range',
|
||||
reminder_id = 'reminder_id',
|
||||
time_format = 'time_format',
|
||||
date_format = 'date_format',
|
||||
time_format = 'time_format_v2',
|
||||
date_format = 'date_format_v2',
|
||||
calculations = 'calculations',
|
||||
field_id = 'field_id',
|
||||
calculation_value = 'calculation_value',
|
||||
@@ -686,11 +686,11 @@ export interface YMapFieldTypeOption extends Y.Map<unknown> {
|
||||
|
||||
// CreatedTime, LastEditedTime, DateTime
|
||||
// eslint-disable-next-line @typescript-eslint/unified-signatures
|
||||
get(key: YjsDatabaseKey.time_format): string;
|
||||
get(key: YjsDatabaseKey.time_format): string | undefined;
|
||||
|
||||
// CreatedTime, LastEditedTime, DateTime
|
||||
// eslint-disable-next-line @typescript-eslint/unified-signatures
|
||||
get(key: YjsDatabaseKey.date_format): string;
|
||||
get(key: YjsDatabaseKey.date_format): string | undefined;
|
||||
|
||||
// Relation
|
||||
get(key: YjsDatabaseKey.database_id): DatabaseId;
|
||||
@@ -699,7 +699,7 @@ export interface YMapFieldTypeOption extends Y.Map<unknown> {
|
||||
// eslint-disable-next-line @typescript-eslint/unified-signatures
|
||||
get(key: YjsDatabaseKey.format): string;
|
||||
|
||||
// LastModified and CreatedTime
|
||||
// LastEditedTime and CreatedTime
|
||||
get(key: YjsDatabaseKey.include_time): boolean;
|
||||
|
||||
// AI Translate
|
||||
@@ -1086,6 +1086,7 @@ export interface ViewComponentProps {
|
||||
}>;
|
||||
updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise<void>;
|
||||
updatePageName?: (viewId: string, name: string) => Promise<void>;
|
||||
currentUser?: User;
|
||||
}
|
||||
|
||||
export interface CreatePagePayload {
|
||||
@@ -1206,3 +1207,16 @@ export interface MentionablePerson {
|
||||
invited: boolean;
|
||||
last_mentioned_at: string | null;
|
||||
}
|
||||
|
||||
export enum DateFormat {
|
||||
Local = 0,
|
||||
US = 1,
|
||||
ISO = 2,
|
||||
Friendly = 3,
|
||||
DayMonthYear = 4,
|
||||
}
|
||||
|
||||
export enum TimeFormat {
|
||||
TwelveHour = 0,
|
||||
TwentyFourHour = 1,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { toZonedTime } from 'date-fns-tz';
|
||||
|
||||
import { DateFormat, TimeFormat } from './types';
|
||||
import { UserTimezone } from './user-timezone.types';
|
||||
|
||||
/**
|
||||
@@ -9,6 +11,8 @@ export enum MetadataKey {
|
||||
Timezone = 'timezone',
|
||||
Language = 'language',
|
||||
DateFormat = 'date_format',
|
||||
TimeFormat = 'time_format',
|
||||
StartWeekOn = 'start_week_on',
|
||||
IconUrl = 'icon_url',
|
||||
}
|
||||
|
||||
@@ -18,18 +22,10 @@ export enum MetadataKey {
|
||||
export interface MetadataValues {
|
||||
[MetadataKey.Timezone]: string | UserTimezone;
|
||||
[MetadataKey.Language]: string;
|
||||
[MetadataKey.DateFormat]: DateFormatType;
|
||||
[MetadataKey.DateFormat]: DateFormat;
|
||||
[MetadataKey.TimeFormat]: TimeFormat;
|
||||
[MetadataKey.StartWeekOn]: number;
|
||||
[MetadataKey.IconUrl]: string;
|
||||
[key: string]: string | DateFormatType | UserTimezone | Record<string, unknown> | number | boolean | null | undefined; // Allow custom keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported date format types
|
||||
*/
|
||||
export enum DateFormatType {
|
||||
US = 'MM/DD/YYYY',
|
||||
EU = 'DD/MM/YYYY',
|
||||
ISO = 'YYYY-MM-DD',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +34,9 @@ export enum DateFormatType {
|
||||
export const MetadataDefaults: Partial<MetadataValues> = {
|
||||
[MetadataKey.Timezone]: 'UTC',
|
||||
[MetadataKey.Language]: 'en',
|
||||
[MetadataKey.DateFormat]: DateFormatType.US,
|
||||
[MetadataKey.DateFormat]: DateFormat.US,
|
||||
[MetadataKey.TimeFormat]: TimeFormat.TwelveHour,
|
||||
[MetadataKey.StartWeekOn]: 0,
|
||||
[MetadataKey.IconUrl]: '',
|
||||
};
|
||||
|
||||
@@ -67,7 +65,7 @@ export class UserMetadataBuilder {
|
||||
/**
|
||||
* Set date format metadata
|
||||
*/
|
||||
setDateFormat(format: DateFormatType): this {
|
||||
setDateFormat(format: DateFormat): this {
|
||||
this.metadata[MetadataKey.DateFormat] = format;
|
||||
return this;
|
||||
}
|
||||
@@ -80,14 +78,6 @@ export class UserMetadataBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom metadata
|
||||
*/
|
||||
setCustom(key: string, value: string | DateFormatType | UserTimezone | Record<string, unknown> | number | boolean | null | undefined): this {
|
||||
this.metadata[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final metadata object
|
||||
*/
|
||||
@@ -103,21 +93,21 @@ export const MetadataUtils = {
|
||||
/**
|
||||
* Detect user's preferred date format based on locale
|
||||
*/
|
||||
detectDateFormat(locale: string = navigator.language): DateFormatType {
|
||||
detectDateFormat(locale: string = navigator.language): DateFormat {
|
||||
const region = locale.split('-')[1]?.toUpperCase() || locale.toUpperCase();
|
||||
|
||||
// US format countries
|
||||
if (['US', 'CA', 'PH'].includes(region)) {
|
||||
return DateFormatType.US;
|
||||
return DateFormat.US;
|
||||
}
|
||||
|
||||
// ISO format preference
|
||||
if (['SE', 'FI', 'JP', 'KR', 'CN', 'TW', 'HK'].includes(region)) {
|
||||
return DateFormatType.ISO;
|
||||
return DateFormat.ISO;
|
||||
}
|
||||
|
||||
// Default to EU format for most other countries
|
||||
return DateFormatType.EU;
|
||||
return DateFormat.DayMonthYear;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { sortBy, uniqBy } from 'lodash-es';
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
|
||||
import { View, DatabaseRelations, UIVariant , ViewLayout, MentionablePerson } from '@/application/types';
|
||||
import { View, DatabaseRelations, UIVariant, ViewLayout, MentionablePerson } from '@/application/types';
|
||||
import { findView, findViewByLayout } from '@/components/_shared/outline/utils';
|
||||
import { createDeduplicatedNoArgsRequest } from '@/utils/deduplicateRequest';
|
||||
import { useAuthInternal } from '../contexts/AuthInternalContext';
|
||||
@@ -214,7 +214,9 @@ export function useWorkspaceData() {
|
||||
}
|
||||
}, [currentWorkspaceId, service]);
|
||||
|
||||
const loadMentionableUsers = createDeduplicatedNoArgsRequest(_loadMentionableUsers);
|
||||
const loadMentionableUsers = useMemo(() => {
|
||||
return createDeduplicatedNoArgsRequest(_loadMentionableUsers);
|
||||
}, [_loadMentionableUsers]);
|
||||
|
||||
// Get mention user
|
||||
const getMentionUser = useCallback(
|
||||
|
||||
@@ -104,19 +104,7 @@ export const AppSyncLayer: React.FC<AppSyncLayerProps> = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge notification changes with existing user data
|
||||
// Only update fields that are present in the notification (selective update)
|
||||
const updatedUser = {
|
||||
...existingUser,
|
||||
// Update name if provided in notification
|
||||
...(profileChange.name !== undefined && { name: profileChange.name }),
|
||||
// Update email if provided in notification
|
||||
...(profileChange.email !== undefined && { email: profileChange.email }),
|
||||
};
|
||||
|
||||
// Update database cache - this triggers useLiveQuery to re-render all UI components
|
||||
// displaying user profile information. No manual component updates needed.
|
||||
await db.users.put(updatedUser, userId);
|
||||
const updatedUser = service?.getCurrentUser();
|
||||
|
||||
console.log('User profile updated in database:', updatedUser);
|
||||
} catch (error) {
|
||||
@@ -131,21 +119,20 @@ export const AppSyncLayer: React.FC<AppSyncLayerProps> = ({ children }) => {
|
||||
return () => {
|
||||
currentEventEmitter.off(APP_EVENTS.USER_PROFILE_CHANGED, handleUserProfileChange);
|
||||
};
|
||||
}, [isAuthenticated, currentWorkspaceId]);
|
||||
}, [isAuthenticated, currentWorkspaceId, service]);
|
||||
|
||||
// Context value for synchronization layer
|
||||
const syncContextValue: SyncInternalContextType = useMemo(() => ({
|
||||
const syncContextValue: SyncInternalContextType = useMemo(
|
||||
() => ({
|
||||
webSocket,
|
||||
broadcastChannel,
|
||||
registerSyncContext,
|
||||
eventEmitter: eventEmitterRef.current,
|
||||
awarenessMap,
|
||||
lastUpdatedCollab,
|
||||
}), [webSocket, broadcastChannel, registerSyncContext, awarenessMap, lastUpdatedCollab]);
|
||||
|
||||
return (
|
||||
<SyncInternalContext.Provider value={syncContextValue}>
|
||||
{children}
|
||||
</SyncInternalContext.Provider>
|
||||
}),
|
||||
[webSocket, broadcastChannel, registerSyncContext, awarenessMap, lastUpdatedCollab]
|
||||
);
|
||||
|
||||
return <SyncInternalContext.Provider value={syncContextValue}>{children}</SyncInternalContext.Provider>;
|
||||
};
|
||||
270
src/components/app/workspaces/AccountSettings.tsx
Normal file
270
src/components/app/workspaces/AccountSettings.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DateFormat, TimeFormat } from '@/application/types';
|
||||
import { MetadataKey } from '@/application/user-metadata';
|
||||
import { ReactComponent as ChevronDownIcon } from '@/assets/icons/alt_arrow_down.svg';
|
||||
import { useCurrentUser, useService } from '@/components/main/app.hooks';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function AccountSettings({ children }: { children?: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const service = useService();
|
||||
|
||||
const handleSelectDateFormat = useCallback(
|
||||
async (dateFormat: number) => {
|
||||
if (!service || !currentUser?.metadata) return;
|
||||
|
||||
await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.DateFormat]: dateFormat });
|
||||
},
|
||||
[currentUser, service]
|
||||
);
|
||||
|
||||
const handleSelectTimeFormat = useCallback(
|
||||
async (timeFormat: number) => {
|
||||
if (!service || !currentUser?.metadata) return;
|
||||
|
||||
await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.TimeFormat]: timeFormat });
|
||||
},
|
||||
[currentUser, service]
|
||||
);
|
||||
|
||||
const handleSelectStartWeekOn = useCallback(
|
||||
async (startWeekOn: number) => {
|
||||
if (!service || !currentUser?.metadata) return;
|
||||
|
||||
await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.StartWeekOn]: startWeekOn });
|
||||
},
|
||||
[currentUser, service]
|
||||
);
|
||||
|
||||
if (!currentUser || !service) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const dateFormat = Number(currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat) || DateFormat.Local;
|
||||
const timeFormat = Number(currentUser?.metadata?.[MetadataKey.TimeFormat] as TimeFormat) || TimeFormat.TwelveHour;
|
||||
const startWeekOn = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn] as number) || 0;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className='flex h-[300px] min-h-0 w-[400px] flex-col gap-3 sm:max-w-[calc(100%-2rem)]'>
|
||||
<DialogTitle className='text-md font-bold text-text-primary'>{t('web.accountSettings')}</DialogTitle>
|
||||
<div className='flex min-h-0 w-full flex-1 flex-col items-start gap-3 py-4'>
|
||||
<DateFormatDropdown dateFormat={dateFormat} onSelect={handleSelectDateFormat} />
|
||||
<TimeFormatDropdown timeFormat={timeFormat} onSelect={handleSelectTimeFormat} />
|
||||
<StartWeekOnDropdown startWeekOn={startWeekOn} onSelect={handleSelectStartWeekOn} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DateFormatDropdown({ dateFormat, onSelect }: { dateFormat: number; onSelect: (dateFormat: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const dateFormats = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: DateFormat.Local,
|
||||
label: t('grid.field.dateFormatLocal'),
|
||||
},
|
||||
{
|
||||
label: t('grid.field.dateFormatUS'),
|
||||
value: DateFormat.US,
|
||||
},
|
||||
{
|
||||
label: t('grid.field.dateFormatISO'),
|
||||
value: DateFormat.ISO,
|
||||
},
|
||||
{
|
||||
label: t('grid.field.dateFormatFriendly'),
|
||||
value: DateFormat.Friendly,
|
||||
},
|
||||
{
|
||||
label: t('grid.field.dateFormatDayMonthYear'),
|
||||
value: DateFormat.DayMonthYear,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const value = dateFormats.find((format) => format.value === dateFormat);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<span className='text-xs font-medium text-text-secondary'>{t('grid.field.dateFormat')}</span>
|
||||
<div className='relative'>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen} modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 flex-1 cursor-default items-center gap-1 rounded-300 border px-2 text-sm font-normal',
|
||||
isOpen ? 'border-border-theme-thick' : 'border-border-primary hover:border-border-primary-hover'
|
||||
)}
|
||||
>
|
||||
<span className='flex-1 truncate' onMouseDown={(e) => e.preventDefault()}>
|
||||
{value?.label || t('settings.workspacePage.dateTime.dateFormat.label')}
|
||||
</span>
|
||||
<ChevronDownIcon className='text-icon-primary' />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-[--radix-dropdown-menu-trigger-width]' align='start'>
|
||||
<DropdownMenuRadioGroup value={dateFormat.toString()} onValueChange={(value) => onSelect(Number(value))}>
|
||||
{dateFormats.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.value} value={item.value.toString()}>
|
||||
{item.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeFormatDropdown({ timeFormat, onSelect }: { timeFormat: number; onSelect: (timeFormat: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const timeFormats = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: TimeFormat.TwelveHour,
|
||||
label: t('grid.field.timeFormatTwelveHour'),
|
||||
},
|
||||
{
|
||||
label: t('grid.field.timeFormatTwentyFourHour'),
|
||||
value: TimeFormat.TwentyFourHour,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const value = timeFormats.find((format) => format.value === timeFormat);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<span className='text-xs font-medium text-text-secondary'>{t('grid.field.timeFormat')}</span>
|
||||
<div className='relative'>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen} modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 flex-1 cursor-default items-center gap-1 rounded-300 border px-2 text-sm font-normal',
|
||||
isOpen ? 'border-border-theme-thick' : 'border-border-primary hover:border-border-primary-hover'
|
||||
)}
|
||||
>
|
||||
<span className='flex-1 truncate' onMouseDown={(e) => e.preventDefault()}>
|
||||
{value?.label || t('grid.field.timeFormatTwelveHour')}
|
||||
</span>
|
||||
<ChevronDownIcon className='text-icon-primary' />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-[--radix-dropdown-menu-trigger-width]' align='start'>
|
||||
<DropdownMenuRadioGroup value={timeFormat.toString()} onValueChange={(value) => onSelect(Number(value))}>
|
||||
{timeFormats.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.value} value={item.value.toString()}>
|
||||
{item.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartWeekOnDropdown({
|
||||
startWeekOn,
|
||||
onSelect,
|
||||
}: {
|
||||
startWeekOn: number;
|
||||
onSelect: (startWeekOn: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const daysOfWeek = [
|
||||
{
|
||||
value: 0,
|
||||
label: dayjs().day(0).format('dddd'),
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: dayjs().day(1).format('dddd'),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const value = daysOfWeek.find((format) => format.value === startWeekOn);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<span className='text-xs font-medium text-text-secondary'>{t('web.startWeekOn')}</span>
|
||||
<div className='relative'>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen} modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 flex-1 cursor-default items-center gap-1 rounded-300 border px-2 text-sm font-normal',
|
||||
isOpen ? 'border-border-theme-thick' : 'border-border-primary hover:border-border-primary-hover'
|
||||
)}
|
||||
>
|
||||
<span className='flex-1 truncate' onMouseDown={(e) => e.preventDefault()}>
|
||||
{value?.label || t('grid.field.timeFormatTwelveHour')}
|
||||
</span>
|
||||
<ChevronDownIcon className='text-icon-primary' />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-[--radix-dropdown-menu-trigger-width]' align='start'>
|
||||
<DropdownMenuRadioGroup value={startWeekOn.toString()} onValueChange={(value) => onSelect(Number(value))}>
|
||||
{daysOfWeek.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.value} value={item.value.toString()}>
|
||||
{item.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import { invalidToken } from '@/application/session/token';
|
||||
import { Workspace } from '@/application/types';
|
||||
import { ReactComponent as UpgradeAIMaxIcon } from '@/assets/icons/ai.svg';
|
||||
import { ReactComponent as ChevronDownIcon } from '@/assets/icons/alt_arrow_down.svg';
|
||||
import { ReactComponent as ChevronRightIcon } from '@/assets/icons/alt_arrow_right.svg';
|
||||
import { ReactComponent as TipIcon } from '@/assets/icons/help.svg';
|
||||
import { ReactComponent as AddUserIcon } from '@/assets/icons/invite_user.svg';
|
||||
import { ReactComponent as LogoutIcon } from '@/assets/icons/logout.svg';
|
||||
import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg';
|
||||
import { ReactComponent as ImportIcon } from '@/assets/icons/save_as.svg';
|
||||
import { ReactComponent as SettingsIcon } from '@/assets/icons/settings.svg';
|
||||
import { ReactComponent as UpgradeIcon } from '@/assets/icons/upgrade.svg';
|
||||
import { useAppHandlers, useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks';
|
||||
import CurrentWorkspace from '@/components/app/workspaces/CurrentWorkspace';
|
||||
@@ -37,6 +39,8 @@ import Import from '@/components/_shared/more-actions/importer/Import';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
import { openUrl } from '@/utils/url';
|
||||
|
||||
import { AccountSettings } from './AccountSettings';
|
||||
|
||||
export function Workspaces() {
|
||||
const { t } = useTranslation();
|
||||
const userWorkspaceInfo = useUserWorkspaceInfo();
|
||||
@@ -211,6 +215,13 @@ export function Workspaces() {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<AccountSettings>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<SettingsIcon />
|
||||
<div className={'flex-1 text-left'}>{t('web.accountSettings')}</div>
|
||||
<ChevronRightIcon className='text-icon-tertiary' />
|
||||
</DropdownMenuItem>
|
||||
</AccountSettings>
|
||||
<DropdownMenuItem onSelect={handleSignOut}>
|
||||
<LogoutIcon />
|
||||
{t('button.logout')}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { languageTexts, parseAITranslateTypeOption } from '@/application/databas
|
||||
import { GenerateAITranslateRowPayload, YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types';
|
||||
import { ReactComponent as AIIcon } from '@/assets/icons/ai_improve_writing.svg';
|
||||
import { ReactComponent as CopyIcon } from '@/assets/icons/copy.svg';
|
||||
import { useCurrentUser } from '@/components/main/app.hooks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -56,18 +57,20 @@ function AITextCellActions({
|
||||
const row = useRowData(rowId);
|
||||
const updateCell = useUpdateCellDispatch(rowId, fieldId);
|
||||
const { generateAITranslateForRow, generateAISummaryForRow } = useDatabaseContext();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const getCellData = useCallback(
|
||||
(cell: YDatabaseCell, field: YDatabaseField) => {
|
||||
if (!currentUser) return '';
|
||||
const type = Number(field?.get(YjsDatabaseKey.type));
|
||||
|
||||
if (type === FieldType.CreatedTime) {
|
||||
return getRowTimeString(field, row.get(YjsDatabaseKey.created_at)) || '';
|
||||
return getRowTimeString(field, row.get(YjsDatabaseKey.created_at), currentUser) || '';
|
||||
} else if (type === FieldType.LastEditedTime) {
|
||||
return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified)) || '';
|
||||
return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified), currentUser) || '';
|
||||
} else if (cell && ![FieldType.AISummaries, FieldType.AITranslations].includes(type)) {
|
||||
try {
|
||||
return getCellDataText(cell, field);
|
||||
return getCellDataText(cell, field, currentUser);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return '';
|
||||
@@ -76,7 +79,7 @@ function AITextCellActions({
|
||||
|
||||
return '';
|
||||
},
|
||||
[row]
|
||||
[currentUser, row]
|
||||
);
|
||||
|
||||
const handleGenerateSummary = useCallback(async () => {
|
||||
|
||||
@@ -4,20 +4,19 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
DateFormat,
|
||||
getDateFormat,
|
||||
getTimeFormat,
|
||||
getTypeOptions, TimeFormat,
|
||||
getFieldDateTimeFormats,
|
||||
getTypeOptions,
|
||||
useFieldSelector,
|
||||
} from '@/application/database-yjs';
|
||||
import { DateTimeCell } from '@/application/database-yjs/cell.type';
|
||||
import { useUpdateCellDispatch } from '@/application/database-yjs/dispatch';
|
||||
import { YjsDatabaseKey } from '@/application/types';
|
||||
import { MetadataKey } from '@/application/user-metadata';
|
||||
import { ReactComponent as ChevronRight } from '@/assets/icons/alt_arrow_right.svg';
|
||||
import { ReactComponent as DateSvg } from '@/assets/icons/date.svg';
|
||||
import { ReactComponent as TimeIcon } from '@/assets/icons/time.svg';
|
||||
import DateTimeFormatMenu from '@/components/database/components/cell/date/DateTimeFormatMenu';
|
||||
import DateTimeInput from '@/components/database/components/cell/date/DateTimeInput';
|
||||
import { useCurrentUser } from '@/components/main/app.hooks';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { dropdownMenuItemVariants } from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getDateFormat, getTimeFormat } from '@/utils/time';
|
||||
|
||||
function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: {
|
||||
open: boolean;
|
||||
@@ -36,6 +36,7 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: {
|
||||
fieldId: string;
|
||||
rowId: string;
|
||||
}) {
|
||||
const currentUser = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isRange, setIsRange] = useState<boolean>(() => {
|
||||
@@ -56,13 +57,20 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: {
|
||||
|
||||
const typeOptionValue = useMemo(() => {
|
||||
const typeOption = getTypeOptions(field);
|
||||
const { dateFormat, timeFormat } = getFieldDateTimeFormats(typeOption, currentUser);
|
||||
|
||||
return {
|
||||
timeFormat: getTimeFormat(Number(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat),
|
||||
dateFormat: getDateFormat(Number(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat),
|
||||
dateFormat: getDateFormat(dateFormat),
|
||||
timeFormat: getTimeFormat(timeFormat),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [field, clock]);
|
||||
}, [field, clock, currentUser?.metadata]);
|
||||
|
||||
const weekStartsOn = useMemo(() => {
|
||||
const value = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn]) || 0;
|
||||
|
||||
return value >= 0 && value <= 6 ? (value as 0 | 1 | 2 | 3 | 4 | 5 | 6) : 0;
|
||||
}, [currentUser?.metadata]);
|
||||
|
||||
const updateCell = useUpdateCellDispatch(rowId, fieldId);
|
||||
|
||||
@@ -193,6 +201,7 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: {
|
||||
showOutsideDays
|
||||
month={month}
|
||||
onMonthChange={onMonthChange}
|
||||
weekStartsOn={weekStartsOn}
|
||||
{...(isRange ? {
|
||||
mode: 'range',
|
||||
selected: dateRange,
|
||||
|
||||
@@ -23,8 +23,9 @@ import {
|
||||
YjsDatabaseKey,
|
||||
YjsEditorKey,
|
||||
} from '@/application/types';
|
||||
import { Editor } from '@/components/editor';
|
||||
import { EditorSkeleton } from '@/components/_shared/skeleton/EditorSkeleton';
|
||||
import { Editor } from '@/components/editor';
|
||||
import { useCurrentUser } from '@/components/main/app.hooks';
|
||||
|
||||
export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => {
|
||||
const meta = useRowMetaSelector(rowId);
|
||||
@@ -34,6 +35,8 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => {
|
||||
const database = useDatabase();
|
||||
const row = useRowData(rowId) as YDatabaseRow | undefined;
|
||||
const checkIfRowDocumentExists = context.checkIfRowDocumentExists;
|
||||
const { createOrphanedView, loadView } = context;
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const getCellData = useCallback(
|
||||
(cell: YDatabaseCell, field: YDatabaseField) => {
|
||||
@@ -41,12 +44,12 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => {
|
||||
const type = Number(field?.get(YjsDatabaseKey.type));
|
||||
|
||||
if (type === FieldType.CreatedTime) {
|
||||
return getRowTimeString(field, row.get(YjsDatabaseKey.created_at)) || '';
|
||||
return getRowTimeString(field, row.get(YjsDatabaseKey.created_at), currentUser) || '';
|
||||
} else if (type === FieldType.LastEditedTime) {
|
||||
return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified)) || '';
|
||||
return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified), currentUser) || '';
|
||||
} else if (cell) {
|
||||
try {
|
||||
return getCellDataText(cell, field);
|
||||
return getCellDataText(cell, field, currentUser);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return '';
|
||||
@@ -55,7 +58,7 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => {
|
||||
|
||||
return '';
|
||||
},
|
||||
[row]
|
||||
[row, currentUser]
|
||||
);
|
||||
|
||||
const properties = useMemo(() => {
|
||||
@@ -82,7 +85,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => {
|
||||
return obj;
|
||||
}, [database, getCellData, row]);
|
||||
|
||||
const { createOrphanedView, loadView } = context;
|
||||
const updateRowMeta = useUpdateRowMetaDispatch(rowId);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
DateFilter,
|
||||
DateFilterCondition,
|
||||
DateFormat,
|
||||
getDateFormat,
|
||||
getTimeFormat,
|
||||
TimeFormat,
|
||||
} from '@/application/database-yjs';
|
||||
import { DateFilter, DateFilterCondition } from '@/application/database-yjs';
|
||||
import { useUpdateFilter } from '@/application/database-yjs/dispatch';
|
||||
import { DateFormat, TimeFormat } from '@/application/types';
|
||||
import { MetadataKey } from '@/application/user-metadata';
|
||||
import DateTimeInput from '@/components/database/components/cell/date/DateTimeInput';
|
||||
import { useCurrentUser } from '@/components/main/app.hooks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { renderDate } from '@/utils/time';
|
||||
import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time';
|
||||
|
||||
function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) {
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const weekStartsOn = useMemo(() => {
|
||||
const value = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn]) || 0;
|
||||
|
||||
return value >= 0 && value <= 6 ? (value as 0 | 1 | 2 | 3 | 4 | 5 | 6) : 0;
|
||||
}, [currentUser?.metadata]);
|
||||
|
||||
const isRange = useMemo(() => {
|
||||
return [DateFilterCondition.DateStartsBetween, DateFilterCondition.DateEndsBetween].includes(filter.condition);
|
||||
}, [filter.condition]);
|
||||
@@ -74,9 +78,11 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) {
|
||||
const { timestamp, end, start } = filter;
|
||||
|
||||
if (isRange && start && end) {
|
||||
return `${renderDate(start.toString(), getDateFormat(DateFormat.Local), true)} - ${renderDate(
|
||||
const dateFormat = currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat | DateFormat.Local;
|
||||
|
||||
return `${renderDate(start.toString(), getDateFormat(dateFormat), true)} - ${renderDate(
|
||||
end.toString(),
|
||||
getDateFormat(DateFormat.Local),
|
||||
getDateFormat(dateFormat),
|
||||
true
|
||||
)}`;
|
||||
}
|
||||
@@ -84,7 +90,7 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
return renderDate(timestamp.toString(), getDateFormat(DateFormat.Local), true);
|
||||
}, [filter, isRange]);
|
||||
}, [filter, isRange, currentUser?.metadata]);
|
||||
|
||||
const [month, setMonth] = useState<Date | undefined>(undefined);
|
||||
|
||||
@@ -135,6 +141,7 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) {
|
||||
showOutsideDays
|
||||
month={month}
|
||||
onMonthChange={onMonthChange}
|
||||
weekStartsOn={weekStartsOn}
|
||||
{...(isRange
|
||||
? {
|
||||
mode: 'range',
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { DateFilter, DateFilterCondition, DateFormat, getDateFormat } from '@/application/database-yjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function DateFilterContentOverview ({ filter }: { filter: DateFilter }) {
|
||||
import { DateFilter, DateFilterCondition, } from '@/application/database-yjs';
|
||||
import { DateFormat } from '@/application/types';
|
||||
import { MetadataKey } from '@/application/user-metadata';
|
||||
import { getDateFormat } from '@/utils/time';
|
||||
import { useCurrentUser } from '@/components/main/app.hooks';
|
||||
|
||||
function DateFilterContentOverview({ filter }: { filter: DateFilter }) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const value = useMemo(() => {
|
||||
let startStr = '';
|
||||
@@ -18,7 +24,8 @@ function DateFilterContentOverview ({ filter }: { filter: DateFilter }) {
|
||||
endStr = dayjs.unix(end).format(format);
|
||||
}
|
||||
|
||||
const timestamp = filter.timestamp ? dayjs.unix(filter.timestamp).format(getDateFormat(DateFormat.Local)) : '';
|
||||
const dateFormat = currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat | DateFormat.Local;
|
||||
const timestamp = filter.timestamp ? dayjs.unix(filter.timestamp).format(getDateFormat(dateFormat)) : '';
|
||||
|
||||
switch (filter.condition) {
|
||||
case DateFilterCondition.DateStartsOn:
|
||||
@@ -48,7 +55,7 @@ function DateFilterContentOverview ({ filter }: { filter: DateFilter }) {
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [filter, t]);
|
||||
}, [filter, t, currentUser?.metadata]);
|
||||
|
||||
return <>{value}</>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DateFormat, TimeFormat } from '@/application/database-yjs';
|
||||
import { useUpdateDateTimeFieldFormat } from '@/application/database-yjs/dispatch';
|
||||
import { YjsDatabaseKey } from '@/application/types';
|
||||
import { DateFormat, TimeFormat, YjsDatabaseKey } from '@/application/types';
|
||||
import { useFieldTypeOption } from '@/components/database/components/cell/Cell.hooks';
|
||||
import {
|
||||
DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemTick,
|
||||
@@ -18,8 +17,11 @@ function DateTimeFormatGroup ({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const typeOption = useFieldTypeOption(fieldId);
|
||||
const selectedDateFormat = Number(typeOption.get(YjsDatabaseKey.date_format));
|
||||
const selectedTimeFormat = Number(typeOption.get(YjsDatabaseKey.time_format));
|
||||
const typeOptionDateFormat = typeOption.get(YjsDatabaseKey.date_format);
|
||||
const typeOptionTimeFormat = typeOption.get(YjsDatabaseKey.time_format);
|
||||
|
||||
const selectedDateFormat = typeOptionDateFormat !== undefined ? Number(typeOptionDateFormat) : undefined;
|
||||
const selectedTimeFormat = typeOptionTimeFormat !== undefined ? Number(typeOptionTimeFormat) : undefined;
|
||||
|
||||
const updateFormat = useUpdateDateTimeFieldFormat(fieldId);
|
||||
const dateFormats = useMemo(() => [{
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { ReactComponent as DateSvg } from '@/assets/icons/date.svg';
|
||||
import { ReactComponent as ReminderSvg } from '@/assets/icons/reminder_clock.svg';
|
||||
import { renderDate } from '@/utils/time';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DateFormat } from '@/application/types';
|
||||
import { MetadataKey } from '@/application/user-metadata';
|
||||
import { ReactComponent as DateSvg } from '@/assets/icons/date.svg';
|
||||
import { ReactComponent as ReminderSvg } from '@/assets/icons/reminder_clock.svg';
|
||||
import { useCurrentUser } from '@/components/main/app.hooks';
|
||||
import { getDateFormat, renderDate } from '@/utils/time';
|
||||
|
||||
function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) {
|
||||
const dateFormat = useMemo(() => {
|
||||
return renderDate(date, 'MMM D, YYYY');
|
||||
}, [date]);
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
const dateFormat = (currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat) ?? DateFormat.Local;
|
||||
|
||||
return renderDate(date, getDateFormat(dateFormat));
|
||||
}, [currentUser?.metadata, date]);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -17,7 +25,7 @@ function MentionDate({ date, reminder }: { date: string; reminder?: { id: string
|
||||
>
|
||||
<span className={'mention-content ml-0 px-0'}>
|
||||
<span>@</span>
|
||||
{dateFormat}
|
||||
{formattedDate}
|
||||
</span>
|
||||
{reminder ? <ReminderSvg /> : <DateSvg />}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ReactComponent as ChevronRightIcon } from '@/assets/icons/alt_arrow_rig
|
||||
import { ReactComponent as CheckIcon } from '@/assets/icons/tick.svg';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
|
||||
}
|
||||
@@ -112,6 +114,28 @@ const DropdownMenuItem = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-[8px] px-2 py-1.5',
|
||||
'cursor-default select-none text-sm',
|
||||
'outline-none transition-colors focus:bg-fill-content-hover',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'data-[state=checked]:bg-fill-theme-select',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
@@ -245,6 +269,7 @@ export {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuItemTick,
|
||||
DropdownMenuRadioItem,
|
||||
dropdownMenuItemVariants,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
@@ -254,4 +279,5 @@ export {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DateFormat, TimeFormat } from '@/application/types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export function renderDate(date: string | number, format: string, isUnix?: boolean): string {
|
||||
@@ -61,3 +62,31 @@ export function isTimestampBetweenRange(timestamp: string, startTimestamp: strin
|
||||
|
||||
return dateUnix >= startUnix && dateUnix <= endUnix;
|
||||
}
|
||||
|
||||
export function getTimeFormat(timeFormat?: TimeFormat) {
|
||||
switch (timeFormat) {
|
||||
case TimeFormat.TwelveHour:
|
||||
return 'h:mm A';
|
||||
case TimeFormat.TwentyFourHour:
|
||||
return 'HH:mm';
|
||||
default:
|
||||
return 'HH:mm';
|
||||
}
|
||||
}
|
||||
|
||||
export function getDateFormat(dateFormat?: DateFormat) {
|
||||
switch (dateFormat) {
|
||||
case DateFormat.Friendly:
|
||||
return 'MMM DD, YYYY';
|
||||
case DateFormat.ISO:
|
||||
return 'YYYY-MM-DD';
|
||||
case DateFormat.US:
|
||||
return 'YYYY/MM/DD';
|
||||
case DateFormat.Local:
|
||||
return 'MM/DD/YYYY';
|
||||
case DateFormat.DayMonthYear:
|
||||
return 'DD/MM/YYYY';
|
||||
default:
|
||||
return 'YYYY-MM-DD';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user