feat(core): doc database properties (#8520)

fix AF-1454

1. move inline tags editor to components
2. add progress component
3. adjust doc properties styles for desktop
4. subscribe bs database links and display in doc info
5. move update/create dates to doc info
6. a trivial e2e test

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">10月23日.mp4</video>
This commit is contained in:
pengx17 2024-10-24 07:38:45 +00:00
parent f7dc65e170
commit 4b6c4ed546
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
67 changed files with 3166 additions and 941 deletions

View File

@ -23,4 +23,14 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
show: 'always-hide',
index: 'a0000003',
},
{
id: 'createdAt',
type: 'createdAt',
index: 'a0000004',
},
{
id: 'updatedAt',
type: 'updatedAt',
index: 'a0000005',
},
] as DocCustomPropertyInfo[];

View File

@ -1,13 +1,16 @@
import './polyfill';
import { getOrCreateI18n, I18nextProvider } from '@affine/i18n';
import { ThemeProvider } from 'next-themes';
import type { ComponentType } from 'react';
import '../src/theme';
import './polyfill';
import './preview.css';
import type { Preview } from '@storybook/react';
import React, { useEffect } from 'react';
import { ConfirmModalProvider } from '../src/ui/modal/confirm-modal';
import { ThemeProvider, useTheme as useNextTheme } from 'next-themes';
import type { ComponentType } from 'react';
import React, { useEffect } from 'react';
import type { Preview } from '@storybook/react';
import { setupGlobal } from '@affine/env/global';
import { useTheme as useNextTheme } from 'next-themes';
setupGlobal();
@ -45,14 +48,18 @@ const ThemeToggle = ({ context }) => {
return null;
};
const i18n = getOrCreateI18n();
export const decorators = [
(Story: ComponentType, context) => {
return (
<ThemeProvider themes={['dark', 'light']} enableSystem={true}>
<ThemeToggle context={context} />
<ConfirmModalProvider>
<Story {...context} />
</ConfirmModalProvider>
<I18nextProvider i18n={i18n}>
<ConfirmModalProvider>
<Story {...context} />
</ConfirmModalProvider>
</I18nextProvider>
</ThemeProvider>
);
},

View File

@ -27,9 +27,11 @@
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-slider": "^1.2.0",

View File

@ -20,6 +20,7 @@ export * from './ui/menu';
export * from './ui/modal';
export * from './ui/notification';
export * from './ui/popover';
export * from './ui/progress';
export * from './ui/property';
export * from './ui/radio';
export * from './ui/safe-area';

View File

@ -282,7 +282,8 @@ body {
/**
* A hack to make the anchor wrapper not affect the layout of the page.
*/
[data-lit-react-wrapper] {
[data-lit-react-wrapper],
affine-lit-template-wrapper {
display: contents;
}

View File

@ -0,0 +1 @@
export * from './progress';

View File

@ -0,0 +1,71 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
const progressHeight = createVar();
export const root = style({
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: 240,
gap: 12,
vars: {
[progressHeight]: '10px',
},
});
export const progress = style({
height: progressHeight,
flex: 1,
background: cssVarV2('layer/background/hoverOverlay'),
borderRadius: 5,
position: 'relative',
});
export const sliderRoot = style({
height: progressHeight,
width: '100%',
position: 'absolute',
top: 0,
left: 0,
});
export const thumb = style({
width: '4px',
height: `calc(${progressHeight} + 2px)`,
transform: 'translateY(-1px)',
borderRadius: '2px',
display: 'block',
background: cssVarV2('layer/insideBorder/primaryBorder'),
opacity: 0,
selectors: {
[`${root}:hover &, &:is(:focus-visible, :focus-within)`]: {
opacity: 1,
},
},
});
export const label = style({
width: '40px',
fontSize: cssVar('fontSm'),
});
export const indicator = style({
height: '100%',
width: '100%',
borderRadius: 5,
background: cssVarV2('toast/iconState/regular'),
transition: 'background 0.2s ease-in-out',
selectors: {
[`${root}:hover &, &:has(${thumb}:is(:focus-visible, :focus-within, :active))`]:
{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
[`[data-state="complete"]&`]: {
background: cssVarV2('status/success'),
},
},
});

View File

@ -0,0 +1,19 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import type { ProgressProps } from './progress';
import { Progress } from './progress';
export default {
title: 'UI/Progress',
component: Progress,
} satisfies Meta<typeof Progress>;
const Template: StoryFn<ProgressProps> = () => {
const [value, setValue] = useState<number>(30);
return (
<Progress style={{ width: '200px' }} value={value} onChange={setValue} />
);
};
export const Default: StoryFn<ProgressProps> = Template.bind(undefined);

View File

@ -0,0 +1,51 @@
import * as RadixProgress from '@radix-ui/react-progress';
import * as RadixSlider from '@radix-ui/react-slider';
import clsx from 'clsx';
import * as styles from './progress.css';
export interface ProgressProps {
/**
* The value of the progress bar.
* A value between 0 and 100.
*/
value: number;
onChange?: (value: number) => void;
onBlur?: () => void;
readonly?: boolean;
className?: string;
style?: React.CSSProperties;
}
export const Progress = ({
value,
onChange,
onBlur,
readonly,
className,
style,
}: ProgressProps) => {
return (
<div className={clsx(styles.root, className)} style={style}>
<RadixProgress.Root className={styles.progress} value={value}>
<RadixProgress.Indicator
className={styles.indicator}
style={{ width: `${value}%` }}
/>
{!readonly ? (
<RadixSlider.Root
className={styles.sliderRoot}
min={0}
max={100}
value={[value]}
onValueChange={values => onChange?.(values[0])}
onBlur={onBlur}
>
<RadixSlider.Thumb className={styles.thumb} />
</RadixSlider.Root>
) : null}
</RadixProgress.Root>
<div className={styles.label}>{value}%</div>
</div>
);
};

View File

@ -129,15 +129,13 @@ export const propertyValueContainer = style({
color: cssVarV2('text/placeholder'),
},
selectors: {
'&[data-readonly="false"]': {
'&[data-readonly="false"][data-hoverable="true"]': {
cursor: 'pointer',
},
'&[data-readonly="false"]:hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
'&[data-readonly="false"]:focus-within': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
'&[data-readonly="false"][data-hoverable="true"]:is(:hover, :focus-within)':
{
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
},
});
@ -162,3 +160,67 @@ globalStyle(`${tableButton} svg`, {
globalStyle(`${tableButton}:hover svg`, {
color: cssVarV2('icon/primary'),
});
export const section = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
});
export const sectionHeader = style({
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 6px',
minHeight: 30,
});
export const sectionHeaderTrigger = style({
display: 'flex',
alignItems: 'center',
gap: 4,
flex: 1,
});
export const sectionHeaderIcon = style({
width: 16,
height: 16,
fontSize: 16,
color: cssVarV2('icon/primary'),
});
export const sectionHeaderName = style({
display: 'flex',
alignItems: 'center',
fontSize: cssVar('fontSm'),
fontWeight: 500,
whiteSpace: 'nowrap',
selectors: {
'&[data-collapsed="true"]': {
color: cssVarV2('text/secondary'),
},
},
});
export const sectionCollapsedIcon = style({
transition: 'all 0.2s ease-in-out',
color: cssVarV2('icon/primary'),
fontSize: 20,
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(90deg)',
color: cssVarV2('icon/secondary'),
},
},
});
export const sectionContent = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
selectors: {
'&[hidden]': {
display: 'none',
},
},
});

View File

@ -3,7 +3,8 @@ import { FrameIcon } from '@blocksuite/icons/rc';
import { useDraggable, useDropTarget } from '../dnd';
import { MenuItem } from '../menu';
import {
PropertyCollapsible,
PropertyCollapsibleContent,
PropertyCollapsibleSection,
PropertyName,
PropertyRoot,
PropertyValue,
@ -100,9 +101,9 @@ export const HideEmptyProperty = () => {
);
};
export const BasicPropertyCollapsible = () => {
export const BasicPropertyCollapsibleContent = () => {
return (
<PropertyCollapsible collapsible>
<PropertyCollapsibleContent collapsible>
<PropertyRoot>
<PropertyName name="Always show" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
@ -115,13 +116,24 @@ export const BasicPropertyCollapsible = () => {
<PropertyName name="Hide" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
</PropertyCollapsible>
</PropertyCollapsibleContent>
);
};
export const BasicPropertyCollapsibleSection = () => {
return (
<PropertyCollapsibleSection
icon={<FrameIcon />}
title="Collapsible Section"
>
<BasicPropertyCollapsibleContent />
</PropertyCollapsibleSection>
);
};
export const PropertyCollapsibleCustomButton = () => {
return (
<PropertyCollapsible
<PropertyCollapsibleContent
collapsible
collapseButtonText={({ hide, isCollapsed }) =>
`${isCollapsed ? 'Show' : 'Hide'} ${hide} properties`
@ -139,6 +151,6 @@ export const PropertyCollapsibleCustomButton = () => {
<PropertyName name="Hide" icon={<FrameIcon />} />
<PropertyValue>Value</PropertyValue>
</PropertyRoot>
</PropertyCollapsible>
</PropertyCollapsibleContent>
);
};

View File

@ -1,5 +1,10 @@
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { ArrowDownSmallIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc';
import {
ArrowDownSmallIcon,
ArrowUpSmallIcon,
ToggleExpandIcon,
} from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import clsx from 'clsx';
import {
createContext,
@ -24,7 +29,92 @@ const PropertyTableContext = createContext<{
showAllHide: boolean;
} | null>(null);
export const PropertyCollapsible = forwardRef<
export const PropertyCollapsibleSection = forwardRef<
HTMLDivElement,
PropsWithChildren<{
defaultCollapsed?: boolean;
icon?: ReactNode;
title: ReactNode;
suffix?: ReactNode;
collapsed?: boolean;
onCollapseChange?: (collapsed: boolean) => void;
}> &
HTMLProps<HTMLDivElement>
>(
(
{
children,
defaultCollapsed = false,
collapsed,
onCollapseChange,
icon,
title,
suffix,
className,
...props
},
ref
) => {
const [internalCollapsed, setInternalCollapsed] =
useState(defaultCollapsed);
const handleCollapse = useCallback(
(open: boolean) => {
setInternalCollapsed(!open);
onCollapseChange?.(!open);
},
[onCollapseChange]
);
const finalCollapsed =
collapsed !== undefined ? collapsed : internalCollapsed;
return (
<Collapsible.Root
{...props}
ref={ref}
className={clsx(styles.section, className)}
open={!finalCollapsed}
onOpenChange={handleCollapse}
data-testid="property-collapsible-section"
>
<div
className={styles.sectionHeader}
data-testid="property-collapsible-section-header"
>
<Collapsible.Trigger
role="button"
data-testid="property-collapsible-section-trigger"
className={styles.sectionHeaderTrigger}
>
{icon && <div className={styles.sectionHeaderIcon}>{icon}</div>}
<div
data-collapsed={finalCollapsed}
className={styles.sectionHeaderName}
>
{title}
</div>
<ToggleExpandIcon
className={styles.sectionCollapsedIcon}
data-collapsed={finalCollapsed}
/>
</Collapsible.Trigger>
{suffix}
</div>
<Collapsible.Content
data-testid="property-collapsible-section-content"
className={styles.sectionContent}
>
{children}
</Collapsible.Content>
</Collapsible.Root>
);
}
);
PropertyCollapsibleSection.displayName = 'PropertyCollapsibleSection';
export const PropertyCollapsibleContent = forwardRef<
HTMLDivElement,
PropsWithChildren<{
collapsible?: boolean;
@ -124,7 +214,7 @@ export const PropertyCollapsible = forwardRef<
}
);
PropertyCollapsible.displayName = 'PropertyCollapsible';
PropertyCollapsibleContent.displayName = 'PropertyCollapsible';
const PropertyRootContext = createContext<{
mountValue: (payload: { isEmpty: boolean }) => () => void;
@ -249,28 +339,38 @@ export const PropertyName = ({
export const PropertyValue = forwardRef<
HTMLDivElement,
{ readonly?: boolean; isEmpty?: boolean } & HTMLProps<HTMLDivElement>
>(({ children, className, readonly, isEmpty, ...props }, ref) => {
const context = useContext(PropertyRootContext);
{
readonly?: boolean;
isEmpty?: boolean;
hoverable?: boolean;
} & HTMLProps<HTMLDivElement>
>(
(
{ children, className, readonly, isEmpty, hoverable = true, ...props },
ref
) => {
const context = useContext(PropertyRootContext);
useLayoutEffect(() => {
if (context) {
return context.mountValue({ isEmpty: !!isEmpty });
}
return;
}, [context, isEmpty]);
useLayoutEffect(() => {
if (context) {
return context.mountValue({ isEmpty: !!isEmpty });
}
return;
}, [context, isEmpty]);
return (
<div
ref={ref}
className={clsx(styles.propertyValueContainer, className)}
data-readonly={readonly ? 'true' : 'false'}
data-empty={isEmpty ? 'true' : 'false'}
data-property-value
{...props}
>
{children}
</div>
);
});
return (
<div
ref={ref}
className={clsx(styles.propertyValueContainer, className)}
data-readonly={readonly ? 'true' : 'false'}
data-empty={isEmpty ? 'true' : 'false'}
data-hoverable={hoverable ? 'true' : 'false'}
data-property-value
{...props}
>
{children}
</div>
);
}
);
PropertyValue.displayName = 'PropertyValue';

View File

@ -0,0 +1,3 @@
export * from './tag';
export * from './tags-editor';
export * from './types';

View File

@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const inlineTagsContainer = style({
display: 'flex',
gap: '6px',
flexWrap: 'wrap',
width: '100%',
});

View File

@ -0,0 +1,47 @@
import type { HTMLAttributes, PropsWithChildren } from 'react';
import * as styles from './inline-tag-list.css';
import { TagItem } from './tag';
import type { TagLike } from './types';
interface InlineTagListProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
onRemoved?: (id: string) => void;
tags: TagLike[];
tagMode: 'inline-tag' | 'db-label';
focusedIndex?: number;
}
export const InlineTagList = ({
children,
focusedIndex,
tags,
onRemoved,
tagMode,
}: PropsWithChildren<InlineTagListProps>) => {
return (
<div className={styles.inlineTagsContainer} data-testid="inline-tags-list">
{tags.map((tag, idx) => {
if (!tag) {
return null;
}
const handleRemoved = onRemoved
? () => {
onRemoved?.(tag.id);
}
: undefined;
return (
<TagItem
key={tag.id}
idx={idx}
focused={focusedIndex === idx}
onRemoved={handleRemoved}
mode={tagMode}
tag={tag}
/>
);
})}
{children}
</div>
);
};

View File

@ -0,0 +1,3 @@
# Tags Editor
A common module for both page and database tags editing (serviceless).

View File

@ -115,20 +115,6 @@ export const spacer = style({
flexGrow: 1,
});
export const tagColorIconWrapper = style({
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const tagColorIcon = style({
width: 16,
height: 16,
borderRadius: '50%',
});
export const menuItemListScrollable = style({});
export const menuItemListScrollbar = style({

View File

@ -0,0 +1,32 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const menuItemListScrollable = style({});
export const menuItemListScrollbar = style({
transform: 'translateX(4px)',
});
export const menuItemList = style({
display: 'flex',
flexDirection: 'column',
maxHeight: 200,
overflow: 'auto',
});
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
display: 'table !important',
});
export const tagColorIconWrapper = style({
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const tagColorIcon = style({
width: 16,
height: 16,
borderRadius: '50%',
});

View File

@ -0,0 +1,113 @@
import { useI18n } from '@affine/i18n';
import { DeleteIcon, TagsIcon } from '@blocksuite/icons/rc';
import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';
import Input from '../input';
import { Menu, MenuItem, type MenuProps, MenuSeparator } from '../menu';
import { Scrollable } from '../scrollbar';
import * as styles from './tag-edit-menu.css';
import type { TagColor, TagLike } from './types';
type TagEditMenuProps = PropsWithChildren<{
onTagDelete: (tagId: string) => void;
colors: TagColor[];
tag: TagLike;
onTagChange: (property: keyof TagLike, value: string) => void;
jumpToTag?: (tagId: string) => void;
}>;
export const TagEditMenu = ({
tag,
onTagDelete,
children,
jumpToTag,
colors,
onTagChange,
}: TagEditMenuProps) => {
const t = useI18n();
const menuProps = useMemo(() => {
const updateTagName = (name: string) => {
if (name.trim() === '') {
return;
}
onTagChange('value', name);
};
return {
contentOptions: {
onClick(e) {
e.stopPropagation();
},
},
items: (
<>
<Input
defaultValue={tag.value}
onBlur={e => {
updateTagName(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
updateTagName(e.currentTarget.value);
}
e.stopPropagation();
}}
placeholder={t['Untitled']()}
/>
<MenuSeparator />
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
onClick={() => {
tag?.id ? onTagDelete(tag.id) : null;
}}
>
{t['Delete']()}
</MenuItem>
{jumpToTag ? (
<MenuItem
prefixIcon={<TagsIcon />}
onClick={() => {
jumpToTag(tag.id);
}}
>
{t['com.affine.page-properties.tags.open-tags-page']()}
</MenuItem>
) : null}
<MenuSeparator />
<Scrollable.Root>
<Scrollable.Viewport className={styles.menuItemList}>
{colors.map(({ name, value: color }, i) => (
<MenuItem
key={i}
checked={tag.color === color}
prefixIcon={
<div key={i} className={styles.tagColorIconWrapper}>
<div
className={styles.tagColorIcon}
style={{
backgroundColor: color,
}}
/>
</div>
}
onClick={() => {
onTagChange('color', color);
}}
>
{name}
</MenuItem>
))}
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
</Scrollable.Viewport>
</Scrollable.Root>
</>
),
} satisfies Partial<MenuProps>;
}, [tag, t, jumpToTag, colors, onTagChange, onTagDelete]);
return <Menu {...menuProps}>{children}</Menu>;
};

View File

@ -0,0 +1,168 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const hoverMaxWidth = createVar();
export const tagColorVar = createVar();
export const root = style({
position: 'relative',
width: '100%',
height: '100%',
minHeight: '32px',
});
export const tagsContainer = style({
display: 'flex',
alignItems: 'center',
});
export const tagsScrollContainer = style([
tagsContainer,
{
overflowX: 'hidden',
position: 'relative',
height: '100%',
gap: '8px',
},
]);
export const tagsListContainer = style([
tagsContainer,
{
flexWrap: 'wrap',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '4px',
},
]);
export const innerContainer = style({
display: 'flex',
columnGap: '8px',
alignItems: 'center',
position: 'absolute',
height: '100%',
maxWidth: '100%',
transition: 'all 0.2s 0.3s ease-in-out',
selectors: {
[`${root}:hover &`]: {
maxWidth: hoverMaxWidth,
},
},
});
// background with linear gradient hack
export const innerBackdrop = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '100%',
opacity: 0,
transition: 'all 0.2s',
background: `linear-gradient(90deg, transparent 0%, ${cssVar(
'hoverColorFilled'
)} 40%)`,
selectors: {
[`${root}:hover &`]: {
opacity: 1,
},
},
});
export const tag = style({
height: '22px',
display: 'flex',
minWidth: 0,
alignItems: 'center',
justifyContent: 'space-between',
':last-child': {
minWidth: 'max-content',
},
});
export const tagInnerWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 8px',
color: cssVar('textPrimaryColor'),
borderColor: cssVar('borderColor'),
selectors: {
'&[data-focused=true]': {
borderColor: cssVar('primaryColor'),
},
},
});
export const tagInlineMode = style([
tagInnerWrapper,
{
fontSize: 'inherit',
borderRadius: '10px',
columnGap: '4px',
borderWidth: '1px',
borderStyle: 'solid',
background: cssVar('backgroundPrimaryColor'),
maxWidth: '128px',
height: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
]);
export const tagListItemMode = style([
tag,
{
fontSize: cssVar('fontSm'),
padding: '4px 12px',
columnGap: '8px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: '30px',
},
]);
export const tagLabelMode = style([
tag,
{
fontSize: cssVar('fontSm'),
background: tagColorVar,
padding: '0 8px',
borderRadius: 4,
border: `1px solid ${cssVarV2('database/border')}`,
gap: 4,
selectors: {
'&[data-focused=true]': {
borderColor: cssVar('primaryColor'),
},
},
},
]);
export const showMoreTag = style({
fontSize: cssVar('fontH5'),
right: 0,
position: 'sticky',
display: 'inline-flex',
});
export const tagIndicator = style({
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: 0,
background: tagColorVar,
});
export const tagLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
userSelect: 'none',
});
export const tagRemove = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 12,
height: 12,
borderRadius: '50%',
flexShrink: 0,
cursor: 'pointer',
':hover': {
background: 'var(--affine-hover-color)',
},
});

View File

@ -0,0 +1,74 @@
import { CloseIcon } from '@blocksuite/icons/rc';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { type MouseEventHandler, useCallback } from 'react';
import * as styles from './tag.css';
import type { TagLike } from './types';
export interface TagItemProps {
tag: TagLike;
idx?: number;
maxWidth?: number | string;
// @todo(pengx17): better naming
mode: 'inline-tag' | 'list-tag' | 'db-label';
focused?: boolean;
onRemoved?: () => void;
style?: React.CSSProperties;
}
export const TagItem = ({
tag,
idx,
mode,
focused,
onRemoved,
style,
maxWidth,
}: TagItemProps) => {
const { value, color, id } = tag;
const handleRemove: MouseEventHandler<HTMLDivElement> = useCallback(
e => {
e.stopPropagation();
onRemoved?.();
},
[onRemoved]
);
return (
<div
className={styles.tag}
data-idx={idx}
data-tag-id={id}
data-tag-value={value}
title={value}
style={{
...style,
...assignInlineVars({
[styles.tagColorVar]: color,
}),
}}
>
<div
style={{ maxWidth: maxWidth }}
data-focused={focused}
className={clsx({
[styles.tagInlineMode]: mode === 'inline-tag',
[styles.tagListItemMode]: mode === 'list-tag',
[styles.tagLabelMode]: mode === 'db-label',
})}
>
{mode !== 'db-label' ? <div className={styles.tagIndicator} /> : null}
<div className={styles.tagLabel}>{value}</div>
{onRemoved ? (
<div
data-testid="remove-tag-button"
className={styles.tagRemove}
onClick={handleRemove}
>
<CloseIcon />
</div>
) : null}
</div>
</div>
);
};

View File

@ -0,0 +1,330 @@
import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { clamp } from 'lodash-es';
import type { KeyboardEvent } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { IconButton } from '../button';
import { RowInput } from '../input';
import { Menu } from '../menu';
import { Scrollable } from '../scrollbar';
import { InlineTagList } from './inline-tag-list';
import * as styles from './styles.css';
import { TagItem } from './tag';
import { TagEditMenu } from './tag-edit-menu';
import type { TagColor, TagLike } from './types';
export interface TagsEditorProps {
tags: TagLike[]; // candidates to show in the tag dropdown
selectedTags: string[];
onCreateTag: (name: string, color: string) => TagLike;
onSelectTag: (tagId: string) => void; // activate tag
onDeselectTag: (tagId: string) => void; // deactivate tag
tagColors: TagColor[];
onTagChange: (id: string, property: keyof TagLike, value: string) => void;
onDeleteTag: (id: string) => void; // a candidate to be deleted
jumpToTag?: (id: string) => void;
tagMode: 'inline-tag' | 'db-label';
}
export interface TagsInlineEditorProps extends TagsEditorProps {
placeholder?: string;
className?: string;
readonly?: boolean;
}
type TagOption = TagLike | { readonly create: true; readonly value: string };
const isCreateNewTag = (
tagOption: TagOption
): tagOption is { readonly create: true; readonly value: string } => {
return 'create' in tagOption;
};
export const TagsEditor = ({
tags,
selectedTags,
onSelectTag,
onDeselectTag,
onCreateTag,
tagColors,
onDeleteTag: onTagDelete,
onTagChange,
jumpToTag,
tagMode,
}: TagsEditorProps) => {
const t = useI18n();
const [inputValue, setInputValue] = useState('');
const filteredTags = tags.filter(tag => tag.value.includes(inputValue));
const inputRef = useRef<HTMLInputElement>(null);
const exactMatch = filteredTags.find(tag => tag.value === inputValue);
const showCreateTag = !exactMatch && inputValue.trim();
// tag option candidates to show in the tag dropdown
const tagOptions: TagOption[] = useMemo(() => {
if (showCreateTag) {
return [{ create: true, value: inputValue } as const, ...filteredTags];
} else {
return filteredTags;
}
}, [filteredTags, inputValue, showCreateTag]);
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
const [focusedInlineIndex, setFocusedInlineIndex] = useState<number>(
selectedTags.length
);
// -1: no focus
const safeFocusedIndex = clamp(focusedIndex, -1, tagOptions.length - 1);
// inline tags focus index can go beyond the length of tagIds
// using -1 and tagIds.length to make keyboard navigation easier
const safeInlineFocusedIndex = clamp(
focusedInlineIndex,
-1,
selectedTags.length
);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const onInputChange = useCallback((value: string) => {
setInputValue(value);
}, []);
const onToggleTag = useCallback(
(id: string) => {
if (!selectedTags.includes(id)) {
onSelectTag(id);
} else {
onDeselectTag(id);
}
},
[selectedTags, onSelectTag, onDeselectTag]
);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
const [nextColor, rotateNextColor] = useReducer(
color => {
const idx = tagColors.findIndex(c => c.value === color);
return tagColors[(idx + 1) % tagColors.length].value;
},
tagColors[Math.floor(Math.random() * tagColors.length)].value
);
const handleCreateTag = useCallback(
(name: string) => {
rotateNextColor();
const newTag = onCreateTag(name.trim(), nextColor);
return newTag.id;
},
[onCreateTag, nextColor]
);
const onSelectTagOption = useCallback(
(tagOption: TagOption) => {
const id = isCreateNewTag(tagOption)
? handleCreateTag(tagOption.value)
: tagOption.id;
onToggleTag(id);
setInputValue('');
focusInput();
setFocusedIndex(-1);
setFocusedInlineIndex(selectedTags.length + 1);
},
[handleCreateTag, onToggleTag, focusInput, selectedTags.length]
);
const onEnter = useCallback(() => {
if (safeFocusedIndex >= 0) {
onSelectTagOption(tagOptions[safeFocusedIndex]);
}
}, [onSelectTagOption, safeFocusedIndex, tagOptions]);
const handleUntag = useCallback(
(id: string) => {
onToggleTag(id);
focusInput();
},
[onToggleTag, focusInput]
);
const onInputKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && inputValue === '' && selectedTags.length) {
const index =
safeInlineFocusedIndex < 0 ||
safeInlineFocusedIndex >= selectedTags.length
? selectedTags.length - 1
: safeInlineFocusedIndex;
const tagToRemove = selectedTags.at(index);
if (tagToRemove) {
onDeselectTag(tagToRemove);
}
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const newFocusedIndex = clamp(
safeFocusedIndex + (e.key === 'ArrowUp' ? -1 : 1),
0,
tagOptions.length - 1
);
scrollContainerRef.current
?.querySelector(
`.${styles.tagSelectorItem}:nth-child(${newFocusedIndex + 1})`
)
?.scrollIntoView({ block: 'nearest' });
setFocusedIndex(newFocusedIndex);
// reset inline focus
setFocusedInlineIndex(selectedTags.length + 1);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const newItemToFocus =
e.key === 'ArrowLeft'
? safeInlineFocusedIndex - 1
: safeInlineFocusedIndex + 1;
e.preventDefault();
setFocusedInlineIndex(newItemToFocus);
// reset tag list focus
setFocusedIndex(-1);
}
},
[
inputValue,
safeInlineFocusedIndex,
selectedTags,
onDeselectTag,
safeFocusedIndex,
tagOptions.length,
]
);
return (
<div data-testid="tags-editor-popup" className={styles.tagsEditorRoot}>
<div className={styles.tagsEditorSelectedTags}>
<InlineTagList
tagMode={tagMode}
tags={tags.filter(tag => selectedTags.includes(tag.id))}
focusedIndex={safeInlineFocusedIndex}
onRemoved={handleUntag}
>
<RowInput
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
onEnter={onEnter}
autoFocus
className={styles.searchInput}
placeholder="Type here ..."
/>
</InlineTagList>
</div>
<div className={styles.tagsEditorTagsSelector}>
<div className={styles.tagsEditorTagsSelectorHeader}>
{t['com.affine.page-properties.tags.selector-header-title']()}
</div>
<Scrollable.Root>
<Scrollable.Viewport
ref={scrollContainerRef}
className={styles.tagSelectorTagsScrollContainer}
>
{tagOptions.map((tag, idx) => {
const commonProps = {
...(safeFocusedIndex === idx ? { focused: 'true' } : {}),
onClick: () => onSelectTagOption(tag),
onMouseEnter: () => setFocusedIndex(idx),
['data-testid']: 'tag-selector-item',
['data-focused']: safeFocusedIndex === idx,
className: styles.tagSelectorItem,
};
if (isCreateNewTag(tag)) {
return (
<div key={tag.value + '.' + idx} {...commonProps}>
{t['Create']()}{' '}
<TagItem
mode={tagMode}
tag={{
id: 'create-new-tag',
value: inputValue,
color: nextColor,
}}
/>
</div>
);
} else {
return (
<div
key={tag.id}
{...commonProps}
data-tag-id={tag.id}
data-tag-value={tag.value}
>
<TagItem maxWidth="100%" tag={tag} mode={tagMode} />
<div className={styles.spacer} />
<TagEditMenu
tag={tag}
onTagDelete={onTagDelete}
onTagChange={(property, value) => {
onTagChange(tag.id, property, value);
}}
jumpToTag={jumpToTag}
colors={tagColors}
>
<IconButton className={styles.tagEditIcon}>
<MoreHorizontalIcon />
</IconButton>
</TagEditMenu>
</div>
);
}
})}
</Scrollable.Viewport>
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
</Scrollable.Root>
</div>
</div>
);
};
export const TagsInlineEditor = ({
readonly,
placeholder,
className,
...props
}: TagsInlineEditorProps) => {
const empty = !props.selectedTags || props.selectedTags.length === 0;
const selectedTags = useMemo(() => {
return props.selectedTags
.map(id => props.tags.find(tag => tag.id === id))
.filter(tag => tag !== undefined);
}, [props.selectedTags, props.tags]);
return (
<Menu
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 0,
avoidCollisions: false,
className: styles.tagsMenu,
onClick(e) {
e.stopPropagation();
},
}}
items={<TagsEditor {...props} />}
>
<div
className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty}
data-readonly={readonly}
>
{empty ? (
placeholder
) : (
<InlineTagList {...props} tags={selectedTags} onRemoved={undefined} />
)}
</div>
</Menu>
);
};

