Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
132 changes: 131 additions & 1 deletion 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) {
console.warn('Database not initialized. Call initDB() first.');
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) => {
return new Date(latest.created_on) > new Date(current.created_on) ? latest : current;
});
}

return latestRevision;
} catch (error) {
console.error('Error fetching latest revision:', 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) {
console.error('Error posting latest revision:', error);
return false;
}
};

export const setConfig = (uuid: any, isTemplate: boolean, skipValidation: boolean) => {
const services = JSON.parse(localStorage.getItem('organizationServices') || '{}');

Expand Down Expand Up @@ -109,7 +239,7 @@ export const setConfig = (uuid: any, isTemplate: boolean, skipValidation: boolea
return config;
};

export const loadfiles = (startFlowEditor: any) => {
export const loadfiles = async (startFlowEditor: any) => {
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Function signature change needs verification

The loadfiles function has been changed from synchronous to asynchronous, but the internal implementation remains synchronous. This could break existing callers.

Verify that all callers of loadfiles have been updated to handle the async nature:


🏁 Script executed:

#!/bin/bash
# Search for all usages of loadfiles function
rg -A 3 -B 3 "loadfiles\(" --type ts --type tsx

Length of output: 77


🏁 Script executed:

#!/bin/bash
# Search for all usages of loadfiles across TS/TSX files
rg -A 3 -B 3 "loadfiles\(" -g "*.ts" -g "*.tsx" || echo "No occurrences found."

Length of output: 535


Ensure callers handle loadfiles’ async return

The loadfiles function now returns a Promise, but its caller in FlowEditor.tsx still treats it as synchronous:

• File: src/components/floweditor/FlowEditor.tsx
Around the useEffect:

useEffect(() => {
  if (flowId) {
-     const files = loadfiles(() => {
+     (async () => {
+       const files = await loadfiles(() => {
        getFreeFlow({ variables: { id: flowId } });
      });
+       // …use files here…
+     })();
  }
}, [flowId]);

Please update this (and any other) call site to either await the async function or chain with .then(...), and adjust the useEffect body accordingly so you’re working with the resolved value instead of a Promise.

🤖 Prompt for AI Agents
In src/components/floweditor/FlowEditor.tsx near the useEffect hook, the call to
the async function loadfiles from FlowEditor.helper.tsx is currently treated
synchronously. Update the useEffect callback to either use an async function
with await when calling loadfiles or use loadfiles(...).then(...) to handle the
Promise resolution properly. This ensures the code works with the resolved value
instead of the Promise itself.

const files: Array<HTMLScriptElement | HTMLLinkElement> = [];
const filesToLoad: any = Manifest.files;

Expand Down
16 changes: 16 additions & 0 deletions src/components/floweditor/FlowEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,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 Down
48 changes: 43 additions & 5 deletions src/components/floweditor/FlowEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,19 @@ 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';

declare function showFlowEditor(node: any, config: any): void;

Expand Down Expand Up @@ -114,14 +124,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 @@ -208,8 +219,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 @@ -227,8 +240,33 @@ 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;
}
}

return revisionSaved;
}
};

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

const handleCancelFlow = () => {
Expand Down
Loading