From abbddc2077267b05c9101695bdab31a073292086 Mon Sep 17 00:00:00 2001 From: Omar Date: Wed, 15 Jan 2025 10:07:04 +0100 Subject: [PATCH] Enable server-side pagination for `device_groups` and `target_groups` Updated queries for `device_groups`, and `target_groups` to support server-side pagination. This change ensures data fetching is paginated on the backend while maintaining the current client-side logic. A future update will optimize the respective tables to fully leverage pagination, avoiding an immediate rewrite of the client-side implementation. Part of #779 Signed-off-by: Omar --- frontend/src/api/schema.graphql | 51 ++++++++-- frontend/src/components/DeviceGroupsTable.tsx | 42 ++++++--- .../src/components/UpdateChannelsTable.tsx | 10 +- frontend/src/forms/CreateUpdateChannel.tsx | 41 ++++---- frontend/src/forms/UpdateUpdateChannel.tsx | 94 +++++++++++-------- frontend/src/pages/DeviceGroup.tsx | 20 ++-- frontend/src/pages/DeviceGroups.tsx | 14 +-- frontend/src/pages/UpdateChannel.tsx | 31 ++++-- 8 files changed, 199 insertions(+), 104 deletions(-) diff --git a/frontend/src/api/schema.graphql b/frontend/src/api/schema.graphql index 649ab2748..4a1c4cdd8 100644 --- a/frontend/src/api/schema.graphql +++ b/frontend/src/api/schema.graphql @@ -1885,6 +1885,27 @@ enum DeviceGroupSortField { SELECTOR } +":device_group connection" +type DeviceGroupConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":device_group edges" + edges: [DeviceGroupEdge!] +} + +":device_group edge" +type DeviceGroupEdge { + "Cursor" + cursor: String! + + ":device_group node" + node: DeviceGroup! +} + input DeviceGroupFilterSelector { isNil: Boolean eq: String @@ -2598,12 +2619,18 @@ type UpdateChannel implements Node { "A filter to limit the results" filter: DeviceGroupFilterInput - "The number of records to return." - limit: Int + "The number of records to return from the beginning. Maximum 250" + first: Int - "The number of records to skip." - offset: Int - ): [DeviceGroup!]! + "Show records before the specified keyset." + before: String + + "Show records after the specified keyset." + after: String + + "The number of records to return to the end. Maximum 250" + last: Int + ): DeviceGroupConnection! } "The result of the :create_update_campaign mutation" @@ -2906,7 +2933,19 @@ type RootQueryType { "A filter to limit the results" filter: DeviceGroupFilterInput - ): [DeviceGroup!]! + + "The number of records to return from the beginning. Maximum 250" + first: Int + + "Show records before the specified keyset." + before: String + + "Show records after the specified keyset." + after: String + + "The number of records to return to the end. Maximum 250" + last: Int + ): DeviceGroupConnection """ Fetches the forwarder config, if available. diff --git a/frontend/src/components/DeviceGroupsTable.tsx b/frontend/src/components/DeviceGroupsTable.tsx index e1dd2715d..9ff4d3259 100644 --- a/frontend/src/components/DeviceGroupsTable.tsx +++ b/frontend/src/components/DeviceGroupsTable.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2022-2023 SECO Mind Srl + Copyright 2022-2025 SECO Mind Srl Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,8 +19,9 @@ */ import { FormattedMessage } from "react-intl"; -import { graphql, useFragment } from "react-relay/hooks"; +import { graphql, usePaginationFragment } from "react-relay/hooks"; +import type { DeviceGroupsTable_PaginationQuery } from "api/__generated__/DeviceGroupsTable_PaginationQuery.graphql"; import type { DeviceGroupsTable_DeviceGroupFragment$data, DeviceGroupsTable_DeviceGroupFragment$key, @@ -32,16 +33,27 @@ import { Link, Route } from "Navigation"; // We use graphql fields below in columns configuration /* eslint-disable relay/unused-fields */ const DEVICE_GROUPS_TABLE_FRAGMENT = graphql` - fragment DeviceGroupsTable_DeviceGroupFragment on DeviceGroup - @relay(plural: true) { - id - name - handle - selector + fragment DeviceGroupsTable_DeviceGroupFragment on RootQueryType + @refetchable(queryName: "DeviceGroupsTable_PaginationQuery") { + deviceGroups(first: $first, after: $after) + @connection(key: "DeviceGroupsTable_deviceGroups") { + edges { + node { + id + name + handle + selector + } + } + } } `; -type TableRecord = DeviceGroupsTable_DeviceGroupFragment$data[0]; +type TableRecord = NonNullable< + NonNullable< + DeviceGroupsTable_DeviceGroupFragment$data["deviceGroups"] + >["edges"] +>[number]["node"]; const columnHelper = createColumnHelper(); const columns = [ @@ -88,12 +100,14 @@ type Props = { }; const DeviceGroupsTable = ({ className, deviceGroupsRef }: Props) => { - const deviceGroups = useFragment( - DEVICE_GROUPS_TABLE_FRAGMENT, - deviceGroupsRef, - ); + const { data } = usePaginationFragment< + DeviceGroupsTable_PaginationQuery, + DeviceGroupsTable_DeviceGroupFragment$key + >(DEVICE_GROUPS_TABLE_FRAGMENT, deviceGroupsRef); - return ; + const tableData = data.deviceGroups?.edges?.map((edge) => edge.node) ?? []; + + return
; }; export default DeviceGroupsTable; diff --git a/frontend/src/components/UpdateChannelsTable.tsx b/frontend/src/components/UpdateChannelsTable.tsx index 600141236..bf2648c01 100644 --- a/frontend/src/components/UpdateChannelsTable.tsx +++ b/frontend/src/components/UpdateChannelsTable.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2023 SECO Mind Srl + Copyright 2023-2025 SECO Mind Srl Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -39,7 +39,11 @@ const DEVICE_GROUPS_TABLE_FRAGMENT = graphql` name handle targetGroups { - name + edges { + node { + name + } + } } } `; @@ -85,7 +89,7 @@ const columns = [ ), cell: ({ getValue }) => ( <> - {getValue().map((group) => ( + {getValue().edges?.map(({ node: group }) => ( {group.name} diff --git a/frontend/src/forms/CreateUpdateChannel.tsx b/frontend/src/forms/CreateUpdateChannel.tsx index a397877a1..77598c676 100644 --- a/frontend/src/forms/CreateUpdateChannel.tsx +++ b/frontend/src/forms/CreateUpdateChannel.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2023-2024 SECO Mind Srl + Copyright 2023-2025 SECO Mind Srl Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -23,6 +23,8 @@ import { useForm, Controller } from "react-hook-form"; import { useIntl, FormattedMessage } from "react-intl"; import { yupResolver } from "@hookform/resolvers/yup"; +import type { CreateUpdateChannel_OptionsFragment$key } from "api/__generated__/CreateUpdateChannel_OptionsFragment.graphql"; + import Button from "components/Button"; import Col from "components/Col"; import Form from "components/Form"; @@ -32,15 +34,18 @@ import Spinner from "components/Spinner"; import Stack from "components/Stack"; import { updateChannelHandleSchema, yup, messages } from "forms"; import { graphql, useFragment } from "react-relay/hooks"; -import type { CreateUpdateChannel_OptionsFragment$key } from "api/__generated__/CreateUpdateChannel_OptionsFragment.graphql"; const CREATE_UPDATE_CHANNEL_OPTIONS_FRAGMENT = graphql` fragment CreateUpdateChannel_OptionsFragment on RootQueryType { deviceGroups { - id - name - updateChannel { - name + edges { + node { + id + name + updateChannel { + name + } + } } } } @@ -179,18 +184,20 @@ const CreateUpdateChannel = ({ const targetGroupOptions = useMemo(() => { // move disabled options to the end - return [...targetGroups].sort((group1, group2) => { - const group1Disabled = isTargetGroupUsedByOtherChannel(group1); - const group2Disabled = isTargetGroupUsedByOtherChannel(group2); + return [...(targetGroups?.edges?.map((edge) => edge.node) || [])].sort( + (group1, group2) => { + const group1Disabled = isTargetGroupUsedByOtherChannel(group1); + const group2Disabled = isTargetGroupUsedByOtherChannel(group2); - if (group1Disabled === group2Disabled) { - return 0; - } - if (group1Disabled) { - return 1; - } - return -1; - }); + if (group1Disabled === group2Disabled) { + return 0; + } + if (group1Disabled) { + return 1; + } + return -1; + }, + ); }, [targetGroups]); const onFormSubmit = (data: FormData) => onSubmit(transformOutputData(data)); diff --git a/frontend/src/forms/UpdateUpdateChannel.tsx b/frontend/src/forms/UpdateUpdateChannel.tsx index 2a9a5b9dd..c3ae2b0d7 100644 --- a/frontend/src/forms/UpdateUpdateChannel.tsx +++ b/frontend/src/forms/UpdateUpdateChannel.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2023-2024 SECO Mind Srl + Copyright 2023-2025 SECO Mind Srl Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -33,10 +33,7 @@ import Spinner from "components/Spinner"; import Stack from "components/Stack"; import { updateChannelHandleSchema, yup, messages } from "forms"; -import type { - UpdateUpdateChannel_UpdateChannelFragment$key, - UpdateUpdateChannel_UpdateChannelFragment$data, -} from "api/__generated__/UpdateUpdateChannel_UpdateChannelFragment.graphql"; +import type { UpdateUpdateChannel_UpdateChannelFragment$key } from "api/__generated__/UpdateUpdateChannel_UpdateChannelFragment.graphql"; import type { UpdateUpdateChannel_OptionsFragment$key, UpdateUpdateChannel_OptionsFragment$data, @@ -48,11 +45,15 @@ const UPDATE_UPDATE_CHANNEL_FRAGMENT = graphql` name handle targetGroups { - id - name - updateChannel { - id - name + edges { + node { + id + name + updateChannel { + id + name + } + } } } } @@ -61,11 +62,15 @@ const UPDATE_UPDATE_CHANNEL_FRAGMENT = graphql` const UPDATE_UPDATE_CHANNEL_OPTIONS_FRAGMENT = graphql` fragment UpdateUpdateChannel_OptionsFragment on RootQueryType { deviceGroups { - id - name - updateChannel { - id - name + edges { + node { + id + name + updateChannel { + id + name + } + } } } } @@ -108,12 +113,16 @@ const TargetGroupsErrors = ({ errors }: { errors: unknown }) => { return null; }; -type UpdateChannel = Omit< - UpdateUpdateChannel_UpdateChannelFragment$data, - " $fragmentType" ->; -type TargetGroup = - UpdateUpdateChannel_OptionsFragment$data["deviceGroups"][number]; +type FormData = { + id: string; + name: string; + handle: string; + targetGroups: TargetGroup[]; +}; + +type TargetGroup = NonNullable< + NonNullable["edges"] +>[number]["node"]; const getTargetGroupValue = (targetGroup: TargetGroup) => targetGroup.id; @@ -135,7 +144,7 @@ const transformOutputData = ({ id: _id, targetGroups, ...rest -}: UpdateChannel): UpdateChannelData => ({ +}: FormData): UpdateChannelData => ({ ...rest, targetGroupIds: targetGroups.map((targetGroup) => targetGroup.id), }); @@ -171,13 +180,21 @@ const UpdateUpdateChannel = ({ formState: { errors, isDirty }, control, reset, - } = useForm({ + } = useForm({ mode: "onTouched", - defaultValues: updateChannel, + defaultValues: { + ...updateChannel, + targetGroups: updateChannel.targetGroups.edges?.map((edge) => edge.node), + }, resolver: yupResolver(updateChannelSchema), }); - useEffect(() => reset(updateChannel), [reset, updateChannel]); + useEffect(() => { + reset({ + ...updateChannel, + targetGroups: updateChannel.targetGroups.edges?.map((edge) => edge.node), + }); + }, [reset, updateChannel]); const intl = useIntl(); @@ -216,22 +233,25 @@ const UpdateUpdateChannel = ({ const targetGroupOptions = useMemo(() => { // move disabled options to the end - return [...targetGroups].sort((group1, group2) => { - const group1Disabled = isTargetGroupUsedByOtherChannel(group1); - const group2Disabled = isTargetGroupUsedByOtherChannel(group2); + return [...(targetGroups?.edges?.map((edge) => edge.node) || [])].sort( + (group1, group2) => { + const group1Disabled = isTargetGroupUsedByOtherChannel(group1); + const group2Disabled = isTargetGroupUsedByOtherChannel(group2); - if (group1Disabled === group2Disabled) { - return 0; - } - if (group1Disabled) { - return 1; - } - return -1; - }); + if (group1Disabled === group2Disabled) { + return 0; + } + if (group1Disabled) { + return 1; + } + return -1; + }, + ); }, [targetGroups, isTargetGroupUsedByOtherChannel]); - const onFormSubmit = (data: UpdateChannel) => + const onFormSubmit = (data: FormData) => { onSubmit(transformOutputData(data)); + }; const canSubmit = !isLoading && isDirty; const canReset = isDirty && !isLoading; diff --git a/frontend/src/pages/DeviceGroup.tsx b/frontend/src/pages/DeviceGroup.tsx index 326cbdd3c..6560f1db2 100644 --- a/frontend/src/pages/DeviceGroup.tsx +++ b/frontend/src/pages/DeviceGroup.tsx @@ -22,6 +22,7 @@ import { Suspense, useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import { ErrorBoundary } from "react-error-boundary"; import { + ConnectionHandler, graphql, useMutation, usePreloadedQuery, @@ -137,24 +138,23 @@ const DeviceGroupContent = ({ deviceGroup }: DeviceGroupContentProps) => { setShowDeleteModal(false); }, updater(store, data) { - if (!data?.deleteDeviceGroup?.result?.id) { + const deviceGroupId = data?.deleteDeviceGroup?.result?.id; + if (!deviceGroupId) { return; } const deviceGroup = store .getRootField("deleteDeviceGroup") .getLinkedRecord("result"); - const deviceGroupId = deviceGroup.getDataID(); const root = store.getRoot(); - const deviceGroups = root.getLinkedRecords("deviceGroups"); - if (deviceGroups) { - root.setLinkedRecords( - deviceGroups.filter( - (deviceGroup) => deviceGroup.getDataID() !== deviceGroupId, - ), - "deviceGroups", - ); + const connection = ConnectionHandler.getConnection( + root, + "DeviceGroupsTable_deviceGroups", + ); + + if (connection) { + ConnectionHandler.deleteNode(connection, deviceGroupId); } const devices = deviceGroup.getLinkedRecords("devices"); diff --git a/frontend/src/pages/DeviceGroups.tsx b/frontend/src/pages/DeviceGroups.tsx index 55542cbc7..f30df2e62 100644 --- a/frontend/src/pages/DeviceGroups.tsx +++ b/frontend/src/pages/DeviceGroups.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2022-2024 SECO Mind Srl + Copyright 2022-2025 SECO Mind Srl Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import { graphql, usePreloadedQuery, useQueryLoader } from "react-relay/hooks"; import type { PreloadedQuery } from "react-relay/hooks"; import type { DeviceGroups_getDeviceGroups_Query } from "api/__generated__/DeviceGroups_getDeviceGroups_Query.graphql"; + import Button from "components/Button"; import Center from "components/Center"; import DeviceGroupsTable from "components/DeviceGroupsTable"; @@ -33,10 +34,8 @@ import Spinner from "components/Spinner"; import { Link, Route } from "Navigation"; const GET_DEVICE_GROUPS_QUERY = graphql` - query DeviceGroups_getDeviceGroups_Query { - deviceGroups { - ...DeviceGroupsTable_DeviceGroupFragment - } + query DeviceGroups_getDeviceGroups_Query($first: Int, $after: String) { + ...DeviceGroupsTable_DeviceGroupFragment } `; @@ -47,7 +46,7 @@ interface DeviceGroupsContentProps { const DeviceGroupsContent = ({ getDeviceGroupsQuery, }: DeviceGroupsContentProps) => { - const { deviceGroups } = usePreloadedQuery( + const deviceGroups = usePreloadedQuery( GET_DEVICE_GROUPS_QUERY, getDeviceGroupsQuery, ); @@ -81,7 +80,8 @@ const DevicesPage = () => { useQueryLoader(GET_DEVICE_GROUPS_QUERY); const fetchDeviceGroups = useCallback( - () => getDeviceGroups({}, { fetchPolicy: "store-and-network" }), + () => + getDeviceGroups({ first: 10_000 }, { fetchPolicy: "store-and-network" }), [getDeviceGroups], ); diff --git a/frontend/src/pages/UpdateChannel.tsx b/frontend/src/pages/UpdateChannel.tsx index 0c1c0d32e..a450a8796 100644 --- a/frontend/src/pages/UpdateChannel.tsx +++ b/frontend/src/pages/UpdateChannel.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2023-2024 SECO Mind Srl + Copyright 2023-2025 SECO Mind Srl Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -81,8 +81,12 @@ const UPDATE_UPDATE_CHANNEL_MUTATION = graphql` handle ...UpdateUpdateChannel_UpdateChannelFragment targetGroups { - id - name + edges { + node { + id + name + } + } } } } @@ -95,7 +99,11 @@ const DELETE_UPDATE_CHANNEL_MUTATION = graphql` result { id targetGroups { - id + edges { + node { + id + } + } } } } @@ -159,13 +167,14 @@ const UpdateChannelContent = ({ setShowDeleteModal(false); }, updater(store, data) { - if (!data?.deleteUpdateChannel?.result?.id) { + const updateChannelId = data?.deleteUpdateChannel?.result?.id; + if (!updateChannelId) { return; } const updateChannel = store .getRootField("deleteUpdateChannel") .getLinkedRecord("result"); - const updateChannelId = updateChannel.getDataID(); + const root = store.getRoot(); const updateChannels = root.getLinkedRecords("updateChannels"); @@ -180,12 +189,14 @@ const UpdateChannelContent = ({ const targetGroupIds = new Set( updateChannel - .getLinkedRecords("targetGroups") - .map((targetGroup) => targetGroup.getDataID()), + .getLinkedRecord("targetGroups") + ?.getLinkedRecords("edges") + ?.map((edge) => edge.getLinkedRecord("node").getDataID()) || [], ); - const deviceGroups = root.getLinkedRecords("deviceGroups"); + + const deviceGroups = root.getLinkedRecord("deviceGroups"); if (deviceGroups && targetGroupIds.size) { - deviceGroups.forEach((deviceGroup) => { + deviceGroups?.getLinkedRecords("edges")?.forEach((deviceGroup) => { if (targetGroupIds.has(deviceGroup.getDataID())) { deviceGroup.invalidateRecord(); }