View File

@ -0,0 +1,120 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { InlineTagList } from './inline-tag-list';
import { TagItem } from './tag';
import { TagEditMenu } from './tag-edit-menu';
import { TagsInlineEditor } from './tags-editor';
import type { TagColor, TagLike } from './types';
export default {
title: 'UI/Tags',
} satisfies Meta;
const tags: TagLike[] = [
{ id: '1', value: 'tag', color: 'red' },
{ id: '2', value: 'tag2', color: 'blue' },
{ id: '3', value: 'tag3', color: 'green' },
];
const tagColors: TagColor[] = [
{ id: '1', value: 'red', name: 'Red' },
{ id: '2', value: 'blue', name: 'Blue' },
{ id: '3', value: 'green', name: 'Green' },
];
export const Tags: StoryFn = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<TagItem
tag={{ id: '1', value: 'tag', color: 'red' }}
mode="inline-tag"
focused
onRemoved={() => {
console.log('removed');
}}
/>
<TagItem
tag={{ id: '2', value: 'tag2', color: 'blue' }}
mode="inline-tag"
/>
<TagItem
tag={{ id: '3', value: 'tag3', color: '#DCFDD7' }}
mode="db-label"
/>
<TagItem
tag={{ id: '3', value: 'tag5', color: '#DCFDD7' }}
mode="db-label"
focused
onRemoved={() => {
console.log('removed');
}}
/>
<TagItem tag={{ id: '1', value: 'tag', color: 'red' }} mode="list-tag" />
</div>
);
};
export const InlineTagListStory: StoryFn = () => {
return <InlineTagList tagMode="inline-tag" tags={tags} />;
};
export const TagEditMenuStory: StoryFn = () => {
return (
<TagEditMenu
tag={tags[0]}
colors={tagColors}
onTagChange={() => {}}
onTagDelete={() => {}}
jumpToTag={() => {}}
>
<div>Trigger Edit Tag Menu</div>
</TagEditMenu>
);
};
export const TagsInlineEditorStory: StoryFn = () => {
const [options, setOptions] = useState<TagLike[]>(tags);
const [selectedTags, setSelectedTags] = useState<string[]>(
options.slice(0, 1).map(item => item.id)
);
return (
<TagsInlineEditor
tags={options}
tagMode="db-label"
selectedTags={selectedTags}
onCreateTag={(name, color) => {
const newTag = {
id: (options.at(-1)!.id ?? 0) + 1,
value: name,
color,
};
setOptions(prev => [...prev, newTag]);
return newTag;
}}
tagColors={tagColors}
onTagChange={(id, property, value) => {
setOptions(prev => {
const index = prev.findIndex(item => item.id === id);
if (index === -1) {
return prev;
}
return options.toSpliced(index, 1, {
...options[index],
[property]: value,
});
});
}}
onDeleteTag={tagId => {
setOptions(prev => prev.filter(item => item.id !== tagId));
}}
onSelectTag={tagId => {
setSelectedTags(prev => [...prev, tagId]);
}}
onDeselectTag={tagId => {
setSelectedTags(prev => prev.filter(id => id !== tagId));
}}
/>
);
};

