refactor(core): doc property (#8465)

doc property upgraded to use orm.

The visibility of the property are simplified to three types: `always show`, `always hide`, `hide when empty`, and the default is `always show`.

![CleanShot 2024-10-14 at 15 34 52](https://github.com/user-attachments/assets/748b8b80-061f-4d6a-8579-52e59df717c2)

Added a sidebar view to manage properties
![CleanShot 2024-10-14 at 15 35 58](https://github.com/user-attachments/assets/bffa9b1a-a1a5-4708-b2e8-4963120f3af9)

new property ui in workspace settings
![CleanShot 2024-10-14 at 15 36 44](https://github.com/user-attachments/assets/572d8dcc-9b3d-462a-9bcc-5f5fa8e622da)

Property lists can be collapsed
![CleanShot 2024-10-14 at 15 37 59](https://github.com/user-attachments/assets/2b20be1a-8141-478a-8fe7-405aff6d04fd)
This commit is contained in:
EYHN
2024-10-15 10:17:11 +00:00
parent 13b24eb823
commit 24e0c5797c
88 changed files with 3151 additions and 3617 deletions
@@ -1,6 +0,0 @@
import { createContext } from 'react';
import type { PagePropertiesManager } from './page-properties-manager';
// @ts-expect-error this should always be set
export const managerContext = createContext<PagePropertiesManager>();
@@ -1,57 +0,0 @@
import { ConfirmModal } from '@affine/component';
import { WorkspacePropertiesAdapter } from '@affine/core/modules/properties';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
import { Trans, useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { PagePropertiesMetaManager } from './page-properties-manager';
export const ConfirmDeletePropertyModal = ({
onConfirm,
onCancel,
property,
show,
}: {
property: PageInfoCustomPropertyMeta;
show: boolean;
onConfirm: () => void;
onCancel: () => void;
}) => {
const t = useI18n();
const adapter = useService(WorkspacePropertiesAdapter);
const count = useMemo(() => {
const manager = new PagePropertiesMetaManager(adapter);
return manager.getPropertyRelatedPages(property.id)?.size || 0;
}, [adapter, property.id]);
return (
<ConfirmModal
open={show}
closeButtonOptions={{
onClick: onCancel,
}}
title={t['com.affine.settings.workspace.properties.delete-property']()}
description={
<Trans
values={{
name: property.name,
count,
}}
i18nKey="com.affine.settings.workspace.properties.delete-property-prompt"
>
The <strong>{{ name: property.name } as any}</strong> property will be
removed from count doc(s). This action cannot be undone.
</Trans>
}
confirmText={t['Confirm']()}
onConfirm={onConfirm}
cancelButtonOptions={{
onClick: onCancel,
}}
confirmButtonOptions={{
variant: 'error',
}}
/>
);
};
@@ -1,147 +0,0 @@
import { PagePropertyType } from '@affine/core/modules/properties/services/schema';
import * as icons from '@blocksuite/icons/rc';
import type { SVGProps } from 'react';
type IconType = (props: SVGProps<SVGSVGElement>) => JSX.Element;
// assume all exports in icons are icon Components
type LibIconComponentName = keyof typeof icons;
type fromLibIconName<T extends string> = T extends `${infer N}Icon`
? Uncapitalize<N>
: never;
export const iconNames = [
'ai',
'email',
'text',
'dateTime',
'keyboard',
'pen',
'account',
'embedWeb',
'layer',
'pin',
'appearance',
'eraser',
'layout',
'presentation',
'bookmark',
'exportToHtml',
'lightMode',
'progress',
'bulletedList',
'exportToMarkdown',
'link',
'publish',
'camera',
'exportToPdf',
'linkedEdgeless',
'quote',
'checkBoxCheckLinear',
'exportToPng',
'linkedPage',
'save',
'cloudWorkspace',
'exportToSvg',
'localData',
'shape',
'code',
'favorite',
'localWorkspace',
'style',
'codeBlock',
'file',
'lock',
'tag',
'collaboration',
'folder',
'multiSelect',
'tags',
'colorPicker',
'frame',
'new',
'today',
'contactWithUs',
'grid',
'now',
'upgrade',
'darkMode',
'grouping',
'number',
'userGuide',
'databaseKanbanView',
'image',
'numberedList',
'view',
'databaseListView',
'inbox',
'other',
'viewLayers',
'databaseTableView',
'info',
'page',
'attachment',
'delete',
'issue',
'paste',
'heartbreak',
'edgeless',
'journal',
'payment',
'createdEdited',
] as const satisfies fromLibIconName<LibIconComponentName>[];
export type PagePropertyIcon = (typeof iconNames)[number];
export const getDefaultIconName = (
type: PagePropertyType
): PagePropertyIcon => {
switch (type) {
case 'text':
return 'text';
case 'tags':
return 'tag';
case 'date':
return 'dateTime';
case 'progress':
return 'progress';
case 'checkbox':
return 'checkBoxCheckLinear';
case 'number':
return 'number';
case 'createdBy':
return 'createdEdited';
case 'updatedBy':
return 'createdEdited';
default:
return 'text';
}
};
export const getSafeIconName = (
iconName: string,
type?: PagePropertyType
): PagePropertyIcon => {
return iconNames.includes(iconName as any)
? (iconName as PagePropertyIcon)
: getDefaultIconName(type || PagePropertyType.Text);
};
const nameToComponentName = (
iconName: PagePropertyIcon
): LibIconComponentName => {
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
return `${capitalize(iconName)}Icon` as LibIconComponentName;
};
export const nameToIcon = (
iconName: string,
type?: PagePropertyType
): IconType => {
const Icon = icons[nameToComponentName(getSafeIconName(iconName, type))];
if (!Icon) {
throw new Error(`Icon ${iconName} not found`);
}
return Icon;
};
@@ -0,0 +1,91 @@
import type * as icons from '@blocksuite/icons/rc';
// assume all exports in icons are icon Components
type LibIconComponentName = keyof typeof icons;
type fromLibIconName<T extends string> = T extends `${infer N}Icon`
? Uncapitalize<N>
: never;
export const DocPropertyIconNames = [
'ai',
'email',
'text',
'dateTime',
'keyboard',
'pen',
'account',
'embedWeb',
'layer',
'pin',
'appearance',
'eraser',
'layout',
'presentation',
'bookmark',
'exportToHtml',
'lightMode',
'progress',
'bulletedList',
'exportToMarkdown',
'link',
'publish',
'camera',
'exportToPdf',
'linkedEdgeless',
'quote',
'checkBoxCheckLinear',
'exportToPng',
'linkedPage',
'save',
'cloudWorkspace',
'exportToSvg',
'localData',
'shape',
'code',
'favorite',
'localWorkspace',
'style',
'codeBlock',
'file',
'lock',
'tag',
'collaboration',
'folder',
'multiSelect',
'tags',
'colorPicker',
'frame',
'new',
'today',
'contactWithUs',
'grid',
'now',
'upgrade',
'darkMode',
'grouping',
'number',
'userGuide',
'databaseKanbanView',
'image',
'numberedList',
'view',
'databaseListView',
'inbox',
'other',
'viewLayers',
'databaseTableView',
'info',
'page',
'attachment',
'delete',
'issue',
'paste',
'heartbreak',
'edgeless',
'journal',
'payment',
'createdEdited',
] as const satisfies fromLibIconName<LibIconComponentName>[];
export type DocPropertyIconName = (typeof DocPropertyIconNames)[number];
@@ -0,0 +1,39 @@
import * as icons from '@blocksuite/icons/rc';
import type { DocCustomPropertyInfo } from '@toeverything/infra';
import type { SVGProps } from 'react';
import {
DocPropertyTypes,
isSupportedDocPropertyType,
} from '../types/constant';
import { type DocPropertyIconName, DocPropertyIconNames } from './constant';
// assume all exports in icons are icon Components
type LibIconComponentName = keyof typeof icons;
export const iconNameToComponent = (name: DocPropertyIconName) => {
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const IconComponent =
icons[`${capitalize(name)}Icon` as LibIconComponentName];
if (!IconComponent) {
throw new Error(`Icon ${name} not found`);
}
return IconComponent;
};
export const DocPropertyIcon = ({
propertyInfo,
...props
}: {
propertyInfo: DocCustomPropertyInfo;
} & SVGProps<SVGSVGElement>) => {
const Icon =
propertyInfo.icon &&
DocPropertyIconNames.includes(propertyInfo.icon as DocPropertyIconName)
? iconNameToComponent(propertyInfo.icon as DocPropertyIconName)
: isSupportedDocPropertyType(propertyInfo.type)
? DocPropertyTypes[propertyInfo.type].icon
: DocPropertyTypes.text.icon;
return <Icon {...props} />;
};
@@ -1,37 +1,23 @@
import { Menu, Scrollable } from '@affine/component';
import { useI18n } from '@affine/i18n';
import type { DocCustomPropertyInfo } from '@toeverything/infra';
import { chunk } from 'lodash-es';
import { useEffect, useRef } from 'react';
import type { PagePropertyIcon } from './icons-mapping';
import { iconNames, nameToIcon } from './icons-mapping';
import { type DocPropertyIconName, DocPropertyIconNames } from './constant';
import { DocPropertyIcon, iconNameToComponent } from './doc-property-icon';
import * as styles from './icons-selector.css';
const iconsPerRow = 6;
const iconRows = chunk(iconNames, iconsPerRow);
const iconRows = chunk(DocPropertyIconNames, iconsPerRow);
export const IconsSelectorPanel = ({
selected,
const IconsSelectorPanel = ({
selectedIcon,
onSelectedChange,
}: {
selected: PagePropertyIcon;
onSelectedChange: (icon: PagePropertyIcon) => void;
selectedIcon?: string | null;
onSelectedChange: (icon: DocPropertyIconName) => void;
}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) {
return;
}
const iconButton = ref.current.querySelector(
`[data-name="${selected}"]`
) as HTMLDivElement;
if (!iconButton) {
return;
}
iconButton.scrollIntoView({ block: 'center' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const t = useI18n();
return (
<Scrollable.Root>
@@ -39,19 +25,19 @@ export const IconsSelectorPanel = ({
{t['com.affine.page-properties.icons']()}
</div>
<Scrollable.Viewport className={styles.iconsContainerScrollable}>
<div className={styles.iconsContainer} ref={ref}>
<div className={styles.iconsContainer}>
{iconRows.map((iconRow, index) => {
return (
<div key={index} className={styles.iconsRow}>
{iconRow.map(iconName => {
const Icon = nameToIcon(iconName);
const Icon = iconNameToComponent(iconName);
return (
<div
onClick={() => onSelectedChange(iconName)}
key={iconName}
className={styles.iconButton}
data-name={iconName}
data-active={selected === iconName}
data-active={iconName === selectedIcon}
>
<Icon key={iconName} />
</div>
@@ -67,25 +53,24 @@ export const IconsSelectorPanel = ({
);
};
export const IconsSelectorButton = ({
selected,
export const DocPropertyIconSelector = ({
propertyInfo,
onSelectedChange,
}: {
selected: PagePropertyIcon;
onSelectedChange: (icon: PagePropertyIcon) => void;
propertyInfo: DocCustomPropertyInfo;
onSelectedChange: (icon: DocPropertyIconName) => void;
}) => {
const Icon = nameToIcon(selected);
return (
<Menu
items={
<IconsSelectorPanel
selected={selected}
selectedIcon={propertyInfo.icon}
onSelectedChange={onSelectedChange}
/>
}
>
<div className={styles.iconSelectorButton}>
<Icon />
<DocPropertyIcon propertyInfo={propertyInfo} />
</div>
</Menu>
);
@@ -1,4 +1,2 @@
export * from './icons-mapping';
export * from './info-modal/info-modal';
export * from './page-properties-manager';
export * from './table';
@@ -1,7 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
import { rowHPadding } from '../styles.css';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const container = style({
maxWidth: 480,
@@ -9,9 +8,6 @@ export const container = style({
padding: '20px 0',
alignSelf: 'start',
marginTop: '120px',
vars: {
[rowHPadding]: '6px',
},
});
export const titleContainer = style({
@@ -53,3 +49,36 @@ export const timeRow = style({
marginTop: 20,
borderBottom: 4,
});
export const tableBodyRoot = style({
display: 'flex',
flexDirection: 'column',
position: 'relative',
});
export const addPropertyButton = style({
alignSelf: 'flex-start',
fontSize: cssVar('fontSm'),
color: `${cssVarV2('text/secondary')}`,
padding: '0 4px',
height: 36,
fontWeight: 400,
gap: 6,
'@media': {
print: {
display: 'none',
},
},
selectors: {
[`[data-property-collapsed="true"] &`]: {
display: 'none',
},
},
});
globalStyle(`${addPropertyButton} svg`, {
fontSize: 16,
color: cssVarV2('icon/secondary'),
});
globalStyle(`${addPropertyButton}:hover svg`, {
color: cssVarV2('icon/primary'),
});
@@ -1,12 +1,16 @@
import {
Button,
Divider,
type InlineEditHandle,
Menu,
Modal,
PropertyCollapsible,
Scrollable,
} from '@affine/component';
import { DocInfoService } from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import type { Doc } from '@toeverything/infra';
import {
DocsService,
@@ -16,27 +20,13 @@ import {
useService,
useServices,
} from '@toeverything/infra';
import {
Suspense,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BlocksuiteHeaderTitle } from '../../../blocksuite/block-suite-header/title';
import { managerContext } from '../common';
import {
PagePropertiesAddProperty,
PagePropertyRow,
SortableProperties,
usePagePropertiesManager,
} from '../table';
import { CreatePropertyMenuItems } from '../menu/create-doc-property';
import { PagePropertyRow } from '../table';
import * as styles from './info-modal.css';
import { LinksRow } from './links-row';
import { TagsRow } from './tags-row';
import { TimeRow } from './time-row';
export const InfoModal = () => {
@@ -68,15 +58,10 @@ const InfoModalOpened = ({ docId }: { docId: string }) => {
const modal = useService(DocInfoService).modal;
const titleInputHandleRef = useRef<InlineEditHandle>(null);
const manager = usePagePropertiesManager(docId ?? '');
const handleClose = useCallback(() => {
modal.close();
}, [modal]);
if (!manager.page || manager.readonly) {
return null;
}
return (
<Modal
contentOptions={{
@@ -98,15 +83,7 @@ const InfoModalOpened = ({ docId }: { docId: string }) => {
inputHandleRef={titleInputHandleRef}
/>
</div>
<managerContext.Provider value={manager}>
<Suspense>
<InfoTable
docId={docId}
onClose={handleClose}
readonly={manager.readonly}
/>
</Suspense>
</managerContext.Provider>
<InfoTable docId={docId} onClose={handleClose} />
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollBar} />
</Scrollable.Root>
@@ -117,17 +94,17 @@ const InfoModalOpened = ({ docId }: { docId: string }) => {
export const InfoTable = ({
onClose,
docId,
readonly,
}: {
docId: string;
onClose: () => void;
readonly: boolean;
}) => {
const t = useI18n();
const manager = useContext(managerContext);
const { docsSearchService } = useServices({
const { docsSearchService, docsService } = useServices({
DocsSearchService,
DocsService,
});
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const properties = useLiveData(docsService.propertyList.sortedProperties$);
const links = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
@@ -165,33 +142,50 @@ export const InfoTable = ({
<Divider size="thinner" />
</>
) : null}
<TagsRow docId={docId} readonly={readonly} />
<SortableProperties>
{properties =>
properties.length ? (
<div>
{properties
.filter(
property =>
manager.isPropertyRequired(property.id) ||
(property.visibility !== 'hide' &&
!(
property.visibility === 'hide-if-empty' &&
!property.value
))
)
.map(property => (
<PagePropertyRow
key={property.id}
property={property}
rowNameClassName={styles.rowNameContainer}
/>
))}
</div>
) : null
<PropertyCollapsible
className={styles.tableBodyRoot}
collapseButtonText={({ hide, isCollapsed }) =>
isCollapsed
? hide === 1
? t['com.affine.page-properties.more-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.more-property.more']({
count: hide.toString(),
})
: hide === 1
? t['com.affine.page-properties.hide-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.hide-property.more']({
count: hide.toString(),
})
}
</SortableProperties>
{manager.readonly ? null : <PagePropertiesAddProperty />}
>
{properties.map(property => (
<PagePropertyRow
key={property.id}
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={setNewPropertyId} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
</PropertyCollapsible>
</div>
);
};
@@ -1,104 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { fallbackVar, style } from '@vanilla-extract/css';
import { rowHPadding } from '../styles.css';
export const icon = style({
fontSize: 16,
color: cssVar('iconSecondary'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
padding: 6,
width: '160px',
});
export const rowName = style({
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: cssVar('fontSm'),
color: cssVar('textSecondaryColor'),
});
export const time = style({
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
flexGrow: 1,
fontSize: cssVar('fontSm'),
});
export const rowCell = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'start',
gap: 4,
});
export const container = style({
display: 'flex',
flexDirection: 'column',
marginTop: 20,
marginBottom: 4,
});
export const rowValueCell = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
position: 'relative',
borderRadius: 4,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
userSelect: 'none',
':focus-visible': {
outline: 'none',
},
cursor: 'pointer',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
padding: `6px ${fallbackVar(rowHPadding, '8px')} 6px 8px`,
border: `1px solid transparent`,
color: cssVar('textPrimaryColor'),
':focus': {
backgroundColor: cssVar('hoverColor'),
},
'::placeholder': {
color: cssVar('placeholderColor'),
},
selectors: {
'&[data-empty="true"]': {
color: cssVar('placeholderColor'),
},
'&[data-readonly=true]': {
pointerEvents: 'none',
},
},
flex: 1,
});
export const tagsMenu = style({
padding: 0,
transform:
'translate(-3.5px, calc(-3.5px + var(--radix-popper-anchor-height) * -1))',
width: 'calc(var(--radix-popper-anchor-width) + 16px)',
overflow: 'hidden',
});
export const tagsInlineEditor = style({
selectors: {
'&[data-empty=true]': {
color: cssVar('placeholderColor'),
},
},
});
@@ -1,63 +0,0 @@
import { Menu } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { TagsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { InlineTagsList, TagsEditor } from '../tags-inline-editor';
import * as styles from './tags-row.css';
export const TagsRow = ({
docId,
readonly,
className,
}: {
docId: string;
readonly: boolean;
className?: string;
}) => {
const t = useI18n();
const tagList = useService(TagService).tagList;
const tagIds = useLiveData(tagList.tagIdsByPageId$(docId));
const empty = !tagIds || tagIds.length === 0;
return (
<div
className={clsx(styles.rowCell, className)}
data-testid="info-modal-tags-row"
>
<div className={styles.rowNameContainer}>
<div className={styles.icon}>
<TagsIcon />
</div>
<div className={styles.rowName}>{t['Tags']()}</div>
</div>
<Menu
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 0,
avoidCollisions: false,
className: styles.tagsMenu,
onClick(e) {
e.stopPropagation();
},
}}
items={<TagsEditor pageId={docId} readonly={readonly} />}
>
<div
className={clsx(styles.tagsInlineEditor, styles.rowValueCell)}
data-empty={empty}
data-readonly={readonly}
data-testid="info-modal-tags-value"
>
{empty ? (
t['com.affine.page-properties.property-value-placeholder']()
) : (
<InlineTagsList pageId={docId} readonly />
)}
</div>
</Menu>
</div>
);
};
@@ -1,48 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const icon = style({
fontSize: 16,
color: cssVar('iconSecondary'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
padding: 6,
width: '160px',
});
export const rowName = style({
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: cssVar('fontSm'),
color: cssVar('textSecondaryColor'),
});
export const time = style({
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
flexGrow: 1,
fontSize: cssVar('fontSm'),
});
export const rowCell = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
});
export const container = style({
display: 'flex',
flexDirection: 'column',
@@ -1,34 +1,19 @@
import { PropertyName, PropertyRoot, PropertyValue } from '@affine/component';
import { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import {
DocsService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import type { ConfigType } from 'dayjs';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import { type ReactNode, useContext, useMemo } from 'react';
import { useMemo } from 'react';
import { managerContext } from '../common';
import * as styles from './time-row.css';
const RowComponent = ({
name,
icon,
time,
}: {
name: string;
icon: ReactNode;
time?: string | null;
}) => {
return (
<div className={styles.rowCell}>
<div className={styles.rowNameContainer}>
<div className={styles.icon}>{icon}</div>
<span className={styles.rowName}>{name}</span>
</div>
<div className={styles.time}>{time ? time : 'unknown'}</div>
</div>
);
};
export const TimeRow = ({
docId,
className,
@@ -37,11 +22,13 @@ export const TimeRow = ({
className?: string;
}) => {
const t = useI18n();
const manager = useContext(managerContext);
const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService);
const { syncing, retrying, serverClock } = useLiveData(
workspaceService.workspace.engine.doc.docState$(docId)
);
const docRecord = useLiveData(docsService.list.doc$(docId));
const docMeta = useLiveData(docRecord?.meta$);
const timestampElement = useMemo(() => {
const formatI18nTime = (time: ConfigType) =>
@@ -54,44 +41,43 @@ export const TimeRow = ({
accuracy: 'day',
},
});
const localizedCreateTime = manager.createDate
? formatI18nTime(manager.createDate)
const localizedCreateTime = docMeta
? formatI18nTime(docMeta.createDate)
: null;
return (
<>
<RowComponent
icon={<DateTimeIcon />}
name={t['Created']()}
time={
manager.createDate
? formatI18nTime(manager.createDate)
: localizedCreateTime
}
/>
<PropertyRoot>
<PropertyName name={t['Created']()} icon={<DateTimeIcon />} />
<PropertyValue>
{docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime}
</PropertyValue>
</PropertyRoot>
{serverClock ? (
<RowComponent
icon={<HistoryIcon />}
name={t[!syncing && !retrying ? 'Updated' : 'com.affine.syncing']()}
time={!syncing && !retrying ? formatI18nTime(serverClock) : null}
/>
) : manager.updatedDate ? (
<RowComponent
icon={<HistoryIcon />}
name={t['Updated']()}
time={formatI18nTime(manager.updatedDate)}
/>
<PropertyRoot>
<PropertyName
name={t[
!syncing && !retrying ? 'Updated' : 'com.affine.syncing'
]()}
icon={<HistoryIcon />}
/>
<PropertyValue>
{!syncing && !retrying
? formatI18nTime(serverClock)
: docMeta?.updatedDate
? formatI18nTime(docMeta.updatedDate)
: null}
</PropertyValue>
</PropertyRoot>
) : docMeta?.updatedDate ? (
<PropertyRoot>
<PropertyName name={t['Updated']()} icon={<HistoryIcon />} />
<PropertyValue>{formatI18nTime(docMeta.updatedDate)}</PropertyValue>
</PropertyRoot>
) : null}
</>
);
}, [
manager.createDate,
manager.updatedDate,
retrying,
serverClock,
syncing,
t,
]);
}, [docMeta, retrying, serverClock, syncing, t]);
const dTimestampElement = useDebouncedValue(timestampElement, 500);
@@ -0,0 +1,164 @@
import {
DropIndicator,
IconButton,
Menu,
useDraggable,
useDropTarget,
} from '@affine/component';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import {
type DocCustomPropertyInfo,
DocsService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { type HTMLProps, useCallback, useState } from 'react';
import { DocPropertyIcon } from '../icons/doc-property-icon';
import { EditDocPropertyMenuItems } from '../menu/edit-doc-property';
import {
DocPropertyTypes,
isSupportedDocPropertyType,
} from '../types/constant';
import * as styles from './styles.css';
const PropertyItem = ({
propertyInfo,
defaultOpenEditMenu,
}: {
propertyInfo: DocCustomPropertyInfo;
defaultOpenEditMenu?: boolean;
}) => {
const t = useI18n();
const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService);
const [moreMenuOpen, setMoreMenuOpen] = useState(defaultOpenEditMenu);
const typeInfo = isSupportedDocPropertyType(propertyInfo.type)
? DocPropertyTypes[propertyInfo.type]
: undefined;
const handleClick = useCallback(() => {
setMoreMenuOpen(true);
}, []);
const { dragRef } = useDraggable<AffineDNDData>(
() => ({
data: {
entity: {
type: 'custom-property',
id: propertyInfo.id,
},
from: {
at: 'doc-property:manager',
workspaceId: workspaceService.workspace.id,
},
},
}),
[propertyInfo, workspaceService]
);
const { dropTargetRef, closestEdge } = useDropTarget<AffineDNDData>(
() => ({
canDrop(data) {
return (
data.source.data.entity?.type === 'custom-property' &&
data.source.data.from?.at === 'doc-property:manager' &&
data.source.data.from?.workspaceId ===
workspaceService.workspace.id &&
data.source.data.entity.id !== propertyInfo.id
);
},
closestEdge: {
allowedEdges: ['top', 'bottom'],
},
isSticky: true,
onDrop(data) {
if (data.source.data.entity?.type !== 'custom-property') {
return;
}
const propertyId = data.source.data.entity.id;
const edge = data.closestEdge;
if (edge !== 'bottom' && edge !== 'top') {
return;
}
docsService.propertyList.updatePropertyInfo(propertyId, {
index: docsService.propertyList.indexAt(
edge === 'bottom' ? 'after' : 'before',
propertyInfo.id
),
});
},
}),
[docsService, propertyInfo, workspaceService]
);
return (
<div
className={styles.itemContainer}
ref={elem => {
dropTargetRef.current = elem;
dragRef.current = elem;
}}
onClick={handleClick}
data-testid="doc-property-manager-item"
>
<DocPropertyIcon
className={styles.itemIcon}
propertyInfo={propertyInfo}
/>
<span className={styles.itemName}>
{propertyInfo.name ||
(typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']())}
</span>
<span className={styles.itemVisibility}>
{propertyInfo.show === 'hide-when-empty'
? t['com.affine.page-properties.property.hide-when-empty']()
: propertyInfo.show === 'always-hide'
? t['com.affine.page-properties.property.always-hide']()
: t['com.affine.page-properties.property.always-show']()}
</span>
<Menu
rootOptions={{
open: moreMenuOpen,
onOpenChange: setMoreMenuOpen,
modal: true,
}}
items={<EditDocPropertyMenuItems propertyId={propertyInfo.id} />}
>
<IconButton size={20} iconClassName={styles.itemMore}>
<MoreHorizontalIcon />
</IconButton>
</Menu>
<DropIndicator edge={closestEdge} />
</div>
);
};
export const DocPropertyManager = ({
className,
defaultOpenEditMenuPropertyId,
...props
}: HTMLProps<HTMLDivElement> & { defaultOpenEditMenuPropertyId?: string }) => {
const docsService = useService(DocsService);
const properties = useLiveData(docsService.propertyList.sortedProperties$);
return (
<div className={clsx(styles.container, className)} {...props}>
{properties.map(propertyInfo => (
<PropertyItem
propertyInfo={propertyInfo}
defaultOpenEditMenu={
defaultOpenEditMenuPropertyId === propertyInfo.id
}
key={propertyInfo.id}
/>
))}
</div>
);
};
@@ -0,0 +1,92 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
});
export const itemContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 8px',
gap: '8px',
color: cssVarV2('text/secondary'),
borderRadius: '6px',
lineHeight: '22px',
position: 'relative',
userSelect: 'none',
selectors: {
'&': {
cursor: 'pointer',
},
'&:hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
'&[data-drag-preview="true"]': {
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
backgroundColor: 'transparent',
},
'&[data-dragging="true"]': {
opacity: 0.5,
},
'&[draggable="true"]:before': {
content: '""',
display: 'block',
position: 'absolute',
cursor: 'grab',
top: '50%',
left: 0,
borderRadius: '2px',
backgroundColor: cssVarV2('text/placeholder'),
transform: 'translate(-6px, -50%)',
transition: 'height 0.2s 0.1s, opacity 0.2s 0.1s',
opacity: 0,
height: '4px',
width: '4px',
willChange: 'height, opacity',
},
'&[draggable="true"]:after': {
content: '""',
display: 'block',
position: 'absolute',
cursor: 'grab',
top: '50%',
left: 0,
borderRadius: '2px',
backgroundColor: 'transparent',
transform: 'translate(-8px, -50%)',
height: '100%',
width: '8px',
willChange: 'height, opacity',
},
'&[draggable="true"]:hover:before': {
height: 12,
opacity: 1,
},
},
});
export const itemIcon = style({
fontSize: '16px',
});
export const itemName = style({
flex: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: cssVar('fontSm'),
color: cssVarV2('text/primary'),
});
export const itemVisibility = style({
fontSize: cssVar('fontXs'),
});
export const itemMore = style({
color: cssVarV2('text/secondary'),
});
@@ -1,133 +0,0 @@
import type { MenuItemProps } from '@affine/component';
import { Input, MenuItem, MenuSeparator, Scrollable } from '@affine/component';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
import { useI18n } from '@affine/i18n';
import type { KeyboardEventHandler, MouseEventHandler } from 'react';
import { cloneElement, isValidElement, useCallback } from 'react';
import type { PagePropertyIcon } from './icons-mapping';
import {
getDefaultIconName,
getSafeIconName,
nameToIcon,
} from './icons-mapping';
import { IconsSelectorButton } from './icons-selector';
import * as styles from './styles.css';
export type MenuItemOption =
| React.ReactElement
| '-'
| {
text: string;
onClick: MouseEventHandler;
key?: string;
icon?: React.ReactElement;
selected?: boolean;
checked?: boolean;
type?: MenuItemProps['type'];
}
| MenuItemOption[];
const isElementOption = (e: MenuItemOption): e is React.ReactElement => {
return isValidElement(e);
};
export const renderMenuItemOptions = (options: MenuItemOption[]) => {
return options.map((option, index) => {
if (option === '-') {
return <MenuSeparator key={index} />;
} else if (isElementOption(option)) {
return cloneElement(option, { key: index });
} else if (Array.isArray(option)) {
// this is an area that needs scrollbar
return (
<Scrollable.Root key={index} className={styles.menuItemListScrollable}>
<Scrollable.Viewport className={styles.menuItemList}>
{renderMenuItemOptions(option)}
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
</Scrollable.Viewport>
</Scrollable.Root>
);
} else {
const { text, icon, onClick, type, key, checked, selected } = option;
return (
<MenuItem
key={key ?? index}
type={type}
selected={selected}
checked={checked}
prefixIcon={icon}
onClick={onClick}
>
{text}
</MenuItem>
);
}
});
};
export const EditPropertyNameMenuItem = ({
property,
onNameBlur: onBlur,
onNameChange,
onIconChange,
}: {
onNameBlur: (e: string) => void;
onNameChange: (e: string) => void;
onIconChange: (icon: PagePropertyIcon) => void;
property: PageInfoCustomPropertyMeta;
}) => {
const iconName = getSafeIconName(property.icon, property.type);
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
e => {
if (e.key !== 'Escape') {
e.stopPropagation();
}
if (e.key === 'Enter') {
e.preventDefault();
onBlur(e.currentTarget.value);
}
},
[onBlur]
);
const handleBlur = useCallback(
(e: FocusEvent & { currentTarget: HTMLInputElement }) => {
onBlur(e.currentTarget.value);
},
[onBlur]
);
const t = useI18n();
return (
<div className={styles.propertyRowNamePopupRow}>
<IconsSelectorButton
selected={iconName}
onSelectedChange={onIconChange}
/>
<Input
defaultValue={property.name}
onBlur={handleBlur}
onChange={onNameChange}
placeholder={t['unnamed']()}
onKeyDown={onKeyDown}
/>
</div>
);
};
export const PropertyTypeMenuItem = ({
property,
}: {
property: PageInfoCustomPropertyMeta;
}) => {
const Icon = nameToIcon(getDefaultIconName(property.type), property.type);
const t = useI18n();
return (
<div className={styles.propertyRowTypeItem}>
{t['com.affine.page-properties.create-property.menu.header']()}
<div className={styles.propertyTypeName}>
<Icon />
{t[`com.affine.page-properties.property.${property.type}`]()}
</div>
</div>
);
};
@@ -0,0 +1,25 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const menuHeader = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '8px',
fontSize: cssVar('fontXs'),
fontWeight: 500,
color: cssVarV2('text/secondary'),
padding: '8px 16px',
minWidth: 200,
textTransform: 'uppercase',
});
export const propertyItem = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
minWidth: 200,
});
@@ -0,0 +1,84 @@
import { MenuItem, MenuSeparator } from '@affine/component';
import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name';
import { useI18n } from '@affine/i18n';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import {
DocPropertyTypes,
isSupportedDocPropertyType,
} from '../types/constant';
import * as styles from './create-doc-property.css';
export const CreatePropertyMenuItems = ({
at = 'before',
onCreated,
}: {
at?: 'before' | 'after';
onCreated?: (propertyId: string) => void;
}) => {
const t = useI18n();
const docsService = useService(DocsService);
const propertyList = docsService.propertyList;
const properties = useLiveData(propertyList.properties$);
const onAddProperty = useCallback(
(option: { type: string; name: string }) => {
if (!isSupportedDocPropertyType(option.type)) {
return;
}
const typeDefined = DocPropertyTypes[option.type];
const nameExists = properties.some(meta => meta.name === option.name);
const allNames = properties
.map(meta => meta.name)
.filter((name): name is string => name !== null && name !== undefined);
const name = nameExists
? generateUniqueNameInSequence(option.name, allNames)
: option.name;
const uniqueId = typeDefined.uniqueId;
const newPropertyId = propertyList.createProperty({
id: uniqueId,
name,
type: option.type,
index: propertyList.indexAt(at),
});
onCreated?.(newPropertyId);
},
[at, onCreated, propertyList, properties]
);
return (
<>
<div role="heading" className={styles.menuHeader}>
{t['com.affine.page-properties.create-property.menu.header']()}
</div>
<MenuSeparator />
{Object.entries(DocPropertyTypes).map(([type, info]) => {
const name = t.t(info.name);
const uniqueId = info.uniqueId;
const isUniqueExist = properties.some(meta => meta.id === uniqueId);
const Icon = info.icon;
return (
<MenuItem
key={type}
prefixIcon={<Icon />}
disabled={isUniqueExist}
onClick={() => {
onAddProperty({
name: name,
type: type,
});
}}
data-testid="create-property-menu-item"
data-property-type={type}
>
<div className={styles.propertyItem}>
{name}
{isUniqueExist && <span>Added</span>}
</div>
</MenuItem>
);
})}
</>
);
};
@@ -0,0 +1,42 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const propertyRowNamePopupRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '8px',
fontSize: cssVar('fontSm'),
fontWeight: 500,
color: cssVarV2('text/secondary'),
padding: '8px 0px',
minWidth: 260,
});
export const propertyRowTypeItem = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
fontSize: cssVar('fontSm'),
padding: '8px 4px',
minWidth: 260,
});
export const propertyTypeName = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontSm'),
display: 'flex',
alignItems: 'center',
gap: 4,
});
export const propertyName = style({
color: cssVarV2('text/primary'),
fontSize: cssVar('fontSm'),
padding: '0 8px',
display: 'flex',
alignItems: 'center',
});
@@ -0,0 +1,212 @@
import {
Input,
MenuItem,
MenuSeparator,
useConfirmModal,
} from '@affine/component';
import { Trans, useI18n } from '@affine/i18n';
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/rc';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import {
type KeyboardEventHandler,
type MouseEvent,
useCallback,
useEffect,
useState,
} from 'react';
import { DocPropertyIcon } from '../icons/doc-property-icon';
import { DocPropertyIconSelector } from '../icons/icons-selector';
import {
DocPropertyTypes,
isSupportedDocPropertyType,
} from '../types/constant';
import * as styles from './edit-doc-property.css';
export const EditDocPropertyMenuItems = ({
propertyId,
}: {
propertyId: string;
}) => {
const t = useI18n();
const docsService = useService(DocsService);
const propertyInfo = useLiveData(
docsService.propertyList.propertyInfo$(propertyId)
);
const propertyType = propertyInfo?.type;
const typeInfo =
propertyType && isSupportedDocPropertyType(propertyType)
? DocPropertyTypes[propertyType]
: undefined;
const propertyName =
propertyInfo?.name ||
(typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']());
const [name, setName] = useState(propertyName);
const confirmModal = useConfirmModal();
useEffect(() => {
setName(propertyName);
}, [propertyName]);
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
e => {
if (e.key !== 'Escape') {
e.stopPropagation();
}
if (e.key === 'Enter') {
e.preventDefault();
docsService.propertyList.updatePropertyInfo(propertyId, {
name: e.currentTarget.value,
});
}
},
[docsService.propertyList, propertyId]
);
const handleBlur = useCallback(
(e: FocusEvent & { currentTarget: HTMLInputElement }) => {
docsService.propertyList.updatePropertyInfo(propertyId, {
name: e.currentTarget.value,
});
},
[docsService.propertyList, propertyId]
);
const handleIconChange = useCallback(
(iconName: string) => {
docsService.propertyList.updatePropertyInfo(propertyId, {
icon: iconName,
});
},
[docsService.propertyList, propertyId]
);
const handleNameChange = useCallback((e: string) => {
setName(e);
}, []);
const handleClickAlwaysShow = useCallback(
(e: MouseEvent) => {
e.preventDefault(); // avoid radix-ui close the menu
docsService.propertyList.updatePropertyInfo(propertyId, {
show: 'always-show',
});
},
[docsService.propertyList, propertyId]
);
const handleClickHideWhenEmpty = useCallback(
(e: MouseEvent) => {
e.preventDefault(); // avoid radix-ui close the menu
docsService.propertyList.updatePropertyInfo(propertyId, {
show: 'hide-when-empty',
});
},
[docsService.propertyList, propertyId]
);
const handleClickAlwaysHide = useCallback(
(e: MouseEvent) => {
e.preventDefault(); // avoid radix-ui close the menu
docsService.propertyList.updatePropertyInfo(propertyId, {
show: 'always-hide',
});
},
[docsService.propertyList, propertyId]
);
if (!propertyInfo || !isSupportedDocPropertyType(propertyType)) {
return null;
}
return (
<>
<div
className={styles.propertyRowNamePopupRow}
data-testid="edit-property-menu-item"
>
<DocPropertyIconSelector
propertyInfo={propertyInfo}
onSelectedChange={handleIconChange}
/>
{typeInfo?.renameable === false ? (
<span className={styles.propertyName}>{name}</span>
) : (
<Input
value={name}
onBlur={handleBlur}
onChange={handleNameChange}
placeholder={t['unnamed']()}
onKeyDown={onKeyDown}
/>
)}
</div>
<div className={styles.propertyRowTypeItem}>
{t['com.affine.page-properties.create-property.menu.header']()}
<div className={styles.propertyTypeName}>
<DocPropertyIcon propertyInfo={propertyInfo} />
{t[`com.affine.page-properties.property.${propertyType}`]()}
</div>
</div>
<MenuSeparator />
<MenuItem
prefixIcon={<ViewIcon />}
onClick={handleClickAlwaysShow}
selected={
propertyInfo.show !== 'hide-when-empty' &&
propertyInfo.show !== 'always-hide'
}
data-property-visibility="always-show"
>
{t['com.affine.page-properties.property.always-show']()}
</MenuItem>
<MenuItem
prefixIcon={<InvisibleIcon />}
onClick={handleClickHideWhenEmpty}
selected={propertyInfo.show === 'hide-when-empty'}
data-property-visibility="hide-when-empty"
>
{t['com.affine.page-properties.property.hide-when-empty']()}
</MenuItem>
<MenuItem
prefixIcon={<InvisibleIcon />}
onClick={handleClickAlwaysHide}
selected={propertyInfo.show === 'always-hide'}
data-property-visibility="always-hide"
>
{t['com.affine.page-properties.property.always-hide']()}
</MenuItem>
<MenuSeparator />
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
onClick={() => {
confirmModal.openConfirmModal({
title:
t['com.affine.settings.workspace.properties.delete-property'](),
description: (
<Trans
values={{
name: propertyInfo.name,
}}
i18nKey="com.affine.settings.workspace.properties.delete-property-desc"
>
The <strong>{{ name: propertyInfo.name } as any}</strong>{' '}
property will be removed from count doc(s). This action cannot
be undone.
</Trans>
),
confirmText: t['Confirm'](),
onConfirm: () => {
docsService.propertyList.removeProperty(propertyId);
},
confirmButtonOptions: {
variant: 'error',
},
});
}}
>
{t['com.affine.settings.workspace.properties.delete-property']()}
</MenuItem>
</>
);
};
@@ -1,303 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { WorkspacePropertiesAdapter } from '@affine/core/modules/properties';
import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
} from '@affine/core/modules/properties/services/schema';
import { PagePropertyType } from '@affine/core/modules/properties/services/schema';
import { createFractionalIndexingSortableHelper } from '@affine/core/utils';
import { DebugLogger } from '@affine/debug';
import { nanoid } from 'nanoid';
import { getDefaultIconName } from './icons-mapping';
const logger = new DebugLogger('PagePropertiesManager');
function validatePropertyValue(type: PagePropertyType, value: any) {
switch (type) {
case PagePropertyType.Text:
return typeof value === 'string';
case PagePropertyType.Number:
return typeof value === 'number' || !isNaN(+value);
case PagePropertyType.Checkbox:
return typeof value === 'boolean';
case PagePropertyType.Date:
return value.match(/^\d{4}-\d{2}-\d{2}$/);
case PagePropertyType.Tags:
return Array.isArray(value) && value.every(v => typeof v === 'string');
default:
return false;
}
}
export const newPropertyTypes: PagePropertyType[] = [
PagePropertyType.Text,
PagePropertyType.Number,
PagePropertyType.Checkbox,
PagePropertyType.Date,
PagePropertyType.CreatedBy,
PagePropertyType.UpdatedBy,
// TODO(@Peng): add more
];
export const readonlyPropertyTypes: PagePropertyType[] = [
PagePropertyType.CreatedBy,
PagePropertyType.UpdatedBy,
];
export class PagePropertiesMetaManager {
constructor(private readonly adapter: WorkspacePropertiesAdapter) {}
get propertiesSchema() {
return this.adapter.schema?.pageProperties ?? {};
}
get systemPropertiesSchema() {
return this.adapter.schema?.pageProperties.system ?? {};
}
get customPropertiesSchema() {
return this.adapter.schema?.pageProperties.custom ?? {};
}
getOrderedPropertiesSchema() {
return Object.values(this.customPropertiesSchema).sort(
(a, b) => a.order - b.order
);
}
checkPropertyExists(id: string) {
return !!this.customPropertiesSchema[id];
}
validatePropertyValue(id: string, value?: any) {
if (!value) {
// value is optional in all cases?
return true;
}
const type = this.customPropertiesSchema[id]?.type;
if (!type) {
logger.warn(`property ${id} not found`);
return false;
}
return validatePropertyValue(type, value);
}
addPropertyMeta(schema: {
name: string;
type: PagePropertyType;
icon?: string;
}) {
const id = nanoid();
const { type, icon } = schema;
const newOrder =
Math.max(
0,
...Object.values(this.customPropertiesSchema).map(p => p.order)
) + 1;
const property = {
...schema,
id,
source: 'custom',
type,
order: newOrder,
icon: icon ?? getDefaultIconName(type),
readonly: readonlyPropertyTypes.includes(type) || undefined,
} as const;
this.customPropertiesSchema[id] = property;
return property;
}
updatePropertyMeta(id: string, opt: Partial<PageInfoCustomPropertyMeta>) {
if (!this.checkPropertyExists(id)) {
logger.warn(`property ${id} not found`);
return;
}
Object.assign(this.customPropertiesSchema[id], opt);
}
isPropertyRequired(id: string) {
return this.customPropertiesSchema[id]?.required;
}
removePropertyMeta(id: string) {
// should warn if the property is in use
delete this.customPropertiesSchema[id];
}
// returns page schema properties -> related page
getPropertyStatistics() {
const mapping = new Map<string, Set<string>>();
for (const page of this.adapter.workspace.docCollection.docs.values()) {
const properties = this.adapter.getPageProperties(page.id);
if (properties) {
for (const id of Object.keys(properties.custom)) {
if (!mapping.has(id)) mapping.set(id, new Set());
mapping.get(id)?.add(page.id);
}
}
}
return mapping;
}
getPropertyRelatedPages(id: string) {
return this.getPropertyStatistics().get(id);
}
}
export class PagePropertiesManager {
public readonly metaManager: PagePropertiesMetaManager;
constructor(
private readonly adapter: WorkspacePropertiesAdapter,
public readonly pageId: string
) {
this.metaManager = new PagePropertiesMetaManager(this.adapter);
this.ensureRequiredProperties();
}
readonly sorter = createFractionalIndexingSortableHelper<
PageInfoCustomProperty,
string | number
>(this);
// prevent infinite loop
private ensuring = false;
ensureRequiredProperties() {
this.adapter.ensurePageProperties(this.pageId);
if (this.ensuring) return;
this.ensuring = true;
this.transact(() => {
this.metaManager.getOrderedPropertiesSchema().forEach(property => {
if (property.required && !this.hasCustomProperty(property.id)) {
this.addCustomProperty(property.id);
}
});
});
this.ensuring = false;
}
getItems() {
return Object.values(this.getCustomProperties());
}
getItemOrder(item: PageInfoCustomProperty): string {
return item.order;
}
setItemOrder(item: PageInfoCustomProperty, order: string) {
item.order = order;
}
getItemId(item: PageInfoCustomProperty) {
return item.id;
}
get workspace() {
return this.adapter.workspace;
}
get page() {
return this.adapter.workspace.docCollection.getDoc(this.pageId);
}
get intrinsicMeta() {
return this.page?.meta;
}
get updatedDate() {
return this.intrinsicMeta?.updatedDate;
}
get createDate() {
return this.intrinsicMeta?.createDate;
}
get properties() {
return this.adapter.getPageProperties(this.pageId);
}
get readonly() {
return !!this.page?.readonly;
}
/**
* get custom properties (filter out properties that are not in schema)
*/
getCustomProperties(): Record<string, PageInfoCustomProperty> {
return this.properties
? Object.fromEntries(
Object.entries(this.properties.custom).filter(([id]) =>
this.metaManager.checkPropertyExists(id)
)
)
: {};
}
getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined {
return this.metaManager.customPropertiesSchema[id];
}
getCustomProperty(id: string) {
return this.properties?.custom[id];
}
addCustomProperty(id: string, value?: any) {
this.ensureRequiredProperties();
if (!this.metaManager.checkPropertyExists(id)) {
logger.warn(`property ${id} not found`);
return;
}
if (!this.metaManager.validatePropertyValue(id, value)) {
logger.warn(`property ${id} value ${value} is invalid`);
return;
}
const newOrder = this.sorter.getNewItemOrder();
if (this.properties!.custom[id]) {
logger.warn(`custom property ${id} already exists`);
}
this.properties!.custom[id] = {
id,
value,
order: newOrder,
visibility: 'visible',
};
}
hasCustomProperty(id: string) {
return !!this.properties?.custom[id];
}
removeCustomProperty(id: string) {
this.ensureRequiredProperties();
delete this.properties!.custom[id];
}
updateCustomProperty(id: string, opt: Partial<PageInfoCustomProperty>) {
this.ensureRequiredProperties();
if (!this.properties?.custom[id]) {
logger.warn(`custom property ${id} not found`);
return;
}
if (
opt.value !== undefined &&
!this.metaManager.validatePropertyValue(id, opt.value)
) {
logger.warn(`property ${id} value ${opt.value} is invalid`);
return;
}
Object.assign(this.properties.custom[id], opt);
}
get updateCustomPropertyMeta() {
return this.metaManager.updatePropertyMeta.bind(this.metaManager);
}
get isPropertyRequired() {
return this.metaManager.isPropertyRequired.bind(this.metaManager);
}
transact = this.adapter.transact;
}
@@ -1,320 +0,0 @@
import { Avatar, Checkbox, DatePicker, Menu } from '@affine/component';
import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta';
import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
PagePropertyType,
} from '@affine/core/modules/properties/services/schema';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { i18nTime, useI18n } from '@affine/i18n';
import {
DocService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { noop } from 'lodash-es';
import type { ChangeEventHandler } from 'react';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { managerContext } from './common';
import * as styles from './styles.css';
import { TagsInlineEditor } from './tags-inline-editor';
interface PropertyRowValueProps {
property: PageInfoCustomProperty;
meta: PageInfoCustomPropertyMeta;
}
export const DateValue = ({ property }: PropertyRowValueProps) => {
const displayValue = property.value
? i18nTime(property.value, { absolute: { accuracy: 'day' } })
: undefined;
const manager = useContext(managerContext);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// show edit popup
}, []);
const handleChange = useCallback(
(e: string) => {
manager.updateCustomProperty(property.id, {
value: e,
});
},
[manager, property.id]
);
const t = useI18n();
return (
<Menu items={<DatePicker value={property.value} onChange={handleChange} />}>
<div
onClick={handleClick}
className={styles.propertyRowValueCell}
data-empty={!property.value}
>
{displayValue ??
t['com.affine.page-properties.property-value-placeholder']()}
</div>
</Menu>
);
};
export const CheckboxValue = ({ property }: PropertyRowValueProps) => {
const manager = useContext(managerContext);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
manager.updateCustomProperty(property.id, {
value: !property.value,
});
},
[manager, property.id, property.value]
);
return (
<div
onClick={handleClick}
className={styles.propertyRowValueCell}
data-empty={!property.value}
>
<Checkbox
className={styles.checkboxProperty}
checked={!!property.value}
onChange={noop}
/>
</div>
);
};
export const TextValue = ({ property }: PropertyRowValueProps) => {
const manager = useContext(managerContext);
const [value, setValue] = useState<string>(property.value);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
const ref = useRef<HTMLTextAreaElement>(null);
const handleBlur = useCallback(
(e: FocusEvent) => {
manager.updateCustomProperty(property.id, {
value: (e.currentTarget as HTMLTextAreaElement).value.trim(),
});
},
[manager, property.id]
);
// use native blur event to get event after unmount
// don't use useLayoutEffect here, cause the cleanup function will be called before unmount
useEffect(() => {
ref.current?.addEventListener('blur', handleBlur);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
ref.current?.removeEventListener('blur', handleBlur);
};
}, [handleBlur]);
const handleOnChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
e => {
setValue(e.target.value);
},
[]
);
const t = useI18n();
useEffect(() => {
setValue(property.value);
}, [property.value]);
return (
<div onClick={handleClick} className={styles.propertyRowValueTextCell}>
<textarea
ref={ref}
className={styles.propertyRowValueTextarea}
value={value || ''}
onChange={handleOnChange}
onClick={handleClick}
data-empty={!value}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
/>
<div className={styles.propertyRowValueTextareaInvisible}>
{value}
{value?.endsWith('\n') || !value ? <br /> : null}
</div>
</div>
);
};
export const NumberValue = ({ property }: PropertyRowValueProps) => {
const manager = useContext(managerContext);
const [value, setValue] = useState(property.value);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
const handleBlur = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
manager.updateCustomProperty(property.id, {
value: e.target.value.trim(),
});
},
[manager, property.id]
);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useCallback(
e => {
setValue(e.target.value);
},
[]
);
const t = useI18n();
useEffect(() => {
setValue(property.value);
}, [property.value]);
return (
<input
className={styles.propertyRowValueNumberCell}
type={'number'}
value={value || ''}
onChange={handleOnChange}
onClick={handleClick}
onBlur={handleBlur}
data-empty={!value}
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
/>
);
};
export const TagsValue = () => {
const doc = useService(DocService).doc;
const t = useI18n();
return (
<TagsInlineEditor
className={styles.propertyRowValueCell}
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
pageId={doc.id}
readonly={doc.blockSuiteDoc.readonly}
/>
);
};
const CloudUserAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy' }) => {
const cloudDocMetaService = useService(CloudDocMetaService);
const cloudDocMeta = useLiveData(cloudDocMetaService.cloudDocMeta.meta$);
const isRevalidating = useLiveData(
cloudDocMetaService.cloudDocMeta.isRevalidating$
);
const error = useLiveData(cloudDocMetaService.cloudDocMeta.error$);
useEffect(() => {
cloudDocMetaService.cloudDocMeta.revalidate();
}, [cloudDocMetaService]);
const user = useMemo(() => {
if (!cloudDocMeta) return null;
if (props.type === 'CreatedBy' && cloudDocMeta.createdBy) {
return {
name: cloudDocMeta.createdBy.name,
avatarUrl: cloudDocMeta.createdBy.avatarUrl,
};
} else if (props.type === 'UpdatedBy' && cloudDocMeta.updatedBy) {
return {
name: cloudDocMeta.updatedBy.name,
avatarUrl: cloudDocMeta.updatedBy.avatarUrl,
};
}
return null;
}, [cloudDocMeta, props.type]);
if (!cloudDocMeta) {
if (isRevalidating) {
// TODO: loading ui
return null;
}
if (error) {
// error ui
return;
}
return null;
}
if (user) {
return (
<>
<Avatar url={user.avatarUrl || ''} name={user.name} size={20} />
<span>{user.name}</span>
</>
);
}
return <NoRecordValue />;
};
const NoRecordValue = () => {
const t = useI18n();
return (
<span>
{t['com.affine.page-properties.property-user-avatar-no-record']()}
</span>
);
};
const LocalUserValue = () => {
const t = useI18n();
return <span>{t['com.affine.page-properties.local-user']()}</span>;
};
export const CreatedUserValue = () => {
const workspaceService = useService(WorkspaceService);
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
if (!isCloud) {
return (
<div className={styles.propertyRowValueUserCell}>
<LocalUserValue />
</div>
);
}
return (
<div className={styles.propertyRowValueUserCell}>
<CloudUserAvatar type="CreatedBy" />
</div>
);
};
export const UpdatedUserValue = () => {
const workspaceService = useService(WorkspaceService);
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
if (!isCloud) {
return <LocalUserValue />;
}
return (
<div className={styles.propertyRowValueUserCell}>
<CloudUserAvatar type="UpdatedBy" />
</div>
);
};
export const propertyValueRenderers: Record<
PagePropertyType,
typeof DateValue
> = {
date: DateValue,
checkbox: CheckboxValue,
text: TextValue,
number: NumberValue,
createdBy: CreatedUserValue,
updatedBy: UpdatedUserValue,
// TODO(@Peng): fix following
tags: TagsValue,
progress: TextValue,
};
@@ -0,0 +1,108 @@
import { Divider, IconButton } from '@affine/component';
import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import {
Content as CollapsibleContent,
Root as CollapsibleRoot,
} from '@radix-ui/react-collapsible';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { DocPropertyManager } from '../manager';
import {
DocPropertyTypes,
isSupportedDocPropertyType,
} from '../types/constant';
import {
AddDocPropertySidebarSection,
DocPropertyListSidebarSection,
} from './section';
import * as styles from './styles.css';
export const DocPropertySidebar = () => {
const t = useI18n();
const [newPropertyId, setNewPropertyId] = useState<string>();
const docsService = useService(DocsService);
const propertyList = docsService.propertyList;
const properties = useLiveData(propertyList.properties$);
const onAddProperty = useCallback(
(option: { type: string; name: string }) => {
if (!isSupportedDocPropertyType(option.type)) {
return;
}
const typeDefined = DocPropertyTypes[option.type];
const nameExists = properties.some(meta => meta.name === option.name);
const allNames = properties
.map(meta => meta.name)
.filter((name): name is string => name !== null && name !== undefined);
const name = nameExists
? generateUniqueNameInSequence(option.name, allNames)
: option.name;
const newPropertyId = propertyList.createProperty({
id: typeDefined.uniqueId,
name,
type: option.type,
index: propertyList.indexAt('after'),
});
setNewPropertyId(newPropertyId);
},
[propertyList, properties]
);
return (
<div className={styles.container}>
<CollapsibleRoot defaultOpen>
<DocPropertyListSidebarSection />
<CollapsibleContent>
<DocPropertyManager
className={styles.manager}
defaultOpenEditMenuPropertyId={newPropertyId}
/>
</CollapsibleContent>
</CollapsibleRoot>
<div className={styles.divider}>
<Divider />
</div>
<CollapsibleRoot defaultOpen>
<AddDocPropertySidebarSection />
<CollapsibleContent>
<div className={styles.AddListContainer}>
{Object.entries(DocPropertyTypes).map(([key, value]) => {
const Icon = value.icon;
const name = t.t(value.name);
const isUniqueExist = properties.some(
meta => meta.id === value.uniqueId
);
return (
<div
className={styles.itemContainer}
key={key}
onClick={() => {
onAddProperty({
type: key,
name,
});
}}
data-disabled={isUniqueExist}
>
<Icon className={styles.itemIcon} />
<span className={styles.itemName}>{t.t(value.name)}</span>
{isUniqueExist ? (
<span className={styles.itemAdded}>Added</span>
) : (
<IconButton size={20} iconClassName={styles.itemAdd}>
<PlusIcon />
</IconButton>
)}
</div>
);
})}
</div>
</CollapsibleContent>
</CollapsibleRoot>
</div>
);
};
@@ -0,0 +1,30 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const headerRoot = style({
display: 'flex',
width: '100%',
alignItems: 'center',
boxSizing: 'border-box',
padding: '8px 16px',
});
export const headerTitle = style({
height: '22px',
fontSize: '14px',
fontWeight: '500',
lineHeight: '22px',
color: cssVarV2('text/primary'),
});
export const collapseIcon = style({
vars: { '--y': '1px', '--r': '90deg' },
color: cssVarV2('icon/secondary'),
transform: 'translateY(var(--y)) rotate(var(--r))',
transition: 'transform 0.2s',
selectors: {
[`button[data-state="closed"] &`]: {
vars: { '--r': '0deg' },
},
},
});
@@ -0,0 +1,38 @@
import { IconButton } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { ToggleCollapseIcon } from '@blocksuite/icons/rc';
import { Trigger as CollapsibleTrigger } from '@radix-ui/react-collapsible';
import * as styles from './section.css';
export const DocPropertyListSidebarSection = () => {
const t = useI18n();
return (
<div className={styles.headerRoot}>
<span className={styles.headerTitle}>
{t['com.affine.propertySidebar.property-list.section']()}
</span>
<CollapsibleTrigger asChild>
<IconButton>
<ToggleCollapseIcon className={styles.collapseIcon} />
</IconButton>
</CollapsibleTrigger>
</div>
);
};
export const AddDocPropertySidebarSection = () => {
const t = useI18n();
return (
<div className={styles.headerRoot}>
<span className={styles.headerTitle}>
{t['com.affine.propertySidebar.add-more.section']()}
</span>
<CollapsibleTrigger asChild>
<IconButton>
<ToggleCollapseIcon className={styles.collapseIcon} />
</IconButton>
</CollapsibleTrigger>
</div>
);
};
@@ -0,0 +1,72 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
height: '100%',
paddingTop: '8px',
position: 'relative',
});
export const manager = style({
padding: '0 8px',
});
export const divider = style({
padding: '0 8px',
});
export const AddListContainer = style({
padding: '0 8px',
});
export const itemContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 8px',
gap: '8px',
color: cssVarV2('text/secondary'),
borderRadius: '6px',
lineHeight: '22px',
position: 'relative',
userSelect: 'none',
selectors: {
'&': {
cursor: 'pointer',
},
'&:hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
'&[data-disabled="true"]': {
opacity: 0.5,
pointerEvents: 'none',
},
},
});
export const itemIcon = style({
fontSize: '16px',
});
export const itemName = style({
flex: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: cssVar('fontSm'),
color: cssVarV2('text/primary'),
});
export const itemAdded = style({
fontSize: cssVar('fontXs'),
});
export const itemAdd = style({
color: cssVarV2('text/secondary'),
});
@@ -1,545 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, globalStyle, style } from '@vanilla-extract/css';
const propertyNameCellWidth = createVar();
export const rowHPadding = createVar();
export const fontSize = createVar();
export const root = style({
display: 'flex',
width: '100%',
justifyContent: 'center',
fontFamily: cssVar('fontSansFamily'),
vars: {
[propertyNameCellWidth]: '160px',
[rowHPadding]: '6px',
[fontSize]: cssVar('fontSm'),
},
'@container': {
[`viewport (width <= 640px)`]: {
vars: {
[rowHPadding]: '0px',
[fontSize]: cssVar('fontXs'),
},
},
},
});
export const rootCentered = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%',
maxWidth: cssVar('editorWidth'),
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
'@container': {
[`viewport (width <= 640px)`]: {
padding: '0 16px',
},
},
});
export const tableHeader = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
});
export const tableHeaderInfoRow = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
color: cssVarV2('text/secondary'),
fontSize: fontSize,
fontWeight: 500,
minHeight: 34,
'@media': {
print: {
display: 'none',
},
},
});
export const tableHeaderSecondaryRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
color: cssVar('textPrimaryColor'),
fontSize: fontSize,
fontWeight: 500,
padding: `0 ${rowHPadding}`,
gap: '8px',
height: 24,
'@media': {
print: {
display: 'none',
},
},
});
export const tableHeaderCollapseButtonWrapper = style({
display: 'flex',
flex: 1,
justifyContent: 'flex-end',
cursor: 'pointer',
});
export const pageInfoDimmed = style({
color: cssVarV2('text/secondary'),
});
export const spacer = style({
flexGrow: 1,
});
export const tableHeaderBacklinksHint = style({
padding: `0 ${rowHPadding}`,
cursor: 'pointer',
borderRadius: '4px',
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
});
export const backlinksList = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
fontSize: cssVar('fontSm'),
});
export const tableHeaderTimestamp = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
gap: '8px',
cursor: 'default',
padding: `0 ${rowHPadding}`,
});
export const tableHeaderDivider = style({
height: 0,
borderTop: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
width: '100%',
margin: '8px 0',
'@media': {
print: {
display: 'none',
},
},
});
export const tableBodyRoot = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
'@media': {
print: {
selectors: {
'&[data-state="open"]': {
marginBottom: 32,
},
},
},
},
});
export const tableBodySortable = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
position: 'relative',
});
export const addPropertyButton = style({
alignSelf: 'flex-start',
fontSize: cssVar('fontSm'),
color: `${cssVarV2('text/secondary')}`,
padding: '0 4px',
height: 36,
fontWeight: 400,
gap: 6,
'@media': {
print: {
display: 'none',
},
},
});
globalStyle(`${addPropertyButton} svg`, {
fontSize: 16,
color: cssVarV2('icon/secondary'),
});
globalStyle(`${addPropertyButton}:hover svg`, {
color: cssVarV2('icon/primary'),
});
export const collapsedIcon = style({
transition: 'transform 0.2s ease-in-out',
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(90deg)',
},
},
});
export const propertyRow = style({
display: 'flex',
gap: 4,
minHeight: 32,
position: 'relative',
selectors: {
'&[data-dragging=true]': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
},
});
export const tagsPropertyRow = style([
propertyRow,
{
marginBottom: -4,
},
]);
export const draggableItem = style({
cursor: 'pointer',
selectors: {
'&:before': {
content: '""',
display: 'block',
position: 'absolute',
top: '50%',
borderRadius: '2px',
backgroundColor: cssVarV2('text/placeholder'),
transform: 'translate(-12px, -50%)',
transition: 'all 0.2s 0.1s',
opacity: 0,
height: '4px',
width: '4px',
willChange: 'height, opacity',
},
'&[data-draggable=false]:before': {
display: 'none',
},
'&:hover:before': {
height: 12,
opacity: 1,
},
'&:active:before': {
height: '100%',
width: '1px',
borderRadius: 0,
opacity: 1,
transform: 'translate(-6px, -50%)',
},
'&[data-other-dragging=true]:before': {
opacity: 0,
},
'&[data-other-dragging=true]': {
pointerEvents: 'none',
},
},
});
export const draggableRowSetting = style([
draggableItem,
{
selectors: {
'&:active:before': {
height: '100%',
width: '1px',
opacity: 1,
transform: 'translate(-12px, -50%)',
},
},
},
]);
export const propertyRowCell = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
position: 'relative',
borderRadius: 4,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
userSelect: 'none',
padding: `6px ${rowHPadding} 6px 8px`,
':focus-visible': {
outline: 'none',
},
});
export const editablePropertyRowCell = style([
propertyRowCell,
{
cursor: 'pointer',
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
},
]);
export const propertyRowNameCell = style([
propertyRowCell,
{
padding: `6px ${rowHPadding}`,
flexShrink: 0,
color: cssVarV2('text/secondary'),
width: propertyNameCellWidth,
gap: 6,
},
]);
export const sortablePropertyRowNameCell = style([
propertyRowNameCell,
draggableItem,
editablePropertyRowCell,
]);
export const propertyRowIconContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
fontSize: 16,
color: cssVarV2('icon/secondary'),
});
export const propertyRowNameContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
flexGrow: 1,
});
export const propertyRowName = style({
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: cssVar('fontSm'),
});
export const propertyRowValueCell = style([
propertyRowCell,
editablePropertyRowCell,
{
border: `1px solid transparent`,
color: cssVarV2('text/primary'),
':focus': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
'::placeholder': {
color: cssVarV2('text/placeholder'),
},
selectors: {
'&[data-empty="true"]': {
color: cssVarV2('text/placeholder'),
},
'&[data-readonly=true]': {
pointerEvents: 'none',
},
},
flex: 1,
},
]);
export const propertyRowValueTextCell = style([
propertyRowValueCell,
{
padding: 0,
position: 'relative',
':focus-within': {
border: `1px solid ${cssVar('blue700')}`,
boxShadow: cssVar('activeShadow'),
},
},
]);
export const propertyRowValueUserCell = style([
propertyRowValueCell,
{
border: 'none',
overflow: 'hidden',
columnGap: '0.5rem',
alignItems: 'center',
},
]);
export const propertyRowValueTextarea = style([
propertyRowValueCell,
{
border: 'none',
padding: `6px ${rowHPadding} 6px 8px`,
height: '100%',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
},
]);
export const propertyRowValueTextareaInvisible = style([
propertyRowValueCell,
{
border: 'none',
padding: `6px ${rowHPadding} 6px 8px`,
visibility: 'hidden',
whiteSpace: 'break-spaces',
wordBreak: 'break-all',
overflow: 'hidden',
},
]);
export const propertyRowValueNumberCell = style([
propertyRowValueTextCell,
{
padding: `6px ${rowHPadding} 6px 8px`,
},
]);
export const menuHeader = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '8px',
fontSize: cssVar('fontXs'),
fontWeight: 500,
color: cssVarV2('text/secondary'),
padding: '8px 16px',
minWidth: 200,
textTransform: 'uppercase',
});
export const menuItemListScrollable = style({});
export const menuItemListScrollbar = style({
transform: 'translateX(4px)',
});
export const menuItemList = style({
display: 'flex',
flexDirection: 'column',
maxHeight: 200,
overflow: 'auto',
});
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
display: 'table !important',
});
export const menuItemIconContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'inherit',
});
export const menuItemName = style({
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const checkboxProperty = style({
fontSize: cssVar('fontH5'),
});
globalStyle(
`${propertyRow}:is([data-dragging=true], [data-other-dragging=true])
:is(${propertyRowValueCell}, ${propertyRowNameCell})`,
{
pointerEvents: 'none',
}
);
export const propertyRowNamePopupRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '8px',
fontSize: cssVar('fontSm'),
fontWeight: 500,
color: cssVarV2('text/secondary'),
padding: '8px 16px',
minWidth: 260,
});
export const propertySettingRow = style([
draggableRowSetting,
{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '8px',
fontSize: cssVar('fontSm'),
padding: '0 12px',
height: 32,
position: 'relative',
},
]);
export const propertySettingRowName = style({
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 200,
});
export const selectorButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
borderRadius: 4,
gap: 8,
fontSize: cssVar('fontSm'),
fontWeight: 400,
padding: '4px 8px',
cursor: 'pointer',
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
selectors: {
'&[data-required=true]': {
color: cssVarV2('text/disable'),
pointerEvents: 'none',
},
},
});
export const propertyRowTypeItem = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
fontSize: cssVar('fontSm'),
padding: '8px 16px',
minWidth: 260,
});
export const propertyTypeName = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontSm'),
display: 'flex',
alignItems: 'center',
gap: 4,
});
export const backLinksMenu = style({
background: cssVarV2('layer/white'),
maxWidth: 'calc(var(--affine-editor-width) / 2)',
width: '100%',
maxHeight: '30vh',
overflowY: 'auto',
});
@@ -0,0 +1,213 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, globalStyle, style } from '@vanilla-extract/css';
const propertyNameCellWidth = createVar();
export const fontSize = createVar();
export const root = style({
display: 'flex',
width: '100%',
justifyContent: 'center',
fontFamily: cssVar('fontSansFamily'),
vars: {
[propertyNameCellWidth]: '160px',
[fontSize]: cssVar('fontSm'),
},
'@container': {
[`viewport (width <= 640px)`]: {
vars: {
[fontSize]: cssVar('fontXs'),
},
},
},
});
export const rootCentered = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%',
maxWidth: cssVar('editorWidth'),
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
'@container': {
[`viewport (width <= 640px)`]: {
padding: '0 16px',
},
},
});
export const tableHeader = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
});
export const tableHeaderInfoRow = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
color: cssVarV2('text/secondary'),
fontSize: fontSize,
fontWeight: 500,
minHeight: 34,
'@media': {
print: {
display: 'none',
},
},
});
export const tableHeaderSecondaryRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
color: cssVar('textPrimaryColor'),
fontSize: fontSize,
fontWeight: 500,
padding: `0 6px`,
gap: '8px',
height: 24,
'@media': {
print: {
display: 'none',
},
},
});
export const tableHeaderCollapseButtonWrapper = style({
display: 'flex',
flex: 1,
justifyContent: 'flex-end',
cursor: 'pointer',
});
export const pageInfoDimmed = style({
color: cssVarV2('text/secondary'),
});
export const tableHeaderBacklinksHint = style({
padding: `0 6px`,
cursor: 'pointer',
borderRadius: '4px',
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
});
export const backlinksList = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
fontSize: cssVar('fontSm'),
});
export const tableHeaderTimestamp = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
gap: '8px',
cursor: 'default',
padding: `0 6px`,
});
export const tableHeaderDivider = style({
height: 0,
borderTop: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
width: '100%',
margin: '8px 0',
'@media': {
print: {
display: 'none',
},
},
});
export const tableBodyRoot = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
'@media': {
print: {
selectors: {
'&[data-state="open"]': {
marginBottom: 32,
},
},
},
},
});
export const tableBodySortable = style({
display: 'flex',
flexDirection: 'column',
position: 'relative',
});
export const actionContainer = style({
display: 'flex',
flexDirection: 'row',
gap: 8,
selectors: {
[`[data-property-collapsed="true"] &`]: {
display: 'none',
},
},
});
export const propertyActionButton = style({
alignSelf: 'flex-start',
fontSize: cssVar('fontSm'),
color: `${cssVarV2('text/secondary')}`,
padding: '0 6px',
height: 36,
fontWeight: 400,
gap: 6,
'@media': {
print: {
display: 'none',
},
},
});
globalStyle(`${propertyActionButton} svg`, {
fontSize: 16,
color: cssVarV2('icon/secondary'),
});
globalStyle(`${propertyActionButton}:hover svg`, {
color: cssVarV2('icon/primary'),
});
export const propertyConfigButton = style({
opacity: 0,
selectors: {
[`${actionContainer}:hover &`]: {
opacity: 1,
},
},
});
export const collapsedIcon = style({
transition: 'transform 0.2s ease-in-out',
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(90deg)',
},
},
});
export const backLinksMenu = style({
background: cssVarV2('layer/white'),
maxWidth: 'calc(var(--affine-editor-width) / 2)',
width: '100%',
maxHeight: '30vh',
overflowY: 'auto',
});
export const propertyRootHideEmpty = style({
selectors: {
'&:has([data-property-value][data-empty="true"])': {
display: 'none',
},
},
});
File diff suppressed because it is too large Load Diff
@@ -128,3 +128,20 @@ export const tagColorIcon = style({
height: 16,
borderRadius: '50%',
});
export const menuItemListScrollable = style({});
export const menuItemListScrollbar = style({
transform: 'translateX(4px)',
});
export const menuItemList = style({
display: 'flex',
flexDirection: 'column',
maxHeight: 200,
overflow: 'auto',
});
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
display: 'table !important',
});
@@ -3,6 +3,8 @@ import {
IconButton,
Input,
Menu,
MenuItem,
MenuSeparator,
RowInput,
Scrollable,
} from '@affine/component';
@@ -19,8 +21,6 @@ import type { HTMLAttributes, PropsWithChildren } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { TagItem, TempTagItem } from '../../page-list';
import type { MenuItemOption } from './menu-items';
import { renderMenuItemOptions } from './menu-items';
import * as styles from './tags-inline-editor.css';
interface TagsEditorProps {
@@ -93,73 +93,12 @@ export const EditTagMenu = ({
const navigate = useNavigateHelper();
const menuProps = useMemo(() => {
const options: MenuItemOption[] = [];
const updateTagName = (name: string) => {
if (name.trim() === '') {
return;
}
tag?.rename(name);
};
options.push(
<Input
defaultValue={tagValue}
onBlur={e => {
updateTagName(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
updateTagName(e.currentTarget.value);
}
e.stopPropagation();
}}
placeholder={t['Untitled']()}
/>
);
options.push('-');
options.push({
text: t['Delete'](),
icon: <DeleteIcon />,
type: 'danger',
onClick() {
onTagDelete([tag?.id || '']);
},
});
options.push({
text: t['com.affine.page-properties.tags.open-tags-page'](),
icon: <TagsIcon />,
onClick() {
navigate.jumpToTag(legacyProperties.workspaceId, tag?.id || '');
},
});
options.push('-');
options.push(
tagService.tagColors.map(([name, color], i) => {
return {
text: name,
icon: (
<div key={i} className={styles.tagColorIconWrapper}>
<div
className={styles.tagColorIcon}
style={{
backgroundColor: color,
}}
/>
</div>
),
checked: tagColor === color,
onClick() {
tag?.changeColor(color);
},
};
})
);
const items = renderMenuItemOptions(options);
return {
contentOptions: {
@@ -167,7 +106,69 @@ export const EditTagMenu = ({
e.stopPropagation();
},
},
items,
items: (
<>
<Input
defaultValue={tagValue}
onBlur={e => {
updateTagName(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
updateTagName(e.currentTarget.value);
}
e.stopPropagation();
}}
placeholder={t['Untitled']()}
/>
<MenuSeparator />
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
onClick={() => {
onTagDelete([tag?.id || '']);
}}
>
{t['Delete']()}
</MenuItem>
<MenuItem
prefixIcon={<TagsIcon />}
onClick={() => {
navigate.jumpToTag(legacyProperties.workspaceId, tag?.id || '');
}}
>
{t['com.affine.page-properties.tags.open-tags-page']()}
</MenuItem>
<MenuSeparator />
<Scrollable.Root>
<Scrollable.Viewport className={styles.menuItemList}>
{tagService.tagColors.map(([name, color], i) => (
<MenuItem
key={i}
checked={tagColor === color}
prefixIcon={
<div key={i} className={styles.tagColorIconWrapper}>
<div
className={styles.tagColorIcon}
style={{
backgroundColor: color,
}}
/>
</div>
}
onClick={() => {
tag?.changeColor(color);
}}
>
{name}
</MenuItem>
))}
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
</Scrollable.Viewport>
</Scrollable.Root>
</>
),
} satisfies Partial<MenuProps>;
}, [
legacyProperties.workspaceId,
@@ -0,0 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const checkboxProperty = style({
fontSize: cssVar('fontH5'),
});
@@ -0,0 +1,25 @@
import { Checkbox, PropertyValue } from '@affine/component';
import { useCallback } from 'react';
import * as styles from './checkbox.css';
import type { PropertyValueProps } from './types';
export const CheckboxValue = ({ value, onChange }: PropertyValueProps) => {
const parsedValue = value === 'true' ? true : false;
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange(parsedValue ? 'false' : 'true');
},
[onChange, parsedValue]
);
return (
<PropertyValue onClick={handleClick}>
<Checkbox
className={styles.checkboxProperty}
checked={parsedValue}
onChange={() => {}}
/>
</PropertyValue>
);
};
@@ -0,0 +1,73 @@
import type { I18nString } from '@affine/i18n';
import {
CheckBoxCheckLinearIcon,
CreatedEditedIcon,
DateTimeIcon,
NumberIcon,
TagIcon,
TextIcon,
} from '@blocksuite/icons/rc';
import { CheckboxValue } from './checkbox';
import { CreatedByValue, UpdatedByValue } from './created-updated-by';
import { DateValue } from './date';
import { NumberValue } from './number';
import { TagsValue } from './tags';
import { TextValue } from './text';
import type { PropertyValueProps } from './types';
export const DocPropertyTypes = {
text: {
icon: TextIcon,
value: TextValue,
name: 'com.affine.page-properties.property.text',
},
number: {
icon: NumberIcon,
value: NumberValue,
name: 'com.affine.page-properties.property.number',
},
date: {
icon: DateTimeIcon,
value: DateValue,
name: 'com.affine.page-properties.property.date',
},
checkbox: {
icon: CheckBoxCheckLinearIcon,
value: CheckboxValue,
name: 'com.affine.page-properties.property.checkbox',
},
createdBy: {
icon: CreatedEditedIcon,
value: CreatedByValue,
name: 'com.affine.page-properties.property.createdBy',
},
updatedBy: {
icon: CreatedEditedIcon,
value: UpdatedByValue,
name: 'com.affine.page-properties.property.updatedBy',
},
tags: {
icon: TagIcon,
value: TagsValue,
name: 'com.affine.page-properties.property.tags',
uniqueId: 'tags',
renameable: false,
},
} as Record<
string,
{
icon: React.FC<React.SVGProps<SVGSVGElement>>;
value?: React.FC<PropertyValueProps>;
/**
* set a unique id for property type, make the property type can only be created once.
*/
uniqueId?: string;
name: I18nString;
renameable?: boolean;
}
>;
export const isSupportedDocPropertyType = (type?: string): boolean => {
return type ? type in DocPropertyTypes : false;
};
@@ -0,0 +1,110 @@
import { Avatar, PropertyValue } from '@affine/component';
import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
const CloudUserAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy' }) => {
const cloudDocMetaService = useService(CloudDocMetaService);
const cloudDocMeta = useLiveData(cloudDocMetaService.cloudDocMeta.meta$);
const isRevalidating = useLiveData(
cloudDocMetaService.cloudDocMeta.isRevalidating$
);
const error = useLiveData(cloudDocMetaService.cloudDocMeta.error$);
useEffect(() => {
cloudDocMetaService.cloudDocMeta.revalidate();
}, [cloudDocMetaService]);
const user = useMemo(() => {
if (!cloudDocMeta) return null;
if (props.type === 'CreatedBy' && cloudDocMeta.createdBy) {
return {
name: cloudDocMeta.createdBy.name,
avatarUrl: cloudDocMeta.createdBy.avatarUrl,
};
} else if (props.type === 'UpdatedBy' && cloudDocMeta.updatedBy) {
return {
name: cloudDocMeta.updatedBy.name,
avatarUrl: cloudDocMeta.updatedBy.avatarUrl,
};
}
return null;
}, [cloudDocMeta, props.type]);
if (!cloudDocMeta) {
if (isRevalidating) {
// TODO: loading ui
return null;
}
if (error) {
// error ui
return;
}
return null;
}
if (user) {
return (
<>
<Avatar url={user.avatarUrl || ''} name={user.name} size={20} />
<span>{user.name}</span>
</>
);
}
return <NoRecordValue />;
};
const NoRecordValue = () => {
const t = useI18n();
return (
<span>
{t['com.affine.page-properties.property-user-avatar-no-record']()}
</span>
);
};
const LocalUserValue = () => {
const t = useI18n();
return <span>{t['com.affine.page-properties.local-user']()}</span>;
};
export const CreatedByValue = () => {
const workspaceService = useService(WorkspaceService);
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
if (!isCloud) {
return (
<PropertyValue>
<LocalUserValue />
</PropertyValue>
);
}
return (
<PropertyValue>
<CloudUserAvatar type="CreatedBy" />
</PropertyValue>
);
};
export const UpdatedByValue = () => {
const workspaceService = useService(WorkspaceService);
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
if (!isCloud) {
return (
<PropertyValue>
<LocalUserValue />
</PropertyValue>
);
}
return (
<PropertyValue>
<CloudUserAvatar type="UpdatedBy" />
</PropertyValue>
);
};
@@ -0,0 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const empty = style({
color: cssVar('placeholderColor'),
});
@@ -0,0 +1,29 @@
import { DatePicker, Menu, PropertyValue } from '@affine/component';
import { i18nTime, useI18n } from '@affine/i18n';
import * as styles from './date.css';
import type { PropertyValueProps } from './types';
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
const parsedValue =
typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}$/)
? value
: undefined;
const displayValue = parsedValue
? i18nTime(parsedValue, { absolute: { accuracy: 'day' } })
: undefined;
const t = useI18n();
return (
<Menu items={<DatePicker value={parsedValue} onChange={onChange} />}>
<PropertyValue
className={parsedValue ? '' : styles.empty}
isEmpty={!parsedValue}
>
{displayValue ??
t['com.affine.page-properties.property-value-placeholder']()}
</PropertyValue>
</Menu>
);
};
@@ -0,0 +1,25 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const numberPropertyValueInput = style({
border: `1px solid transparent`,
padding: `6px`,
paddingLeft: '5px',
width: '100%',
height: '100%',
borderRadius: '4px',
fontSize: cssVar('fontSm'),
':focus': {
border: `1px solid ${cssVar('blue700')}`,
boxShadow: cssVar('activeShadow'),
},
selectors: {
'&::placeholder': {
color: cssVar('placeholderColor'),
},
},
});
export const numberPropertyValueContainer = style({
padding: '0px',
});
@@ -0,0 +1,50 @@
import { PropertyValue } from '@affine/component';
import { useI18n } from '@affine/i18n';
import {
type ChangeEventHandler,
useCallback,
useEffect,
useState,
} from 'react';
import * as styles from './number.css';
import type { PropertyValueProps } from './types';
export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
const parsedValue = isNaN(Number(value)) ? null : value;
const [tempValue, setTempValue] = useState(parsedValue);
const handleBlur = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value.trim());
},
[onChange]
);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useCallback(
e => {
setTempValue(e.target.value.trim());
},
[]
);
const t = useI18n();
useEffect(() => {
setTempValue(parsedValue);
}, [parsedValue]);
return (
<PropertyValue
className={styles.numberPropertyValueContainer}
isEmpty={!parsedValue}
>
<input
className={styles.numberPropertyValueInput}
type={'number'}
value={tempValue || ''}
onChange={handleOnChange}
onBlur={handleBlur}
data-empty={!tempValue}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
/>
</PropertyValue>
);
};
@@ -0,0 +1,10 @@
import { style } from '@vanilla-extract/css';
export const tagInlineEditor = style({
width: '100%',
padding: `6px`,
});
export const container = style({
padding: `0px`,
});
@@ -0,0 +1,33 @@
import { PropertyValue } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { DocService, useLiveData, useService } from '@toeverything/infra';
import { TagsInlineEditor } from '../tags-inline-editor';
import * as styles from './tags.css';
export const TagsValue = () => {
const t = useI18n();
const doc = useService(DocService).doc;
const tagList = useService(TagService).tagList;
const tagIds = useLiveData(tagList.tagIdsByPageId$(doc.id));
const empty = !tagIds || tagIds.length === 0;
return (
<PropertyValue
className={styles.container}
isEmpty={empty}
data-testid="property-tags-value"
>
<TagsInlineEditor
className={styles.tagInlineEditor}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
pageId={doc.id}
/>
</PropertyValue>
);
};
@@ -0,0 +1,46 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const textarea = style({
border: 'none',
height: '100%',
width: '100%',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
whiteSpace: 'break-spaces',
wordBreak: 'break-word',
padding: `6px`,
paddingLeft: '5px',
overflow: 'hidden',
fontSize: cssVar('fontSm'),
lineHeight: '22px',
selectors: {
'&::placeholder': {
color: cssVar('placeholderColor'),
},
},
});
export const textPropertyValueContainer = style({
outline: `1px solid transparent`,
padding: `6px`,
':focus-within': {
outline: `1px solid ${cssVar('blue700')}`,
boxShadow: cssVar('activeShadow'),
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
});
export const textInvisible = style({
border: 'none',
whiteSpace: 'break-spaces',
wordBreak: 'break-word',
overflow: 'hidden',
visibility: 'hidden',
fontSize: cssVar('fontSm'),
lineHeight: '22px',
});
@@ -0,0 +1,70 @@
import { PropertyValue } from '@affine/component';
import { useI18n } from '@affine/i18n';
import {
type ChangeEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import * as styles from './text.css';
import type { PropertyValueProps } from './types';
export const TextValue = ({ value, onChange }: PropertyValueProps) => {
const [tempValue, setTempValue] = useState<string>(value);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
const ref = useRef<HTMLTextAreaElement>(null);
const handleBlur = useCallback(
(e: FocusEvent) => {
onChange((e.currentTarget as HTMLTextAreaElement).value.trim());
},
[onChange]
);
// use native blur event to get event after unmount
// don't use useLayoutEffect here, cause the cleanup function will be called before unmount
useEffect(() => {
ref.current?.addEventListener('blur', handleBlur);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
ref.current?.removeEventListener('blur', handleBlur);
};
}, [handleBlur]);
const handleOnChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
e => {
setTempValue(e.target.value);
},
[]
);
const t = useI18n();
useEffect(() => {
setTempValue(value);
}, [value]);
return (
<PropertyValue
className={styles.textPropertyValueContainer}
onClick={handleClick}
isEmpty={!value}
>
<textarea
ref={ref}
className={styles.textarea}
value={tempValue || ''}
onChange={handleOnChange}
onClick={handleClick}
data-empty={!tempValue}
autoFocus={false}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
/>
<div className={styles.textInvisible}>
{tempValue}
{tempValue?.endsWith('\n') || !tempValue ? <br /> : null}
</div>
</PropertyValue>
);
};
@@ -0,0 +1,7 @@
import type { DocCustomPropertyInfo } from '@toeverything/infra';
export interface PropertyValueProps {
propertyInfo: DocCustomPropertyInfo;
value: any;
onChange: (value: any) => void;
}
@@ -1,413 +1,31 @@
import { Button, IconButton, Menu } from '@affine/component';
import { Button, Menu } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
import { Trans, useI18n } from '@affine/i18n';
import {
DeleteIcon,
FilterIcon,
MoreHorizontalIcon,
} from '@blocksuite/icons/rc';
import { FrameworkScope, type WorkspaceMetadata } from '@toeverything/infra';
import type { MouseEvent } from 'react';
import {
createContext,
Fragment,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useCurrentWorkspacePropertiesAdapter } from '../../../../../components/hooks/use-affine-adapter';
import { useWorkspace } from '../../../../../components/hooks/use-workspace';
import type { PagePropertyIcon } from '../../../page-properties';
import {
nameToIcon,
PagePropertiesCreatePropertyMenuItems,
PagePropertiesMetaManager,
} from '../../../page-properties';
import { ConfirmDeletePropertyModal } from '../../../page-properties/confirm-delete-property-modal';
import type { MenuItemOption } from '../../../page-properties/menu-items';
import {
EditPropertyNameMenuItem,
PropertyTypeMenuItem,
renderMenuItemOptions,
} from '../../../page-properties/menu-items';
import { DocPropertyManager } from '../../../page-properties/manager';
import { CreatePropertyMenuItems } from '../../../page-properties/menu/create-doc-property';
import * as styles from './styles.css';
// @ts-expect-error this should always be set
const managerContext = createContext<PagePropertiesMetaManager>();
const usePagePropertiesMetaManager = () => {
// the workspace properties adapter adapter is reactive,
// which means it's reference will change when any of the properties change
// also it will trigger a re-render of the component
const adapter = useCurrentWorkspacePropertiesAdapter();
const manager = useMemo(() => {
return new PagePropertiesMetaManager(adapter);
}, [adapter]);
return manager;
};
const Divider = () => {
return <div className={styles.divider} />;
};
const EditPropertyButton = ({
property,
}: {
property: PageInfoCustomPropertyMeta;
}) => {
const t = useI18n();
const manager = useContext(managerContext);
const [localPropertyMeta, setLocalPropertyMeta] = useState(() => ({
...property,
}));
useEffect(() => {
setLocalPropertyMeta(property);
}, [property]);
const handleToggleRequired = useCallback(() => {
manager.updatePropertyMeta(localPropertyMeta.id, {
required: !localPropertyMeta.required,
});
}, [manager, localPropertyMeta.id, localPropertyMeta.required]);
const handleDelete = useCallback(() => {
manager.removePropertyMeta(localPropertyMeta.id);
}, [manager, localPropertyMeta.id]);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleFinishEditing = useCallback(() => {
setOpen(false);
setEditing(false);
manager.updatePropertyMeta(localPropertyMeta.id, localPropertyMeta);
}, [localPropertyMeta, manager]);
const defaultMenuItems = useMemo(() => {
const options: MenuItemOption[] = [];
options.push({
text: t['com.affine.settings.workspace.properties.set-as-required'](),
onClick: handleToggleRequired,
checked: localPropertyMeta.required,
});
options.push('-');
options.push({
text: t['com.affine.settings.workspace.properties.edit-property'](),
onClick: e => {
e.preventDefault();
setEditing(true);
},
});
options.push({
text: t['com.affine.settings.workspace.properties.delete-property'](),
onClick: () => setShowDeleteModal(true),
type: 'danger',
icon: <DeleteIcon />,
});
return renderMenuItemOptions(options);
}, [handleToggleRequired, localPropertyMeta.required, t]);
const handleNameBlur = useCallback(
(e: string) => {
manager.updatePropertyMeta(localPropertyMeta.id, {
name: e,
});
},
[manager, localPropertyMeta.id]
);
const handleNameChange = useCallback((e: string) => {
setLocalPropertyMeta(prev => ({
...prev,
name: e,
}));
}, []);
const handleIconChange = useCallback(
(icon: PagePropertyIcon) => {
setLocalPropertyMeta(prev => ({
...prev,
icon,
}));
manager.updatePropertyMeta(localPropertyMeta.id, {
icon,
});
},
[localPropertyMeta.id, manager]
);
const editMenuItems = useMemo(() => {
const options: MenuItemOption[] = [];
options.push(
<EditPropertyNameMenuItem
property={localPropertyMeta}
onIconChange={handleIconChange}
onNameBlur={handleNameBlur}
onNameChange={handleNameChange}
/>
);
options.push(<PropertyTypeMenuItem property={localPropertyMeta} />);
options.push('-');
options.push({
text: t['com.affine.settings.workspace.properties.delete-property'](),
onClick: handleDelete,
type: 'danger',
icon: <DeleteIcon />,
});
return renderMenuItemOptions(options);
}, [
handleDelete,
handleIconChange,
handleNameBlur,
handleNameChange,
localPropertyMeta,
t,
]);
return (
<>
<Menu
rootOptions={{
open,
onOpenChange: handleFinishEditing,
}}
items={editing ? editMenuItems : defaultMenuItems}
contentOptions={{
align: 'end',
sideOffset: 4,
}}
>
<IconButton onClick={() => setOpen(true)} size="20">
<MoreHorizontalIcon />
</IconButton>
</Menu>
<ConfirmDeletePropertyModal
onConfirm={() => {
setShowDeleteModal(false);
handleDelete();
}}
onCancel={() => setShowDeleteModal(false)}
show={showDeleteModal}
property={property}
/>
</>
);
};
const CustomPropertyRow = ({
property,
relatedPages,
}: {
relatedPages: string[];
property: PageInfoCustomPropertyMeta;
}) => {
const Icon = nameToIcon(property.icon, property.type);
const required = property.required;
const t = useI18n();
return (
<div
className={styles.propertyRow}
data-property-id={property.id}
data-testid="custom-property-row"
>
<Icon className={styles.propertyIcon} />
<div data-unnamed={!property.name} className={styles.propertyName}>
{property.name || t['unnamed']()}
</div>
{relatedPages.length > 0 ? (
<div className={styles.propertyDocCount}>
·{' '}
<Trans
i18nKey={
relatedPages.length > 1
? 'com.affine.settings.workspace.properties.doc_others'
: 'com.affine.settings.workspace.properties.doc'
}
count={relatedPages.length}
>
<span>{{ count: relatedPages.length } as any}</span> doc
</Trans>
</div>
) : null}
<div className={styles.spacer} />
{required ? (
<div className={styles.propertyRequired}>
{t['com.affine.page-properties.property.required']()}
</div>
) : null}
<EditPropertyButton property={property} />
</div>
);
};
const propertyFilterModes = ['all', 'in-use', 'unused'] as const;
type PropertyFilterMode = (typeof propertyFilterModes)[number];
const CustomPropertyRows = ({
properties,
statistics,
}: {
properties: PageInfoCustomPropertyMeta[];
statistics: Map<string, Set<string>>;
}) => {
return (
<div className={styles.metaList}>
{properties.map(property => {
const pages = [...(statistics.get(property.id) ?? [])];
return (
<Fragment key={property.id}>
<CustomPropertyRow property={property} relatedPages={pages} />
<Divider />
</Fragment>
);
})}
</div>
);
};
const CustomPropertyRowsList = ({
filterMode,
}: {
filterMode: PropertyFilterMode;
}) => {
const manager = useContext(managerContext);
const properties = manager.getOrderedPropertiesSchema();
const statistics = manager.getPropertyStatistics();
const t = useI18n();
if (filterMode !== 'all') {
const filtered = properties.filter(property => {
const count = statistics.get(property.id)?.size ?? 0;
return filterMode === 'in-use' ? count > 0 : count === 0;
});
return <CustomPropertyRows properties={filtered} statistics={statistics} />;
} else {
const partition = Object.groupBy(properties, p =>
p.required ? 'required' : p.readonly ? 'readonly' : 'optional'
);
return (
<>
{partition.required && partition.required.length > 0 ? (
<>
<div className={styles.subListHeader}>
{t[
'com.affine.settings.workspace.properties.required-properties'
]()}
</div>
<CustomPropertyRows
properties={partition.required}
statistics={statistics}
/>
</>
) : null}
{partition.optional && partition.optional.length > 0 ? (
<>
<div className={styles.subListHeader}>
{t[
'com.affine.settings.workspace.properties.general-properties'
]()}
</div>
<CustomPropertyRows
properties={partition.optional}
statistics={statistics}
/>
</>
) : null}
{partition.readonly && partition.readonly.length > 0 ? (
<>
<div className={styles.subListHeader}>
{t[
'com.affine.settings.workspace.properties.readonly-properties'
]()}
</div>
<CustomPropertyRows
properties={partition.readonly}
statistics={statistics}
/>
</>
) : null}
</>
);
}
};
const WorkspaceSettingPropertiesMain = () => {
const t = useI18n();
const manager = useContext(managerContext);
const [filterMode, setFilterMode] = useState<PropertyFilterMode>('all');
const properties = manager.getOrderedPropertiesSchema();
const filterMenuItems = useMemo(() => {
const options: MenuItemOption[] = (
['all', '-', 'in-use', 'unused'] as const
).map(mode => {
return mode === '-'
? '-'
: {
text: t[`com.affine.settings.workspace.properties.${mode}`](),
onClick: () => setFilterMode(mode),
checked: filterMode === mode,
};
});
return renderMenuItemOptions(options);
}, [filterMode, t]);
const onPropertyCreated = useCallback((_e: MouseEvent, id: string) => {
setTimeout(() => {
const newRow = document.querySelector<HTMLDivElement>(
`[data-testid="custom-property-row"][data-property-id="${id}"]`
);
if (newRow) {
newRow.scrollIntoView({ behavior: 'smooth' });
newRow.dataset.highlight = '';
setTimeout(() => {
delete newRow.dataset.highlight;
}, 3000);
}
});
}, []);
return (
<div className={styles.main}>
<div className={styles.listHeader}>
{properties.length > 0 ? (
<Menu items={filterMenuItems}>
<Button prefix={<FilterIcon />}>
{filterMode === 'all'
? t['com.affine.filter']()
: t[`com.affine.settings.workspace.properties.${filterMode}`]()}
</Button>
</Menu>
) : null}
<Menu
items={
<PagePropertiesCreatePropertyMenuItems
onCreated={onPropertyCreated}
metaManager={manager}
/>
}
>
<Menu items={<CreatePropertyMenuItems />}>
<Button variant="primary">
{t['com.affine.settings.workspace.properties.add_property']()}
</Button>
</Menu>
</div>
<CustomPropertyRowsList filterMode={filterMode} />
<DocPropertyManager />
</div>
);
};
const WorkspaceSettingPropertiesInner = () => {
const manager = usePagePropertiesMetaManager();
return (
<managerContext.Provider value={manager}>
<WorkspaceSettingPropertiesMain />
</managerContext.Provider>
);
};
export const WorkspaceSettingProperties = ({
workspaceMetadata,
}: {
@@ -437,7 +55,7 @@ export const WorkspaceSettingProperties = ({
</Trans>
}
/>
<WorkspaceSettingPropertiesInner />
<WorkspaceSettingPropertiesMain />
</FrameworkScope>
);
};
@@ -11,7 +11,7 @@ export const main = style({
export const listHeader = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
justifyItems: 'flex-end',
alignItems: 'center',
padding: '6px 0',
marginBottom: 16,
@@ -34,7 +34,7 @@ import React, {
useRef,
} from 'react';
import { PagePropertiesTable } from '../../affine/page-properties';
import { DocPropertiesTable } from '../../affine/page-properties';
import {
AffinePageReference,
AffineSharedPageReference,
@@ -232,7 +232,7 @@ export const BlocksuiteDocEditor = forwardRef<
) : (
<BlocksuiteEditorJournalDocTitle page={page} />
)}
{!shared ? <PagePropertiesTable docId={page.id} /> : null}
{!shared ? <DocPropertiesTable /> : null}
<adapted.DocEditor
className={styles.docContainer}
ref={onDocRef}