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
This commit is contained in:
Richard Shiue
2025-09-30 16:17:07 +08:00
committed by GitHub
parent 89299f6b0a
commit 79543bcb19
20 changed files with 316 additions and 10 deletions

View File

@@ -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",

View File

@@ -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<T extends Cell> {
cell?: T;
rowId: string;

View File

@@ -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
}
}

View File

@@ -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';
}

View File

@@ -0,0 +1,2 @@
export * from './person.type';
export * from './parse';

View File

@@ -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;
}
}

View File

@@ -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,
}[];
}

View File

@@ -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: '',
};
}
}

View File

@@ -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<AppContextConsumerProps> = memo(
/>
</Suspense>
}
{<OpenClient />}
</AppOverlayProvider>
</AIChatProvider>
</AppContext.Provider>
);
}
);
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 <></>;
}

View File

@@ -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<CellType>) {
const { rowId, fieldId, style, wrap, isHovering } = props;
@@ -44,6 +45,8 @@ export function Cell(props: CellProps<CellType>) {
case FieldType.AISummaries:
case FieldType.AITranslations:
return AITextCell;
case FieldType.Person:
return PersonCell;
default:
return TextCell;
}

View File

@@ -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<PersonCellType>) {
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) => (
<div key={user.id} className='min-w-fit max-w-[120px]'>
<div className='flex items-center gap-1'>
<Avatar className='h-5 w-5 border border-border-primary'>
<AvatarImage src={user.avatar_url} alt={''} />
<AvatarFallback>
{user.avatar_url ? <span className='flex justify-center text-sm'>{user.avatar_url}</span> : user.name}
</AvatarFallback>
</Avatar>
<span className='truncate text-sm'>{user.name}</span>
</div>
</div>
));
}, [users]);
return (
<div
style={style}
className={cn(
'select-option-cell flex w-full items-center gap-1',
isEmpty && placeholder ? 'text-text-tertiary' : '',
wrap
? 'flex-wrap overflow-x-hidden'
: 'appflowy-hidden-scroller h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden'
)}
>
{isEmpty ? placeholder || null : renderedUsers}
</div>
);
}

View File

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

View File

@@ -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;
}

View File

@@ -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]);

View File

@@ -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, FC<React.SVGProps<SVGSVGElement>>> = {
[FieldType.RichText]: TextSvg,
@@ -30,6 +31,7 @@ export const FieldTypeSvgMap: Record<FieldType, FC<React.SVGProps<SVGSVGElement>
[FieldType.AISummaries]: AISummariesSvg,
[FieldType.AITranslations]: AITranslationsSvg,
[FieldType.FileMedia]: FileMediaSvg,
[FieldType.Person]: PersonSvg,
};
export const FieldTypeIcon: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => {

View File

@@ -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 <DateTimeFilterMenu filter={filter as DateFilter} />;
case FieldType.Person:
return <PersonFilterMenu filter={filter as PersonFilter} />;
default:
return null;
}

View File

@@ -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 (
<div className={'flex flex-col'}>
<FieldMenuTitle fieldId={filter.fieldId} filterId={filter.id} renderConditionSelect={<></>} />
</div>
);
}
export default PersonFilterMenu;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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]);