diff --git a/src/components/floweditor/FlowEditor.helper.tsx b/src/components/floweditor/FlowEditor.helper.tsx index 23b3cd4a0c..5953fb9906 100644 --- a/src/components/floweditor/FlowEditor.helper.tsx +++ b/src/components/floweditor/FlowEditor.helper.tsx @@ -4,9 +4,139 @@ import '@nyaruka/temba-components/dist/temba-components.js'; import Tooltip from 'components/UI/Tooltip/Tooltip'; import styles from './FlowEditor.module.css'; +import { getAuthSession } from 'services/AuthService'; +import setLogs from 'config/logs'; const glificBase = FLOW_EDITOR_API; +const DB_NAME = 'FlowDefinitionDB'; +const VERSION = 1; +const STORE_NAME = 'flowDefinitions'; +let dbInstance: IDBDatabase | null = null; + +async function initDB(): Promise { + if (dbInstance) { + return dbInstance; + } + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, VERSION); + + request.onerror = () => { + reject(new Error('Failed to open IndexedDB')); + }; + + request.onsuccess = () => { + dbInstance = request.result; + resolve(dbInstance); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'uuid' }); + store.createIndex('timestamp', 'timestamp', { unique: false }); + } + }; + }); +} + +export const getFlowDefinition = async (uuid: string): Promise => { + const db = dbInstance || (await initDB()); + if (!db) { + setLogs('Database not initialized. Call initDB() first.', 'error'); + return null; + } + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(uuid); + + request.onsuccess = () => { + const result = request.result; + resolve(result ? result : null); + }; + + request.onerror = () => { + reject(new Error('Failed to get flow definition')); + }; + }); +}; + +export const deleteFlowDefinition = async (uuid: string): Promise => { + const db = dbInstance || (await initDB()); + + if (!db) { + setLogs('Database not initialized. Call initDB() first.', 'error'); + return false; + } + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(uuid); + + request.onsuccess = () => { + resolve(true); + }; + + request.onerror = () => { + reject(new Error(`Failed to delete flow definition with UUID: ${uuid}`)); + }; + }); +}; + +export const fetchLatestRevision = async (uuid: string) => { + try { + let latestRevision = null; + const token = getAuthSession('access_token'); + + const response = await fetch(`${glificBase}revisions/${uuid}?version=13.2`, { + headers: { + authorization: token, + }, + }); + const data = await response.json(); + + if (data.results.length > 0) { + latestRevision = data.results.reduce((latest: any, current: any) => + new Date(latest.created_on) > new Date(current.created_on) ? latest : current + ); + } + + return latestRevision; + } catch (error) { + setLogs(`Error fetching latest revision: ${error}`, 'error'); + return null; + } +}; + +export const postLatestRevision = async (uuid: string, definition: any) => { + const url = `${glificBase}revisions/${uuid}`; + const token = getAuthSession('access_token'); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization: token, + }, + body: JSON.stringify(definition), + }); + + if (response.ok) { + return true; + } + return false; + } catch (error) { + setLogs(`Error posting latest revision: ${error}`, 'error'); + return false; + } +}; + export const setConfig = (uuid: any, isTemplate: boolean, skipValidation: boolean) => { const services = JSON.parse(localStorage.getItem('organizationServices') || '{}'); diff --git a/src/components/floweditor/FlowEditor.test.tsx b/src/components/floweditor/FlowEditor.test.tsx index ab1692a84e..da4f619e81 100644 --- a/src/components/floweditor/FlowEditor.test.tsx +++ b/src/components/floweditor/FlowEditor.test.tsx @@ -10,12 +10,12 @@ import { getInactiveFlow, getFlowWithoutKeyword, getOrganizationServicesQuery, - publishFlow, getFreeFlow, resetFlowCount, getFlowTranslations, getTemplateFlow, getFlowWithManyKeywords, + publishFlowWithSuccess, exportFlow, } from 'mocks/Flow'; import { conversationQuery } from 'mocks/Chat'; @@ -29,6 +29,7 @@ import { } from 'mocks/Simulator'; import * as Notification from 'common/notification'; import * as Utils from 'common/utils'; +import * as FlowEditorHelper from './FlowEditor.helper'; window.location = { assign: vi.fn() } as any; window.location.reload = vi.fn(); @@ -38,6 +39,7 @@ beforeEach(() => { writable: true, value: { reload: vi.fn() }, }); + vi.clearAllMocks(); }); vi.mock('react-router', async () => { @@ -53,7 +55,23 @@ const mockedAxios = axios as any; vi.mock('../simulator/Simulator', () => ({ default: ({ message }: { message: string }) =>
{message}
, // Mocking the component's behavior })); +mockedAxios.get.mockImplementation(() => + Promise.resolve({ + data: { + results: [], + }, + }) +); +beforeAll(() => { + globalThis.indexedDB = { + open: vi.fn(() => ({ + onerror: vi.fn(), + onsuccess: vi.fn(), + result: {}, + })), + } as unknown as IDBFactory; +}); const mocks = [ messageReceivedSubscription({ organizationId: null }), messageSendSubscription({ organizationId: null }), @@ -64,11 +82,11 @@ const mocks = [ simulatorGetQuery, simulatorSearchQuery, simulatorSearchQuery, - publishFlow, getOrganizationServicesQuery, getFreeFlow, getFreeFlow, getFlowTranslations, + publishFlowWithSuccess, exportFlow, ]; @@ -337,6 +355,45 @@ test('if keywords are more than 8 it should be shown in a tooltip', async () => }); }); +test('it should check the timestamp of the local revision and remote revision and only publish the latest version', async () => { + const fetchRevisionSpy = vi.spyOn(FlowEditorHelper, 'fetchLatestRevision').mockResolvedValue({ + id: 'test-revision-id', + created_on: '2023-01-01T00:00:00Z', + definition: {}, + }); + const getFlowDefinitionSpy = vi.spyOn(FlowEditorHelper, 'getFlowDefinition').mockResolvedValue({ + uuid: 'test-uuid', + definition: {}, + timestamp: Date.now(), + }); + const postRevisionSpy = vi.spyOn(FlowEditorHelper, 'postLatestRevision').mockResolvedValue(true); + const notificationSpy = vi.spyOn(Notification, 'setNotification'); + + render(defaultWrapper); + + await waitFor(() => { + expect(screen.getByText('help workflow')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Publish')); + + await waitFor(() => { + expect(screen.getByText('Ready to publish?')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('ok-button')); + + await waitFor(() => { + expect(fetchRevisionSpy).toHaveBeenCalled(); + expect(getFlowDefinitionSpy).toHaveBeenCalled(); + expect(postRevisionSpy).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(notificationSpy).toHaveBeenCalled(); + }); +}); + test('should export the flow', async () => { const exportSpy = vi.spyOn(Utils, 'exportFlowMethod'); mockedAxios.post.mockImplementation(() => Promise.resolve({ data: {} })); diff --git a/src/components/floweditor/FlowEditor.tsx b/src/components/floweditor/FlowEditor.tsx index a65793a928..313545b7e5 100644 --- a/src/components/floweditor/FlowEditor.tsx +++ b/src/components/floweditor/FlowEditor.tsx @@ -19,9 +19,20 @@ import { Loading } from 'components/UI/Layout/Loading/Loading'; import Track from 'services/TrackService'; import { exportFlowMethod } from 'common/utils'; import styles from './FlowEditor.module.css'; -import { checkElementInRegistry, getKeywords, loadfiles, setConfig } from './FlowEditor.helper'; +import { + checkElementInRegistry, + deleteFlowDefinition, + fetchLatestRevision, + getFlowDefinition, + getKeywords, + loadfiles, + postLatestRevision, + setConfig, +} from './FlowEditor.helper'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import { BackdropLoader, FlowTranslation } from 'containers/Flow/FlowTranslation'; +import dayjs from 'dayjs'; +import setLogs from 'config/logs'; import ShareResponderLink from 'containers/Flow/ShareResponderLink/ShareResponderLink'; declare function showFlowEditor(node: any, config: any): void; @@ -116,7 +127,7 @@ export const FlowEditor = () => { }); const [publishFlow] = useMutation(PUBLISH_FLOW, { - onCompleted: (data) => { + onCompleted: async (data) => { if (data.publishFlow.errors && data.publishFlow.errors.length > 0) { setFlowValidation(data.publishFlow.errors); setIsError(true); @@ -124,6 +135,7 @@ export const FlowEditor = () => { setPublished(true); } setPublishLoading(false); + if (uuid) await deleteFlowDefinition(uuid); }, onError: () => { setPublishLoading(false); @@ -252,8 +264,10 @@ export const FlowEditor = () => { Track('Flow opened'); return () => { - Object.keys(files).forEach((node: any) => { + Object.keys(files).forEach((node) => { + // @ts-ignore if (files[node] && document.body.contains(files[node])) { + // @ts-ignore document.body.removeChild(files[node]); } }); @@ -271,8 +285,37 @@ export const FlowEditor = () => { return () => {}; }, [flowId]); - const handlePublishFlow = () => { - publishFlow({ variables: { uuid: params.uuid } }); + const checkLatestRevision = async () => { + let revisionSaved = false; + if (uuid) { + const latestRevision = await fetchLatestRevision(uuid); + const flowDefinition = await getFlowDefinition(uuid); + + if (latestRevision && flowDefinition) { + const latestRevisionTime = dayjs(latestRevision.created_on); + const flowDefinitionTime = dayjs(flowDefinition.timestamp); + + if (flowDefinitionTime.isAfter(latestRevisionTime)) { + const timeDifferenceSeconds = flowDefinitionTime.diff(latestRevisionTime, 'seconds'); + revisionSaved = + timeDifferenceSeconds > 300 ? await postLatestRevision(uuid, flowDefinition.definition) : true; + } else { + revisionSaved = true; + } + } else if (!flowDefinition) { + setLogs(`Local Flow definition not found ${uuid}`, 'info'); + + // If flowDefinition is not found, we assume the revision is saved + revisionSaved = true; + } + } + return revisionSaved; + }; + + const handlePublishFlow = async () => { + if (await checkLatestRevision()) { + publishFlow({ variables: { uuid: params.uuid } }); + } }; const handleCancelFlow = () => { diff --git a/src/mocks/Flow.tsx b/src/mocks/Flow.tsx index 688cf78bd9..1fca97ea5b 100644 --- a/src/mocks/Flow.tsx +++ b/src/mocks/Flow.tsx @@ -437,6 +437,23 @@ export const publishFlow = { }, }; +export const publishFlowWithSuccess = { + request: { + query: PUBLISH_FLOW, + variables: { + uuid: 'b050c652-65b5-4ccf-b62b-1e8b3f328676', + }, + }, + result: { + data: { + publishFlow: { + errors: null, + success: true, + }, + }, + }, +}; + export const getOrganizationServicesQuery = { request: { query: GET_ORGANIZATION_SERVICES,