Skip to content

Commit adb91f4

Browse files
authored
feat: add device group subscription (#1198)
- add GraphQL subscription for device group creation and updates - update tests to verify subscription functionality Signed-off-by: Osman Hadzic <osman.hadzic@secomind.com>
1 parent f806909 commit adb91f4

5 files changed

Lines changed: 305 additions & 3 deletions

File tree

backend/lib/edgehog/groups/device_group/device_group.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ defmodule Edgehog.Groups.DeviceGroup do
3333
type :device_group
3434

3535
# TODO: paginate `device` relationship with relay
36+
37+
subscriptions do
38+
pubsub EdgehogWeb.Endpoint
39+
40+
subscribe :device_group do
41+
action_types [:create, :update, :destroy]
42+
end
43+
end
3644
end
3745

3846
actions do
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#
2+
# This file is part of Edgehog.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# SPDX-License-Identifier: Apache-2.0
19+
#
20+
21+
defmodule EdgehogWeb.Schema.Subscriptions.DeviceGroup.DeviceGroupSubscriptionsTest do
22+
@moduledoc false
23+
use EdgehogWeb.SubsCase
24+
25+
import Edgehog.GroupsFixtures
26+
27+
describe "DeviceGroup subscription" do
28+
test "receive data on device group creation", %{socket: socket, tenant: tenant} do
29+
subscribe(socket)
30+
31+
device_group = device_group_fixture(tenant: tenant)
32+
33+
assert_push "subscription:data", push
34+
35+
assert_created "deviceGroup", device_group_data, push
36+
37+
assert device_group_data["id"] == AshGraphql.Resource.encode_relay_id(device_group)
38+
assert device_group_data["name"] == device_group.name
39+
assert device_group_data["handle"] == device_group.handle
40+
assert device_group_data["selector"] == device_group.selector
41+
end
42+
43+
test "receive data on device group update", %{socket: socket, tenant: tenant} do
44+
device_group = device_group_fixture(tenant: tenant)
45+
subscribe(socket)
46+
47+
new_name = "new_name_#{System.unique_integer()}"
48+
49+
device_group
50+
|> Ash.Changeset.for_update(:update, %{name: new_name})
51+
|> Ash.update!(tenant: tenant)
52+
53+
assert_push "subscription:data", push
54+
assert_updated "deviceGroup", device_group_data, push
55+
56+
assert device_group_data["id"] == AshGraphql.Resource.encode_relay_id(device_group)
57+
assert device_group_data["name"] == new_name
58+
assert device_group_data["handle"] == device_group.handle
59+
assert device_group_data["selector"] == device_group.selector
60+
end
61+
62+
test "receive data on device group destroy", %{socket: socket, tenant: tenant} do
63+
device_group = device_group_fixture(tenant: tenant)
64+
subscribe(socket)
65+
66+
Ash.destroy!(device_group, tenant: tenant)
67+
assert_push "subscription:data", push
68+
assert_destroyed("deviceGroup", device_group_id, push)
69+
70+
assert device_group_id == AshGraphql.Resource.encode_relay_id(device_group)
71+
end
72+
end
73+
74+
defp subscribe(socket, opts \\ []) do
75+
default_sub_gql = """
76+
subscription {
77+
deviceGroup {
78+
created {
79+
id
80+
name
81+
handle
82+
selector
83+
}
84+
updated {
85+
id
86+
name
87+
handle
88+
selector
89+
}
90+
destroyed
91+
}
92+
}
93+
"""
94+
95+
sub_gql = Keyword.get(opts, :query, default_sub_gql)
96+
97+
ref = push_doc(socket, sub_gql)
98+
assert_reply ref, :ok, %{subscriptionId: subscription_id}
99+
100+
subscription_id
101+
end
102+
end

backend/test/support/assertions.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,18 @@ defmodule Edgehog.Assertions do
5151
} = var!(unquote(push))
5252
end
5353
end
54+
55+
defmacro assert_destroyed(query, destroyed_id, push) do
56+
quote do
57+
assert %{
58+
result: %{
59+
data: %{
60+
unquote(query) => %{
61+
"destroyed" => var!(unquote(destroyed_id))
62+
}
63+
}
64+
}
65+
} = var!(unquote(push))
66+
end
67+
end
5468
end

