Skip to content

Commit dffc632

Browse files
ivictborCopilot
andcommitted
feat: add websocket topic management and show page update functionality https://web.tracklify.com/project/2b7ZVgE5/AdminForth/1503/b5tFOucv/when-user-goes-to-show-page-su
Co-authored-by: Copilot <copilot@github.com>
1 parent a90f3d7 commit dffc632

6 files changed

Lines changed: 80 additions & 2 deletions

File tree

adminforth/dataConnectors/baseConnector.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ type AdminForthFilterNormalizationResult = {
2222
normalizedFilters?: AdminForthFilterInput;
2323
};
2424

25+
async function publishShowPageUpdate(resource: AdminForthResource, recordId: string, updates: Record<string, any>) {
26+
await global.adminforth.websocket.publish(`/showPage/${resource.resourceId}/${String(recordId)}`, {
27+
resourceId: resource.resourceId,
28+
recordId,
29+
updates,
30+
});
31+
}
32+
2533

2634
export default class AdminForthBaseConnector implements IAdminForthDataSourceConnectorBase {
2735

@@ -578,6 +586,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
578586
afLogger.trace(`🪲✏️ updating record id:${recordId}, values: ${JSON.stringify(recordWithOriginalValues)}`);
579587

580588
await this.updateRecordOriginalValues({ resource, recordId, newValues: recordWithOriginalValues });
589+
await publishShowPageUpdate(resource, recordId, newValues);
581590

582591
return { ok: true };
583592
}

