feat(core): workspace properties setting (#5739)
the property settings in workspace settings
This commit is contained in:
@@ -9,7 +9,7 @@ export const button = style({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
outline: '0',
|
outline: '0',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
padding: '0 18px',
|
padding: '0 8px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const inputWrapper = style({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: 28,
|
height: 28,
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
padding: '0 10px',
|
gap: '10px',
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVar('textPrimaryColor'),
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
backgroundColor: cssVar('white'),
|
backgroundColor: cssVar('white'),
|
||||||
@@ -53,6 +53,7 @@ export const input = style({
|
|||||||
width: '0',
|
width: '0',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
padding: '0 12px',
|
||||||
// prevent default style
|
// prevent default style
|
||||||
WebkitAppearance: 'none',
|
WebkitAppearance: 'none',
|
||||||
WebkitTapHighlightColor: 'transparent',
|
WebkitTapHighlightColor: 'transparent',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export const menuContent = style({
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
});
|
});
|
||||||
export const menuItem = style({
|
export const menuItem = style({
|
||||||
maxWidth: '296px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -50,6 +49,9 @@ export const menuItem = style({
|
|||||||
color: cssVar('warningColor'),
|
color: cssVar('warningColor'),
|
||||||
backgroundColor: cssVar('backgroundWarningColor'),
|
backgroundColor: cssVar('backgroundWarningColor'),
|
||||||
},
|
},
|
||||||
|
'&.checked': {
|
||||||
|
color: cssVar('primaryColor'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const menuSpan = style({
|
export const menuSpan = style({
|
||||||
|
|||||||
@@ -1,40 +1,47 @@
|
|||||||
import type { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
|
import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
|
||||||
import * as icons from '@blocksuite/icons';
|
import * as icons from '@blocksuite/icons';
|
||||||
import type { SVGProps } from 'react';
|
import type { SVGProps } from 'react';
|
||||||
|
|
||||||
type IconType = (props: SVGProps<SVGSVGElement>) => JSX.Element;
|
type IconType = (props: SVGProps<SVGSVGElement>) => JSX.Element;
|
||||||
|
|
||||||
// todo: this breaks tree-shaking, and we should fix it (using dynamic imports?)
|
// todo: this breaks tree-shaking, and we should fix it (using dynamic imports?)
|
||||||
const IconsMapping = {
|
const IconsMapping = icons;
|
||||||
text: icons.TextIcon,
|
|
||||||
tag: icons.TagIcon,
|
|
||||||
dateTime: icons.DateTimeIcon,
|
|
||||||
progress: icons.ProgressIcon,
|
|
||||||
checkbox: icons.CheckBoxCheckLinearIcon,
|
|
||||||
number: icons.NumberIcon,
|
|
||||||
// todo: add more icons
|
|
||||||
} satisfies Record<string, IconType>;
|
|
||||||
|
|
||||||
export type PagePropertyIcon = keyof typeof IconsMapping;
|
export type PagePropertyIcon = keyof typeof IconsMapping;
|
||||||
|
|
||||||
|
const excludedIcons: PagePropertyIcon[] = [
|
||||||
|
'YoutubeDuotoneIcon',
|
||||||
|
'LinearLogoIcon',
|
||||||
|
'RedditDuotoneIcon',
|
||||||
|
'Logo2Icon',
|
||||||
|
'Logo3Icon',
|
||||||
|
'Logo4Icon',
|
||||||
|
'InstagramDuotoneIcon',
|
||||||
|
'TelegramDuotoneIcon',
|
||||||
|
'TextBackgroundDuotoneIcon',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const iconNames = Object.keys(IconsMapping).filter(
|
||||||
|
icon => !excludedIcons.includes(icon as PagePropertyIcon)
|
||||||
|
) as PagePropertyIcon[];
|
||||||
|
|
||||||
export const getDefaultIconName = (
|
export const getDefaultIconName = (
|
||||||
type: PagePropertyType
|
type: PagePropertyType
|
||||||
): PagePropertyIcon => {
|
): PagePropertyIcon => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return 'text';
|
return 'TextIcon';
|
||||||
case 'tags':
|
case 'tags':
|
||||||
return 'tag';
|
return 'TagIcon';
|
||||||
case 'date':
|
case 'date':
|
||||||
return 'dateTime';
|
return 'DateTimeIcon';
|
||||||
case 'progress':
|
case 'progress':
|
||||||
return 'progress';
|
return 'ProgressIcon';
|
||||||
case 'checkbox':
|
case 'checkbox':
|
||||||
return 'checkbox';
|
return 'CheckBoxCheckLinearIcon';
|
||||||
case 'number':
|
case 'number':
|
||||||
return 'number';
|
return 'NumberIcon';
|
||||||
default:
|
default:
|
||||||
return 'text';
|
return 'TextIcon';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,12 +53,18 @@ export const IconToIconName = (icon: IconType) => {
|
|||||||
return iconKey;
|
return iconKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getSafeIconName = (
|
||||||
|
iconName: string,
|
||||||
|
type?: PagePropertyType
|
||||||
|
): PagePropertyIcon => {
|
||||||
|
return Object.hasOwn(IconsMapping, iconName)
|
||||||
|
? (iconName as PagePropertyIcon)
|
||||||
|
: getDefaultIconName(type || PagePropertyType.Text);
|
||||||
|
};
|
||||||
|
|
||||||
export const nameToIcon = (
|
export const nameToIcon = (
|
||||||
iconName: string,
|
iconName: string,
|
||||||
type: PagePropertyType
|
type?: PagePropertyType
|
||||||
): IconType => {
|
): IconType => {
|
||||||
return (
|
return IconsMapping[getSafeIconName(iconName, type)];
|
||||||
IconsMapping[iconName as keyof typeof IconsMapping] ??
|
|
||||||
getDefaultIconName(type)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const iconsContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '2px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconsRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: cssVar('iconColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '0 6px',
|
||||||
|
gap: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconButton = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 20,
|
||||||
|
borderRadius: '4px',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
},
|
||||||
|
selectors: {
|
||||||
|
'&[data-active=true]': {
|
||||||
|
color: cssVar('primaryColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconSelectorButton = style({
|
||||||
|
fontSize: cssVar('fontH5'),
|
||||||
|
borderRadius: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
background: cssVar('backgroundSecondaryColor'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: cssVar('backgroundTertiaryColor'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconsContainerScrollable = style({
|
||||||
|
maxHeight: 320,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconsContainerScroller = style({
|
||||||
|
transform: 'translateX(4px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const menuHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
fontWeight: 500,
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
padding: '8px 12px',
|
||||||
|
minWidth: 200,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Menu, Scrollable } from '@affine/component';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { chunk } from 'lodash-es';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { iconNames, nameToIcon, type PagePropertyIcon } from './icons-mapping';
|
||||||
|
import * as styles from './icons-selector.css';
|
||||||
|
|
||||||
|
const iconsPerRow = 10;
|
||||||
|
|
||||||
|
const iconRows = chunk(iconNames, iconsPerRow);
|
||||||
|
|
||||||
|
export const IconsSelectorPanel = ({
|
||||||
|
selected,
|
||||||
|
onSelectedChange,
|
||||||
|
}: {
|
||||||
|
selected: PagePropertyIcon;
|
||||||
|
onSelectedChange: (icon: PagePropertyIcon) => 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 = useAFFiNEI18N();
|
||||||
|
return (
|
||||||
|
<Scrollable.Root>
|
||||||
|
<div className={styles.menuHeader}>
|
||||||
|
{t['com.affine.page-properties.icons']()}
|
||||||
|
</div>
|
||||||
|
<Scrollable.Viewport className={styles.iconsContainerScrollable}>
|
||||||
|
<div className={styles.iconsContainer} ref={ref}>
|
||||||
|
{iconRows.map((iconRow, index) => {
|
||||||
|
return (
|
||||||
|
<div key={index} className={styles.iconsRow}>
|
||||||
|
{iconRow.map(iconName => {
|
||||||
|
const Icon = nameToIcon(iconName);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={iconName}
|
||||||
|
className={styles.iconButton}
|
||||||
|
data-name={iconName}
|
||||||
|
data-active={selected === iconName}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
key={iconName}
|
||||||
|
onClick={() => onSelectedChange(iconName)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Scrollable.Scrollbar className={styles.iconsContainerScroller} />
|
||||||
|
</Scrollable.Viewport>
|
||||||
|
</Scrollable.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IconsSelectorButton = ({
|
||||||
|
selected,
|
||||||
|
onSelectedChange,
|
||||||
|
}: {
|
||||||
|
selected: PagePropertyIcon;
|
||||||
|
onSelectedChange: (icon: PagePropertyIcon) => void;
|
||||||
|
}) => {
|
||||||
|
const Icon = nameToIcon(selected);
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
items={
|
||||||
|
<IconsSelectorPanel
|
||||||
|
selected={selected}
|
||||||
|
onSelectedChange={onSelectedChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.iconSelectorButton}>
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
Input,
|
||||||
|
MenuIcon,
|
||||||
|
MenuItem,
|
||||||
|
type MenuItemProps,
|
||||||
|
MenuSeparator,
|
||||||
|
Scrollable,
|
||||||
|
} from '@affine/component';
|
||||||
|
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import {
|
||||||
|
type ChangeEventHandler,
|
||||||
|
cloneElement,
|
||||||
|
isValidElement,
|
||||||
|
type MouseEventHandler,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDefaultIconName,
|
||||||
|
getSafeIconName,
|
||||||
|
nameToIcon,
|
||||||
|
type PagePropertyIcon,
|
||||||
|
} 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}
|
||||||
|
preFix={icon ? <MenuIcon>{icon}</MenuIcon> : null}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditPropertyNameMenuItem = ({
|
||||||
|
property,
|
||||||
|
onNameBlur: onBlur,
|
||||||
|
onNameChange,
|
||||||
|
onIconChange,
|
||||||
|
}: {
|
||||||
|
onNameBlur: ChangeEventHandler;
|
||||||
|
onNameChange: (name: string) => void;
|
||||||
|
onIconChange: (icon: PagePropertyIcon) => void;
|
||||||
|
property: PageInfoCustomPropertyMeta;
|
||||||
|
}) => {
|
||||||
|
const iconName = getSafeIconName(property.icon, property.type);
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
return (
|
||||||
|
<div className={styles.propertyRowNamePopupRow}>
|
||||||
|
<IconsSelectorButton
|
||||||
|
selected={iconName}
|
||||||
|
onSelectedChange={onIconChange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
defaultValue={property.name}
|
||||||
|
onBlur={onBlur}
|
||||||
|
size="large"
|
||||||
|
style={{ borderRadius: 4 }}
|
||||||
|
onChange={onNameChange}
|
||||||
|
placeholder={t['unnamed']()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PropertyTypeMenuItem = ({
|
||||||
|
property,
|
||||||
|
}: {
|
||||||
|
property: PageInfoCustomPropertyMeta;
|
||||||
|
}) => {
|
||||||
|
const Icon = nameToIcon(getDefaultIconName(property.type), property.type);
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,29 +29,11 @@ function validatePropertyValue(type: PagePropertyType, value: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewPropertyOption {
|
export const newPropertyTypes: PagePropertyType[] = [
|
||||||
name: string;
|
PagePropertyType.Text,
|
||||||
type: PagePropertyType;
|
PagePropertyType.Number,
|
||||||
}
|
PagePropertyType.Checkbox,
|
||||||
|
PagePropertyType.Date,
|
||||||
export const newPropertyOptions: NewPropertyOption[] = [
|
|
||||||
// todo: name i18n?
|
|
||||||
{
|
|
||||||
name: 'Text',
|
|
||||||
type: PagePropertyType.Text,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Number',
|
|
||||||
type: PagePropertyType.Number,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Checkbox',
|
|
||||||
type: PagePropertyType.Checkbox,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Date',
|
|
||||||
type: PagePropertyType.Date,
|
|
||||||
},
|
|
||||||
// todo: add more
|
// todo: add more
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -121,6 +103,21 @@ export class PagePropertiesMetaManager {
|
|||||||
return property;
|
return property;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCustomPropertyMeta(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
removeCustomPropertyMeta(id: string) {
|
removeCustomPropertyMeta(id: string) {
|
||||||
// should warn if the property is in use
|
// should warn if the property is in use
|
||||||
delete this.customPropertiesSchema[id];
|
delete this.customPropertiesSchema[id];
|
||||||
@@ -136,6 +133,7 @@ export class PagePropertiesMetaManager {
|
|||||||
mapping.get(id)?.add(page.id);
|
mapping.get(id)?.add(page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return mapping;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +147,20 @@ export class PagePropertiesManager {
|
|||||||
this.metaManager = new PagePropertiesMetaManager(this.adapter);
|
this.metaManager = new PagePropertiesMetaManager(this.adapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensuring = false;
|
||||||
|
ensureRequiredProperties() {
|
||||||
|
if (this.ensuring) return;
|
||||||
|
this.ensuring = true;
|
||||||
|
this.transact(() => {
|
||||||
|
this.metaManager.getOrderedCustomPropertiesSchema().forEach(property => {
|
||||||
|
if (property.required && !this.hasCustomProperty(property.id)) {
|
||||||
|
this.addCustomProperty(property.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.ensuring = false;
|
||||||
|
}
|
||||||
|
|
||||||
get workspace() {
|
get workspace() {
|
||||||
return this.adapter.workspace;
|
return this.adapter.workspace;
|
||||||
}
|
}
|
||||||
@@ -174,6 +186,7 @@ export class PagePropertiesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get properties() {
|
get properties() {
|
||||||
|
this.ensureRequiredProperties();
|
||||||
return this.adapter.getPageProperties(this.pageId);
|
return this.adapter.getPageProperties(this.pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +205,7 @@ export class PagePropertiesManager {
|
|||||||
/**
|
/**
|
||||||
* get custom properties (filter out properties that are not in schema)
|
* get custom properties (filter out properties that are not in schema)
|
||||||
*/
|
*/
|
||||||
getCustomProperties() {
|
getCustomProperties(): Record<string, PageInfoCustomProperty> {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(this.properties.custom).filter(([id]) =>
|
Object.entries(this.properties.custom).filter(([id]) =>
|
||||||
this.metaManager.checkPropertyExists(id)
|
this.metaManager.checkPropertyExists(id)
|
||||||
@@ -213,13 +226,6 @@ export class PagePropertiesManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
leastOrder() {
|
|
||||||
return Math.min(
|
|
||||||
...Object.values(this.properties.custom).map(p => p.order),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined {
|
getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined {
|
||||||
return this.metaManager.customPropertiesSchema[id];
|
return this.metaManager.customPropertiesSchema[id];
|
||||||
}
|
}
|
||||||
@@ -275,15 +281,12 @@ export class PagePropertiesManager {
|
|||||||
Object.assign(this.properties.custom[id], opt);
|
Object.assign(this.properties.custom[id], opt);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCustomPropertyMeta(
|
get updateCustomPropertyMeta() {
|
||||||
id: string,
|
return this.metaManager.updateCustomPropertyMeta.bind(this.metaManager);
|
||||||
opt: Partial<PageInfoCustomPropertyMeta>
|
|
||||||
) {
|
|
||||||
if (!this.metaManager.checkPropertyExists(id)) {
|
|
||||||
logger.warn(`property ${id} not found`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
Object.assign(this.metaManager.customPropertiesSchema[id], opt);
|
|
||||||
|
get isPropertyRequired() {
|
||||||
|
return this.metaManager.isPropertyRequired.bind(this.metaManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
transact = this.adapter.transact;
|
transact = this.adapter.transact;
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export const rootCentered = style({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: cssVar('editorWidth'),
|
maxWidth: cssVar('editorWidth'),
|
||||||
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
||||||
|
'@container': {
|
||||||
|
[`viewport (width <= 640px)`]: {
|
||||||
|
padding: '0 24px',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tableHeader = style({
|
export const tableHeader = style({
|
||||||
@@ -226,7 +231,6 @@ export const propertyRowIconContainer = style({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
transition: 'transform 0.2s',
|
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,13 +281,11 @@ export const menuHeader = style({
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: cssVar('textSecondaryColor'),
|
color: cssVar('textSecondaryColor'),
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
minWidth: 320,
|
minWidth: 200,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const menuItemListScrollable = style({
|
export const menuItemListScrollable = style({});
|
||||||
maxHeight: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const menuItemListScrollbar = style({
|
export const menuItemListScrollbar = style({
|
||||||
transform: 'translateX(4px)',
|
transform: 'translateX(4px)',
|
||||||
@@ -296,7 +298,7 @@ export const menuItemList = style({
|
|||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`${menuItemList}${menuItemList} > div`, {
|
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
|
||||||
display: 'table !important',
|
display: 'table !important',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,30 +320,6 @@ export const checkboxProperty = style({
|
|||||||
fontSize: cssVar('fontH5'),
|
fontSize: cssVar('fontH5'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const propertyNameIconEditable = style({
|
|
||||||
fontSize: cssVar('fontH5'),
|
|
||||||
borderRadius: 4,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
flexShrink: 0,
|
|
||||||
border: `1px solid ${cssVar('borderColor')}`,
|
|
||||||
background: cssVar('backgroundSecondaryColor'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const propertyNameInput = style({
|
|
||||||
fontSize: cssVar('fontSm'),
|
|
||||||
borderRadius: 4,
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
background: 'none',
|
|
||||||
border: `1px solid ${cssVar('borderColor')}`,
|
|
||||||
outline: 'none',
|
|
||||||
width: '100%',
|
|
||||||
padding: 6,
|
|
||||||
});
|
|
||||||
|
|
||||||
globalStyle(
|
globalStyle(
|
||||||
`${propertyRow}:is([data-dragging=true], [data-other-dragging=true])
|
`${propertyRow}:is([data-dragging=true], [data-other-dragging=true])
|
||||||
:is(${propertyRowValueCell}, ${propertyRowNameCell})`,
|
:is(${propertyRowValueCell}, ${propertyRowNameCell})`,
|
||||||
@@ -387,7 +365,7 @@ export const propertySettingRowName = style({
|
|||||||
export const selectorButton = style({
|
export const selectorButton = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'flex-end',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
fontSize: cssVar('fontSm'),
|
fontSize: cssVar('fontSm'),
|
||||||
@@ -397,4 +375,29 @@ export const selectorButton = style({
|
|||||||
':hover': {
|
':hover': {
|
||||||
backgroundColor: cssVar('hoverColor'),
|
backgroundColor: cssVar('hoverColor'),
|
||||||
},
|
},
|
||||||
|
selectors: {
|
||||||
|
'&[data-required=true]': {
|
||||||
|
color: cssVar('textDisableColor'),
|
||||||
|
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: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
MenuIcon,
|
MenuIcon,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Scrollable,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
|
import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
|
||||||
@@ -12,6 +11,7 @@ import { useBlockSuitePageBacklinks } from '@affine/core/hooks/use-block-suite-p
|
|||||||
import type {
|
import type {
|
||||||
PageInfoCustomProperty,
|
PageInfoCustomProperty,
|
||||||
PageInfoCustomPropertyMeta,
|
PageInfoCustomPropertyMeta,
|
||||||
|
PagePropertyType,
|
||||||
} from '@affine/core/modules/workspace/properties/schema';
|
} from '@affine/core/modules/workspace/properties/schema';
|
||||||
import { timestampToLocalDate } from '@affine/core/utils';
|
import { timestampToLocalDate } from '@affine/core/utils';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
@@ -54,19 +54,28 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { AffinePageReference } from '../reference-link';
|
import { AffinePageReference } from '../reference-link';
|
||||||
import { managerContext, pageInfoCollapsedAtom } from './common';
|
import { managerContext, pageInfoCollapsedAtom } from './common';
|
||||||
import { getDefaultIconName, nameToIcon } from './icons-mapping';
|
|
||||||
import {
|
import {
|
||||||
type NewPropertyOption,
|
getDefaultIconName,
|
||||||
newPropertyOptions,
|
nameToIcon,
|
||||||
|
type PagePropertyIcon,
|
||||||
|
} from './icons-mapping';
|
||||||
|
import {
|
||||||
|
EditPropertyNameMenuItem,
|
||||||
|
type MenuItemOption,
|
||||||
|
PropertyTypeMenuItem,
|
||||||
|
renderMenuItemOptions,
|
||||||
|
} from './menu-items';
|
||||||
|
import type { PagePropertiesMetaManager } from './page-properties-manager';
|
||||||
|
import {
|
||||||
|
newPropertyTypes,
|
||||||
PagePropertiesManager,
|
PagePropertiesManager,
|
||||||
} from './page-properties-manager';
|
} from './page-properties-manager';
|
||||||
import { propertyValueRenderers } from './property-row-values';
|
import { propertyValueRenderers } from './property-row-value-renderer';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
type PagePropertiesSettingsPopupProps = PropsWithChildren<{
|
type PagePropertiesSettingsPopupProps = PropsWithChildren<{
|
||||||
@@ -220,37 +229,36 @@ const VisibilityModeSelector = ({
|
|||||||
const manager = useContext(managerContext);
|
const manager = useContext(managerContext);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const meta = manager.getCustomPropertyMeta(property.id);
|
const meta = manager.getCustomPropertyMeta(property.id);
|
||||||
|
const visibility = property.visibility || 'visible';
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const options: MenuItemOption[] = [];
|
||||||
|
options.push(
|
||||||
|
visibilities.map(v => {
|
||||||
|
const text = visibilityMenuText(v);
|
||||||
|
return {
|
||||||
|
text: t[text](),
|
||||||
|
selected: visibility === v,
|
||||||
|
onClick: () => {
|
||||||
|
manager.updateCustomProperty(property.id, {
|
||||||
|
visibility: v,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return renderMenuItemOptions(options);
|
||||||
|
}, [manager, property.id, t, visibility]);
|
||||||
|
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = meta.required;
|
const required = meta.required;
|
||||||
const visibility = property.visibility || 'visible';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
items={
|
items={menuItems}
|
||||||
<>
|
|
||||||
{visibilities.map(v => {
|
|
||||||
const text = visibilitySelectorText(v);
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
key={v}
|
|
||||||
checked={visibility === v}
|
|
||||||
data-testid="page-properties-visibility-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
manager.updateCustomProperty(property.id, {
|
|
||||||
visibility: v,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t[text]()}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
rootOptions={{
|
rootOptions={{
|
||||||
open: required ? false : undefined,
|
open: required ? false : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -276,17 +284,16 @@ export const PagePropertiesSettingsPopup = ({
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const properties = manager.getOrderedCustomProperties();
|
const properties = manager.getOrderedCustomProperties();
|
||||||
|
|
||||||
return (
|
const menuItems = useMemo(() => {
|
||||||
<Menu
|
const options: MenuItemOption[] = [];
|
||||||
items={
|
options.push(
|
||||||
<>
|
|
||||||
<div className={styles.menuHeader}>
|
<div className={styles.menuHeader}>
|
||||||
{t['com.affine.page-properties.settings.title']()}
|
{t['com.affine.page-properties.settings.title']()}
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
);
|
||||||
<Scrollable.Root className={styles.menuItemListScrollable}>
|
options.push('-');
|
||||||
<Scrollable.Viewport className={styles.menuItemList}>
|
options.push([
|
||||||
<SortableProperties>
|
<SortableProperties key="sortable-settings">
|
||||||
{properties.map(property => {
|
{properties.map(property => {
|
||||||
const meta = manager.getCustomPropertyMeta(property.id);
|
const meta = manager.getCustomPropertyMeta(property.id);
|
||||||
assertExists(meta, 'meta should exist for property');
|
assertExists(meta, 'meta should exist for property');
|
||||||
@@ -307,15 +314,12 @@ export const PagePropertiesSettingsPopup = ({
|
|||||||
</SortablePropertyRow>
|
</SortablePropertyRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SortableProperties>
|
</SortableProperties>,
|
||||||
</Scrollable.Viewport>
|
]);
|
||||||
</Scrollable.Root>
|
return renderMenuItemOptions(options);
|
||||||
</>
|
}, [manager, properties, t]);
|
||||||
}
|
|
||||||
>
|
return <Menu items={menuItems}>{children}</Menu>;
|
||||||
{children}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageBacklinksPopupProps = PropsWithChildren<{
|
type PageBacklinksPopupProps = PropsWithChildren<{
|
||||||
@@ -363,18 +367,24 @@ export const PagePropertyRowName = ({
|
|||||||
children,
|
children,
|
||||||
}: PropsWithChildren<PagePropertyRowNameProps>) => {
|
}: PropsWithChildren<PagePropertyRowNameProps>) => {
|
||||||
const manager = useContext(managerContext);
|
const manager = useContext(managerContext);
|
||||||
const Icon = nameToIcon(meta.icon, meta.type);
|
const [localPropertyMeta, setLocalPropertyMeta] = useState(() => ({
|
||||||
const localPropertyMetaRef = useRef({ ...meta });
|
...meta,
|
||||||
const localPropertyRef = useRef({ ...property });
|
}));
|
||||||
const [nextVisibility, setNextVisibility] = useState(property.visibility);
|
const [localProperty, setLocalProperty] = useState(() => ({ ...property }));
|
||||||
const toHide =
|
const nextVisibility = rotateVisibility(localProperty.visibility);
|
||||||
nextVisibility === 'hide' || nextVisibility === 'hide-if-empty';
|
|
||||||
|
|
||||||
const handleFinishEditing = useCallback(() => {
|
const handleFinishEditing = useCallback(() => {
|
||||||
onFinishEditing();
|
onFinishEditing();
|
||||||
manager.updateCustomPropertyMeta(meta.id, localPropertyMetaRef.current);
|
manager.updateCustomPropertyMeta(meta.id, localPropertyMeta);
|
||||||
manager.updateCustomProperty(property.id, localPropertyRef.current);
|
manager.updateCustomProperty(property.id, localProperty);
|
||||||
}, [manager, meta.id, onFinishEditing, property.id]);
|
}, [
|
||||||
|
localProperty,
|
||||||
|
localPropertyMeta,
|
||||||
|
manager,
|
||||||
|
meta.id,
|
||||||
|
onFinishEditing,
|
||||||
|
property.id,
|
||||||
|
]);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
|
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
e => {
|
e => {
|
||||||
@@ -385,21 +395,23 @@ export const PagePropertyRowName = ({
|
|||||||
},
|
},
|
||||||
[manager, meta.id]
|
[manager, meta.id]
|
||||||
);
|
);
|
||||||
const handleNameChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
const handleNameChange: (name: string) => void = useCallback(name => {
|
||||||
e => {
|
setLocalPropertyMeta(prev => ({
|
||||||
localPropertyMetaRef.current.name = e.target.value;
|
...prev,
|
||||||
},
|
name: name,
|
||||||
[]
|
}));
|
||||||
);
|
}, []);
|
||||||
const toggleHide = useCallback((e: MouseEvent) => {
|
const toggleHide = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const nextVisibility = rotateVisibility(
|
setLocalProperty(prev => ({
|
||||||
localPropertyRef.current.visibility
|
...prev,
|
||||||
|
visibility: nextVisibility,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[nextVisibility]
|
||||||
);
|
);
|
||||||
setNextVisibility(nextVisibility);
|
|
||||||
localPropertyRef.current.visibility = nextVisibility;
|
|
||||||
}, []);
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -409,6 +421,64 @@ export const PagePropertyRowName = ({
|
|||||||
[manager, property.id]
|
[manager, property.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleIconChange = useCallback(
|
||||||
|
(icon: PagePropertyIcon) => {
|
||||||
|
setLocalPropertyMeta(prev => ({
|
||||||
|
...prev,
|
||||||
|
icon,
|
||||||
|
}));
|
||||||
|
manager.updateCustomPropertyMeta(meta.id, {
|
||||||
|
icon: icon,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[manager, meta.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const options: MenuItemOption[] = [];
|
||||||
|
options.push(
|
||||||
|
<EditPropertyNameMenuItem
|
||||||
|
property={localPropertyMeta}
|
||||||
|
onIconChange={handleIconChange}
|
||||||
|
onNameBlur={handleNameBlur}
|
||||||
|
onNameChange={handleNameChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
options.push(<PropertyTypeMenuItem property={localPropertyMeta} />);
|
||||||
|
if (!localPropertyMeta.required) {
|
||||||
|
options.push('-');
|
||||||
|
options.push({
|
||||||
|
icon:
|
||||||
|
nextVisibility === 'hide' || nextVisibility === 'hide-if-empty' ? (
|
||||||
|
<InvisibleIcon />
|
||||||
|
) : (
|
||||||
|
<ViewIcon />
|
||||||
|
),
|
||||||
|
text: t[
|
||||||
|
visibilityMenuText(rotateVisibility(localProperty.visibility))
|
||||||
|
](),
|
||||||
|
onClick: toggleHide,
|
||||||
|
});
|
||||||
|
options.push({
|
||||||
|
type: 'danger',
|
||||||
|
icon: <DeleteIcon />,
|
||||||
|
text: t['com.affine.page-properties.property.remove-property'](),
|
||||||
|
onClick: handleDelete,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return renderMenuItemOptions(options);
|
||||||
|
}, [
|
||||||
|
handleDelete,
|
||||||
|
handleIconChange,
|
||||||
|
handleNameBlur,
|
||||||
|
handleNameChange,
|
||||||
|
localProperty.visibility,
|
||||||
|
localPropertyMeta,
|
||||||
|
nextVisibility,
|
||||||
|
t,
|
||||||
|
toggleHide,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
rootOptions={{
|
rootOptions={{
|
||||||
@@ -417,43 +487,7 @@ export const PagePropertyRowName = ({
|
|||||||
contentOptions={{
|
contentOptions={{
|
||||||
onInteractOutside: handleFinishEditing,
|
onInteractOutside: handleFinishEditing,
|
||||||
}}
|
}}
|
||||||
items={
|
items={menuItems}
|
||||||
<>
|
|
||||||
<div className={styles.propertyRowNamePopupRow}>
|
|
||||||
<div className={styles.propertyNameIconEditable}>
|
|
||||||
<Icon />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
className={styles.propertyNameInput}
|
|
||||||
defaultValue={meta.name}
|
|
||||||
onBlur={handleNameBlur}
|
|
||||||
onChange={handleNameChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
<MenuItem
|
|
||||||
preFix={
|
|
||||||
<MenuIcon>{!toHide ? <ViewIcon /> : <InvisibleIcon />}</MenuIcon>
|
|
||||||
}
|
|
||||||
data-testid="page-property-row-name-hide-menu-item"
|
|
||||||
onClick={toggleHide}
|
|
||||||
>
|
|
||||||
{t[visibilityMenuText(nextVisibility)]()}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
type="danger"
|
|
||||||
preFix={
|
|
||||||
<MenuIcon>
|
|
||||||
<DeleteIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
}
|
|
||||||
data-testid="page-property-row-name-delete-menu-item"
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
{t['com.affine.page-properties.property.remove-property']()}
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -641,7 +675,10 @@ export const PagePropertiesTableBody = ({
|
|||||||
style,
|
style,
|
||||||
}: PagePropertiesTableBodyProps) => {
|
}: PagePropertiesTableBodyProps) => {
|
||||||
const manager = useContext(managerContext);
|
const manager = useContext(managerContext);
|
||||||
const properties = manager.getOrderedCustomProperties();
|
|
||||||
|
const properties = useMemo(() => {
|
||||||
|
return manager.getOrderedCustomProperties();
|
||||||
|
}, [manager]);
|
||||||
return (
|
return (
|
||||||
<Collapsible.Content
|
<Collapsible.Content
|
||||||
className={clsx(styles.tableBodyRoot, className)}
|
className={clsx(styles.tableBodyRoot, className)}
|
||||||
@@ -652,8 +689,9 @@ export const PagePropertiesTableBody = ({
|
|||||||
{properties
|
{properties
|
||||||
.filter(
|
.filter(
|
||||||
property =>
|
property =>
|
||||||
property.visibility !== 'hide' &&
|
manager.isPropertyRequired(property.id) ||
|
||||||
!(property.visibility === 'hide-if-empty' && !property.value)
|
(property.visibility !== 'hide' &&
|
||||||
|
!(property.visibility === 'hide-if-empty' && !property.value))
|
||||||
)
|
)
|
||||||
.map(property => (
|
.map(property => (
|
||||||
<PagePropertyRow key={property.id} property={property} />
|
<PagePropertyRow key={property.id} property={property} />
|
||||||
@@ -668,6 +706,7 @@ export const PagePropertiesTableBody = ({
|
|||||||
|
|
||||||
interface PagePropertiesCreatePropertyMenuItemsProps {
|
interface PagePropertiesCreatePropertyMenuItemsProps {
|
||||||
onCreated?: (e: React.MouseEvent, id: string) => void;
|
onCreated?: (e: React.MouseEvent, id: string) => void;
|
||||||
|
metaManager: PagePropertiesMetaManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
const findNextDefaultName = (name: string, allNames: string[]): string => {
|
const findNextDefaultName = (name: string, allNames: string[]): string => {
|
||||||
@@ -688,63 +727,62 @@ const findNextDefaultName = (name: string, allNames: string[]): string => {
|
|||||||
|
|
||||||
export const PagePropertiesCreatePropertyMenuItems = ({
|
export const PagePropertiesCreatePropertyMenuItems = ({
|
||||||
onCreated,
|
onCreated,
|
||||||
|
metaManager,
|
||||||
}: PagePropertiesCreatePropertyMenuItemsProps) => {
|
}: PagePropertiesCreatePropertyMenuItemsProps) => {
|
||||||
const manager = useContext(managerContext);
|
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const onAddProperty = useCallback(
|
const onAddProperty = useCallback(
|
||||||
(e: React.MouseEvent, option: NewPropertyOption & { icon: string }) => {
|
(
|
||||||
const nameExists = manager.metaManager
|
e: React.MouseEvent,
|
||||||
.getOrderedCustomPropertiesSchema()
|
option: { type: PagePropertyType; name: string; icon: string }
|
||||||
.some(meta => meta.name === option.name);
|
) => {
|
||||||
const allNames = manager.metaManager
|
const schemaList = metaManager.getOrderedCustomPropertiesSchema();
|
||||||
.getOrderedCustomPropertiesSchema()
|
const nameExists = schemaList.some(meta => meta.name === option.name);
|
||||||
.map(meta => meta.name);
|
const allNames = schemaList.map(meta => meta.name);
|
||||||
const name = nameExists
|
const name = nameExists
|
||||||
? findNextDefaultName(option.name, allNames)
|
? findNextDefaultName(option.name, allNames)
|
||||||
: option.name;
|
: option.name;
|
||||||
const { id } = manager.metaManager.addCustomPropertyMeta({
|
const { id } = metaManager.addCustomPropertyMeta({
|
||||||
name,
|
name,
|
||||||
icon: option.icon,
|
icon: option.icon,
|
||||||
type: option.type,
|
type: option.type,
|
||||||
});
|
});
|
||||||
onCreated?.(e, id);
|
onCreated?.(e, id);
|
||||||
},
|
},
|
||||||
[manager.metaManager, onCreated]
|
[metaManager, onCreated]
|
||||||
);
|
);
|
||||||
return (
|
|
||||||
<>
|
return useMemo(() => {
|
||||||
|
const options: MenuItemOption[] = [];
|
||||||
|
options.push(
|
||||||
<div className={styles.menuHeader}>
|
<div className={styles.menuHeader}>
|
||||||
{t['com.affine.page-properties.create-property.menu.header']()}
|
{t['com.affine.page-properties.create-property.menu.header']()}
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
);
|
||||||
<div className={styles.menuItemList}>
|
options.push('-');
|
||||||
{newPropertyOptions.map(({ name, type }) => {
|
options.push(
|
||||||
|
newPropertyTypes.map(type => {
|
||||||
const iconName = getDefaultIconName(type);
|
const iconName = getDefaultIconName(type);
|
||||||
const Icon = nameToIcon(iconName, type);
|
const Icon = nameToIcon(iconName, type);
|
||||||
return (
|
const name = t[`com.affine.page-properties.property.${type}`]();
|
||||||
<MenuItem
|
return {
|
||||||
key={type}
|
icon: <Icon />,
|
||||||
preFix={
|
text: name,
|
||||||
<MenuIcon>
|
onClick: (e: React.MouseEvent) => {
|
||||||
<Icon />
|
onAddProperty(e, {
|
||||||
</MenuIcon>
|
icon: iconName,
|
||||||
}
|
name: name,
|
||||||
data-testid="page-properties-create-property-menu-item"
|
type: type,
|
||||||
onClick={e => {
|
});
|
||||||
onAddProperty(e, { icon: iconName, name, type });
|
},
|
||||||
}}
|
};
|
||||||
>
|
})
|
||||||
{name}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
return renderMenuItemOptions(options);
|
||||||
|
}, [onAddProperty, t]);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PagePropertiesAddPropertyMenuItemsProps {
|
interface PagePropertiesAddPropertyMenuItemsProps {
|
||||||
onCreateClicked?: (e: React.MouseEvent) => void;
|
onCreateClicked: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PagePropertiesAddPropertyMenuItems = ({
|
const PagePropertiesAddPropertyMenuItems = ({
|
||||||
@@ -754,6 +792,7 @@ const PagePropertiesAddPropertyMenuItems = ({
|
|||||||
|
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const metaList = manager.metaManager.getOrderedCustomPropertiesSchema();
|
const metaList = manager.metaManager.getOrderedCustomPropertiesSchema();
|
||||||
|
const nonRequiredMetaList = metaList.filter(meta => !meta.required);
|
||||||
const isChecked = useCallback(
|
const isChecked = useCallback(
|
||||||
(m: string) => {
|
(m: string) => {
|
||||||
return manager.hasCustomProperty(m);
|
return manager.hasCustomProperty(m);
|
||||||
@@ -774,59 +813,41 @@ const PagePropertiesAddPropertyMenuItems = ({
|
|||||||
[isChecked, manager]
|
[isChecked, manager]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const menuItems = useMemo(() => {
|
||||||
<>
|
const options: MenuItemOption[] = [];
|
||||||
|
options.push(
|
||||||
<div className={styles.menuHeader}>
|
<div className={styles.menuHeader}>
|
||||||
{t['com.affine.page-properties.add-property.menu.header']()}
|
{t['com.affine.page-properties.add-property.menu.header']()}
|
||||||
</div>
|
</div>
|
||||||
{/* hide available properties if there are none */}
|
);
|
||||||
{metaList.length > 0 ? (
|
|
||||||
<>
|
if (nonRequiredMetaList.length > 0) {
|
||||||
<Divider />
|
options.push('-');
|
||||||
<Scrollable.Root className={styles.menuItemListScrollable}>
|
const nonRequiredMetaOptions: MenuItemOption = nonRequiredMetaList.map(
|
||||||
<Scrollable.Viewport className={styles.menuItemList}>
|
meta => {
|
||||||
{metaList.map(meta => {
|
|
||||||
const Icon = nameToIcon(meta.icon, meta.type);
|
const Icon = nameToIcon(meta.icon, meta.type);
|
||||||
const name = meta.name;
|
const name = meta.name;
|
||||||
return (
|
return {
|
||||||
<MenuItem
|
icon: <Icon />,
|
||||||
key={meta.id}
|
text: name,
|
||||||
preFix={
|
selected: isChecked(meta.id),
|
||||||
<MenuIcon>
|
onClick: (e: React.MouseEvent) => onClickProperty(e, meta.id),
|
||||||
<Icon />
|
};
|
||||||
</MenuIcon>
|
|
||||||
}
|
}
|
||||||
data-testid="page-properties-add-property-menu-item"
|
|
||||||
data-property={meta.id}
|
|
||||||
checked={isChecked(meta.id)}
|
|
||||||
onClick={(e: React.MouseEvent) =>
|
|
||||||
onClickProperty(e, meta.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
);
|
||||||
})}
|
options.push(nonRequiredMetaOptions);
|
||||||
</Scrollable.Viewport>
|
|
||||||
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
|
|
||||||
</Scrollable.Root>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Divider />
|
|
||||||
<MenuItem
|
|
||||||
onClick={onCreateClicked}
|
|
||||||
preFix={
|
|
||||||
<MenuIcon>
|
|
||||||
<PlusIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
}
|
}
|
||||||
>
|
options.push('-');
|
||||||
<div className={styles.menuItemName}>
|
options.push({
|
||||||
{t['com.affine.page-properties.add-property.menu.create']()}
|
icon: <PlusIcon />,
|
||||||
</div>
|
text: t['com.affine.page-properties.add-property.menu.create'](),
|
||||||
</MenuItem>
|
onClick: onCreateClicked,
|
||||||
</>
|
});
|
||||||
);
|
|
||||||
|
return renderMenuItemOptions(options);
|
||||||
|
}, [isChecked, nonRequiredMetaList, onClickProperty, onCreateClicked, t]);
|
||||||
|
|
||||||
|
return menuItems;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PagePropertiesAddProperty = () => {
|
export const PagePropertiesAddProperty = () => {
|
||||||
@@ -848,7 +869,10 @@ export const PagePropertiesAddProperty = () => {
|
|||||||
const items = adding ? (
|
const items = adding ? (
|
||||||
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
|
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
|
||||||
) : (
|
) : (
|
||||||
<PagePropertiesCreatePropertyMenuItems onCreated={handleCreated} />
|
<PagePropertiesCreatePropertyMenuItems
|
||||||
|
metaManager={manager.metaManager}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Menu rootOptions={{ onOpenChange: () => setAdding(true) }} items={items}>
|
<Menu rootOptions={{ onOpenChange: () => setAdding(true) }} items={items}>
|
||||||
@@ -882,6 +906,13 @@ const PagePropertiesTableInner = () => {
|
|||||||
export const PagePropertiesTable = ({ page }: { page: Page }) => {
|
export const PagePropertiesTable = ({ page }: { page: Page }) => {
|
||||||
const manager = usePagePropertiesManager(page);
|
const manager = usePagePropertiesManager(page);
|
||||||
|
|
||||||
|
// if the given page is not in the current workspace, then we don't render anything
|
||||||
|
// eg. when it is in history modal
|
||||||
|
|
||||||
|
if (!manager.page) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<managerContext.Provider value={manager}>
|
<managerContext.Provider value={manager}>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ const subTabConfigs = [
|
|||||||
key: 'experimental-features',
|
key: 'experimental-features',
|
||||||
title: 'com.affine.settings.workspace.experimental-features',
|
title: 'com.affine.settings.workspace.experimental-features',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'properties',
|
||||||
|
title: 'com.affine.settings.workspace.properties',
|
||||||
|
},
|
||||||
] satisfies {
|
] satisfies {
|
||||||
key: WorkspaceSubTab;
|
key: WorkspaceSubTab;
|
||||||
title: keyof ReturnType<typeof useAFFiNEI18N>;
|
title: keyof ReturnType<typeof useAFFiNEI18N>;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const GeneralSettingKeys = [
|
|||||||
export const WorkspaceSubTabs = [
|
export const WorkspaceSubTabs = [
|
||||||
'preference',
|
'preference',
|
||||||
'experimental-features',
|
'experimental-features',
|
||||||
|
'properties',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type GeneralSettingKey = (typeof GeneralSettingKeys)[number];
|
export type GeneralSettingKey = (typeof GeneralSettingKeys)[number];
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import type { WorkspaceMetadata } from '@toeverything/infra';
|
|||||||
import { useIsWorkspaceOwner } from '../../../../hooks/affine/use-is-workspace-owner';
|
import { useIsWorkspaceOwner } from '../../../../hooks/affine/use-is-workspace-owner';
|
||||||
import { ExperimentalFeatures } from './experimental-features';
|
import { ExperimentalFeatures } from './experimental-features';
|
||||||
import { WorkspaceSettingDetail } from './new-workspace-setting-detail';
|
import { WorkspaceSettingDetail } from './new-workspace-setting-detail';
|
||||||
|
import { WorkspaceSettingProperties } from './properties';
|
||||||
|
|
||||||
export const WorkspaceSetting = ({
|
export const WorkspaceSetting = ({
|
||||||
workspaceMetadata,
|
workspaceMetadata,
|
||||||
subTab,
|
subTab,
|
||||||
}: {
|
}: {
|
||||||
workspaceMetadata: WorkspaceMetadata;
|
workspaceMetadata: WorkspaceMetadata;
|
||||||
subTab: 'preference' | 'experimental-features';
|
subTab: 'preference' | 'experimental-features' | 'properties';
|
||||||
}) => {
|
}) => {
|
||||||
const isOwner = useIsWorkspaceOwner(workspaceMetadata);
|
const isOwner = useIsWorkspaceOwner(workspaceMetadata);
|
||||||
|
|
||||||
@@ -23,5 +24,9 @@ export const WorkspaceSetting = ({
|
|||||||
);
|
);
|
||||||
case 'experimental-features':
|
case 'experimental-features':
|
||||||
return <ExperimentalFeatures workspaceMetadata={workspaceMetadata} />;
|
return <ExperimentalFeatures workspaceMetadata={workspaceMetadata} />;
|
||||||
|
case 'properties':
|
||||||
|
return (
|
||||||
|
<WorkspaceSettingProperties workspaceMetadata={workspaceMetadata} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import { Button, IconButton, Menu } from '@affine/component';
|
||||||
|
import { SettingHeader } from '@affine/component/setting-components';
|
||||||
|
import { useWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
|
||||||
|
import { useWorkspace } from '@affine/core/hooks/use-workspace';
|
||||||
|
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
|
||||||
|
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema';
|
||||||
|
import { Trans } from '@affine/i18n';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { DeleteIcon, FilterIcon, MoreHorizontalIcon } from '@blocksuite/icons';
|
||||||
|
import type {
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMetadata,
|
||||||
|
} from '@toeverything/infra/workspace';
|
||||||
|
import {
|
||||||
|
type ChangeEventHandler,
|
||||||
|
createContext,
|
||||||
|
Fragment,
|
||||||
|
type MouseEvent,
|
||||||
|
type MouseEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
nameToIcon,
|
||||||
|
PagePropertiesCreatePropertyMenuItems,
|
||||||
|
PagePropertiesMetaManager,
|
||||||
|
type PagePropertyIcon,
|
||||||
|
} from '../../../page-properties';
|
||||||
|
import {
|
||||||
|
EditPropertyNameMenuItem,
|
||||||
|
type MenuItemOption,
|
||||||
|
PropertyTypeMenuItem,
|
||||||
|
renderMenuItemOptions,
|
||||||
|
} from '../../../page-properties/menu-items';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
// @ts-expect-error this should always be set
|
||||||
|
const managerContext = createContext<PagePropertiesMetaManager>();
|
||||||
|
|
||||||
|
const usePagePropertiesMetaManager = (workspace: Workspace) => {
|
||||||
|
// 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 = useWorkspacePropertiesAdapter(workspace);
|
||||||
|
const manager = useMemo(() => {
|
||||||
|
return new PagePropertiesMetaManager(adapter);
|
||||||
|
}, [adapter]);
|
||||||
|
return manager;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Divider = () => {
|
||||||
|
return <div className={styles.divider} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditPropertyButton = ({
|
||||||
|
property,
|
||||||
|
}: {
|
||||||
|
property: PageInfoCustomPropertyMeta;
|
||||||
|
}) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const manager = useContext(managerContext);
|
||||||
|
const [localPropertyMeta, setLocalPropertyMeta] = useState(() => ({
|
||||||
|
...property,
|
||||||
|
}));
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalPropertyMeta(property);
|
||||||
|
}, [property]);
|
||||||
|
const handleToggleRequired: MouseEventHandler = useCallback(
|
||||||
|
e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
|
||||||
|
required: !localPropertyMeta.required,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[manager, localPropertyMeta.id, localPropertyMeta.required]
|
||||||
|
);
|
||||||
|
const handleDelete: MouseEventHandler = useCallback(
|
||||||
|
e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
manager.removeCustomPropertyMeta(localPropertyMeta.id);
|
||||||
|
},
|
||||||
|
[manager, localPropertyMeta.id]
|
||||||
|
);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const handleFinishEditing = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setEditing(false);
|
||||||
|
manager.updateCustomPropertyMeta(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: handleDelete,
|
||||||
|
type: 'danger',
|
||||||
|
icon: <DeleteIcon />,
|
||||||
|
});
|
||||||
|
return renderMenuItemOptions(options);
|
||||||
|
}, [handleDelete, handleToggleRequired, localPropertyMeta.required, t]);
|
||||||
|
|
||||||
|
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
|
e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
|
||||||
|
name: e.target.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[manager, localPropertyMeta.id]
|
||||||
|
);
|
||||||
|
const handleNameChange: (name: string) => void = useCallback(name => {
|
||||||
|
setLocalPropertyMeta(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: name,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
const handleIconChange = useCallback(
|
||||||
|
(icon: PagePropertyIcon) => {
|
||||||
|
setLocalPropertyMeta(prev => ({
|
||||||
|
...prev,
|
||||||
|
icon,
|
||||||
|
}));
|
||||||
|
manager.updateCustomPropertyMeta(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}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
type="plain"
|
||||||
|
icon={<MoreHorizontalIcon />}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomPropertyRow = ({
|
||||||
|
property,
|
||||||
|
relatedPages,
|
||||||
|
}: {
|
||||||
|
relatedPages: string[];
|
||||||
|
property: PageInfoCustomPropertyMeta;
|
||||||
|
}) => {
|
||||||
|
const Icon = nameToIcon(property.icon, property.type);
|
||||||
|
const required = property.required;
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
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.getOrderedCustomPropertiesSchema();
|
||||||
|
const statistics = manager.getCustomPropertyStatistics();
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
|
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 required = properties.filter(property => property.required);
|
||||||
|
const optional = properties.filter(property => !property.required);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{required.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.subListHeader}>
|
||||||
|
{t[
|
||||||
|
'com.affine.settings.workspace.properties.required-properties'
|
||||||
|
]()}
|
||||||
|
</div>
|
||||||
|
<CustomPropertyRows properties={required} statistics={statistics} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{optional.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.subListHeader}>
|
||||||
|
{t[
|
||||||
|
'com.affine.settings.workspace.properties.general-properties'
|
||||||
|
]()}
|
||||||
|
</div>
|
||||||
|
<CustomPropertyRows properties={optional} statistics={statistics} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspaceSettingPropertiesMain = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const manager = useContext(managerContext);
|
||||||
|
const [filterMode, setFilterMode] = useState<PropertyFilterMode>('all');
|
||||||
|
const properties = manager.getOrderedCustomPropertiesSchema();
|
||||||
|
const filterMenuItems = useMemo(() => {
|
||||||
|
const options: MenuItemOption[] = (
|
||||||
|
['all', '-', 'in-use', 'unused'] as const
|
||||||
|
).map(mode => {
|
||||||
|
return mode === '-'
|
||||||
|
? '-'
|
||||||
|
: {
|
||||||
|
text: t[`com.affine.settings.workspace.properties.${mode}`](),
|
||||||
|
onClick: () => setFilterMode(mode),
|
||||||
|
checked: filterMode === mode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return renderMenuItemOptions(options);
|
||||||
|
}, [filterMode, t]);
|
||||||
|
|
||||||
|
const onPropertyCreated = useCallback((_e: MouseEvent, id: string) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const newRow = document.querySelector<HTMLDivElement>(
|
||||||
|
`[data-testid="custom-property-row"][data-property-id="${id}"]`
|
||||||
|
);
|
||||||
|
if (newRow) {
|
||||||
|
newRow.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
newRow.dataset.highlight = '';
|
||||||
|
setTimeout(() => {
|
||||||
|
delete newRow.dataset.highlight;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className={styles.main}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
{properties.length > 0 ? (
|
||||||
|
<Menu items={filterMenuItems}>
|
||||||
|
<Button type="default" icon={<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 type="primary">
|
||||||
|
{t['com.affine.settings.workspace.properties.add_property']()}
|
||||||
|
</Button>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
<CustomPropertyRowsList filterMode={filterMode} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspaceSettingPropertiesInner = ({
|
||||||
|
workspace,
|
||||||
|
}: {
|
||||||
|
workspace: Workspace;
|
||||||
|
}) => {
|
||||||
|
const manager = usePagePropertiesMetaManager(workspace);
|
||||||
|
return (
|
||||||
|
<managerContext.Provider value={manager}>
|
||||||
|
<WorkspaceSettingPropertiesMain />
|
||||||
|
</managerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceSettingProperties = ({
|
||||||
|
workspaceMetadata,
|
||||||
|
}: {
|
||||||
|
workspaceMetadata: WorkspaceMetadata;
|
||||||
|
}) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const workspace = useWorkspace(workspaceMetadata);
|
||||||
|
const workspaceInfo = useWorkspaceInfo(workspaceMetadata);
|
||||||
|
const title = workspaceInfo.name || 'untitled';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingHeader
|
||||||
|
title={t['com.affine.settings.workspace.properties.header.title']()}
|
||||||
|
subtitle={
|
||||||
|
<Trans
|
||||||
|
values={{
|
||||||
|
name: title,
|
||||||
|
}}
|
||||||
|
i18nKey="com.affine.settings.workspace.properties.header.subtitle"
|
||||||
|
>
|
||||||
|
Manage workspace <strong>name</strong> properties
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{workspace ? (
|
||||||
|
<WorkspaceSettingPropertiesInner workspace={workspace} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const main = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 0',
|
||||||
|
marginBottom: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 0',
|
||||||
|
gap: 6,
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'background 0.2s ease',
|
||||||
|
selectors: {
|
||||||
|
'&[data-highlight]': {
|
||||||
|
background: cssVar('backgroundSecondaryColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metaList = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyIcon = style({
|
||||||
|
color: cssVar('iconColor'),
|
||||||
|
fontSize: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyName = style({
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
selectors: {
|
||||||
|
'&[data-unnamed=true]': {
|
||||||
|
color: cssVar('placeholderColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyDocCount = style({
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const divider = style({
|
||||||
|
width: '100%',
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: cssVar('dividerColor'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const spacer = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyRequired = style({
|
||||||
|
color: cssVar('textDisableColor'),
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const subListHeader = style({
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
marginBottom: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyRowNamePopupRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 500,
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
padding: '8px 16px',
|
||||||
|
minWidth: 260,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const propertyNameIconEditable = style({
|
||||||
|
fontSize: cssVar('fontH5'),
|
||||||
|
borderRadius: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
background: cssVar('backgroundSecondaryColor'),
|
||||||
|
});
|
||||||
@@ -308,6 +308,7 @@
|
|||||||
"Unpublished hint": "Once published to the web, visitors can view the contents through the provided link.",
|
"Unpublished hint": "Once published to the web, visitors can view the contents through the provided link.",
|
||||||
"Untitled": "Untitled",
|
"Untitled": "Untitled",
|
||||||
"Untitled Collection": "Untitled Collection",
|
"Untitled Collection": "Untitled Collection",
|
||||||
|
"unnamed": "unnamed",
|
||||||
"Update Available": "Update available",
|
"Update Available": "Update available",
|
||||||
"Update Collection": "Update Collection",
|
"Update Collection": "Update Collection",
|
||||||
"Update workspace name success": "Update workspace name success",
|
"Update workspace name success": "Update workspace name success",
|
||||||
@@ -901,8 +902,22 @@
|
|||||||
"com.affine.settings.workspace.experimental-features.prompt-disclaimer": "I am aware of the risks, and I am willing to continue to use it.",
|
"com.affine.settings.workspace.experimental-features.prompt-disclaimer": "I am aware of the risks, and I am willing to continue to use it.",
|
||||||
"com.affine.settings.workspace.experimental-features.get-started": "Get Started",
|
"com.affine.settings.workspace.experimental-features.get-started": "Get Started",
|
||||||
"com.affine.settings.workspace.experimental-features.header.plugins": "Experimental Features",
|
"com.affine.settings.workspace.experimental-features.header.plugins": "Experimental Features",
|
||||||
|
"com.affine.settings.workspace.properties.header.title": "Properties",
|
||||||
|
"com.affine.settings.workspace.properties.header.subtitle": "Manage workspace <1>{{name}}</1> properties",
|
||||||
|
"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.add_property": "Add property",
|
||||||
|
"com.affine.settings.workspace.properties.all": "All",
|
||||||
|
"com.affine.settings.workspace.properties.in-use": "In use",
|
||||||
|
"com.affine.settings.workspace.properties.unused": "Unused",
|
||||||
|
"com.affine.settings.workspace.properties.set-as-required": "Set as required property",
|
||||||
|
"com.affine.settings.workspace.properties.edit-property": "Edit property",
|
||||||
|
"com.affine.settings.workspace.properties.delete-property": "Delete property",
|
||||||
|
"com.affine.settings.workspace.properties.required-properties": "Required properties",
|
||||||
|
"com.affine.settings.workspace.properties.general-properties": "General properties",
|
||||||
"com.affine.settings.workspace.preferences": "Preference",
|
"com.affine.settings.workspace.preferences": "Preference",
|
||||||
"com.affine.settings.workspace.experimental-features": "Plugins",
|
"com.affine.settings.workspace.experimental-features": "Plugins",
|
||||||
|
"com.affine.settings.workspace.properties": "Properties",
|
||||||
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
|
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
|
||||||
"com.affine.share-menu.ShareMode": "Share mode",
|
"com.affine.share-menu.ShareMode": "Share mode",
|
||||||
"com.affine.share-menu.SharePage": "Share Doc",
|
"com.affine.share-menu.SharePage": "Share Doc",
|
||||||
@@ -1103,5 +1118,12 @@
|
|||||||
"com.affine.page-properties.property.always-show": "Always show",
|
"com.affine.page-properties.property.always-show": "Always show",
|
||||||
"com.affine.page-properties.property.hide-when-empty": "Hide when empty",
|
"com.affine.page-properties.property.hide-when-empty": "Hide when empty",
|
||||||
"com.affine.page-properties.property.required": "Required",
|
"com.affine.page-properties.property.required": "Required",
|
||||||
"com.affine.page-properties.property.remove-property": "Remove property"
|
"com.affine.page-properties.property.remove-property": "Remove property",
|
||||||
|
"com.affine.page-properties.property.text": "Text",
|
||||||
|
"com.affine.page-properties.property.number": "Number",
|
||||||
|
"com.affine.page-properties.property.date": "Date",
|
||||||
|
"com.affine.page-properties.property.checkbox": "Checkbox",
|
||||||
|
"com.affine.page-properties.property.progress": "Progress",
|
||||||
|
"com.affine.page-properties.property.tags": "Tags",
|
||||||
|
"com.affine.page-properties.icons": "Icons"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user