Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
dc9f067
Implement IndexedDB for flow definitions and add revision handling logic
akanshaaa19 Jul 3, 2025
0b8f976
Refactor axios call in fetchLatestRevision to use axios.get and add I…
akanshaaa19 Jul 3, 2025
9b06d85
Refactor fetchLatestRevision to use fetch API and improve error handl…
akanshaaa19 Jul 14, 2025
a92d4b8
Implement IndexedDB for flow definitions and add revision handling logic
akanshaaa19 Jul 3, 2025
3b818b9
Refactor axios call in fetchLatestRevision to use axios.get and add I…
akanshaaa19 Jul 3, 2025
14f4b43
Refactor fetchLatestRevision to use fetch API and improve error handl…
akanshaaa19 Jul 14, 2025
4c6734b
Add deleteFlowDefinition function and integrate it into FlowEditor on…
akanshaaa19 Aug 5, 2025
c0e60d7
Merge branch 'enhancements/save-revision-before-publish' of github.co…
akanshaaa19 Aug 6, 2025
ad9317c
Remove unused import of deleteFlowDefinition in FlowEditor component
akanshaaa19 Aug 6, 2025
ce7138f
Merge branch 'master' into enhancements/save-revision-before-publish
akanshaaa19 Aug 6, 2025
2020d7b
Add publishFlowWithSuccess mock and update tests for flow publishing
akanshaaa19 Aug 6, 2025
581aebf
Merge branch 'enhancements/save-revision-before-publish' of github.co…
akanshaaa19 Aug 6, 2025
8417fb8
Refactor error handling and logging in FlowEditor helper and tests
akanshaaa19 Aug 6, 2025
7e3fe1b
Change loadfiles function from async to synchronous
akanshaaa19 Aug 6, 2025
cfdd620
Skip inactive flow test
akanshaaa19 Aug 6, 2025
cdb2729
Merge branch 'master' of github.com:glific/glific-frontend into enhan…
akanshaaa19 Oct 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/components/floweditor/FlowEditor.helper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDBDatabase> {
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<any | null> => {
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<boolean> => {
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,
},
});
Comment on lines +96 to +100
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Include Bearer prefix on Authorization header

The flow-editor API expects standard bearer auth. Here we send the raw token (authorization: token), which the backend treats as missing credentials, returning 401. This regresses publishing after idle periods—the exact bug reported in PR comments. Prefix the header with Bearer (and bail early if token is falsy) so the request reuses our auth session correctly.

-    const response = await fetch(`${glificBase}revisions/${uuid}?version=13.2`, {
-      headers: {
-        authorization: token,
-      },
-    });
+    if (!token) {
+      throw new Error('Missing access token for revision fetch');
+    }
+
+    const response = await fetch(`${glificBase}revisions/${uuid}?version=13.2`, {
+      headers: {
+        authorization: `Bearer ${token}`,
+      },
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(`${glificBase}revisions/${uuid}?version=13.2`, {
headers: {
authorization: token,
},
});
if (!token) {
throw new Error('Missing access token for revision fetch');
}
const response = await fetch(`${glificBase}revisions/${uuid}?version=13.2`, {
headers: {
authorization: `Bearer ${token}`,
},
});
🤖 Prompt for AI Agents
In src/components/floweditor/FlowEditor.helper.tsx around lines 96 to 100, the
fetch call sends the raw token in the authorization header which the API expects
as a Bearer token; update the code to early-return or throw if token is falsy,
and set the header to "Authorization": `Bearer ${token}` (i.e., prefix the token
with "Bearer " and use the proper header key casing) so the backend recognizes
the credentials and avoids 401s after idle periods.

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') || '{}');

Expand Down
61 changes: 59 additions & 2 deletions src/components/floweditor/FlowEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import {
getInactiveFlow,
getFlowWithoutKeyword,
getOrganizationServicesQuery,
publishFlow,
getFreeFlow,
resetFlowCount,
getFlowTranslations,
getTemplateFlow,
getFlowWithManyKeywords,
publishFlowWithSuccess,
exportFlow,
} from 'mocks/Flow';
import { conversationQuery } from 'mocks/Chat';
Expand All @@ -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();
Expand All @@ -38,6 +39,7 @@ beforeEach(() => {
writable: true,
value: { reload: vi.fn() },
});
vi.clearAllMocks();
});

vi.mock('react-router', async () => {
Expand All @@ -53,7 +55,23 @@ const mockedAxios = axios as any;
vi.mock('../simulator/Simulator', () => ({
default: ({ message }: { message: string }) => <div data-testid="simulator">{message}</div>, // 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 }),
Expand All @@ -64,11 +82,11 @@ const mocks = [
simulatorGetQuery,
simulatorSearchQuery,
simulatorSearchQuery,
publishFlow,
getOrganizationServicesQuery,
getFreeFlow,
getFreeFlow,
getFlowTranslations,
publishFlowWithSuccess,
exportFlow,
];

Expand Down Expand Up @@ -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: {} }));
Expand Down
53 changes: 48 additions & 5 deletions src/components/floweditor/FlowEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,14 +127,15 @@ 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);
} else if (data.publishFlow.success) {
setPublished(true);
}
setPublishLoading(false);
if (uuid) await deleteFlowDefinition(uuid);
},
onError: () => {
setPublishLoading(false);
Expand Down Expand Up @@ -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]);
}
});
Expand All @@ -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;
};
Comment on lines +288 to +313
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Surface failure when revisions can't be synced

When fetchLatestRevision/getFlowDefinition returns null we bail with false, but nothing informs the user why publish didn't proceed; dialog just spins. Add error notification + reset loading.

🤖 Prompt for AI Agents
In src/components/floweditor/FlowEditor.tsx around lines 288-313, when
fetchLatestRevision or getFlowDefinition returns null the function currently
proceeds without surfacing an error and the publish dialog can continue
spinning; update the null/failed branches to log an error (use setLogs with an
error message including the uuid and which call failed) and reset the
publishing/loading state (e.g., call setIsPublishing(false) or the component's
equivalent) before returning false so the UI is notified and the dialog stops.


const handlePublishFlow = async () => {
if (await checkLatestRevision()) {
publishFlow({ variables: { uuid: params.uuid } });
}
};

const handleCancelFlow = () => {
Expand Down
17 changes: 17 additions & 0 deletions src/mocks/Flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading