Skip to content

Commit a90c844

Browse files
authored
Merge cdb2729 into a67e782
2 parents a67e782 + cdb2729 commit a90c844

File tree

4 files changed

+254
-7
lines changed

4 files changed

+254
-7
lines changed

src/components/floweditor/FlowEditor.helper.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,139 @@ import '@nyaruka/temba-components/dist/temba-components.js';
44

55
import Tooltip from 'components/UI/Tooltip/Tooltip';
66
import styles from './FlowEditor.module.css';
7+
import { getAuthSession } from 'services/AuthService';
8+
import setLogs from 'config/logs';
79

810
const glificBase = FLOW_EDITOR_API;
911

12+
const DB_NAME = 'FlowDefinitionDB';
13+
const VERSION = 1;
14+
const STORE_NAME = 'flowDefinitions';
15+
let dbInstance: IDBDatabase | null = null;
16+
17+
async function initDB(): Promise<IDBDatabase> {
18+
if (dbInstance) {
19+
return dbInstance;
20+
}
21+
22+
return new Promise((resolve, reject) => {
23+
const request = indexedDB.open(DB_NAME, VERSION);
24+
25+
request.onerror = () => {
26+
reject(new Error('Failed to open IndexedDB'));
27+
};
28+
29+
request.onsuccess = () => {
30+
dbInstance = request.result;
31+
resolve(dbInstance);
32+
};
33+
34+
request.onupgradeneeded = (event) => {
35+
const db = (event.target as IDBOpenDBRequest).result;
36+
37+
if (!db.objectStoreNames.contains(STORE_NAME)) {
38+
const store = db.createObjectStore(STORE_NAME, { keyPath: 'uuid' });
39+
store.createIndex('timestamp', 'timestamp', { unique: false });
40+
}
41+
};
42+
});
43+
}
44+
45+
export const getFlowDefinition = async (uuid: string): Promise<any | null> => {
46+
const db = dbInstance || (await initDB());
47+
if (!db) {
48+
setLogs('Database not initialized. Call initDB() first.', 'error');
49+
return null;
50+
}
51+
52+
return new Promise((resolve, reject) => {
53+
const transaction = db.transaction([STORE_NAME], 'readonly');
54+
const store = transaction.objectStore(STORE_NAME);
55+
const request = store.get(uuid);
56+
57+
request.onsuccess = () => {
58+
const result = request.result;
59+
resolve(result ? result : null);
60+
};
61+
62+
request.onerror = () => {
63+
reject(new Error('Failed to get flow definition'));
64+
};
65+
});
66+
};
67+
68+
export const deleteFlowDefinition = async (uuid: string): Promise<boolean> => {
69+
const db = dbInstance || (await initDB());
70+
71+
if (!db) {
72+
setLogs('Database not initialized. Call initDB() first.', 'error');
73+
return false;
74+
}
75+
76+
return new Promise((resolve, reject) => {
77+
const transaction = db.transaction([STORE_NAME], 'readwrite');
78+
const store = transaction.objectStore(STORE_NAME);
79+
const request = store.delete(uuid);
80+
81+
request.onsuccess = () => {
82+
resolve(true);
83+
};
84+
85+
request.onerror = () => {
86+
reject(new Error(`Failed to delete flow definition with UUID: ${uuid}`));
87+
};
88+
});
89+
};
90+
91+
export const fetchLatestRevision = async (uuid: string) => {
92+
try {
93+
let latestRevision = null;
94+
const token = getAuthSession('access_token');
95+
96+
const response = await fetch(`${glificBase}revisions/${uuid}?version=13.2`, {
97+
headers: {
98+
authorization: token,
99+
},
100+
});
101+
const data = await response.json();
102+
103+
if (data.results.length > 0) {
104+
latestRevision = data.results.reduce((latest: any, current: any) =>
105+
new Date(latest.created_on) > new Date(current.created_on) ? latest : current
106+
);
107+
}
108+
109+
return latestRevision;
110+
} catch (error) {
111+
setLogs(`Error fetching latest revision: ${error}`, 'error');
112+
return null;
113+
}
114+
};
115+
116+
export const postLatestRevision = async (uuid: string, definition: any) => {
117+
const url = `${glificBase}revisions/${uuid}`;
118+
const token = getAuthSession('access_token');
119+
120+
try {
121+
const response = await fetch(url, {
122+
method: 'POST',
123+
headers: {
124+
'Content-Type': 'application/json',
125+
authorization: token,
126+
},
127+
body: JSON.stringify(definition),
128+
});
129+
130+
if (response.ok) {
131+
return true;
132+
}
133+
return false;
134+
} catch (error) {
135+
setLogs(`Error posting latest revision: ${error}`, 'error');
136+
return false;
137+
}
138+
};
139+
10140
export const setConfig = (uuid: any, isTemplate: boolean, skipValidation: boolean) => {
11141
const services = JSON.parse(localStorage.getItem('organizationServices') || '{}');
12142

src/components/floweditor/FlowEditor.test.tsx

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import {
1010
getInactiveFlow,
1111
getFlowWithoutKeyword,
1212
getOrganizationServicesQuery,
13-
publishFlow,
1413
getFreeFlow,
1514
resetFlowCount,
1615
getFlowTranslations,
1716
getTemplateFlow,
1817
getFlowWithManyKeywords,
18+
publishFlowWithSuccess,
1919
exportFlow,
2020
} from 'mocks/Flow';
2121
import { conversationQuery } from 'mocks/Chat';
@@ -29,6 +29,7 @@ import {
2929
} from 'mocks/Simulator';
3030
import * as Notification from 'common/notification';
3131
import * as Utils from 'common/utils';
32+
import * as FlowEditorHelper from './FlowEditor.helper';
3233

3334
window.location = { assign: vi.fn() } as any;
3435
window.location.reload = vi.fn();
@@ -38,6 +39,7 @@ beforeEach(() => {
3839
writable: true,
3940
value: { reload: vi.fn() },
4041
});
42+
vi.clearAllMocks();
4143
});
4244

4345
vi.mock('react-router', async () => {
@@ -53,7 +55,23 @@ const mockedAxios = axios as any;
5355
vi.mock('../simulator/Simulator', () => ({
5456
default: ({ message }: { message: string }) => <div data-testid="simulator">{message}</div>, // Mocking the component's behavior
5557
}));
58+
mockedAxios.get.mockImplementation(() =>
59+
Promise.resolve({
60+
data: {
61+
results: [],
62+
},
63+
})
64+
);
5665

66+
beforeAll(() => {
67+
globalThis.indexedDB = {
68+
open: vi.fn(() => ({
69+
onerror: vi.fn(),
70+
onsuccess: vi.fn(),
71+
result: {},
72+
})),
73+
} as unknown as IDBFactory;
74+
});
5775
const mocks = [
5876
messageReceivedSubscription({ organizationId: null }),
5977
messageSendSubscription({ organizationId: null }),
@@ -64,11 +82,11 @@ const mocks = [
6482
simulatorGetQuery,
6583
simulatorSearchQuery,
6684
simulatorSearchQuery,
67-
publishFlow,
6885
getOrganizationServicesQuery,
6986
getFreeFlow,
7087
getFreeFlow,
7188
getFlowTranslations,
89+
publishFlowWithSuccess,
7290
exportFlow,
7391
];
7492

@@ -337,6 +355,45 @@ test('if keywords are more than 8 it should be shown in a tooltip', async () =>
337355
});
338356
});
339357

