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
View File
@@ -17,6 +17,7 @@
"@blocksuite/affine": "0.17.18",
"@datastructures-js/binary-search-tree": "^5.3.2",
"foxact": "^0.2.33",
"fractional-indexing": "^3.2.0",
"fuse.js": "^7.0.0",
"graphemer": "^1.4.0",
"idb": "^8.0.0",
@@ -4,7 +4,7 @@ import { WorkspaceDB } from './entities/db';
import { WorkspaceDBTable } from './entities/table';
import { WorkspaceDBService } from './services/db';
export type { DocProperties } from './schema';
export type { DocCustomPropertyInfo, DocProperties } from './schema';
export { WorkspaceDBService } from './services/db';
export { transformWorkspaceDBLocalToCloud } from './services/db';
@@ -1,4 +1,4 @@
export type { DocProperties } from './schema';
export type { DocCustomPropertyInfo, DocProperties } from './schema';
export {
AFFiNE_WORKSPACE_DB_SCHEMA,
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
@@ -23,6 +23,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
type: f.string(),
show: f.string().optional(),
index: f.string().optional(),
icon: f.string().optional(),
additionalData: f.json().optional(),
isDeleted: f.boolean().optional(),
// we will keep deleted properties in the database, for override legacy data
@@ -0,0 +1,11 @@
import type { DocCustomPropertyInfo } from '../db';
/**
* default built-in custom property, user can update and delete them
*/
export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
{
id: 'tags',
type: 'tags',
},
] as DocCustomPropertyInfo[];
@@ -34,6 +34,14 @@ export class Doc extends Entity {
readonly title$ = this.record.title$;
readonly trash$ = this.record.trash$;
customProperty$(propertyId: string) {
return this.record.customProperty$(propertyId);
}
setCustomProperty(propertyId: string, value: string) {
return this.record.setCustomProperty(propertyId, value);
}
setPrimaryMode(mode: DocMode) {
return this.record.setPrimaryMode(mode);
}
@@ -1,5 +1,6 @@
import { Entity } from '../../../framework';
import { LiveData } from '../../../livedata';
import { generateFractionalIndexingKeyBetween } from '../../../utils';
import type { DocCustomPropertyInfo } from '../../db/schema/schema';
import type { DocPropertiesStore } from '../stores/doc-properties';
@@ -13,15 +14,61 @@ export class DocPropertyList extends Entity {
[]
);
sortedProperties$ = this.properties$.map(list =>
// default index key is '', so always before any others
list.toSorted((a, b) => ((a.index ?? '') > (b.index ?? '') ? 1 : -1))
);
propertyInfo$(id: string) {
return this.properties$.map(list => list.find(info => info.id === id));
}
updatePropertyInfo(id: string, properties: Partial<DocCustomPropertyInfo>) {
this.docPropertiesStore.updateDocPropertyInfo(id, properties);
}
createProperty(properties: DocCustomPropertyInfo) {
createProperty(
properties: Omit<DocCustomPropertyInfo, 'id'> & { id?: string }
) {
return this.docPropertiesStore.createDocPropertyInfo(properties);
}
removeProperty(id: string) {
this.docPropertiesStore.removeDocPropertyInfo(id);
}
indexAt(at: 'before' | 'after', targetId?: string) {
const sortedChildren = this.sortedProperties$.value.filter(
node => node.index
) as (DocCustomPropertyInfo & { index: string })[];
const targetIndex = targetId
? sortedChildren.findIndex(node => node.id === targetId)
: -1;
if (targetIndex === -1) {
if (at === 'before') {
const first = sortedChildren.at(0);
return generateFractionalIndexingKeyBetween(null, first?.index ?? null);
} else {
const last = sortedChildren.at(-1);
return generateFractionalIndexingKeyBetween(last?.index ?? null, null);
}
} else {
const target = sortedChildren[targetIndex];
const before: DocCustomPropertyInfo | null =
sortedChildren[targetIndex - 1] || null;
const after: DocCustomPropertyInfo | null =
sortedChildren[targetIndex + 1] || null;
if (at === 'before') {
return generateFractionalIndexingKeyBetween(
before?.index ?? null,
target.index
);
} else {
return generateFractionalIndexingKeyBetween(
target.index,
after?.index ?? null
);
}
}
}
}
@@ -31,8 +31,16 @@ export class DocRecord extends Entity<{ id: string }> {
{ id: this.id }
);
setProperties(properties: Partial<DocProperties>): void {
this.docPropertiesStore.updateDocProperties(this.id, properties);
customProperty$(propertyId: string) {
return this.properties$.selector(
p => p['custom:' + propertyId]
) as LiveData<string | undefined | null>;
}
setCustomProperty(propertyId: string, value: string) {
this.docPropertiesStore.updateDocProperties(this.id, {
['custom:' + propertyId]: value,
});
}
setMeta(meta: Partial<DocMeta>): void {
@@ -13,6 +13,7 @@ import type {
DocProperties,
} from '../../db/schema/schema';
import type { WorkspaceService } from '../../workspace';
import { BUILT_IN_CUSTOM_PROPERTY_TYPE } from '../constants';
interface LegacyDocProperties {
custom?: Record<string, { value: unknown } | undefined>;
@@ -23,6 +24,7 @@ type LegacyDocPropertyInfo = {
id?: string;
name?: string;
type?: string;
icon?: string;
};
type LegacyDocPropertyInfoList = Record<
@@ -50,11 +52,18 @@ export class DocPropertiesStore extends Store {
const legacy = this.upgradeLegacyDocPropertyInfoList(
this.getLegacyDocPropertyInfoList()
);
const notOverridden = differenceBy(legacy, db, i => i.id);
return [...db, ...notOverridden].filter(i => !i.isDeleted);
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)];
const all = [
...withLegacy,
...differenceBy(builtIn, withLegacy, i => i.id),
];
return all.filter(i => !i.isDeleted);
}
createDocPropertyInfo(config: DocCustomPropertyInfo) {
createDocPropertyInfo(
config: Omit<DocCustomPropertyInfo, 'id'> & { id?: string }
) {
return this.dbService.db.docCustomPropertyInfo.create(config).id;
}
@@ -67,7 +76,11 @@ export class DocPropertiesStore extends Store {
updateDocPropertyInfo(id: string, config: Partial<DocCustomPropertyInfo>) {
const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id);
if (needMigration) {
const isBuiltIn =
needMigration && BUILT_IN_CUSTOM_PROPERTY_TYPE.some(i => i.id === id);
if (isBuiltIn) {
this.createPropertyFromBuiltIn(id, config);
} else if (needMigration) {
// if this property is not in db, we need to migration it from legacy to db, only type and name is needed
this.migrateLegacyDocPropertyInfo(id, config);
} else {
@@ -90,16 +103,32 @@ export class DocPropertiesStore extends Store {
});
}
createPropertyFromBuiltIn(
id: string,
override: Partial<DocCustomPropertyInfo>
) {
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE.find(i => i.id === id);
if (!builtIn) {
return;
}
this.createDocPropertyInfo({ ...builtIn, ...override });
}
watchDocPropertyInfoList() {
return combineLatest([
this.watchLegacyDocPropertyInfoList().pipe(
map(this.upgradeLegacyDocPropertyInfoList)
),
this.dbService.db.docCustomPropertyInfo.find$({}),
this.dbService.db.docCustomPropertyInfo.find$(),
]).pipe(
map(([legacy, db]) => {
const notOverridden = differenceBy(legacy, db, i => i.id);
return [...db, ...notOverridden].filter(i => !i.isDeleted);
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)];
const all = [
...withLegacy,
...differenceBy(builtIn, withLegacy, i => i.id),
];
return all.filter(i => !i.isDeleted);
})
);
}
@@ -133,15 +162,15 @@ export class DocPropertiesStore extends Store {
if (!properties) {
return {};
}
const newProperties: Record<string, unknown> = {};
const newProperties: Record<string, string> = {};
for (const [key, info] of Object.entries(properties.system ?? {})) {
if (info?.value !== undefined) {
newProperties[key] = info.value;
if (info?.value !== undefined && info.value !== null) {
newProperties[key] = info.value.toString();
}
}
for (const [key, info] of Object.entries(properties.custom ?? {})) {
if (info?.value !== undefined) {
newProperties['custom:' + key] = info.value;
if (info?.value !== undefined && info.value !== null) {
newProperties['custom:' + key] = info.value.toString();
}
}
return newProperties;
@@ -162,6 +191,7 @@ export class DocPropertiesStore extends Store {
id,
name: info.name,
type: info.type,
icon: info.icon,
});
}
}
@@ -6,7 +6,7 @@ import type { DataValidator } from './types';
function inputType(val: any) {
return val === null ||
Array.isArray(val) ||
val.constructor === 'Object' ||
val.constructor === Object ||
!val.constructor /* Object.create(null) */
? 'json'
: typeof val;
+1
View File
@@ -1,6 +1,7 @@
export * from './async-lock';
export * from './async-queue';
export * from './exhaustmap-with-trailing';
export * from './fractional-indexing';
export * from './merge-updates';
export * from './object-pool';
export * from './stable-hash';
+1
View File
@@ -20,6 +20,7 @@ export * from './ui/menu';
export * from './ui/modal';
export * from './ui/notification';
export * from './ui/popover';
export * from './ui/property';
export * from './ui/radio';
export * from './ui/safe-area';
export * from './ui/scrollbar';
@@ -182,6 +182,11 @@ export const useDraggable = <D extends DNDData = DNDData>(
let previewPosition: DraggableDragPreviewPosition =
options.dragPreviewPosition ?? 'native';
source.element.dataset['dragPreview'] = 'true';
requestAnimationFrame(() => {
delete source.element.dataset['dragPreview'];
});
if (enableCustomDragPreview.current) {
setCustomNativeDragPreview({
getOffset: (...args) => {
@@ -49,6 +49,12 @@ export const treeLine = style({
height: 2,
right: 0,
},
selectors: {
['&[data-no-terminal="true"]::before']: {
display: 'none',
},
},
});
export const lineAboveStyles = style({
@@ -143,7 +149,7 @@ export const left = style({
export const edgeLine = style({
vars: {
[terminalSize]: '8px',
[terminalSize]: '6px',
},
display: 'block',
position: 'absolute',
@@ -156,11 +162,17 @@ export const edgeLine = style({
// Terminal
'::before': {
content: '""',
width: terminalSize,
height: terminalSize,
width: 0,
height: 0,
boxSizing: 'border-box',
position: 'absolute',
border: `${terminalSize} solid ${cssVar('--affine-primary-color')}`,
borderRadius: '50%',
},
selectors: {
['&[data-no-terminal="true"]::before']: {
display: 'none',
},
},
});
@@ -10,14 +10,17 @@ import * as styles from './drop-indicator.css';
export type DropIndicatorProps = {
instruction?: Instruction | null;
edge?: Edge | null;
noTerminal?: boolean;
};
function getTreeElement({
instruction,
isBlocked,
noTerminal,
}: {
instruction: Exclude<Instruction, { type: 'instruction-blocked' }>;
isBlocked: boolean;
noTerminal?: boolean;
}): ReactElement | null {
const style = {
[styles.horizontalIndent]: `${instruction.currentLevel * instruction.indentPerLevel}px`,
@@ -31,6 +34,7 @@ function getTreeElement({
<div
className={clsx(styles.treeLine, styles.lineAboveStyles)}
style={assignInlineVars(style)}
data-no-terminal={noTerminal}
/>
);
}
@@ -39,6 +43,7 @@ function getTreeElement({
<div
className={clsx(styles.treeLine, styles.lineBelowStyles)}
style={assignInlineVars(style)}
data-no-terminal={noTerminal}
/>
);
}
@@ -48,6 +53,7 @@ function getTreeElement({
<div
className={clsx(styles.outlineStyles)}
style={assignInlineVars(style)}
data-no-terminal={noTerminal}
/>
);
}
@@ -61,6 +67,7 @@ function getTreeElement({
<div
className={clsx(styles.treeLine, styles.lineBelowStyles)}
style={assignInlineVars(style)}
data-no-terminal={noTerminal}
/>
);
}
@@ -88,13 +95,14 @@ const edgeStyles: Record<Edge, string> = {
right: styles.right,
};
function getEdgeElement(edge: Edge, gap: number = 0) {
function getEdgeElement(edge: Edge, gap: number = 0, noTerminal?: boolean) {
const lineOffset = `calc(-0.5 * (${gap}px + 2px))`;
const orientation = edgeToOrientationMap[edge];
return (
<div
data-no-terminal={noTerminal}
className={clsx([
styles.edgeLine,
orientationStyles[orientation],
@@ -105,9 +113,13 @@ function getEdgeElement(edge: Edge, gap: number = 0) {
);
}
export function DropIndicator({ instruction, edge }: DropIndicatorProps) {
export function DropIndicator({
instruction,
edge,
noTerminal,
}: DropIndicatorProps) {
if (edge) {
return getEdgeElement(edge, 0);
return getEdgeElement(edge, 0, noTerminal);
}
if (instruction) {
if (instruction.type === 'instruction-blocked') {
@@ -0,0 +1 @@
export * from './property';
@@ -0,0 +1,163 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const propertyRoot = style({
display: 'flex',
minHeight: 32,
position: 'relative',
padding: '2px 0px 2px 2px',
selectors: {
'&[draggable="true"]': {
cursor: 'grab',
},
'&[draggable="true"][data-dragging="true"]': {
visibility: 'hidden',
},
'&[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 hide = style({
// Visually hide the property while maintaining its position in the layout.
// This ensures that any open menu remains in the same position when the property is hidden.
overflow: 'hidden',
height: '0px',
minHeight: '0px',
padding: '0px',
visibility: 'hidden',
pointerEvents: 'none',
});
export const propertyNameContainer = style({
display: 'flex',
flexDirection: 'column',
position: 'relative',
borderRadius: 4,
fontSize: cssVar('fontSm'),
padding: `6px 6px 6px 4px`,
flexShrink: 0,
lineHeight: '22px',
userSelect: 'none',
color: cssVarV2('text/secondary'),
width: '160px',
selectors: {
'&[data-has-menu="true"]': {
cursor: 'pointer',
},
'&[data-has-menu="true"]:hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
},
});
globalStyle(`[data-drag-preview] ${propertyNameContainer}:hover`, {
backgroundColor: 'transparent',
});
export const propertyNameInnerContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
});
export const propertyIconContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
fontSize: 16,
color: cssVarV2('icon/secondary'),
});
export const propertyNameContent = style({
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: cssVar('fontSm'),
});
export const propertyValueContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
position: 'relative',
borderRadius: 4,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
padding: `6px`,
flex: 1,
':focus-visible': {
outline: 'none',
},
'::placeholder': {
color: cssVarV2('text/placeholder'),
},
selectors: {
'&[data-readonly="false"]': {
cursor: 'pointer',
},
'&[data-readonly="false"]:hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
'&[data-readonly="false"]:focus-within': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
},
});
export const tableButton = 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(`${tableButton} svg`, {
fontSize: 16,
color: cssVarV2('icon/secondary'),
});
globalStyle(`${tableButton}:hover svg`, {
color: cssVarV2('icon/primary'),
});
@@ -0,0 +1,144 @@
import { FrameIcon } from '@blocksuite/icons/rc';
import { useDraggable, useDropTarget } from '../dnd';
import { MenuItem } from '../menu';
import {
PropertyCollapsible,
PropertyName,
PropertyRoot,
PropertyValue,
} from './property';
export default {
title: 'UI/Property',
};
export const SingleProperty = () => {
return (
<>
<PropertyRoot>
<PropertyName name="Name" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot>
<PropertyName name="Long nameeeeeeeeeeeeeeeee" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot>
<PropertyName
name="With Menu"
icon={<FrameIcon />}
menuItems={<MenuItem>Menu</MenuItem>}
/>
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot>
<PropertyName name="Readonly" icon={<FrameIcon />} />
<PropertyValue readonly>Readonly Value</PropertyValue>
</PropertyRoot>
</>
);
};
export const DNDProperty = () => {
const { dragRef: dragRef1 } = useDraggable(
() => ({
data: { text: 'hello' },
}),
[]
);
const { dragRef: dragRef2 } = useDraggable(
() => ({
data: { text: 'hello' },
}),
[]
);
const { dropTargetRef, closestEdge } = useDropTarget(
() => ({
closestEdge: {
allowedEdges: ['top', 'bottom'],
},
}),
[]
);
return (
<>
<PropertyRoot ref={dragRef1}>
<PropertyName name="Draggable" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot ref={dragRef2}>
<PropertyName
name="Draggable Menu"
icon={<FrameIcon />}
menuItems={<MenuItem>Menu</MenuItem>}
/>
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot ref={dropTargetRef} dropIndicatorEdge={closestEdge}>
<PropertyName name="DropTarget" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
</>
);
};
export const HideEmptyProperty = () => {
return (
<>
<PropertyRoot hideEmpty>
<PropertyName name="Should not be hidden" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot hideEmpty>
<PropertyName name="Should be hidden" icon={<FrameIcon />} />
<PropertyValue isEmpty>Value</PropertyValue>
</PropertyRoot>
</>
);
};
export const BasicPropertyCollapsible = () => {
return (
<PropertyCollapsible collapsible>
<PropertyRoot>
<PropertyName name="Always show" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot hideEmpty>
<PropertyName name="Hide with empty" icon={<FrameIcon />} />
<PropertyValue isEmpty>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot hide>
<PropertyName name="Hide" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
</PropertyCollapsible>
);
};
export const PropertyCollapsibleCustomButton = () => {
return (
<PropertyCollapsible
collapsible
collapseButtonText={({ hide, isCollapsed }) =>
`${isCollapsed ? 'Show' : 'Hide'} ${hide} properties`
}
>
<PropertyRoot>
<PropertyName name="Always show" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot hideEmpty>
<PropertyName name="Hide with empty" icon={<FrameIcon />} />
<PropertyValue isEmpty>Value</PropertyValue>
</PropertyRoot>
<PropertyRoot hide>
<PropertyName name="Hide" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
</PropertyCollapsible>
);
};
@@ -0,0 +1,276 @@
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { ArrowDownSmallIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
createContext,
forwardRef,
type HTMLProps,
type PropsWithChildren,
type ReactNode,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { Button } from '../button';
import { DropIndicator } from '../dnd';
import { Menu } from '../menu';
import * as styles from './property.css';
const PropertyTableContext = createContext<{
mountProperty: (payload: { isHide: boolean }) => () => void;
showAllHide: boolean;
} | null>(null);
export const PropertyCollapsible = forwardRef<
HTMLDivElement,
PropsWithChildren<{
collapsible?: boolean;
defaultCollapsed?: boolean;
collapsed?: boolean;
onCollapseChange?: (collapsed: boolean) => void;
collapseButtonText?: (option: {
total: number;
hide: number;
isCollapsed: boolean;
}) => ReactNode;
}> &
HTMLProps<HTMLDivElement>
>(
(
{
children,
collapsible = true,
collapsed,
defaultCollapsed,
onCollapseChange,
collapseButtonText,
...props
},
ref
) => {
const [propertyCount, setPropertyCount] = useState({ total: 0, hide: 0 });
const [showAllHide, setShowAllHide] = useState(!defaultCollapsed);
const finalCollapsible = collapsible ? propertyCount.hide !== 0 : false;
const controlled = collapsed !== undefined;
const finalShowAllHide = finalCollapsible
? !controlled
? showAllHide
: !collapsed
: true;
const mountProperty = useCallback((payload: { isHide: boolean }) => {
setPropertyCount(prev => ({
total: prev.total + 1,
hide: prev.hide + (payload.isHide ? 1 : 0),
}));
return () => {
setPropertyCount(prev => ({
total: prev.total - 1,
hide: prev.hide - (payload.isHide ? 1 : 0),
}));
};
}, []);
const contextValue = useMemo(
() => ({ mountProperty, showAllHide: finalShowAllHide }),
[mountProperty, finalShowAllHide]
);
const handleShowAllHide = useCallback(() => {
setShowAllHide(!finalShowAllHide);
onCollapseChange?.(finalShowAllHide);
}, [finalShowAllHide, onCollapseChange]);
return (
<div
ref={ref}
data-property-collapsible={finalCollapsible}
data-property-collapsed={!finalShowAllHide}
{...props}
>
<PropertyTableContext.Provider value={contextValue}>
{children}
{finalCollapsible && (
<Button
variant="plain"
prefix={
!finalShowAllHide ? (
<ArrowDownSmallIcon />
) : (
<ArrowUpSmallIcon />
)
}
className={styles.tableButton}
onClick={handleShowAllHide}
data-testid="property-collapsible-button"
>
{collapseButtonText
? collapseButtonText({
total: propertyCount.total,
hide: propertyCount.hide,
isCollapsed: !finalShowAllHide,
})
: !finalShowAllHide
? 'Show All'
: 'Hide'}
</Button>
)}
</PropertyTableContext.Provider>
</div>
);
}
);
PropertyCollapsible.displayName = 'PropertyCollapsible';
const PropertyRootContext = createContext<{
mountValue: (payload: { isEmpty: boolean }) => () => void;
} | null>(null);
export const PropertyRoot = forwardRef<
HTMLDivElement,
{
dropIndicatorEdge?: Edge | null;
hideEmpty?: boolean;
hide?: boolean;
} & HTMLProps<HTMLDivElement>
>(
(
{ children, className, dropIndicatorEdge, hideEmpty, hide, ...props },
ref
) => {
const [isEmpty, setIsEmpty] = useState(false);
const context = useContext(PropertyTableContext);
const preferHide = hide || (hideEmpty && isEmpty);
const showAllHide = context?.showAllHide;
const shouldHide = preferHide && !showAllHide;
useLayoutEffect(() => {
if (context) {
return context.mountProperty({ isHide: !!preferHide });
}
return;
}, [context, preferHide]);
const contextValue = useMemo(
() => ({
mountValue: (payload: { isEmpty: boolean }) => {
setIsEmpty(payload.isEmpty);
return () => {
setIsEmpty(false);
};
},
}),
[setIsEmpty]
);
return (
<PropertyRootContext.Provider value={contextValue}>
<div
ref={ref}
className={clsx(
styles.propertyRoot,
shouldHide && styles.hide,
className
)}
{...props}
>
{children}
<DropIndicator edge={dropIndicatorEdge} />
</div>
</PropertyRootContext.Provider>
);
}
);
PropertyRoot.displayName = 'PropertyRoot';
export const PropertyName = ({
icon,
name,
className,
menuItems,
defaultOpenMenu,
...props
}: {
icon?: ReactNode;
name?: ReactNode;
menuItems?: ReactNode;
defaultOpenMenu?: boolean;
} & HTMLProps<HTMLDivElement>) => {
const [menuOpen, setMenuOpen] = useState(defaultOpenMenu);
const hasMenu = !!menuItems;
const handleClick = useCallback(() => {
if (!hasMenu) return;
setMenuOpen(true);
}, [hasMenu]);
const handleMenuClose = useCallback((open: boolean) => {
if (!open) {
setMenuOpen(false);
}
}, []);
const content = (
<div
className={clsx(styles.propertyNameContainer, className)}
data-has-menu={hasMenu}
onClick={handleClick}
{...props}
>
<div className={styles.propertyNameInnerContainer}>
{icon && <div className={styles.propertyIconContainer}>{icon}</div>}
<div className={styles.propertyNameContent}>{name}</div>
</div>
</div>
);
if (menuOpen && menuItems) {
// Do not mount <Menu /> when menuOpen is false, as <Menu /> will cause draggable to not work
return (
<Menu
items={menuItems}
rootOptions={{
open: true,
modal: true, // false will case bug
onOpenChange: handleMenuClose,
}}
>
{content}
</Menu>
);
}
return content;
};
export const PropertyValue = forwardRef<
HTMLDivElement,
{ readonly?: boolean; isEmpty?: boolean } & HTMLProps<HTMLDivElement>
>(({ children, className, readonly, isEmpty, ...props }, ref) => {
const context = useContext(PropertyRootContext);
useLayoutEffect(() => {
if (context) {
return context.mountValue({ isEmpty: !!isEmpty });
}
return;
}, [context, isEmpty]);
return (
<div
ref={ref}
className={clsx(styles.propertyValueContainer, className)}
data-readonly={readonly ? 'true' : 'false'}
data-empty={isEmpty ? 'true' : 'false'}
data-property-value
{...props}
>
{children}
</div>
);
});
PropertyValue.displayName = 'PropertyValue';
-1
View File
@@ -43,7 +43,6 @@
"dayjs": "^1.11.10",
"file-type": "^19.1.0",
"foxact": "^0.2.33",
"fractional-indexing": "^3.2.0",
"fuse.js": "^7.0.0",
"graphemer": "^1.4.0",
"graphql": "^16.8.1",
@@ -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}
@@ -3,6 +3,7 @@ import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { DocPropertySidebar } from '@affine/core/components/affine/page-properties/sidebar';
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
@@ -13,7 +14,13 @@ import { useI18n } from '@affine/i18n';
import { RefNodeSlotsProvider } from '@blocksuite/affine/blocks';
import { DisposableGroup } from '@blocksuite/affine/global/utils';
import { type AffineEditorContainer } from '@blocksuite/affine/presets';
import { AiIcon, FrameIcon, TocIcon, TodayIcon } from '@blocksuite/icons/rc';
import {
AiIcon,
FrameIcon,
PropertyIcon,
TocIcon,
TodayIcon,
} from '@blocksuite/icons/rc';
import {
DocService,
FeatureFlagService,
@@ -284,6 +291,10 @@ const DetailPageImpl = memo(function DetailPageImpl() {
</ViewSidebarTab>
)}
<ViewSidebarTab tabId="properties" icon={<PropertyIcon />}>
<DocPropertySidebar />
</ViewSidebarTab>
<ViewSidebarTab tabId="journal" icon={<TodayIcon />}>
<EditorJournalPanel />
</ViewSidebarTab>
@@ -1,26 +1,5 @@
import { rowHPadding } from '@affine/core/components/affine/page-properties/styles.css';
import { style } from '@vanilla-extract/css';
export const viewport = style({
vars: {
[rowHPadding]: '0px',
},
});
export const item = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 4,
height: 34,
padding: '0 20px',
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
});
export const linksRow = style({
padding: '0 16px',
});
@@ -28,24 +7,7 @@ export const linksRow = style({
export const timeRow = style({
padding: '0 16px',
});
export const tagsRow = style({
padding: '0 16px',
});
export const properties = style({
padding: '0 16px',
});
export const scrollBar = style({
width: 6,
transform: 'translateX(-4px)',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
gap: 6,
padding: 6,
width: '160px',
});
@@ -1,12 +1,6 @@
import { Divider, Scrollable } from '@affine/component';
import {
PagePropertyRow,
SortableProperties,
usePagePropertiesManager,
} from '@affine/core/components/affine/page-properties';
import { managerContext } from '@affine/core/components/affine/page-properties/common';
import { DocPropertiesTable } from '@affine/core/components/affine/page-properties';
import { LinksRow } from '@affine/core/components/affine/page-properties/info-modal/links-row';
import { TagsRow } from '@affine/core/components/affine/page-properties/info-modal/tags-row';
import { TimeRow } from '@affine/core/components/affine/page-properties/info-modal/time-row';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
@@ -16,7 +10,6 @@ import { Suspense, useMemo } from 'react';
import * as styles from './doc-info.css';
export const DocInfoSheet = ({ docId }: { docId: string }) => {
const manager = usePagePropertiesManager(docId);
const docsSearchService = useService(DocsSearchService);
const t = useI18n();
@@ -35,64 +28,32 @@ export const DocInfoSheet = ({ docId }: { docId: string }) => {
return (
<Scrollable.Root>
<Scrollable.Viewport
className={styles.viewport}
data-testid="doc-info-menu"
>
<managerContext.Provider value={manager}>
<Suspense>
<TimeRow docId={docId} className={styles.timeRow} />
<Divider size="thinner" />
{backlinks && backlinks.length > 0 ? (
<>
<LinksRow
className={styles.linksRow}
references={backlinks}
label={t['com.affine.page-properties.backlinks']()}
/>
<Divider size="thinner" />
</>
) : null}
{links && links.length > 0 ? (
<>
<LinksRow
className={styles.linksRow}
references={links}
label={t['com.affine.page-properties.outgoing-links']()}
/>
<Divider size="thinner" />
</>
) : null}
<div className={styles.properties}>
<TagsRow docId={docId} readonly={manager.readonly} />
<SortableProperties>
{properties =>
properties.length ? (
<>
{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}
/>
))}
</>
) : null
}
</SortableProperties>
</div>
</Suspense>
</managerContext.Provider>
<Scrollable.Viewport data-testid="doc-info-menu">
<Suspense>
<TimeRow docId={docId} className={styles.timeRow} />
<Divider size="thinner" />
{backlinks && backlinks.length > 0 ? (
<>
<LinksRow
className={styles.linksRow}
references={backlinks}
label={t['com.affine.page-properties.backlinks']()}
/>
<Divider size="thinner" />
</>
) : null}
{links && links.length > 0 ? (
<>
<LinksRow
className={styles.linksRow}
references={links}
label={t['com.affine.page-properties.outgoing-links']()}
/>
<Divider size="thinner" />
</>
) : null}
<DocPropertiesTable />
</Suspense>
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollBar} />
</Scrollable.Root>
@@ -259,28 +259,28 @@ export const ExplorerFolderNodeFolder = ({
}
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
} else if (
data.source.data.from?.at === 'explorer:organize:folder-node'
) {
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
track.$.navigationPanel.organize.moveOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
} else if (
data.source.data.entity?.type === 'collection' ||
data.source.data.entity?.type === 'doc' ||
data.source.data.entity?.type === 'tag'
) {
node.createLink(
data.source.data.entity?.type,
data.source.data.entity.id,
node.indexAt('before')
);
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
track.$.navigationPanel.organize.moveOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
} else {
node.createLink(
data.source.data.entity?.type,
data.source.data.entity.id,
node.indexAt('before')
);
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
}
}
} else {
onDrop?.(data);
@@ -330,27 +330,27 @@ export const ExplorerFolderNodeFolder = ({
}
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
} else if (
data.source.data.from?.at === 'explorer:organize:folder-node'
) {
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
track.$.navigationPanel.organize.moveOrganizeItem({
type: data.source.data.entity?.type,
});
} else if (
data.source.data.entity?.type === 'collection' ||
data.source.data.entity?.type === 'doc' ||
data.source.data.entity?.type === 'tag'
) {
node.createLink(
data.source.data.entity?.type,
data.source.data.entity.id,
node.indexAt('before')
);
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
track.$.navigationPanel.organize.moveOrganizeItem({
type: data.source.data.entity?.type,
});
} else {
node.createLink(
data.source.data.entity?.type,
data.source.data.entity.id,
node.indexAt('before')
);
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
}
}
},
[node]
@@ -379,32 +379,32 @@ export const ExplorerFolderNodeFolder = ({
node.indexAt(at, dropAtNode.id)
);
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
} else if (
data.source.data.from?.at === 'explorer:organize:folder-node'
) {
node.moveHere(
data.source.data.from.nodeId,
node.indexAt(at, dropAtNode.id)
);
track.$.navigationPanel.organize.moveOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
} else if (
data.source.data.entity?.type === 'collection' ||
data.source.data.entity?.type === 'doc' ||
data.source.data.entity?.type === 'tag'
) {
node.createLink(
data.source.data.entity?.type,
data.source.data.entity.id,
node.indexAt(at, dropAtNode.id)
);
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
node.moveHere(
data.source.data.from.nodeId,
node.indexAt(at, dropAtNode.id)
);
track.$.navigationPanel.organize.moveOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
} else {
node.createLink(
data.source.data.entity?.type,
data.source.data.entity.id,
node.indexAt(at, dropAtNode.id)
);
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: data.source.data.entity?.type,
});
}
}
} else if (data.treeInstruction?.type === 'reparent') {
const currentLevel = data.treeInstruction.currentLevel;
@@ -86,7 +86,7 @@ export const ExplorerOrganize = () => {
const entity = data.source.data.entity;
if (!entity) return;
const { type, id } = entity;
if (type === 'folder') return;
if (type !== 'doc' && type !== 'tag' && type !== 'collection') return;
const folder = newFolder$.value;
if (!folder) return;
@@ -1,5 +1,7 @@
import { generateFractionalIndexingKeyBetween } from '@affine/core/utils/fractional-indexing';
import { Entity } from '@toeverything/infra';
import {
Entity,
generateFractionalIndexingKeyBetween,
} from '@toeverything/infra';
import type { FavoriteSupportType } from '../constant';
import type { FavoriteRecord, FavoriteStore } from '../stores/favorite';
@@ -1,5 +1,8 @@
import { generateFractionalIndexingKeyBetween } from '@affine/core/utils';
import { Entity, LiveData } from '@toeverything/infra';
import {
Entity,
generateFractionalIndexingKeyBetween,
LiveData,
} from '@toeverything/infra';
import { map, of, switchMap } from 'rxjs';
import type { FolderStore } from '../stores/folder';
@@ -6,7 +6,7 @@ import { Suspense, useCallback } from 'react';
import { Outlet } from 'react-router-dom';
import { AppSidebarService } from '../../app-sidebar';
import { SidebarSwitch } from '../../app-sidebar/views';
import { SidebarSwitch } from '../../app-sidebar/views/sidebar-header';
import { ViewService } from '../services/view';
import { WorkbenchService } from '../services/workbench';
import * as styles from './route-container.css';
+12
View File
@@ -18,6 +18,10 @@ export interface AffineDNDData extends DNDData {
| {
type: 'tag';
id: string;
}
| {
type: 'custom-property';
id: string;
};
from?:
| {
@@ -62,6 +66,14 @@ export interface AffineDNDData extends DNDData {
| {
at: 'app-header:tabs';
tabId: string;
}
| {
at: 'doc-property:table';
docId: string;
}
| {
at: 'doc-property:manager';
workspaceId: string;
};
};
dropTarget:
@@ -1,7 +1,6 @@
export * from './create-emotion-cache';
export * from './event';
export * from './extract-emoji-icon';
export * from './fractional-indexing';
export * from './popup';
export * from './string2color';
export * from './toast';
@@ -0,0 +1,17 @@
export const generateUniqueNameInSequence = (
baseName: string,
existingNames: string[]
): string => {
if (!existingNames.includes(baseName)) {
return baseName;
}
const numericSuffix = baseName.match(/(\d+)$/);
if (numericSuffix) {
const currentNumber = parseInt(numericSuffix[1], 10);
const newName = baseName.replace(/\d+$/, (currentNumber + 1).toString());
return generateUniqueNameInSequence(newName, existingNames);
}
return generateUniqueNameInSequence(`${baseName} 2`, existingNames);
};
@@ -7,16 +7,16 @@
"es-AR": 14,
"es-CL": 16,
"es": 14,
"fr": 78,
"fr": 77,
"hi": 2,
"it": 1,
"ja": 28,
"ko": 92,
"pl": 0,
"pt-BR": 100,
"pt-BR": 99,
"ru": 85,
"sv-SE": 5,
"ur": 3,
"zh-Hans": 99,
"zh-Hant": 21
"zh-Hans": 98,
"zh-Hant": 20
}
+8 -1
View File
@@ -615,9 +615,14 @@
"com.affine.other-page.nav.official-website": "Official website",
"com.affine.other-page.nav.open-affine": "Open AFFiNE",
"com.affine.page-operation.add-linked-page": "Add linked doc",
"com.affine.page-properties.more-property.more": "{{ count }} more properties",
"com.affine.page-properties.more-property.one": "{{ count }} more property",
"com.affine.page-properties.hide-property.one": "hide {{ count }} property",
"com.affine.page-properties.hide-property.more": "hide {{ count }} properties",
"com.affine.page-properties.add-property": "Add property",
"com.affine.page-properties.add-property.menu.create": "Create property",
"com.affine.page-properties.add-property.menu.header": "Properties",
"com.affine.page-properties.config-properties": "Config properties",
"com.affine.page-properties.backlinks": "Backlinks",
"com.affine.page-properties.create-property.menu.header": "Type",
"com.affine.page-properties.icons": "Icons",
@@ -644,6 +649,8 @@
"com.affine.page-properties.property.tags": "Tags",
"com.affine.page-properties.property.text": "Text",
"com.affine.page-properties.property.updatedBy": "Last edited by",
"com.affine.propertySidebar.property-list.section": "Properties",
"com.affine.propertySidebar.add-more.section": "Add more properties",
"com.affine.page-properties.settings.title": "customize properties",
"com.affine.page-properties.tags.open-tags-page": "Open tag page",
"com.affine.page-properties.tags.selector-header-title": "Select tag or create one",
@@ -1101,7 +1108,7 @@
"com.affine.settings.workspace.properties.add_property": "Add property",
"com.affine.settings.workspace.properties.all": "All",
"com.affine.settings.workspace.properties.delete-property": "Delete property",
"com.affine.settings.workspace.properties.delete-property-prompt": "The \"<1>{{ name }}</1>\" property will be remove from {{ count }} doc(s). This action cannot be undone.",
"com.affine.settings.workspace.properties.delete-property-desc": "The \"<1>{{ name }}</1>\" property will be removed. This action cannot be undone.",
"com.affine.settings.workspace.properties.doc": "<0>{{count}}</0> doc",
"com.affine.settings.workspace.properties.doc_others": "<0>{{count}}</0> docs",
"com.affine.settings.workspace.properties.edit-property": "Edit property",
+10 -18
View File
@@ -44,8 +44,6 @@ test('New a page and open it ,then open info modal in the title bar', async ({
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
const tagRow = page.getByTestId('info-modal-tags-row');
await expect(tagRow).toBeVisible();
const title = page.getByTestId('info-modal-title');
await expect(title).toHaveText('this is a new page');
});
@@ -58,8 +56,6 @@ test('New a page and open it ,then open info modal in the title bar more action
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
const tagRow = page.getByTestId('info-modal-tags-row');
await expect(tagRow).toBeVisible();
const title = page.getByTestId('info-modal-title');
await expect(title).toHaveText('this is a new page');
});
@@ -75,8 +71,6 @@ test('New a page, then open info modal from all doc', async ({ page }) => {
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
const tagRow = page.getByTestId('info-modal-tags-row');
await expect(tagRow).toBeVisible();
const title = page.getByTestId('info-modal-title');
await expect(title).toHaveText('this is a new page');
});
@@ -105,8 +99,6 @@ test('New a page and add to favourites, then open info modal from sidebar', asyn
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
const tagRow = page.getByTestId('info-modal-tags-row');
await expect(tagRow).toBeVisible();
const title = page.getByTestId('info-modal-title');
await expect(title).toHaveText('this is a new page');
});
@@ -116,16 +108,16 @@ test('allow create tag', async ({ page }) => {
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
await page.getByTestId('info-modal-tags-value').click();
await infoModal.getByTestId('property-tags-value').click();
await searchAndCreateTag(page, 'Test1');
await searchAndCreateTag(page, 'Test2');
await closeTagsEditor(page);
await expectTagsVisible(page, ['Test1', 'Test2']);
await expectTagsVisible(infoModal, ['Test1', 'Test2']);
await page.getByTestId('info-modal-tags-value').click();
await infoModal.getByTestId('property-tags-value').click();
await removeSelectedTag(page, 'Test1');
await closeTagsEditor(page);
await expectTagsVisible(page, ['Test2']);
await expectTagsVisible(infoModal, ['Test2']);
});
test('add custom property', async ({ page }) => {
@@ -133,10 +125,10 @@ test('add custom property', async ({ page }) => {
const infoModal = page.getByTestId('info-modal');
await expect(infoModal).toBeVisible();
await addCustomProperty(page, 'Text');
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
await addCustomProperty(page, infoModal, 'text');
await addCustomProperty(page, infoModal, 'number');
await addCustomProperty(page, infoModal, 'date');
await addCustomProperty(page, infoModal, 'checkbox');
await addCustomProperty(page, infoModal, 'createdBy');
await addCustomProperty(page, infoModal, 'updatedBy');
});
+53 -135
View File
@@ -81,39 +81,40 @@ test('allow create tag on journals page', async ({ page }) => {
});
test('add custom property', async ({ page }) => {
await addCustomProperty(page, 'Text');
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
await addCustomProperty(page, page, 'text');
await addCustomProperty(page, page, 'number');
await addCustomProperty(page, page, 'date');
await addCustomProperty(page, page, 'checkbox');
await addCustomProperty(page, page, 'createdBy');
await addCustomProperty(page, page, 'updatedBy');
});
test('add custom property & edit', async ({ page }) => {
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, page, 'checkbox');
await expect(
getPropertyValueLocator(page, 'Checkbox').locator('input')
getPropertyValueLocator(page, 'checkbox').locator('input')
).not.toBeChecked();
await clickPropertyValue(page, 'Checkbox');
await clickPropertyValue(page, 'checkbox');
await expect(
getPropertyValueLocator(page, 'Checkbox').locator('input')
getPropertyValueLocator(page, 'checkbox').locator('input')
).toBeChecked();
});
test('property table reordering', async ({ page }) => {
await addCustomProperty(page, 'Text');
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
await addCustomProperty(page, page, 'text');
await addCustomProperty(page, page, 'number');
await addCustomProperty(page, page, 'date');
await addCustomProperty(page, page, 'checkbox');
await addCustomProperty(page, page, 'createdBy');
await addCustomProperty(page, page, 'updatedBy');
await dragTo(
page,
page.locator('[data-testid="page-property-row-name"]:has-text("Text")'),
page.locator('[data-testid="doc-property-name"]:has-text("Text")'),
page.locator(
'[data-testid="page-property-row-name"]:has-text("Checkbox") + div'
)
'[data-testid="doc-property-name"]:has-text("Checkbox") + div'
),
'bottom'
);
// new order should be (Tags), Number, Date, Checkbox, Text
@@ -128,9 +129,9 @@ test('property table reordering', async ({ page }) => {
].entries()) {
await expect(
page
.getByTestId('page-property-row')
.getByTestId('doc-property-row')
.nth(index)
.getByTestId('page-property-row-name')
.getByTestId('doc-property-name')
).toHaveText(property);
}
});
@@ -143,23 +144,20 @@ test('page info show more will not should by default when there is no properties
});
test('page info show more will show all properties', async ({ page }) => {
await addCustomProperty(page, 'Text');
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
await addCustomProperty(page, page, 'text');
await addCustomProperty(page, page, 'number');
await addCustomProperty(page, page, 'date');
await addCustomProperty(page, page, 'checkbox');
await addCustomProperty(page, page, 'createdBy');
await addCustomProperty(page, page, 'updatedBy');
await expect(page.getByTestId('page-info-show-more')).toBeVisible();
await page.click('[data-testid="page-info-show-more"]');
await expect(
page.getByRole('heading', {
name: 'customize properties',
})
).toBeVisible();
await changePropertyVisibility(page, 'Text', 'always-hide');
await expect(page.getByTestId('property-collapsible-button')).toBeVisible();
await page.click('[data-testid="property-collapsible-button"]');
// new order should be (Tags), Number, Date, Checkbox, Text
for (const [index, property] of [
'Tags',
'Text',
'Number',
'Date',
@@ -169,51 +167,51 @@ test('page info show more will show all properties', async ({ page }) => {
].entries()) {
await expect(
page
.getByTestId('page-properties-settings-menu-item')
.getByTestId('doc-property-row')
.nth(index)
.getByTestId('page-property-setting-row-name')
.getByTestId('doc-property-name')
).toHaveText(property);
}
});
test('change page properties visibility', async ({ page }) => {
await addCustomProperty(page, 'Text');
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, page, 'text');
await addCustomProperty(page, page, 'number');
await addCustomProperty(page, page, 'date');
await addCustomProperty(page, page, 'checkbox');
// add some number to number property
await clickPropertyValue(page, 'Number');
await page.locator('input[type=number]').fill('123');
await changePropertyVisibility(page, 'Text', 'Hide in view');
await changePropertyVisibility(page, 'Number', 'Hide in view when empty');
await changePropertyVisibility(page, 'Text', 'always-hide');
await changePropertyVisibility(page, 'Number', 'hide-when-empty');
// text property should not be visible
await expect(
page.locator('[data-testid="page-property-row-name"]:has-text("Text")')
page.locator('[data-testid="doc-property-name"]:has-text("Text")')
).not.toBeVisible();
// number property should be visible
await expect(
page.locator('[data-testid="page-property-row-name"]:has-text("Number")')
page.locator('[data-testid="doc-property-name"]:has-text("Number")')
).toBeVisible();
});
test('check if added property is also in workspace settings', async ({
page,
}) => {
await addCustomProperty(page, 'Text');
await addCustomProperty(page, page, 'text');
await openWorkspaceProperties(page);
await expect(
page.locator('[data-testid=custom-property-row]:has-text("Text")')
page.locator('[data-testid=doc-property-manager-item]:has-text("Text")')
).toBeVisible();
});
test('edit property name', async ({ page }) => {
await addCustomProperty(page, 'Text');
await addCustomProperty(page, page, 'text');
await page
.locator('[data-testid="page-property-row-name"]:has-text("Text")')
.locator('[data-testid="doc-property-name"]:has-text("Text")')
.click();
await expect(page.locator('[data-radix-menu-content]')).toBeVisible();
await expect(page.locator('[data-radix-menu-content] input')).toHaveValue(
@@ -229,26 +227,24 @@ test('edit property name', async ({ page }) => {
// check if the property name is also updated in workspace settings
await openWorkspaceProperties(page);
await expect(
page.locator('[data-testid=custom-property-row]:has-text("New Text")')
page.locator('[data-testid=doc-property-manager-item]:has-text("New Text")')
).toBeVisible();
});
test('delete property via property popup', async ({ page }) => {
await addCustomProperty(page, 'Text');
await addCustomProperty(page, page, 'text');
await page
.locator('[data-testid="page-property-row-name"]:has-text("Text")')
.locator('[data-testid="doc-property-name"]:has-text("Text")')
.click();
await expect(page.locator('[data-radix-menu-content]')).toBeVisible();
await page
.locator('[data-radix-menu-content]')
.getByRole('menuitem', {
name: 'Remove property',
name: 'Delete property',
})
.click();
// confirm delete dialog should show
await expect(page.getByRole('dialog')).toContainText(
`The "Text" property will be remove from 1 doc(s). This action cannot be undone.`
);
await expect(page.getByRole('dialog')).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm',
@@ -256,84 +252,6 @@ test('delete property via property popup', async ({ page }) => {
.click();
// check if the property is removed
await expect(
page.locator('[data-testid="page-property-row-name"]:has-text("Text")')
).not.toBeVisible();
});
test('create a required property', async ({ page }) => {
await openWorkspaceProperties(page);
await addCustomProperty(page, 'Text', true);
await page
.locator('[data-testid="custom-property-row"]:has-text("Text")')
.getByRole('button')
.click();
await page
.getByRole('menuitem', {
name: 'Set as required property',
})
.click();
await expect(
page.locator('[data-testid="custom-property-row"]:has-text("Text")')
).toContainText('Required');
// close workspace settings
await page.keyboard.press('Escape');
// check if the property is also required in page properties
await expect(
page.locator('[data-testid="page-property-row-name"]:has-text("Text")')
).toBeVisible();
// check if the required property is also listed in the show more menu
await page.click('[data-testid="page-info-show-more"]');
await expect(
page.locator(
'[data-testid="page-properties-settings-menu-item"]:has-text("Text")'
)
).toContainText('Required');
});
test('delete a required property', async ({ page }) => {
await openWorkspaceProperties(page);
await addCustomProperty(page, 'Text', true);
await page
.locator('[data-testid="custom-property-row"]:has-text("Text")')
.getByRole('button')
.click();
await page
.getByRole('menuitem', {
name: 'Set as required property',
})
.click();
await page
.locator('[data-testid="custom-property-row"]:has-text("Text")')
.getByRole('button')
.click();
await page
.getByRole('menuitem', {
name: 'Delete property',
})
.click();
await page
.getByRole('button', {
name: 'Confirm',
})
.click();
// close workspace settings
await page.keyboard.press('Escape');
await waitForEditorLoad(page);
// check if the property is removed from page properties
await expect(
page.locator('[data-testid="page-property-row-name"]:has-text("Text")')
page.locator('[data-testid="http://localhost:8080/"]:has-text("Text")')
).not.toBeVisible();
});
+1 -3
View File
@@ -127,9 +127,7 @@ export const createPageWithTag = async (
await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('test page');
await page.getByTestId('page-info-collapse').click();
await page
.locator('[data-testid="page-property-row"][data-property="tags"]')
.click();
await page.locator('[data-testid="property-tags-value"]').click();
for (const name of options.tags) {
await createTag(page, name);
}
+24 -46
View File
@@ -1,9 +1,9 @@
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
export const getPropertyValueLocator = (page: Page, property: string) => {
return page.locator(
`[data-testid="page-property-row-name"]:has-text("${property}") + *`
`[data-testid="doc-property-name"]:has-text("${property}") + *`
);
};
@@ -66,9 +66,12 @@ export const searchAndCreateTag = async (page: Page, name: string) => {
.click();
};
export const expectTagsVisible = async (page: Page, tags: string[]) => {
const tagListPanel = page
.getByTestId('page-property-row')
export const expectTagsVisible = async (
root: Locator | Page,
tags: string[]
) => {
const tagListPanel = root
.getByTestId('property-tags-value')
.getByTestId('inline-tags-list');
expect(await tagListPanel.locator('[data-tag-value]').count()).toBe(
@@ -82,8 +85,8 @@ export const expectTagsVisible = async (page: Page, tags: string[]) => {
}
};
export const clickAddPropertyButton = async (page: Page) => {
await page
export const clickAddPropertyButton = async (root: Locator | Page) => {
await root
.getByRole('button', {
name: 'Add property',
})
@@ -92,40 +95,17 @@ export const clickAddPropertyButton = async (page: Page) => {
export const addCustomProperty = async (
page: Page,
type: string,
inSettings?: boolean
root: Locator | Page,
type: string
) => {
await clickAddPropertyButton(page);
if (!inSettings) {
await expect(
page.getByRole('heading', {
name: 'Properties',
})
).toBeVisible();
await page
.getByRole('menuitem', {
name: 'Create property',
})
.click();
}
await expect(
page.getByRole('heading', {
name: 'Type',
})
).toBeVisible();
await clickAddPropertyButton(root);
await page
.getByRole('menuitem', {
name: type,
})
.locator(
`[data-testid="${'create-property-menu-item'}"][data-property-type="${type}"]`
)
.click();
if (!inSettings) {
await expect(
page
.getByRole('menuitem', {
name: type,
})
.locator('.selected')
).toBeVisible();
if (await page.getByTestId('edit-property-menu-item').isVisible()) {
// is edit property menu opened, close it
await page.keyboard.press('Escape');
}
await page.waitForTimeout(500);
@@ -184,12 +164,10 @@ export const changePropertyVisibility = async (
name: string,
option: string
) => {
await expect(page.getByTestId('page-info-show-more')).toBeVisible();
await page.click('[data-testid="page-info-show-more"]');
await expect(
page.getByRole('heading', {
name: 'customize properties',
})
).toBeVisible();
await selectVisibilitySelector(page, name, option);
await page
.locator(`[data-testid="doc-property-name"]:has-text("${name}")`)
.click();
await page.locator(`[data-property-visibility="${option}"]`).click();
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
};
+1 -1
View File
@@ -398,7 +398,6 @@ __metadata:
fake-indexeddb: "npm:^6.0.0"
file-type: "npm:^19.1.0"
foxact: "npm:^0.2.33"
fractional-indexing: "npm:^3.2.0"
fuse.js: "npm:^7.0.0"
graphemer: "npm:^1.4.0"
graphql: "npm:^16.8.1"
@@ -12847,6 +12846,7 @@ __metadata:
"@testing-library/react": "npm:^16.0.0"
fake-indexeddb: "npm:^6.0.0"
foxact: "npm:^0.2.33"
fractional-indexing: "npm:^3.2.0"
fuse.js: "npm:^7.0.0"
graphemer: "npm:^1.4.0"
idb: "npm:^8.0.0"