diff --git a/frontend/src/api/schema.graphql b/frontend/src/api/schema.graphql index 7a263afe1..4daa75c02 100644 --- a/frontend/src/api/schema.graphql +++ b/frontend/src/api/schema.graphql @@ -485,6 +485,27 @@ enum BaseImageCollectionSortField { HANDLE } +":base_image_collection connection" +type BaseImageCollectionConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":base_image_collection edges" + edges: [BaseImageCollectionEdge!] +} + +":base_image_collection edge" +type BaseImageCollectionEdge { + "Cursor" + cursor: String! + + ":base_image_collection node" + node: BaseImageCollection! +} + input BaseImageCollectionFilterHandle { isNil: Boolean eq: String @@ -582,12 +603,18 @@ type BaseImageCollection implements Node { "A filter to limit the results" filter: BaseImageFilterInput - "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 - ): [BaseImage!]! + "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 + ): BaseImageConnection! } "The result of the :delete_base_image mutation" @@ -655,6 +682,27 @@ enum BaseImageSortField { URL } +":base_image connection" +type BaseImageConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":base_image edges" + edges: [BaseImageEdge!] +} + +":base_image edge" +type BaseImageEdge { + "Cursor" + cursor: String! + + ":base_image node" + node: BaseImage! +} + input BaseImageFilterUrl { isNil: Boolean eq: String @@ -761,6 +809,27 @@ type BaseImage implements Node { ): [LocalizedAttribute!] } +"A relay page info" +type PageInfo { + "When paginating backwards, are there more items?" + hasPreviousPage: Boolean! + + "When paginating forwards, are there more items?" + hasNextPage: Boolean! + + "When paginating backwards, the cursor to continue" + startCursor: String + + "When paginating forwards, the cursor to continue" + endCursor: String +} + +"A relay node" +interface Node { + "A unique identifier" + id: ID! +} + enum SortOrder { DESC DESC_NULLS_FIRST @@ -793,6 +862,27 @@ enum SystemModelPartNumberSortField { PART_NUMBER } +":system_model_part_number connection" +type SystemModelPartNumberConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":system_model_part_number edges" + edges: [SystemModelPartNumberEdge!] +} + +":system_model_part_number edge" +type SystemModelPartNumberEdge { + "Cursor" + cursor: String! + + ":system_model_part_number node" + node: SystemModelPartNumber! +} + input SystemModelPartNumberFilterPartNumber { isNil: Boolean eq: String @@ -936,6 +1026,27 @@ enum SystemModelSortField { PICTURE_URL } +":system_model connection" +type SystemModelConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":system_model edges" + edges: [SystemModelEdge!] +} + +":system_model edge" +type SystemModelEdge { + "Cursor" + cursor: String! + + ":system_model node" + node: SystemModel! +} + input SystemModelFilterPictureUrl { isNil: Boolean eq: String @@ -1050,12 +1161,18 @@ type SystemModel implements Node { "A filter to limit the results" filter: SystemModelPartNumberFilterInput - "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 - ): [SystemModelPartNumber!]! + "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 + ): SystemModelPartNumberConnection! "The Hardware type associated with the System Model" hardwareType: HardwareType @@ -1517,12 +1634,18 @@ type Device implements Node { "A filter to limit the results" filter: TagFilterInput - "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 - ): [Tag!]! + "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 + ): TagConnection! "The groups the device belongs to." deviceGroups( @@ -1547,12 +1670,18 @@ type Device implements Node { "A filter to limit the results" filter: OtaOperationFilterInput - "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 - ): [OtaOperation!]! + "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 + ): OtaOperationConnection! "The capabilities that the device can support." capabilities: [DeviceCapability!]! @@ -1840,6 +1969,27 @@ enum TagSortField { NAME } +":tag connection" +type TagConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":tag edges" + edges: [TagEdge!] +} + +":tag edge" +type TagEdge { + "Cursor" + cursor: String! + + ":tag node" + node: Tag! +} + input TagFilterName { isNil: Boolean eq: String @@ -1911,6 +2061,27 @@ enum OtaOperationSortField { UPDATED_AT } +":ota_operation connection" +type OtaOperationConnection { + "Total count on all pages" + count: Int + + "Page information" + pageInfo: PageInfo! + + ":ota_operation edges" + edges: [OtaOperationEdge!] +} + +":ota_operation edge" +type OtaOperationEdge { + "Cursor" + cursor: String! + + ":ota_operation node" + node: OtaOperation! +} + input OtaOperationFilterUpdatedAt { isNil: Boolean eq: DateTime @@ -2035,6 +2206,12 @@ input OtaOperationFilterInput { "The device targeted from the operation" device: DeviceFilterInput + + """ + The update target of an update campaing that created the managed + ota operation, if any. + """ + updateTarget: UpdateTargetFilterInput } input OtaOperationSortInput { @@ -2069,6 +2246,12 @@ type OtaOperation { "The device targeted from the operation" device: Device! + + """ + The update target of an update campaing that created the managed + ota operation, if any. + """ + updateTarget: UpdateTarget } type TenantInfo { @@ -2616,14 +2799,7 @@ type UpdateCampaign implements Node { successfulTargetCount: Int! } -interface Node { - "The ID of the object." - id: ID! -} - type RootQueryType { - node("The ID of an object." id: ID!): Node - "Returns a single update campaign." updateCampaign("The id of the record" id: ID!): UpdateCampaign @@ -2663,7 +2839,7 @@ type RootQueryType { "Returns a single device group." deviceGroup("The id of the record" id: ID!): DeviceGroup - "Returns the list of all device groups." + "Returns a list of device groups." deviceGroups( "How to sort the records in the response" sort: [DeviceGroupSortInput] @@ -2693,7 +2869,7 @@ type RootQueryType { filter: DeviceFilterInput ): [Device!]! - "Returns a hardware type." + "Returns a single hardware type." hardwareType("The id of the record" id: ID!): HardwareType "Returns a list of hardware types." @@ -2705,7 +2881,7 @@ type RootQueryType { filter: HardwareTypeFilterInput ): [HardwareType!]! - "Returns a system model." + "Returns a single system model." systemModel("The id of the record" id: ID!): SystemModel "Returns a list of system models." @@ -2715,22 +2891,49 @@ type RootQueryType { "A filter to limit the results" filter: SystemModelFilterInput - ): [SystemModel!]! + + "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 + ): SystemModelConnection + + "Retrieves a Node from its global id" + node("The Node unique identifier" id: ID!): Node! "Returns a single base image." baseImage("The id of the record" id: ID!): BaseImage - "Returns a single base image." + "Returns a single base image collection." baseImageCollection("The id of the record" id: ID!): BaseImageCollection - "Returns a list of base images." + "Returns a list of base image collections." baseImageCollections( "How to sort the records in the response" sort: [BaseImageCollectionSortInput] "A filter to limit the results" filter: BaseImageCollectionFilterInput - ): [BaseImageCollection!]! + + "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 + ): BaseImageCollectionConnection } type RootMutationType { diff --git a/frontend/src/api/schema.graphql.license b/frontend/src/api/schema.graphql.license index 7e0c9a00c..af6c52501 100644 --- a/frontend/src/api/schema.graphql.license +++ b/frontend/src/api/schema.graphql.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2021-2023 SECO Mind Srl +SPDX-FileCopyrightText: 2021-2025 SECO Mind Srl SPDX-License-Identifier: Apache-2.0 diff --git a/frontend/src/components/BaseImageCollectionsTable.tsx b/frontend/src/components/BaseImageCollectionsTable.tsx index a3f219241..d69855dc0 100644 --- a/frontend/src/components/BaseImageCollectionsTable.tsx +++ b/frontend/src/components/BaseImageCollectionsTable.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. @@ -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 { BaseImageCollectionsTable_PaginationQuery } from "api/__generated__/BaseImageCollectionsTable_PaginationQuery.graphql"; import type { BaseImageCollectionsTable_BaseImageCollectionFragment$data, BaseImageCollectionsTable_BaseImageCollectionFragment$key, @@ -32,19 +33,29 @@ import { Link, Route } from "Navigation"; // We use graphql fields below in columns configuration /* eslint-disable relay/unused-fields */ const BASE_IMAGE_COLLECTIONS_TABLE_FRAGMENT = graphql` - fragment BaseImageCollectionsTable_BaseImageCollectionFragment on BaseImageCollection - @relay(plural: true) { - id - name - handle - systemModel { - name + fragment BaseImageCollectionsTable_BaseImageCollectionFragment on RootQueryType + @refetchable(queryName: "BaseImageCollectionsTable_PaginationQuery") { + baseImageCollections(first: $first, after: $after) + @connection(key: "BaseImageCollectionsTable_baseImageCollections") { + edges { + node { + id + name + handle + systemModel { + name + } + } + } } } `; -type TableRecord = - BaseImageCollectionsTable_BaseImageCollectionFragment$data[number]; +type TableRecord = NonNullable< + NonNullable< + BaseImageCollectionsTable_BaseImageCollectionFragment$data["baseImageCollections"] + >["edges"] +>[number]["node"]; const columnHelper = createColumnHelper(); const columns = [ @@ -96,18 +107,15 @@ const BaseImageCollectionsTable = ({ className, baseImageCollectionsRef, }: Props) => { - const baseImageCollections = useFragment( - BASE_IMAGE_COLLECTIONS_TABLE_FRAGMENT, - baseImageCollectionsRef, - ); - - return ( - - ); + const { data } = usePaginationFragment< + BaseImageCollectionsTable_PaginationQuery, + BaseImageCollectionsTable_BaseImageCollectionFragment$key + >(BASE_IMAGE_COLLECTIONS_TABLE_FRAGMENT, baseImageCollectionsRef); + + const tableData = + data.baseImageCollections?.edges?.map((edge) => edge.node) ?? []; + + return
; }; export default BaseImageCollectionsTable; diff --git a/frontend/src/components/BaseImageSelect.tsx b/frontend/src/components/BaseImageSelect.tsx index 0923eac1e..5b08c1257 100644 --- a/frontend/src/components/BaseImageSelect.tsx +++ b/frontend/src/components/BaseImageSelect.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. @@ -38,15 +38,21 @@ import Stack from "components/Stack"; type SelectProps = ComponentProps; type BaseImage = NonNullable< - BaseImageSelect_getBaseImages_Query$data["baseImageCollection"] ->["baseImages"][number]; + NonNullable< + BaseImageSelect_getBaseImages_Query$data["baseImageCollection"] + >["baseImages"]["edges"] +>[number]["node"]; const GET_BASE_IMAGES_QUERY = graphql` query BaseImageSelect_getBaseImages_Query($baseImageCollectionId: ID!) { baseImageCollection(id: $baseImageCollectionId) { baseImages { - id - name + edges { + node { + id + name + } + } } } } @@ -97,13 +103,10 @@ const BaseImageSelectContent = forwardRef< return notFoundComponent; } - return ( - - ); + const baseImages = + baseImageCollection.baseImages.edges?.map((edge) => edge.node) ?? []; + + return ; }); BaseImageSelectContent.displayName = "BaseImageSelectContent"; diff --git a/frontend/src/components/BaseImagesTable.tsx b/frontend/src/components/BaseImagesTable.tsx index 853ad2da1..25fa48566 100644 --- a/frontend/src/components/BaseImagesTable.tsx +++ b/frontend/src/components/BaseImagesTable.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. @@ -20,8 +20,9 @@ import { useMemo } from "react"; import { FormattedMessage } from "react-intl"; -import { graphql, useFragment } from "react-relay/hooks"; +import { graphql, usePaginationFragment } from "react-relay/hooks"; +import type { BaseImagesTable_PaginationQuery } from "api/__generated__/BaseImagesTable_PaginationQuery.graphql"; import type { BaseImagesTable_BaseImagesFragment$data, BaseImagesTable_BaseImagesFragment$key, @@ -33,22 +34,29 @@ import { Link, Route } from "Navigation"; // We use graphql fields below in columns configuration /* eslint-disable relay/unused-fields */ const BASE_IMAGES_TABLE_FRAGMENT = graphql` - fragment BaseImagesTable_BaseImagesFragment on BaseImageCollection { + fragment BaseImagesTable_BaseImagesFragment on BaseImageCollection + @refetchable(queryName: "BaseImagesTable_PaginationQuery") { id - baseImages { - id - version - startingVersionRequirement - localizedReleaseDisplayNames { - value - languageTag + baseImages(first: $first, after: $after) + @connection(key: "BaseImagesTable_baseImages") { + edges { + node { + id + version + startingVersionRequirement + localizedReleaseDisplayNames { + value + languageTag + } + } } } } `; -type TableRecord = - BaseImagesTable_BaseImagesFragment$data["baseImages"][number]; +type TableRecord = NonNullable< + NonNullable["edges"] +>[number]["node"]; const columnHelper = createColumnHelper(); const getColumnsDefinition = (baseImageCollectionId: string) => [ @@ -110,21 +118,20 @@ const BaseImagesTable = ({ baseImageCollectionRef, hideSearch = false, }: Props) => { - const baseImageCollection = useFragment( - BASE_IMAGES_TABLE_FRAGMENT, - baseImageCollectionRef, - ); + const { data } = usePaginationFragment< + BaseImagesTable_PaginationQuery, + BaseImagesTable_BaseImagesFragment$key + >(BASE_IMAGES_TABLE_FRAGMENT, baseImageCollectionRef); - const columns = useMemo( - () => getColumnsDefinition(baseImageCollection.id), - [baseImageCollection.id], - ); + const tableData = data.baseImages?.edges?.map((edge) => edge.node) ?? []; + + const columns = useMemo(() => getColumnsDefinition(data.id), [data.id]); return (
); diff --git a/frontend/src/components/DevicesTable.tsx b/frontend/src/components/DevicesTable.tsx index 7f75e7c35..f523fb693 100644 --- a/frontend/src/components/DevicesTable.tsx +++ b/frontend/src/components/DevicesTable.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2021-2024 SECO Mind Srl + Copyright 2021-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. @@ -49,7 +49,11 @@ const DEVICES_TABLE_FRAGMENT = graphql` } } tags { - name + edges { + node { + name + } + } } } `; @@ -145,7 +149,7 @@ const columns = [ ), cell: ({ getValue }) => ( <> - {getValue().map(({ name: tag }) => ( + {getValue().edges?.map(({ node: { name: tag } }) => ( {tag} diff --git a/frontend/src/components/OperationTable.tsx b/frontend/src/components/OperationTable.tsx index 2289f54a4..eed3b9e70 100644 --- a/frontend/src/components/OperationTable.tsx +++ b/frontend/src/components/OperationTable.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 { defineMessages, FormattedDate, FormattedMessage } from "react-intl"; -import { graphql, useFragment } from "react-relay/hooks"; +import { graphql, usePaginationFragment } from "react-relay/hooks"; +import type { OperationTable_PaginationQuery } from "api/__generated__/OperationTable_PaginationQuery.graphql"; import type { OtaOperationStatus, OperationTable_otaOperations$data, @@ -33,12 +34,18 @@ import Table, { createColumnHelper } from "components/Table"; // We use graphql fields below in columns configuration /* eslint-disable relay/unused-fields */ const OPERATION_TABLE_FRAGMENT = graphql` - fragment OperationTable_otaOperations on Device { - otaOperations { - baseImageUrl - createdAt - status - updatedAt + fragment OperationTable_otaOperations on Device + @refetchable(queryName: "OperationTable_PaginationQuery") { + otaOperations(first: $first, after: $after) + @connection(key: "Device_otaOperations") { + edges { + node { + baseImageUrl + createdAt + status + updatedAt + } + } } } `; @@ -51,7 +58,9 @@ const isOtaOperationFinalStatus = ( ): status is OtaOperationFinalStatus => (otaOperationFinalStatuses as readonly string[]).includes(status); -type OtaOperation = OperationTable_otaOperations$data["otaOperations"][number]; +type OtaOperation = NonNullable< + NonNullable["edges"] +>[number]["node"]; type OtaOperationWithFinalStatus = Omit & { readonly status: OtaOperationFinalStatus; }; @@ -154,11 +163,15 @@ type OperationTableProps = { const initialSortedColumns = [{ id: "updatedAt", desc: true }]; const OperationTable = ({ className, deviceRef }: OperationTableProps) => { - const data = useFragment(OPERATION_TABLE_FRAGMENT, deviceRef); - - const otaOperations = data.otaOperations.filter( - isOtaOperationWithFinalStatus, - ); + const { data } = usePaginationFragment< + OperationTable_PaginationQuery, + OperationTable_otaOperations$key + >(OPERATION_TABLE_FRAGMENT, deviceRef); + + const otaOperations = + data.otaOperations?.edges + ?.map((edge) => edge.node) + .filter(isOtaOperationWithFinalStatus) ?? []; if (!otaOperations) { return ( diff --git a/frontend/src/components/SystemModelsTable.test.tsx b/frontend/src/components/SystemModelsTable.test.tsx index b0cc456fe..5740d2688 100644 --- a/frontend/src/components/SystemModelsTable.test.tsx +++ b/frontend/src/components/SystemModelsTable.test.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2024 SECO Mind Srl + Copyright 2024-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. @@ -29,15 +29,14 @@ import type { SystemModelsTable_getSystemModels_Query } from "api/__generated__/ import SystemModelsTable from "./SystemModelsTable"; const GET_SYSTEM_MODELS_QUERY = graphql` - query SystemModelsTable_getSystemModels_Query @relay_test_operation { - systemModels { - ...SystemModelsTable_SystemModelsFragment - } + query SystemModelsTable_getSystemModels_Query($first: Int, $after: String) + @relay_test_operation { + ...SystemModelsTable_SystemModelsFragment } `; const ComponentWithQuery = () => { - const { systemModels } = + const systemModels = useLazyLoadQuery( GET_SYSTEM_MODELS_QUERY, {}, @@ -54,16 +53,30 @@ type SystemModel = { name: string; }; partNumbers: { - id: string; - partNumber: string; - }[]; + edges: { + node: { + id: string; + partNumber: string; + }; + }[]; + }; }; const renderComponent = (systemModels: SystemModel[] = []) => { const relayEnvironment = createMockEnvironment(); - relayEnvironment.mock.queueOperationResolver((_operation) => ({ - data: { systemModels }, + + relayEnvironment.mock.queueOperationResolver(() => ({ + data: { + systemModels: { + edges: systemModels.map((model) => ({ + node: model, + })), + }, + }, })); + + relayEnvironment.mock.queuePendingOperation(GET_SYSTEM_MODELS_QUERY, {}); + renderWithProviders(, { relayEnvironment }); }; @@ -92,7 +105,9 @@ it("renders System Model data", async () => { id: "HW-ID", name: "HW name", }, - partNumbers: [{ id: "SM-PN1", partNumber: "SM-PN1" }], + partNumbers: { + edges: [{ node: { id: "SM-PN1", partNumber: "SM-PN1" } }], + }, }, ]); @@ -116,10 +131,12 @@ it("renders multiple Part Numbers separated by comma", async () => { id: "HW-ID", name: "HW name", }, - partNumbers: [ - { id: "SM-PN1", partNumber: "SM-PN1" }, - { id: "SM-PN2", partNumber: "SM-PN2" }, - ], + partNumbers: { + edges: [ + { node: { id: "SM-PN1", partNumber: "SM-PN1" } }, + { node: { id: "SM-PN2", partNumber: "SM-PN2" } }, + ], + }, }, ]); @@ -136,7 +153,9 @@ it("renders System Model data in correct columns", async () => { id: "HW-ID", name: "HW name", }, - partNumbers: [{ id: "SM-PN1", partNumber: "SM-PN1" }], + partNumbers: { + edges: [{ node: { id: "SM-PN1", partNumber: "SM-PN1" } }], + }, }, ]); diff --git a/frontend/src/components/SystemModelsTable.tsx b/frontend/src/components/SystemModelsTable.tsx index d1733373b..595347d0e 100644 --- a/frontend/src/components/SystemModelsTable.tsx +++ b/frontend/src/components/SystemModelsTable.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2021-2024 SECO Mind Srl + Copyright 2021-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. @@ -20,33 +20,50 @@ import React from "react"; import { FormattedMessage } from "react-intl"; -import { graphql, useFragment } from "react-relay/hooks"; +import { graphql, usePaginationFragment } from "react-relay/hooks"; -import Table, { createColumnHelper } from "components/Table"; -import { Link, Route } from "Navigation"; +import type { SystemModelsTable_PaginationQuery } from "../api/__generated__/SystemModelsTable_PaginationQuery.graphql"; import type { SystemModelsTable_SystemModelsFragment$key, SystemModelsTable_SystemModelsFragment$data, } from "../api/__generated__/SystemModelsTable_SystemModelsFragment.graphql"; +import Table, { createColumnHelper } from "components/Table"; +import { Link, Route } from "Navigation"; + // We use graphql fields below in columns configuration /* eslint-disable relay/unused-fields */ const SYSTEM_MODELS_TABLE_FRAGMENT = graphql` - fragment SystemModelsTable_SystemModelsFragment on SystemModel - @relay(plural: true) { - id - handle - name - hardwareType { - name - } - partNumbers { - partNumber + fragment SystemModelsTable_SystemModelsFragment on RootQueryType + @refetchable(queryName: "SystemModelsTable_PaginationQuery") { + systemModels(first: $first, after: $after) + @connection(key: "SystemModelsTable_systemModels") { + edges { + node { + id + handle + name + hardwareType { + name + } + partNumbers { + edges { + node { + partNumber + } + } + } + } + } } } `; -type TableRecord = SystemModelsTable_SystemModelsFragment$data[number]; +type TableRecord = NonNullable< + NonNullable< + SystemModelsTable_SystemModelsFragment$data["systemModels"] + >["edges"] +>[number]["node"]; const columnHelper = createColumnHelper(); const columns = [ @@ -93,7 +110,7 @@ const columns = [ /> ), cell: ({ getValue }) => - getValue().map(({ partNumber }, index) => ( + getValue().edges?.map(({ node: { partNumber } }, index) => ( {index > 0 && ", "} {partNumber} @@ -109,11 +126,14 @@ type Props = { }; const SystemModelsTable = ({ className, systemModelsRef }: Props) => { - const systemModels = useFragment( - SYSTEM_MODELS_TABLE_FRAGMENT, - systemModelsRef, - ); - return
; + const { data } = usePaginationFragment< + SystemModelsTable_PaginationQuery, + SystemModelsTable_SystemModelsFragment$key + >(SYSTEM_MODELS_TABLE_FRAGMENT, systemModelsRef); + + const tableData = data.systemModels?.edges?.map((edge) => edge.node) ?? []; + + return
; }; export default SystemModelsTable; diff --git a/frontend/src/forms/CreateBaseImageCollection.tsx b/frontend/src/forms/CreateBaseImageCollection.tsx index a69590d21..2372daeb4 100644 --- a/frontend/src/forms/CreateBaseImageCollection.tsx +++ b/frontend/src/forms/CreateBaseImageCollection.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. @@ -37,8 +37,12 @@ import type { CreateBaseImageCollection_OptionsFragment$key } from "api/__genera const CREATE_BASE_IMAGE_COLLECTION_FRAGMENT = graphql` fragment CreateBaseImageCollection_OptionsFragment on RootQueryType { systemModels { - id - name + edges { + node { + id + name + } + } } } `; @@ -160,7 +164,7 @@ const CreateBaseImageCollectionForm = ({ defaultMessage: "Select a System Model", })} - {systemModels.map((systemModel) => ( + {systemModels?.edges?.map(({ node: systemModel }) => ( diff --git a/frontend/src/forms/CreateUpdateCampaign.tsx b/frontend/src/forms/CreateUpdateCampaign.tsx index 56385df2b..ede7a490e 100644 --- a/frontend/src/forms/CreateUpdateCampaign.tsx +++ b/frontend/src/forms/CreateUpdateCampaign.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,8 +39,12 @@ import type { CreateUpdateCampaign_OptionsFragment$key } from "api/__generated__ const UPDATE_CAMPAIGN_OPTIONS_FRAGMENT = graphql` fragment CreateUpdateCampaign_OptionsFragment on RootQueryType { baseImageCollections { - id - name + edges { + node { + id + name + } + } } updateChannels { id @@ -262,14 +266,16 @@ const CreateBaseImageCollectionForm = ({ defaultMessage: "Select a Base Image Collection", })} - {baseImageCollections.map((baseImageCollection) => ( - - ))} + {baseImageCollections?.edges?.map( + ({ node: baseImageCollection }) => ( + + ), + )} diff --git a/frontend/src/forms/UpdateSystemModel.tsx b/frontend/src/forms/UpdateSystemModel.tsx index 5c7526a39..e5ea55ccf 100644 --- a/frontend/src/forms/UpdateSystemModel.tsx +++ b/frontend/src/forms/UpdateSystemModel.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2021-2024 SECO Mind Srl + Copyright 2021-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. @@ -56,7 +56,11 @@ const UPDATE_SYSTEM_MODEL_FRAGMENT = graphql` name } partNumbers { - partNumber + edges { + node { + partNumber + } + } } pictureUrl } @@ -164,8 +168,10 @@ const transformInputData = ( description, hardwareType: hardwareType?.name || "", partNumbers: - partNumbers.length > 0 - ? data.partNumbers.map(({ partNumber }) => ({ value: partNumber })) + partNumbers.edges && partNumbers.edges.length > 0 + ? data.partNumbers.edges?.map(({ node }) => ({ + value: node.partNumber, + })) ?? [] : [{ value: "" }], // default with at least one empty part number }; }; @@ -202,7 +208,7 @@ const transformOutputData = ( const partNumbers = data.partNumbers.map((pn) => pn.value); const systemModelPartNumbers = new Set( - systemModel.partNumbers.map(({ partNumber }) => partNumber), + systemModel.partNumbers.edges?.map(({ node }) => node.partNumber) || [], ); const formPartNumbers = new Set(partNumbers); const partNumbersEqual = diff --git a/frontend/src/pages/BaseImageCollection.tsx b/frontend/src/pages/BaseImageCollection.tsx index b9fe11618..8a85a87d3 100644 --- a/frontend/src/pages/BaseImageCollection.tsx +++ b/frontend/src/pages/BaseImageCollection.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. @@ -51,6 +51,8 @@ import type { BaseImageCollectionChanges } from "forms/UpdateBaseImageCollection const GET_BASE_IMAGE_COLLECTION_QUERY = graphql` query BaseImageCollection_getBaseImageCollection_Query( $baseImageCollectionId: ID! + $first: Int + $after: String ) { baseImageCollection(id: $baseImageCollectionId) { id @@ -65,6 +67,8 @@ const GET_BASE_IMAGE_COLLECTION_QUERY = graphql` const UPDATE_BASE_IMAGE_COLLECTION_MUTATION = graphql` mutation BaseImageCollection_updateBaseImageCollection_Mutation( $baseImageCollectionId: ID! + $first: Int + $after: String $input: UpdateBaseImageCollectionInput! ) { updateBaseImageCollection(id: $baseImageCollectionId, input: $input) { @@ -173,7 +177,11 @@ const BaseImageCollectionContent = ({ const handleUpdateBaseImageCollection = useCallback( (baseImageCollection: BaseImageCollectionChanges) => { updateBaseImageCollection({ - variables: { baseImageCollectionId, input: baseImageCollection }, + variables: { + baseImageCollectionId, + input: baseImageCollection, + first: 10_000, + }, onCompleted(data, errors) { if (errors) { const errorFeedback = errors @@ -322,7 +330,7 @@ const BaseImageCollectionPage = () => { const fetchBaseImageCollection = useCallback( () => getBaseImageCollection( - { baseImageCollectionId }, + { baseImageCollectionId, first: 10_000 }, { fetchPolicy: "network-only" }, ), [getBaseImageCollection, baseImageCollectionId], diff --git a/frontend/src/pages/BaseImageCollectionCreate.tsx b/frontend/src/pages/BaseImageCollectionCreate.tsx index 85dde11fa..a48a46bc6 100644 --- a/frontend/src/pages/BaseImageCollectionCreate.tsx +++ b/frontend/src/pages/BaseImageCollectionCreate.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. @@ -48,6 +48,7 @@ const CREATE_BASE_IMAGE_COLLECTION_PAGE_QUERY = graphql` query BaseImageCollectionCreate_getOptions_Query { systemModels { __typename + count } ...CreateBaseImageCollection_OptionsFragment } @@ -190,7 +191,7 @@ const BaseImageCollectionWrapper = ({ getBaseImageCollectionOptionsQuery, ); const { systemModels } = baseImageCollectionOptions; - if (systemModels.length === 0) { + if (systemModels?.count === 0) { return ; } return ( diff --git a/frontend/src/pages/BaseImageCollections.tsx b/frontend/src/pages/BaseImageCollections.tsx index 725e6630b..35699cc0e 100644 --- a/frontend/src/pages/BaseImageCollections.tsx +++ b/frontend/src/pages/BaseImageCollections.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. @@ -25,6 +25,7 @@ import { graphql, usePreloadedQuery, useQueryLoader } from "react-relay/hooks"; import type { PreloadedQuery } from "react-relay/hooks"; import type { BaseImageCollections_getBaseImageCollections_Query } from "api/__generated__/BaseImageCollections_getBaseImageCollections_Query.graphql"; + import Button from "components/Button"; import Center from "components/Center"; import BaseImageCollectionsTable from "components/BaseImageCollectionsTable"; @@ -33,10 +34,11 @@ import Spinner from "components/Spinner"; import { Link, Route } from "Navigation"; const GET_BASE_IMAGE_COLLECTIONS_QUERY = graphql` - query BaseImageCollections_getBaseImageCollections_Query { - baseImageCollections { - ...BaseImageCollectionsTable_BaseImageCollectionFragment - } + query BaseImageCollections_getBaseImageCollections_Query( + $first: Int + $after: String + ) { + ...BaseImageCollectionsTable_BaseImageCollectionFragment } `; @@ -47,7 +49,7 @@ interface BaseImageCollectionsContentProps { const BaseImageCollectionsContent = ({ getBaseImageCollectionsQuery, }: BaseImageCollectionsContentProps) => { - const { baseImageCollections } = usePreloadedQuery( + const baseImageCollections = usePreloadedQuery( GET_BASE_IMAGE_COLLECTIONS_QUERY, getBaseImageCollectionsQuery, ); @@ -85,7 +87,11 @@ const BaseImageCollectionsPage = () => { ); const fetchBaseImageCollections = useCallback( - () => getBaseImageCollections({}, { fetchPolicy: "store-and-network" }), + () => + getBaseImageCollections( + { first: 10_000 }, + { fetchPolicy: "store-and-network" }, + ), [getBaseImageCollections], ); diff --git a/frontend/src/pages/Device.tsx b/frontend/src/pages/Device.tsx index c7a98aa0f..c8b499e7c 100644 --- a/frontend/src/pages/Device.tsx +++ b/frontend/src/pages/Device.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2021-2024 SECO Mind Srl + Copyright 2021-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. @@ -36,6 +36,7 @@ import { useMutation, fetchQuery, useRelayEnvironment, + usePaginationFragment, } from "react-relay/hooks"; import type { PreloadedQuery } from "react-relay/hooks"; import type { PayloadError, Subscription } from "relay-runtime"; @@ -43,6 +44,7 @@ import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; import dayjs from "dayjs"; import _ from "lodash"; +import type { Device_PaginationQuery } from "api/__generated__/Device_PaginationQuery.graphql"; import type { Device_batteryStatus$key } from "api/__generated__/Device_batteryStatus.graphql"; import type { Device_hardwareInfo$key } from "api/__generated__/Device_hardwareInfo.graphql"; import type { Device_location$key } from "api/__generated__/Device_location.graphql"; @@ -173,14 +175,20 @@ const DEVICE_BATTERY_STATUS_FRAGMENT = graphql` `; const DEVICE_OTA_OPERATIONS_FRAGMENT = graphql` - fragment Device_otaOperations on Device { + fragment Device_otaOperations on Device + @refetchable(queryName: "Device_PaginationQuery") { id capabilities - otaOperations { - id - baseImageUrl - status - createdAt + otaOperations(first: $first, after: $after) + @connection(key: "Device_otaOperations") { + edges { + node { + id + baseImageUrl + status + createdAt + } + } } ...OperationTable_otaOperations } @@ -221,7 +229,7 @@ const DEVICE_CONNECTION_STATUS_FRAGMENT = graphql` `; const GET_DEVICE_QUERY = graphql` - query Device_getDevice_Query($id: ID!) { + query Device_getDevice_Query($id: ID!, $first: Int, $after: String) { forwarderConfig { __typename } @@ -239,8 +247,12 @@ const GET_DEVICE_QUERY = graphql` } } tags { - id - name + edges { + node { + id + name + } + } } deviceGroups { id @@ -264,7 +276,11 @@ const GET_DEVICE_QUERY = graphql` `; const GET_DEVICE_OTA_OPERATIONS_QUERY = graphql` - query Device_getDeviceOtaOperations_Query($id: ID!) { + query Device_getDeviceOtaOperations_Query( + $id: ID! + $first: Int + $after: String + ) { device(id: $id) { id online @@ -315,8 +331,12 @@ const ADD_DEVICE_TAGS_MUTATION = graphql` result { id tags { - id - name + edges { + node { + id + name + } + } } deviceGroups { id @@ -336,8 +356,12 @@ const REMOVE_DEVICE_TAGS_MUTATION = graphql` result { id tags { - id - name + edges { + node { + id + name + } + } } deviceGroups { id @@ -1011,25 +1035,29 @@ const SoftwareUpdateTab = ({ deviceRef }: SoftwareUpdateTabProps) => { const intl = useIntl(); const relayEnvironment = useRelayEnvironment(); - const device = useFragment(DEVICE_OTA_OPERATIONS_FRAGMENT, deviceRef); - const deviceId = device.id; + const { data } = usePaginationFragment< + Device_PaginationQuery, + Device_otaOperations$key + >(DEVICE_OTA_OPERATIONS_FRAGMENT, deviceRef); + + const deviceId = data.id; const [createOtaOperation, isCreatingOtaOperation] = useMutation( DEVICE_CREATE_MANUAL_OTA_OPERATION_MUTATION, ); - const otaOperations = device.otaOperations - .map((operation) => ({ ...operation })) - .sort((a, b) => { - if (a.createdAt > b.createdAt) { - return -1; - } - if (a.createdAt < b.createdAt) { - return 1; - } - return 0; - }); + const otaOperations = ( + data.otaOperations?.edges?.map(({ node }) => node) || [] + ).sort((a, b) => { + if (a.createdAt > b.createdAt) { + return -1; + } + if (a.createdAt < b.createdAt) { + return 1; + } + return 0; + }); const lastFinishedOperationIndex = otaOperations.findIndex( ({ status }) => status === "SUCCESS" || status === "FAILURE", @@ -1063,6 +1091,7 @@ const SoftwareUpdateTab = ({ deviceRef }: SoftwareUpdateTabProps) => { GET_DEVICE_OTA_OPERATIONS_QUERY, { id: deviceId, + first: 10_000, }, ).subscribe({ complete: () => { @@ -1085,7 +1114,7 @@ const SoftwareUpdateTab = ({ deviceRef }: SoftwareUpdateTabProps) => { deviceId, ]); - if (!device.capabilities.includes("SOFTWARE_UPDATES")) { + if (!data.capabilities.includes("SOFTWARE_UPDATES")) { return null; } @@ -1187,7 +1216,7 @@ const SoftwareUpdateTab = ({ deviceRef }: SoftwareUpdateTabProps) => { defaultMessage="History" /> - + ); @@ -1354,7 +1383,7 @@ const DeviceContent = ({ const deviceTags = useMemo( () => - device?.tags?.map(({ name: tag }) => ({ + device?.tags?.edges?.map(({ node: { name: tag } }) => ({ label: tag, value: tag, })) || [], @@ -1908,7 +1937,7 @@ const DevicePage = () => { ); useEffect(() => { - getDevice({ id: deviceId }); + getDevice({ id: deviceId, first: 10_000 }); refreshTags(); }, [getDevice, deviceId, refreshTags]); @@ -1927,7 +1956,7 @@ const DevicePage = () => { )} onReset={() => { - getDevice({ id: deviceId }); + getDevice({ id: deviceId, first: 10_000 }); refreshTags(); }} > diff --git a/frontend/src/pages/SystemModels.tsx b/frontend/src/pages/SystemModels.tsx index a8cce2da0..4c101666b 100644 --- a/frontend/src/pages/SystemModels.tsx +++ b/frontend/src/pages/SystemModels.tsx @@ -1,7 +1,7 @@ /* This file is part of Edgehog. - Copyright 2021-2024 SECO Mind Srl + Copyright 2021-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. @@ -34,10 +34,11 @@ import Spinner from "components/Spinner"; import { Link, Route } from "Navigation"; const GET_SYSTEM_MODELS_QUERY = graphql` - query SystemModels_getSystemModels_Query { - systemModels { - ...SystemModelsTable_SystemModelsFragment + query SystemModels_getSystemModels_Query($first: Int, $after: String) { + systemModels(first: $first, after: $after) { + count } + ...SystemModelsTable_SystemModelsFragment } `; @@ -48,7 +49,7 @@ type SystemModelsContentProps = { const SystemModelsContent = ({ getSystemModelsQuery, }: SystemModelsContentProps) => { - const { systemModels } = usePreloadedQuery( + const systemModels = usePreloadedQuery( GET_SYSTEM_MODELS_QUERY, getSystemModelsQuery, ); @@ -71,7 +72,7 @@ const SystemModelsContent = ({ - {systemModels.length === 0 ? ( + {systemModels.systemModels?.count === 0 ? ( { useQueryLoader(GET_SYSTEM_MODELS_QUERY); const fetchSystemModels = useCallback( - () => getSystemModels({}, { fetchPolicy: "store-and-network" }), + () => + getSystemModels({ first: 10_000 }, { fetchPolicy: "store-and-network" }), [getSystemModels], ); diff --git a/frontend/src/pages/UpdateCampaignCreate.tsx b/frontend/src/pages/UpdateCampaignCreate.tsx index 42a4125ee..d3edd2250 100644 --- a/frontend/src/pages/UpdateCampaignCreate.tsx +++ b/frontend/src/pages/UpdateCampaignCreate.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. @@ -49,6 +49,7 @@ const GET_CREATE_UPDATE_CAMPAIGN_OPTIONS_QUERY = graphql` query UpdateCampaignCreate_getOptions_Query { baseImageCollections { __typename + count } updateChannels { __typename @@ -214,7 +215,7 @@ const UpdateCampaignWrapper = ({ ); const { baseImageCollections, updateChannels } = updateCampaignOptions; - if (baseImageCollections.length === 0) { + if (baseImageCollections?.count === 0) { return ; } if (updateChannels.length === 0) {