Skip to content

Commit

Permalink
feat: support multiple search docs list styles.
Browse files Browse the repository at this point in the history
table/grid/json
  • Loading branch information
riccox committed Oct 10, 2024
1 parent 4140c94 commit 444d496
Show file tree
Hide file tree
Showing 7 changed files with 248 additions and 55 deletions.
63 changes: 63 additions & 0 deletions src/components/Document/GridItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Button } from '@arco-design/web-react';
import { BaseDocItemProps } from './list';
import { useTranslation } from 'react-i18next';
import { Descriptions, Image, Modal } from '@douyinfe/semi-ui';
import _ from 'lodash';
import { Copyable } from '../Copyable';
import { getTimeText, isValidDateTime, isValidImgUrl } from '@/utils/text';

export const GridItem = ({ doc, onClickDocumentDel, onClickDocumentUpdate }: BaseDocItemProps) => {
const { t } = useTranslation('document');

return (
<div
className={`rounded-xl px-3 py-5 bg-primary-50/20 border border-transparent hover:border-primary group relative overflow-hidden`}
>
<Descriptions
data={Object.entries(doc.content).map(([k, v]) => ({
key: k,
value: <ValueDisplay name={k} value={v} />,
}))}
/>
<div
className={`absolute right-0 bottom-0 opacity-95 invisible group-hover:visible p-1.5 flex items-center gap-2`}
>
<Button type="secondary" size="mini" status="warning" onClick={() => onClickDocumentUpdate(doc)}>
{t('common:update')}
</Button>
<Button type="secondary" size="mini" status="danger" onClick={() => onClickDocumentDel(doc)}>
{t('common:delete')}
</Button>
</div>
</div>
);
};

const ValueDisplay = ({ name, value }: { name: string; value: unknown }) => {
const str = _.toString(value).trim();
return (
<div
className="cursor-pointer"
onClick={() => {
Modal.info({
title: name,
centered: true,
content: (
<div className="grid gap-2">
<Copyable className="overflow-scroll whitespace-pre-wrap text-balance break-words">{str}</Copyable>
{isValidImgUrl(str) && <Image width={'100%'} src={str} />}
</div>
),
});
}}
>
{/^.*(date|time).*$/gim.test(name) && isValidDateTime(str) ? (
getTimeText(isValidDateTime(str) as Date)
) : isValidImgUrl(str) ? (
<Image width={'100%'} src={str} preview={false} />
) : (
_.truncate(str, { length: 20 })
)}
</div>
);
};
29 changes: 29 additions & 0 deletions src/components/Document/JSONItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import ReactJson from 'react-json-view';
import { Button } from '@arco-design/web-react';
import { BaseDocItemProps } from './list';
import { useTranslation } from 'react-i18next';

export const JSONItem = ({ doc, onClickDocumentDel, onClickDocumentUpdate }: BaseDocItemProps) => {
const { t } = useTranslation('document');

return (
<div className={`text-xs rounded-xl p-4 bg-primary-50 odd:bg-opacity-20 even:bg-opacity-10 group relative`}>
<ReactJson
name={false}
displayDataTypes={false}
displayObjectSize={false}
src={doc.content}
collapsed={3}
collapseStringsAfterLength={50}
/>
<div className={`absolute right-0 bottom-0 opacity-95 invisible group-hover:visible p-2 flex items-center gap-2`}>
<Button type="secondary" size="mini" status="warning" onClick={() => onClickDocumentUpdate(doc)}>
{t('common:update')}
</Button>
<Button type="secondary" size="mini" status="danger" onClick={() => onClickDocumentDel(doc)}>
{t('common:delete')}
</Button>
</div>
</div>
);
};
128 changes: 88 additions & 40 deletions src/components/Document/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,29 @@ import { showTaskErrorNotification, showTaskSubmitNotification } from '@/utils/t
import { toast } from '@/utils/toast';
import { useMutation } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import ReactJson from 'react-json-view';
import MonacoEditor from '@monaco-editor/react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { Button } from '@arco-design/web-react';
import { Modal } from '@douyinfe/semi-ui';
import { JSONItem } from './JSONItem';
import { Table, TableProps } from '@arco-design/web-react';
import { GridItem } from './GridItem';
import { Button } from '@arco-design/web-react';

type Doc = { indexId: string; content: object; primaryKey: string };
export type Doc = { indexId: string; content: Record<string, unknown>; primaryKey: string };
export type BaseDocItemProps = {
doc: Doc;
onClickDocumentUpdate: (doc: Doc) => void;
onClickDocumentDel: (doc: Doc) => void;
};
export type ListType = 'json' | 'table' | 'grid';