frontend/src/api/schema.graphql

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,12 @@ type LocalizedAttribute {
900900
languageTag: String!
901901
}
902902

903+
type base_image_collection_result {
904+
created: BaseImageCollection
905+
updated: BaseImageCollection
906+
destroyed: ID
907+
}
908+
903909
"The result of the :delete_base_image_collection mutation"
904910
type DeleteBaseImageCollectionResult {
905911
"The record that was successfully deleted"
@@ -1092,6 +1098,12 @@ type BaseImageCollection implements Node {
10921098
): BaseImageConnection!
10931099
}
10941100

1101+
type base_image_result {
1102+
created: BaseImage
1103+
updated: BaseImage
1104+
destroyed: ID
1105+
}
1106+
10951107
"The result of the :delete_base_image mutation"
10961108
type DeleteBaseImageResult {
10971109
"The record that was successfully deleted"
@@ -4716,6 +4728,12 @@ type ForwarderConfig {
47164728
secureSessions: Boolean!
47174729
}
47184730

4731+
type device_group_result {
4732+
created: DeviceGroup
4733+
updated: DeviceGroup
4734+
destroyed: ID
4735+
}
4736+
47194737
"The result of the :delete_device_group mutation"
47204738
type DeleteDeviceGroupResult {
47214739
"The record that was successfully deleted"
@@ -6411,10 +6429,22 @@ type RootMutationType {
64116429
}
64126430

64136431
type RootSubscriptionType {
6432+
deviceGroup(
6433+
"A filter to limit the results"
6434+
filter: DeviceGroupFilterInput
6435+
): device_group_result
64146436
deviceChanged(
64156437
"A filter to limit the results"
64166438
filter: DeviceFilterInput
64176439
): device_changed_result
6440+
baseImage(
6441+
"A filter to limit the results"
6442+
filter: BaseImageFilterInput
6443+
): base_image_result
6444+
baseImageCollection(
6445+
"A filter to limit the results"
6446+
filter: BaseImageCollectionFilterInput
6447+
): base_image_collection_result
64186448
}
64196449

64206450
"""

frontend/src/components/DeviceGroupsTable.tsx

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,15 @@
2020

2121
import { useCallback, useEffect, useMemo, useState } from "react";
2222
import { FormattedMessage } from "react-intl";
23-
import { graphql, usePaginationFragment } from "react-relay/hooks";
23+
import {
24+
graphql,
25+
usePaginationFragment,
26+
useSubscription,
27+
} from "react-relay/hooks";
2428
import _ from "lodash";
2529

30+
import { ConnectionHandler } from "relay-runtime";
31+
2632
import type { DeviceGroupsTable_PaginationQuery } from "@/api/__generated__/DeviceGroupsTable_PaginationQuery.graphql";
2733
import type {
2834
DeviceGroupsTable_DeviceGroupFragment$data,
@@ -54,6 +60,40 @@ const DEVICE_GROUPS_TABLE_FRAGMENT = graphql`
5460
}
5561
`;
5662

63+
const DEVICE_GROUP_CREATED_SUBSCRIPTION = graphql`
64+
subscription DeviceGroupsTable_deviceGroupEvent_created_Subscription {
65+
deviceGroup {
66+
created {
67+
id
68+
name
69+
handle
70+
selector
71+
}
72+
}
73+
}
74+
`;
75+
76+
const DEVICE_GROUP_UPDATED_SUBSCRIPTION = graphql`
77+
subscription DeviceGroupsTable_deviceGroupEvent_updated_Subscription {
78+
deviceGroup {
79+
updated {
80+
id
81+
name
82+
handle
83+
selector
84+
}
85+
}
86+
}
87+
`;
88+
89+
const DEVICE_GROUP_DESTROYED_SUBSCRIPTION = graphql`
90+
subscription DeviceGroupsTable_deviceGroupEvent_destroyed_Subscription {
91+
deviceGroup {
92+
destroyed
93+
}
94+
}
95+
`;
96+
5797
type TableRecord = NonNullable<
5898
NonNullable<
5999
DeviceGroupsTable_DeviceGroupFragment$data["deviceGroups"]
@@ -122,6 +162,114 @@ const DeviceGroupsTable = ({
122162
>(DEVICE_GROUPS_TABLE_FRAGMENT, deviceGroupsRef);
123163
const [searchText, setSearchText] = useState<string | null>(null);
124164

165+
const normalizedSearchText = useMemo(
166+
() => (searchText ?? "").trim(),
167+
[searchText],
168+
);
169+
170+
const connectionFilter = useMemo(() => {
171+
if (normalizedSearchText === "") return undefined;
172+
173+
return {
174+
or: [
175+
{ name: { ilike: `%${normalizedSearchText}%` } },
176+
{ handle: { ilike: `%${normalizedSearchText}%` } },
177+
{ selector: { ilike: `%${normalizedSearchText}%` } },
178+
],
179+
};
180+
}, [normalizedSearchText]);
181+
182+
useSubscription(
183+
useMemo(
184+
() => ({
185+
subscription: DEVICE_GROUP_CREATED_SUBSCRIPTION,
186+
variables: {},
187+
updater: (store) => {
188+
const groupEvent = store.getRootField("deviceGroup");
189+
const newGroup = groupEvent?.getLinkedRecord("created");
190+
if (!newGroup) return;
191+
192+
if (normalizedSearchText !== "") {
193+
const search = normalizedSearchText.toLowerCase();
194+
const name = String(newGroup.getValue("name") ?? "").toLowerCase();
195+
const handle = String(
196+
newGroup.getValue("handle") ?? "",
197+
).toLowerCase();
198+
const selector = String(
199+
newGroup.getValue("selector") ?? "",
200+
).toLowerCase();
201+
202+
if (
203+
!name.includes(search) &&
204+
!handle.includes(search) &&
205+
!selector.includes(search)
206+
) {
207+
return;
208+
}
209+
}
210+
211+
const connection = ConnectionHandler.getConnection(
212+
store.getRoot(),
213+
"DeviceGroupsTable_deviceGroups",
214+
connectionFilter ? { filter: connectionFilter } : undefined,
215+
);
216+
if (!connection) return;
217+
218+
const newGroupId = newGroup.getDataID();
219+
const edges = connection.getLinkedRecords("edges") ?? [];
220+
const alreadyPresent = edges.some(
221+
(edge) => edge.getLinkedRecord("node")?.getDataID() === newGroupId,
222+
);
223+
if (alreadyPresent) return;
224+
225+
const edge = ConnectionHandler.createEdge(
226+
store,
227+
connection,
228+
newGroup,
229+
"DeviceGroupEdge",
230+
);
231+
232+
ConnectionHandler.insertEdgeBefore(connection, edge);
233+
},
234+
}),
235+
[connectionFilter, normalizedSearchText],
236+
),
237+
);
238+
239+
useSubscription(
240+
useMemo(
241+
() => ({
242+
subscription: DEVICE_GROUP_UPDATED_SUBSCRIPTION,
243+
variables: {},
244+
}),
245+
[],
246+
),
247+
);
248+
249+
useSubscription(
250+
useMemo(
251+
() => ({
252+
subscription: DEVICE_GROUP_DESTROYED_SUBSCRIPTION,
253+
variables: {},
254+
updater: (store) => {
255+
const groupEvent = store.getRootField("deviceGroup");
256+
const destroyedId = groupEvent?.getValue("destroyed");
257+
if (!destroyedId || typeof destroyedId !== "string") return;
258+
259+
const connection = ConnectionHandler.getConnection(
260+
store.getRoot(),
261+
"DeviceGroupsTable_deviceGroups",
262+
connectionFilter ? { filter: connectionFilter } : undefined,
263+
);
264+
if (!connection) return;
265+
266+
ConnectionHandler.deleteNode(connection, destroyedId);
267+
},
268+
}),
269+
[connectionFilter],
270+
),
271+
);
272+
125273
const debounceRefetch = useMemo(
126274
() =>
127275
_.debounce((text: string) => {
@@ -153,9 +301,9 @@ const DeviceGroupsTable = ({
153301

154302
useEffect(() => {
155303
if (searchText !== null) {
156-
debounceRefetch(searchText);
304+
debounceRefetch(normalizedSearchText);
157305
}
158-
}, [debounceRefetch, searchText]);
306+
}, [debounceRefetch, normalizedSearchText, searchText]);
159307

160308
const loadNextDeviceGroups = useCallback(() => {
161309
if (hasNext && !isLoadingNext) {

0 commit comments

Comments
 (0)