mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-28 14:01:39 +03:00
feat(core): workspace properties setting (#5739)
the property settings in workspace settings
This commit is contained in:
parent
55b8082d3a
commit
546d96c5c9
@ -9,7 +9,7 @@ export const button = style({
|
||||
flexShrink: 0,
|
||||
outline: '0',
|
||||
border: '1px solid',
|
||||
padding: '0 18px',
|
||||
padding: '0 8px',
|
||||
borderRadius: '8px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
|
@ -4,7 +4,7 @@ export const inputWrapper = style({
|
||||
width: '100%',
|
||||
height: 28,
|
||||
lineHeight: '22px',
|
||||
padding: '0 10px',
|
||||
gap: '10px',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
border: '1px solid',
|
||||
backgroundColor: cssVar('white'),
|
||||
@ -53,6 +53,7 @@ export const input = style({
|
||||
width: '0',
|
||||
flex: 1,
|
||||
boxSizing: 'border-box',
|
||||
padding: '0 12px',
|
||||
// prevent default style
|
||||
WebkitAppearance: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
|
@ -13,7 +13,6 @@ export const menuContent = style({
|
||||
userSelect: 'none',
|
||||
});
|
||||
export const menuItem = style({
|
||||
maxWidth: '296px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
@ -50,6 +49,9 @@ export const menuItem = style({
|
||||
color: cssVar('warningColor'),
|
||||
backgroundColor: cssVar('backgroundWarningColor'),
|
||||
},
|
||||
'&.checked': {
|
||||
color: cssVar('primaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
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 type { SVGProps } from 'react';
|
||||
|
||||
type IconType = (props: SVGProps<SVGSVGElement>) => JSX.Element;
|
||||
|
||||
// todo: this breaks tree-shaking, and we should fix it (using dynamic imports?)
|
||||
const IconsMapping = {
|
||||
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>;
|
||||
|
||||
const IconsMapping = icons;
|
||||
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 = (
|
||||
type: PagePropertyType
|
||||
): PagePropertyIcon => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return 'text';
|
||||
return 'TextIcon';
|
||||
case 'tags':
|
||||
return 'tag';
|
||||
return 'TagIcon';
|
||||
case 'date':
|
||||
return 'dateTime';
|
||||
return 'DateTimeIcon';
|
||||
case 'progress':
|
||||
return 'progress';
|
||||
return 'ProgressIcon';
|
||||
case 'checkbox':
|
||||
return 'checkbox';
|
||||
return 'CheckBoxCheckLinearIcon';
|
||||
case 'number':
|
||||
return 'number';
|
||||
return 'NumberIcon';
|
||||
default:
|
||||
return 'text';
|
||||
return 'TextIcon';
|
||||
}
|
||||
};
|
||||
|
||||
@ -46,12 +53,18 @@ export const IconToIconName = (icon: IconType) => {
|
||||
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 = (
|
||||
iconName: string,
|
||||
type: PagePropertyType
|
||||
type?: PagePropertyType
|
||||
): IconType => {
|
||||
return (
|
||||
IconsMapping[iconName as keyof typeof IconsMapping] ??
|
||||
getDefaultIconName(type)
|
||||
);
|
||||
return IconsMapping[getSafeIconName(iconName, 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 {
|
||||
name: string;
|
||||
type: PagePropertyType;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
export const newPropertyTypes: PagePropertyType[] = [
|
||||
PagePropertyType.Text,
|
||||
PagePropertyType.Number,
|
||||
PagePropertyType.Checkbox,
|
||||
PagePropertyType.Date,
|
||||
// todo: add more
|
||||
];
|
||||
|
||||
@ -121,6 +103,21 @@ export class PagePropertiesMetaManager {
|
||||
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) {
|
||||
// should warn if the property is in use
|
||||
delete this.customPropertiesSchema[id];
|
||||
@ -136,6 +133,7 @@ export class PagePropertiesMetaManager {
|
||||
mapping.get(id)?.add(page.id);
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +147,20 @@ export class PagePropertiesManager {
|
||||
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() {
|
||||
return this.adapter.workspace;
|
||||
}
|
||||
@ -174,6 +186,7 @@ export class PagePropertiesManager {
|
||||
}
|
||||
|
||||
get properties() {
|
||||
this.ensureRequiredProperties();
|
||||
return this.adapter.getPageProperties(this.pageId);
|
||||
}
|
||||
|
||||
@ -192,7 +205,7 @@ export class PagePropertiesManager {
|
||||
/**
|
||||
* get custom properties (filter out properties that are not in schema)
|
||||
*/
|
||||
getCustomProperties() {
|
||||
getCustomProperties(): Record<string, PageInfoCustomProperty> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.properties.custom).filter(([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 {
|
||||
return this.metaManager.customPropertiesSchema[id];
|
||||
}
|
||||
@ -275,15 +281,12 @@ export class PagePropertiesManager {
|
||||
Object.assign(this.properties.custom[id], opt);
|
||||
}
|
||||
|
||||
updateCustomPropertyMeta(
|
||||
id: string,
|
||||
opt: Partial<PageInfoCustomPropertyMeta>
|
||||
) {
|
||||
if (!this.metaManager.checkPropertyExists(id)) {
|
||||
logger.warn(`property ${id} not found`);
|
||||
return;
|
||||
}
|
||||
Object.assign(this.metaManager.customPropertiesSchema[id], opt);
|
||||
get updateCustomPropertyMeta() {
|
||||
return this.metaManager.updateCustomPropertyMeta.bind(this.metaManager);
|
||||
}
|
||||
|
||||
get isPropertyRequired() {
|
||||
return this.metaManager.isPropertyRequired.bind(this.metaManager);
|
||||
}
|
||||
|
||||
transact = this.adapter.transact;
|
||||
|
@ -19,6 +19,11 @@ export const rootCentered = style({
|
||||
width: '100%',
|
||||
maxWidth: cssVar('editorWidth'),
|
||||
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
||||
'@container': {
|
||||
[`viewport (width <= 640px)`]: {
|
||||
padding: '0 24px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableHeader = style({
|
||||
@ -226,7 +231,6 @@ export const propertyRowIconContainer = style({
|
||||
justifyContent: 'center',
|
||||
borderRadius: '2px',
|
||||
fontSize: 16,
|
||||
transition: 'transform 0.2s',
|
||||
color: 'inherit',
|
||||
});
|
||||
|
||||
@ -277,13 +281,11 @@ export const menuHeader = style({
|
||||
fontWeight: 500,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
padding: '8px 16px',
|
||||
minWidth: 320,
|
||||
minWidth: 200,
|
||||
textTransform: 'uppercase',
|
||||
});
|
||||
|
||||
export const menuItemListScrollable = style({
|
||||
maxHeight: 300,
|
||||
});
|
||||
export const menuItemListScrollable = style({});
|
||||
|
||||
export const menuItemListScrollbar = style({
|
||||
transform: 'translateX(4px)',
|
||||
@ -296,7 +298,7 @@ export const menuItemList = style({
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
globalStyle(`${menuItemList}${menuItemList} > div`, {
|
||||
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
|
||||
display: 'table !important',
|
||||
});
|
||||
|
||||
@ -318,30 +320,6 @@ export const checkboxProperty = style({
|
||||
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(
|
||||
`${propertyRow}:is([data-dragging=true], [data-other-dragging=true])
|
||||
:is(${propertyRowValueCell}, ${propertyRowNameCell})`,
|
||||
@ -387,7 +365,7 @@ export const propertySettingRowName = style({
|
||||
export const selectorButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
borderRadius: 4,
|
||||
gap: 8,
|
||||
fontSize: cssVar('fontSm'),
|
||||
@ -397,4 +375,29 @@ export const selectorButton = style({
|
||||
':hover': {
|
||||
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,
|
||||
MenuIcon,
|
||||
MenuItem,
|
||||
Scrollable,
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
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 {
|
||||
PageInfoCustomProperty,
|
||||
PageInfoCustomPropertyMeta,
|
||||
PagePropertyType,
|
||||
} from '@affine/core/modules/workspace/properties/schema';
|
||||
import { timestampToLocalDate } from '@affine/core/utils';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@ -54,19 +54,28 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { AffinePageReference } from '../reference-link';
|
||||
import { managerContext, pageInfoCollapsedAtom } from './common';
|
||||
import { getDefaultIconName, nameToIcon } from './icons-mapping';
|
||||
import {
|
||||
type NewPropertyOption,
|
||||
newPropertyOptions,
|
||||
getDefaultIconName,
|
||||
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,
|
||||
} from './page-properties-manager';
|
||||
import { propertyValueRenderers } from './property-row-values';
|
||||
import { propertyValueRenderers } from './property-row-value-renderer';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type PagePropertiesSettingsPopupProps = PropsWithChildren<{
|
||||
@ -220,37 +229,36 @@ const VisibilityModeSelector = ({
|
||||
const manager = useContext(managerContext);
|
||||
const t = useAFFiNEI18N();
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const required = meta.required;
|
||||
const visibility = property.visibility || 'visible';
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
items={menuItems}
|
||||
rootOptions={{
|
||||
open: required ? false : undefined,
|
||||
}}
|
||||
@ -276,46 +284,42 @@ export const PagePropertiesSettingsPopup = ({
|
||||
const t = useAFFiNEI18N();
|
||||
const properties = manager.getOrderedCustomProperties();
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<>
|
||||
<div className={styles.menuHeader}>
|
||||
{t['com.affine.page-properties.settings.title']()}
|
||||
</div>
|
||||
<Divider />
|
||||
<Scrollable.Root className={styles.menuItemListScrollable}>
|
||||
<Scrollable.Viewport className={styles.menuItemList}>
|
||||
<SortableProperties>
|
||||
{properties.map(property => {
|
||||
const meta = manager.getCustomPropertyMeta(property.id);
|
||||
assertExists(meta, 'meta should exist for property');
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const name = meta.name;
|
||||
return (
|
||||
<SortablePropertyRow
|
||||
key={meta.id}
|
||||
property={property}
|
||||
className={styles.propertySettingRow}
|
||||
data-testid="page-properties-settings-menu-item"
|
||||
>
|
||||
<MenuIcon>
|
||||
<Icon />
|
||||
</MenuIcon>
|
||||
<div className={styles.propertyRowName}>{name}</div>
|
||||
<VisibilityModeSelector property={property} />
|
||||
</SortablePropertyRow>
|
||||
);
|
||||
})}
|
||||
</SortableProperties>
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
const menuItems = useMemo(() => {
|
||||
const options: MenuItemOption[] = [];
|
||||
options.push(
|
||||
<div className={styles.menuHeader}>
|
||||
{t['com.affine.page-properties.settings.title']()}
|
||||
</div>
|
||||
);
|
||||
options.push('-');
|
||||
options.push([
|
||||
<SortableProperties key="sortable-settings">
|
||||
{properties.map(property => {
|
||||
const meta = manager.getCustomPropertyMeta(property.id);
|
||||
assertExists(meta, 'meta should exist for property');
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const name = meta.name;
|
||||
return (
|
||||
<SortablePropertyRow
|
||||
key={meta.id}
|
||||
property={property}
|
||||
className={styles.propertySettingRow}
|
||||
data-testid="page-properties-settings-menu-item"
|
||||
>
|
||||
<MenuIcon>
|
||||
<Icon />
|
||||
</MenuIcon>
|
||||
<div className={styles.propertyRowName}>{name}</div>
|
||||
<VisibilityModeSelector property={property} />
|
||||
</SortablePropertyRow>
|
||||
);
|
||||
})}
|
||||
</SortableProperties>,
|
||||
]);
|
||||
return renderMenuItemOptions(options);
|
||||
}, [manager, properties, t]);
|
||||
|
||||
return <Menu items={menuItems}>{children}</Menu>;
|
||||
};
|
||||
|
||||
type PageBacklinksPopupProps = PropsWithChildren<{
|
||||
@ -363,18 +367,24 @@ export const PagePropertyRowName = ({
|
||||
children,
|
||||
}: PropsWithChildren<PagePropertyRowNameProps>) => {
|
||||
const manager = useContext(managerContext);
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const localPropertyMetaRef = useRef({ ...meta });
|
||||
const localPropertyRef = useRef({ ...property });
|
||||
const [nextVisibility, setNextVisibility] = useState(property.visibility);
|
||||
const toHide =
|
||||
nextVisibility === 'hide' || nextVisibility === 'hide-if-empty';
|
||||
const [localPropertyMeta, setLocalPropertyMeta] = useState(() => ({
|
||||
...meta,
|
||||
}));
|
||||
const [localProperty, setLocalProperty] = useState(() => ({ ...property }));
|
||||
const nextVisibility = rotateVisibility(localProperty.visibility);
|
||||
|
||||
const handleFinishEditing = useCallback(() => {
|
||||
onFinishEditing();
|
||||
manager.updateCustomPropertyMeta(meta.id, localPropertyMetaRef.current);
|
||||
manager.updateCustomProperty(property.id, localPropertyRef.current);
|
||||
}, [manager, meta.id, onFinishEditing, property.id]);
|
||||
manager.updateCustomPropertyMeta(meta.id, localPropertyMeta);
|
||||
manager.updateCustomProperty(property.id, localProperty);
|
||||
}, [
|
||||
localProperty,
|
||||
localPropertyMeta,
|
||||
manager,
|
||||
meta.id,
|
||||
onFinishEditing,
|
||||
property.id,
|
||||
]);
|
||||
const t = useAFFiNEI18N();
|
||||
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
@ -385,21 +395,23 @@ export const PagePropertyRowName = ({
|
||||
},
|
||||
[manager, meta.id]
|
||||
);
|
||||
const handleNameChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
localPropertyMetaRef.current.name = e.target.value;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const toggleHide = useCallback((e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const nextVisibility = rotateVisibility(
|
||||
localPropertyRef.current.visibility
|
||||
);
|
||||
setNextVisibility(nextVisibility);
|
||||
localPropertyRef.current.visibility = nextVisibility;
|
||||
const handleNameChange: (name: string) => void = useCallback(name => {
|
||||
setLocalPropertyMeta(prev => ({
|
||||
...prev,
|
||||
name: name,
|
||||
}));
|
||||
}, []);
|
||||
const toggleHide = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setLocalProperty(prev => ({
|
||||
...prev,
|
||||
visibility: nextVisibility,
|
||||
}));
|
||||
},
|
||||
[nextVisibility]
|
||||
);
|
||||
const handleDelete = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@ -409,6 +421,64 @@ export const PagePropertyRowName = ({
|
||||
[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 (
|
||||
<Menu
|
||||
rootOptions={{
|
||||
@ -417,43 +487,7 @@ export const PagePropertyRowName = ({
|
||||
contentOptions={{
|
||||
onInteractOutside: handleFinishEditing,
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
items={menuItems}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
@ -641,7 +675,10 @@ export const PagePropertiesTableBody = ({
|
||||
style,
|
||||
}: PagePropertiesTableBodyProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const properties = manager.getOrderedCustomProperties();
|
||||
|
||||
const properties = useMemo(() => {
|
||||
return manager.getOrderedCustomProperties();
|
||||
}, [manager]);
|
||||
return (
|
||||
<Collapsible.Content
|
||||
className={clsx(styles.tableBodyRoot, className)}
|
||||
@ -652,8 +689,9 @@ export const PagePropertiesTableBody = ({
|
||||
{properties
|
||||
.filter(
|
||||
property =>
|
||||
property.visibility !== 'hide' &&
|
||||
!(property.visibility === 'hide-if-empty' && !property.value)
|
||||
manager.isPropertyRequired(property.id) ||
|
||||
(property.visibility !== 'hide' &&
|
||||
!(property.visibility === 'hide-if-empty' && !property.value))
|
||||
)
|
||||
.map(property => (
|
||||
<PagePropertyRow key={property.id} property={property} />
|
||||
@ -668,6 +706,7 @@ export const PagePropertiesTableBody = ({
|
||||
|
||||
interface PagePropertiesCreatePropertyMenuItemsProps {
|
||||
onCreated?: (e: React.MouseEvent, id: string) => void;
|
||||
metaManager: PagePropertiesMetaManager;
|
||||
}
|
||||
|
||||
const findNextDefaultName = (name: string, allNames: string[]): string => {
|
||||
@ -688,63 +727,62 @@ const findNextDefaultName = (name: string, allNames: string[]): string => {
|
||||
|
||||
export const PagePropertiesCreatePropertyMenuItems = ({
|
||||
onCreated,
|
||||
metaManager,
|
||||
}: PagePropertiesCreatePropertyMenuItemsProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const t = useAFFiNEI18N();
|
||||
const onAddProperty = useCallback(
|
||||
(e: React.MouseEvent, option: NewPropertyOption & { icon: string }) => {
|
||||
const nameExists = manager.metaManager
|
||||
.getOrderedCustomPropertiesSchema()
|
||||
.some(meta => meta.name === option.name);
|
||||
const allNames = manager.metaManager
|
||||
.getOrderedCustomPropertiesSchema()
|
||||
.map(meta => meta.name);
|
||||
(
|
||||
e: React.MouseEvent,
|
||||
option: { type: PagePropertyType; name: string; icon: string }
|
||||
) => {
|
||||
const schemaList = metaManager.getOrderedCustomPropertiesSchema();
|
||||
const nameExists = schemaList.some(meta => meta.name === option.name);
|
||||
const allNames = schemaList.map(meta => meta.name);
|
||||
const name = nameExists
|
||||
? findNextDefaultName(option.name, allNames)
|
||||
: option.name;
|
||||
const { id } = manager.metaManager.addCustomPropertyMeta({
|
||||
const { id } = metaManager.addCustomPropertyMeta({
|
||||
name,
|
||||
icon: option.icon,
|
||||
type: option.type,
|
||||
});
|
||||
onCreated?.(e, id);
|
||||
},
|
||||
[manager.metaManager, onCreated]
|
||||
[metaManager, onCreated]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
return useMemo(() => {
|
||||
const options: MenuItemOption[] = [];
|
||||
options.push(
|
||||
<div className={styles.menuHeader}>
|
||||
{t['com.affine.page-properties.create-property.menu.header']()}
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={styles.menuItemList}>
|
||||
{newPropertyOptions.map(({ name, type }) => {
|
||||
const iconName = getDefaultIconName(type);
|
||||
const Icon = nameToIcon(iconName, type);
|
||||
return (
|
||||
<MenuItem
|
||||
key={type}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<Icon />
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="page-properties-create-property-menu-item"
|
||||
onClick={e => {
|
||||
onAddProperty(e, { icon: iconName, name, type });
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
);
|
||||
options.push('-');
|
||||
options.push(
|
||||
newPropertyTypes.map(type => {
|
||||
const iconName = getDefaultIconName(type);
|
||||
const Icon = nameToIcon(iconName, type);
|
||||
const name = t[`com.affine.page-properties.property.${type}`]();
|
||||
return {
|
||||
icon: <Icon />,
|
||||
text: name,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
onAddProperty(e, {
|
||||
icon: iconName,
|
||||
name: name,
|
||||
type: type,
|
||||
});
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
return renderMenuItemOptions(options);
|
||||
}, [onAddProperty, t]);
|
||||
};
|
||||
|
||||
interface PagePropertiesAddPropertyMenuItemsProps {
|
||||
onCreateClicked?: (e: React.MouseEvent) => void;
|
||||
onCreateClicked: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const PagePropertiesAddPropertyMenuItems = ({
|
||||
@ -754,6 +792,7 @@ const PagePropertiesAddPropertyMenuItems = ({
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
const metaList = manager.metaManager.getOrderedCustomPropertiesSchema();
|
||||
const nonRequiredMetaList = metaList.filter(meta => !meta.required);
|
||||
const isChecked = useCallback(
|
||||
(m: string) => {
|
||||
return manager.hasCustomProperty(m);
|
||||
@ -774,59 +813,41 @@ const PagePropertiesAddPropertyMenuItems = ({
|
||||
[isChecked, manager]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
const menuItems = useMemo(() => {
|
||||
const options: MenuItemOption[] = [];
|
||||
options.push(
|
||||
<div className={styles.menuHeader}>
|
||||
{t['com.affine.page-properties.add-property.menu.header']()}
|
||||
</div>
|
||||
{/* hide available properties if there are none */}
|
||||
{metaList.length > 0 ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Scrollable.Root className={styles.menuItemListScrollable}>
|
||||
<Scrollable.Viewport className={styles.menuItemList}>
|
||||
{metaList.map(meta => {
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const name = meta.name;
|
||||
return (
|
||||
<MenuItem
|
||||
key={meta.id}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
|
||||
</Scrollable.Root>
|
||||
</>
|
||||
) : null}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={onCreateClicked}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<PlusIcon />
|
||||
</MenuIcon>
|
||||
);
|
||||
|
||||
if (nonRequiredMetaList.length > 0) {
|
||||
options.push('-');
|
||||
const nonRequiredMetaOptions: MenuItemOption = nonRequiredMetaList.map(
|
||||
meta => {
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const name = meta.name;
|
||||
return {
|
||||
icon: <Icon />,
|
||||
text: name,
|
||||
selected: isChecked(meta.id),
|
||||
onClick: (e: React.MouseEvent) => onClickProperty(e, meta.id),
|
||||
};
|
||||
}
|
||||
>
|
||||
<div className={styles.menuItemName}>
|
||||
{t['com.affine.page-properties.add-property.menu.create']()}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
);
|
||||
options.push(nonRequiredMetaOptions);
|
||||
}
|
||||
options.push('-');
|
||||
options.push({
|
||||
icon: <PlusIcon />,
|
||||
text: t['com.affine.page-properties.add-property.menu.create'](),
|
||||
onClick: onCreateClicked,
|
||||
});
|
||||
|
||||
return renderMenuItemOptions(options);
|
||||
}, [isChecked, nonRequiredMetaList, onClickProperty, onCreateClicked, t]);
|
||||
|
||||
return menuItems;
|
||||
};
|
||||
|
||||
export const PagePropertiesAddProperty = () => {
|
||||
@ -848,7 +869,10 @@ export const PagePropertiesAddProperty = () => {
|
||||
const items = adding ? (
|
||||
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
|
||||
) : (
|
||||
<PagePropertiesCreatePropertyMenuItems onCreated={handleCreated} />
|
||||
<PagePropertiesCreatePropertyMenuItems
|
||||
metaManager={manager.metaManager}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Menu rootOptions={{ onOpenChange: () => setAdding(true) }} items={items}>
|
||||
@ -882,6 +906,13 @@ const PagePropertiesTableInner = () => {
|
||||
export const PagePropertiesTable = ({ page }: { page: 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 (
|
||||
<managerContext.Provider value={manager}>
|
||||
<Suspense>
|
||||
|
@ -223,6 +223,10 @@ const subTabConfigs = [
|
||||
key: 'experimental-features',
|
||||
title: 'com.affine.settings.workspace.experimental-features',
|
||||
},
|
||||
{
|
||||
key: 'properties',
|
||||
title: 'com.affine.settings.workspace.properties',
|
||||
},
|
||||
] satisfies {
|
||||
key: WorkspaceSubTab;
|
||||
title: keyof ReturnType<typeof useAFFiNEI18N>;
|
||||
|
@ -9,6 +9,7 @@ export const GeneralSettingKeys = [
|
||||
export const WorkspaceSubTabs = [
|
||||
'preference',
|
||||
'experimental-features',
|
||||
'properties',
|
||||
] as const;
|
||||
|
||||
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 { ExperimentalFeatures } from './experimental-features';
|
||||
import { WorkspaceSettingDetail } from './new-workspace-setting-detail';
|
||||
import { WorkspaceSettingProperties } from './properties';
|
||||
|
||||
export const WorkspaceSetting = ({
|
||||
workspaceMetadata,
|
||||
subTab,
|
||||
}: {
|
||||
workspaceMetadata: WorkspaceMetadata;
|
||||
subTab: 'preference' | 'experimental-features';
|
||||
subTab: 'preference' | 'experimental-features' | 'properties';
|
||||
}) => {
|
||||
const isOwner = useIsWorkspaceOwner(workspaceMetadata);
|
||||
|
||||
@ -23,5 +24,9 @@ export const WorkspaceSetting = ({
|
||||
);
|
||||
case 'experimental-features':
|
||||
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.",
|
||||
"Untitled": "Untitled",
|
||||
"Untitled Collection": "Untitled Collection",
|
||||
"unnamed": "unnamed",
|
||||
"Update Available": "Update available",
|
||||
"Update Collection": "Update Collection",
|
||||
"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.get-started": "Get Started",
|
||||
"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.experimental-features": "Plugins",
|
||||
"com.affine.settings.workspace.properties": "Properties",
|
||||
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
|
||||
"com.affine.share-menu.ShareMode": "Share mode",
|
||||
"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.hide-when-empty": "Hide when empty",
|
||||
"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"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user