feat: request pagination for resource(#1425)

* feat: add support for resource page on frontend

* [WIP]feat: add backend support for limit and offset search

* feat: add reducer to add resource

* support fetch all resource when first search

* beautify the fetch ui

* restore file

* feat: add all resource before clear resource

* eslint

* i18n

* chore:change the nane

* chore: change the name of param

* eslint

* feat: setIsComplete to true when first loading resource fully

* fix the bug of fetch

* feat change finally to then

* feat: add await and async to clear and search

* feat: return all resource when fetch

* chore: change variable name

* Update web/src/pages/ResourcesDashboard.tsx

Co-authored-by: boojack <stevenlgtm@gmail.com>

* fix missing const value

---------

Co-authored-by: boojack <stevenlgtm@gmail.com>
This commit is contained in:
CorrectRoadH 2023-04-01 16:51:20 +08:00 committed by GitHub
parent fab3dac70a
commit 424f10e180
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 147 additions and 30 deletions

View File

@ -45,6 +45,10 @@ type ResourceFind struct {
Filename *string `json:"filename"`
MemoID *int
GetBlob bool
// Pagination
Limit *int
Offset *int
}
type ResourcePatch struct {

View File

@ -256,6 +256,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
resourceFind := &api.ResourceFind{
CreatorID: &userID,
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
resourceFind.Limit = &limit
}
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
resourceFind.Offset = &offset
}
list, err := s.Store.FindResourceList(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)

View File

@ -312,6 +312,13 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.
GROUP BY resource.id
ORDER BY resource.id DESC
`, strings.Join(fields, ", "), strings.Join(where, " AND "))
if find.Limit != nil {
query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit)
if find.Offset != nil {
query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset)
}
}
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)

View File

@ -164,6 +164,17 @@ export function getResourceList() {
return axios.get<ResponseObject<Resource[]>>("/api/resource");
}
export function getResourceListWithLimit(resourceFind?: ResourceFind) {
const queryList = [];
if (resourceFind?.offset) {
queryList.push(`offset=${resourceFind.offset}`);
}
if (resourceFind?.limit) {
queryList.push(`limit=${resourceFind.limit}`);
}
return axios.get<ResponseObject<Resource[]>>(`/api/resource?${queryList.join("&")}`);
}
export function createResource(resourceCreate: ResourceCreate) {
return axios.post<ResponseObject<Resource>>("/api/resource", resourceCreate);
}

View File

@ -245,6 +245,8 @@
"message": {
"no-memos": "no memos 🌃",
"memos-ready": "all memos are ready 🎉",
"no-resource": "no resource 🌃",
"resource-ready": "all resource are ready 🎉",
"restored-successfully": "Restored successfully",
"memo-updated-datetime": "Memo created datetime changed.",
"invalid-created-datetime": "Invalid created datetime.",

View File

@ -218,6 +218,8 @@
"message": {
"no-memos": "沒有 Memo 了 🌃",
"memos-ready": "所有 Memo 已就緒 🎉",
"no-resource": "沒有 Resource 了 🌃",
"memos-resource": "所有 Resource 已就緒 🎉",
"restored-successfully": "還原成功",
"memo-updated-datetime": "Memo 建立日期時間已更改。",
"invalid-created-datetime": "建立的日期時間無效。",

View File

@ -221,6 +221,8 @@
"message": {
"no-memos": "没有 Memo 了 🌃",
"memos-ready": "所有 Memo 已就绪 🎉",
"no-resource": "没有 Resource 了 🌃",
"resource-ready": "所有 Resource 已就绪 🎉",
"restored-successfully": "恢复成功",
"memo-updated-datetime": "Memo 创建日期时间已更改。",
"invalid-created-datetime": "创建的日期时间无效。",

View File

@ -16,6 +16,7 @@ import { showCommonDialog } from "@/components/Dialog/CommonDialog";
import showChangeResourceFilenameDialog from "@/components/ChangeResourceFilenameDialog";
import showPreviewImageDialog from "@/components/PreviewImageDialog";
import showCreateResourceDialog from "@/components/CreateResourceDialog";
import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts";
const ResourcesDashboard = () => {
const { t } = useTranslation();
@ -26,16 +27,20 @@ const ResourcesDashboard = () => {
const [listStyle, setListStyle] = useState<"GRID" | "TABLE">("GRID");
const [queryText, setQueryText] = useState<string>("");
const [dragActive, setDragActive] = useState(false);
const [isComplete, setIsComplete] = useState<boolean>(false);
useEffect(() => {
resourceStore
.fetchResourceList()
.fetchResourceListWithLimit(DEFAULT_MEMO_LIMIT)
.then((fetchedResource) => {
if (fetchedResource.length < DEFAULT_MEMO_LIMIT) {
setIsComplete(true);
}
loadingState.setFinish();
})
.catch((error) => {
console.error(error);
toast.error(error.response.data.message);
})
.finally(() => {
loadingState.setFinish();
});
}, []);
@ -47,29 +52,31 @@ const ResourcesDashboard = () => {
setSelectedList(selectedList.filter((resId) => resId !== resourceId));
};
const handleDeleteUnusedResourcesBtnClick = () => {
const handleDeleteUnusedResourcesBtnClick = async () => {
let warningText = t("resources.warning-text-unused");
const unusedResources = resources.filter((resource) => {
if (resource.linkedMemoAmount === 0) {
warningText = warningText + `\n- ${resource.filename}`;
return true;
}
return false;
});
if (unusedResources.length === 0) {
toast.success(t("resources.no-unused-resources"));
return;
}
showCommonDialog({
title: t("resources.delete-resource"),
content: warningText,
style: "warning",
dialogName: "delete-unused-resources",
onConfirm: async () => {
for (const resource of unusedResources) {
await resourceStore.deleteResourceById(resource.id);
await loadAllResources((allResources: Resource[]) => {
const unusedResources = allResources.filter((resource) => {
if (resource.linkedMemoAmount === 0) {
warningText = warningText + `\n- ${resource.filename}`;
return true;
}
},
return false;
});
if (unusedResources.length === 0) {
toast.success(t("resources.no-unused-resources"));
return;
}
showCommonDialog({
title: t("resources.delete-resource"),
content: warningText,
style: "warning",
dialogName: "delete-unused-resources",
onConfirm: async () => {
for (const resource of unusedResources) {
await resourceStore.deleteResourceById(resource.id);
}
},
});
});
};
@ -136,9 +143,44 @@ const ResourcesDashboard = () => {
toast.success(t("message.succeed-copy-resource-link"));
};
const handleSearchResourceInputChange = (query: string) => {
setQueryText(query);
setSelectedList([]);
const handleFetchMoreResourceBtnClick = async () => {
try {
const fetchedResource = await resourceStore.fetchResourceListWithLimit(DEFAULT_MEMO_LIMIT, resources.length);
if (fetchedResource.length < DEFAULT_MEMO_LIMIT) {
setIsComplete(true);
} else {
setIsComplete(false);
}
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message);
}
};
const loadAllResources = async (resolve: (allResources: Resource[]) => void) => {
if (!isComplete) {
loadingState.setLoading();
try {
const allResources = await resourceStore.fetchResourceList();
loadingState.setFinish();
setIsComplete(true);
resolve(allResources);
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message);
}
} else {
resolve(resources);
}
};
const handleSearchResourceInputChange = async (query: string) => {
// to prevent first tiger when page is loaded
if (query === queryText) return;
await loadAllResources(() => {
setQueryText(query);
setSelectedList([]);
});
};
const resourceList = useMemo(
@ -304,6 +346,23 @@ const ResourcesDashboard = () => {
</div>
)}
</div>
<div className="flex flex-col justify-start items-center w-full my-6">
<p className="text-sm text-gray-400 italic">
{isComplete ? (
resources.length === 0 ? (
t("message.no-resource")
) : (
t("message.resource-ready")
)
) : (
<>
<span className="cursor-pointer hover:text-green-600" onClick={handleFetchMoreResourceBtnClick}>
{t("memo-list.fetch-more")}
</span>
</>
)}
</p>
</div>
</div>
</div>
</section>

View File

@ -1,6 +1,7 @@
import store, { useAppSelector } from "../";
import { patchResource, setResources, deleteResource } from "../reducer/resource";
import { patchResource, setResources, deleteResource, upsertResources } from "../reducer/resource";
import * as api from "../../helpers/api";
import { DEFAULT_MEMO_LIMIT } from "../../helpers/consts";
const MAX_FILE_SIZE = 32 << 20;
@ -26,6 +27,16 @@ export const useResourceStore = () => {
store.dispatch(setResources(resourceList));
return resourceList;
},
async fetchResourceListWithLimit(limit = DEFAULT_MEMO_LIMIT, offset?: number): Promise<Resource[]> {
const resourceFind: ResourceFind = {
limit,
offset,
};
const { data } = (await api.getResourceListWithLimit(resourceFind)).data;
const resourceList = data.map((m) => convertResponseModelResource(m));
store.dispatch(upsertResources(resourceList));
return resourceList;
},
async createResource(resourceCreate: ResourceCreate): Promise<Resource> {
const { data } = (await api.createResource(resourceCreate)).data;
const resource = convertResponseModelResource(data);

View File

@ -1,4 +1,5 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { uniqBy } from "lodash-es";
interface State {
resources: Resource[];
@ -16,6 +17,12 @@ const resourceSlice = createSlice({
resources: action.payload,
};
},
upsertResources: (state, action: PayloadAction<Resource[]>) => {
return {
...state,
resources: uniqBy([...state.resources, ...action.payload], "id"),
};
},
patchResource: (state, action: PayloadAction<Partial<Resource>>) => {
return {
...state,
@ -42,6 +49,6 @@ const resourceSlice = createSlice({
},
});
export const { setResources, patchResource, deleteResource } = resourceSlice.actions;
export const { setResources, upsertResources, patchResource, deleteResource } = resourceSlice.actions;
export default resourceSlice.reducer;

View File

@ -24,3 +24,8 @@ interface ResourcePatch {
id: ResourceId;
filename?: string;
}
interface ResourceFind {
offset?: number;
limit?: number;
}