interface Props {
type?: ListType;
docs?: Doc[];
showIndex?: boolean;
refetchDocs: () => void;
}

export const DocumentList = ({ docs = [], showIndex = false, refetchDocs }: Props) => {
export const DocumentList = ({ docs = [], refetchDocs, type = 'json' }: Props) => {
const { t } = useTranslation('document');
const client = useMeiliClient();
const [editingDocument, setEditingDocument] = useState<Doc>();
Expand Down Expand Up @@ -127,40 +134,81 @@ export const DocumentList = ({ docs = [], showIndex = false, refetchDocs }: Prop
[editDocumentMutation, editingDocument, onEditDocumentJsonEditorUpdate, t]
);

const list = useMemo(() => {
return docs.map((d, i) => {
return (
<div
className={`text-xs rounded-xl p-4 bg-primary-100 odd:bg-opacity-20 even:bg-opacity-10 group relative`}
key={i}
>
<div
className={clsx(`absolute right-3 top-3 opacity-95 badge outline sm bw cornered`, !showIndex && 'hidden')}
>
{d.indexId}
</div>
<ReactJson
name={false}
displayDataTypes={false}
displayObjectSize={false}
src={d.content}
collapsed={3}
collapseStringsAfterLength={50}
return useMemo(
() =>
type === 'table' ? (
<>
<Table
columns={([
...new Set(
docs.reduce(
(keys, obj) => {
return keys.concat(Object.keys(obj.content));
},
[docs[0].primaryKey]
)
),
].map((i) => ({ title: i, dataIndex: i })) as TableProps['columns'])!.concat([
{
title: t('common:actions'),
fixed: 'right',
render: (_col, _record, index) => (
<div className={`flex items-center gap-2`}>
<Button
type="secondary"
size="mini"
status="warning"
onClick={() => onClickDocumentUpdate(docs[index])}
>
{t('common:update')}
</Button>
<Button
type="secondary"
size="mini"
status="danger"
onClick={() => onClickDocumentDel(docs[index])}
>
{t('common:delete')}
</Button>
</div>
),
},
])}
data={docs.map((d) => ({ ...d.content }))}
stripe
hover
virtualized
pagination={false}
size="small"
/>
<div
className={`absolute right-0 bottom-0 opacity-95 invisible group-hover:visible p-2 flex items-center gap-2`}
>
<Button type="secondary" size="mini" status="warning" onClick={() => onClickDocumentUpdate(d)}>
{t('common:update')}
</Button>
<Button type="secondary" size="mini" status="danger" onClick={() => onClickDocumentDel(d)}>
{t('common:delete')}
</Button>
</div>
</>
) : type === 'grid' ? (
<div className="grid grid-cols-3 laptop:grid-cols-4 gap-3">
{docs.map((d, i) => {
return (
<GridItem
doc={d}
key={i}
onClickDocumentDel={onClickDocumentDel}
onClickDocumentUpdate={onClickDocumentUpdate}
/>
);
})}
</div>
);
});
}, [docs, showIndex, onClickDocumentUpdate, onClickDocumentDel, t]);

return useMemo(() => <>{list}</>, [list]);
) : (
<>
{docs.map((d, i) => {
return (
<JSONItem
doc={d}
key={i}
onClickDocumentDel={onClickDocumentDel}
onClickDocumentUpdate={onClickDocumentUpdate}
/>
);
})}
</>
),
[docs, onClickDocumentDel, onClickDocumentUpdate, t, type]
);
};
43 changes: 28 additions & 15 deletions src/components/Document/search.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DocumentList } from '@/components/Document/list';
import { DocumentList, ListType } from '@/components/Document/list';
import { useForm } from '@mantine/form';
import { useQuery } from '@tanstack/react-query';
import { useMeiliClient } from '@/hooks/useMeiliClient';
Expand All @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import useDebounce from 'ahooks/lib/useDebounce';
import { Loader } from '../loader';
import { SearchForm } from './searchForm';
import { Button } from '@douyinfe/semi-ui';
import { Button, Radio, RadioGroup } from '@douyinfe/semi-ui';
import { exportToJSON } from '@/utils/file';

