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",
|
"@blocksuite/affine": "0.17.18",
|
||||||
"@datastructures-js/binary-search-tree": "^5.3.2",
|
"@datastructures-js/binary-search-tree": "^5.3.2",
|
||||||
"foxact": "^0.2.33",
|
"foxact": "^0.2.33",
|
||||||
|
"fractional-indexing": "^3.2.0",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { WorkspaceDB } from './entities/db';
|
|||||||
import { WorkspaceDBTable } from './entities/table';
|
import { WorkspaceDBTable } from './entities/table';
|
||||||
import { WorkspaceDBService } from './services/db';
|
import { WorkspaceDBService } from './services/db';
|
||||||
|
|
||||||
export type { DocProperties } from './schema';
|
export type { DocCustomPropertyInfo, DocProperties } from './schema';
|
||||||
export { WorkspaceDBService } from './services/db';
|
export { WorkspaceDBService } from './services/db';
|
||||||
export { transformWorkspaceDBLocalToCloud } from './services/db';
|
export { transformWorkspaceDBLocalToCloud } from './services/db';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type { DocProperties } from './schema';
|
export type { DocCustomPropertyInfo, DocProperties } from './schema';
|
||||||
export {
|
export {
|
||||||
AFFiNE_WORKSPACE_DB_SCHEMA,
|
AFFiNE_WORKSPACE_DB_SCHEMA,
|
||||||
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
|
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
|||||||
type: f.string(),
|
type: f.string(),
|
||||||
show: f.string().optional(),
|
show: f.string().optional(),
|
||||||
index: f.string().optional(),
|
index: f.string().optional(),
|
||||||
|
icon: f.string().optional(),
|
||||||
additionalData: f.json().optional(),
|
additionalData: f.json().optional(),
|
||||||
isDeleted: f.boolean().optional(),
|
isDeleted: f.boolean().optional(),
|
||||||
// we will keep deleted properties in the database, for override legacy data
|
// we will keep deleted properties in the database, for override legacy data
|
||||||
|
|||||||
11
packages/common/infra/src/modules/doc/constants.ts
Normal file
11
packages/common/infra/src/modules/doc/constants.ts
Normal file
@@ -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 title$ = this.record.title$;
|
||||||
readonly trash$ = this.record.trash$;
|
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) {
|
setPrimaryMode(mode: DocMode) {
|
||||||
return this.record.setPrimaryMode(mode);
|
return this.record.setPrimaryMode(mode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Entity } from '../../../framework';
|
import { Entity } from '../../../framework';
|
||||||
import { LiveData } from '../../../livedata';
|
import { LiveData } from '../../../livedata';
|
||||||
|
import { generateFractionalIndexingKeyBetween } from '../../../utils';
|
||||||
import type { DocCustomPropertyInfo } from '../../db/schema/schema';
|
import type { DocCustomPropertyInfo } from '../../db/schema/schema';
|
||||||
import type { DocPropertiesStore } from '../stores/doc-properties';
|
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>) {
|
updatePropertyInfo(id: string, properties: Partial<DocCustomPropertyInfo>) {
|
||||||
this.docPropertiesStore.updateDocPropertyInfo(id, properties);
|
this.docPropertiesStore.updateDocPropertyInfo(id, properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
createProperty(properties: DocCustomPropertyInfo) {
|
createProperty(
|
||||||
|
properties: Omit<DocCustomPropertyInfo, 'id'> & { id?: string }
|
||||||
|
) {
|
||||||
return this.docPropertiesStore.createDocPropertyInfo(properties);
|
return this.docPropertiesStore.createDocPropertyInfo(properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeProperty(id: string) {
|
removeProperty(id: string) {
|
||||||
this.docPropertiesStore.removeDocPropertyInfo(id);
|
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 }
|
{ id: this.id }
|
||||||
);
|
);
|
||||||
|
|
||||||
setProperties(properties: Partial<DocProperties>): void {
|
customProperty$(propertyId: string) {
|
||||||
this.docPropertiesStore.updateDocProperties(this.id, properties);
|
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 {
|
setMeta(meta: Partial<DocMeta>): void {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
DocProperties,
|
DocProperties,
|
||||||
} from '../../db/schema/schema';
|
} from '../../db/schema/schema';
|
||||||
import type { WorkspaceService } from '../../workspace';
|
import type { WorkspaceService } from '../../workspace';
|
||||||
|
import { BUILT_IN_CUSTOM_PROPERTY_TYPE } from '../constants';
|
||||||
|
|
||||||
interface LegacyDocProperties {
|
interface LegacyDocProperties {
|
||||||
custom?: Record<string, { value: unknown } | undefined>;
|
custom?: Record<string, { value: unknown } | undefined>;
|
||||||
@@ -23,6 +24,7 @@ type LegacyDocPropertyInfo = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
icon?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LegacyDocPropertyInfoList = Record<
|
type LegacyDocPropertyInfoList = Record<
|
||||||
@@ -50,11 +52,18 @@ export class DocPropertiesStore extends Store {
|
|||||||
const legacy = this.upgradeLegacyDocPropertyInfoList(
|
const legacy = this.upgradeLegacyDocPropertyInfoList(
|
||||||
this.getLegacyDocPropertyInfoList()
|
this.getLegacyDocPropertyInfoList()
|
||||||
);
|
);
|
||||||
const notOverridden = differenceBy(legacy, db, i => i.id);
|
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
|
||||||
return [...db, ...notOverridden].filter(i => !i.isDeleted);
|
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;
|
return this.dbService.db.docCustomPropertyInfo.create(config).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +76,11 @@ export class DocPropertiesStore extends Store {
|
|||||||
|
|
||||||
updateDocPropertyInfo(id: string, config: Partial<DocCustomPropertyInfo>) {
|
updateDocPropertyInfo(id: string, config: Partial<DocCustomPropertyInfo>) {
|
||||||
const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id);
|
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
|
// 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);
|
this.migrateLegacyDocPropertyInfo(id, config);
|
||||||
} else {
|
} 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() {
|
watchDocPropertyInfoList() {
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
this.watchLegacyDocPropertyInfoList().pipe(
|
this.watchLegacyDocPropertyInfoList().pipe(
|
||||||
map(this.upgradeLegacyDocPropertyInfoList)
|
map(this.upgradeLegacyDocPropertyInfoList)
|
||||||
),
|
),
|
||||||
this.dbService.db.docCustomPropertyInfo.find$({}),
|
this.dbService.db.docCustomPropertyInfo.find$(),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(([legacy, db]) => {
|
map(([legacy, db]) => {
|
||||||
const notOverridden = differenceBy(legacy, db, i => i.id);
|
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
|
||||||
return [...db, ...notOverridden].filter(i => !i.isDeleted);
|
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) {
|
if (!properties) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const newProperties: Record<string, unknown> = {};
|
const newProperties: Record<string, string> = {};
|
||||||
for (const [key, info] of Object.entries(properties.system ?? {})) {
|
for (const [key, info] of Object.entries(properties.system ?? {})) {
|
||||||
if (info?.value !== undefined) {
|
if (info?.value !== undefined && info.value !== null) {
|
||||||
newProperties[key] = info.value;
|
newProperties[key] = info.value.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [key, info] of Object.entries(properties.custom ?? {})) {
|
for (const [key, info] of Object.entries(properties.custom ?? {})) {
|
||||||
if (info?.value !== undefined) {
|
if (info?.value !== undefined && info.value !== null) {
|
||||||
newProperties['custom:' + key] = info.value;
|
newProperties['custom:' + key] = info.value.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newProperties;
|
return newProperties;
|
||||||
@@ -162,6 +191,7 @@ export class DocPropertiesStore extends Store {
|
|||||||
id,
|
id,
|
||||||
name: info.name,
|
name: info.name,
|
||||||
type: info.type,
|
type: info.type,
|
||||||
|
icon: info.icon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { DataValidator } from './types';
|
|||||||
function inputType(val: any) {
|
function inputType(val: any) {
|
||||||
return val === null ||
|
return val === null ||
|
||||||
Array.isArray(val) ||
|
Array.isArray(val) ||
|
||||||
val.constructor === 'Object' ||
|
val.constructor === Object ||
|
||||||
!val.constructor /* Object.create(null) */
|
!val.constructor /* Object.create(null) */
|
||||||
? 'json'
|
? 'json'
|
||||||
: typeof val;
|
: typeof val;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './async-lock';
|
export * from './async-lock';
|
||||||
export * from './async-queue';
|
export * from './async-queue';
|
||||||
export * from './exhaustmap-with-trailing';
|
export * from './exhaustmap-with-trailing';
|
||||||
|
export * from './fractional-indexing';
|
||||||
export * from './merge-updates';
|
export * from './merge-updates';
|
||||||
export * from './object-pool';
|
export * from './object-pool';
|
||||||
export * from './stable-hash';
|
export * from './stable-hash';
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export * from './ui/menu';
|
|||||||
export * from './ui/modal';
|
export * from './ui/modal';
|
||||||
export * from './ui/notification';
|
export * from './ui/notification';
|
||||||
export * from './ui/popover';
|
export * from './ui/popover';
|
||||||
|
export * from './ui/property';
|
||||||
export * from './ui/radio';
|
export * from './ui/radio';
|
||||||
export * from './ui/safe-area';
|
export * from './ui/safe-area';
|
||||||
export * from './ui/scrollbar';
|
export * from './ui/scrollbar';
|
||||||
|
|||||||
@@ -182,6 +182,11 @@ export const useDraggable = <D extends DNDData = DNDData>(
|
|||||||
let previewPosition: DraggableDragPreviewPosition =
|
let previewPosition: DraggableDragPreviewPosition =
|
||||||
options.dragPreviewPosition ?? 'native';
|
options.dragPreviewPosition ?? 'native';
|
||||||
|
|
||||||
|
source.element.dataset['dragPreview'] = 'true';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
delete source.element.dataset['dragPreview'];
|
||||||
|
});
|
||||||
|
|
||||||
if (enableCustomDragPreview.current) {
|
if (enableCustomDragPreview.current) {
|
||||||
setCustomNativeDragPreview({
|
setCustomNativeDragPreview({
|
||||||
getOffset: (...args) => {
|
getOffset: (...args) => {
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export const treeLine = style({
|
|||||||
height: 2,
|
height: 2,
|
||||||
right: 0,
|
right: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
['&[data-no-terminal="true"]::before']: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const lineAboveStyles = style({
|
export const lineAboveStyles = style({
|
||||||
@@ -143,7 +149,7 @@ export const left = style({
|
|||||||
|
|
||||||
export const edgeLine = style({
|
export const edgeLine = style({
|
||||||
vars: {
|
vars: {
|
||||||
[terminalSize]: '8px',
|
[terminalSize]: '6px',
|
||||||
},
|
},
|
||||||
display: 'block',
|
display: 'block',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -156,11 +162,17 @@ export const edgeLine = style({
|
|||||||
// Terminal
|
// Terminal
|
||||||
'::before': {
|
'::before': {
|
||||||
content: '""',
|
content: '""',
|
||||||
width: terminalSize,
|
width: 0,
|
||||||
height: terminalSize,
|
height: 0,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
border: `${terminalSize} solid ${cssVar('--affine-primary-color')}`,
|
border: `${terminalSize} solid ${cssVar('--affine-primary-color')}`,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
['&[data-no-terminal="true"]::before']: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import * as styles from './drop-indicator.css';
|
|||||||
export type DropIndicatorProps = {
|
export type DropIndicatorProps = {
|
||||||
instruction?: Instruction | null;
|
instruction?: Instruction | null;
|
||||||
edge?: Edge | null;
|
edge?: Edge | null;
|
||||||
|
noTerminal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTreeElement({
|
function getTreeElement({
|
||||||
instruction,
|
instruction,
|
||||||
isBlocked,
|
isBlocked,
|
||||||
|
noTerminal,
|
||||||
}: {
|
}: {
|
||||||
instruction: Exclude<Instruction, { type: 'instruction-blocked' }>;
|
instruction: Exclude<Instruction, { type: 'instruction-blocked' }>;
|
||||||
isBlocked: boolean;
|
isBlocked: boolean;
|
||||||
|
noTerminal?: boolean;
|
||||||
}): ReactElement | null {
|
}): ReactElement | null {
|
||||||
const style = {
|
const style = {
|
||||||
[styles.horizontalIndent]: `${instruction.currentLevel * instruction.indentPerLevel}px`,
|
[styles.horizontalIndent]: `${instruction.currentLevel * instruction.indentPerLevel}px`,
|
||||||
@@ -31,6 +34,7 @@ function getTreeElement({
|
|||||||
<div
|
<div
|
||||||
className={clsx(styles.treeLine, styles.lineAboveStyles)}
|
className={clsx(styles.treeLine, styles.lineAboveStyles)}
|
||||||
style={assignInlineVars(style)}
|
style={assignInlineVars(style)}
|
||||||
|
data-no-terminal={noTerminal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,6 +43,7 @@ function getTreeElement({
|
|||||||
<div
|
<div
|
||||||
className={clsx(styles.treeLine, styles.lineBelowStyles)}
|
className={clsx(styles.treeLine, styles.lineBelowStyles)}
|
||||||
style={assignInlineVars(style)}
|
style={assignInlineVars(style)}
|
||||||
|
data-no-terminal={noTerminal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -48,6 +53,7 @@ function getTreeElement({
|
|||||||
<div
|
<div
|
||||||
className={clsx(styles.outlineStyles)}
|
className={clsx(styles.outlineStyles)}
|
||||||
style={assignInlineVars(style)}
|
style={assignInlineVars(style)}
|
||||||
|
data-no-terminal={noTerminal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,6 +67,7 @@ function getTreeElement({
|
|||||||
<div
|
<div
|
||||||
className={clsx(styles.treeLine, styles.lineBelowStyles)}
|
className={clsx(styles.treeLine, styles.lineBelowStyles)}
|
||||||
style={assignInlineVars(style)}
|
style={assignInlineVars(style)}
|
||||||
|
data-no-terminal={noTerminal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,13 +95,14 @@ const edgeStyles: Record<Edge, string> = {
|
|||||||
right: styles.right,
|
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 lineOffset = `calc(-0.5 * (${gap}px + 2px))`;
|
||||||
|
|
||||||
const orientation = edgeToOrientationMap[edge];
|
const orientation = edgeToOrientationMap[edge];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-no-terminal={noTerminal}
|
||||||
className={clsx([
|
className={clsx([
|
||||||
styles.edgeLine,
|
styles.edgeLine,
|
||||||
orientationStyles[orientation],
|
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) {
|
if (edge) {
|
||||||
return getEdgeElement(edge, 0);
|
return getEdgeElement(edge, 0, noTerminal);
|
||||||
}
|
}
|
||||||
if (instruction) {
|
if (instruction) {
|
||||||
if (instruction.type === 'instruction-blocked') {
|
if (instruction.type === 'instruction-blocked') {
|
||||||
|
|||||||
1
packages/frontend/component/src/ui/property/index.ts
Normal file
1
packages/frontend/component/src/ui/property/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './property';
|
||||||
163
packages/frontend/component/src/ui/property/property.css.ts
Normal file
163
packages/frontend/component/src/ui/property/property.css.ts
Normal file
@@ -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'),
|
||||||
|
});
|
||||||
144
packages/frontend/component/src/ui/property/property.stories.tsx
Normal file
144
packages/frontend/component/src/ui/property/property.stories.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
276
packages/frontend/component/src/ui/property/property.tsx
Normal file
276
packages/frontend/component/src/ui/property/property.tsx
Normal file
@@ -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",
|
"dayjs": "^1.11.10",
|
||||||
"file-type": "^19.1.0",
|
"file-type": "^19.1.0",
|
||||||
"foxact": "^0.2.33",
|
"foxact": "^0.2.33",
|
||||||
"fractional-indexing": "^3.2.0",
|
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createContext } from 'react';
|
|
||||||
|
|
||||||
import type { PagePropertiesManager } from './page-properties-manager';
|
|
||||||
|
|
||||||
// @ts-expect-error this should always be set
|
|
||||||
export const managerContext = createContext<PagePropertiesManager>();
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { ConfirmModal } from '@affine/component';
|
|
||||||
import { WorkspacePropertiesAdapter } from '@affine/core/modules/properties';
|
|
||||||
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
|
|
||||||
import { Trans, useI18n } from '@affine/i18n';
|
|
||||||
import { useService } from '@toeverything/infra';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { PagePropertiesMetaManager } from './page-properties-manager';
|
|
||||||
|
|
||||||
export const ConfirmDeletePropertyModal = ({
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
property,
|
|
||||||
show,
|
|
||||||
}: {
|
|
||||||
property: PageInfoCustomPropertyMeta;
|
|
||||||
show: boolean;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const adapter = useService(WorkspacePropertiesAdapter);
|
|
||||||
const count = useMemo(() => {
|
|
||||||
const manager = new PagePropertiesMetaManager(adapter);
|
|
||||||
return manager.getPropertyRelatedPages(property.id)?.size || 0;
|
|
||||||
}, [adapter, property.id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfirmModal
|
|
||||||
open={show}
|
|
||||||
closeButtonOptions={{
|
|
||||||
onClick: onCancel,
|
|
||||||
}}
|
|
||||||
title={t['com.affine.settings.workspace.properties.delete-property']()}
|
|
||||||
description={
|
|
||||||
<Trans
|
|
||||||
values={{
|
|
||||||
name: property.name,
|
|
||||||
count,
|
|
||||||
}}
|
|
||||||
i18nKey="com.affine.settings.workspace.properties.delete-property-prompt"
|
|
||||||
>
|
|
||||||
The <strong>{{ name: property.name } as any}</strong> property will be
|
|
||||||
removed from count doc(s). This action cannot be undone.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
confirmText={t['Confirm']()}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
cancelButtonOptions={{
|
|
||||||
onClick: onCancel,
|
|
||||||
}}
|
|
||||||
confirmButtonOptions={{
|
|
||||||
variant: 'error',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { PagePropertyType } from '@affine/core/modules/properties/services/schema';
|
|
||||||
import * as icons from '@blocksuite/icons/rc';
|
|
||||||
import type { SVGProps } from 'react';
|
|
||||||
|
|
||||||
type IconType = (props: SVGProps<SVGSVGElement>) => JSX.Element;
|
|
||||||
|
|
||||||
// assume all exports in icons are icon Components
|
|
||||||
type LibIconComponentName = keyof typeof icons;
|
|
||||||
|
|
||||||
type fromLibIconName<T extends string> = T extends `${infer N}Icon`
|
|
||||||
? Uncapitalize<N>
|
|
||||||
: never;
|
|
||||||
|
|
||||||
export const iconNames = [
|
|
||||||
'ai',
|
|
||||||
'email',
|
|
||||||
'text',
|
|
||||||
'dateTime',
|
|
||||||
'keyboard',
|
|
||||||
'pen',
|
|
||||||
'account',
|
|
||||||
'embedWeb',
|
|
||||||
'layer',
|
|
||||||
'pin',
|
|
||||||
'appearance',
|
|
||||||
'eraser',
|
|
||||||
'layout',
|
|
||||||
'presentation',
|
|
||||||
'bookmark',
|
|
||||||
'exportToHtml',
|
|
||||||
'lightMode',
|
|
||||||
'progress',
|
|
||||||
'bulletedList',
|
|
||||||
'exportToMarkdown',
|
|
||||||
'link',
|
|
||||||
'publish',
|
|
||||||
'camera',
|
|
||||||
'exportToPdf',
|
|
||||||
'linkedEdgeless',
|
|
||||||
'quote',
|
|
||||||
'checkBoxCheckLinear',
|
|
||||||
'exportToPng',
|
|
||||||
'linkedPage',
|
|
||||||
'save',
|
|
||||||
'cloudWorkspace',
|
|
||||||
'exportToSvg',
|
|
||||||
'localData',
|
|
||||||
'shape',
|
|
||||||
'code',
|
|
||||||
'favorite',
|
|
||||||
'localWorkspace',
|
|
||||||
'style',
|
|
||||||
'codeBlock',
|
|
||||||
'file',
|
|
||||||
'lock',
|
|
||||||
'tag',
|
|
||||||
'collaboration',
|
|
||||||
'folder',
|
|
||||||
'multiSelect',
|
|
||||||
'tags',
|
|
||||||
'colorPicker',
|
|
||||||
'frame',
|
|
||||||
'new',
|
|
||||||
'today',
|
|
||||||
'contactWithUs',
|
|
||||||
'grid',
|
|
||||||
'now',
|
|
||||||
'upgrade',
|
|
||||||
'darkMode',
|
|
||||||
'grouping',
|
|
||||||
'number',
|
|
||||||
'userGuide',
|
|
||||||
'databaseKanbanView',
|
|
||||||
'image',
|
|
||||||
'numberedList',
|
|
||||||
'view',
|
|
||||||
'databaseListView',
|
|
||||||
'inbox',
|
|
||||||
'other',
|
|
||||||
'viewLayers',
|
|
||||||
'databaseTableView',
|
|
||||||
'info',
|
|
||||||
'page',
|
|
||||||
'attachment',
|
|
||||||
'delete',
|
|
||||||
'issue',
|
|
||||||
'paste',
|
|
||||||
'heartbreak',
|
|
||||||
'edgeless',
|
|
||||||
'journal',
|
|
||||||
'payment',
|
|
||||||
'createdEdited',
|
|
||||||
] as const satisfies fromLibIconName<LibIconComponentName>[];
|
|
||||||
|
|
||||||
export type PagePropertyIcon = (typeof iconNames)[number];
|
|
||||||
|
|
||||||
export const getDefaultIconName = (
|
|
||||||
type: PagePropertyType
|
|
||||||
): PagePropertyIcon => {
|
|
||||||
switch (type) {
|
|
||||||
case 'text':
|
|
||||||
return 'text';
|
|
||||||
case 'tags':
|
|
||||||
return 'tag';
|
|
||||||
case 'date':
|
|
||||||
return 'dateTime';
|
|
||||||
case 'progress':
|
|
||||||
return 'progress';
|
|
||||||
case 'checkbox':
|
|
||||||
return 'checkBoxCheckLinear';
|
|
||||||
case 'number':
|
|
||||||
return 'number';
|
|
||||||
case 'createdBy':
|
|
||||||
return 'createdEdited';
|
|
||||||
case 'updatedBy':
|
|
||||||
return 'createdEdited';
|
|
||||||
default:
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSafeIconName = (
|
|
||||||
iconName: string,
|
|
||||||
type?: PagePropertyType
|
|
||||||
): PagePropertyIcon => {
|
|
||||||
return iconNames.includes(iconName as any)
|
|
||||||
? (iconName as PagePropertyIcon)
|
|
||||||
: getDefaultIconName(type || PagePropertyType.Text);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nameToComponentName = (
|
|
||||||
iconName: PagePropertyIcon
|
|
||||||
): LibIconComponentName => {
|
|
||||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
return `${capitalize(iconName)}Icon` as LibIconComponentName;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const nameToIcon = (
|
|
||||||
iconName: string,
|
|
||||||
type?: PagePropertyType
|
|
||||||
): IconType => {
|
|
||||||
const Icon = icons[nameToComponentName(getSafeIconName(iconName, type))];
|
|
||||||
if (!Icon) {
|
|
||||||
throw new Error(`Icon ${iconName} not found`);
|
|
||||||
}
|
|
||||||
return Icon;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type * as icons from '@blocksuite/icons/rc';
|
||||||
|
|
||||||
|
// assume all exports in icons are icon Components
|
||||||
|
type LibIconComponentName = keyof typeof icons;
|
||||||
|
|
||||||
|
type fromLibIconName<T extends string> = T extends `${infer N}Icon`
|
||||||
|
? Uncapitalize<N>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export const DocPropertyIconNames = [
|
||||||
|
'ai',
|
||||||
|
'email',
|
||||||
|
'text',
|
||||||
|
'dateTime',
|
||||||
|
'keyboard',
|
||||||
|
'pen',
|
||||||
|
'account',
|
||||||
|
'embedWeb',
|
||||||
|
'layer',
|
||||||
|
'pin',
|
||||||
|
'appearance',
|
||||||
|
'eraser',
|
||||||
|
'layout',
|
||||||
|
'presentation',
|
||||||
|
'bookmark',
|
||||||
|
'exportToHtml',
|
||||||
|
'lightMode',
|
||||||
|
'progress',
|
||||||
|
'bulletedList',
|
||||||
|
'exportToMarkdown',
|
||||||
|
'link',
|
||||||
|
'publish',
|
||||||
|
'camera',
|
||||||
|
'exportToPdf',
|
||||||
|
'linkedEdgeless',
|
||||||
|
'quote',
|
||||||
|
'checkBoxCheckLinear',
|
||||||
|
'exportToPng',
|
||||||
|
'linkedPage',
|
||||||
|
'save',
|
||||||
|
'cloudWorkspace',
|
||||||
|
'exportToSvg',
|
||||||
|
'localData',
|
||||||
|
'shape',
|
||||||
|
'code',
|
||||||
|
'favorite',
|
||||||
|
'localWorkspace',
|
||||||
|
'style',
|
||||||
|
'codeBlock',
|
||||||
|
'file',
|
||||||
|
'lock',
|
||||||
|
'tag',
|
||||||
|
'collaboration',
|
||||||
|
'folder',
|
||||||
|
'multiSelect',
|
||||||
|
'tags',
|
||||||
|
'colorPicker',
|
||||||
|
'frame',
|
||||||
|
'new',
|
||||||
|
'today',
|
||||||
|
'contactWithUs',
|
||||||
|
'grid',
|
||||||
|
'now',
|
||||||
|
'upgrade',
|
||||||
|
'darkMode',
|
||||||
|
'grouping',
|
||||||
|
'number',
|
||||||
|
'userGuide',
|
||||||
|
'databaseKanbanView',
|
||||||
|
'image',
|
||||||
|
'numberedList',
|
||||||
|
'view',
|
||||||
|
'databaseListView',
|
||||||
|
'inbox',
|
||||||
|
'other',
|
||||||
|
'viewLayers',
|
||||||
|
'databaseTableView',
|
||||||
|
'info',
|
||||||
|
'page',
|
||||||
|
'attachment',
|
||||||
|
'delete',
|
||||||
|
'issue',
|
||||||
|
'paste',
|
||||||
|
'heartbreak',
|
||||||
|
'edgeless',
|
||||||
|
'journal',
|
||||||
|
'payment',
|
||||||
|
'createdEdited',
|
||||||
|
] as const satisfies fromLibIconName<LibIconComponentName>[];
|
||||||
|
|
||||||
|
export type DocPropertyIconName = (typeof DocPropertyIconNames)[number];
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as icons from '@blocksuite/icons/rc';
|
||||||
|
import type { DocCustomPropertyInfo } from '@toeverything/infra';
|
||||||
|
import type { SVGProps } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DocPropertyTypes,
|
||||||
|
isSupportedDocPropertyType,
|
||||||
|
} from '../types/constant';
|
||||||
|
import { type DocPropertyIconName, DocPropertyIconNames } from './constant';
|
||||||
|
|
||||||
|
// assume all exports in icons are icon Components
|
||||||
|
type LibIconComponentName = keyof typeof icons;
|
||||||
|
|
||||||
|
export const iconNameToComponent = (name: DocPropertyIconName) => {
|
||||||
|
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
const IconComponent =
|
||||||
|
icons[`${capitalize(name)}Icon` as LibIconComponentName];
|
||||||
|
if (!IconComponent) {
|
||||||
|
throw new Error(`Icon ${name} not found`);
|
||||||
|
}
|
||||||
|
return IconComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocPropertyIcon = ({
|
||||||
|
propertyInfo,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
propertyInfo: DocCustomPropertyInfo;
|
||||||
|
} & SVGProps<SVGSVGElement>) => {
|
||||||
|
const Icon =
|
||||||
|
propertyInfo.icon &&
|
||||||
|
DocPropertyIconNames.includes(propertyInfo.icon as DocPropertyIconName)
|
||||||
|
? iconNameToComponent(propertyInfo.icon as DocPropertyIconName)
|
||||||
|
: isSupportedDocPropertyType(propertyInfo.type)
|
||||||
|
? DocPropertyTypes[propertyInfo.type].icon
|
||||||
|
: DocPropertyTypes.text.icon;
|
||||||
|
|
||||||
|
return <Icon {...props} />;
|
||||||
|
};
|
||||||
@@ -1,37 +1,23 @@
|
|||||||
import { Menu, Scrollable } from '@affine/component';
|
import { Menu, Scrollable } from '@affine/component';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import type { DocCustomPropertyInfo } from '@toeverything/infra';
|
||||||
import { chunk } from 'lodash-es';
|
import { chunk } from 'lodash-es';
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
import type { PagePropertyIcon } from './icons-mapping';
|
import { type DocPropertyIconName, DocPropertyIconNames } from './constant';
|
||||||
import { iconNames, nameToIcon } from './icons-mapping';
|
import { DocPropertyIcon, iconNameToComponent } from './doc-property-icon';
|
||||||
import * as styles from './icons-selector.css';
|
import * as styles from './icons-selector.css';
|
||||||
|
|
||||||
const iconsPerRow = 6;
|
const iconsPerRow = 6;
|
||||||
|
|
||||||
const iconRows = chunk(iconNames, iconsPerRow);
|
const iconRows = chunk(DocPropertyIconNames, iconsPerRow);
|
||||||
|
|
||||||
export const IconsSelectorPanel = ({
|
const IconsSelectorPanel = ({
|
||||||
selected,
|
selectedIcon,
|
||||||
onSelectedChange,
|
onSelectedChange,
|
||||||
}: {
|
}: {
|
||||||
selected: PagePropertyIcon;
|
selectedIcon?: string | null;
|
||||||
onSelectedChange: (icon: PagePropertyIcon) => void;
|
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();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Scrollable.Root>
|
<Scrollable.Root>
|
||||||
@@ -39,19 +25,19 @@ export const IconsSelectorPanel = ({
|
|||||||
{t['com.affine.page-properties.icons']()}
|
{t['com.affine.page-properties.icons']()}
|
||||||
</div>
|
</div>
|
||||||
<Scrollable.Viewport className={styles.iconsContainerScrollable}>
|
<Scrollable.Viewport className={styles.iconsContainerScrollable}>
|
||||||
<div className={styles.iconsContainer} ref={ref}>
|
<div className={styles.iconsContainer}>
|
||||||
{iconRows.map((iconRow, index) => {
|
{iconRows.map((iconRow, index) => {
|
||||||
return (
|
return (
|
||||||
<div key={index} className={styles.iconsRow}>
|
<div key={index} className={styles.iconsRow}>
|
||||||
{iconRow.map(iconName => {
|
{iconRow.map(iconName => {
|
||||||
const Icon = nameToIcon(iconName);
|
const Icon = iconNameToComponent(iconName);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onSelectedChange(iconName)}
|
onClick={() => onSelectedChange(iconName)}
|
||||||
key={iconName}
|
key={iconName}
|
||||||
className={styles.iconButton}
|
className={styles.iconButton}
|
||||||
data-name={iconName}
|
data-name={iconName}
|
||||||
data-active={selected === iconName}
|
data-active={iconName === selectedIcon}
|
||||||
>
|
>
|
||||||
<Icon key={iconName} />
|
<Icon key={iconName} />
|
||||||
</div>
|
</div>
|
||||||
@@ -67,25 +53,24 @@ export const IconsSelectorPanel = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IconsSelectorButton = ({
|
export const DocPropertyIconSelector = ({
|
||||||
selected,
|
propertyInfo,
|
||||||
onSelectedChange,
|
onSelectedChange,
|
||||||
}: {
|
}: {
|
||||||
selected: PagePropertyIcon;
|
propertyInfo: DocCustomPropertyInfo;
|
||||||
onSelectedChange: (icon: PagePropertyIcon) => void;
|
onSelectedChange: (icon: DocPropertyIconName) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const Icon = nameToIcon(selected);
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
items={
|
items={
|
||||||
<IconsSelectorPanel
|
<IconsSelectorPanel
|
||||||
selected={selected}
|
selectedIcon={propertyInfo.icon}
|
||||||
onSelectedChange={onSelectedChange}
|
onSelectedChange={onSelectedChange}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={styles.iconSelectorButton}>
|
<div className={styles.iconSelectorButton}>
|
||||||
<Icon />
|
<DocPropertyIcon propertyInfo={propertyInfo} />
|
||||||
</div>
|
</div>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
export * from './icons-mapping';
|
|
||||||
export * from './info-modal/info-modal';
|
export * from './info-modal/info-modal';
|
||||||
export * from './page-properties-manager';
|
|
||||||
export * from './table';
|
export * from './table';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { rowHPadding } from '../styles.css';
|
|
||||||
|
|
||||||
export const container = style({
|
export const container = style({
|
||||||
maxWidth: 480,
|
maxWidth: 480,
|
||||||
@@ -9,9 +8,6 @@ export const container = style({
|
|||||||
padding: '20px 0',
|
padding: '20px 0',
|
||||||
alignSelf: 'start',
|
alignSelf: 'start',
|
||||||
marginTop: '120px',
|
marginTop: '120px',
|
||||||
vars: {
|
|
||||||
[rowHPadding]: '6px',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const titleContainer = style({
|
export const titleContainer = style({
|
||||||
@@ -53,3 +49,36 @@ export const timeRow = style({
|
|||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
borderBottom: 4,
|
borderBottom: 4,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const tableBodyRoot = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addPropertyButton = style({
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
color: `${cssVarV2('text/secondary')}`,
|
||||||
|
padding: '0 4px',
|
||||||
|
height: 36,
|
||||||
|
fontWeight: 400,
|
||||||
|
gap: 6,
|
||||||
|
'@media': {
|
||||||
|
print: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selectors: {
|
||||||
|
[`[data-property-collapsed="true"] &`]: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
globalStyle(`${addPropertyButton} svg`, {
|
||||||
|
fontSize: 16,
|
||||||
|
color: cssVarV2('icon/secondary'),
|
||||||
|
});
|
||||||
|
globalStyle(`${addPropertyButton}:hover svg`, {
|
||||||
|
color: cssVarV2('icon/primary'),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
type InlineEditHandle,
|
type InlineEditHandle,
|
||||||
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
|
PropertyCollapsible,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { DocInfoService } from '@affine/core/modules/doc-info';
|
import { DocInfoService } from '@affine/core/modules/doc-info';
|
||||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||||
import type { Doc } from '@toeverything/infra';
|
import type { Doc } from '@toeverything/infra';
|
||||||
import {
|
import {
|
||||||
DocsService,
|
DocsService,
|
||||||
@@ -16,27 +20,13 @@ import {
|
|||||||
useService,
|
useService,
|
||||||
useServices,
|
useServices,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import {
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
Suspense,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { BlocksuiteHeaderTitle } from '../../../blocksuite/block-suite-header/title';
|
import { BlocksuiteHeaderTitle } from '../../../blocksuite/block-suite-header/title';
|
||||||
import { managerContext } from '../common';
|
import { CreatePropertyMenuItems } from '../menu/create-doc-property';
|
||||||
import {
|
import { PagePropertyRow } from '../table';
|
||||||
PagePropertiesAddProperty,
|
|
||||||
PagePropertyRow,
|
|
||||||
SortableProperties,
|
|
||||||
usePagePropertiesManager,
|
|
||||||
} from '../table';
|
|
||||||
import * as styles from './info-modal.css';
|
import * as styles from './info-modal.css';
|
||||||
import { LinksRow } from './links-row';
|
import { LinksRow } from './links-row';
|
||||||
import { TagsRow } from './tags-row';
|
|
||||||
import { TimeRow } from './time-row';
|
import { TimeRow } from './time-row';
|
||||||
|
|
||||||
export const InfoModal = () => {
|
export const InfoModal = () => {
|
||||||
@@ -68,15 +58,10 @@ const InfoModalOpened = ({ docId }: { docId: string }) => {
|
|||||||
const modal = useService(DocInfoService).modal;
|
const modal = useService(DocInfoService).modal;
|
||||||
|
|
||||||
const titleInputHandleRef = useRef<InlineEditHandle>(null);
|
const titleInputHandleRef = useRef<InlineEditHandle>(null);
|
||||||
const manager = usePagePropertiesManager(docId ?? '');
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
modal.close();
|
modal.close();
|
||||||
}, [modal]);
|
}, [modal]);
|
||||||
|
|
||||||
if (!manager.page || manager.readonly) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
contentOptions={{
|
contentOptions={{
|
||||||
@@ -98,15 +83,7 @@ const InfoModalOpened = ({ docId }: { docId: string }) => {
|
|||||||
inputHandleRef={titleInputHandleRef}
|
inputHandleRef={titleInputHandleRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<managerContext.Provider value={manager}>
|
<InfoTable docId={docId} onClose={handleClose} />
|
||||||
<Suspense>
|
|
||||||
<InfoTable
|
|
||||||
docId={docId}
|
|
||||||
onClose={handleClose}
|
|
||||||
readonly={manager.readonly}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</managerContext.Provider>
|
|
||||||
</Scrollable.Viewport>
|
</Scrollable.Viewport>
|
||||||
<Scrollable.Scrollbar className={styles.scrollBar} />
|
<Scrollable.Scrollbar className={styles.scrollBar} />
|
||||||
</Scrollable.Root>
|
</Scrollable.Root>
|
||||||
@@ -117,17 +94,17 @@ const InfoModalOpened = ({ docId }: { docId: string }) => {
|
|||||||
export const InfoTable = ({
|
export const InfoTable = ({
|
||||||
onClose,
|
onClose,
|
||||||
docId,
|
docId,
|
||||||
readonly,
|
|
||||||
}: {
|
}: {
|
||||||
docId: string;
|
docId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
readonly: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const manager = useContext(managerContext);
|
const { docsSearchService, docsService } = useServices({
|
||||||
const { docsSearchService } = useServices({
|
|
||||||
DocsSearchService,
|
DocsSearchService,
|
||||||
|
DocsService,
|
||||||
});
|
});
|
||||||
|
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
|
||||||
|
const properties = useLiveData(docsService.propertyList.sortedProperties$);
|
||||||
const links = useLiveData(
|
const links = useLiveData(
|
||||||
useMemo(
|
useMemo(
|
||||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||||
@@ -165,33 +142,50 @@ export const InfoTable = ({
|
|||||||
<Divider size="thinner" />
|
<Divider size="thinner" />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<TagsRow docId={docId} readonly={readonly} />
|
<PropertyCollapsible
|
||||||
<SortableProperties>
|
className={styles.tableBodyRoot}
|
||||||
{properties =>
|
collapseButtonText={({ hide, isCollapsed }) =>
|
||||||
properties.length ? (
|
isCollapsed
|
||||||
<div>
|
? hide === 1
|
||||||
{properties
|
? t['com.affine.page-properties.more-property.one']({
|
||||||
.filter(
|
count: hide.toString(),
|
||||||
property =>
|
})
|
||||||
manager.isPropertyRequired(property.id) ||
|
: t['com.affine.page-properties.more-property.more']({
|
||||||
(property.visibility !== 'hide' &&
|
count: hide.toString(),
|
||||||
!(
|
})
|
||||||
property.visibility === 'hide-if-empty' &&
|
: hide === 1
|
||||||
!property.value
|
? t['com.affine.page-properties.hide-property.one']({
|
||||||
))
|
count: hide.toString(),
|
||||||
)
|
})
|
||||||
.map(property => (
|
: t['com.affine.page-properties.hide-property.more']({
|
||||||
<PagePropertyRow
|
count: hide.toString(),
|
||||||
key={property.id}
|
})
|
||||||
property={property}
|
|
||||||
rowNameClassName={styles.rowNameContainer}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { fallbackVar, style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
import { rowHPadding } from '../styles.css';
|
|
||||||
|
|
||||||
export const icon = style({
|
|
||||||
fontSize: 16,
|
|
||||||
color: cssVar('iconSecondary'),
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const rowNameContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
padding: 6,
|
|
||||||
width: '160px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const rowName = style({
|
|
||||||
flexGrow: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
color: cssVar('textSecondaryColor'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const time = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '6px 8px',
|
|
||||||
flexGrow: 1,
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const rowCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'start',
|
|
||||||
gap: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const container = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
marginTop: 20,
|
|
||||||
marginBottom: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const rowValueCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
position: 'relative',
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
lineHeight: '22px',
|
|
||||||
userSelect: 'none',
|
|
||||||
':focus-visible': {
|
|
||||||
outline: 'none',
|
|
||||||
},
|
|
||||||
cursor: 'pointer',
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: cssVar('hoverColor'),
|
|
||||||
},
|
|
||||||
padding: `6px ${fallbackVar(rowHPadding, '8px')} 6px 8px`,
|
|
||||||
border: `1px solid transparent`,
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
':focus': {
|
|
||||||
backgroundColor: cssVar('hoverColor'),
|
|
||||||
},
|
|
||||||
'::placeholder': {
|
|
||||||
color: cssVar('placeholderColor'),
|
|
||||||
},
|
|
||||||
selectors: {
|
|
||||||
'&[data-empty="true"]': {
|
|
||||||
color: cssVar('placeholderColor'),
|
|
||||||
},
|
|
||||||
'&[data-readonly=true]': {
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
flex: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagsMenu = style({
|
|
||||||
padding: 0,
|
|
||||||
transform:
|
|
||||||
'translate(-3.5px, calc(-3.5px + var(--radix-popper-anchor-height) * -1))',
|
|
||||||
width: 'calc(var(--radix-popper-anchor-width) + 16px)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagsInlineEditor = style({
|
|
||||||
selectors: {
|
|
||||||
'&[data-empty=true]': {
|
|
||||||
color: cssVar('placeholderColor'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Menu } from '@affine/component';
|
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { TagsIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
import { InlineTagsList, TagsEditor } from '../tags-inline-editor';
|
|
||||||
import * as styles from './tags-row.css';
|
|
||||||
|
|
||||||
export const TagsRow = ({
|
|
||||||
docId,
|
|
||||||
readonly,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
docId: string;
|
|
||||||
readonly: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const tagList = useService(TagService).tagList;
|
|
||||||
const tagIds = useLiveData(tagList.tagIdsByPageId$(docId));
|
|
||||||
const empty = !tagIds || tagIds.length === 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(styles.rowCell, className)}
|
|
||||||
data-testid="info-modal-tags-row"
|
|
||||||
>
|
|
||||||
<div className={styles.rowNameContainer}>
|
|
||||||
<div className={styles.icon}>
|
|
||||||
<TagsIcon />
|
|
||||||
</div>
|
|
||||||
<div className={styles.rowName}>{t['Tags']()}</div>
|
|
||||||
</div>
|
|
||||||
<Menu
|
|
||||||
contentOptions={{
|
|
||||||
side: 'bottom',
|
|
||||||
align: 'start',
|
|
||||||
sideOffset: 0,
|
|
||||||
avoidCollisions: false,
|
|
||||||
className: styles.tagsMenu,
|
|
||||||
onClick(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
items={<TagsEditor pageId={docId} readonly={readonly} />}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={clsx(styles.tagsInlineEditor, styles.rowValueCell)}
|
|
||||||
data-empty={empty}
|
|
||||||
data-readonly={readonly}
|
|
||||||
data-testid="info-modal-tags-value"
|
|
||||||
>
|
|
||||||
{empty ? (
|
|
||||||
t['com.affine.page-properties.property-value-placeholder']()
|
|
||||||
) : (
|
|
||||||
<InlineTagsList pageId={docId} readonly />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,48 +1,5 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { style } from '@vanilla-extract/css';
|
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({
|
export const container = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -1,34 +1,19 @@
|
|||||||
|
import { PropertyName, PropertyRoot, PropertyValue } from '@affine/component';
|
||||||
import { i18nTime, useI18n } from '@affine/i18n';
|
import { i18nTime, useI18n } from '@affine/i18n';
|
||||||
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
|
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 clsx from 'clsx';
|
||||||
import type { ConfigType } from 'dayjs';
|
import type { ConfigType } from 'dayjs';
|
||||||
import { useDebouncedValue } from 'foxact/use-debounced-value';
|
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';
|
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 = ({
|
export const TimeRow = ({
|
||||||
docId,
|
docId,
|
||||||
className,
|
className,
|
||||||
@@ -37,11 +22,13 @@ export const TimeRow = ({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const manager = useContext(managerContext);
|
|
||||||
const workspaceService = useService(WorkspaceService);
|
const workspaceService = useService(WorkspaceService);
|
||||||
|
const docsService = useService(DocsService);
|
||||||
const { syncing, retrying, serverClock } = useLiveData(
|
const { syncing, retrying, serverClock } = useLiveData(
|
||||||
workspaceService.workspace.engine.doc.docState$(docId)
|
workspaceService.workspace.engine.doc.docState$(docId)
|
||||||
);
|
);
|
||||||
|
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||||
|
const docMeta = useLiveData(docRecord?.meta$);
|
||||||
|
|
||||||
const timestampElement = useMemo(() => {
|
const timestampElement = useMemo(() => {
|
||||||
const formatI18nTime = (time: ConfigType) =>
|
const formatI18nTime = (time: ConfigType) =>
|
||||||
@@ -54,44 +41,43 @@ export const TimeRow = ({
|
|||||||
accuracy: 'day',
|
accuracy: 'day',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const localizedCreateTime = manager.createDate
|
const localizedCreateTime = docMeta
|
||||||
? formatI18nTime(manager.createDate)
|
? formatI18nTime(docMeta.createDate)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RowComponent
|
<PropertyRoot>
|
||||||
icon={<DateTimeIcon />}
|
<PropertyName name={t['Created']()} icon={<DateTimeIcon />} />
|
||||||
name={t['Created']()}
|
<PropertyValue>
|
||||||
time={
|
{docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime}
|
||||||
manager.createDate
|
</PropertyValue>
|
||||||
? formatI18nTime(manager.createDate)
|
</PropertyRoot>
|
||||||
: localizedCreateTime
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{serverClock ? (
|
{serverClock ? (
|
||||||
<RowComponent
|
<PropertyRoot>
|
||||||
icon={<HistoryIcon />}
|
<PropertyName
|
||||||
name={t[!syncing && !retrying ? 'Updated' : 'com.affine.syncing']()}
|
name={t[
|
||||||
time={!syncing && !retrying ? formatI18nTime(serverClock) : null}
|
!syncing && !retrying ? 'Updated' : 'com.affine.syncing'
|
||||||
/>
|
]()}
|
||||||
) : manager.updatedDate ? (
|
icon={<HistoryIcon />}
|
||||||
<RowComponent
|
/>
|
||||||
icon={<HistoryIcon />}
|
<PropertyValue>
|
||||||
name={t['Updated']()}
|
{!syncing && !retrying
|
||||||
time={formatI18nTime(manager.updatedDate)}
|
? 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}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [
|
}, [docMeta, retrying, serverClock, syncing, t]);
|
||||||
manager.createDate,
|
|
||||||
manager.updatedDate,
|
|
||||||
retrying,
|
|
||||||
serverClock,
|
|
||||||
syncing,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const dTimestampElement = useDebouncedValue(timestampElement, 500);
|
const dTimestampElement = useDebouncedValue(timestampElement, 500);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
DropIndicator,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
useDraggable,
|
||||||
|
useDropTarget,
|
||||||
|
} from '@affine/component';
|
||||||
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||||
|
import {
|
||||||
|
type DocCustomPropertyInfo,
|
||||||
|
DocsService,
|
||||||
|
useLiveData,
|
||||||
|
useService,
|
||||||
|
WorkspaceService,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { type HTMLProps, useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { DocPropertyIcon } from '../icons/doc-property-icon';
|
||||||
|
import { EditDocPropertyMenuItems } from '../menu/edit-doc-property';
|
||||||
|
import {
|
||||||
|
DocPropertyTypes,
|
||||||
|
isSupportedDocPropertyType,
|
||||||
|
} from '../types/constant';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
const PropertyItem = ({
|
||||||
|
propertyInfo,
|
||||||
|
defaultOpenEditMenu,
|
||||||
|
}: {
|
||||||
|
propertyInfo: DocCustomPropertyInfo;
|
||||||
|
defaultOpenEditMenu?: boolean;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const workspaceService = useService(WorkspaceService);
|
||||||
|
const docsService = useService(DocsService);
|
||||||
|
const [moreMenuOpen, setMoreMenuOpen] = useState(defaultOpenEditMenu);
|
||||||
|
|
||||||
|
const typeInfo = isSupportedDocPropertyType(propertyInfo.type)
|
||||||
|
? DocPropertyTypes[propertyInfo.type]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setMoreMenuOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { dragRef } = useDraggable<AffineDNDData>(
|
||||||
|
() => ({
|
||||||
|
data: {
|
||||||
|
entity: {
|
||||||
|
type: 'custom-property',
|
||||||
|
id: propertyInfo.id,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
at: 'doc-property:manager',
|
||||||
|
workspaceId: workspaceService.workspace.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[propertyInfo, workspaceService]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { dropTargetRef, closestEdge } = useDropTarget<AffineDNDData>(
|
||||||
|
() => ({
|
||||||
|
canDrop(data) {
|
||||||
|
return (
|
||||||
|
data.source.data.entity?.type === 'custom-property' &&
|
||||||
|
data.source.data.from?.at === 'doc-property:manager' &&
|
||||||
|
data.source.data.from?.workspaceId ===
|
||||||
|
workspaceService.workspace.id &&
|
||||||
|
data.source.data.entity.id !== propertyInfo.id
|
||||||
|
);
|
||||||
|
},
|
||||||
|
closestEdge: {
|
||||||
|
allowedEdges: ['top', 'bottom'],
|
||||||
|
},
|
||||||
|
isSticky: true,
|
||||||
|
onDrop(data) {
|
||||||
|
if (data.source.data.entity?.type !== 'custom-property') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const propertyId = data.source.data.entity.id;
|
||||||
|
const edge = data.closestEdge;
|
||||||
|
if (edge !== 'bottom' && edge !== 'top') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
docsService.propertyList.updatePropertyInfo(propertyId, {
|
||||||
|
index: docsService.propertyList.indexAt(
|
||||||
|
edge === 'bottom' ? 'after' : 'before',
|
||||||
|
propertyInfo.id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[docsService, propertyInfo, workspaceService]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.itemContainer}
|
||||||
|
ref={elem => {
|
||||||
|
dropTargetRef.current = elem;
|
||||||
|
dragRef.current = elem;
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid="doc-property-manager-item"
|
||||||
|
>
|
||||||
|
<DocPropertyIcon
|
||||||
|
className={styles.itemIcon}
|
||||||
|
propertyInfo={propertyInfo}
|
||||||
|
/>
|
||||||
|
<span className={styles.itemName}>
|
||||||
|
{propertyInfo.name ||
|
||||||
|
(typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']())}
|
||||||
|
</span>
|
||||||
|
<span className={styles.itemVisibility}>
|
||||||
|
{propertyInfo.show === 'hide-when-empty'
|
||||||
|
? t['com.affine.page-properties.property.hide-when-empty']()
|
||||||
|
: propertyInfo.show === 'always-hide'
|
||||||
|
? t['com.affine.page-properties.property.always-hide']()
|
||||||
|
: t['com.affine.page-properties.property.always-show']()}
|
||||||
|
</span>
|
||||||
|
<Menu
|
||||||
|
rootOptions={{
|
||||||
|
open: moreMenuOpen,
|
||||||
|
onOpenChange: setMoreMenuOpen,
|
||||||
|
modal: true,
|
||||||
|
}}
|
||||||
|
items={<EditDocPropertyMenuItems propertyId={propertyInfo.id} />}
|
||||||
|
>
|
||||||
|
<IconButton size={20} iconClassName={styles.itemMore}>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Menu>
|
||||||
|
<DropIndicator edge={closestEdge} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocPropertyManager = ({
|
||||||
|
className,
|
||||||
|
defaultOpenEditMenuPropertyId,
|
||||||
|
...props
|
||||||
|
}: HTMLProps<HTMLDivElement> & { defaultOpenEditMenuPropertyId?: string }) => {
|
||||||
|
const docsService = useService(DocsService);
|
||||||
|
|
||||||
|
const properties = useLiveData(docsService.propertyList.sortedProperties$);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.container, className)} {...props}>
|
||||||
|
{properties.map(propertyInfo => (
|
||||||
|
<PropertyItem
|
||||||
|
propertyInfo={propertyInfo}
|
||||||
|
defaultOpenEditMenu={
|
||||||
|
defaultOpenEditMenuPropertyId === propertyInfo.id
|
||||||
|
}
|
||||||
|
key={propertyInfo.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const container = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '4px 8px',
|
||||||
|
gap: '8px',
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
borderRadius: '6px',
|
||||||
|
lineHeight: '22px',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
selectors: {
|
||||||
|
'&': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
},
|
||||||
|
'&[data-drag-preview="true"]': {
|
||||||
|
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
'&[data-dragging="true"]': {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
'&[draggable="true"]:before': {
|
||||||
|
content: '""',
|
||||||
|
display: 'block',
|
||||||
|
position: 'absolute',
|
||||||
|
cursor: 'grab',
|
||||||
|
top: '50%',
|
||||||
|
left: 0,
|
||||||
|
borderRadius: '2px',
|
||||||
|
backgroundColor: cssVarV2('text/placeholder'),
|
||||||
|
transform: 'translate(-6px, -50%)',
|
||||||
|
transition: 'height 0.2s 0.1s, opacity 0.2s 0.1s',
|
||||||
|
opacity: 0,
|
||||||
|
height: '4px',
|
||||||
|
width: '4px',
|
||||||
|
willChange: 'height, opacity',
|
||||||
|
},
|
||||||
|
'&[draggable="true"]:after': {
|
||||||
|
content: '""',
|
||||||
|
display: 'block',
|
||||||
|
position: 'absolute',
|
||||||
|
cursor: 'grab',
|
||||||
|
top: '50%',
|
||||||
|
left: 0,
|
||||||
|
borderRadius: '2px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
transform: 'translate(-8px, -50%)',
|
||||||
|
height: '100%',
|
||||||
|
width: '8px',
|
||||||
|
willChange: 'height, opacity',
|
||||||
|
},
|
||||||
|
'&[draggable="true"]:hover:before': {
|
||||||
|
height: 12,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemIcon = style({
|
||||||
|
fontSize: '16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemName = style({
|
||||||
|
flex: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
color: cssVarV2('text/primary'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemVisibility = style({
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemMore = style({
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
});
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import type { MenuItemProps } from '@affine/component';
|
|
||||||
import { Input, MenuItem, MenuSeparator, Scrollable } from '@affine/component';
|
|
||||||
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import type { KeyboardEventHandler, MouseEventHandler } from 'react';
|
|
||||||
import { cloneElement, isValidElement, useCallback } from 'react';
|
|
||||||
|
|
||||||
import type { PagePropertyIcon } from './icons-mapping';
|
|
||||||
import {
|
|
||||||
getDefaultIconName,
|
|
||||||
getSafeIconName,
|
|
||||||
nameToIcon,
|
|
||||||
} from './icons-mapping';
|
|
||||||
import { IconsSelectorButton } from './icons-selector';
|
|
||||||
import * as styles from './styles.css';
|
|
||||||
export type MenuItemOption =
|
|
||||||
| React.ReactElement
|
|
||||||
| '-'
|
|
||||||
| {
|
|
||||||
text: string;
|
|
||||||
onClick: MouseEventHandler;
|
|
||||||
key?: string;
|
|
||||||
icon?: React.ReactElement;
|
|
||||||
selected?: boolean;
|
|
||||||
checked?: boolean;
|
|
||||||
type?: MenuItemProps['type'];
|
|
||||||
}
|
|
||||||
| MenuItemOption[];
|
|
||||||
|
|
||||||
const isElementOption = (e: MenuItemOption): e is React.ReactElement => {
|
|
||||||
return isValidElement(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const renderMenuItemOptions = (options: MenuItemOption[]) => {
|
|
||||||
return options.map((option, index) => {
|
|
||||||
if (option === '-') {
|
|
||||||
return <MenuSeparator key={index} />;
|
|
||||||
} else if (isElementOption(option)) {
|
|
||||||
return cloneElement(option, { key: index });
|
|
||||||
} else if (Array.isArray(option)) {
|
|
||||||
// this is an area that needs scrollbar
|
|
||||||
return (
|
|
||||||
<Scrollable.Root key={index} className={styles.menuItemListScrollable}>
|
|
||||||
<Scrollable.Viewport className={styles.menuItemList}>
|
|
||||||
{renderMenuItemOptions(option)}
|
|
||||||
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
|
|
||||||
</Scrollable.Viewport>
|
|
||||||
</Scrollable.Root>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const { text, icon, onClick, type, key, checked, selected } = option;
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
key={key ?? index}
|
|
||||||
type={type}
|
|
||||||
selected={selected}
|
|
||||||
checked={checked}
|
|
||||||
prefixIcon={icon}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EditPropertyNameMenuItem = ({
|
|
||||||
property,
|
|
||||||
onNameBlur: onBlur,
|
|
||||||
onNameChange,
|
|
||||||
onIconChange,
|
|
||||||
}: {
|
|
||||||
onNameBlur: (e: string) => void;
|
|
||||||
onNameChange: (e: string) => void;
|
|
||||||
onIconChange: (icon: PagePropertyIcon) => void;
|
|
||||||
property: PageInfoCustomPropertyMeta;
|
|
||||||
}) => {
|
|
||||||
const iconName = getSafeIconName(property.icon, property.type);
|
|
||||||
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
|
|
||||||
e => {
|
|
||||||
if (e.key !== 'Escape') {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
onBlur(e.currentTarget.value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onBlur]
|
|
||||||
);
|
|
||||||
const handleBlur = useCallback(
|
|
||||||
(e: FocusEvent & { currentTarget: HTMLInputElement }) => {
|
|
||||||
onBlur(e.currentTarget.value);
|
|
||||||
},
|
|
||||||
[onBlur]
|
|
||||||
);
|
|
||||||
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<div className={styles.propertyRowNamePopupRow}>
|
|
||||||
<IconsSelectorButton
|
|
||||||
selected={iconName}
|
|
||||||
onSelectedChange={onIconChange}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
defaultValue={property.name}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onChange={onNameChange}
|
|
||||||
placeholder={t['unnamed']()}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PropertyTypeMenuItem = ({
|
|
||||||
property,
|
|
||||||
}: {
|
|
||||||
property: PageInfoCustomPropertyMeta;
|
|
||||||
}) => {
|
|
||||||
const Icon = nameToIcon(getDefaultIconName(property.type), property.type);
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<div className={styles.propertyRowTypeItem}>
|
|
||||||
{t['com.affine.page-properties.create-property.menu.header']()}
|
|
||||||
<div className={styles.propertyTypeName}>
|
|
||||||
<Icon />
|
|
||||||
{t[`com.affine.page-properties.property.${property.type}`]()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const menuHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
fontWeight: 500,
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
padding: '8px 16px',
|
||||||
|
minWidth: 200,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyItem = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
|
minWidth: 200,
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { MenuItem, MenuSeparator } from '@affine/component';
|
||||||
|
import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DocPropertyTypes,
|
||||||
|
isSupportedDocPropertyType,
|
||||||
|
} from '../types/constant';
|
||||||
|
import * as styles from './create-doc-property.css';
|
||||||
|
|
||||||
|
export const CreatePropertyMenuItems = ({
|
||||||
|
at = 'before',
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
at?: 'before' | 'after';
|
||||||
|
onCreated?: (propertyId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const docsService = useService(DocsService);
|
||||||
|
const propertyList = docsService.propertyList;
|
||||||
|
const properties = useLiveData(propertyList.properties$);
|
||||||
|
|
||||||
|
const onAddProperty = useCallback(
|
||||||
|
(option: { type: string; name: string }) => {
|
||||||
|
if (!isSupportedDocPropertyType(option.type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typeDefined = DocPropertyTypes[option.type];
|
||||||
|
const nameExists = properties.some(meta => meta.name === option.name);
|
||||||
|
const allNames = properties
|
||||||
|
.map(meta => meta.name)
|
||||||
|
.filter((name): name is string => name !== null && name !== undefined);
|
||||||
|
const name = nameExists
|
||||||
|
? generateUniqueNameInSequence(option.name, allNames)
|
||||||
|
: option.name;
|
||||||
|
const uniqueId = typeDefined.uniqueId;
|
||||||
|
const newPropertyId = propertyList.createProperty({
|
||||||
|
id: uniqueId,
|
||||||
|
name,
|
||||||
|
type: option.type,
|
||||||
|
index: propertyList.indexAt(at),
|
||||||
|
});
|
||||||
|
onCreated?.(newPropertyId);
|
||||||
|
},
|
||||||
|
[at, onCreated, propertyList, properties]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div role="heading" className={styles.menuHeader}>
|
||||||
|
{t['com.affine.page-properties.create-property.menu.header']()}
|
||||||
|
</div>
|
||||||
|
<MenuSeparator />
|
||||||
|
{Object.entries(DocPropertyTypes).map(([type, info]) => {
|
||||||
|
const name = t.t(info.name);
|
||||||
|
const uniqueId = info.uniqueId;
|
||||||
|
const isUniqueExist = properties.some(meta => meta.id === uniqueId);
|
||||||
|
const Icon = info.icon;
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={type}
|
||||||
|
prefixIcon={<Icon />}
|
||||||
|
disabled={isUniqueExist}
|
||||||
|
onClick={() => {
|
||||||
|
onAddProperty({
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
data-testid="create-property-menu-item"
|
||||||
|
data-property-type={type}
|
||||||
|
>
|
||||||
|
<div className={styles.propertyItem}>
|
||||||
|
{name}
|
||||||
|
{isUniqueExist && <span>Added</span>}
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const propertyRowNamePopupRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 500,
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
padding: '8px 0px',
|
||||||
|
minWidth: 260,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyRowTypeItem = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
padding: '8px 4px',
|
||||||
|
minWidth: 260,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyTypeName = style({
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyName = style({
|
||||||
|
color: cssVarV2('text/primary'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
padding: '0 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import {
|
||||||
|
Input,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
useConfirmModal,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { Trans, useI18n } from '@affine/i18n';
|
||||||
|
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import {
|
||||||
|
type KeyboardEventHandler,
|
||||||
|
type MouseEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { DocPropertyIcon } from '../icons/doc-property-icon';
|
||||||
|
import { DocPropertyIconSelector } from '../icons/icons-selector';
|
||||||
|
import {
|
||||||
|
DocPropertyTypes,
|
||||||
|
isSupportedDocPropertyType,
|
||||||
|
} from '../types/constant';
|
||||||
|
import * as styles from './edit-doc-property.css';
|
||||||
|
|
||||||
|
export const EditDocPropertyMenuItems = ({
|
||||||
|
propertyId,
|
||||||
|
}: {
|
||||||
|
propertyId: string;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const docsService = useService(DocsService);
|
||||||
|
const propertyInfo = useLiveData(
|
||||||
|
docsService.propertyList.propertyInfo$(propertyId)
|
||||||
|
);
|
||||||
|
const propertyType = propertyInfo?.type;
|
||||||
|
const typeInfo =
|
||||||
|
propertyType && isSupportedDocPropertyType(propertyType)
|
||||||
|
? DocPropertyTypes[propertyType]
|
||||||
|
: undefined;
|
||||||
|
const propertyName =
|
||||||
|
propertyInfo?.name ||
|
||||||
|
(typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']());
|
||||||
|
const [name, setName] = useState(propertyName);
|
||||||
|
const confirmModal = useConfirmModal();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(propertyName);
|
||||||
|
}, [propertyName]);
|
||||||
|
|
||||||
|
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
|
||||||
|
e => {
|
||||||
|
if (e.key !== 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
docsService.propertyList.updatePropertyInfo(propertyId, {
|
||||||
|
name: e.currentTarget.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[docsService.propertyList, propertyId]
|
||||||
|
);
|
||||||
|
const handleBlur = useCallback(
|
||||||
|
(e: FocusEvent & { currentTarget: HTMLInputElement }) => {
|
||||||
|
docsService.propertyList.updatePropertyInfo(propertyId, {
|
||||||
|
name: e.currentTarget.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[docsService.propertyList, propertyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIconChange = useCallback(
|
||||||
|
(iconName: string) => {
|
||||||
|
docsService.propertyList.updatePropertyInfo(propertyId, {
|
||||||
|
icon: iconName,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[docsService.propertyList, propertyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNameChange = useCallback((e: string) => {
|
||||||
|
setName(e);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClickAlwaysShow = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
e.preventDefault(); // avoid radix-ui close the menu
|
||||||
|
docsService.propertyList.updatePropertyInfo(propertyId, {
|
||||||
|
show: 'always-show',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[docsService.propertyList, propertyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClickHideWhenEmpty = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
e.preventDefault(); // avoid radix-ui close the menu
|
||||||
|
docsService.propertyList.updatePropertyInfo(propertyId, {
|
||||||
|
show: 'hide-when-empty',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[docsService.propertyList, propertyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClickAlwaysHide = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
e.preventDefault(); // avoid radix-ui close the menu
|
||||||
|
docsService.propertyList.updatePropertyInfo(propertyId, {
|
||||||
|
show: 'always-hide',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[docsService.propertyList, propertyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!propertyInfo || !isSupportedDocPropertyType(propertyType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={styles.propertyRowNamePopupRow}
|
||||||
|
data-testid="edit-property-menu-item"
|
||||||
|
>
|
||||||
|
<DocPropertyIconSelector
|
||||||
|
propertyInfo={propertyInfo}
|
||||||
|
onSelectedChange={handleIconChange}
|
||||||
|
/>
|
||||||
|
{typeInfo?.renameable === false ? (
|
||||||
|
<span className={styles.propertyName}>{name}</span>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
placeholder={t['unnamed']()}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.propertyRowTypeItem}>
|
||||||
|
{t['com.affine.page-properties.create-property.menu.header']()}
|
||||||
|
<div className={styles.propertyTypeName}>
|
||||||
|
<DocPropertyIcon propertyInfo={propertyInfo} />
|
||||||
|
{t[`com.affine.page-properties.property.${propertyType}`]()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<ViewIcon />}
|
||||||
|
onClick={handleClickAlwaysShow}
|
||||||
|
selected={
|
||||||
|
propertyInfo.show !== 'hide-when-empty' &&
|
||||||
|
propertyInfo.show !== 'always-hide'
|
||||||
|
}
|
||||||
|
data-property-visibility="always-show"
|
||||||
|
>
|
||||||
|
{t['com.affine.page-properties.property.always-show']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<InvisibleIcon />}
|
||||||
|
onClick={handleClickHideWhenEmpty}
|
||||||
|
selected={propertyInfo.show === 'hide-when-empty'}
|
||||||
|
data-property-visibility="hide-when-empty"
|
||||||
|
>
|
||||||
|
{t['com.affine.page-properties.property.hide-when-empty']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<InvisibleIcon />}
|
||||||
|
onClick={handleClickAlwaysHide}
|
||||||
|
selected={propertyInfo.show === 'always-hide'}
|
||||||
|
data-property-visibility="always-hide"
|
||||||
|
>
|
||||||
|
{t['com.affine.page-properties.property.always-hide']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<DeleteIcon />}
|
||||||
|
type="danger"
|
||||||
|
onClick={() => {
|
||||||
|
confirmModal.openConfirmModal({
|
||||||
|
title:
|
||||||
|
t['com.affine.settings.workspace.properties.delete-property'](),
|
||||||
|
description: (
|
||||||
|
<Trans
|
||||||
|
values={{
|
||||||
|
name: propertyInfo.name,
|
||||||
|
}}
|
||||||
|
i18nKey="com.affine.settings.workspace.properties.delete-property-desc"
|
||||||
|
>
|
||||||
|
The <strong>{{ name: propertyInfo.name } as any}</strong>{' '}
|
||||||
|
property will be removed from count doc(s). This action cannot
|
||||||
|
be undone.
|
||||||
|
</Trans>
|
||||||
|
),
|
||||||
|
confirmText: t['Confirm'](),
|
||||||
|
onConfirm: () => {
|
||||||
|
docsService.propertyList.removeProperty(propertyId);
|
||||||
|
},
|
||||||
|
confirmButtonOptions: {
|
||||||
|
variant: 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t['com.affine.settings.workspace.properties.delete-property']()}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
||||||
import type { WorkspacePropertiesAdapter } from '@affine/core/modules/properties';
|
|
||||||
import type {
|
|
||||||
PageInfoCustomProperty,
|
|
||||||
PageInfoCustomPropertyMeta,
|
|
||||||
} from '@affine/core/modules/properties/services/schema';
|
|
||||||
import { PagePropertyType } from '@affine/core/modules/properties/services/schema';
|
|
||||||
import { createFractionalIndexingSortableHelper } from '@affine/core/utils';
|
|
||||||
import { DebugLogger } from '@affine/debug';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
|
|
||||||
import { getDefaultIconName } from './icons-mapping';
|
|
||||||
|
|
||||||
const logger = new DebugLogger('PagePropertiesManager');
|
|
||||||
|
|
||||||
function validatePropertyValue(type: PagePropertyType, value: any) {
|
|
||||||
switch (type) {
|
|
||||||
case PagePropertyType.Text:
|
|
||||||
return typeof value === 'string';
|
|
||||||
case PagePropertyType.Number:
|
|
||||||
return typeof value === 'number' || !isNaN(+value);
|
|
||||||
case PagePropertyType.Checkbox:
|
|
||||||
return typeof value === 'boolean';
|
|
||||||
case PagePropertyType.Date:
|
|
||||||
return value.match(/^\d{4}-\d{2}-\d{2}$/);
|
|
||||||
case PagePropertyType.Tags:
|
|
||||||
return Array.isArray(value) && value.every(v => typeof v === 'string');
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const newPropertyTypes: PagePropertyType[] = [
|
|
||||||
PagePropertyType.Text,
|
|
||||||
PagePropertyType.Number,
|
|
||||||
PagePropertyType.Checkbox,
|
|
||||||
PagePropertyType.Date,
|
|
||||||
PagePropertyType.CreatedBy,
|
|
||||||
PagePropertyType.UpdatedBy,
|
|
||||||
// TODO(@Peng): add more
|
|
||||||
];
|
|
||||||
|
|
||||||
export const readonlyPropertyTypes: PagePropertyType[] = [
|
|
||||||
PagePropertyType.CreatedBy,
|
|
||||||
PagePropertyType.UpdatedBy,
|
|
||||||
];
|
|
||||||
|
|
||||||
export class PagePropertiesMetaManager {
|
|
||||||
constructor(private readonly adapter: WorkspacePropertiesAdapter) {}
|
|
||||||
|
|
||||||
get propertiesSchema() {
|
|
||||||
return this.adapter.schema?.pageProperties ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
get systemPropertiesSchema() {
|
|
||||||
return this.adapter.schema?.pageProperties.system ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
get customPropertiesSchema() {
|
|
||||||
return this.adapter.schema?.pageProperties.custom ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
getOrderedPropertiesSchema() {
|
|
||||||
return Object.values(this.customPropertiesSchema).sort(
|
|
||||||
(a, b) => a.order - b.order
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkPropertyExists(id: string) {
|
|
||||||
return !!this.customPropertiesSchema[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
validatePropertyValue(id: string, value?: any) {
|
|
||||||
if (!value) {
|
|
||||||
// value is optional in all cases?
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const type = this.customPropertiesSchema[id]?.type;
|
|
||||||
if (!type) {
|
|
||||||
logger.warn(`property ${id} not found`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return validatePropertyValue(type, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
addPropertyMeta(schema: {
|
|
||||||
name: string;
|
|
||||||
type: PagePropertyType;
|
|
||||||
icon?: string;
|
|
||||||
}) {
|
|
||||||
const id = nanoid();
|
|
||||||
const { type, icon } = schema;
|
|
||||||
const newOrder =
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
...Object.values(this.customPropertiesSchema).map(p => p.order)
|
|
||||||
) + 1;
|
|
||||||
const property = {
|
|
||||||
...schema,
|
|
||||||
id,
|
|
||||||
source: 'custom',
|
|
||||||
type,
|
|
||||||
order: newOrder,
|
|
||||||
icon: icon ?? getDefaultIconName(type),
|
|
||||||
readonly: readonlyPropertyTypes.includes(type) || undefined,
|
|
||||||
} as const;
|
|
||||||
this.customPropertiesSchema[id] = property;
|
|
||||||
return property;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePropertyMeta(id: string, opt: Partial<PageInfoCustomPropertyMeta>) {
|
|
||||||
if (!this.checkPropertyExists(id)) {
|
|
||||||
logger.warn(`property ${id} not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Object.assign(this.customPropertiesSchema[id], opt);
|
|
||||||
}
|
|
||||||
|
|
||||||
isPropertyRequired(id: string) {
|
|
||||||
return this.customPropertiesSchema[id]?.required;
|
|
||||||
}
|
|
||||||
|
|
||||||
removePropertyMeta(id: string) {
|
|
||||||
// should warn if the property is in use
|
|
||||||
delete this.customPropertiesSchema[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
// returns page schema properties -> related page
|
|
||||||
getPropertyStatistics() {
|
|
||||||
const mapping = new Map<string, Set<string>>();
|
|
||||||
for (const page of this.adapter.workspace.docCollection.docs.values()) {
|
|
||||||
const properties = this.adapter.getPageProperties(page.id);
|
|
||||||
if (properties) {
|
|
||||||
for (const id of Object.keys(properties.custom)) {
|
|
||||||
if (!mapping.has(id)) mapping.set(id, new Set());
|
|
||||||
mapping.get(id)?.add(page.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPropertyRelatedPages(id: string) {
|
|
||||||
return this.getPropertyStatistics().get(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PagePropertiesManager {
|
|
||||||
public readonly metaManager: PagePropertiesMetaManager;
|
|
||||||
constructor(
|
|
||||||
private readonly adapter: WorkspacePropertiesAdapter,
|
|
||||||
public readonly pageId: string
|
|
||||||
) {
|
|
||||||
this.metaManager = new PagePropertiesMetaManager(this.adapter);
|
|
||||||
this.ensureRequiredProperties();
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly sorter = createFractionalIndexingSortableHelper<
|
|
||||||
PageInfoCustomProperty,
|
|
||||||
string | number
|
|
||||||
>(this);
|
|
||||||
|
|
||||||
// prevent infinite loop
|
|
||||||
private ensuring = false;
|
|
||||||
ensureRequiredProperties() {
|
|
||||||
this.adapter.ensurePageProperties(this.pageId);
|
|
||||||
if (this.ensuring) return;
|
|
||||||
this.ensuring = true;
|
|
||||||
this.transact(() => {
|
|
||||||
this.metaManager.getOrderedPropertiesSchema().forEach(property => {
|
|
||||||
if (property.required && !this.hasCustomProperty(property.id)) {
|
|
||||||
this.addCustomProperty(property.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.ensuring = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
getItems() {
|
|
||||||
return Object.values(this.getCustomProperties());
|
|
||||||
}
|
|
||||||
|
|
||||||
getItemOrder(item: PageInfoCustomProperty): string {
|
|
||||||
return item.order;
|
|
||||||
}
|
|
||||||
|
|
||||||
setItemOrder(item: PageInfoCustomProperty, order: string) {
|
|
||||||
item.order = order;
|
|
||||||
}
|
|
||||||
|
|
||||||
getItemId(item: PageInfoCustomProperty) {
|
|
||||||
return item.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
get workspace() {
|
|
||||||
return this.adapter.workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
get page() {
|
|
||||||
return this.adapter.workspace.docCollection.getDoc(this.pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
get intrinsicMeta() {
|
|
||||||
return this.page?.meta;
|
|
||||||
}
|
|
||||||
|
|
||||||
get updatedDate() {
|
|
||||||
return this.intrinsicMeta?.updatedDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
get createDate() {
|
|
||||||
return this.intrinsicMeta?.createDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
get properties() {
|
|
||||||
return this.adapter.getPageProperties(this.pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
get readonly() {
|
|
||||||
return !!this.page?.readonly;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get custom properties (filter out properties that are not in schema)
|
|
||||||
*/
|
|
||||||
getCustomProperties(): Record<string, PageInfoCustomProperty> {
|
|
||||||
return this.properties
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(this.properties.custom).filter(([id]) =>
|
|
||||||
this.metaManager.checkPropertyExists(id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: {};
|
|
||||||
}
|
|
||||||
|
|
||||||
getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined {
|
|
||||||
return this.metaManager.customPropertiesSchema[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
getCustomProperty(id: string) {
|
|
||||||
return this.properties?.custom[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
addCustomProperty(id: string, value?: any) {
|
|
||||||
this.ensureRequiredProperties();
|
|
||||||
if (!this.metaManager.checkPropertyExists(id)) {
|
|
||||||
logger.warn(`property ${id} not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.metaManager.validatePropertyValue(id, value)) {
|
|
||||||
logger.warn(`property ${id} value ${value} is invalid`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOrder = this.sorter.getNewItemOrder();
|
|
||||||
if (this.properties!.custom[id]) {
|
|
||||||
logger.warn(`custom property ${id} already exists`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.properties!.custom[id] = {
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
order: newOrder,
|
|
||||||
visibility: 'visible',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
hasCustomProperty(id: string) {
|
|
||||||
return !!this.properties?.custom[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
removeCustomProperty(id: string) {
|
|
||||||
this.ensureRequiredProperties();
|
|
||||||
delete this.properties!.custom[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCustomProperty(id: string, opt: Partial<PageInfoCustomProperty>) {
|
|
||||||
this.ensureRequiredProperties();
|
|
||||||
if (!this.properties?.custom[id]) {
|
|
||||||
logger.warn(`custom property ${id} not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
opt.value !== undefined &&
|
|
||||||
!this.metaManager.validatePropertyValue(id, opt.value)
|
|
||||||
) {
|
|
||||||
logger.warn(`property ${id} value ${opt.value} is invalid`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Object.assign(this.properties.custom[id], opt);
|
|
||||||
}
|
|
||||||
|
|
||||||
get updateCustomPropertyMeta() {
|
|
||||||
return this.metaManager.updatePropertyMeta.bind(this.metaManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isPropertyRequired() {
|
|
||||||
return this.metaManager.isPropertyRequired.bind(this.metaManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
transact = this.adapter.transact;
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
import { Avatar, Checkbox, DatePicker, Menu } from '@affine/component';
|
|
||||||
import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta';
|
|
||||||
import type {
|
|
||||||
PageInfoCustomProperty,
|
|
||||||
PageInfoCustomPropertyMeta,
|
|
||||||
PagePropertyType,
|
|
||||||
} from '@affine/core/modules/properties/services/schema';
|
|
||||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
|
||||||
import { i18nTime, useI18n } from '@affine/i18n';
|
|
||||||
import {
|
|
||||||
DocService,
|
|
||||||
useLiveData,
|
|
||||||
useService,
|
|
||||||
WorkspaceService,
|
|
||||||
} from '@toeverything/infra';
|
|
||||||
import { noop } from 'lodash-es';
|
|
||||||
import type { ChangeEventHandler } from 'react';
|
|
||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { managerContext } from './common';
|
|
||||||
import * as styles from './styles.css';
|
|
||||||
import { TagsInlineEditor } from './tags-inline-editor';
|
|
||||||
|
|
||||||
interface PropertyRowValueProps {
|
|
||||||
property: PageInfoCustomProperty;
|
|
||||||
meta: PageInfoCustomPropertyMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DateValue = ({ property }: PropertyRowValueProps) => {
|
|
||||||
const displayValue = property.value
|
|
||||||
? i18nTime(property.value, { absolute: { accuracy: 'day' } })
|
|
||||||
: undefined;
|
|
||||||
const manager = useContext(managerContext);
|
|
||||||
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// show edit popup
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(e: string) => {
|
|
||||||
manager.updateCustomProperty(property.id, {
|
|
||||||
value: e,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[manager, property.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu items={<DatePicker value={property.value} onChange={handleChange} />}>
|
|
||||||
<div
|
|
||||||
onClick={handleClick}
|
|
||||||
className={styles.propertyRowValueCell}
|
|
||||||
data-empty={!property.value}
|
|
||||||
>
|
|
||||||
{displayValue ??
|
|
||||||
t['com.affine.page-properties.property-value-placeholder']()}
|
|
||||||
</div>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CheckboxValue = ({ property }: PropertyRowValueProps) => {
|
|
||||||
const manager = useContext(managerContext);
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
manager.updateCustomProperty(property.id, {
|
|
||||||
value: !property.value,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[manager, property.id, property.value]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={handleClick}
|
|
||||||
className={styles.propertyRowValueCell}
|
|
||||||
data-empty={!property.value}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
className={styles.checkboxProperty}
|
|
||||||
checked={!!property.value}
|
|
||||||
onChange={noop}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TextValue = ({ property }: PropertyRowValueProps) => {
|
|
||||||
const manager = useContext(managerContext);
|
|
||||||
const [value, setValue] = useState<string>(property.value);
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}, []);
|
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const handleBlur = useCallback(
|
|
||||||
(e: FocusEvent) => {
|
|
||||||
manager.updateCustomProperty(property.id, {
|
|
||||||
value: (e.currentTarget as HTMLTextAreaElement).value.trim(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[manager, property.id]
|
|
||||||
);
|
|
||||||
// use native blur event to get event after unmount
|
|
||||||
// don't use useLayoutEffect here, cause the cleanup function will be called before unmount
|
|
||||||
useEffect(() => {
|
|
||||||
ref.current?.addEventListener('blur', handleBlur);
|
|
||||||
return () => {
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
ref.current?.removeEventListener('blur', handleBlur);
|
|
||||||
};
|
|
||||||
}, [handleBlur]);
|
|
||||||
const handleOnChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
|
||||||
e => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const t = useI18n();
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(property.value);
|
|
||||||
}, [property.value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div onClick={handleClick} className={styles.propertyRowValueTextCell}>
|
|
||||||
<textarea
|
|
||||||
ref={ref}
|
|
||||||
className={styles.propertyRowValueTextarea}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={handleOnChange}
|
|
||||||
onClick={handleClick}
|
|
||||||
data-empty={!value}
|
|
||||||
placeholder={t[
|
|
||||||
'com.affine.page-properties.property-value-placeholder'
|
|
||||||
]()}
|
|
||||||
/>
|
|
||||||
<div className={styles.propertyRowValueTextareaInvisible}>
|
|
||||||
{value}
|
|
||||||
{value?.endsWith('\n') || !value ? <br /> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NumberValue = ({ property }: PropertyRowValueProps) => {
|
|
||||||
const manager = useContext(managerContext);
|
|
||||||
const [value, setValue] = useState(property.value);
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}, []);
|
|
||||||
const handleBlur = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
manager.updateCustomProperty(property.id, {
|
|
||||||
value: e.target.value.trim(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[manager, property.id]
|
|
||||||
);
|
|
||||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
|
||||||
e => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const t = useI18n();
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(property.value);
|
|
||||||
}, [property.value]);
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
className={styles.propertyRowValueNumberCell}
|
|
||||||
type={'number'}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={handleOnChange}
|
|
||||||
onClick={handleClick}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
data-empty={!value}
|
|
||||||
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TagsValue = () => {
|
|
||||||
const doc = useService(DocService).doc;
|
|
||||||
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TagsInlineEditor
|
|
||||||
className={styles.propertyRowValueCell}
|
|
||||||
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
|
|
||||||
pageId={doc.id}
|
|
||||||
readonly={doc.blockSuiteDoc.readonly}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CloudUserAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy' }) => {
|
|
||||||
const cloudDocMetaService = useService(CloudDocMetaService);
|
|
||||||
const cloudDocMeta = useLiveData(cloudDocMetaService.cloudDocMeta.meta$);
|
|
||||||
const isRevalidating = useLiveData(
|
|
||||||
cloudDocMetaService.cloudDocMeta.isRevalidating$
|
|
||||||
);
|
|
||||||
const error = useLiveData(cloudDocMetaService.cloudDocMeta.error$);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cloudDocMetaService.cloudDocMeta.revalidate();
|
|
||||||
}, [cloudDocMetaService]);
|
|
||||||
|
|
||||||
const user = useMemo(() => {
|
|
||||||
if (!cloudDocMeta) return null;
|
|
||||||
if (props.type === 'CreatedBy' && cloudDocMeta.createdBy) {
|
|
||||||
return {
|
|
||||||
name: cloudDocMeta.createdBy.name,
|
|
||||||
avatarUrl: cloudDocMeta.createdBy.avatarUrl,
|
|
||||||
};
|
|
||||||
} else if (props.type === 'UpdatedBy' && cloudDocMeta.updatedBy) {
|
|
||||||
return {
|
|
||||||
name: cloudDocMeta.updatedBy.name,
|
|
||||||
avatarUrl: cloudDocMeta.updatedBy.avatarUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [cloudDocMeta, props.type]);
|
|
||||||
|
|
||||||
if (!cloudDocMeta) {
|
|
||||||
if (isRevalidating) {
|
|
||||||
// TODO: loading ui
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
// error ui
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Avatar url={user.avatarUrl || ''} name={user.name} size={20} />
|
|
||||||
<span>{user.name}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <NoRecordValue />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const NoRecordValue = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{t['com.affine.page-properties.property-user-avatar-no-record']()}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LocalUserValue = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
return <span>{t['com.affine.page-properties.local-user']()}</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CreatedUserValue = () => {
|
|
||||||
const workspaceService = useService(WorkspaceService);
|
|
||||||
const isCloud =
|
|
||||||
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
|
||||||
|
|
||||||
if (!isCloud) {
|
|
||||||
return (
|
|
||||||
<div className={styles.propertyRowValueUserCell}>
|
|
||||||
<LocalUserValue />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.propertyRowValueUserCell}>
|
|
||||||
<CloudUserAvatar type="CreatedBy" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UpdatedUserValue = () => {
|
|
||||||
const workspaceService = useService(WorkspaceService);
|
|
||||||
const isCloud =
|
|
||||||
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
|
||||||
|
|
||||||
if (!isCloud) {
|
|
||||||
return <LocalUserValue />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.propertyRowValueUserCell}>
|
|
||||||
<CloudUserAvatar type="UpdatedBy" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const propertyValueRenderers: Record<
|
|
||||||
PagePropertyType,
|
|
||||||
typeof DateValue
|
|
||||||
> = {
|
|
||||||
date: DateValue,
|
|
||||||
checkbox: CheckboxValue,
|
|
||||||
text: TextValue,
|
|
||||||
number: NumberValue,
|
|
||||||
createdBy: CreatedUserValue,
|
|
||||||
updatedBy: UpdatedUserValue,
|
|
||||||
// TODO(@Peng): fix following
|
|
||||||
tags: TagsValue,
|
|
||||||
progress: TextValue,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Divider, IconButton } from '@affine/component';
|
||||||
|
import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||||
|
import {
|
||||||
|
Content as CollapsibleContent,
|
||||||
|
Root as CollapsibleRoot,
|
||||||
|
} from '@radix-ui/react-collapsible';
|
||||||
|
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { DocPropertyManager } from '../manager';
|
||||||
|
import {
|
||||||
|
DocPropertyTypes,
|
||||||
|
isSupportedDocPropertyType,
|
||||||
|
} from '../types/constant';
|
||||||
|
import {
|
||||||
|
AddDocPropertySidebarSection,
|
||||||
|
DocPropertyListSidebarSection,
|
||||||
|
} from './section';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
export const DocPropertySidebar = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [newPropertyId, setNewPropertyId] = useState<string>();
|
||||||
|
|
||||||
|
const docsService = useService(DocsService);
|
||||||
|
const propertyList = docsService.propertyList;
|
||||||
|
const properties = useLiveData(propertyList.properties$);
|
||||||
|
|
||||||
|
const onAddProperty = useCallback(
|
||||||
|
(option: { type: string; name: string }) => {
|
||||||
|
if (!isSupportedDocPropertyType(option.type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typeDefined = DocPropertyTypes[option.type];
|
||||||
|
const nameExists = properties.some(meta => meta.name === option.name);
|
||||||
|
const allNames = properties
|
||||||
|
.map(meta => meta.name)
|
||||||
|
.filter((name): name is string => name !== null && name !== undefined);
|
||||||
|
const name = nameExists
|
||||||
|
? generateUniqueNameInSequence(option.name, allNames)
|
||||||
|
: option.name;
|
||||||
|
const newPropertyId = propertyList.createProperty({
|
||||||
|
id: typeDefined.uniqueId,
|
||||||
|
name,
|
||||||
|
type: option.type,
|
||||||
|
index: propertyList.indexAt('after'),
|
||||||
|
});
|
||||||
|
setNewPropertyId(newPropertyId);
|
||||||
|
},
|
||||||
|
[propertyList, properties]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<CollapsibleRoot defaultOpen>
|
||||||
|
<DocPropertyListSidebarSection />
|
||||||
|
<CollapsibleContent>
|
||||||
|
<DocPropertyManager
|
||||||
|
className={styles.manager}
|
||||||
|
defaultOpenEditMenuPropertyId={newPropertyId}
|
||||||
|
/>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</CollapsibleRoot>
|
||||||
|
<div className={styles.divider}>
|
||||||
|
<Divider />
|
||||||
|
</div>
|
||||||
|
<CollapsibleRoot defaultOpen>
|
||||||
|
<AddDocPropertySidebarSection />
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className={styles.AddListContainer}>
|
||||||
|
{Object.entries(DocPropertyTypes).map(([key, value]) => {
|
||||||
|
const Icon = value.icon;
|
||||||
|
const name = t.t(value.name);
|
||||||
|
const isUniqueExist = properties.some(
|
||||||
|
meta => meta.id === value.uniqueId
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.itemContainer}
|
||||||
|
key={key}
|
||||||
|
onClick={() => {
|
||||||
|
onAddProperty({
|
||||||
|
type: key,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
data-disabled={isUniqueExist}
|
||||||
|
>
|
||||||
|
<Icon className={styles.itemIcon} />
|
||||||
|
<span className={styles.itemName}>{t.t(value.name)}</span>
|
||||||
|
{isUniqueExist ? (
|
||||||
|
<span className={styles.itemAdded}>Added</span>
|
||||||
|
) : (
|
||||||
|
<IconButton size={20} iconClassName={styles.itemAdd}>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</CollapsibleRoot>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const headerRoot = style({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: '8px 16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerTitle = style({
|
||||||
|
height: '22px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: '22px',
|
||||||
|
color: cssVarV2('text/primary'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collapseIcon = style({
|
||||||
|
vars: { '--y': '1px', '--r': '90deg' },
|
||||||
|
color: cssVarV2('icon/secondary'),
|
||||||
|
transform: 'translateY(var(--y)) rotate(var(--r))',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
selectors: {
|
||||||
|
[`button[data-state="closed"] &`]: {
|
||||||
|
vars: { '--r': '0deg' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { IconButton } from '@affine/component';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { ToggleCollapseIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { Trigger as CollapsibleTrigger } from '@radix-ui/react-collapsible';
|
||||||
|
|
||||||
|
import * as styles from './section.css';
|
||||||
|
|
||||||
|
export const DocPropertyListSidebarSection = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.headerRoot}>
|
||||||
|
<span className={styles.headerTitle}>
|
||||||
|
{t['com.affine.propertySidebar.property-list.section']()}
|
||||||
|
</span>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<IconButton>
|
||||||
|
<ToggleCollapseIcon className={styles.collapseIcon} />
|
||||||
|
</IconButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddDocPropertySidebarSection = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.headerRoot}>
|
||||||
|
<span className={styles.headerTitle}>
|
||||||
|
{t['com.affine.propertySidebar.add-more.section']()}
|
||||||
|
</span>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<IconButton>
|
||||||
|
<ToggleCollapseIcon className={styles.collapseIcon} />
|
||||||
|
</IconButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const container = style({
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
height: '100%',
|
||||||
|
paddingTop: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const manager = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const divider = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddListContainer = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '4px 8px',
|
||||||
|
gap: '8px',
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
borderRadius: '6px',
|
||||||
|
lineHeight: '22px',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
selectors: {
|
||||||
|
'&': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
},
|
||||||
|
'&[data-disabled="true"]': {
|
||||||
|
opacity: 0.5,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemIcon = style({
|
||||||
|
fontSize: '16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemName = style({
|
||||||
|
flex: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
color: cssVarV2('text/primary'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemAdded = style({
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemAdd = style({
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
});
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
const propertyNameCellWidth = createVar();
|
|
||||||
export const rowHPadding = createVar();
|
|
||||||
export const fontSize = createVar();
|
|
||||||
|
|
||||||
export const root = style({
|
|
||||||
display: 'flex',
|
|
||||||
width: '100%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontFamily: cssVar('fontSansFamily'),
|
|
||||||
vars: {
|
|
||||||
[propertyNameCellWidth]: '160px',
|
|
||||||
[rowHPadding]: '6px',
|
|
||||||
[fontSize]: cssVar('fontSm'),
|
|
||||||
},
|
|
||||||
'@container': {
|
|
||||||
[`viewport (width <= 640px)`]: {
|
|
||||||
vars: {
|
|
||||||
[rowHPadding]: '0px',
|
|
||||||
[fontSize]: cssVar('fontXs'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const rootCentered = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 8,
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: cssVar('editorWidth'),
|
|
||||||
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
|
||||||
'@container': {
|
|
||||||
[`viewport (width <= 640px)`]: {
|
|
||||||
padding: '0 16px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableHeader = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableHeaderInfoRow = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
color: cssVarV2('text/secondary'),
|
|
||||||
fontSize: fontSize,
|
|
||||||
fontWeight: 500,
|
|
||||||
minHeight: 34,
|
|
||||||
'@media': {
|
|
||||||
print: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableHeaderSecondaryRow = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
fontSize: fontSize,
|
|
||||||
fontWeight: 500,
|
|
||||||
padding: `0 ${rowHPadding}`,
|
|
||||||
gap: '8px',
|
|
||||||
height: 24,
|
|
||||||
'@media': {
|
|
||||||
print: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableHeaderCollapseButtonWrapper = style({
|
|
||||||
display: 'flex',
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
cursor: 'pointer',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pageInfoDimmed = style({
|
|
||||||
color: cssVarV2('text/secondary'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const spacer = style({
|
|
||||||
flexGrow: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableHeaderBacklinksHint = style({
|
|
||||||
padding: `0 ${rowHPadding}`,
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: '4px',
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const backlinksList = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableHeaderTimestamp = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'start',
|
|
||||||
gap: '8px',
|
|
||||||
cursor: 'default',
|
|
||||||
padding: `0 ${rowHPadding}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableHeaderDivider = style({
|
|
||||||
height: 0,
|
|
||||||
borderTop: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
|
||||||
width: '100%',
|
|
||||||
margin: '8px 0',
|
|
||||||
'@media': {
|
|
||||||
print: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableBodyRoot = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 8,
|
|
||||||
'@media': {
|
|
||||||
print: {
|
|
||||||
selectors: {
|
|
||||||
'&[data-state="open"]': {
|
|
||||||
marginBottom: 32,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tableBodySortable = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 4,
|
|
||||||
position: 'relative',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const addPropertyButton = style({
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
color: `${cssVarV2('text/secondary')}`,
|
|
||||||
padding: '0 4px',
|
|
||||||
height: 36,
|
|
||||||
fontWeight: 400,
|
|
||||||
gap: 6,
|
|
||||||
'@media': {
|
|
||||||
print: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
globalStyle(`${addPropertyButton} svg`, {
|
|
||||||
fontSize: 16,
|
|
||||||
color: cssVarV2('icon/secondary'),
|
|
||||||
});
|
|
||||||
globalStyle(`${addPropertyButton}:hover svg`, {
|
|
||||||
color: cssVarV2('icon/primary'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const collapsedIcon = style({
|
|
||||||
transition: 'transform 0.2s ease-in-out',
|
|
||||||
selectors: {
|
|
||||||
'&[data-collapsed="true"]': {
|
|
||||||
transform: 'rotate(90deg)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const propertyRow = style({
|
|
||||||
display: 'flex',
|
|
||||||
gap: 4,
|
|
||||||
minHeight: 32,
|
|
||||||
position: 'relative',
|
|
||||||
selectors: {
|
|
||||||
'&[data-dragging=true]': {
|
|
||||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
|
||||||
borderTopLeftRadius: 0,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagsPropertyRow = style([
|
|
||||||
propertyRow,
|
|
||||||
{
|
|
||||||
marginBottom: -4,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const draggableItem = style({
|
|
||||||
cursor: 'pointer',
|
|
||||||
selectors: {
|
|
||||||
'&:before': {
|
|
||||||
content: '""',
|
|
||||||
display: 'block',
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
borderRadius: '2px',
|
|
||||||
backgroundColor: cssVarV2('text/placeholder'),
|
|
||||||
transform: 'translate(-12px, -50%)',
|
|
||||||
transition: 'all 0.2s 0.1s',
|
|
||||||
opacity: 0,
|
|
||||||
height: '4px',
|
|
||||||
width: '4px',
|
|
||||||
willChange: 'height, opacity',
|
|
||||||
},
|
|
||||||
'&[data-draggable=false]:before': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
'&:hover:before': {
|
|
||||||
height: 12,
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
'&:active:before': {
|
|
||||||
height: '100%',
|
|
||||||
width: '1px',
|
|
||||||
borderRadius: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'translate(-6px, -50%)',
|
|
||||||
},
|
|
||||||
'&[data-other-dragging=true]:before': {
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
'&[data-other-dragging=true]': {
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const draggableRowSetting = style([
|
|
||||||
draggableItem,
|
|
||||||
{
|
|
||||||
selectors: {
|
|
||||||
'&:active:before': {
|
|
||||||
height: '100%',
|
|
||||||
width: '1px',
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'translate(-12px, -50%)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertyRowCell = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
position: 'relative',
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
lineHeight: '22px',
|
|
||||||
userSelect: 'none',
|
|
||||||
padding: `6px ${rowHPadding} 6px 8px`,
|
|
||||||
':focus-visible': {
|
|
||||||
outline: 'none',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const editablePropertyRowCell = style([
|
|
||||||
propertyRowCell,
|
|
||||||
{
|
|
||||||
cursor: 'pointer',
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertyRowNameCell = style([
|
|
||||||
propertyRowCell,
|
|
||||||
{
|
|
||||||
padding: `6px ${rowHPadding}`,
|
|
||||||
flexShrink: 0,
|
|
||||||
color: cssVarV2('text/secondary'),
|
|
||||||
width: propertyNameCellWidth,
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const sortablePropertyRowNameCell = style([
|
|
||||||
propertyRowNameCell,
|
|
||||||
draggableItem,
|
|
||||||
editablePropertyRowCell,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertyRowIconContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: '2px',
|
|
||||||
fontSize: 16,
|
|
||||||
color: cssVarV2('icon/secondary'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const propertyRowNameContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
flexGrow: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const propertyRowName = style({
|
|
||||||
flexGrow: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const propertyRowValueCell = style([
|
|
||||||
propertyRowCell,
|
|
||||||
editablePropertyRowCell,
|
|
||||||
{
|
|
||||||
border: `1px solid transparent`,
|
|
||||||
color: cssVarV2('text/primary'),
|
|
||||||
':focus': {
|
|
||||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
|
||||||
},
|
|
||||||
'::placeholder': {
|
|
||||||
color: cssVarV2('text/placeholder'),
|
|
||||||
},
|
|
||||||
selectors: {
|
|
||||||
'&[data-empty="true"]': {
|
|
||||||
color: cssVarV2('text/placeholder'),
|
|
||||||
},
|
|
||||||
'&[data-readonly=true]': {
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertyRowValueTextCell = style([
|
|
||||||
propertyRowValueCell,
|
|
||||||
{
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative',
|
|
||||||
':focus-within': {
|
|
||||||
border: `1px solid ${cssVar('blue700')}`,
|
|
||||||
boxShadow: cssVar('activeShadow'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertyRowValueUserCell = style([
|
|
||||||
propertyRowValueCell,
|
|
||||||
{
|
|
||||||
border: 'none',
|
|
||||||
overflow: 'hidden',
|
|
||||||
columnGap: '0.5rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertyRowValueTextarea = style([
|
|
||||||
propertyRowValueCell,
|
|
||||||
{
|
|
||||||
border: 'none',
|
|
||||||
padding: `6px ${rowHPadding} 6px 8px`,
|
|
||||||
height: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertyRowValueTextareaInvisible = style([
|
|
||||||
propertyRowValueCell,
|
|
||||||
{
|
|
||||||
border: 'none',
|
|
||||||
padding: `6px ${rowHPadding} 6px 8px`,
|
|
||||||
visibility: 'hidden',
|
|
||||||
whiteSpace: 'break-spaces',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertyRowValueNumberCell = style([
|
|
||||||
propertyRowValueTextCell,
|
|
||||||
{
|
|
||||||
padding: `6px ${rowHPadding} 6px 8px`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const menuHeader = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
fontWeight: 500,
|
|
||||||
color: cssVarV2('text/secondary'),
|
|
||||||
padding: '8px 16px',
|
|
||||||
minWidth: 200,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const menuItemListScrollable = style({});
|
|
||||||
|
|
||||||
export const menuItemListScrollbar = style({
|
|
||||||
transform: 'translateX(4px)',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const menuItemList = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
maxHeight: 200,
|
|
||||||
overflow: 'auto',
|
|
||||||
});
|
|
||||||
|
|
||||||
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
|
|
||||||
display: 'table !important',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const menuItemIconContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'inherit',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const menuItemName = style({
|
|
||||||
flexGrow: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const checkboxProperty = style({
|
|
||||||
fontSize: cssVar('fontH5'),
|
|
||||||
});
|
|
||||||
|
|
||||||
globalStyle(
|
|
||||||
`${propertyRow}:is([data-dragging=true], [data-other-dragging=true])
|
|
||||||
:is(${propertyRowValueCell}, ${propertyRowNameCell})`,
|
|
||||||
{
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const propertyRowNamePopupRow = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
fontWeight: 500,
|
|
||||||
color: cssVarV2('text/secondary'),
|
|
||||||
padding: '8px 16px',
|
|
||||||
minWidth: 260,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const propertySettingRow = style([
|
|
||||||
draggableRowSetting,
|
|
||||||
{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
padding: '0 12px',
|
|
||||||
height: 32,
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const propertySettingRowName = style({
|
|
||||||
flexGrow: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
maxWidth: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const selectorButton = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
borderRadius: 4,
|
|
||||||
gap: 8,
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
fontWeight: 400,
|
|
||||||
padding: '4px 8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
|
||||||
},
|
|
||||||
selectors: {
|
|
||||||
'&[data-required=true]': {
|
|
||||||
color: cssVarV2('text/disable'),
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const propertyRowTypeItem = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
padding: '8px 16px',
|
|
||||||
minWidth: 260,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const propertyTypeName = style({
|
|
||||||
color: cssVarV2('text/secondary'),
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const backLinksMenu = style({
|
|
||||||
background: cssVarV2('layer/white'),
|
|
||||||
maxWidth: 'calc(var(--affine-editor-width) / 2)',
|
|
||||||
width: '100%',
|
|
||||||
maxHeight: '30vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
const propertyNameCellWidth = createVar();
|
||||||
|
export const fontSize = createVar();
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: cssVar('fontSansFamily'),
|
||||||
|
vars: {
|
||||||
|
[propertyNameCellWidth]: '160px',
|
||||||
|
[fontSize]: cssVar('fontSm'),
|
||||||
|
},
|
||||||
|
'@container': {
|
||||||
|
[`viewport (width <= 640px)`]: {
|
||||||
|
vars: {
|
||||||
|
[fontSize]: cssVar('fontXs'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rootCentered = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: cssVar('editorWidth'),
|
||||||
|
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
||||||
|
'@container': {
|
||||||
|
[`viewport (width <= 640px)`]: {
|
||||||
|
padding: '0 16px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableHeaderInfoRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: 500,
|
||||||
|
minHeight: 34,
|
||||||
|
'@media': {
|
||||||
|
print: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableHeaderSecondaryRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: `0 6px`,
|
||||||
|
gap: '8px',
|
||||||
|
height: 24,
|
||||||
|
'@media': {
|
||||||
|
print: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableHeaderCollapseButtonWrapper = style({
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
cursor: 'pointer',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pageInfoDimmed = style({
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableHeaderBacklinksHint = style({
|
||||||
|
padding: `0 6px`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '4px',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const backlinksList = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableHeaderTimestamp = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'start',
|
||||||
|
gap: '8px',
|
||||||
|
cursor: 'default',
|
||||||
|
padding: `0 6px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableHeaderDivider = style({
|
||||||
|
height: 0,
|
||||||
|
borderTop: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||||
|
width: '100%',
|
||||||
|
margin: '8px 0',
|
||||||
|
'@media': {
|
||||||
|
print: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableBodyRoot = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
'@media': {
|
||||||
|
print: {
|
||||||
|
selectors: {
|
||||||
|
'&[data-state="open"]': {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tableBodySortable = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
selectors: {
|
||||||
|
[`[data-property-collapsed="true"] &`]: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyActionButton = style({
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
color: `${cssVarV2('text/secondary')}`,
|
||||||
|
padding: '0 6px',
|
||||||
|
height: 36,
|
||||||
|
fontWeight: 400,
|
||||||
|
gap: 6,
|
||||||
|
'@media': {
|
||||||
|
print: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
globalStyle(`${propertyActionButton} svg`, {
|
||||||
|
fontSize: 16,
|
||||||
|
color: cssVarV2('icon/secondary'),
|
||||||
|
});
|
||||||
|
globalStyle(`${propertyActionButton}:hover svg`, {
|
||||||
|
color: cssVarV2('icon/primary'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyConfigButton = style({
|
||||||
|
opacity: 0,
|
||||||
|
selectors: {
|
||||||
|
[`${actionContainer}:hover &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collapsedIcon = style({
|
||||||
|
transition: 'transform 0.2s ease-in-out',
|
||||||
|
selectors: {
|
||||||
|
'&[data-collapsed="true"]': {
|
||||||
|
transform: 'rotate(90deg)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const backLinksMenu = style({
|
||||||
|
background: cssVarV2('layer/white'),
|
||||||
|
maxWidth: 'calc(var(--affine-editor-width) / 2)',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '30vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyRootHideEmpty = style({
|
||||||
|
selectors: {
|
||||||
|
'&:has([data-property-value][data-empty="true"])': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -128,3 +128,20 @@ export const tagColorIcon = style({
|
|||||||
height: 16,
|
height: 16,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const menuItemListScrollable = style({});
|
||||||
|
|
||||||
|
export const menuItemListScrollbar = style({
|
||||||
|
transform: 'translateX(4px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const menuItemList = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: 200,
|
||||||
|
overflow: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
|
||||||
|
display: 'table !important',
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Menu,
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
RowInput,
|
RowInput,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
@@ -19,8 +21,6 @@ import type { HTMLAttributes, PropsWithChildren } from 'react';
|
|||||||
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { TagItem, TempTagItem } from '../../page-list';
|
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';
|
import * as styles from './tags-inline-editor.css';
|
||||||
|
|
||||||
interface TagsEditorProps {
|
interface TagsEditorProps {
|
||||||
@@ -93,73 +93,12 @@ export const EditTagMenu = ({
|
|||||||
const navigate = useNavigateHelper();
|
const navigate = useNavigateHelper();
|
||||||
|
|
||||||
const menuProps = useMemo(() => {
|
const menuProps = useMemo(() => {
|
||||||
const options: MenuItemOption[] = [];
|
|
||||||
const updateTagName = (name: string) => {
|
const updateTagName = (name: string) => {
|
||||||
if (name.trim() === '') {
|
if (name.trim() === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tag?.rename(name);
|
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 {
|
return {
|
||||||
contentOptions: {
|
contentOptions: {
|
||||||
@@ -167,7 +106,69 @@ export const EditTagMenu = ({
|
|||||||
e.stopPropagation();
|
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>;
|
} satisfies Partial<MenuProps>;
|
||||||
}, [
|
}, [
|
||||||
legacyProperties.workspaceId,
|
legacyProperties.workspaceId,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const checkboxProperty = style({
|
||||||
|
fontSize: cssVar('fontH5'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Checkbox, PropertyValue } from '@affine/component';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './checkbox.css';
|
||||||
|
import type { PropertyValueProps } from './types';
|
||||||
|
|
||||||
|
export const CheckboxValue = ({ value, onChange }: PropertyValueProps) => {
|
||||||
|
const parsedValue = value === 'true' ? true : false;
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(parsedValue ? 'false' : 'true');
|
||||||
|
},
|
||||||
|
[onChange, parsedValue]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<PropertyValue onClick={handleClick}>
|
||||||
|
<Checkbox
|
||||||
|
className={styles.checkboxProperty}
|
||||||
|
checked={parsedValue}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
</PropertyValue>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { I18nString } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
CheckBoxCheckLinearIcon,
|
||||||
|
CreatedEditedIcon,
|
||||||
|
DateTimeIcon,
|
||||||
|
NumberIcon,
|
||||||
|
TagIcon,
|
||||||
|
TextIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
|
||||||
|
import { CheckboxValue } from './checkbox';
|
||||||
|
import { CreatedByValue, UpdatedByValue } from './created-updated-by';
|
||||||
|
import { DateValue } from './date';
|
||||||
|
import { NumberValue } from './number';
|
||||||
|
import { TagsValue } from './tags';
|
||||||
|
import { TextValue } from './text';
|
||||||
|
import type { PropertyValueProps } from './types';
|
||||||
|
|
||||||
|
export const DocPropertyTypes = {
|
||||||
|
text: {
|
||||||
|
icon: TextIcon,
|
||||||
|
value: TextValue,
|
||||||
|
name: 'com.affine.page-properties.property.text',
|
||||||
|
},
|
||||||
|
number: {
|
||||||
|
icon: NumberIcon,
|
||||||
|
value: NumberValue,
|
||||||
|
name: 'com.affine.page-properties.property.number',
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
icon: DateTimeIcon,
|
||||||
|
value: DateValue,
|
||||||
|
name: 'com.affine.page-properties.property.date',
|
||||||
|
},
|
||||||
|
checkbox: {
|
||||||
|
icon: CheckBoxCheckLinearIcon,
|
||||||
|
value: CheckboxValue,
|
||||||
|
name: 'com.affine.page-properties.property.checkbox',
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
icon: CreatedEditedIcon,
|
||||||
|
value: CreatedByValue,
|
||||||
|
name: 'com.affine.page-properties.property.createdBy',
|
||||||
|
},
|
||||||
|
updatedBy: {
|
||||||
|
icon: CreatedEditedIcon,
|
||||||
|
value: UpdatedByValue,
|
||||||
|
name: 'com.affine.page-properties.property.updatedBy',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
icon: TagIcon,
|
||||||
|
value: TagsValue,
|
||||||
|
name: 'com.affine.page-properties.property.tags',
|
||||||
|
uniqueId: 'tags',
|
||||||
|
renameable: false,
|
||||||
|
},
|
||||||
|
} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
icon: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||||
|
value?: React.FC<PropertyValueProps>;
|
||||||
|
/**
|
||||||
|
* set a unique id for property type, make the property type can only be created once.
|
||||||
|
*/
|
||||||
|
uniqueId?: string;
|
||||||
|
name: I18nString;
|
||||||
|
renameable?: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const isSupportedDocPropertyType = (type?: string): boolean => {
|
||||||
|
return type ? type in DocPropertyTypes : false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Avatar, PropertyValue } from '@affine/component';
|
||||||
|
import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta';
|
||||||
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
const CloudUserAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy' }) => {
|
||||||
|
const cloudDocMetaService = useService(CloudDocMetaService);
|
||||||
|
const cloudDocMeta = useLiveData(cloudDocMetaService.cloudDocMeta.meta$);
|
||||||
|
const isRevalidating = useLiveData(
|
||||||
|
cloudDocMetaService.cloudDocMeta.isRevalidating$
|
||||||
|
);
|
||||||
|
const error = useLiveData(cloudDocMetaService.cloudDocMeta.error$);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cloudDocMetaService.cloudDocMeta.revalidate();
|
||||||
|
}, [cloudDocMetaService]);
|
||||||
|
|
||||||
|
const user = useMemo(() => {
|
||||||
|
if (!cloudDocMeta) return null;
|
||||||
|
if (props.type === 'CreatedBy' && cloudDocMeta.createdBy) {
|
||||||
|
return {
|
||||||
|
name: cloudDocMeta.createdBy.name,
|
||||||
|
avatarUrl: cloudDocMeta.createdBy.avatarUrl,
|
||||||
|
};
|
||||||
|
} else if (props.type === 'UpdatedBy' && cloudDocMeta.updatedBy) {
|
||||||
|
return {
|
||||||
|
name: cloudDocMeta.updatedBy.name,
|
||||||
|
avatarUrl: cloudDocMeta.updatedBy.avatarUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [cloudDocMeta, props.type]);
|
||||||
|
|
||||||
|
if (!cloudDocMeta) {
|
||||||
|
if (isRevalidating) {
|
||||||
|
// TODO: loading ui
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
// error ui
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Avatar url={user.avatarUrl || ''} name={user.name} size={20} />
|
||||||
|
<span>{user.name}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <NoRecordValue />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NoRecordValue = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{t['com.affine.page-properties.property-user-avatar-no-record']()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LocalUserValue = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
return <span>{t['com.affine.page-properties.local-user']()}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreatedByValue = () => {
|
||||||
|
const workspaceService = useService(WorkspaceService);
|
||||||
|
const isCloud =
|
||||||
|
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||||
|
|
||||||
|
if (!isCloud) {
|
||||||
|
return (
|
||||||
|
<PropertyValue>
|
||||||
|
<LocalUserValue />
|
||||||
|
</PropertyValue>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PropertyValue>
|
||||||
|
<CloudUserAvatar type="CreatedBy" />
|
||||||
|
</PropertyValue>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UpdatedByValue = () => {
|
||||||
|
const workspaceService = useService(WorkspaceService);
|
||||||
|
const isCloud =
|
||||||
|
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||||
|
|
||||||
|
if (!isCloud) {
|
||||||
|
return (
|
||||||
|
<PropertyValue>
|
||||||
|
<LocalUserValue />
|
||||||
|
</PropertyValue>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PropertyValue>
|
||||||
|
<CloudUserAvatar type="UpdatedBy" />
|
||||||
|
</PropertyValue>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const empty = style({
|
||||||
|
color: cssVar('placeholderColor'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { DatePicker, Menu, PropertyValue } from '@affine/component';
|
||||||
|
import { i18nTime, useI18n } from '@affine/i18n';
|
||||||
|
|
||||||
|
import * as styles from './date.css';
|
||||||
|
import type { PropertyValueProps } from './types';
|
||||||
|
|
||||||
|
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
|
||||||
|
const parsedValue =
|
||||||
|
typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
const displayValue = parsedValue
|
||||||
|
? i18nTime(parsedValue, { absolute: { accuracy: 'day' } })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu items={<DatePicker value={parsedValue} onChange={onChange} />}>
|
||||||
|
<PropertyValue
|
||||||
|
className={parsedValue ? '' : styles.empty}
|
||||||
|
isEmpty={!parsedValue}
|
||||||
|
>
|
||||||
|
{displayValue ??
|
||||||
|
t['com.affine.page-properties.property-value-placeholder']()}
|
||||||
|
</PropertyValue>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const numberPropertyValueInput = style({
|
||||||
|
border: `1px solid transparent`,
|
||||||
|
padding: `6px`,
|
||||||
|
paddingLeft: '5px',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
':focus': {
|
||||||
|
border: `1px solid ${cssVar('blue700')}`,
|
||||||
|
boxShadow: cssVar('activeShadow'),
|
||||||
|
},
|
||||||
|
selectors: {
|
||||||
|
'&::placeholder': {
|
||||||
|
color: cssVar('placeholderColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const numberPropertyValueContainer = style({
|
||||||
|
padding: '0px',
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { PropertyValue } from '@affine/component';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
type ChangeEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import * as styles from './number.css';
|
||||||
|
import type { PropertyValueProps } from './types';
|
||||||
|
|
||||||
|
export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
|
||||||
|
const parsedValue = isNaN(Number(value)) ? null : value;
|
||||||
|
const [tempValue, setTempValue] = useState(parsedValue);
|
||||||
|
const handleBlur = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value.trim());
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
|
e => {
|
||||||
|
setTempValue(e.target.value.trim());
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const t = useI18n();
|
||||||
|
useEffect(() => {
|
||||||
|
setTempValue(parsedValue);
|
||||||
|
}, [parsedValue]);
|
||||||
|
return (
|
||||||
|
<PropertyValue
|
||||||
|
className={styles.numberPropertyValueContainer}
|
||||||
|
isEmpty={!parsedValue}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={styles.numberPropertyValueInput}
|
||||||
|
type={'number'}
|
||||||
|
value={tempValue || ''}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
data-empty={!tempValue}
|
||||||
|
placeholder={t[
|
||||||
|
'com.affine.page-properties.property-value-placeholder'
|
||||||
|
]()}
|
||||||
|
/>
|
||||||
|
</PropertyValue>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const tagInlineEditor = style({
|
||||||
|
width: '100%',
|
||||||
|
padding: `6px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const container = style({
|
||||||
|
padding: `0px`,
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { PropertyValue } from '@affine/component';
|
||||||
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { DocService, useLiveData, useService } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { TagsInlineEditor } from '../tags-inline-editor';
|
||||||
|
import * as styles from './tags.css';
|
||||||
|
|
||||||
|
export const TagsValue = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const doc = useService(DocService).doc;
|
||||||
|
|
||||||
|
const tagList = useService(TagService).tagList;
|
||||||
|
const tagIds = useLiveData(tagList.tagIdsByPageId$(doc.id));
|
||||||
|
const empty = !tagIds || tagIds.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PropertyValue
|
||||||
|
className={styles.container}
|
||||||
|
isEmpty={empty}
|
||||||
|
data-testid="property-tags-value"
|
||||||
|
>
|
||||||
|
<TagsInlineEditor
|
||||||
|
className={styles.tagInlineEditor}
|
||||||
|
placeholder={t[
|
||||||
|
'com.affine.page-properties.property-value-placeholder'
|
||||||
|
]()}
|
||||||
|
pageId={doc.id}
|
||||||
|
/>
|
||||||
|
</PropertyValue>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const textarea = style({
|
||||||
|
border: 'none',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
whiteSpace: 'break-spaces',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
padding: `6px`,
|
||||||
|
paddingLeft: '5px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
lineHeight: '22px',
|
||||||
|
selectors: {
|
||||||
|
'&::placeholder': {
|
||||||
|
color: cssVar('placeholderColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const textPropertyValueContainer = style({
|
||||||
|
outline: `1px solid transparent`,
|
||||||
|
padding: `6px`,
|
||||||
|
':focus-within': {
|
||||||
|
outline: `1px solid ${cssVar('blue700')}`,
|
||||||
|
boxShadow: cssVar('activeShadow'),
|
||||||
|
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const textInvisible = style({
|
||||||
|
border: 'none',
|
||||||
|
whiteSpace: 'break-spaces',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflow: 'hidden',
|
||||||
|
visibility: 'hidden',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
lineHeight: '22px',
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { PropertyValue } from '@affine/component';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
type ChangeEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import * as styles from './text.css';
|
||||||
|
import type { PropertyValueProps } from './types';
|
||||||
|
|
||||||
|
export const TextValue = ({ value, onChange }: PropertyValueProps) => {
|
||||||
|
const [tempValue, setTempValue] = useState<string>(value);
|
||||||
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const handleBlur = useCallback(
|
||||||
|
(e: FocusEvent) => {
|
||||||
|
onChange((e.currentTarget as HTMLTextAreaElement).value.trim());
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
// use native blur event to get event after unmount
|
||||||
|
// don't use useLayoutEffect here, cause the cleanup function will be called before unmount
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current?.addEventListener('blur', handleBlur);
|
||||||
|
return () => {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
ref.current?.removeEventListener('blur', handleBlur);
|
||||||
|
};
|
||||||
|
}, [handleBlur]);
|
||||||
|
const handleOnChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||||
|
e => {
|
||||||
|
setTempValue(e.target.value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const t = useI18n();
|
||||||
|
useEffect(() => {
|
||||||
|
setTempValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PropertyValue
|
||||||
|
className={styles.textPropertyValueContainer}
|
||||||
|
onClick={handleClick}
|
||||||
|
isEmpty={!value}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={styles.textarea}
|
||||||
|
value={tempValue || ''}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
onClick={handleClick}
|
||||||
|
data-empty={!tempValue}
|
||||||
|
autoFocus={false}
|
||||||
|
placeholder={t[
|
||||||
|
'com.affine.page-properties.property-value-placeholder'
|
||||||
|
]()}
|
||||||
|
/>
|
||||||
|
<div className={styles.textInvisible}>
|
||||||
|
{tempValue}
|
||||||
|
{tempValue?.endsWith('\n') || !tempValue ? <br /> : null}
|
||||||
|
</div>
|
||||||
|
</PropertyValue>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { DocCustomPropertyInfo } from '@toeverything/infra';
|
||||||
|
|
||||||
|
export interface PropertyValueProps {
|
||||||
|
propertyInfo: DocCustomPropertyInfo;
|
||||||
|
value: any;
|
||||||
|
onChange: (value: any) => void;
|
||||||
|
}
|
||||||
@@ -1,413 +1,31 @@
|
|||||||
import { Button, IconButton, Menu } from '@affine/component';
|
import { Button, Menu } from '@affine/component';
|
||||||
import { SettingHeader } from '@affine/component/setting-components';
|
import { SettingHeader } from '@affine/component/setting-components';
|
||||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
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 { Trans, useI18n } from '@affine/i18n';
|
||||||
import {
|
|
||||||
DeleteIcon,
|
|
||||||
FilterIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
} from '@blocksuite/icons/rc';
|
|
||||||
import { FrameworkScope, type WorkspaceMetadata } from '@toeverything/infra';
|
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 { useWorkspace } from '../../../../../components/hooks/use-workspace';
|
||||||
import type { PagePropertyIcon } from '../../../page-properties';
|
import { DocPropertyManager } from '../../../page-properties/manager';
|
||||||
import {
|
import { CreatePropertyMenuItems } from '../../../page-properties/menu/create-doc-property';
|
||||||
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 * as styles from './styles.css';
|
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 WorkspaceSettingPropertiesMain = () => {
|
||||||
const t = useI18n();
|
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 (
|
return (
|
||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
<div className={styles.listHeader}>
|
<div className={styles.listHeader}>
|
||||||
{properties.length > 0 ? (
|
<Menu items={<CreatePropertyMenuItems />}>
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button variant="primary">
|
<Button variant="primary">
|
||||||
{t['com.affine.settings.workspace.properties.add_property']()}
|
{t['com.affine.settings.workspace.properties.add_property']()}
|
||||||
</Button>
|
</Button>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
<CustomPropertyRowsList filterMode={filterMode} />
|
<DocPropertyManager />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const WorkspaceSettingPropertiesInner = () => {
|
|
||||||
const manager = usePagePropertiesMetaManager();
|
|
||||||
return (
|
|
||||||
<managerContext.Provider value={manager}>
|
|
||||||
<WorkspaceSettingPropertiesMain />
|
|
||||||
</managerContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WorkspaceSettingProperties = ({
|
export const WorkspaceSettingProperties = ({
|
||||||
workspaceMetadata,
|
workspaceMetadata,
|
||||||
}: {
|
}: {
|
||||||
@@ -437,7 +55,7 @@ export const WorkspaceSettingProperties = ({
|
|||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<WorkspaceSettingPropertiesInner />
|
<WorkspaceSettingPropertiesMain />
|
||||||
</FrameworkScope>
|
</FrameworkScope>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const main = style({
|
|||||||
export const listHeader = style({
|
export const listHeader = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyItems: 'flex-end',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '6px 0',
|
padding: '6px 0',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { PagePropertiesTable } from '../../affine/page-properties';
|
import { DocPropertiesTable } from '../../affine/page-properties';
|
||||||
import {
|
import {
|
||||||
AffinePageReference,
|
AffinePageReference,
|
||||||
AffineSharedPageReference,
|
AffineSharedPageReference,
|
||||||
@@ -232,7 +232,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
|||||||
) : (
|
) : (
|
||||||
<BlocksuiteEditorJournalDocTitle page={page} />
|
<BlocksuiteEditorJournalDocTitle page={page} />
|
||||||
)}
|
)}
|
||||||
{!shared ? <PagePropertiesTable docId={page.id} /> : null}
|
{!shared ? <DocPropertiesTable /> : null}
|
||||||
<adapted.DocEditor
|
<adapted.DocEditor
|
||||||
className={styles.docContainer}
|
className={styles.docContainer}
|
||||||
ref={onDocRef}
|
ref={onDocRef}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
|||||||
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
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 { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
|
||||||
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
|
||||||
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
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 { RefNodeSlotsProvider } from '@blocksuite/affine/blocks';
|
||||||
import { DisposableGroup } from '@blocksuite/affine/global/utils';
|
import { DisposableGroup } from '@blocksuite/affine/global/utils';
|
||||||
import { type AffineEditorContainer } from '@blocksuite/affine/presets';
|
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 {
|
import {
|
||||||
DocService,
|
DocService,
|
||||||
FeatureFlagService,
|
FeatureFlagService,
|
||||||
@@ -284,6 +291,10 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
</ViewSidebarTab>
|
</ViewSidebarTab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ViewSidebarTab tabId="properties" icon={<PropertyIcon />}>
|
||||||
|
<DocPropertySidebar />
|
||||||
|
</ViewSidebarTab>
|
||||||
|
|
||||||
<ViewSidebarTab tabId="journal" icon={<TodayIcon />}>
|
<ViewSidebarTab tabId="journal" icon={<TodayIcon />}>
|
||||||
<EditorJournalPanel />
|
<EditorJournalPanel />
|
||||||
</ViewSidebarTab>
|
</ViewSidebarTab>
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
import { rowHPadding } from '@affine/core/components/affine/page-properties/styles.css';
|
|
||||||
import { style } from '@vanilla-extract/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({
|
export const linksRow = style({
|
||||||
padding: '0 16px',
|
padding: '0 16px',
|
||||||
});
|
});
|
||||||
@@ -28,24 +7,7 @@ export const linksRow = style({
|
|||||||
export const timeRow = style({
|
export const timeRow = style({
|
||||||
padding: '0 16px',
|
padding: '0 16px',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tagsRow = style({
|
|
||||||
padding: '0 16px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const properties = style({
|
|
||||||
padding: '0 16px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const scrollBar = style({
|
export const scrollBar = style({
|
||||||
width: 6,
|
width: 6,
|
||||||
transform: 'translateX(-4px)',
|
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 { Divider, Scrollable } from '@affine/component';
|
||||||
import {
|
import { DocPropertiesTable } from '@affine/core/components/affine/page-properties';
|
||||||
PagePropertyRow,
|
|
||||||
SortableProperties,
|
|
||||||
usePagePropertiesManager,
|
|
||||||
} from '@affine/core/components/affine/page-properties';
|
|
||||||
import { managerContext } from '@affine/core/components/affine/page-properties/common';
|
|
||||||
import { LinksRow } from '@affine/core/components/affine/page-properties/info-modal/links-row';
|
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 { TimeRow } from '@affine/core/components/affine/page-properties/info-modal/time-row';
|
||||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -16,7 +10,6 @@ import { Suspense, useMemo } from 'react';
|
|||||||
import * as styles from './doc-info.css';
|
import * as styles from './doc-info.css';
|
||||||
|
|
||||||
export const DocInfoSheet = ({ docId }: { docId: string }) => {
|
export const DocInfoSheet = ({ docId }: { docId: string }) => {
|
||||||
const manager = usePagePropertiesManager(docId);
|
|
||||||
const docsSearchService = useService(DocsSearchService);
|
const docsSearchService = useService(DocsSearchService);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
@@ -35,64 +28,32 @@ export const DocInfoSheet = ({ docId }: { docId: string }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Scrollable.Root>
|
<Scrollable.Root>
|
||||||
<Scrollable.Viewport
|
<Scrollable.Viewport data-testid="doc-info-menu">
|
||||||
className={styles.viewport}
|
<Suspense>
|
||||||
data-testid="doc-info-menu"
|
<TimeRow docId={docId} className={styles.timeRow} />
|
||||||
>
|
<Divider size="thinner" />
|
||||||
<managerContext.Provider value={manager}>
|
{backlinks && backlinks.length > 0 ? (
|
||||||
<Suspense>
|
<>
|
||||||
<TimeRow docId={docId} className={styles.timeRow} />
|
<LinksRow
|
||||||
<Divider size="thinner" />
|
className={styles.linksRow}
|
||||||
{backlinks && backlinks.length > 0 ? (
|
references={backlinks}
|
||||||
<>
|
label={t['com.affine.page-properties.backlinks']()}
|
||||||
<LinksRow
|
/>
|
||||||
className={styles.linksRow}
|
<Divider size="thinner" />
|
||||||
references={backlinks}
|
</>
|
||||||
label={t['com.affine.page-properties.backlinks']()}
|
) : null}
|
||||||
/>
|
{links && links.length > 0 ? (
|
||||||
<Divider size="thinner" />
|
<>
|
||||||
</>
|
<LinksRow
|
||||||
) : null}
|
className={styles.linksRow}
|
||||||
{links && links.length > 0 ? (
|
references={links}
|
||||||
<>
|
label={t['com.affine.page-properties.outgoing-links']()}
|
||||||
<LinksRow
|
/>
|
||||||
className={styles.linksRow}
|
<Divider size="thinner" />
|
||||||
references={links}
|
</>
|
||||||
label={t['com.affine.page-properties.outgoing-links']()}
|
) : null}
|
||||||
/>
|
<DocPropertiesTable />
|
||||||
<Divider size="thinner" />
|
</Suspense>
|
||||||
</>
|
|
||||||
) : 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>
|
</Scrollable.Viewport>
|
||||||
<Scrollable.Scrollbar className={styles.scrollBar} />
|
<Scrollable.Scrollbar className={styles.scrollBar} />
|
||||||
</Scrollable.Root>
|
</Scrollable.Root>
|
||||||
|
|||||||
@@ -259,28 +259,28 @@ export const ExplorerFolderNodeFolder = ({
|
|||||||
}
|
}
|
||||||
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
|
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
|
||||||
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
|
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 (
|
} else if (
|
||||||
data.source.data.entity?.type === 'collection' ||
|
data.source.data.entity?.type === 'collection' ||
|
||||||
data.source.data.entity?.type === 'doc' ||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
data.source.data.entity?.type === 'tag'
|
data.source.data.entity?.type === 'tag'
|
||||||
) {
|
) {
|
||||||
node.createLink(
|
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
|
||||||
data.source.data.entity?.type,
|
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
|
||||||
data.source.data.entity.id,
|
track.$.navigationPanel.organize.moveOrganizeItem({
|
||||||
node.indexAt('before')
|
type: 'link',
|
||||||
);
|
target: data.source.data.entity?.type,
|
||||||
track.$.navigationPanel.organize.createOrganizeItem({
|
});
|
||||||
type: 'link',
|
} else {
|
||||||
target: data.source.data.entity?.type,
|
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 {
|
} else {
|
||||||
onDrop?.(data);
|
onDrop?.(data);
|
||||||
@@ -330,27 +330,27 @@ export const ExplorerFolderNodeFolder = ({
|
|||||||
}
|
}
|
||||||
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
|
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
|
||||||
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
|
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 (
|
} else if (
|
||||||
data.source.data.entity?.type === 'collection' ||
|
data.source.data.entity?.type === 'collection' ||
|
||||||
data.source.data.entity?.type === 'doc' ||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
data.source.data.entity?.type === 'tag'
|
data.source.data.entity?.type === 'tag'
|
||||||
) {
|
) {
|
||||||
node.createLink(
|
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
|
||||||
data.source.data.entity?.type,
|
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
|
||||||
data.source.data.entity.id,
|
track.$.navigationPanel.organize.moveOrganizeItem({
|
||||||
node.indexAt('before')
|
type: data.source.data.entity?.type,
|
||||||
);
|
});
|
||||||
track.$.navigationPanel.organize.createOrganizeItem({
|
} else {
|
||||||
type: 'link',
|
node.createLink(
|
||||||
target: data.source.data.entity?.type,
|
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]
|
[node]
|
||||||
@@ -379,32 +379,32 @@ export const ExplorerFolderNodeFolder = ({
|
|||||||
node.indexAt(at, dropAtNode.id)
|
node.indexAt(at, dropAtNode.id)
|
||||||
);
|
);
|
||||||
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
|
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 (
|
} else if (
|
||||||
data.source.data.entity?.type === 'collection' ||
|
data.source.data.entity?.type === 'collection' ||
|
||||||
data.source.data.entity?.type === 'doc' ||
|
data.source.data.entity?.type === 'doc' ||
|
||||||
data.source.data.entity?.type === 'tag'
|
data.source.data.entity?.type === 'tag'
|
||||||
) {
|
) {
|
||||||
node.createLink(
|
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
|
||||||
data.source.data.entity?.type,
|
node.moveHere(
|
||||||
data.source.data.entity.id,
|
data.source.data.from.nodeId,
|
||||||
node.indexAt(at, dropAtNode.id)
|
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({
|
track.$.navigationPanel.organize.createOrganizeItem({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
target: data.source.data.entity?.type,
|
target: data.source.data.entity?.type,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (data.treeInstruction?.type === 'reparent') {
|
} else if (data.treeInstruction?.type === 'reparent') {
|
||||||
const currentLevel = data.treeInstruction.currentLevel;
|
const currentLevel = data.treeInstruction.currentLevel;
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const ExplorerOrganize = () => {
|
|||||||
const entity = data.source.data.entity;
|
const entity = data.source.data.entity;
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
const { type, id } = entity;
|
const { type, id } = entity;
|
||||||
if (type === 'folder') return;
|
if (type !== 'doc' && type !== 'tag' && type !== 'collection') return;
|
||||||
|
|
||||||
const folder = newFolder$.value;
|
const folder = newFolder$.value;
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { generateFractionalIndexingKeyBetween } from '@affine/core/utils/fractional-indexing';
|
import {
|
||||||
import { Entity } from '@toeverything/infra';
|
Entity,
|
||||||
|
generateFractionalIndexingKeyBetween,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
|
||||||
import type { FavoriteSupportType } from '../constant';
|
import type { FavoriteSupportType } from '../constant';
|
||||||
import type { FavoriteRecord, FavoriteStore } from '../stores/favorite';
|
import type { FavoriteRecord, FavoriteStore } from '../stores/favorite';
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { generateFractionalIndexingKeyBetween } from '@affine/core/utils';
|
import {
|
||||||
import { Entity, LiveData } from '@toeverything/infra';
|
Entity,
|
||||||
|
generateFractionalIndexingKeyBetween,
|
||||||
|
LiveData,
|
||||||
|
} from '@toeverything/infra';
|
||||||
import { map, of, switchMap } from 'rxjs';
|
import { map, of, switchMap } from 'rxjs';
|
||||||
|
|
||||||
import type { FolderStore } from '../stores/folder';
|
import type { FolderStore } from '../stores/folder';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Suspense, useCallback } from 'react';
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppSidebarService } from '../../app-sidebar';
|
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 { ViewService } from '../services/view';
|
||||||
import { WorkbenchService } from '../services/workbench';
|
import { WorkbenchService } from '../services/workbench';
|
||||||
import * as styles from './route-container.css';
|
import * as styles from './route-container.css';
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export interface AffineDNDData extends DNDData {
|
|||||||
| {
|
| {
|
||||||
type: 'tag';
|
type: 'tag';
|
||||||
id: string;
|
id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'custom-property';
|
||||||
|
id: string;
|
||||||
};
|
};
|
||||||
from?:
|
from?:
|
||||||
| {
|
| {
|
||||||
@@ -62,6 +66,14 @@ export interface AffineDNDData extends DNDData {
|
|||||||
| {
|
| {
|
||||||
at: 'app-header:tabs';
|
at: 'app-header:tabs';
|
||||||
tabId: string;
|
tabId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
at: 'doc-property:table';
|
||||||
|
docId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
at: 'doc-property:manager';
|
||||||
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
dropTarget:
|
dropTarget:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export * from './create-emotion-cache';
|
export * from './create-emotion-cache';
|
||||||
export * from './event';
|
export * from './event';
|
||||||
export * from './extract-emoji-icon';
|
export * from './extract-emoji-icon';
|
||||||
export * from './fractional-indexing';
|
|
||||||
export * from './popup';
|
export * from './popup';
|
||||||
export * from './string2color';
|
export * from './string2color';
|
||||||
export * from './toast';
|
export * from './toast';
|
||||||
|
|||||||
17
packages/frontend/core/src/utils/unique-name.ts
Normal file
17
packages/frontend/core/src/utils/unique-name.ts
Normal file
@@ -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-AR": 14,
|
||||||
"es-CL": 16,
|
"es-CL": 16,
|
||||||
"es": 14,
|
"es": 14,
|
||||||
"fr": 78,
|
"fr": 77,
|
||||||
"hi": 2,
|
"hi": 2,
|
||||||
"it": 1,
|
"it": 1,
|
||||||
"ja": 28,
|
"ja": 28,
|
||||||
"ko": 92,
|
"ko": 92,
|
||||||
"pl": 0,
|
"pl": 0,
|
||||||
"pt-BR": 100,
|
"pt-BR": 99,
|
||||||
"ru": 85,
|
"ru": 85,
|
||||||
"sv-SE": 5,
|
"sv-SE": 5,
|
||||||
"ur": 3,
|
"ur": 3,
|
||||||
"zh-Hans": 99,
|
"zh-Hans": 98,
|
||||||
"zh-Hant": 21
|
"zh-Hant": 20
|
||||||
}
|
}
|
||||||
@@ -615,9 +615,14 @@
|
|||||||
"com.affine.other-page.nav.official-website": "Official website",
|
"com.affine.other-page.nav.official-website": "Official website",
|
||||||
"com.affine.other-page.nav.open-affine": "Open AFFiNE",
|
"com.affine.other-page.nav.open-affine": "Open AFFiNE",
|
||||||
"com.affine.page-operation.add-linked-page": "Add linked doc",
|
"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": "Add property",
|
||||||
"com.affine.page-properties.add-property.menu.create": "Create property",
|
"com.affine.page-properties.add-property.menu.create": "Create property",
|
||||||
"com.affine.page-properties.add-property.menu.header": "Properties",
|
"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.backlinks": "Backlinks",
|
||||||
"com.affine.page-properties.create-property.menu.header": "Type",
|
"com.affine.page-properties.create-property.menu.header": "Type",
|
||||||
"com.affine.page-properties.icons": "Icons",
|
"com.affine.page-properties.icons": "Icons",
|
||||||
@@ -644,6 +649,8 @@
|
|||||||
"com.affine.page-properties.property.tags": "Tags",
|
"com.affine.page-properties.property.tags": "Tags",
|
||||||
"com.affine.page-properties.property.text": "Text",
|
"com.affine.page-properties.property.text": "Text",
|
||||||
"com.affine.page-properties.property.updatedBy": "Last edited by",
|
"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.settings.title": "customize properties",
|
||||||
"com.affine.page-properties.tags.open-tags-page": "Open tag page",
|
"com.affine.page-properties.tags.open-tags-page": "Open tag page",
|
||||||
"com.affine.page-properties.tags.selector-header-title": "Select tag or create one",
|
"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.add_property": "Add property",
|
||||||
"com.affine.settings.workspace.properties.all": "All",
|
"com.affine.settings.workspace.properties.all": "All",
|
||||||
"com.affine.settings.workspace.properties.delete-property": "Delete property",
|
"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": "<0>{{count}}</0> doc",
|
||||||
"com.affine.settings.workspace.properties.doc_others": "<0>{{count}}</0> docs",
|
"com.affine.settings.workspace.properties.doc_others": "<0>{{count}}</0> docs",
|
||||||
"com.affine.settings.workspace.properties.edit-property": "Edit property",
|
"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');
|
const infoModal = page.getByTestId('info-modal');
|
||||||
await expect(infoModal).toBeVisible();
|
await expect(infoModal).toBeVisible();
|
||||||
const tagRow = page.getByTestId('info-modal-tags-row');
|
|
||||||
await expect(tagRow).toBeVisible();
|
|
||||||
const title = page.getByTestId('info-modal-title');
|
const title = page.getByTestId('info-modal-title');
|
||||||
await expect(title).toHaveText('this is a new page');
|
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');
|
const infoModal = page.getByTestId('info-modal');
|
||||||
await expect(infoModal).toBeVisible();
|
await expect(infoModal).toBeVisible();
|
||||||
const tagRow = page.getByTestId('info-modal-tags-row');
|
|
||||||
await expect(tagRow).toBeVisible();
|
|
||||||
const title = page.getByTestId('info-modal-title');
|
const title = page.getByTestId('info-modal-title');
|
||||||
await expect(title).toHaveText('this is a new page');
|
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');
|
const infoModal = page.getByTestId('info-modal');
|
||||||
await expect(infoModal).toBeVisible();
|
await expect(infoModal).toBeVisible();
|
||||||
const tagRow = page.getByTestId('info-modal-tags-row');
|
|
||||||
await expect(tagRow).toBeVisible();
|
|
||||||
const title = page.getByTestId('info-modal-title');
|
const title = page.getByTestId('info-modal-title');
|
||||||
await expect(title).toHaveText('this is a new page');
|
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');
|
const infoModal = page.getByTestId('info-modal');
|
||||||
await expect(infoModal).toBeVisible();
|
await expect(infoModal).toBeVisible();
|
||||||
const tagRow = page.getByTestId('info-modal-tags-row');
|
|
||||||
await expect(tagRow).toBeVisible();
|
|
||||||
const title = page.getByTestId('info-modal-title');
|
const title = page.getByTestId('info-modal-title');
|
||||||
await expect(title).toHaveText('this is a new page');
|
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');
|
const infoModal = page.getByTestId('info-modal');
|
||||||
await expect(infoModal).toBeVisible();
|
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, 'Test1');
|
||||||
await searchAndCreateTag(page, 'Test2');
|
await searchAndCreateTag(page, 'Test2');
|
||||||
await closeTagsEditor(page);
|
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 removeSelectedTag(page, 'Test1');
|
||||||
await closeTagsEditor(page);
|
await closeTagsEditor(page);
|
||||||
await expectTagsVisible(page, ['Test2']);
|
await expectTagsVisible(infoModal, ['Test2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add custom property', async ({ page }) => {
|
test('add custom property', async ({ page }) => {
|
||||||
@@ -133,10 +125,10 @@ test('add custom property', async ({ page }) => {
|
|||||||
|
|
||||||
const infoModal = page.getByTestId('info-modal');
|
const infoModal = page.getByTestId('info-modal');
|
||||||
await expect(infoModal).toBeVisible();
|
await expect(infoModal).toBeVisible();
|
||||||
await addCustomProperty(page, 'Text');
|
await addCustomProperty(page, infoModal, 'text');
|
||||||
await addCustomProperty(page, 'Number');
|
await addCustomProperty(page, infoModal, 'number');
|
||||||
await addCustomProperty(page, 'Date');
|
await addCustomProperty(page, infoModal, 'date');
|
||||||
await addCustomProperty(page, 'Checkbox');
|
await addCustomProperty(page, infoModal, 'checkbox');
|
||||||
await addCustomProperty(page, 'Created by');
|
await addCustomProperty(page, infoModal, 'createdBy');
|
||||||
await addCustomProperty(page, 'Last edited by');
|
await addCustomProperty(page, infoModal, 'updatedBy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,39 +81,40 @@ test('allow create tag on journals page', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('add custom property', async ({ page }) => {
|
test('add custom property', async ({ page }) => {
|
||||||
await addCustomProperty(page, 'Text');
|
await addCustomProperty(page, page, 'text');
|
||||||
await addCustomProperty(page, 'Number');
|
await addCustomProperty(page, page, 'number');
|
||||||
await addCustomProperty(page, 'Date');
|
await addCustomProperty(page, page, 'date');
|
||||||
await addCustomProperty(page, 'Checkbox');
|
await addCustomProperty(page, page, 'checkbox');
|
||||||
await addCustomProperty(page, 'Created by');
|
await addCustomProperty(page, page, 'createdBy');
|
||||||
await addCustomProperty(page, 'Last edited by');
|
await addCustomProperty(page, page, 'updatedBy');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add custom property & edit', async ({ page }) => {
|
test('add custom property & edit', async ({ page }) => {
|
||||||
await addCustomProperty(page, 'Checkbox');
|
await addCustomProperty(page, page, 'checkbox');
|
||||||
await expect(
|
await expect(
|
||||||
getPropertyValueLocator(page, 'Checkbox').locator('input')
|
getPropertyValueLocator(page, 'checkbox').locator('input')
|
||||||
).not.toBeChecked();
|
).not.toBeChecked();
|
||||||
await clickPropertyValue(page, 'Checkbox');
|
await clickPropertyValue(page, 'checkbox');
|
||||||
await expect(
|
await expect(
|
||||||
getPropertyValueLocator(page, 'Checkbox').locator('input')
|
getPropertyValueLocator(page, 'checkbox').locator('input')
|
||||||
).toBeChecked();
|
).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('property table reordering', async ({ page }) => {
|
test('property table reordering', async ({ page }) => {
|
||||||
await addCustomProperty(page, 'Text');
|
await addCustomProperty(page, page, 'text');
|
||||||
await addCustomProperty(page, 'Number');
|
await addCustomProperty(page, page, 'number');
|
||||||
await addCustomProperty(page, 'Date');
|
await addCustomProperty(page, page, 'date');
|
||||||
await addCustomProperty(page, 'Checkbox');
|
await addCustomProperty(page, page, 'checkbox');
|
||||||
await addCustomProperty(page, 'Created by');
|
await addCustomProperty(page, page, 'createdBy');
|
||||||
await addCustomProperty(page, 'Last edited by');
|
await addCustomProperty(page, page, 'updatedBy');
|
||||||
|
|
||||||
await dragTo(
|
await dragTo(
|
||||||
page,
|
page,
|
||||||
page.locator('[data-testid="page-property-row-name"]:has-text("Text")'),
|
page.locator('[data-testid="doc-property-name"]:has-text("Text")'),
|
||||||
page.locator(
|
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
|
// new order should be (Tags), Number, Date, Checkbox, Text
|
||||||
@@ -128,9 +129,9 @@ test('property table reordering', async ({ page }) => {
|
|||||||
].entries()) {
|
].entries()) {
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.getByTestId('page-property-row')
|
.getByTestId('doc-property-row')
|
||||||
.nth(index)
|
.nth(index)
|
||||||
.getByTestId('page-property-row-name')
|
.getByTestId('doc-property-name')
|
||||||
).toHaveText(property);
|
).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 }) => {
|
test('page info show more will show all properties', async ({ page }) => {
|
||||||
await addCustomProperty(page, 'Text');
|
await addCustomProperty(page, page, 'text');
|
||||||
await addCustomProperty(page, 'Number');
|
await addCustomProperty(page, page, 'number');
|
||||||
await addCustomProperty(page, 'Date');
|
await addCustomProperty(page, page, 'date');
|
||||||
await addCustomProperty(page, 'Checkbox');
|
await addCustomProperty(page, page, 'checkbox');
|
||||||
await addCustomProperty(page, 'Created by');
|
await addCustomProperty(page, page, 'createdBy');
|
||||||
await addCustomProperty(page, 'Last edited by');
|
await addCustomProperty(page, page, 'updatedBy');
|
||||||
|
|
||||||
await expect(page.getByTestId('page-info-show-more')).toBeVisible();
|
await changePropertyVisibility(page, 'Text', 'always-hide');
|
||||||
await page.click('[data-testid="page-info-show-more"]');
|
|
||||||
await expect(
|
await expect(page.getByTestId('property-collapsible-button')).toBeVisible();
|
||||||
page.getByRole('heading', {
|
await page.click('[data-testid="property-collapsible-button"]');
|
||||||
name: 'customize properties',
|
|
||||||
})
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// new order should be (Tags), Number, Date, Checkbox, Text
|
|
||||||
for (const [index, property] of [
|
for (const [index, property] of [
|
||||||
|
'Tags',
|
||||||
'Text',
|
'Text',
|
||||||
'Number',
|
'Number',
|
||||||
'Date',
|
'Date',
|
||||||
@@ -169,51 +167,51 @@ test('page info show more will show all properties', async ({ page }) => {
|
|||||||
].entries()) {
|
].entries()) {
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.getByTestId('page-properties-settings-menu-item')
|
.getByTestId('doc-property-row')
|
||||||
.nth(index)
|
.nth(index)
|
||||||
.getByTestId('page-property-setting-row-name')
|
.getByTestId('doc-property-name')
|
||||||
).toHaveText(property);
|
).toHaveText(property);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('change page properties visibility', async ({ page }) => {
|
test('change page properties visibility', async ({ page }) => {
|
||||||
await addCustomProperty(page, 'Text');
|
await addCustomProperty(page, page, 'text');
|
||||||
await addCustomProperty(page, 'Number');
|
await addCustomProperty(page, page, 'number');
|
||||||
await addCustomProperty(page, 'Date');
|
await addCustomProperty(page, page, 'date');
|
||||||
await addCustomProperty(page, 'Checkbox');
|
await addCustomProperty(page, page, 'checkbox');
|
||||||
|
|
||||||
// add some number to number property
|
// add some number to number property
|
||||||
await clickPropertyValue(page, 'Number');
|
await clickPropertyValue(page, 'Number');
|
||||||
await page.locator('input[type=number]').fill('123');
|
await page.locator('input[type=number]').fill('123');
|
||||||
|
|
||||||
await changePropertyVisibility(page, 'Text', 'Hide in view');
|
await changePropertyVisibility(page, 'Text', 'always-hide');
|
||||||
await changePropertyVisibility(page, 'Number', 'Hide in view when empty');
|
await changePropertyVisibility(page, 'Number', 'hide-when-empty');
|
||||||
|
|
||||||
// text property should not be visible
|
// text property should not be visible
|
||||||
await expect(
|
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();
|
).not.toBeVisible();
|
||||||
|
|
||||||
// number property should be visible
|
// number property should be visible
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('[data-testid="page-property-row-name"]:has-text("Number")')
|
page.locator('[data-testid="doc-property-name"]:has-text("Number")')
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('check if added property is also in workspace settings', async ({
|
test('check if added property is also in workspace settings', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await addCustomProperty(page, 'Text');
|
await addCustomProperty(page, page, 'text');
|
||||||
await openWorkspaceProperties(page);
|
await openWorkspaceProperties(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('[data-testid=custom-property-row]:has-text("Text")')
|
page.locator('[data-testid=doc-property-manager-item]:has-text("Text")')
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit property name', async ({ page }) => {
|
test('edit property name', async ({ page }) => {
|
||||||
await addCustomProperty(page, 'Text');
|
await addCustomProperty(page, page, 'text');
|
||||||
await page
|
await page
|
||||||
.locator('[data-testid="page-property-row-name"]:has-text("Text")')
|
.locator('[data-testid="doc-property-name"]:has-text("Text")')
|
||||||
.click();
|
.click();
|
||||||
await expect(page.locator('[data-radix-menu-content]')).toBeVisible();
|
await expect(page.locator('[data-radix-menu-content]')).toBeVisible();
|
||||||
await expect(page.locator('[data-radix-menu-content] input')).toHaveValue(
|
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
|
// check if the property name is also updated in workspace settings
|
||||||
await openWorkspaceProperties(page);
|
await openWorkspaceProperties(page);
|
||||||
await expect(
|
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();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete property via property popup', async ({ page }) => {
|
test('delete property via property popup', async ({ page }) => {
|
||||||
await addCustomProperty(page, 'Text');
|
await addCustomProperty(page, page, 'text');
|
||||||
await page
|
await page
|
||||||
.locator('[data-testid="page-property-row-name"]:has-text("Text")')
|
.locator('[data-testid="doc-property-name"]:has-text("Text")')
|
||||||
.click();
|
.click();
|
||||||
await expect(page.locator('[data-radix-menu-content]')).toBeVisible();
|
await expect(page.locator('[data-radix-menu-content]')).toBeVisible();
|
||||||
await page
|
await page
|
||||||
.locator('[data-radix-menu-content]')
|
.locator('[data-radix-menu-content]')
|
||||||
.getByRole('menuitem', {
|
.getByRole('menuitem', {
|
||||||
name: 'Remove property',
|
name: 'Delete property',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
// confirm delete dialog should show
|
// confirm delete dialog should show
|
||||||
await expect(page.getByRole('dialog')).toContainText(
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
`The "Text" property will be remove from 1 doc(s). This action cannot be undone.`
|
|
||||||
);
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'Confirm',
|
name: 'Confirm',
|
||||||
@@ -256,84 +252,6 @@ test('delete property via property popup', async ({ page }) => {
|
|||||||
.click();
|
.click();
|
||||||
// check if the property is removed
|
// check if the property is removed
|
||||||
await expect(
|
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
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")')
|
|
||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,9 +127,7 @@ export const createPageWithTag = async (
|
|||||||
await getBlockSuiteEditorTitle(page).click();
|
await getBlockSuiteEditorTitle(page).click();
|
||||||
await getBlockSuiteEditorTitle(page).fill('test page');
|
await getBlockSuiteEditorTitle(page).fill('test page');
|
||||||
await page.getByTestId('page-info-collapse').click();
|
await page.getByTestId('page-info-collapse').click();
|
||||||
await page
|
await page.locator('[data-testid="property-tags-value"]').click();
|
||||||
.locator('[data-testid="page-property-row"][data-property="tags"]')
|
|
||||||
.click();
|
|
||||||
for (const name of options.tags) {
|
for (const name of options.tags) {
|
||||||
await createTag(page, name);
|
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';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
export const getPropertyValueLocator = (page: Page, property: string) => {
|
export const getPropertyValueLocator = (page: Page, property: string) => {
|
||||||
return page.locator(
|
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();
|
.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const expectTagsVisible = async (page: Page, tags: string[]) => {
|
export const expectTagsVisible = async (
|
||||||
const tagListPanel = page
|
root: Locator | Page,
|
||||||
.getByTestId('page-property-row')
|
tags: string[]
|
||||||
|
) => {
|
||||||
|
const tagListPanel = root
|
||||||
|
.getByTestId('property-tags-value')
|
||||||
.getByTestId('inline-tags-list');
|
.getByTestId('inline-tags-list');
|
||||||
|
|
||||||
expect(await tagListPanel.locator('[data-tag-value]').count()).toBe(
|
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) => {
|
export const clickAddPropertyButton = async (root: Locator | Page) => {
|
||||||
await page
|
await root
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'Add property',
|
name: 'Add property',
|
||||||
})
|
})
|
||||||
@@ -92,40 +95,17 @@ export const clickAddPropertyButton = async (page: Page) => {
|
|||||||
|
|
||||||
export const addCustomProperty = async (
|
export const addCustomProperty = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
type: string,
|
root: Locator | Page,
|
||||||
inSettings?: boolean
|
type: string
|
||||||
) => {
|
) => {
|
||||||
await clickAddPropertyButton(page);
|
await clickAddPropertyButton(root);
|
||||||
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 page
|
await page
|
||||||
.getByRole('menuitem', {
|
.locator(
|
||||||
name: type,
|
`[data-testid="${'create-property-menu-item'}"][data-property-type="${type}"]`
|
||||||
})
|
)
|
||||||
.click();
|
.click();
|
||||||
if (!inSettings) {
|
if (await page.getByTestId('edit-property-menu-item').isVisible()) {
|
||||||
await expect(
|
// is edit property menu opened, close it
|
||||||
page
|
|
||||||
.getByRole('menuitem', {
|
|
||||||
name: type,
|
|
||||||
})
|
|
||||||
.locator('.selected')
|
|
||||||
).toBeVisible();
|
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
}
|
}
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
@@ -184,12 +164,10 @@ export const changePropertyVisibility = async (
|
|||||||
name: string,
|
name: string,
|
||||||
option: string
|
option: string
|
||||||
) => {
|
) => {
|
||||||
await expect(page.getByTestId('page-info-show-more')).toBeVisible();
|
await page
|
||||||
await page.click('[data-testid="page-info-show-more"]');
|
.locator(`[data-testid="doc-property-name"]:has-text("${name}")`)
|
||||||
await expect(
|
.click();
|
||||||
page.getByRole('heading', {
|
await page.locator(`[data-property-visibility="${option}"]`).click();
|
||||||
name: 'customize properties',
|
await page.keyboard.press('Escape');
|
||||||
})
|
await page.waitForTimeout(500);
|
||||||
).toBeVisible();
|
|
||||||
await selectVisibilitySelector(page, name, option);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -398,7 +398,6 @@ __metadata:
|
|||||||
fake-indexeddb: "npm:^6.0.0"
|
fake-indexeddb: "npm:^6.0.0"
|
||||||
file-type: "npm:^19.1.0"
|
file-type: "npm:^19.1.0"
|
||||||
foxact: "npm:^0.2.33"
|
foxact: "npm:^0.2.33"
|
||||||
fractional-indexing: "npm:^3.2.0"
|
|
||||||
fuse.js: "npm:^7.0.0"
|
fuse.js: "npm:^7.0.0"
|
||||||
graphemer: "npm:^1.4.0"
|
graphemer: "npm:^1.4.0"
|
||||||
graphql: "npm:^16.8.1"
|
graphql: "npm:^16.8.1"
|
||||||
@@ -12847,6 +12846,7 @@ __metadata:
|
|||||||
"@testing-library/react": "npm:^16.0.0"
|
"@testing-library/react": "npm:^16.0.0"
|
||||||
fake-indexeddb: "npm:^6.0.0"
|
fake-indexeddb: "npm:^6.0.0"
|
||||||
foxact: "npm:^0.2.33"
|
foxact: "npm:^0.2.33"
|
||||||
|
fractional-indexing: "npm:^3.2.0"
|
||||||
fuse.js: "npm:^7.0.0"
|
fuse.js: "npm:^7.0.0"
|
||||||
graphemer: "npm:^1.4.0"
|
graphemer: "npm:^1.4.0"
|
||||||
idb: "npm:^8.0.0"
|
idb: "npm:^8.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user