mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 02:56:23 +08:00
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`.  Added a sidebar view to manage properties  new property ui in workspace settings  Property lists can be collapsed 
This commit is contained in:
@@ -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,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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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>();
|
||||
-57
@@ -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];
|
||||
+39
@@ -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} />;
|
||||
};
|
||||
+17
-32
@@ -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';
|
||||
|
||||
+35
-6
@@ -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'),
|
||||
});
|
||||
|
||||
+55
-61
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-104
@@ -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>
|
||||
);
|
||||
};
|
||||
-43
@@ -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',
|
||||
|
||||
+40
-54
@@ -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>
|
||||
);
|
||||
};
|
||||
+25
@@ -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,
|
||||
});
|
||||
+84
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+42
@@ -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',
|
||||
});
|
||||
+212
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-303
@@ -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;
|
||||
}
|
||||
-320
@@ -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
+17
@@ -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',
|
||||
});
|
||||
|
||||
+65
-64
@@ -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;
|
||||
};
|
||||
+110
@@ -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;
|
||||
}
|
||||
+6
-388
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user