358+
test('it should check the timestamp of the local revision and remote revision and only publish the latest version', async () => {
359+
const fetchRevisionSpy = vi.spyOn(FlowEditorHelper, 'fetchLatestRevision').mockResolvedValue({
360+
id: 'test-revision-id',
361+
created_on: '2023-01-01T00:00:00Z',
362+
definition: {},
363+
});
364+
const getFlowDefinitionSpy = vi.spyOn(FlowEditorHelper, 'getFlowDefinition').mockResolvedValue({
365+
uuid: 'test-uuid',
366+
definition: {},
367+
timestamp: Date.now(),
368+
});
369+
const postRevisionSpy = vi.spyOn(FlowEditorHelper, 'postLatestRevision').mockResolvedValue(true);
370+
const notificationSpy = vi.spyOn(Notification, 'setNotification');
371+
372+
render(defaultWrapper);
373+
374+
await waitFor(() => {
375+
expect(screen.getByText('help workflow')).toBeInTheDocument();
376+
});
377+
378+
fireEvent.click(screen.getByText('Publish'));
379+
380+
await waitFor(() => {
381+
expect(screen.getByText('Ready to publish?')).toBeInTheDocument();
382+
});
383+
384+
fireEvent.click(screen.getByTestId('ok-button'));
385+
386+
await waitFor(() => {
387+
expect(fetchRevisionSpy).toHaveBeenCalled();
388+
expect(getFlowDefinitionSpy).toHaveBeenCalled();
389+
expect(postRevisionSpy).toHaveBeenCalled();
390+
});
391+
392+
await waitFor(() => {
393+
expect(notificationSpy).toHaveBeenCalled();
394+
});
395+
});
396+
340397
test('should export the flow', async () => {
341398
const exportSpy = vi.spyOn(Utils, 'exportFlowMethod');
342399
mockedAxios.post.mockImplementation(() => Promise.resolve({ data: {} }));

src/components/floweditor/FlowEditor.tsx

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,20 @@ import { Loading } from 'components/UI/Layout/Loading/Loading';
1919
import Track from 'services/TrackService';
2020
import { exportFlowMethod } from 'common/utils';
2121
import styles from './FlowEditor.module.css';
22-
import { checkElementInRegistry, getKeywords, loadfiles, setConfig } from './FlowEditor.helper';
22+
import {
23+
checkElementInRegistry,
24+
deleteFlowDefinition,
25+
fetchLatestRevision,
26+
getFlowDefinition,
27+
getKeywords,
28+
loadfiles,
29+
postLatestRevision,
30+
setConfig,
31+
} from './FlowEditor.helper';
2332
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
2433
import { BackdropLoader, FlowTranslation } from 'containers/Flow/FlowTranslation';
34+
import dayjs from 'dayjs';
35+
import setLogs from 'config/logs';
2536
import ShareResponderLink from 'containers/Flow/ShareResponderLink/ShareResponderLink';
2637

2738
declare function showFlowEditor(node: any, config: any): void;
@@ -116,14 +127,15 @@ export const FlowEditor = () => {
116127
});
117128

118129
const [publishFlow] = useMutation(PUBLISH_FLOW, {
119-
onCompleted: (data) => {
130+
onCompleted: async (data) => {
120131
if (data.publishFlow.errors && data.publishFlow.errors.length > 0) {
121132
setFlowValidation(data.publishFlow.errors);
122133
setIsError(true);
123134
} else if (data.publishFlow.success) {
124135
setPublished(true);
125136
}
126137
setPublishLoading(false);
138+
if (uuid) await deleteFlowDefinition(uuid);
127139
},
128140
onError: () => {
129141
setPublishLoading(false);
@@ -252,8 +264,10 @@ export const FlowEditor = () => {
252264
Track('Flow opened');
253265

254266
return () => {
255-
Object.keys(files).forEach((node: any) => {
267+
Object.keys(files).forEach((node) => {
268+
// @ts-ignore
256269
if (files[node] && document.body.contains(files[node])) {
270+
// @ts-ignore
257271
document.body.removeChild(files[node]);
258272
}
259273
});
@@ -271,8 +285,37 @@ export const FlowEditor = () => {
271285
return () => {};
272286
}, [flowId]);
273287

274-
const handlePublishFlow = () => {
275-
publishFlow({ variables: { uuid: params.uuid } });
288+
const checkLatestRevision = async () => {
289+
let revisionSaved = false;
290+
if (uuid) {
291+
const latestRevision = await fetchLatestRevision(uuid);
292+
const flowDefinition = await getFlowDefinition(uuid);
293+
294+
if (latestRevision && flowDefinition) {
295+
const latestRevisionTime = dayjs(latestRevision.created_on);
296+
const flowDefinitionTime = dayjs(flowDefinition.timestamp);
297+
298+
if (flowDefinitionTime.isAfter(latestRevisionTime)) {
299+
const timeDifferenceSeconds = flowDefinitionTime.diff(latestRevisionTime, 'seconds');
300+
revisionSaved =
301+
timeDifferenceSeconds > 300 ? await postLatestRevision(uuid, flowDefinition.definition) : true;
302+
} else {
303+
revisionSaved = true;
304+
}
305+
} else if (!flowDefinition) {
306+
setLogs(`Local Flow definition not found ${uuid}`, 'info');
307+
308+
// If flowDefinition is not found, we assume the revision is saved
309+
revisionSaved = true;
310+
}
311+
}
312+
return revisionSaved;
313+
};
314+
315+
const handlePublishFlow = async () => {
316+
if (await checkLatestRevision()) {
317+
publishFlow({ variables: { uuid: params.uuid } });
318+
}
276319
};
277320

278321
const handleCancelFlow = () => {

src/mocks/Flow.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,23 @@ export const publishFlow = {
437437
},
438438
};
439439

440+
export const publishFlowWithSuccess = {
441+
request: {
442+
query: PUBLISH_FLOW,
443+
variables: {
444+
uuid: 'b050c652-65b5-4ccf-b62b-1e8b3f328676',
445+
},
446+
},
447+
result: {
448+
data: {
449+
publishFlow: {
450+
errors: null,
451+
success: true,
452+
},
453+
},
454+
},
455+
};
456+
440457
export const getOrganizationServicesQuery = {
441458
request: {
442459
query: GET_ORGANIZATION_SERVICES,

0 commit comments

Comments
 (0)