mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat: add tags support (#2988)
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import 'fake-indexeddb/auto';
|
||||
import type {
|
||||
Filter,
|
||||
LiteralValue,
|
||||
PropertiesMeta,
|
||||
Ref,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
@@ -21,7 +22,7 @@ import { tBoolean, tDate } from '../filter/logical/custom-type';
|
||||
import { toLiteral } from '../filter/shared-types';
|
||||
import type { FilterMatcherDataType } from '../filter/vars';
|
||||
import { filterMatcher } from '../filter/vars';
|
||||
import { filterByFilterList } from '../use-all-page-setting';
|
||||
import { filterByFilterList } from '../use-collection-manager';
|
||||
const ref = (name: keyof VariableMap): Ref => {
|
||||
return {
|
||||
type: 'ref',
|
||||
@@ -33,9 +34,18 @@ const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
|
||||
Created: 0,
|
||||
Updated: 0,
|
||||
'Is Favourited': false,
|
||||
Tags: [],
|
||||
...vars,
|
||||
};
|
||||
};
|
||||
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
|
||||
return {
|
||||
tags: {
|
||||
options: [],
|
||||
},
|
||||
...meta,
|
||||
};
|
||||
};
|
||||
const filter = (
|
||||
matcherData: FilterMatcherDataType,
|
||||
left: Ref,
|
||||
@@ -127,7 +137,11 @@ describe('render filter', () => {
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Condition value={value} onChange={onChange} />
|
||||
<Condition
|
||||
propertiesMeta={mockPropertiesMeta({})}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
@@ -143,7 +157,13 @@ describe('render filter', () => {
|
||||
const [value, onChange] = useState(
|
||||
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
|
||||
);
|
||||
return <Condition value={value} onChange={onChange} />;
|
||||
return (
|
||||
<Condition
|
||||
propertiesMeta={mockPropertiesMeta({})}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test('date condition function change', async () => {
|
||||
|
||||
@@ -7,20 +7,24 @@ import { renderHook } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { createDefaultFilter, vars } from '../filter/vars';
|
||||
import { useAllPageSetting } from '../use-all-page-setting';
|
||||
import { useCollectionManager } from '../use-collection-manager';
|
||||
|
||||
const defaultMeta = { tags: { options: [] } };
|
||||
|
||||
test('useAllPageSetting', async () => {
|
||||
const settingHook = renderHook(() => useAllPageSetting());
|
||||
const settingHook = renderHook(() => useCollectionManager());
|
||||
const prevCollection = settingHook.result.current.currentCollection;
|
||||
expect(settingHook.result.current.savedCollections).toEqual([]);
|
||||
await settingHook.result.current.updateCollection({
|
||||
...settingHook.result.current.currentCollection,
|
||||
filterList: [createDefaultFilter(vars[0])],
|
||||
filterList: [createDefaultFilter(vars[0], defaultMeta)],
|
||||
});
|
||||
settingHook.rerender();
|
||||
const nextCollection = settingHook.result.current.currentCollection;
|
||||
expect(nextCollection).not.toBe(prevCollection);
|
||||
expect(nextCollection.filterList).toEqual([createDefaultFilter(vars[0])]);
|
||||
expect(nextCollection.filterList).toEqual([
|
||||
createDefaultFilter(vars[0], defaultMeta),
|
||||
]);
|
||||
settingHook.result.current.backToAll();
|
||||
await settingHook.result.current.saveCollection({
|
||||
...settingHook.result.current.currentCollection,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CollectionBar } from '@affine/component/page-list';
|
||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
|
||||
@@ -34,6 +35,7 @@ const AllPagesHead = ({
|
||||
createNewEdgeless,
|
||||
importFile,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
isPublicWorkspace: boolean;
|
||||
sorter: ReturnType<typeof useSorter<ListData>>;
|
||||
@@ -41,6 +43,7 @@ const AllPagesHead = ({
|
||||
createNewEdgeless: () => void;
|
||||
importFile: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const titleList = [
|
||||
@@ -49,17 +52,21 @@ const AllPagesHead = ({
|
||||
content: t['Title'](),
|
||||
proportion: 0.5,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: t['Tags'](),
|
||||
proportion: 0.2,
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: t['Created'](),
|
||||
proportion: 0.2,
|
||||
proportion: 0.1,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: t['Updated'](),
|
||||
proportion: 0.2,
|
||||
proportion: 0.1,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'unsortable_action',
|
||||
content: (
|
||||
@@ -110,7 +117,11 @@ const AllPagesHead = ({
|
||||
</TableCell>
|
||||
))}
|
||||
</TableHeadRow>
|
||||
<CollectionBar getPageInfo={getPageInfo} />
|
||||
<CollectionBar
|
||||
columnsCount={titleList.length}
|
||||
getPageInfo={getPageInfo}
|
||||
propertiesMeta={propertiesMeta}
|
||||
/>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
@@ -123,6 +134,7 @@ export const PageList = ({
|
||||
onImportFile,
|
||||
fallback,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
}: PageListProps) => {
|
||||
const sorter = useSorter<ListData>({
|
||||
data: list,
|
||||
@@ -160,6 +172,7 @@ export const PageList = ({
|
||||
<StyledTableContainer ref={ref}>
|
||||
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
|
||||
<AllPagesHead
|
||||
propertiesMeta={propertiesMeta}
|
||||
isPublicWorkspace={isPublicWorkspace}
|
||||
sorter={sorter}
|
||||
createNewPage={onCreateNewPage}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Fragment } from 'react';
|
||||
import { styled } from '../../styles';
|
||||
import { TableBody, TableCell } from '../../ui/table';
|
||||
import { FavoriteTag } from './components/favorite-tag';
|
||||
import { Tags } from './components/tags';
|
||||
import { TitleCell } from './components/title-cell';
|
||||
import { OperationCell } from './operation-cell';
|
||||
import { StyledTableBodyRow } from './styles';
|
||||
@@ -51,6 +52,7 @@ export const AllPagesBody = ({
|
||||
pageId,
|
||||
title,
|
||||
preview,
|
||||
tags,
|
||||
icon,
|
||||
isPublicPage,
|
||||
favorite,
|
||||
@@ -86,6 +88,14 @@ export const AllPagesBody = ({
|
||||
data-testid="title"
|
||||
onClick={onClickPage}
|
||||
/>
|
||||
<TableCell
|
||||
data-testid="tags"
|
||||
hidden={isSmallDevices}
|
||||
onClick={onClickPage}
|
||||
style={{ fontSize: 'var(--affine-font-xs)' }}
|
||||
>
|
||||
<Tags value={tags}></Tags>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
data-testid="created-date"
|
||||
ellipsis={true}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagList = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
gap: 10,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const tagListFull = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
maxWidth: 300,
|
||||
padding: 10,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const tag = style({
|
||||
flexShrink: 0,
|
||||
padding: '2px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
lineHeight: '16px',
|
||||
fontWeight: 400,
|
||||
maxWidth: '100%',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
|
||||
import Menu from '../../../ui/menu/menu';
|
||||
import * as styles from './tags.css';
|
||||
|
||||
export const Tags = ({ value }: { value: Tag[] }) => {
|
||||
const list = value.map(tag => {
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={styles.tag}
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.value}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Menu
|
||||
pointerEnterDelay={500}
|
||||
content={<div className={styles.tagListFull}>{list}</div>}
|
||||
>
|
||||
<div className={styles.tagList}>{list}</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Filter, Literal } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -6,26 +7,42 @@ import { Menu, MenuItem } from '../../../ui/menu';
|
||||
import { FilterTag } from './filter-tag-translation';
|
||||
import * as styles from './index.css';
|
||||
import { literalMatcher } from './literal-matcher';
|
||||
import { tBoolean } from './logical/custom-type';
|
||||
import type { TFunction, TType } from './logical/typesystem';
|
||||
import { typesystem } from './logical/typesystem';
|
||||
import { variableDefineMap } from './shared-types';
|
||||
import { filterMatcher, VariableSelect, vars } from './vars';
|
||||
|
||||
export const Condition = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter;
|
||||
onChange: (filter: Filter) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const data = useMemo(
|
||||
() => filterMatcher.find(v => v.data.name === value.funcName),
|
||||
[value.funcName]
|
||||
);
|
||||
const data = useMemo(() => {
|
||||
const data = filterMatcher.find(v => v.data.name === value.funcName);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const instance = typesystem.instance(
|
||||
{},
|
||||
[variableDefineMap[value.left.name].type(propertiesMeta)],
|
||||
tBoolean.create(),
|
||||
data.type
|
||||
);
|
||||
return {
|
||||
render: data.data.render,
|
||||
type: instance,
|
||||
};
|
||||
}, [propertiesMeta, value.funcName, value.left.name]);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
const render =
|
||||
data.data.render ??
|
||||
data.render ??
|
||||
(({ ast }) => {
|
||||
const args = renderArgs(value, onChange, data.type);
|
||||
return (
|
||||
@@ -34,7 +51,13 @@ export const Condition = ({
|
||||
>
|
||||
<Menu
|
||||
trigger="click"
|
||||
content={<VariableSelect selected={[]} onSelect={onChange} />}
|
||||
content={
|
||||
<VariableSelect
|
||||
propertiesMeta={propertiesMeta}
|
||||
selected={[]}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div data-testid="variable-name" className={styles.filterTypeStyle}>
|
||||
<div className={styles.filterTypeIconStyle}>
|
||||
@@ -47,7 +70,13 @@ export const Condition = ({
|
||||
</Menu>
|
||||
<Menu
|
||||
trigger="click"
|
||||
content={<FunctionSelect value={value} onChange={onChange} />}
|
||||
content={
|
||||
<FunctionSelect
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.switchStyle} data-testid="filter-name">
|
||||
<FilterTag name={ast.funcName} />
|
||||
@@ -63,17 +92,19 @@ export const Condition = ({
|
||||
const FunctionSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter;
|
||||
onChange: (value: Filter) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const list = useMemo(() => {
|
||||
const type = vars.find(v => v.name === value.left.name)?.type;
|
||||
if (!type) {
|
||||
return [];
|
||||
}
|
||||
return filterMatcher.allMatchedData(type);
|
||||
}, [value.left.name]);
|
||||
return filterMatcher.allMatchedData(type(propertiesMeta));
|
||||
}, [propertiesMeta, value.left.name]);
|
||||
return (
|
||||
<div data-testid="filter-name-select">
|
||||
{list.map(v => (
|
||||
@@ -109,7 +140,11 @@ export const Arg = ({
|
||||
}
|
||||
return (
|
||||
<div data-testid="filter-arg" style={{ marginLeft: 4, fontWeight: 600 }}>
|
||||
{data.render({ type, value, onChange })}
|
||||
{data.render({
|
||||
type,
|
||||
value: value?.value,
|
||||
onChange: v => onChange({ type: 'literal', value: v }),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -119,15 +154,17 @@ export const renderArgs = (
|
||||
type: TFunction
|
||||
): ReactNode => {
|
||||
const rest = type.args.slice(1);
|
||||
return rest.map((type, i) => {
|
||||
return rest.map((argType, i) => {
|
||||
const value = filter.args[i];
|
||||
return (
|
||||
<Arg
|
||||
key={i}
|
||||
type={type}
|
||||
type={argType}
|
||||
value={value}
|
||||
onChange={value => {
|
||||
const args = filter.args.map((v, index) => (i === index ? value : v));
|
||||
const args = type.args.map((_, index) =>
|
||||
i === index ? value : filter.args[index]
|
||||
);
|
||||
onChange({
|
||||
...filter,
|
||||
args,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { filterMatcher } from './vars';
|
||||
const evalRef = (ref: Ref, variableMap: VariableMap) => {
|
||||
return variableMap[ref.name];
|
||||
};
|
||||
const evalLiteral = (lit: Literal) => {
|
||||
return lit.value;
|
||||
const evalLiteral = (lit?: Literal) => {
|
||||
return lit?.value;
|
||||
};
|
||||
const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => {
|
||||
const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import { CloseIcon, PlusIcon } from '@blocksuite/icons';
|
||||
|
||||
import { Menu } from '../../..';
|
||||
@@ -9,9 +10,11 @@ import { CreateFilterMenu } from './vars';
|
||||
export const FilterList = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter[];
|
||||
onChange: (value: Filter[]) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
@@ -25,6 +28,7 @@ export const FilterList = ({
|
||||
return (
|
||||
<div className={styles.filterItemStyle} key={i}>
|
||||
<Condition
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={filter}
|
||||
onChange={filter => {
|
||||
onChange(
|
||||
@@ -45,7 +49,13 @@ export const FilterList = ({
|
||||
})}
|
||||
<Menu
|
||||
trigger={'click'}
|
||||
content={<CreateFilterMenu value={value} onChange={onChange} />}
|
||||
content={
|
||||
<CreateFilterMenu
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
propertiesMeta={propertiesMeta}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './eval';
|
||||
export * from './filter-list';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { Literal } from '@affine/env/filter';
|
||||
import type { LiteralValue, Tag } from '@affine/env/filter';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { AFFiNEDatePicker } from '../../date-picker';
|
||||
import { FilterTag } from './filter-tag-translation';
|
||||
import { inputStyle } from './index.css';
|
||||
import { tBoolean, tDate } from './logical/custom-type';
|
||||
import { tBoolean, tDate, tTag } from './logical/custom-type';
|
||||
import { Matcher } from './logical/matcher';
|
||||
import type { TType } from './logical/typesystem';
|
||||
import { typesystem } from './logical/typesystem';
|
||||
import { tArray, typesystem } from './logical/typesystem';
|
||||
import { MultiSelect } from './multi-select';
|
||||
|
||||
export const literalMatcher = new Matcher<{
|
||||
render: (props: {
|
||||
type: TType;
|
||||
value: Literal;
|
||||
onChange: (lit: Literal) => void;
|
||||
value: LiteralValue;
|
||||
onChange: (lit: LiteralValue) => void;
|
||||
}) => ReactNode;
|
||||
}>((type, target) => {
|
||||
return typesystem.isSubtype(type, target);
|
||||
@@ -26,23 +27,44 @@ literalMatcher.register(tBoolean.create(), {
|
||||
className={inputStyle}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
onChange({ type: 'literal', value: !value.value });
|
||||
onChange(!value);
|
||||
}}
|
||||
>
|
||||
<FilterTag name={value.value?.toString()} />
|
||||
<FilterTag name={value?.toString()} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
literalMatcher.register(tDate.create(), {
|
||||
render: ({ value, onChange }) => (
|
||||
<AFFiNEDatePicker
|
||||
value={dayjs(value.value as number).format('YYYY-MM-DD')}
|
||||
value={dayjs(value as number).format('YYYY-MM-DD')}
|
||||
onChange={e => {
|
||||
onChange({
|
||||
type: 'literal',
|
||||
value: dayjs(e, 'YYYY-MM-DD').valueOf(),
|
||||
});
|
||||
onChange(dayjs(e, 'YYYY-MM-DD').valueOf());
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
const getTagsOfArrayTag = (type: TType): Tag[] => {
|
||||
if (type.type === 'array') {
|
||||
if (tTag.is(type.ele)) {
|
||||
return type.ele.data?.tags ?? [];
|
||||
}
|
||||
return [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
literalMatcher.register(tArray(tTag.create()), {
|
||||
render: ({ type, value, onChange }) => {
|
||||
return (
|
||||
<MultiSelect
|
||||
value={(value ?? []) as string[]}
|
||||
onChange={value => onChange(value)}
|
||||
options={getTagsOfArrayTag(type).map(v => ({
|
||||
label: v.value,
|
||||
value: v.id,
|
||||
}))}
|
||||
></MultiSelect>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
|
||||
import { DataHelper, typesystem } from './typesystem';
|
||||
|
||||
export const tNumber = typesystem.defineData(
|
||||
@@ -12,3 +14,8 @@ export const tBoolean = typesystem.defineData(
|
||||
export const tDate = typesystem.defineData(
|
||||
DataHelper.create<{ value: number }>('Date')
|
||||
);
|
||||
|
||||
export const tTag = typesystem.defineData<{ tags: Tag[] }>({
|
||||
name: 'Tag',
|
||||
supers: [],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const content = style({
|
||||
fontSize: 12,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
borderRadius: 8,
|
||||
padding: '3px 4px',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const text = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 350,
|
||||
});
|
||||
export const optionList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
padding: '0 4px',
|
||||
});
|
||||
export const selectOption = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 14,
|
||||
height: 26,
|
||||
borderRadius: 5,
|
||||
maxWidth: 240,
|
||||
minWidth: 100,
|
||||
padding: '0 12px',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const optionLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
});
|
||||
export const done = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-primary-color)',
|
||||
marginLeft: 8,
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { DoneIcon } from '@blocksuite/icons';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Menu from '../../../ui/menu/menu';
|
||||
import * as styles from './multi-select.css';
|
||||
|
||||
export const MultiSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}) => {
|
||||
const optionMap = useMemo(
|
||||
() => Object.fromEntries(options.map(v => [v.value, v])),
|
||||
[options]
|
||||
);
|
||||
return (
|
||||
<Menu
|
||||
trigger="click"
|
||||
content={
|
||||
<div data-testid="multi-select" className={styles.optionList}>
|
||||
{options.map(option => {
|
||||
const selected = value.includes(option.value);
|
||||
const click = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (selected) {
|
||||
onChange(value.filter(v => v !== option.value));
|
||||
} else {
|
||||
onChange([...value, option.value]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={styles.selectOption}
|
||||
data-testid="select-option"
|
||||
style={{
|
||||
backgroundColor: selected
|
||||
? 'var(--affine-hover-color)'
|
||||
: undefined,
|
||||
}}
|
||||
onClick={click}
|
||||
key={option.value}
|
||||
>
|
||||
<div className={styles.optionLabel}>{option.label}</div>
|
||||
<div
|
||||
style={{ opacity: selected ? 1 : 0 }}
|
||||
className={styles.done}
|
||||
>
|
||||
<DoneIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{value.length ? (
|
||||
<div className={styles.text}>
|
||||
{value.map(id => optionMap[id]?.label).join(', ')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||
Empty
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { Literal, LiteralValue, VariableMap } from '@affine/env/filter';
|
||||
import { DateTimeIcon, FavoritedIcon } from '@blocksuite/icons';
|
||||
import type {
|
||||
Literal,
|
||||
LiteralValue,
|
||||
PropertiesMeta,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
import {
|
||||
DateTimeIcon,
|
||||
FavoritedIcon,
|
||||
MultiSelectIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import { tBoolean, tDate } from './logical/custom-type';
|
||||
import { tBoolean, tDate, tTag } from './logical/custom-type';
|
||||
import type { TType } from './logical/typesystem';
|
||||
import { tArray } from './logical/typesystem';
|
||||
|
||||
export const toLiteral = (value: LiteralValue): Literal => ({
|
||||
type: 'literal',
|
||||
@@ -11,29 +22,34 @@ export const toLiteral = (value: LiteralValue): Literal => ({
|
||||
|
||||
export type FilterVariable = {
|
||||
name: keyof VariableMap;
|
||||
type: TType;
|
||||
type: (propertiesMeta: PropertiesMeta) => TType;
|
||||
icon: ReactElement;
|
||||
};
|
||||
|
||||
export const variableDefineMap = {
|
||||
Created: {
|
||||
type: tDate.create(),
|
||||
type: () => tDate.create(),
|
||||
icon: <DateTimeIcon />,
|
||||
},
|
||||
Updated: {
|
||||
type: tDate.create(),
|
||||
type: () => tDate.create(),
|
||||
icon: <DateTimeIcon />,
|
||||
},
|
||||
'Is Favourited': {
|
||||
type: tBoolean.create(),
|
||||
type: () => tBoolean.create(),
|
||||
icon: <FavoritedIcon />,
|
||||
},
|
||||
Tags: {
|
||||
type: meta => tArray(tTag.create({ tags: meta.tags.options })),
|
||||
icon: <MultiSelectIcon />,
|
||||
},
|
||||
// Imported: {
|
||||
// type: tBoolean.create(),
|
||||
// },
|
||||
// 'Daily Note': {
|
||||
// type: tBoolean.create(),
|
||||
// },
|
||||
} as const;
|
||||
} satisfies Record<string, Omit<FilterVariable, 'name'>>;
|
||||
|
||||
export type InternalVariableMap = {
|
||||
[K in keyof typeof variableDefineMap]: LiteralValue;
|
||||
|
||||
10
packages/component/src/components/page-list/filter/utils.ts
Normal file
10
packages/component/src/components/page-list/filter/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
|
||||
export const createTagFilter = (id: string): Filter => {
|
||||
return {
|
||||
type: 'filter',
|
||||
left: { type: 'ref', name: 'Tags' },
|
||||
funcName: 'contains all',
|
||||
args: [{ type: 'literal', value: [id] }],
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Filter, LiteralValue, VariableMap } from '@affine/env/filter';
|
||||
import type {
|
||||
Filter,
|
||||
LiteralValue,
|
||||
PropertiesMeta,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -6,10 +11,16 @@ import type { ReactNode } from 'react';
|
||||
import { MenuItem } from '../../../ui/menu';
|
||||
import { FilterTag } from './filter-tag-translation';
|
||||
import * as styles from './index.css';
|
||||
import { tBoolean, tDate } from './logical/custom-type';
|
||||
import { tBoolean, tDate, tTag } from './logical/custom-type';
|
||||
import { Matcher } from './logical/matcher';
|
||||
import type { TFunction } from './logical/typesystem';
|
||||
import { tFunction, typesystem } from './logical/typesystem';
|
||||
import {
|
||||
tArray,
|
||||
tFunction,
|
||||
tTypeRef,
|
||||
tTypeVar,
|
||||
typesystem,
|
||||
} from './logical/typesystem';
|
||||
import type { FilterVariable } from './shared-types';
|
||||
import { variableDefineMap } from './shared-types';
|
||||
|
||||
@@ -21,8 +32,11 @@ export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
|
||||
})
|
||||
);
|
||||
|
||||
export const createDefaultFilter = (variable: FilterVariable): Filter => {
|
||||
const data = filterMatcher.match(variable.type);
|
||||
export const createDefaultFilter = (
|
||||
variable: FilterVariable,
|
||||
propertiesMeta: PropertiesMeta
|
||||
): Filter => {
|
||||
const data = filterMatcher.match(variable.type(propertiesMeta));
|
||||
if (!data) {
|
||||
throw new Error('No matching function found');
|
||||
}
|
||||
@@ -37,12 +51,15 @@ export const createDefaultFilter = (variable: FilterVariable): Filter => {
|
||||
export const CreateFilterMenu = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter[];
|
||||
onChange: (value: Filter[]) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
return (
|
||||
<VariableSelect
|
||||
propertiesMeta={propertiesMeta}
|
||||
selected={value}
|
||||
onSelect={filter => {
|
||||
onChange([...value, filter]);
|
||||
@@ -52,9 +69,11 @@ export const CreateFilterMenu = ({
|
||||
};
|
||||
export const VariableSelect = ({
|
||||
onSelect,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
selected: Filter[];
|
||||
onSelect: (value: Filter) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
@@ -70,7 +89,7 @@ export const VariableSelect = ({
|
||||
icon={variableDefineMap[v.name].icon}
|
||||
key={v.name}
|
||||
onClick={() => {
|
||||
onSelect(createDefaultFilter(v));
|
||||
onSelect(createDefaultFilter(v, propertiesMeta));
|
||||
}}
|
||||
className={styles.menuItemStyle}
|
||||
>
|
||||
@@ -90,7 +109,7 @@ export type FilterMatcherDataType = {
|
||||
name: string;
|
||||
defaultArgs: () => LiteralValue[];
|
||||
render?: (props: { ast: Filter }) => ReactNode;
|
||||
impl: (...args: LiteralValue[]) => boolean;
|
||||
impl: (...args: (LiteralValue | undefined)[]) => boolean;
|
||||
};
|
||||
export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>(
|
||||
(type, target) => {
|
||||
@@ -146,3 +165,103 @@ filterMatcher.register(
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({ args: [tArray(tTag.create())], rt: tBoolean.create() }),
|
||||
{
|
||||
name: 'is not empty',
|
||||
defaultArgs: () => [],
|
||||
impl: tags => {
|
||||
if (Array.isArray(tags)) {
|
||||
return tags.length > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({ args: [tArray(tTag.create())], rt: tBoolean.create() }),
|
||||
{
|
||||
name: 'is empty',
|
||||
defaultArgs: () => [],
|
||||
impl: tags => {
|
||||
if (Array.isArray(tags)) {
|
||||
return tags.length == 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
typeVars: [tTypeVar('T', tTag.create())],
|
||||
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'contains all',
|
||||
defaultArgs: () => [],
|
||||
impl: (tags, target) => {
|
||||
if (Array.isArray(tags) && Array.isArray(target)) {
|
||||
return target.every(id => tags.includes(id));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
typeVars: [tTypeVar('T', tTag.create())],
|
||||
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'contains one of',
|
||||
defaultArgs: () => [],
|
||||
impl: (tags, target) => {
|
||||
if (Array.isArray(tags) && Array.isArray(target)) {
|
||||
return target.some(id => tags.includes(id));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
typeVars: [tTypeVar('T', tTag.create())],
|
||||
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'does not contains all',
|
||||
defaultArgs: () => [],
|
||||
impl: (tags, target) => {
|
||||
if (Array.isArray(tags) && Array.isArray(target)) {
|
||||
return !target.every(id => tags.includes(id));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
filterMatcher.register(
|
||||
tFunction({
|
||||
typeVars: [tTypeVar('T', tTag.create())],
|
||||
args: [tArray(tTypeRef('T')), tArray(tTypeRef('T'))],
|
||||
rt: tBoolean.create(),
|
||||
}),
|
||||
{
|
||||
name: 'does not contains one of',
|
||||
defaultArgs: () => [],
|
||||
impl: (tags, target) => {
|
||||
if (Array.isArray(tags) && Array.isArray(target)) {
|
||||
return !target.some(id => tags.includes(id));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -7,5 +7,5 @@ export * from './operation-cell';
|
||||
export * from './operation-menu-items';
|
||||
export * from './styles';
|
||||
export * from './type';
|
||||
export * from './use-all-page-setting';
|
||||
export * from './use-collection-manager';
|
||||
export * from './view';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
|
||||
/**
|
||||
@@ -14,6 +16,7 @@ export type ListData = {
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
preview?: string;
|
||||
tags: Tag[];
|
||||
favorite: boolean;
|
||||
createDate: Date;
|
||||
updatedDate: Date;
|
||||
@@ -48,6 +51,7 @@ export type PageListProps = {
|
||||
onCreateNewEdgeless: () => void;
|
||||
onImportFile: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
};
|
||||
|
||||
export type DraggableTitleCellData = {
|
||||
|
||||
@@ -31,16 +31,17 @@ const pageCollectionDBPromise: Promise<IDBPDatabase<PageCollectionDBV1>> =
|
||||
},
|
||||
});
|
||||
|
||||
const defaultCollection = {
|
||||
id: NIL,
|
||||
name: 'All',
|
||||
filterList: [],
|
||||
};
|
||||
const collectionAtom = atomWithReset<{
|
||||
currentId: string;
|
||||
defaultCollection: Collection;
|
||||
}>({
|
||||
currentId: NIL,
|
||||
defaultCollection: {
|
||||
id: NIL,
|
||||
name: 'All',
|
||||
filterList: [],
|
||||
},
|
||||
defaultCollection: defaultCollection,
|
||||
});
|
||||
|
||||
export const useSavedCollections = () => {
|
||||
@@ -102,7 +103,7 @@ export const useSavedCollections = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const useAllPageSetting = () => {
|
||||
export const useCollectionManager = () => {
|
||||
const { savedCollections, saveCollection, deleteCollection, addPage } =
|
||||
useSavedCollections();
|
||||
const [collectionData, setCollectionData] = useAtom(collectionAtom);
|
||||
@@ -132,6 +133,18 @@ export const useAllPageSetting = () => {
|
||||
const backToAll = useCallback(() => {
|
||||
setCollectionData(RESET);
|
||||
}, [setCollectionData]);
|
||||
const setTemporaryFilter = useCallback(
|
||||
(filterList: Filter[]) => {
|
||||
setCollectionData({
|
||||
currentId: NIL,
|
||||
defaultCollection: {
|
||||
...defaultCollection,
|
||||
filterList: filterList,
|
||||
},
|
||||
});
|
||||
},
|
||||
[setCollectionData]
|
||||
);
|
||||
const currentCollection =
|
||||
collectionData.currentId === NIL
|
||||
? collectionData.defaultCollection
|
||||
@@ -149,6 +162,7 @@ export const useAllPageSetting = () => {
|
||||
backToAll,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
setTemporaryFilter,
|
||||
};
|
||||
};
|
||||
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EditCollectionModel } from '@affine/component/page-list';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import {
|
||||
DeleteIcon,
|
||||
@@ -13,15 +14,19 @@ import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '../../../ui/button/button';
|
||||
import { useAllPageSetting } from '../use-all-page-setting';
|
||||
import { useCollectionManager } from '../use-collection-manager';
|
||||
import * as styles from './collection-bar.css';
|
||||
|
||||
export const CollectionBar = ({
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
columnsCount,
|
||||
}: {
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
columnsCount: number;
|
||||
}) => {
|
||||
const setting = useAllPageSetting();
|
||||
const setting = useCollectionManager();
|
||||
const collection = setting.currentCollection;
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions: {
|
||||
@@ -80,6 +85,7 @@ export const CollectionBar = ({
|
||||
<td>
|
||||
<div className={styles.view}>
|
||||
<EditCollectionModel
|
||||
propertiesMeta={propertiesMeta}
|
||||
getPageInfo={getPageInfo}
|
||||
init={collection}
|
||||
open={open}
|
||||
@@ -109,8 +115,9 @@ export const CollectionBar = ({
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
{Array.from({ length: columnsCount - 2 }).map((_, i) => (
|
||||
<td key={i}></td>
|
||||
))}
|
||||
<td
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EditCollectionModel } from '@affine/component/page-list';
|
||||
import type { Collection, Filter } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
@@ -19,7 +20,7 @@ import { Button, MenuItem } from '../../..';
|
||||
import Menu from '../../../ui/menu/menu';
|
||||
import { appSidebarOpenAtom } from '../../app-sidebar';
|
||||
import { CreateFilterMenu } from '../filter/vars';
|
||||
import type { useAllPageSetting } from '../use-all-page-setting';
|
||||
import type { useCollectionManager } from '../use-collection-manager';
|
||||
import * as styles from './collection-list.css';
|
||||
|
||||
const CollectionOption = ({
|
||||
@@ -28,7 +29,7 @@ const CollectionOption = ({
|
||||
updateCollection,
|
||||
}: {
|
||||
collection: Collection;
|
||||
setting: ReturnType<typeof useAllPageSetting>;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
updateCollection: (view: Collection) => void;
|
||||
}) => {
|
||||
const actions: {
|
||||
@@ -118,9 +119,11 @@ const CollectionOption = ({
|
||||
export const CollectionList = ({
|
||||
setting,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
setting: ReturnType<typeof useAllPageSetting>;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open] = useAtom(appSidebarOpenAtom);
|
||||
@@ -205,6 +208,7 @@ export const CollectionList = ({
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<CreateFilterMenu
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={setting.currentCollection.filterList}
|
||||
onChange={onChange}
|
||||
/>
|
||||
@@ -221,6 +225,7 @@ export const CollectionList = ({
|
||||
</Button>
|
||||
</Menu>
|
||||
<EditCollectionModel
|
||||
propertiesMeta={propertiesMeta}
|
||||
getPageInfo={getPageInfo}
|
||||
init={collection}
|
||||
open={!!collection}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import {
|
||||
EdgelessIcon,
|
||||
@@ -25,6 +26,7 @@ type CreateCollectionProps = {
|
||||
onConfirm: (collection: Collection) => void;
|
||||
onConfirmText?: string;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
};
|
||||
export const EditCollectionModel = ({
|
||||
init,
|
||||
@@ -32,12 +34,14 @@ export const EditCollectionModel = ({
|
||||
open,
|
||||
onClose,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
init?: Collection;
|
||||
onConfirm: (view: Collection) => void;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
@@ -56,6 +60,7 @@ export const EditCollectionModel = ({
|
||||
/>
|
||||
{init ? (
|
||||
<EditCollection
|
||||
propertiesMeta={propertiesMeta}
|
||||
title="Update Collection"
|
||||
onConfirmText="Save"
|
||||
init={init}
|
||||
@@ -120,6 +125,7 @@ export const EditCollection = ({
|
||||
onCancel,
|
||||
onConfirmText,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
}: CreateCollectionProps & {
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
@@ -189,6 +195,7 @@ export const EditCollection = ({
|
||||
>
|
||||
<div className={styles.filterTitle}>Filters</div>
|
||||
<FilterList
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={value.filterList}
|
||||
onChange={list =>
|
||||
onChange({
|
||||
@@ -262,6 +269,7 @@ export const SaveCollectionButton = ({
|
||||
init,
|
||||
onConfirm,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
}: CreateCollectionProps) => {
|
||||
const [show, changeShow] = useState(false);
|
||||
return (
|
||||
@@ -280,6 +288,7 @@ export const SaveCollectionButton = ({
|
||||
</div>
|
||||
</Button>
|
||||
<EditCollectionModel
|
||||
propertiesMeta={propertiesMeta}
|
||||
init={init}
|
||||
onConfirm={onConfirm}
|
||||
open={show}
|
||||
|
||||
10
packages/env/src/filter.ts
vendored
10
packages/env/src/filter.ts
vendored
@@ -1,3 +1,5 @@
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
|
||||
export type LiteralValue =
|
||||
| number
|
||||
| string
|
||||
@@ -33,3 +35,11 @@ export type Collection = {
|
||||
allowList?: string[];
|
||||
excludeList?: string[];
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
value: string;
|
||||
color: string;
|
||||
parentId?: string;
|
||||
};
|
||||
export type PropertiesMeta = Workspace['meta']['properties'];
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"Delete": "Delete",
|
||||
"Title": "Title",
|
||||
"Untitled": "Untitled",
|
||||
"Tags": "Tags",
|
||||
"Created": "Created",
|
||||
"Updated": "Updated",
|
||||
"Open in new tab": "Open in new tab",
|
||||
|
||||
Reference in New Issue
Block a user