feat(core): integration property ui (#10844)

close AF-2258, AF-2256
This commit is contained in:
CatsJuice
2025-03-20 23:20:56 +00:00
parent 0773a719d5
commit e4bc43df67
16 changed files with 293 additions and 5 deletions

View File

@@ -15,6 +15,7 @@ import type {
DatabaseRow,
DatabaseValueCell,
} from '@affine/core/modules/doc-info/types';
import { DocIntegrationPropertiesTable } from '@affine/core/modules/integration';
import { GuardService } from '@affine/core/modules/permissions';
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
@@ -448,6 +449,7 @@ const DocPropertiesTableInner = ({
onOpenChange={setExpanded}
/>
<Collapsible.Content>
<DocIntegrationPropertiesTable />
<DocWorkspacePropertiesTableBody
defaultOpen={
!defaultOpenProperty || defaultOpenProperty.type === 'workspace'

View File

@@ -1,5 +1,11 @@
import type { I18nString } from '@affine/i18n';
import { ReadwiseLogoDuotoneIcon } from '@blocksuite/icons/rc';
import {
DateTimeIcon,
HistoryIcon,
LinkIcon,
ReadwiseLogoDuotoneIcon,
TextIcon,
} from '@blocksuite/icons/rc';
import type { SVGProps } from 'react';
import type { IntegrationProperty, IntegrationType } from './type';
@@ -16,14 +22,32 @@ export const INTEGRATION_PROPERTY_SCHEMA: {
} = {
readwise: {
author: {
order: '400',
label: 'com.affine.integration.readwise-prop.author',
key: 'author',
type: 'text',
icon: TextIcon,
},
source: {
order: '300',
label: 'com.affine.integration.readwise-prop.source',
key: 'readwise_url',
type: 'source',
icon: LinkIcon,
},
created: {
order: '100',
label: 'com.affine.integration.readwise-prop.created',
key: 'created_at',
type: 'date',
icon: DateTimeIcon,
},
updated: {
order: '200',
label: 'com.affine.integration.readwise-prop.updated',
key: 'updated_at',
type: 'date',
icon: HistoryIcon,
},
},
zotero: {},

View File

@@ -15,6 +15,7 @@ import { ReadwiseStore } from './store/readwise';
export { IntegrationService };
export { IntegrationTypeIcon } from './views/icon';
export { DocIntegrationPropertiesTable } from './views/properties-table';
export function configureIntegrationModule(framework: Framework) {
framework

View File

@@ -9,10 +9,18 @@ export class IntegrationPropertyService extends Service {
super();
}
integrationType$ = this.docService.doc.properties$.selector(
p => p.integrationType
);
schema$ = this.docService.doc.properties$
.selector(p => p.integrationType)
.map(type => (type ? INTEGRATION_PROPERTY_SCHEMA[type] : null));
integrationProperty$(
type: IntegrationType,
key: string
): LiveData<Record<string, any> | undefined | null>;
integrationProperty$<
T extends IntegrationType,
Key extends keyof IntegrationDocPropertiesMap[T],

View File

@@ -1,4 +1,5 @@
import type { I18nString } from '@affine/i18n';
import type { ComponentType, SVGProps } from 'react';
import type { DocIntegrationRef } from '../db/schema/schema';
@@ -11,8 +12,10 @@ export type IntegrationDocPropertiesMap = {
export type IntegrationProperty<T extends IntegrationType> = {
key: keyof IntegrationDocPropertiesMap[T];
label?: I18nString;
label: I18nString;
type: 'link' | 'text' | 'date' | 'source';
icon?: ComponentType<SVGProps<SVGSVGElement>>;
order?: string;
};
// ===============================

View File

@@ -0,0 +1,66 @@
import {
PropertyCollapsibleContent,
PropertyCollapsibleSection,
PropertyName,
PropertyRoot,
} from '@affine/component';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { IntegrationPropertyService } from '../services/integration-property';
import type { IntegrationProperty } from '../type';
import { ValueRenderer } from './property-values';
export const DocIntegrationPropertiesTable = () => {
const t = useI18n();
const integrationPropertyService = useService(IntegrationPropertyService);
const integrationType = useLiveData(
integrationPropertyService.integrationType$
);
const schema = useLiveData(integrationPropertyService.schema$);
const properties = useMemo(
() =>
(Object.values(schema || {}) as IntegrationProperty<any>[]).sort(
(a, b) => {
const aOrder = a.order ?? '9999';
const bOrder = b.order ?? '9999';
return aOrder.localeCompare(bOrder);
}
),
[schema]
);
if (!schema || !integrationType) return null;
return (
<PropertyCollapsibleSection
title={t['com.affine.integration.properties']()}
>
<PropertyCollapsibleContent>
{properties.map(property => {
const Icon = property.icon;
const key = property.key as string;
const label = property.label;
const displayName =
typeof label === 'string'
? t[label]()
: t.t(label?.i18nKey, label?.options);
return (
<PropertyRoot key={key}>
<PropertyName name={displayName} icon={Icon ? <Icon /> : null} />
<ValueRenderer
integration={integrationType}
type={property.type}
propertyKey={key}
/>
</PropertyRoot>
);
})}
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
);
};

View File

@@ -0,0 +1,24 @@
import { PropertyValue, Tooltip } from '@affine/component';
import { i18nTime } from '@affine/i18n';
import { useMemo } from 'react';
import * as styles from './styles.css';
import type { PropertyValueProps } from './type';
export const DateValue = ({ value }: PropertyValueProps) => {
const accuracySecond = useMemo(() => {
return i18nTime(value, { absolute: { accuracy: 'second' } });
}, [value]);
const accuracyDay = useMemo(() => {
return i18nTime(value, { absolute: { accuracy: 'day' } });
}, [value]);
return (
<PropertyValue className={styles.value} hoverable={false}>
<Tooltip content={accuracySecond} side="right">
<span>{accuracyDay}</span>
</Tooltip>
</PropertyValue>
);
};

View File

@@ -0,0 +1,51 @@
import { useLiveData, useService } from '@toeverything/infra';
import { type ComponentType, useMemo } from 'react';
import { IntegrationPropertyService } from '../../services/integration-property';
import type { IntegrationProperty } from '../../type';
import { DateValue } from './date-value';
import { LinkValue } from './link-value';
import { SourceValue } from './source-value';
import { TextValue } from './text-value';
import type { PropertyValueProps } from './type';
type IntegrationPropertyType = IntegrationProperty<any>['type'];
const valueRenderers: Record<
IntegrationPropertyType,
ComponentType<PropertyValueProps>
> = {
link: LinkValue,
source: SourceValue,
text: TextValue,
date: DateValue,
};
type ValueRendererProps = {
type: IntegrationPropertyType;
propertyKey: string;
} & Pick<PropertyValueProps, 'integration'>;
export const ValueRenderer = ({
integration,
type,
propertyKey,
}: ValueRendererProps) => {
const Renderer = valueRenderers[type];
const integrationPropertyService = useService(IntegrationPropertyService);
const propertyValue = useLiveData(
useMemo(() => {
return integrationPropertyService.integrationProperty$(
integration,
propertyKey
);
}, [integration, integrationPropertyService, propertyKey])
);
if (!Renderer) {
return null;
}
return <Renderer integration={integration} value={propertyValue} />;
};

View File

@@ -0,0 +1,17 @@
import { PropertyValue } from '@affine/component';
import * as styles from './styles.css';
import type { PropertyValueProps } from './type';
export const LinkValue = ({ value }: PropertyValueProps) => {
return (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className={styles.linkWrapper}
>
<PropertyValue className={styles.value}>{value}</PropertyValue>
</a>
);
};

View File

@@ -0,0 +1,25 @@
import { PropertyValue } from '@affine/component';
import { DualLinkIcon } from '@blocksuite/icons/rc';
import { IntegrationTypeIcon } from '../icon';
import * as styles from './styles.css';
import type { PropertyValueProps } from './type';
export const SourceValue = ({ value, integration }: PropertyValueProps) => {
return (
<a
href={value}
target="_blank"
rel="noreferrer"
className={styles.linkWrapper}
>
<PropertyValue className={styles.sourceValue}>
<div className={styles.sourceValueIcon}>
<IntegrationTypeIcon type={integration} />
</div>
{value}
<DualLinkIcon className={styles.sourceValueLinkIcon} />
</PropertyValue>
</a>
);
};

View File

@@ -0,0 +1,36 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const value = style({
fontSize: 14,
fontWeight: 400,
lineHeight: '22px',
color: cssVarV2.text.primary,
});
export const linkWrapper = style({
flex: 1,
});
export const sourceValue = style([
value,
{
display: 'flex',
alignItems: 'center',
gap: 6,
},
]);
export const sourceValueIcon = style({
fontSize: 18,
width: 22,
height: 22,
borderRadius: 3,
boxShadow: `0px 0px 0px 1px ${cssVarV2.layer.insideBorder.border}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: cssVarV2.integrations.background.iconSolid,
});
export const sourceValueLinkIcon = style({
fontSize: 18,
color: cssVarV2.icon.primary,
});

View File

@@ -0,0 +1,10 @@
import { PropertyValue } from '@affine/component';
import * as styles from './styles.css';
export const TextValue = ({ value }: { value: string }) => {
return (
<PropertyValue hoverable={false} className={styles.value}>
{value}
</PropertyValue>
);
};

View File

@@ -0,0 +1,6 @@
import type { IntegrationType } from '../../type';
export interface PropertyValueProps {
value: any;
integration: IntegrationType;
}