const emptySearchResult = {
Expand All @@ -23,6 +23,7 @@ type Props = {

export const DocSearchPage = ({ currentIndex }: Props) => {
const { t } = useTranslation('document');
const [listType, setListType] = useState<ListType>('json');
const [searchAutoRefresh, setSearchAutoRefresh] = useState<boolean>(false);
const [searchFormError, setSearchFormError] = useState<string | null>(null);
const currentInstance = useCurrentInstance();
Expand Down Expand Up @@ -138,14 +139,24 @@ export const DocSearchPage = ({ currentIndex }: Props) => {
<div className="h-px w-full bg-neutral-200 scale-x-150"></div>
<div className={`flex gap-4 items-center`}>
<p className={`font-extrabold text-2xl`}>{t('search.results.label')}</p>
<Button
type="secondary"
size="small"
onClick={() => exportToJSON(searchDocumentsQuery.data?.hits || emptySearchResult.hits, 'search-results')}
<RadioGroup
type="button"
buttonSize="middle"
defaultValue={listType}
onChange={(e) => setListType(e.target.value)}
>
{t('search.results.download')}
</Button>
<div className={`ml-auto flex gap-2 px-4 font-light text-xs text-neutral-500`}>
<Radio value={'json'}>JSON</Radio>
<Radio value={'table'}>{t('search.results.type.table')}</Radio>
<Radio value={'grid'}>{t('search.results.type.grid')}</Radio>
</RadioGroup>
<div className={`ml-auto flex items-center gap-3 px-4 font-light text-xs text-neutral-500`}>
<Button
type="secondary"
size="small"
onClick={() => exportToJSON(searchDocumentsQuery.data?.hits || emptySearchResult.hits, 'search-results')}
>
{t('search.results.download')}
</Button>
<p>
{t('search.results.total_hits', { estimatedTotalHits: searchDocumentsQuery.data?.estimatedTotalHits })}
</p>
Expand All @@ -162,6 +173,7 @@ export const DocSearchPage = ({ currentIndex }: Props) => {
</div>
) : (
<DocumentList
type={listType}
docs={searchDocumentsQuery.data?.hits.map((i) => ({
indexId: currentIndex,
content: i,
Expand All @@ -174,17 +186,18 @@ export const DocSearchPage = ({ currentIndex }: Props) => {
</div>
),
[
currentIndex,
t,
indexPrimaryKeyQuery.data,
onSearchSubmit,
searchDocumentsQuery.isFetching,
searchDocumentsQuery.data?.estimatedTotalHits,
searchDocumentsQuery.data?.hits,
searchDocumentsQuery.data?.processingTimeMs,
searchDocumentsQuery.isFetching,
searchDocumentsQuery.data?.hits,
searchDocumentsQuery.refetch,
searchForm,
searchFormError,
onSearchSubmit,
t,
listType,
currentIndex,
indexPrimaryKeyQuery.data,
]
);
};
5 changes: 5 additions & 0 deletions src/locales/en/document.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
"results": {
"label": "Results",
"download": "Download results (JSON format)",
"type": {
"json": "JSON",
"table": "Table",
"grid": "Grid"
},
"total_hits": "total {{estimatedTotalHits}} hits",
"processing_time": "in {{processingTimeMs}} ms"
}
Expand Down
5 changes: 5 additions & 0 deletions src/locales/zh/document.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
"results": {
"label": "搜索结果",
"download": "下载搜索结果集(JSON格式)",
"type": {
"json": "JSON",
"table": "表格",
"grid": "网格"
},
"total_hits": "总计返回 {{estimatedTotalHits}} 条结果",
"processing_time": "用时 {{processingTimeMs}} ms"
}
Expand Down
30 changes: 30 additions & 0 deletions src/utils/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,33 @@ export const getTimeText = (
export const stringifyJsonPretty = (json?: string | object | null) => {
return JSON.stringify(json, undefined, 2);
};

export function isValidDateTime(str: string): Date | false {
if (dayjs(str).isValid()) {
if (/^\d+$/g.test(str) && str.length < 13) {
// unix timestamp
return dayjs.unix(parseInt(str)).toDate();
}
return dayjs(str).toDate();
} else {
return false;
}
}

export function isValidHttpUrl(str: string): boolean {
try {
const url = new URL(str);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}

export function isValidImgUrl(str: string): boolean {
try {
const url = new URL(str);
return (url.protocol === 'http:' || url.protocol === 'https:') && /\.(jpg|jpeg|png|gif|webp)$/.test(url.pathname);
} catch {
return false;
}
}

0 comments on commit 444d496

Please sign in to comment.