adminforth/documentation/docs/tutorial/03-Customization/16-websocket.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ On server you can publish data to the topic by calling
2020
admin.websocket.publish('/topic-name', {some: 'data'});
2121
```
2222

23+
If you need to unsubscribe from a whole family of topics, for example when route changes can leave old dynamic subscriptions behind, you can use `unsubscribeByPrefix`:
24+
25+
```javascript
26+
import websocket from '@/websocket';
27+
28+
websocket.unsubscribeByPrefix('/topic-name/');
29+
```
30+
31+
This will unsubscribe from all topics whose name starts with the prefix.
32+
33+
It is useful for dynamic topics like `/topic-name/<resourceId>/<recordId>` where a stale subscription can update the wrong page if component unmount does not happen exactly when you expect.
34+
2335
Let's consider a real-world example.
2436

2537
## Usage example

adminforth/documentation/docs/tutorial/08-Plugins/26-agent.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
title: Agent
33
slug: /tutorial/Plugins/agent
4+
sidebar_position: 0
45
---
56

67
> TODO: this plugin tutorial is in progress, some information might be missing, we are actively working on it now. If you have any questions regarding this plugin, please reach out to us in GitHub issues

adminforth/modules/operationalResource.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export default class OperationalResource implements IOperationalResource {
9191
}
9292

9393
async update(primaryKey: any, record: any): Promise<any> {
94+
if (Object.keys(record).length === 0) {
95+
return { ok: true };
96+
}
97+
9498
return await this.dataConnector.updateRecord({
9599
resource: this.resourceConfig,
96100
recordId: primaryKey,

adminforth/spa/src/views/ShowView.vue

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ import BreadcrumbsWithButtons from '@/components/BreadcrumbsWithButtons.vue';
181181
import { useCoreStore } from '@/stores/core';
182182
import { getCustomComponent, checkAcessByAllowedActions, initThreeDotsDropdown, formatComponent, executeCustomAction } from '@/utils';
183183
import { IconPenSolid, IconTrashBinSolid, IconPlusOutline } from '@iconify-prerendered/vue-flowbite';
184-
import { onMounted, ref, computed } from 'vue';
184+
import { onMounted, onUnmounted, ref, computed } from 'vue';
185185
import { useRoute,useRouter } from 'vue-router';
186186
import {callAdminForthApi} from '@/utils';
187187
import { showSuccesTost, showErrorTost } from '@/composables/useFrontendApi';
@@ -193,15 +193,18 @@ import { getIcon } from '@/utils';
193193
import { type AdminForthComponentDeclarationFull, type AdminForthResourceColumnCommon, type FieldGroup } from '@/types/Common.js';
194194
import CallActionWrapper from '@/components/CallActionWrapper.vue'
195195
import { Spinner } from '@/afcl';
196+
import websocket from '@/websocket';
196197
197198
const route = useRoute();
198199
const router = useRouter();
199200
const loading = ref(true);
200201
const { t } = useI18n();
201202
const { confirm, alert, show } = useAdminforth();
202203
const coreStore = useCoreStore();
204+
const SHOW_PAGE_TOPIC_PREFIX = '/showPage/';
203205
204206
const actionLoadingStates = ref<Record<string, boolean>>({});
207+
const subscribedShowPageTopic = ref<string | null>(null);
205208
206209
const customActions = computed(() => {
207210
return coreStore.resource?.options?.actions?.filter((a: any) => a.showIn?.showThreeDotsMenu) || [];
@@ -219,7 +222,34 @@ const skeletonRowsCount = computed(() => {
219222
return finalCount > 0 ? finalCount : 10;
220223
});
221224
225+
const showPageTopic = computed(() => {
226+
return `${SHOW_PAGE_TOPIC_PREFIX}${String(route.params.resourceId)}/${String(route.params.primaryKey)}`;
227+
});
228+
229+
function applyShowPageUpdates(data: { resourceId: string; recordId: string; updates: Record<string, any> }) {
230+
if (String(data.resourceId) !== String(route.params.resourceId)) {
231+
return;
232+
}
233+
if (String(data.recordId) !== String(route.params.primaryKey)) {
234+
return;
235+
}
236+
if (!coreStore.record) {
237+
return;
238+
}
239+
240+
Object.entries(data.updates).forEach(([attribute, value]) => {
241+
coreStore.record[attribute] = value;
242+
});
243+
}
244+
245+
function subscribeToShowPageUpdates() {
246+
websocket.unsubscribeByPrefix(SHOW_PAGE_TOPIC_PREFIX);
247+
subscribedShowPageTopic.value = showPageTopic.value;
248+
websocket.subscribe(subscribedShowPageTopic.value, applyShowPageUpdates);
249+
}
250+
222251
onMounted(async () => {
252+
subscribeToShowPageUpdates();
223253
loading.value = true;
224254
await coreStore.fetchResourceFull({
225255
resourceId: route.params.resourceId as string,
@@ -236,6 +266,13 @@ onMounted(async () => {
236266
loading.value = false;
237267
});
238268
269+
onUnmounted(() => {
270+
if (!subscribedShowPageTopic.value) {
271+
return;
272+
}
273+
websocket.unsubscribe(subscribedShowPageTopic.value);
274+
});
275+
239276
const groups = computed(() => {
240277
let fieldGroupType;
241278
if (coreStore.resource) {

adminforth/spa/src/websocket.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,22 +106,37 @@ setInterval(() => {
106106

107107
export default {
108108
subscribe(topic: string, callback: (data: any) => void): void {
109+
const isFirstSubscription = !subscriptions[topic];
109110
if (!subscriptions[topic]) {
110111
subscriptions[topic] = [];
111112
}
112113
subscriptions[topic].push(callback);
113-
if (state.status === 'connected') {
114+
if (isFirstSubscription && state.status === 'connected') {
114115
doPhysicalSubscribe(topic);
115116
}
116117
},
117118

118119
unsubscribe(topic: string): void {
120+
if (!subscriptions[topic]) {
121+
return;
122+
}
119123
delete subscriptions[topic];
120124
if (state.status === 'connected') {
121125
doPhysicalUnsubscribe(topic);
122126
}
123127
},
124128

129+
unsubscribeByPrefix(prefix: string): void {
130+
Object.keys(subscriptions)
131+
.filter((topic) => topic.startsWith(prefix))
132+
.forEach((topic) => {
133+
delete subscriptions[topic];
134+
if (state.status === 'connected') {
135+
doPhysicalUnsubscribe(topic);
136+
}
137+
});
138+
},
139+
125140
unsubscribeAll(): void {
126141
Object.keys(subscriptions).forEach((topic) => {
127142
delete subscriptions[topic];

0 commit comments

Comments
 (0)