View File

@ -0,0 +1,11 @@
export interface TagLike {
id: string;
value: string; // value is the tag name
color: string; // css color value
}
export interface TagColor {
id: string;
value: string; // css color value
name?: string; // display name
}

View File

@ -8,6 +8,7 @@ import { track } from '@affine/track';
import type { DocMode } from '@blocksuite/affine/blocks';
import type { DocCollection } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { nanoid } from 'nanoid';
import {
type PropsWithChildren,
@ -24,10 +25,12 @@ export function AffinePageReference({
pageId,
wrapper: Wrapper,
params,
className,
}: {
pageId: string;
wrapper?: React.ComponentType<PropsWithChildren>;
params?: URLSearchParams;
className?: string;
}) {
const docDisplayMetaService = useService(DocDisplayMetaService);
const journalService = useService(JournalService);
@ -108,7 +111,7 @@ export function AffinePageReference({
ref={ref}
to={`/${pageId}${query}`}
onClick={onClick}
className={styles.pageReferenceLink}
className={clsx(styles.pageReferenceLink, className)}
>
{Wrapper ? <Wrapper>{el}</Wrapper> : el}
</WorkbenchLink>

View File

@ -14,11 +14,15 @@ export const titleContainer = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
marginBottom: 20,
padding: 2,
});
export const titleStyle = style({
fontSize: cssVar('fontH6'),
fontSize: cssVar('fontH2'),
fontWeight: '600',
minHeight: 42,
padding: 0,
});
export const rowNameContainer = style({

View File

@ -4,10 +4,14 @@ import {
type InlineEditHandle,
Menu,
Modal,
PropertyCollapsible,
PropertyCollapsibleContent,
PropertyCollapsibleSection,
Scrollable,
} from '@affine/component';
import { DocInfoService } from '@affine/core/modules/doc-info';
import {
DocDatabaseBacklinkInfo,
DocInfoService,
} from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
@ -27,7 +31,6 @@ import { CreatePropertyMenuItems } from '../menu/create-doc-property';
import { DocPropertyRow } from '../table';
import * as styles from './info-modal.css';
import { LinksRow } from './links-row';
import { TimeRow } from './time-row';
export const InfoModal = () => {
const modal = useService(DocInfoService).modal;
@ -119,9 +122,7 @@ export const InfoTable = ({
);
return (
<div>
<TimeRow className={styles.timeRow} docId={docId} />
<Divider size="thinner" />
<>
{backlinks && backlinks.length > 0 ? (
<>
<LinksRow
@ -142,50 +143,56 @@ export const InfoTable = ({
<Divider size="thinner" />
</>
) : null}
<PropertyCollapsible
className={styles.tableBodyRoot}
collapseButtonText={({ hide, isCollapsed }) =>
isCollapsed
? hide === 1
? t['com.affine.page-properties.more-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.more-property.more']({
count: hide.toString(),
})
: hide === 1
? t['com.affine.page-properties.hide-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.hide-property.more']({
count: hide.toString(),
})
}
<PropertyCollapsibleSection
title={t.t('com.affine.workspace.properties')}
>
{properties.map(property => (
<DocPropertyRow
key={property.id}
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={setNewPropertyId} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
<PropertyCollapsibleContent
className={styles.tableBodyRoot}
collapseButtonText={({ hide, isCollapsed }) =>
isCollapsed
? hide === 1
? t['com.affine.page-properties.more-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.more-property.more']({
count: hide.toString(),
})
: hide === 1
? t['com.affine.page-properties.hide-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.hide-property.more']({
count: hide.toString(),
})
}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
{properties.map(property => (
<DocPropertyRow
key={property.id}
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={setNewPropertyId} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
</PropertyCollapsible>
</div>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
<Divider size="thinner" />
<DocDatabaseBacklinkInfo />
</>
);
};

View File

@ -1,13 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const title = style({
fontSize: cssVar('fontSm'),
fontWeight: '500',
color: cssVar('textSecondaryColor'),
padding: '6px',
});
export const wrapper = style({
width: '100%',
borderRadius: 4,
@ -15,11 +8,7 @@ export const wrapper = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 2,
padding: '6px',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
padding: 4,
});
globalStyle(`${wrapper} svg`, {

View File

@ -1,3 +1,4 @@
import { PropertyCollapsibleSection } from '@affine/component';
import type { Backlink, Link } from '@affine/core/modules/doc-link';
import { AffinePageReference } from '../../affine/reference-link';
@ -15,10 +16,10 @@ export const LinksRow = ({
onClick?: () => void;
}) => {
return (
<div className={className}>
<div className={styles.title}>
{label} · {references.length}
</div>
<PropertyCollapsibleSection
title={`${label} · ${references.length}`}
className={className}
>
{references.map((link, index) => (
<AffinePageReference
key={index}
@ -29,6 +30,6 @@ export const LinksRow = ({
)}
/>
))}
</div>
</PropertyCollapsibleSection>
);
};

View File

@ -74,7 +74,11 @@ export const CreatePropertyMenuItems = ({
>
<div className={styles.propertyItem}>
{name}
{isUniqueExist && <span>Added</span>}
{isUniqueExist && (
<span>
{t['com.affine.page-properties.create-property.added']()}
</span>
)}
</div>
</MenuItem>
);

View File

@ -39,36 +39,12 @@ export const rootCentered = style({
export const tableHeader = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
});
export const tableHeaderInfoRow = style({
display: 'flex',
flexDirection: 'row',
height: 30,
padding: 4,
justifyContent: 'space-between',
alignItems: 'center',
color: cssVarV2('text/secondary'),
fontSize: fontSize,
fontWeight: 500,
minHeight: 34,
'@media': {
print: {
display: 'none',
},
},
});
export const tableHeaderSecondaryRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
color: cssVar('textPrimaryColor'),
fontSize: fontSize,
fontWeight: 500,
padding: `0 6px`,
gap: '8px',
height: 24,
'@media': {
print: {
display: 'none',
@ -81,6 +57,7 @@ export const tableHeaderCollapseButtonWrapper = style({
flex: 1,
justifyContent: 'flex-end',
cursor: 'pointer',
fontSize: 20,
});
export const pageInfoDimmed = style({

View File

@ -1,21 +1,19 @@
import {
Button,
IconButton,
Menu,
MenuItem,
PropertyCollapsible,
PropertyCollapsibleContent,
PropertyCollapsibleSection,
PropertyName,
PropertyRoot,
Tooltip,
useDraggable,
useDropTarget,
} from '@affine/component';
import { DocLinksService } from '@affine/core/modules/doc-link';
import { EditorSettingService } from '@affine/core/modules/editor-settting';
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { i18nTime, useI18n } from '@affine/i18n';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { PlusIcon, PropertyIcon, ToggleExpandIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
@ -25,14 +23,11 @@ import {
DocsService,
useLiveData,
useService,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import type React from 'react';
import type { HTMLProps, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useMemo, useState } from 'react';
import { forwardRef, useCallback, useState } from 'react';
import { AffinePageReference } from '../affine/reference-link';
import { DocPropertyIcon } from './icons/doc-property-icon';
@ -81,145 +76,38 @@ interface DocPropertiesTableHeaderProps {
onOpenChange: (open: boolean) => void;
}
// backlinks - #no Updated yyyy-mm-dd
// Info
// ─────────────────────────────────────────────────
// Page Info ...
export const DocPropertiesTableHeader = ({
className,
style,
open,
onOpenChange,
}: DocPropertiesTableHeaderProps) => {
const t = useI18n();
const {
docLinksService,
docService,
workspaceService,
editorSettingService,
} = useServices({
DocLinksService,
DocService,
WorkspaceService,
EditorSettingService,
});
const docBacklinks = docLinksService.backlinks;
const backlinks = useMemo(
() => docBacklinks.backlinks$.value,
[docBacklinks]
);
const displayDocInfo = useLiveData(
editorSettingService.editorSetting.settings$.selector(s => s.displayDocInfo)
);
const { syncing, retrying, serverClock } = useLiveData(
workspaceService.workspace.engine.doc.docState$(docService.doc.id)
);
const { createDate, updatedDate } = useLiveData(
docService.doc.meta$.selector(m => ({
createDate: m.createDate,
updatedDate: m.updatedDate,
}))
);
const timestampElement = useMemo(() => {
const localizedCreateTime = createDate ? i18nTime(createDate) : null;
const createTimeElement = (
<div className={styles.tableHeaderTimestamp}>
{t['Created']()} {localizedCreateTime}
</div>
);
return serverClock ? (
<Tooltip
side="right"
content={
<>
<div className={styles.tableHeaderTimestamp}>
{t['Updated']()} {i18nTime(serverClock)}
</div>
{createDate && (
<div className={styles.tableHeaderTimestamp}>
{t['Created']()} {i18nTime(createDate)}
</div>
)}
</>
}
>
<div className={styles.tableHeaderTimestamp}>
{!syncing && !retrying ? (
<>
{t['Updated']()}{' '}
{i18nTime(serverClock, {
relative: {
max: [1, 'day'],
accuracy: 'minute',
},
absolute: {
accuracy: 'day',
},
})}
</>
) : (
<>{t['com.affine.syncing']()}</>
)}
</div>
</Tooltip>
) : updatedDate ? (
<Tooltip side="right" content={createTimeElement}>
<div className={styles.tableHeaderTimestamp}>
{t['Updated']()} {i18nTime(updatedDate)}
</div>
</Tooltip>
) : (
createTimeElement
);
}, [createDate, updatedDate, retrying, serverClock, syncing, t]);
const dTimestampElement = useDebouncedValue(timestampElement, 500);
const handleCollapse = useCallback(() => {
track.doc.inlineDocInfo.$.toggle();
onOpenChange(!open);
}, [onOpenChange, open]);
const t = useI18n();
return (
<div className={clsx(styles.tableHeader, className)} style={style}>
{/* TODO(@Peng): add click handler to backlinks */}
<div className={styles.tableHeaderInfoRow}>
{backlinks.length > 0 ? (
<DocBacklinksPopup backlinks={backlinks}>
<div className={styles.tableHeaderBacklinksHint}>
{t['com.affine.page-properties.backlinks']()} · {backlinks.length}
</div>
</DocBacklinksPopup>
) : null}
{dTimestampElement}
</div>
<div className={styles.tableHeaderDivider} />
{displayDocInfo ? (
<div className={styles.tableHeaderSecondaryRow}>
<div className={clsx(!open ? styles.pageInfoDimmed : null)}>
{t['com.affine.page-properties.page-info']()}
</div>
<Collapsible.Trigger asChild role="button" onClick={handleCollapse}>
<div
className={styles.tableHeaderCollapseButtonWrapper}
data-testid="page-info-collapse"
>
<IconButton size="20">
<ToggleExpandIcon
className={styles.collapsedIcon}
data-collapsed={!open}
/>
</IconButton>
</div>
</Collapsible.Trigger>
<Collapsible.Trigger style={style} role="button" onClick={handleCollapse}>
<div className={clsx(styles.tableHeader, className)}>
<div className={clsx(!open ? styles.pageInfoDimmed : null)}>
{t['com.affine.page-properties.page-info']()}
</div>
) : null}
</div>
<div
className={styles.tableHeaderCollapseButtonWrapper}
data-testid="page-info-collapse"
>
<ToggleExpandIcon
className={styles.collapsedIcon}
data-collapsed={!open}
/>
</div>
</div>
<div className={styles.tableHeaderDivider} />
</Collapsible.Trigger>
);
};
@ -364,13 +252,14 @@ export const DocPropertiesTableBody = forwardRef<
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
return (
<div
<PropertyCollapsibleSection
ref={ref}
className={clsx(styles.tableBodyRoot, className)}
style={style}
title={t.t('com.affine.workspace.properties')}
{...props}
>
<PropertyCollapsible
<PropertyCollapsibleContent
collapsible
collapsed={propertyCollapsed}
onCollapseChange={setPropertyCollapsed}
@ -438,9 +327,8 @@ export const DocPropertiesTableBody = forwardRef<
{t['com.affine.page-properties.config-properties']()}
</Button>
</div>
</PropertyCollapsible>
<div className={styles.tableHeaderDivider} />
</div>
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
);
});
DocPropertiesTableBody.displayName = 'PagePropertiesTableBody';
@ -455,8 +343,10 @@ const DocPropertiesTableInner = () => {
className={styles.rootCentered}
>
<DocPropertiesTableHeader open={expanded} onOpenChange={setExpanded} />
<Collapsible.Content asChild>
<Collapsible.Content>
<DocPropertiesTableBody />
<div className={styles.tableHeaderDivider} />
<DocDatabaseBacklinkInfo />
</Collapsible.Content>
</Collapsible.Root>
</div>

View File

@ -1,26 +1,16 @@
import type { MenuProps } from '@affine/component';
import type { TagLike } from '@affine/component/ui/tags';
import { TagsInlineEditor as TagsInlineEditorComponent } from '@affine/component/ui/tags';
import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
import {
IconButton,
Input,
Menu,
MenuItem,
MenuSeparator,
RowInput,
Scrollable,
} from '@affine/component';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import type { Tag } from '@affine/core/modules/tag';
import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { clamp } from 'lodash-es';
import type { HTMLAttributes, PropsWithChildren } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
LiveData,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { TagItem, TempTagItem } from '../page-list';
import * as styles from './tags-inline-editor.css';
import { useAsyncCallback } from '../hooks/affine-async-hooks';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
interface TagsEditorProps {
pageId: string;
@ -28,444 +18,113 @@ interface TagsEditorProps {
focusedIndex?: number;
}
interface InlineTagsListProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>,
Omit<TagsEditorProps, 'onOptionsChange'> {
onRemove?: () => void;
}
export const InlineTagsList = ({
pageId,
readonly,
children,
focusedIndex,
onRemove,
}: PropsWithChildren<InlineTagsListProps>) => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
return (
<div className={styles.inlineTagsContainer} data-testid="inline-tags-list">
{tagIds.map((tagId, idx) => {
const tag = tags.find(t => t.id === tagId);
if (!tag) {
return null;
}
const onRemoved = readonly
? undefined
: () => {
tag.untag(pageId);
onRemove?.();
};
return (
<TagItem
key={tagId}
idx={idx}
focused={focusedIndex === idx}
onRemoved={onRemoved}
mode="inline"
tag={tag}
/>
);
})}
{children}
</div>
);
};
export const EditTagMenu = ({
tagId,
onTagDelete,
children,
}: PropsWithChildren<{
tagId: string;
onTagDelete: (tagIds: string[]) => void;
}>) => {
const t = useI18n();
const workspaceService = useService(WorkspaceService);
const tagService = useService(TagService);
const tagList = tagService.tagList;
const tag = useLiveData(tagList.tagByTagId$(tagId));
const tagColor = useLiveData(tag?.color$);
const tagValue = useLiveData(tag?.value$);
const navigate = useNavigateHelper();
const menuProps = useMemo(() => {
const updateTagName = (name: string) => {
if (name.trim() === '') {
return;
}
tag?.rename(name);
};
return {
contentOptions: {
onClick(e) {
e.stopPropagation();
},
},
items: (
<>
<Input
defaultValue={tagValue}
onBlur={e => {
updateTagName(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
updateTagName(e.currentTarget.value);
}
e.stopPropagation();
}}
placeholder={t['Untitled']()}
/>
<MenuSeparator />
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
onClick={() => {
onTagDelete([tag?.id || '']);
}}
>
{t['Delete']()}
</MenuItem>
<MenuItem
prefixIcon={<TagsIcon />}
onClick={() => {
navigate.jumpToTag(workspaceService.workspace.id, tag?.id || '');
}}
>
{t['com.affine.page-properties.tags.open-tags-page']()}
</MenuItem>
<MenuSeparator />
<Scrollable.Root>
<Scrollable.Viewport className={styles.menuItemList}>
{tagService.tagColors.map(([name, color], i) => (
<MenuItem
key={i}
checked={tagColor === color}
prefixIcon={
<div key={i} className={styles.tagColorIconWrapper}>
<div
className={styles.tagColorIcon}
style={{
backgroundColor: color,
}}
/>
</div>
}
onClick={() => {
tag?.changeColor(color);
}}
>
{name}
</MenuItem>
))}
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
</Scrollable.Viewport>
</Scrollable.Root>
</>
),
} satisfies Partial<MenuProps>;
}, [
workspaceService,
navigate,
onTagDelete,
t,
tag,
tagColor,
tagService.tagColors,
tagValue,
]);
return <Menu {...menuProps}>{children}</Menu>;
};
type TagOption = Tag | { readonly create: true; readonly value: string };
const isCreateNewTag = (
tagOption: TagOption
): tagOption is { readonly create: true; readonly value: string } => {
return 'create' in tagOption;
};
export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
const t = useI18n();
const tagService = useService(TagService);
const tagList = tagService.tagList;
const tags = useLiveData(tagList.tags$);
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
const [inputValue, setInputValue] = useState('');
const filteredTags = useLiveData(
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
);
const [open, setOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const exactMatch = filteredTags.find(tag => tag.value$.value === inputValue);
const showCreateTag = !exactMatch && inputValue.trim();
// tag option candidates to show in the tag dropdown
const tagOptions: TagOption[] = useMemo(() => {
if (showCreateTag) {
return [{ create: true, value: inputValue } as const, ...filteredTags];
} else {
return filteredTags;
}
}, [filteredTags, inputValue, showCreateTag]);
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
const [focusedInlineIndex, setFocusedInlineIndex] = useState<number>(
tagIds.length
);
// -1: no focus
const safeFocusedIndex = clamp(focusedIndex, -1, tagOptions.length - 1);
// inline tags focus index can go beyond the length of tagIds
// using -1 and tagIds.length to make keyboard navigation easier
const safeInlineFocusedIndex = clamp(focusedInlineIndex, -1, tagIds.length);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const handleCloseModal = useCallback(
(open: boolean) => {
setOpen(open);
setSelectedTagIds([]);
},
[setOpen]
);
const onTagDelete = useCallback(
(tagIds: string[]) => {
setOpen(true);
setSelectedTagIds(tagIds);
},
[setOpen, setSelectedTagIds]
);
const onInputChange = useCallback((value: string) => {
setInputValue(value);
}, []);
const onToggleTag = useCallback(
(id: string) => {
const tagEntity = tagList.tags$.value.find(o => o.id === id);
if (!tagEntity) {
return;
}
if (!tagIds.includes(id)) {
tagEntity.tag(pageId);
} else {
tagEntity.untag(pageId);
}
},
[pageId, tagIds, tagList.tags$.value]
);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
const [nextColor, rotateNextColor] = useReducer(
color => {
const idx = tagService.tagColors.findIndex(c => c[1] === color);
return tagService.tagColors[(idx + 1) % tagService.tagColors.length][1];
},
tagService.tagColors[
Math.floor(Math.random() * tagService.tagColors.length)
][1]
);
const onCreateTag = useCallback(
(name: string) => {
rotateNextColor();
const newTag = tagList.createTag(name.trim(), nextColor);
return newTag.id;
},
[nextColor, tagList]
);
const onSelectTagOption = useCallback(
(tagOption: TagOption) => {
const id = isCreateNewTag(tagOption)
? onCreateTag(tagOption.value)
: tagOption.id;
onToggleTag(id);
setInputValue('');
focusInput();
setFocusedIndex(-1);
setFocusedInlineIndex(tagIds.length + 1);
},
[onCreateTag, onToggleTag, focusInput, tagIds.length]
);
const onEnter = useCallback(() => {
if (safeFocusedIndex >= 0) {
onSelectTagOption(tagOptions[safeFocusedIndex]);
}
}, [onSelectTagOption, safeFocusedIndex, tagOptions]);
const onInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && inputValue === '' && tagIds.length) {
const tagToRemove =
safeInlineFocusedIndex < 0 || safeInlineFocusedIndex >= tagIds.length
? tagIds.length - 1
: safeInlineFocusedIndex;
tags.find(item => item.id === tagIds.at(tagToRemove))?.untag(pageId);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const newFocusedIndex = clamp(
safeFocusedIndex + (e.key === 'ArrowUp' ? -1 : 1),
0,
tagOptions.length - 1
);
scrollContainerRef.current
?.querySelector(
`.${styles.tagSelectorItem}:nth-child(${newFocusedIndex + 1})`
)
?.scrollIntoView({ block: 'nearest' });
setFocusedIndex(newFocusedIndex);
// reset inline focus
setFocusedInlineIndex(tagIds.length + 1);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const newItemToFocus =
e.key === 'ArrowLeft'
? safeInlineFocusedIndex - 1
: safeInlineFocusedIndex + 1;
e.preventDefault();
setFocusedInlineIndex(newItemToFocus);
// reset tag list focus
setFocusedIndex(-1);
}
},
[
inputValue,
tagIds,
safeFocusedIndex,
tagOptions,
safeInlineFocusedIndex,
tags,
pageId,
]
);
return (
<div data-testid="tags-editor-popup" className={styles.tagsEditorRoot}>
<div className={styles.tagsEditorSelectedTags}>
<InlineTagsList
pageId={pageId}
readonly={readonly}
focusedIndex={safeInlineFocusedIndex}
onRemove={focusInput}
>
<RowInput
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
onEnter={onEnter}
autoFocus
className={styles.searchInput}
placeholder="Type here ..."
/>
</InlineTagsList>
</div>
<div className={styles.tagsEditorTagsSelector}>
<div className={styles.tagsEditorTagsSelectorHeader}>
{t['com.affine.page-properties.tags.selector-header-title']()}
</div>
<Scrollable.Root>
<Scrollable.Viewport
ref={scrollContainerRef}
className={styles.tagSelectorTagsScrollContainer}
>
{tagOptions.map((tag, idx) => {
const commonProps = {
...(safeFocusedIndex === idx ? { focused: 'true' } : {}),
onClick: () => onSelectTagOption(tag),
onMouseEnter: () => setFocusedIndex(idx),
['data-testid']: 'tag-selector-item',
['data-focused']: safeFocusedIndex === idx,
className: styles.tagSelectorItem,
};
if (isCreateNewTag(tag)) {
return (
<div key={tag.value + '.' + idx} {...commonProps}>
{t['Create']()}{' '}
<TempTagItem value={inputValue} color={nextColor} />
</div>
);
} else {
return (
<div
key={tag.id}
{...commonProps}
data-tag-id={tag.id}
data-tag-value={tag.value$.value}
>
<TagItem maxWidth="100%" tag={tag} mode="inline" />
<div className={styles.spacer} />
<EditTagMenu tagId={tag.id} onTagDelete={onTagDelete}>
<IconButton className={styles.tagEditIcon}>
<MoreHorizontalIcon />
</IconButton>
</EditTagMenu>
</div>
);
}
})}
</Scrollable.Viewport>
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
</Scrollable.Root>
</div>
<DeleteTagConfirmModal
open={open}
onOpenChange={handleCloseModal}
selectedTagIds={selectedTagIds}
/>
</div>
);
};
interface TagsInlineEditorProps extends TagsEditorProps {
placeholder?: string;
className?: string;
}
// this tags value renderer right now only renders the legacy tags for now
export const TagsInlineEditor = ({
pageId,
readonly,
placeholder,
className,
}: TagsInlineEditorProps) => {
const tagList = useService(TagService).tagList;
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
const empty = !tagIds || tagIds.length === 0;
const workspace = useService(WorkspaceService);
const tagService = useService(TagService);
const tagIds = useLiveData(tagService.tagList.tagIdsByPageId$(pageId));
const tags = useLiveData(tagService.tagList.tags$);
const tagColors = tagService.tagColors;
const onCreateTag = useCallback(
(name: string, color: string) => {
const newTag = tagService.tagList.createTag(name, color);
return {
id: newTag.id,
value: newTag.value$.value,
color: newTag.color$.value,
};
},
[tagService.tagList]
);
const onSelectTag = useCallback(
(tagId: string) => {
tagService.tagList.tagByTagId$(tagId).value?.tag(pageId);
},
[pageId, tagService.tagList]
);
const onDeselectTag = useCallback(
(tagId: string) => {
tagService.tagList.tagByTagId$(tagId).value?.untag(pageId);
},
[pageId, tagService.tagList]
);
const onTagChange = useCallback(
(id: string, property: keyof TagLike, value: string) => {
if (property === 'value') {
tagService.tagList.tagByTagId$(id).value?.rename(value);
} else if (property === 'color') {
tagService.tagList.tagByTagId$(id).value?.changeColor(value);
}
},
[tagService.tagList]
);
const deleteTags = useDeleteTagConfirmModal();
const onTagDelete = useAsyncCallback(
async (id: string) => {
await deleteTags([id]);
},
[deleteTags]
);
const adaptedTags = useLiveData(
useMemo(() => {
return LiveData.computed(get => {
return tags.map(tag => ({
id: tag.id,
value: get(tag.value$),
color: get(tag.color$),
}));
});
}, [tags])
);
const adaptedTagColors = useMemo(() => {
return tagColors.map(color => ({
id: color[0],
value: color[1],
name: color[0],
}));
}, [tagColors]);
const navigator = useNavigateHelper();
const jumpToTag = useCallback(
(id: string) => {
navigator.jumpToTag(workspace.workspace.id, id);
},
[navigator, workspace.workspace.id]
);
return (
<Menu
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 0,
avoidCollisions: false,
className: styles.tagsMenu,
onClick(e) {
e.stopPropagation();
},
}}
items={<TagsEditor pageId={pageId} readonly={readonly} />}
>
<div
className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty}
data-readonly={readonly}
>
{empty ? placeholder : <InlineTagsList pageId={pageId} readonly />}
</div>
</Menu>
<TagsInlineEditorComponent
tagMode="inline-tag"
jumpToTag={jumpToTag}
readonly={readonly}
placeholder={placeholder}
className={className}
tags={adaptedTags}
selectedTags={tagIds}
onCreateTag={onCreateTag}
onSelectTag={onSelectTag}
onDeselectTag={onDeselectTag}
tagColors={adaptedTagColors}
onTagChange={onTagChange}
onDeleteTag={onTagDelete}
/>
);
};

View File

@ -4,6 +4,7 @@ import {
CreatedEditedIcon,
DateTimeIcon,
FileIcon,
HistoryIcon,
NumberIcon,
TagIcon,
TextIcon,
@ -12,7 +13,7 @@ import {
import { CheckboxValue } from './checkbox';
import { CreatedByValue, UpdatedByValue } from './created-updated-by';
import { DateValue } from './date';
import { CreateDateValue, DateValue, UpdatedDateValue } from './date';
import { DocPrimaryModeValue } from './doc-primary-mode';
import { JournalValue } from './journal';
import { NumberValue } from './number';
@ -65,6 +66,20 @@ export const DocPropertyTypes = {
name: 'com.affine.page-properties.property.updatedBy',
description: 'com.affine.page-properties.property.updatedBy.tooltips',
},
updatedAt: {
icon: DateTimeIcon,
value: UpdatedDateValue,
name: 'com.affine.page-properties.property.updatedAt',
renameable: false,
uniqueId: 'updatedAt',
},
createdAt: {
icon: HistoryIcon,
value: CreateDateValue,
name: 'com.affine.page-properties.property.createdAt',
renameable: false,
uniqueId: 'createdAt',
},
docPrimaryMode: {
icon: FileIcon,
value: DocPrimaryModeValue,

View File

@ -1,10 +1,11 @@
import { DatePicker, Menu, PropertyValue } from '@affine/component';
import { DatePicker, Menu, PropertyValue, Tooltip } from '@affine/component';
import { i18nTime, useI18n } from '@affine/i18n';
import { DocService, useLiveData, useServices } from '@toeverything/infra';
import * as styles from './date.css';
import type { PropertyValueProps } from './types';
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
const useParsedDate = (value: string) => {
const parsedValue =
typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}$/)
? value
@ -12,8 +13,17 @@ export const DateValue = ({ value, onChange }: PropertyValueProps) => {
const displayValue = parsedValue
? i18nTime(parsedValue, { absolute: { accuracy: 'day' } })
: undefined;
const t = useI18n();
return {
parsedValue,
displayValue:
displayValue ??
t['com.affine.page-properties.property-value-placeholder'](),
};
};
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
const { parsedValue, displayValue } = useParsedDate(value);
return (
<Menu items={<DatePicker value={parsedValue} onChange={onChange} />}>
@ -21,9 +31,55 @@ export const DateValue = ({ value, onChange }: PropertyValueProps) => {
className={parsedValue ? '' : styles.empty}
isEmpty={!parsedValue}
>
{displayValue ??
t['com.affine.page-properties.property-value-placeholder']()}
{displayValue}
</PropertyValue>
</Menu>
);
};
const toRelativeDate = (time: string | number) => {
return i18nTime(time, {
relative: {
max: [1, 'day'],
},
absolute: {
accuracy: 'day',
},
});
};
const MetaDateValueFactory = ({
type,
}: {
type: 'createDate' | 'updatedDate';
}) =>
function ReadonlyDateValue() {
const { docService } = useServices({
DocService,
});
const docMeta = useLiveData(docService.doc.meta$);
const value = docMeta?.[type];
const relativeDate = value ? toRelativeDate(value) : null;
const date = value ? i18nTime(value) : null;
return (
<Tooltip content={date}>
<PropertyValue
className={relativeDate ? '' : styles.empty}
isEmpty={!relativeDate}
>
{relativeDate}
</PropertyValue>
</Tooltip>
);
};
export const CreateDateValue = MetaDateValueFactory({
type: 'createDate',
});
export const UpdatedDateValue = MetaDateValueFactory({
type: 'updatedDate',
});

View File

@ -48,7 +48,7 @@ export const DocPrimaryModeValue = () => {
[doc, t]
);
return (
<PropertyValue className={styles.container}>
<PropertyValue className={styles.container} hoverable={false}>
<RadioGroup
width={194}
itemHeight={24}

View File

@ -2,9 +2,10 @@ import { style } from '@vanilla-extract/css';
export const tagInlineEditor = style({
width: '100%',
minHeight: 34,
padding: `6px`,
});
export const container = style({
padding: `0px`,
padding: '0px !important',
});

View File

@ -1,7 +1,7 @@
import type { DocCustomPropertyInfo } from '@toeverything/infra';
export interface PropertyValueProps {
propertyInfo: DocCustomPropertyInfo;
propertyInfo?: DocCustomPropertyInfo;
value: any;
onChange: (value: any) => void;
}

View File

@ -1,8 +1,8 @@
import { Menu } from '@affine/component';
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
import { TagItem as TagItemComponent } from '@affine/component/ui/tags';
import type { Tag } from '@affine/core/modules/tag';
import { stopPropagation } from '@affine/core/utils';
import { CloseIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
@ -27,77 +27,24 @@ interface TagItemProps {
style?: React.CSSProperties;
}
export const TempTagItem = ({
value,
color,
maxWidth = '100%',
}: {
value: string;
color: string;
maxWidth?: number | string;
}) => {
return (
<div className={styles.tag} title={value}>
<div style={{ maxWidth: maxWidth }} className={styles.tagInline}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: color,
}}
/>
<div className={styles.tagLabel}>{value}</div>
</div>
</div>
);
};
export const TagItem = ({
tag,
idx,
mode,
focused,
onRemoved,
style,
maxWidth,
}: TagItemProps) => {
export const TagItem = ({ tag, ...props }: TagItemProps) => {
const value = useLiveData(tag?.value$);
const color = useLiveData(tag?.color$);
const handleRemove = useCatchEventCallback(() => {
onRemoved?.();
}, [onRemoved]);
if (!tag || !value || !color) {
return null;
}
return (
<div
data-testid="page-tag"
className={styles.tag}
data-idx={idx}
data-tag-id={tag?.id}
data-tag-value={value}
title={value}
style={style}
>
<div
style={{ maxWidth: maxWidth }}
data-focused={focused}
className={mode === 'inline' ? styles.tagInline : styles.tagListItem}
>
<div
className={styles.tagIndicator}
style={{
backgroundColor: color,
}}
/>
<div className={styles.tagLabel}>{value}</div>
{onRemoved ? (
<div
data-testid="remove-tag-button"
className={styles.tagRemove}
onClick={handleRemove}
>
<CloseIcon />
</div>
) : null}
</div>
</div>
<TagItemComponent
{...props}
mode={props.mode === 'inline' ? 'inline-tag' : 'list-tag'}
tag={{
id: tag?.id,
value: value,
color: color,
}}
/>
);
};

View File

@ -1,10 +1,11 @@
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
TagListHeader,
VirtualizedTagList,
} from '@affine/core/components/page-list/tags';
import { CreateOrEditTag } from '@affine/core/components/page-list/tags/create-tag';
import type { TagMeta } from '@affine/core/components/page-list/types';
import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
@ -39,25 +40,14 @@ const EmptyTagListHeader = () => {
export const AllTag = () => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
const [open, setOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const tagMetas: TagMeta[] = useLiveData(tagList.tagMetas$);
const handleDeleteTags = useDeleteTagConfirmModal();
const handleCloseModal = useCallback(
(open: boolean) => {
setOpen(open);
setSelectedTagIds([]);
const onTagDelete = useAsyncCallback(
async (tagIds: string[]) => {
await handleDeleteTags(tagIds);
},
[setOpen]
);
const onTagDelete = useCallback(
(tagIds: string[]) => {
setOpen(true);
setSelectedTagIds(tagIds);
},
[setOpen, setSelectedTagIds]
[handleDeleteTags]
);
const t = useI18n();
@ -82,11 +72,6 @@ export const AllTag = () => {
)}
</div>
</ViewBody>
<DeleteTagConfirmModal
open={open}
onOpenChange={handleCloseModal}
selectedTagIds={selectedTagIds}
/>
</>
);
};

View File

@ -1,10 +1,22 @@
import { type Framework, WorkspaceScope } from '@toeverything/infra';
import {
DocsService,
type Framework,
WorkspaceScope,
} from '@toeverything/infra';
import { DocsSearchService } from '../docs-search';
import { DocInfoModal } from './entities/modal';
import { DocDatabaseBacklinksService } from './services/doc-database-backlinks';
import { DocInfoService } from './services/doc-info';
export { DocDatabaseBacklinkInfo } from './views/database-properties/doc-database-backlink-info';
export { DocInfoService };
export function configureDocInfoModule(framework: Framework) {
framework.scope(WorkspaceScope).service(DocInfoService).entity(DocInfoModal);
framework
.scope(WorkspaceScope)
.service(DocInfoService)
.service(DocDatabaseBacklinksService, [DocsService, DocsSearchService])
.entity(DocInfoModal);
}

View File

@ -0,0 +1,149 @@
import {
DatabaseBlockDataSource,
type DatabaseBlockModel,
} from '@blocksuite/affine/blocks';
import type { DocsService } from '@toeverything/infra';
import { Service } from '@toeverything/infra';
import { isEqual } from 'lodash-es';
import { combineLatest, distinctUntilChanged, map, Observable } from 'rxjs';
import type { DocsSearchService } from '../../docs-search';
import type { DatabaseRow, DatabaseValueCell } from '../types';
import { signalToLiveData, signalToObservable } from '../utils';
const equalComparator = <T>(a: T, b: T) => {
return isEqual(a, b);
};
export class DocDatabaseBacklinksService extends Service {
constructor(
private readonly docsService: DocsService,
private readonly docsSearchService: DocsSearchService
) {
super();
}
private async ensureDocLoaded(docId: string) {
const docRef = this.docsService.open(docId);
if (!docRef.doc.blockSuiteDoc.ready) {
docRef.doc.blockSuiteDoc.load();
}
docRef.doc.setPriorityLoad(10);
await docRef.doc.waitForSyncReady();
return docRef;
}
private adaptRowCells(dbModel: DatabaseBlockModel, rowId: string) {
const dataSource = new DatabaseBlockDataSource(dbModel);
const hydratedRows$ = combineLatest([
signalToObservable(dataSource.rows$),
signalToObservable(dataSource.properties$),
]).pipe(
map(([rowIds, propertyIds]) => {
const rowExists = rowIds.some(id => id === rowId);
if (!rowExists) {
return undefined;
}
return propertyIds
.map<DatabaseValueCell>(id => {
return {
id,
value$: signalToLiveData(
dataSource.cellValueGet$(rowId, id)
).distinctUntilChanged(equalComparator),
property: {
id,
type$: signalToLiveData(dataSource.propertyTypeGet$(id)),
name$: signalToLiveData(dataSource.propertyNameGet$(id)),
data$: signalToLiveData(dataSource.propertyDataGet$(id)),
},
};
})
.filter((p: any): p is DatabaseValueCell => !!p);
})
);
return [hydratedRows$, dataSource] as const;
}
// for each db doc backlink,
private watchDatabaseRow$(backlink: {
docId: string;
rowId: string;
databaseBlockId: string;
databaseName: string | undefined;
}) {
return new Observable<DatabaseRow | undefined>(subscriber => {
let disposed = false;
let unsubscribe = () => {};
const docRef = this.docsService.open(backlink.docId);
const run = async () => {
await this.ensureDocLoaded(backlink.docId);
if (disposed) {
return;
}
const maybeDatabaseBlock = docRef.doc.blockSuiteDoc.getBlock(
backlink.databaseBlockId
);
if (maybeDatabaseBlock?.flavour === 'affine:database') {
const dbModel = maybeDatabaseBlock.model as DatabaseBlockModel;
const [cells$, dataSource] = this.adaptRowCells(
dbModel,
backlink.rowId
);
const subscription = cells$.subscribe(cells => {
if (cells) {
subscriber.next({
cells,
id: backlink.rowId,
doc: docRef.doc,
docId: backlink.docId,
databaseId: dbModel.id,
databaseName: dbModel.title.yText.toString(),
dataSource: dataSource,
});
} else {
subscriber.next(undefined);
}
});
unsubscribe = () => subscription.unsubscribe();
} else {
subscriber.next(undefined);
}
};
run().catch(e => {
console.error(`failed to get database info:`, e);
docRef.release();
});
return () => {
docRef.release();
disposed = true;
unsubscribe();
};
});
}
// backlinks (docid:blockid:databaseBlockId)
// -> related db rows (DatabaseRow[])
watchDbBacklinkRows$(docId: string) {
return this.docsSearchService.watchDatabasesTo(docId).pipe(
distinctUntilChanged(equalComparator),
map(rows =>
rows.toSorted(
(a, b) => a.databaseName?.localeCompare(b.databaseName ?? '') ?? 0
)
),
map(backlinks =>
backlinks.map(backlink => {
return {
...backlink,
row$: this.watchDatabaseRow$(backlink),
};
})
)
);
}
}

View File

@ -0,0 +1,35 @@
import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
import type { Doc, LiveData } from '@toeverything/infra';
// make database property type to be compatible with DocCustomPropertyInfo
export type DatabaseProperty<Data = Record<string, unknown>> = {
id: string;
name$: LiveData<string | undefined>;
type$: LiveData<string | undefined>;
data$: LiveData<Data | undefined>;
};
export interface DatabaseValueCell<
T = unknown,
Data = Record<string, unknown>,
> {
value$: LiveData<T>;
property: DatabaseProperty<Data>;
id: string;
}
export interface DatabaseRow {
cells: DatabaseValueCell[];
id: string; // row id (block id)
doc: Doc; // the doc that contains the database. required for editing etc.
docId: string; // for rendering the doc reference
dataSource: DatabaseBlockDataSource;
databaseId: string;
databaseName: string; // the title
}
export interface DatabaseCellRendererProps {
rowId: string;
cell: DatabaseValueCell;
dataSource: DatabaseBlockDataSource;
}

View File

@ -0,0 +1,56 @@
import { DebugLogger } from '@affine/debug';
import { BlockStdScope } from '@blocksuite/affine/block-std';
import { PageEditorBlockSpecs } from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { LiveData } from '@toeverything/infra';
import { useMemo } from 'react';
import { Observable } from 'rxjs';
const logger = new DebugLogger('doc-info');
interface ReadonlySignal<T> {
subscribe: (fn: (value: T) => void) => () => void;
}
export function signalToObservable<T>(
signal: ReadonlySignal<T>
): Observable<T> {
return new Observable(subscriber => {
const unsub = signal.subscribe(value => {
subscriber.next(value);
});
return () => {
unsub();
};
});
}
export function signalToLiveData<T>(
signal: ReadonlySignal<T>,
defaultValue: T
): LiveData<T>;
export function signalToLiveData<T>(
signal: ReadonlySignal<T>
): LiveData<T | undefined>;
export function signalToLiveData<T>(
signal: ReadonlySignal<T>,
defaultValue?: T
) {
return LiveData.from(signalToObservable(signal), defaultValue);
}
// todo(pengx17): use rc pool?
export function createBlockStdScope(doc: Doc) {
logger.debug('createBlockStdScope', doc.id);
const std = new BlockStdScope({
doc,
extensions: PageEditorBlockSpecs,
});
return std;
}
export function useBlockStdScope(doc: Doc) {
return useMemo(() => createBlockStdScope(doc), [doc]);
}

View File

@ -0,0 +1,22 @@
import { CheckboxValue } from '@affine/core/components/doc-properties/types/checkbox';
import type { LiveData } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import type { DatabaseCellRendererProps } from '../../../types';
export const CheckboxCell = ({
cell,
rowId,
dataSource,
}: DatabaseCellRendererProps) => {
const value = useLiveData(cell.value$ as LiveData<boolean>);
return (
<CheckboxValue
// todo(pengx17): better internal impl
value={value ? 'true' : 'false'}
onChange={v => {
dataSource.cellValueChange(rowId, cell.property.id, v === 'true');
}}
/>
);
};

View File

@ -0,0 +1,40 @@
import { DateValue } from '@affine/core/components/doc-properties/types/date';
import type { LiveData } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import dayjs from 'dayjs';
import type { DatabaseCellRendererProps } from '../../../types';
const toInternalDateString = (date: unknown) => {
if (typeof date !== 'string' && typeof date !== 'number') {
return '';
}
return dayjs(date).format('YYYY-MM-DD');
};
const fromInternalDateString = (date: string) => {
return dayjs(date).toDate().getTime();
};
export const DateCell = ({
cell,
rowId,
dataSource,
}: DatabaseCellRendererProps) => {
const value = useLiveData(
cell.value$ as LiveData<number | string | undefined>
);
const date = value ? toInternalDateString(value) : '';
return (
<DateValue
value={date}
onChange={v => {
dataSource.cellValueChange(
rowId,
cell.property.id,
fromInternalDateString(v)
);
}}
/>
);
};

View File

@ -0,0 +1,56 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const link = style({
textDecoration: 'none',
color: cssVarV2('text/link'),
whiteSpace: 'wrap',
wordBreak: 'break-all',
display: 'inline',
});
export const textarea = style({
border: 'none',
height: '100%',
width: '100%',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
whiteSpace: 'wrap',
wordBreak: 'break-all',
padding: `6px`,
paddingLeft: '5px',
overflow: 'hidden',
fontSize: cssVar('fontSm'),
lineHeight: '22px',
selectors: {
'&::placeholder': {
color: cssVar('placeholderColor'),
},
},
});
export const container = style({
position: 'relative',
outline: `1px solid transparent`,
padding: `6px`,
display: 'block',
':focus-within': {
outline: `1px solid ${cssVar('blue700')}`,
boxShadow: cssVar('activeShadow'),
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
});
export const textInvisible = style({
border: 'none',
whiteSpace: 'wrap',
wordBreak: 'break-all',
overflow: 'hidden',
visibility: 'hidden',
fontSize: cssVar('fontSm'),
lineHeight: '22px',
});

View File

@ -0,0 +1,145 @@
import { PropertyValue } from '@affine/component';
import { AffinePageReference } from '@affine/core/components/affine/reference-link';
import { resolveLinkToDoc } from '@affine/core/modules/navigation';
import { useI18n } from '@affine/i18n';
import type { LiveData } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import {
type ChangeEventHandler,
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { DatabaseCellRendererProps } from '../../../types';
import * as styles from './link.css';
export const LinkCell = ({
cell,
dataSource,
rowId,
}: DatabaseCellRendererProps) => {
const isEmpty = useLiveData(
cell.value$.map(value => typeof value !== 'string' || !value)
);
const link = useLiveData(cell.value$ as LiveData<string | undefined>) || '';
const [editing, setEditing] = useState(false);
const [tempValue, setTempValue] = useState<string>(link);
const ref = useRef<HTMLTextAreaElement>(null);
const commitChange = useCallback(() => {
dataSource.cellValueChange(rowId, cell.id, tempValue.trim());
setEditing(false);
setTempValue(tempValue.trim());
}, [dataSource, rowId, cell.id, tempValue]);
const handleOnChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
e => {
setTempValue(e.target.value);
},
[]
);
const resolvedDocLink = useMemo(() => {
const docInfo = resolveLinkToDoc(link);
if (docInfo) {
const params = new URLSearchParams();
if (docInfo.mode) {
params.set('mode', docInfo.mode);
}
if (docInfo.blockIds) {
params.set('blockIds', docInfo.blockIds.join(','));
}
if (docInfo.elementIds) {
params.set('elementIds', docInfo.elementIds.join(','));
}
return {
docId: docInfo.docId,
params,
};
}
return null;
}, [link]);
const onKeydown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
commitChange();
} else if (e.key === 'Escape') {
setEditing(false);
setTempValue(link);
}
},
[commitChange, link]
);
useEffect(() => {
setTempValue(link);
}, [link]);
const onClick = useCallback(() => {
setEditing(true);
setTimeout(() => {
ref.current?.focus();
});
}, []);
const onLinkClick = useCallback((e: React.MouseEvent) => {
// prevent click event from propagating to parent (editing)
e.stopPropagation();
setEditing(false);
}, []);
const t = useI18n();
return (
<PropertyValue
className={styles.container}
isEmpty={isEmpty}
onClick={onClick}
>
{!editing ? (
resolvedDocLink ? (
<AffinePageReference
pageId={resolvedDocLink.docId}
params={resolvedDocLink.params}
/>
) : (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
onClick={onLinkClick}
className={styles.link}
>
{link?.replace(/^https?:\/\//, '').trim()}
</a>
)
) : (
<>
<textarea
ref={ref}
onKeyDown={onKeydown}
className={styles.textarea}
onBlur={commitChange}
value={tempValue || ''}
onChange={handleOnChange}
data-empty={!tempValue}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
/>
<div className={styles.textInvisible}>
{tempValue}
{tempValue?.endsWith('\n') || !tempValue ? <br /> : null}
</div>
</>
)}
</PropertyValue>
);
};

View File

@ -0,0 +1,20 @@
import { NumberValue } from '@affine/core/components/doc-properties/types/number';
import { useLiveData } from '@toeverything/infra';
import type { DatabaseCellRendererProps } from '../../../types';
export const NumberCell = ({
cell,
rowId,
dataSource,
}: DatabaseCellRendererProps) => {
const value = useLiveData(cell.value$);
return (
<NumberValue
value={value}
onChange={v => {
dataSource.cellValueChange(rowId, cell.property.id, v);
}}
/>
);
};

View File

@ -0,0 +1,34 @@
import { Progress, PropertyValue } from '@affine/component';
import type { LiveData } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import type { DatabaseCellRendererProps } from '../../../types';
export const ProgressCell = ({
cell,
dataSource,
rowId,
}: DatabaseCellRendererProps) => {
const value = useLiveData(cell.value$ as LiveData<number>);
const isEmpty = value === undefined;
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
return (
<PropertyValue isEmpty={isEmpty} hoverable={false}>
<Progress
value={localValue}
onChange={v => {
setLocalValue(v);
}}
onBlur={() => {
dataSource.cellValueChange(rowId, cell.id, localValue);
}}
/>
</PropertyValue>
);
};

View File

@ -0,0 +1,62 @@
import { PropertyValue } from '@affine/component';
import type { BlockStdScope } from '@blocksuite/affine/block-std';
import {
DefaultInlineManagerExtension,
RichText,
} from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { type LiveData, useLiveData } from '@toeverything/infra';
import { useEffect, useRef } from 'react';
import type * as Y from 'yjs';
import type { DatabaseCellRendererProps } from '../../../types';
import { useBlockStdScope } from '../../../utils';
// todo(@pengx17): handle markdown/keyboard shortcuts
const renderRichText = ({
doc,
std,
text,
}: {
std: BlockStdScope;
text: Y.Text;
doc: Doc;
}) => {
const inlineManager = std.get(DefaultInlineManagerExtension.identifier);
if (!inlineManager) {
return null;
}
const richText = new RichText();
richText.yText = text;
richText.undoManager = doc.history;
richText.readonly = doc.readonly;
richText.attributesSchema = inlineManager.getSchema() as any;
richText.attributeRenderer = inlineManager.getRenderer();
return richText;
};
export const RichTextCell = ({
cell,
dataSource,
}: DatabaseCellRendererProps) => {
const std = useBlockStdScope(dataSource.doc);
const text = useLiveData(cell.value$ as LiveData<Y.Text>);
const ref = useRef<HTMLDivElement>(null);
// todo(@pengx17): following is a workaround to y.Text that it is got renewed when the cell is updated externally. however it breaks the cursor position.
useEffect(() => {
if (ref.current) {
ref.current.innerHTML = '';
const richText = renderRichText({ doc: dataSource.doc, std, text });
if (richText) {
ref.current.append(richText);
return () => {
richText.remove();
};
}
}
return () => {};
}, [dataSource.doc, std, text]);
return <PropertyValue ref={ref}></PropertyValue>;
};

View File

@ -0,0 +1,11 @@
import { style } from '@vanilla-extract/css';
export const tagInlineEditor = style({
width: '100%',
padding: `6px`,
minHeight: 34,
});
export const container = style({
padding: '0px !important',
});

View File

@ -0,0 +1,259 @@
/* eslint-disable rxjs/finnish */
import { PropertyValue } from '@affine/component';
import { type TagLike, TagsInlineEditor } from '@affine/component/ui/tags';
import { paletteLineToTag, TagService } from '@affine/core/modules/tag';
import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
import type { SelectTag } from '@blocksuite/data-view';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback, useMemo } from 'react';
import type {
DatabaseCellRendererProps,
DatabaseValueCell,
} from '../../../types';
import * as styles from './select.css';
interface SelectPropertyData {
options: SelectTag[];
}
type SelectCellValue = string[] | string;
type SelectCell<T extends SelectCellValue> = DatabaseValueCell<
T,
SelectPropertyData
>;
type SingleSelectCell = SelectCell<string>;
type MultiSelectCell = SelectCell<string[]>;
const adapter = {
getSelectedIds$(cell: SingleSelectCell | MultiSelectCell) {
return cell.value$.map(ids => {
if (!Array.isArray(ids)) {
return typeof ids === 'string' ? [ids] : [];
}
return ids.filter(id => typeof id === 'string');
});
},
getSelectedTags$(cell: SingleSelectCell | MultiSelectCell) {
return LiveData.computed(get => {
const ids = get(adapter.getSelectedIds$(cell));
const options = get(adapter.getTagOptions$(cell));
return ids
.map(
id =>
typeof id === 'string' && options.find(option => option.id === id)
)
.filter(option => !!option);
});
},
getTagOptions$(cell: SingleSelectCell | MultiSelectCell) {
return LiveData.computed(get => {
const data = get(cell.property.data$);
return data?.options as SelectTag[];
});
},
updateOptions(
cell: SingleSelectCell | MultiSelectCell,
dataSource: DatabaseBlockDataSource,
updater: (oldOptions: SelectTag[]) => SelectTag[]
) {
const oldData = dataSource.propertyDataGet(cell.property.id);
return dataSource.propertyDataSet(cell.property.id, {
...oldData,
options: updater(oldData.options as SelectTag[]),
});
},
deselectTag(
rowId: string,
cell: SingleSelectCell | MultiSelectCell,
dataSource: DatabaseBlockDataSource,
tagId: string,
multiple: boolean
) {
const ids = adapter.getSelectedIds$(cell).value;
dataSource.cellValueChange(
rowId,
cell.property.id,
multiple ? ids.filter(id => id !== tagId) : undefined
);
},
selectTag(
rowId: string,
cell: SingleSelectCell | MultiSelectCell,
dataSource: DatabaseBlockDataSource,
tagId: string,
multiple: boolean
) {
const ids = adapter.getSelectedIds$(cell).value;
dataSource.cellValueChange(
rowId,
cell.property.id,
multiple ? [...ids, tagId] : tagId
);
},
createTag(
cell: SingleSelectCell | MultiSelectCell,
dataSource: DatabaseBlockDataSource,
newTag: TagLike
) {
adapter.updateOptions(cell, dataSource, options => [
...options,
{
id: newTag.id,
value: newTag.value,
color: newTag.color,
},
]);
},
deleteTag(
cell: SingleSelectCell | MultiSelectCell,
dataSource: DatabaseBlockDataSource,
tagId: string
) {
adapter.updateOptions(cell, dataSource, options =>
options.filter(option => option.id !== tagId)
);
},
updateTag(
cell: SingleSelectCell | MultiSelectCell,
dataSource: DatabaseBlockDataSource,
tagId: string,
updater: (oldTag: SelectTag) => SelectTag
) {
adapter.updateOptions(cell, dataSource, options =>
options.map(option => (option.id === tagId ? updater(option) : option))
);
},
};
const BlocksuiteDatabaseSelector = ({
cell,
dataSource,
rowId,
multiple,
}: DatabaseCellRendererProps & { multiple: boolean }) => {
const tagService = useService(TagService);
const selectCell = cell as any as SingleSelectCell | MultiSelectCell;
const selectedIds = useLiveData(adapter.getSelectedIds$(selectCell));
const tagOptions = useLiveData(adapter.getTagOptions$(selectCell));
const onCreateTag = useCallback(
(name: string, color: string) => {
// bs database uses --affine-tag-xxx colors
const newTag = {
id: nanoid(),
value: name,
color: color,
};
adapter.createTag(selectCell, dataSource, newTag);
return newTag;
},
[dataSource, selectCell]
);
const onDeleteTag = useCallback(
(tagId: string) => {
adapter.deleteTag(selectCell, dataSource, tagId);
},
[dataSource, selectCell]
);
const onDeselectTag = useCallback(
(tagId: string) => {
adapter.deselectTag(rowId, selectCell, dataSource, tagId, multiple);
},
[selectCell, dataSource, rowId, multiple]
);
const onSelectTag = useCallback(
(tagId: string) => {
adapter.selectTag(rowId, selectCell, dataSource, tagId, multiple);
},
[rowId, selectCell, dataSource, multiple]
);
const tagColors = useMemo(() => {
return tagService.tagColors.map(([name, color]) => ({
id: name,
value: paletteLineToTag(color), // map from palette line to tag color
name,
}));
}, [tagService.tagColors]);
const onTagChange = useCallback(
(tagId: string, property: string, value: string) => {
adapter.updateTag(selectCell, dataSource, tagId, old => {
return {
...old,
[property]: value,
};
});
},
[dataSource, selectCell]
);
return (
<TagsInlineEditor
tagMode="db-label"
className={styles.tagInlineEditor}
tags={tagOptions}
selectedTags={selectedIds}
onCreateTag={onCreateTag}
onDeleteTag={onDeleteTag}
onDeselectTag={onDeselectTag}
onSelectTag={onSelectTag}
tagColors={tagColors}
onTagChange={onTagChange}
/>
);
};
export const SelectCell = ({
cell,
dataSource,
rowId,
}: DatabaseCellRendererProps) => {
const isEmpty = useLiveData(
cell.value$.map(value => Array.isArray(value) && value.length === 0)
);
return (
<PropertyValue isEmpty={isEmpty} className={styles.container}>
<BlocksuiteDatabaseSelector
cell={cell}
dataSource={dataSource}
rowId={rowId}
multiple={false}
/>
</PropertyValue>
);
};
export const MultiSelectCell = ({
cell,
dataSource,
rowId,
}: DatabaseCellRendererProps) => {
const isEmpty = useLiveData(
cell.value$.map(value => Array.isArray(value) && value.length === 0)
);
return (
<PropertyValue isEmpty={isEmpty} className={styles.container}>
<BlocksuiteDatabaseSelector
cell={cell}
dataSource={dataSource}
rowId={rowId}
multiple={true}
/>
</PropertyValue>
);
};

View File

@ -0,0 +1,74 @@
import type { I18nString } from '@affine/i18n';
import {
CheckBoxCheckLinearIcon,
DateTimeIcon,
LinkIcon,
MultiSelectIcon,
NumberIcon,
ProgressIcon,
SingleSelectIcon,
TextIcon,
} from '@blocksuite/icons/rc';
import type { DatabaseCellRendererProps } from '../../types';
import { CheckboxCell } from './cells/checkbox';
import { DateCell } from './cells/date';
import { LinkCell } from './cells/link';
import { NumberCell } from './cells/number';
import { ProgressCell } from './cells/progress';
import { RichTextCell } from './cells/rich-text';
import { MultiSelectCell, SelectCell } from './cells/select';
export const DatabaseRendererTypes = {
'rich-text': {
Icon: TextIcon,
Renderer: RichTextCell,
name: 'com.affine.page-properties.property.text',
},
checkbox: {
Icon: CheckBoxCheckLinearIcon,
Renderer: CheckboxCell,
name: 'com.affine.page-properties.property.checkbox',
},
date: {
Icon: DateTimeIcon,
Renderer: DateCell,
name: 'com.affine.page-properties.property.date',
},
number: {
Icon: NumberIcon,
Renderer: NumberCell,
name: 'com.affine.page-properties.property.number',
},
link: {
Icon: LinkIcon,
Renderer: LinkCell,
name: 'com.affine.page-properties.property.link',
},
progress: {
Icon: ProgressIcon,
Renderer: ProgressCell,
name: 'com.affine.page-properties.property.progress',
},
select: {
Icon: SingleSelectIcon,
Renderer: SelectCell,
name: 'com.affine.page-properties.property.select',
},
'multi-select': {
Icon: MultiSelectIcon,
Renderer: MultiSelectCell,
name: 'com.affine.page-properties.property.multi-select',
},
} as Record<
string,
{
Icon: React.FC<React.SVGProps<SVGSVGElement>>;
Renderer: React.FC<DatabaseCellRendererProps>;
name: I18nString;
}
>;
export const isSupportedDatabaseRendererType = (type?: string): boolean => {
return type ? type in DatabaseRendererTypes : false;
};

View File

@ -0,0 +1,47 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
});
export const section = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const cell = style({
display: 'flex',
gap: 4,
});
export const divider = style({
margin: '8px 0',
});
export const spacer = style({
flex: 1,
});
export const docRefLink = style({
maxWidth: '50%',
fontSize: cssVar('fontSm'),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/tertiary'),
});
export const cellList = style({
padding: '0 2px',
display: 'flex',
flexDirection: 'column',
gap: 4,
});
globalStyle(`${docRefLink} .affine-reference-title`, {
border: 'none',
});

View File

@ -0,0 +1,167 @@
import {
Divider,
PropertyCollapsibleContent,
PropertyCollapsibleSection,
PropertyName,
} from '@affine/component';
import { AffinePageReference } from '@affine/core/components/affine/reference-link';
import { useI18n } from '@affine/i18n';
import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
import { DatabaseTableViewIcon } from '@blocksuite/icons/rc';
import {
DocService,
LiveData,
useLiveData,
useService,
} from '@toeverything/infra';
import { Fragment, useMemo } from 'react';
import type { Observable } from 'rxjs';
import { DocDatabaseBacklinksService } from '../../services/doc-database-backlinks';
import type { DatabaseRow, DatabaseValueCell } from '../../types';
import { DatabaseRendererTypes } from './constant';
import * as styles from './doc-database-backlink-info.css';
type CellConfig =
(typeof DatabaseRendererTypes)[keyof typeof DatabaseRendererTypes];
const DatabaseBacklinkCellName = ({
cell,
config,
}: {
cell: DatabaseValueCell;
config: CellConfig;
}) => {
const propertyName = useLiveData(cell.property.name$);
const t = useI18n();
return (
<PropertyName
icon={<config.Icon />}
name={propertyName ?? (config.name ? t.t(config.name) : t['unnamed']())}
/>
);
};
const DatabaseBacklinkCell = ({
cell,
dataSource,
rowId,
}: {
cell: DatabaseValueCell;
dataSource: DatabaseBlockDataSource;
rowId: string;
}) => {
const cellType = useLiveData(cell.property.type$);
const config = cellType ? DatabaseRendererTypes[cellType] : undefined;
// do not render title cell!
if (!config || cellType === 'title') {
return null;
}
return (
<li
key={cell.id}
className={styles.cell}
data-testid="database-backlink-cell"
>
<DatabaseBacklinkCellName cell={cell} config={config} />
<config.Renderer cell={cell} dataSource={dataSource} rowId={rowId} />
</li>
);
};
/**
* A row in the database backlink info.
* Note: it is being rendered in a list. The name might be confusing.
*/
const DatabaseBacklinkRow = ({
defaultOpen = false,
row$,
}: {
defaultOpen: boolean;
row$: Observable<DatabaseRow | undefined>;
}) => {
const row = useLiveData(
useMemo(() => LiveData.from(row$, undefined), [row$])
);
const sortedCells = useMemo(() => {
return row?.cells.toSorted((a, b) => {
return (a.property.name$.value ?? '').localeCompare(
b.property.name$.value ?? ''
);
});
}, [row?.cells]);
const t = useI18n();
if (!row || !sortedCells) {
return null;
}
return (
<PropertyCollapsibleSection
title={row.databaseName + ' ' + t['properties']()}
defaultCollapsed={!defaultOpen}
icon={<DatabaseTableViewIcon />}
suffix={
<AffinePageReference className={styles.docRefLink} pageId={row.docId} />
}
>
<PropertyCollapsibleContent
className={styles.cellList}
collapsible={false}
>
{sortedCells.map(cell => {
return (
<DatabaseBacklinkCell
key={cell.id}
cell={cell}
dataSource={row.dataSource}
rowId={row.id}
/>
);
})}
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
);
};
export const DocDatabaseBacklinkInfo = ({
defaultOpen = [],
}: {
defaultOpen?: {
docId: string;
blockId: string;
}[];
}) => {
const doc = useService(DocService).doc;
const docDatabaseBacklinks = useService(DocDatabaseBacklinksService);
const rows = useLiveData(
useMemo(
() =>
LiveData.from(docDatabaseBacklinks.watchDbBacklinkRows$(doc.id), []),
[docDatabaseBacklinks, doc.id]
)
);
if (!rows.length) {
return null;
}
return (
<div className={styles.root}>
{rows.map(({ docId, rowId, row$ }) => (
<Fragment key={`${docId}-${rowId}`}>
<DatabaseBacklinkRow
defaultOpen={defaultOpen?.some(
backlink => backlink.docId === docId && backlink.blockId === rowId
)}
row$={row$}
/>
<Divider size="thinner" className={styles.divider} />
</Fragment>
))}
</div>
);
};

View File

@ -8,7 +8,8 @@ import {
WorkspaceEngineBeforeStart,
} from '@toeverything/infra';
import { isEmpty, omit } from 'lodash-es';
import { type Observable, switchMap } from 'rxjs';
import { map, type Observable, switchMap } from 'rxjs';
import { z } from 'zod';
import { DocsIndexer } from '../entities/docs-indexer';
@ -509,6 +510,76 @@ export class DocsSearchService extends Service {
);
}
watchDatabasesTo(docId: string) {
const DatabaseAdditionalSchema = z.object({
databaseName: z.string().optional(),
});
return this.indexer.blockIndex
.search$(
{
type: 'boolean',
occur: 'must',
queries: [
{
type: 'match',
field: 'refDocId',
match: docId,
},
{
type: 'match',
field: 'parentFlavour',
match: 'affine:database',
},
// Ignore if it is a link to the current document.
{
type: 'boolean',
occur: 'must_not',
queries: [
{
type: 'match',
field: 'docId',
match: docId,
},
],
},
],
},
{
fields: ['docId', 'blockId', 'parentBlockId', 'additional'],
pagination: {
limit: 100,
},
}
)
.pipe(
map(({ nodes }) => {
return nodes.map(node => {
const additional =
typeof node.fields.additional === 'string'
? node.fields.additional
: node.fields.additional[0];
return {
docId:
typeof node.fields.docId === 'string'
? node.fields.docId
: node.fields.docId[0],
rowId:
typeof node.fields.blockId === 'string'
? node.fields.blockId
: node.fields.blockId[0],
databaseBlockId:
typeof node.fields.parentBlockId === 'string'
? node.fields.parentBlockId
: node.fields.parentBlockId[0],
databaseName: DatabaseAdditionalSchema.safeParse(additional).data
?.databaseName as string | undefined,
};
});
})
);
}
async getDocTitle(docId: string) {
const doc = await this.indexer.docIndex.get(docId);
const title = doc?.get('title');

View File

@ -2,7 +2,7 @@ import type { DocsService } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import type { TagStore } from '../stores/tag';
import { tagColorMap } from './utils';
import { tagToPaletteLine } from './utils';
export class Tag extends Entity<{ id: string }> {
id = this.props.id;
@ -20,7 +20,7 @@ export class Tag extends Entity<{ id: string }> {
value$ = this.tagOption$.map(tag => tag?.value || '');
color$ = this.tagOption$.map(tag => tagColorMap(tag?.color ?? '') || '');
color$ = this.tagOption$.map(tag => tagToPaletteLine(tag?.color ?? '') || '');
createDate$ = this.tagOption$.map(tag => tag?.createDate || Date.now());

View File

@ -1,16 +1,25 @@
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
export const tagColorMap = (color: string) => {
const mapping: Record<string, string> = {
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
};
return mapping[color] || color;
const tagToPaletteLineMap: Record<string, string> = {
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
};
const paletteLineToTagMap: Record<string, string> = Object.fromEntries(
Object.entries(tagToPaletteLineMap).map(([key, value]) => [value, key])
);
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
export const tagToPaletteLine = (color: string) => {
return tagToPaletteLineMap[color] || color;
};
export const paletteLineToTag = (color: string) => {
return paletteLineToTagMap[color] || color;
};

View File

@ -1,7 +1,7 @@
export { Tag } from './entities/tag';
export { tagColorMap } from './entities/utils';
export { paletteLineToTag, tagToPaletteLine } from './entities/utils';
export { TagService } from './service/tag';
export { DeleteTagConfirmModal } from './view/delete-tag-modal';
export { useDeleteTagConfirmModal } from './view/delete-tag-modal';
import {
DocsService,

View File

@ -1,64 +1,77 @@
import { ConfirmModal, toast } from '@affine/component';
import { toast, useConfirmModal } from '@affine/component';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { TagService } from '../service/tag';
export const DeleteTagConfirmModal = ({
open,
onOpenChange,
selectedTagIds,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedTagIds: string[];
}) => {
/**
* Show a confirm modal AND delete the tags
*/
export const useDeleteTagConfirmModal = () => {
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const tagService = useService(TagService);
const tags = useLiveData(tagService.tagList.tags$);
const selectedTags = useMemo(() => {
return tags.filter(tag => selectedTagIds.includes(tag.id));
}, [selectedTagIds, tags]);
const tagName = useLiveData(selectedTags[0]?.value$ || '');
const handleDelete = useCallback(() => {
selectedTagIds.forEach(tagId => {
tagService.tagList.deleteTag(tagId);
});
const confirm = useCallback(
(tagIdsToDelete: string[]) => {
let closed = false;
const { resolve, promise } = Promise.withResolvers<boolean>();
const tagsToDelete = tags.filter(tag => tagIdsToDelete.includes(tag.id));
const tagName = tagsToDelete[0]?.value$.value;
const handleClose = (state: boolean) => {
if (!closed) {
closed = true;
resolve(state);
toast(
selectedTagIds.length > 1
? t['com.affine.delete-tags.count']({ count: selectedTagIds.length })
: t['com.affine.tags.delete-tags.toast']()
);
onOpenChange(false);
}, [onOpenChange, selectedTagIds, t, tagService]);
return (
<ConfirmModal
open={open}
onOpenChange={onOpenChange}
title={t['com.affine.delete-tags.confirm.title']()}
description={
selectedTags.length === 1 ? (
<Trans
i18nKey={'com.affine.delete-tags.confirm.description'}
values={{ tag: tagName }}
components={{ 1: <strong /> }}
/>
) : (
t['com.affine.delete-tags.confirm.multi-tag-description']({
count: selectedTags.length.toString(),
})
)
}
confirmText={t['Delete']()}
confirmButtonOptions={{
variant: 'error',
}}
onConfirm={handleDelete}
/>
if (state) {
tagIdsToDelete.forEach(tagId => {
tagService.tagList.deleteTag(tagId);
});
toast(
tagIdsToDelete.length > 1
? t['com.affine.delete-tags.count']({
count: tagIdsToDelete.length,
})
: t['com.affine.tags.delete-tags.toast']()
);
}
}
};
openConfirmModal({
title: t['com.affine.delete-tags.confirm.title'](),
description:
tagIdsToDelete.length === 1 ? (
<Trans
i18nKey={'com.affine.delete-tags.confirm.description'}
values={{ tag: tagName }}
components={{ 1: <strong /> }}
/>
) : (
t['com.affine.delete-tags.confirm.multi-tag-description']({
count: tagIdsToDelete.length.toString(),
})
),
confirmText: t['Delete'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
handleClose(true);
},
onCancel: () => {
handleClose(true);
},
onOpenChange: state => {
handleClose(state);
},
});
return promise;
},
[openConfirmModal, t, tagService.tagList, tags]
);
return confirm;
};

View File

@ -2,7 +2,7 @@
"ar": 85,
"ca": 6,
"da": 6,
"de": 32,
"de": 31,
"en": 100,
"es-AR": 15,
"es-CL": 17,

View File

@ -134,7 +134,7 @@
"com.affine.aboutAFFiNE.checkUpdate.subtitle.checking": "Checking for updates...",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading": "Downloading the latest version...",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.error": "Unable to connect to the update server.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.latest": "Youve got the latest version of AFFiNE.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.latest": "You've got the latest version of AFFiNE.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.restart": "Restart to apply update.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available": "New update available ({{version}})",
"com.affine.aboutAFFiNE.checkUpdate.title": "Check for updates",
@ -156,7 +156,7 @@
"com.affine.ai-onboarding.edgeless.title": "Right-clicking to select content AI",
"com.affine.ai-onboarding.general.1.description": "Lets you think bigger, create faster, work smarter and save time for every project.",
"com.affine.ai-onboarding.general.1.title": "Meet AFFiNE AI",
"com.affine.ai-onboarding.general.2.description": "Answer questions, draft docs, visualize ideas - AFFiNE AI can save you time at every possible step. Powered by GPTs most powerful model.",
"com.affine.ai-onboarding.general.2.description": "Answer questions, draft docs, visualize ideas - AFFiNE AI can save you time at every possible step. Powered by GPT's most powerful model.",
"com.affine.ai-onboarding.general.2.title": "Chat with AFFiNE AI",
"com.affine.ai-onboarding.general.3.description": "Get insightful answer to any question, instantly.",
"com.affine.ai-onboarding.general.3.title": "Edit inline with AFFiNE AI",
@ -213,7 +213,7 @@
"com.affine.appearanceSettings.title": "Appearance settings",
"com.affine.appearanceSettings.translucentUI.description": "Use transparency effect on the sidebar.",
"com.affine.appearanceSettings.translucentUI.title": "Translucent UI on the sidebar",
"com.affine.auth.change.email.message": "Your current email is {{email}}. Well send a temporary verification link to this email.",
"com.affine.auth.change.email.message": "Your current email is {{email}}. We'll send a temporary verification link to this email.",
"com.affine.auth.change.email.page.subtitle": "Please enter your new email address below. We will send a verification link to this email address to complete the process.",
"com.affine.auth.change.email.page.success.subtitle": "Congratulations! You have successfully updated the email address associated with your AFFiNE Cloud account.",
"com.affine.auth.change.email.page.success.title": "Email address updated!",
@ -277,14 +277,14 @@
"com.affine.auth.sign.up": "Sign up",
"com.affine.auth.sign.up.sent.email.subtitle": "Create your account",
"com.affine.auth.sign.up.success.subtitle": "The app will automatically open or redirect to the web version. If you encounter any issues, you can also click the button below to manually open the AFFiNE app.",
"com.affine.auth.sign.up.success.title": "Your account has been created and youre now signed in!",
"com.affine.auth.sign.up.success.title": "Your account has been created and you're now signed in!",
"com.affine.auth.signed.success.subtitle": "You have successfully signed in. The app will automatically open or redirect to the web version. if you encounter any issues, you can also click the button below to manually open the AFFiNE app.",
"com.affine.auth.signed.success.title": "Youre almost there!",
"com.affine.auth.signed.success.title": "You're almost there!",
"com.affine.auth.toast.message.failed": "Server error, please try again later.",
"com.affine.auth.toast.message.signed-in": "You have been signed in, start to sync your data with AFFiNE Cloud!",
"com.affine.auth.toast.title.failed": "Unable to sign in",
"com.affine.auth.toast.title.signed-in": "Signed in",
"com.affine.auth.verify.email.message": "Your current email is {{email}}. Well send a temporary verification link to this email.",
"com.affine.auth.verify.email.message": "Your current email is {{email}}. We'll send a temporary verification link to this email.",
"com.affine.backButton": "Back",
"com.affine.banner.content": "This demo is limited. <1>Download the AFFiNE Client</1> for the latest features and Performance.",
"com.affine.banner.local-warning": "Your local data is stored in the browser and may be lost. Don't risk it - enable cloud now!",
@ -628,6 +628,7 @@
"com.affine.page-properties.config-properties": "Config properties",
"com.affine.page-properties.backlinks": "Backlinks",
"com.affine.page-properties.create-property.menu.header": "Type",
"com.affine.page-properties.create-property.added": "Added",
"com.affine.page-properties.icons": "Icons",
"com.affine.page-properties.local-user": "Local user",
"com.affine.page-properties.outgoing-links": "Outgoing links",
@ -656,6 +657,8 @@
"com.affine.page-properties.property.journal-duplicated": "Duplicated",
"com.affine.page-properties.property.journal-remove": "Remove journal mark",
"com.affine.page-properties.property.updatedBy": "Last edited by",
"com.affine.page-properties.property.createdAt": "Created at",
"com.affine.page-properties.property.updatedAt": "Updated at",
"com.affine.page-properties.property.tags.tooltips": "Add relevant identifiers or categories to the doc. Useful for organizing content, improving searchability, and grouping related docs together.",
"com.affine.page-properties.property.journal.tooltips": "Indicates that this doc is a journal entry or daily note. Facilitates easy capture of ideas, quick logging of thoughts, and ongoing personal reflection.",
"com.affine.page-properties.property.checkbox.tooltips": "Use a checkbox to indicate whether a condition is true or false. Useful for confirming options, toggling features, or tracking task states.",
@ -1077,7 +1080,7 @@
"com.affine.settings.editorSettings.general.default-new-doc.title": "New doc default mode",
"com.affine.settings.editorSettings.general.font-family.custom.description": "Customize your text experience.",
"com.affine.settings.editorSettings.general.font-family.custom.title": "Custom font family",
"com.affine.settings.editorSettings.general.font-family.description": "Choose your editors font family.",
"com.affine.settings.editorSettings.general.font-family.description": "Choose your editor's font family.",
"com.affine.settings.editorSettings.general.font-family.title": "Font family",
"com.affine.settings.editorSettings.general.spell-check.description": "Automatically detect and correct spelling errors.",
"com.affine.settings.editorSettings.general.spell-check.title": "Spell check",
@ -1307,5 +1310,6 @@
"recommendBrowser": " We recommend the <1>Chrome</1> browser for optimal experience.",
"system": "System",
"unnamed": "unnamed",
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience."
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
"com.affine.workspace.properties": "Workspace properties"
}

View File

@ -1,22 +1,13 @@
import { test } from '@affine-test/kit/playwright';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import {
addDatabase,
clickNewPageButton,
getBlockSuiteEditorTitle,
waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic';
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
const addDatabase = async (page: Page) => {
await page.keyboard.press('/', { delay: 500 });
await page.keyboard.press('d', { delay: 500 });
await page.keyboard.press('a', { delay: 500 });
await page.keyboard.press('t', { delay: 500 });
await page.keyboard.press('a', { delay: 500 });
await page.getByTestId('Table View').click();
};
test('database is useable', async ({ page }) => {
test.slow();
await openHomePage(page);

View File

@ -5,7 +5,10 @@ import {
openJournalsPage,
} from '@affine-test/kit/utils/load-page';
import {
addDatabase,
addDatabaseRow,
clickNewPageButton,
createLinkedPage,
dragTo,
waitForEditorLoad,
waitForEmptyEditor,
@ -118,11 +121,13 @@ test('property table reordering', async ({ page }) => {
'bottom'
);
// new order should be Doc mode, (Tags), Number, Date, Checkbox, Text
// new order should be Doc mode, (Tags), Created at, Updated at, Number, Date, Checkbox, Text
for (const [index, property] of [
'Tags',
'Doc mode',
'Journal',
'Created at',
'Updated at',
'Number',
'Date',
'Checkbox',
@ -163,6 +168,8 @@ test('page info show more will show all properties', async ({ page }) => {
'Tags',
'Doc mode',
'Journal',
'Created at',
'Updated at',
'Text',
'Number',
'Date',
@ -261,3 +268,89 @@ test('delete property via property popup', async ({ page }) => {
page.locator('[data-testid="http://localhost:8080/"]:has-text("Text")')
).not.toBeVisible();
});
test('workspace properties can be collapsed', async ({ page }) => {
await expect(page.getByTestId('doc-property-row').first()).toBeVisible();
await page.getByRole('button', { name: 'Workspace properties' }).click();
await expect(page.getByTestId('doc-property-row').first()).not.toBeVisible();
await page.getByRole('button', { name: 'Workspace properties' }).click();
await expect(page.getByTestId('doc-property-row').first()).toBeVisible();
});
// todo: add more tests for database backlink info for different cell types
test('can show database backlink info', async ({ page }) => {
const pageTitle = 'some page title';
await clickNewPageButton(page, pageTitle);
await page.keyboard.press('Enter');
const databaseTitle = 'some database title';
await addDatabase(page, databaseTitle);
await expect(page.locator('affine-database-title')).toContainText(
databaseTitle
);
await expect(
page.locator(`affine-database-title:has-text("${databaseTitle}")`)
).toBeVisible();
await addDatabaseRow(page, databaseTitle);
// the new row's title cell should have been focused at the point of adding the row
await createLinkedPage(page, 'linked page');
// change status label
await page.keyboard.press('Escape');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Enter');
await page.keyboard.type('Done');
await page
.locator('affine-multi-tag-select .select-option:has-text("Done")')
.click();
// go back to title cell
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('Enter');
// goto the linked page
await page.locator('.affine-reference-title:has-text("linked page")').click();
// ensure the page properties are visible
await ensurePagePropertiesVisible(page);
// database backlink property should be rendered, but collapsed
const linkedDatabaseSection = page
.getByTestId('property-collapsible-section')
.filter({
hasText: 'some database title',
});
await expect(linkedDatabaseSection).toBeVisible();
await expect(
linkedDatabaseSection.getByTestId('property-collapsible-section-content')
).not.toBeVisible();
await expect(
linkedDatabaseSection.locator(
`.affine-reference-title:has-text("${pageTitle}")`
)
).toBeVisible();
// expand the linked database section
await linkedDatabaseSection
.getByTestId('property-collapsible-section-trigger')
.click();
await expect(
linkedDatabaseSection.getByTestId('property-collapsible-section-content')
).toBeVisible();
await expect(
linkedDatabaseSection
.getByTestId('database-backlink-cell')
.getByTestId('inline-tags-list')
.filter({
hasText: 'Done',
})
).toBeVisible();
});

View File

@ -134,3 +134,27 @@ export const focusInlineEditor = async (page: Page) => {
.locator('.inline-editor')
.focus();
};
export const addDatabase = async (page: Page, title?: string) => {
await page.keyboard.press('/');
await expect(page.locator('affine-slash-menu .slash-menu')).toBeVisible();
await page.keyboard.type('database');
await page.getByTestId('Table View').click();
if (title) {
await page.locator('affine-database-title').click();
await page
.locator('affine-database-title rich-text [contenteditable]')
.fill(title);
await page
.locator('affine-database-title rich-text [contenteditable]')
.blur();
}
};
export const addDatabaseRow = async (page: Page, databaseTitle: string) => {
const db = page.locator(`affine-database-table`, {
has: page.locator(`affine-database-title:has-text("${databaseTitle}")`),
});
await db.locator('.data-view-table-group-add-row-button').click();
};

View File

@ -323,9 +323,11 @@ __metadata:
"@emotion/react": "npm:^11.11.4"
"@emotion/styled": "npm:^11.11.5"
"@radix-ui/react-avatar": "npm:^1.0.4"
"@radix-ui/react-collapsible": "npm:^1.1.1"
"@radix-ui/react-dialog": "npm:^1.1.1"
"@radix-ui/react-dropdown-menu": "npm:^2.1.1"
"@radix-ui/react-popover": "npm:^1.0.7"
"@radix-ui/react-progress": "npm:^1.1.0"
"@radix-ui/react-radio-group": "npm:^1.1.3"
"@radix-ui/react-scroll-area": "npm:^1.0.5"
"@radix-ui/react-slider": "npm:^1.2.0"
@ -9705,7 +9707,7 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-collapsible@npm:1.1.0, @radix-ui/react-collapsible@npm:^1.0.3, @radix-ui/react-collapsible@npm:^1.1.0":
"@radix-ui/react-collapsible@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-collapsible@npm:1.1.0"
dependencies:
@ -9731,6 +9733,32 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-collapsible@npm:^1.0.3, @radix-ui/react-collapsible@npm:^1.1.0, @radix-ui/react-collapsible@npm:^1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-collapsible@npm:1.1.1"
dependencies:
"@radix-ui/primitive": "npm:1.1.0"
"@radix-ui/react-compose-refs": "npm:1.1.0"
"@radix-ui/react-context": "npm:1.1.1"
"@radix-ui/react-id": "npm:1.1.0"
"@radix-ui/react-presence": "npm:1.1.1"
"@radix-ui/react-primitive": "npm:2.0.0"
"@radix-ui/react-use-controllable-state": "npm:1.1.0"
"@radix-ui/react-use-layout-effect": "npm:1.1.0"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10/44332b493b5f7dfb044f08bcc287ce221625434bf40b25f0181c536af562a138f4ccdc14ce46a2e1771b3c7633cedca3d41646cbb8a9dc7c8d263f26f58874d1
languageName: node
linkType: hard
"@radix-ui/react-collection@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-collection@npm:1.1.0"
@ -9833,6 +9861,19 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-context@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-context@npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10/f6469583bf11cc7bff3ea5c95c56b0774a959512adead00dc64b0527cca01b90b476ca39a64edfd7e18e428e17940aa0339116b1ce5b6e8eab513cfd1065d391
languageName: node
linkType: hard
"@radix-ui/react-dialog@npm:1.0.5":
version: 1.0.5
resolution: "@radix-ui/react-dialog@npm:1.0.5"
@ -10369,6 +10410,26 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-presence@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-presence@npm:1.1.1"
dependencies:
"@radix-ui/react-compose-refs": "npm:1.1.0"
"@radix-ui/react-use-layout-effect": "npm:1.1.0"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10/1ae074efae47ab52a63239a5936fddb334b2f66ed91e74bfe8b1ae591e5db01fa7e9ddb1412002cc043066d40478ba05187a27eb2684dcd68dea545993f9ee20
languageName: node
linkType: hard
"@radix-ui/react-primitive@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-primitive@npm:1.0.3"