From 79543bcb199183bcbcdf7b64ebcab809e847fca8 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:17:07 +0800 Subject: [PATCH] feat: display person property (#86) * feat: person property * feat: person property part 2 * feat: person property part 3 filter menu * Update AppContextConsumer.tsx * chore: one line * chore: disable person from properties menu * chore: disable when tab is inactive --- src/@types/translations/en.json | 10 ++- src/application/database-yjs/cell.type.ts | 5 ++ src/application/database-yjs/database.type.ts | 5 +- src/application/database-yjs/fields/index.ts | 3 + .../database-yjs/fields/person/index.ts | 2 + .../database-yjs/fields/person/parse.ts | 36 +++++++++ .../database-yjs/fields/person/person.type.ts | 29 +++++++ src/application/database-yjs/filter.ts | 79 +++++++++++++++++++ .../app/components/AppContextConsumer.tsx | 50 +++++++++++- .../database/components/cell/Cell.tsx | 3 + .../components/cell/person/PersonCell.tsx | 50 ++++++++++++ .../database/components/cell/person/index.ts | 1 + .../components/conditions/PropertiesMenu.tsx | 10 ++- .../database/components/field/FieldLabel.tsx | 1 + .../components/field/FieldTypeIcon.tsx | 2 + .../filters/filter-menu/FilterMenu.tsx | 7 +- .../filters/filter-menu/PersonFilterMenu.tsx | 12 +++ .../overview/FilterContentOverview.tsx | 16 ++++ .../database/components/property/Property.tsx | 3 + .../property/PropertySelectTrigger.tsx | 2 + 20 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 src/application/database-yjs/fields/person/index.ts create mode 100644 src/application/database-yjs/fields/person/parse.ts create mode 100644 src/application/database-yjs/fields/person/person.type.ts create mode 100644 src/components/database/components/cell/person/PersonCell.tsx create mode 100644 src/components/database/components/cell/person/index.ts create mode 100644 src/components/database/components/filters/filter-menu/PersonFilterMenu.tsx diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index fbc2d590..7798be5c 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -510,7 +510,8 @@ "relationField": "Relate to another database. Useful for linking items across databases", "AISummaryField": "Summarize the content of the record. Useful for getting a quick overview", "AITranslateField": "Translate the content of the record. Useful for multilingual support", - "mediaField": "Add images, videos, or other media. Useful for rich content" + "mediaField": "Add images, videos, or other media. Useful for rich content", + "personField": "Select a person from your workspace. Useful for assigning tasks or ownership" }, "sideBar": { "closeSidebar": "Close sidebar", @@ -1653,6 +1654,12 @@ "isEmpty": "Is empty", "isNotEmpty": "Is not empty" }, + "personFilter": { + "contains": "Contains", + "doesNotContain": "Does not contain", + "isEmpty": "Is empty", + "isNotEmpty": "Is not empty" + }, "field": { "label": "Property", "hide": "Hide property", @@ -1679,6 +1686,7 @@ "timeFieldName": "Time", "mediaFieldName": "Files & media", "translateFieldName": "AI Translate", + "personFieldName": "Person", "translateTo": "Translate to", "numberFormat": "Number format", "dateFormat": "Date format", diff --git a/src/application/database-yjs/cell.type.ts b/src/application/database-yjs/cell.type.ts index c1fdf9e3..092cc4eb 100644 --- a/src/application/database-yjs/cell.type.ts +++ b/src/application/database-yjs/cell.type.ts @@ -103,6 +103,11 @@ export interface RelationCell extends Cell { export type RelationCellData = RowId[]; +export interface PersonCell extends Cell { + fieldType: FieldType.Person; + data: string; +} + export interface CellProps { cell?: T; rowId: string; diff --git a/src/application/database-yjs/database.type.ts b/src/application/database-yjs/database.type.ts index 993e60e5..9936caff 100644 --- a/src/application/database-yjs/database.type.ts +++ b/src/application/database-yjs/database.type.ts @@ -20,7 +20,8 @@ export enum FieldType { Relation = 10, AISummaries = 11, AITranslations = 12, - FileMedia = 14 + FileMedia = 14, + Person = 15, } export enum CalculationType { @@ -104,4 +105,4 @@ export enum DateGroupCondition { Week = 2, Month = 3, Year = 4 -} \ No newline at end of file +} diff --git a/src/application/database-yjs/fields/index.ts b/src/application/database-yjs/fields/index.ts index 7de2f9dc..de7f4bf4 100644 --- a/src/application/database-yjs/fields/index.ts +++ b/src/application/database-yjs/fields/index.ts @@ -8,6 +8,7 @@ export * from './text'; export * from './checkbox'; export * from './checklist'; export * from './relation'; +export * from './person'; export function getFieldName (fieldType: FieldType) { switch (fieldType) { @@ -39,6 +40,8 @@ export function getFieldName (fieldType: FieldType) { return 'AI Summary'; case FieldType.AITranslations: return 'AI Translate'; + case FieldType.Person: + return 'Person'; default: return 'Text'; } diff --git a/src/application/database-yjs/fields/person/index.ts b/src/application/database-yjs/fields/person/index.ts new file mode 100644 index 00000000..c02e82a9 --- /dev/null +++ b/src/application/database-yjs/fields/person/index.ts @@ -0,0 +1,2 @@ +export * from './person.type'; +export * from './parse'; diff --git a/src/application/database-yjs/fields/person/parse.ts b/src/application/database-yjs/fields/person/parse.ts new file mode 100644 index 00000000..07f8ff4b --- /dev/null +++ b/src/application/database-yjs/fields/person/parse.ts @@ -0,0 +1,36 @@ +import { YDatabaseField, YjsDatabaseKey } from "@/application/types"; + +import { getTypeOptions } from "../type_option"; + +import { PersonCellData, PersonTypeOption } from "./person.type"; + +export function parsePersonTypeOptions(field: YDatabaseField) { + const content = getTypeOptions(field)?.get(YjsDatabaseKey.content); + + if (!content) + return { + persons: [], + }; + + try { + return JSON.parse(content) as PersonTypeOption; + } catch (e) { + return { + persons: [], + }; + } +} + +export function parsePersonCellData(field: YDatabaseField, data: string): PersonCellData | null { + const users = parsePersonTypeOptions(field)?.persons; + + if (!data) return null; + + try { + const userIds = JSON.parse(data); + + return { users: users.filter((user) => userIds.includes(user.id)) }; + } catch (e) { + return null; + } +} diff --git a/src/application/database-yjs/fields/person/person.type.ts b/src/application/database-yjs/fields/person/person.type.ts new file mode 100644 index 00000000..be58689e --- /dev/null +++ b/src/application/database-yjs/fields/person/person.type.ts @@ -0,0 +1,29 @@ +import { Filter } from '@/application/database-yjs'; + +export enum PersonFilterCondition { + PersonContains = 0, + PersonDoesNotContain = 1, + PersonIsEmpty = 2, + PersonIsNotEmpty = 3, +} + +export interface PersonFilter extends Filter { + condition: PersonFilterCondition; + userIds: string[]; +} + +export interface PersonTypeOption { + persons: { + id: string, + name?: string, + avatar_url?: string, + }[]; +} + +export interface PersonCellData { + users: { + id: string, + name?: string, + avatar_url?: string, + }[]; +} diff --git a/src/application/database-yjs/filter.ts b/src/application/database-yjs/filter.ts index 40f083fd..828cf31f 100644 --- a/src/application/database-yjs/filter.ts +++ b/src/application/database-yjs/filter.ts @@ -14,6 +14,7 @@ import { NumberFilter, NumberFilterCondition, parseChecklistData, + PersonFilterCondition, SelectOptionFilter, SelectOptionFilterCondition, TextFilter, @@ -87,6 +88,22 @@ export function parseFilter(fieldType: FieldType, filter: YDatabaseFilter) { condition: DateFilterCondition.DateStartsOn, }; } + + case FieldType.Person: + try { + const personIds = JSON.parse(value.content) as string[]; + + return { + ...value, + personIds, + }; + } catch (e) { + console.error('Error parsing person filter content:', e); + return { + ...value, + personIds: [], + }; + } } return value; @@ -158,6 +175,10 @@ export function filterBy( return rowTimeFilterCheck(data, filterValue as DateFilter); } + case FieldType.Person: { + return personFilterCheck(cellData, content, condition); + } + default: return true; } @@ -359,6 +380,42 @@ export function selectOptionFilterCheck(data: string, content: string, condition } } + +export function personFilterCheck(data: string, content: string, condition: number) { + let userIds: string[] = []; + let filterIds: string[] = []; + + try { + userIds = JSON.parse(data || '[]'); + filterIds = JSON.parse(content || '[]'); + } catch (e) { + console.error('Error parsing person filter data:', e); + return false; + } + + if (PersonFilterCondition.PersonIsEmpty === condition) { + return filterIds.length === 0 || data === ''; + } + + if (PersonFilterCondition.PersonIsNotEmpty === condition) { + return filterIds.length > 0 && data !== ''; + } + + switch (condition) { + case PersonFilterCondition.PersonContains: + if (filterIds.length === 0) return true; + return some(filterIds, (id) => userIds.includes(id)); + + case PersonFilterCondition.PersonDoesNotContain: + if (filterIds.length === 0) return true; + return every(filterIds, (id) => !userIds.includes(id)); + + // Default case, if no conditions match + default: + return false; + } +} + // Return the default value for the filter export function textFilterFillData(content: string, condition: number) { switch (condition) { @@ -566,6 +623,21 @@ export function dateFilterFillData(filter: YDatabaseFilter): { } } +export function personFilterFillData(content: string, condition: number) { + switch (condition) { + case PersonFilterCondition.PersonContains: + return content; + case PersonFilterCondition.PersonDoesNotContain: + return ''; + case PersonFilterCondition.PersonIsEmpty: + return ''; + case PersonFilterCondition.PersonIsNotEmpty: + return content; + default: + return ''; + } +} + export function filterFillData(filter: YDatabaseFilter, field: YDatabaseField) { const content = filter.get(YjsDatabaseKey.content); const condition = Number(filter.get(YjsDatabaseKey.condition)); @@ -585,6 +657,8 @@ export function filterFillData(filter: YDatabaseFilter, field: YDatabaseField) { return selectOptionFilterFillData(content, condition); case FieldType.Checklist: return checklistFilterFillData(content, condition); + case FieldType.Person: + return personFilterFillData(content, condition); default: return null; } @@ -630,5 +704,10 @@ export function getDefaultFilterCondition(fieldType: FieldType) { timestamp: dayjs().startOf('day').unix(), }), }; + case FieldType.Person: + return { + condition: PersonFilterCondition.PersonContains, + content: '', + }; } } diff --git a/src/components/app/components/AppContextConsumer.tsx b/src/components/app/components/AppContextConsumer.tsx index 925ea585..0b6d2e9b 100644 --- a/src/components/app/components/AppContextConsumer.tsx +++ b/src/components/app/components/AppContextConsumer.tsx @@ -1,10 +1,12 @@ -import React, { memo, Suspense } from 'react'; +import React, { memo, Suspense, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Awareness } from 'y-protocols/awareness'; import { AIChatProvider } from '@/components/ai-chat/AIChatProvider'; import { AppOverlayProvider } from '@/components/app/app-overlay/AppOverlayProvider'; -import { AppContext } from '@/components/app/app.hooks'; +import { AppContext, useAppViewId, useCurrentWorkspaceId } from '@/components/app/app.hooks'; import RequestAccess from '@/components/app/landing-pages/RequestAccess'; +import { useCurrentUser } from '@/components/main/app.hooks'; import { useAllContextData } from '../hooks/useAllContextData'; @@ -41,9 +43,53 @@ export const AppContextConsumer: React.FC = memo( /> } + {} ); } ); + +function OpenClient() { + const currentWorkspaceId = useCurrentWorkspaceId(); + const viewId = useAppViewId(); + const [searchParams] = useSearchParams(); + const openClient = searchParams.get('is_desktop') === 'true'; + const rowId = searchParams.get('r'); + const currentUser = useCurrentUser(); + + const [isTabVisible, setIsTabVisible] = useState(true); + const prevOpenClientRef = useRef(openClient); + + useEffect(() => { + const handleVisibilityChange = () => { + setIsTabVisible(document.visibilityState === 'visible'); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + setIsTabVisible(document.visibilityState === 'visible'); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + useEffect(() => { + if (isTabVisible && openClient && currentUser) { + if (!prevOpenClientRef.current) { + window.open( + `appflowy-flutter://open-page?workspace_id=${currentWorkspaceId}&view_id=${viewId}&email=${currentUser.email}${ + rowId ? `&row_id=${rowId}` : '' + }`, + '_self' + ); + } + } + + prevOpenClientRef.current = openClient; + }, [currentWorkspaceId, viewId, currentUser, openClient, rowId, isTabVisible]); + + return <>; +} diff --git a/src/components/database/components/cell/Cell.tsx b/src/components/database/components/cell/Cell.tsx index 6635bc2e..aa60608e 100644 --- a/src/components/database/components/cell/Cell.tsx +++ b/src/components/database/components/cell/Cell.tsx @@ -15,6 +15,7 @@ import { SelectOptionCell } from '@/components/database/components/cell/select-o import { TextCell } from '@/components/database/components/cell/text'; import { FileMediaCell } from 'src/components/database/components/cell/file-media'; +import { PersonCell } from './person'; export function Cell(props: CellProps) { const { rowId, fieldId, style, wrap, isHovering } = props; @@ -44,6 +45,8 @@ export function Cell(props: CellProps) { case FieldType.AISummaries: case FieldType.AITranslations: return AITextCell; + case FieldType.Person: + return PersonCell; default: return TextCell; } diff --git a/src/components/database/components/cell/person/PersonCell.tsx b/src/components/database/components/cell/person/PersonCell.tsx new file mode 100644 index 00000000..f34c7969 --- /dev/null +++ b/src/components/database/components/cell/person/PersonCell.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +import { parsePersonCellData, useFieldSelector } from '@/application/database-yjs'; +import { CellProps, PersonCell as PersonCellType } from '@/application/database-yjs/cell.type'; +import { cn } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +export function PersonCell({ cell, style, placeholder, fieldId, wrap }: CellProps) { + const { field, clock } = useFieldSelector(fieldId); + + const users = useMemo(() => { + return parsePersonCellData(field, cell?.data ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cell?.data, field, clock]); + + const isEmpty = !users || !users.users.length; + + const renderedUsers = useMemo(() => { + if (!users) return; + + return users?.users.map((user) => ( +
+
+ + + + {user.avatar_url ? {user.avatar_url} : user.name} + + + {user.name} +
+
+ )); + }, [users]); + + return ( +
+ {isEmpty ? placeholder || null : renderedUsers} +
+ ); +} diff --git a/src/components/database/components/cell/person/index.ts b/src/components/database/components/cell/person/index.ts new file mode 100644 index 00000000..13cab037 --- /dev/null +++ b/src/components/database/components/cell/person/index.ts @@ -0,0 +1 @@ +export * from './PersonCell'; diff --git a/src/components/database/components/conditions/PropertiesMenu.tsx b/src/components/database/components/conditions/PropertiesMenu.tsx index 2db15795..9f083be6 100644 --- a/src/components/database/components/conditions/PropertiesMenu.tsx +++ b/src/components/database/components/conditions/PropertiesMenu.tsx @@ -36,9 +36,13 @@ function PropertiesMenu({ } if ( - [FieldType.Relation, FieldType.AISummaries, FieldType.AITranslations, FieldType.FileMedia].includes( - property.type - ) + [ + FieldType.Relation, + FieldType.AISummaries, + FieldType.AITranslations, + FieldType.FileMedia, + FieldType.Person, + ].includes(property.type) ) { return false; } diff --git a/src/components/database/components/field/FieldLabel.tsx b/src/components/database/components/field/FieldLabel.tsx index 9b758dc7..75257e51 100644 --- a/src/components/database/components/field/FieldLabel.tsx +++ b/src/components/database/components/field/FieldLabel.tsx @@ -21,6 +21,7 @@ function FieldLabel ({ type, ...props }: { type: FieldType } & React.HTMLAttribu [FieldType.AISummaries]: t('grid.field.summaryFieldName'), [FieldType.AITranslations]: t('grid.field.translateFieldName'), [FieldType.FileMedia]: t('grid.field.mediaFieldName'), + [FieldType.Person]: t('grid.field.personFieldName'), }[type]; }, [t, type]); diff --git a/src/components/database/components/field/FieldTypeIcon.tsx b/src/components/database/components/field/FieldTypeIcon.tsx index 0048e8f0..09ffd9cb 100644 --- a/src/components/database/components/field/FieldTypeIcon.tsx +++ b/src/components/database/components/field/FieldTypeIcon.tsx @@ -14,6 +14,7 @@ import { ReactComponent as RelationSvg } from '@/assets/icons/relation.svg'; import { ReactComponent as AISummariesSvg } from '@/assets/icons/ai_summary.svg'; import { ReactComponent as AITranslationsSvg } from '@/assets/icons/ai_translate.svg'; import { ReactComponent as FileMediaSvg } from '@/assets/icons/attachment.svg'; +import { ReactComponent as PersonSvg } from '@/assets/icons/user.svg'; export const FieldTypeSvgMap: Record>> = { [FieldType.RichText]: TextSvg, @@ -30,6 +31,7 @@ export const FieldTypeSvgMap: Record [FieldType.AISummaries]: AISummariesSvg, [FieldType.AITranslations]: AITranslationsSvg, [FieldType.FileMedia]: FileMediaSvg, + [FieldType.Person]: PersonSvg, }; export const FieldTypeIcon: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => { diff --git a/src/components/database/components/filters/filter-menu/FilterMenu.tsx b/src/components/database/components/filters/filter-menu/FilterMenu.tsx index 8c86dbc8..608ba712 100644 --- a/src/components/database/components/filters/filter-menu/FilterMenu.tsx +++ b/src/components/database/components/filters/filter-menu/FilterMenu.tsx @@ -1,4 +1,4 @@ -import { DateFilter, FieldType, Filter, SelectOptionFilter, useFieldSelector } from '@/application/database-yjs'; +import { DateFilter, FieldType, Filter, PersonFilter, SelectOptionFilter, useFieldSelector } from '@/application/database-yjs'; import { YjsDatabaseKey } from '@/application/types'; import DateTimeFilterMenu from '@/components/database/components/filters/filter-menu/DateTimeFilterMenu'; import { useMemo } from 'react'; @@ -6,10 +6,11 @@ import CheckboxFilterMenu from './CheckboxFilterMenu'; import ChecklistFilterMenu from './ChecklistFilterMenu'; import MultiSelectOptionFilterMenu from './MultiSelectOptionFilterMenu'; import NumberFilterMenu from './NumberFilterMenu'; +import PersonFilterMenu from './PersonFilterMenu'; import SingleSelectOptionFilterMenu from './SingleSelectOptionFilterMenu'; import TextFilterMenu from './TextFilterMenu'; -export function FilterMenu ({ filter }: { filter: Filter }) { +export function FilterMenu({ filter }: { filter: Filter }) { const { field } = useFieldSelector(filter?.fieldId); const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; @@ -33,6 +34,8 @@ export function FilterMenu ({ filter }: { filter: Filter }) { case FieldType.LastEditedTime: case FieldType.CreatedTime: return ; + case FieldType.Person: + return ; default: return null; } diff --git a/src/components/database/components/filters/filter-menu/PersonFilterMenu.tsx b/src/components/database/components/filters/filter-menu/PersonFilterMenu.tsx new file mode 100644 index 00000000..75dcf575 --- /dev/null +++ b/src/components/database/components/filters/filter-menu/PersonFilterMenu.tsx @@ -0,0 +1,12 @@ +import { PersonFilter } from '@/application/database-yjs'; +import FieldMenuTitle from '@/components/database/components/filters/filter-menu/FieldMenuTitle'; + +function PersonFilterMenu({ filter }: { filter: PersonFilter }) { + return ( +
+ } /> +
+ ); +} + +export default PersonFilterMenu; diff --git a/src/components/database/components/filters/overview/FilterContentOverview.tsx b/src/components/database/components/filters/overview/FilterContentOverview.tsx index f8625457..47c2e4da 100644 --- a/src/components/database/components/filters/overview/FilterContentOverview.tsx +++ b/src/components/database/components/filters/overview/FilterContentOverview.tsx @@ -3,6 +3,7 @@ import { ChecklistFilterCondition, FieldType, Filter, + PersonFilterCondition, SelectOptionFilter, useFieldSelector, } from '@/application/database-yjs'; @@ -52,6 +53,21 @@ export function FilterContentOverview({ filter }: { filter: Filter }) { : t('grid.checklistFilter.isIncomplted')} ); + case FieldType.Person: + return ( + <> + :{' '} + {filter.condition === PersonFilterCondition.PersonContains + ? t('grid.personFilter.contains') + : filter.condition === PersonFilterCondition.PersonDoesNotContain + ? t('grid.personFilter.doesNotContain') + : filter.condition === PersonFilterCondition.PersonIsEmpty + ? t('grid.personFilter.isEmpty') + : filter.condition === PersonFilterCondition.PersonIsNotEmpty + ? t('grid.personFilter.isNotEmpty') + : ''} + + ); default: return null; } diff --git a/src/components/database/components/property/Property.tsx b/src/components/database/components/property/Property.tsx index 5d88f6da..1aed9255 100644 --- a/src/components/database/components/property/Property.tsx +++ b/src/components/database/components/property/Property.tsx @@ -15,6 +15,7 @@ import { TextProperty } from '@/components/database/components/property/text'; import { FC, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ChecklistProperty } from 'src/components/database/components/property/cheklist'; +import { PersonCell } from '../cell/person'; export function Property ({ fieldId, rowId }: { fieldId: string; rowId: string }) { const cell = useCellSelector({ @@ -48,6 +49,8 @@ export function Property ({ fieldId, rowId }: { fieldId: string; rowId: string } return TextCell; case FieldType.FileMedia: return FileMediaCell; + case FieldType.Person: + return PersonCell; default: return TextProperty; } diff --git a/src/components/database/components/property/PropertySelectTrigger.tsx b/src/components/database/components/property/PropertySelectTrigger.tsx index a2e97830..5d1e09f4 100644 --- a/src/components/database/components/property/PropertySelectTrigger.tsx +++ b/src/components/database/components/property/PropertySelectTrigger.tsx @@ -31,6 +31,7 @@ const properties = [ FieldType.Relation, FieldType.AISummaries, FieldType.AITranslations, + FieldType.Person, ]; export function PropertySelectTrigger({ fieldId, disabled }: { fieldId: string; disabled?: boolean }) { @@ -62,6 +63,7 @@ export function PropertySelectTrigger({ fieldId, disabled }: { fieldId: string; [FieldType.AISummaries]: t('tooltip.AISummaryField'), [FieldType.AITranslations]: t('tooltip.AITranslateField'), [FieldType.FileMedia]: t('tooltip.mediaField'), + [FieldType.Person]: t('tooltip.personField'), }; }, [t]);