Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
10 changes: 3 additions & 7 deletions src/containers/WhatsAppForms/Configure/Configure.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -880,10 +880,7 @@ describe('<Configure />', () => {
});
});

test('it generates a random ID for screens with non-alpha names', async () => {
// Make randomAlphaId deterministic: always returns 'aaaaaa'
vi.spyOn(Math, 'random').mockReturnValue(0);

test('it preserves original screen ID even when name changes to non-alpha', async () => {
render(wrapper());

await waitFor(() => {
Expand All @@ -897,10 +894,9 @@ describe('<Configure />', () => {
await waitFor(() => {
const jsonText = screen.getByTestId('json-preview') as HTMLTextAreaElement;
const parsed = JSON.parse(jsonText.value);
expect(parsed.screens[0].id).toBe('screen_aaaaaa');
// Screen was imported from flow JSON with id 'screen', so it preserves that ID
expect(parsed.screens[0].id).toBe('screen');
});

vi.spyOn(Math, 'random').mockRestore();
});

test('it should create form with different types of short answers and shows them in preview', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,33 @@

.ContentBody {
padding: 20px;
}

.UnsupportedContent {
display: flex;
flex-direction: column;
gap: 8px;
}

.UnsupportedInfo {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: #f0f4f8;
border-radius: 6px;
font-size: 13px;
color: #555;
}

.EndpointWarning {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: #fff4e5;
border: 1px solid #ffb74d;
border-radius: 6px;
font-size: 13px;
color: #663c00;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import DeleteOutlined from '@mui/icons-material/DeleteOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import WarningIcon from '@mui/icons-material/Warning';
import { IconButton } from '@mui/material';
import { ContentItem } from '../FormBuilder.types';
Expand Down Expand Up @@ -53,10 +54,25 @@ export const ContentItemComponent = ({
return <SelectionContent item={item} onUpdate={onUpdate} isViewOnly={isViewOnly} />;
case 'Unsupported':
return (
<div style={{ color: '#666', fontSize: '13px', fontStyle: 'italic' }}>
This component ({item.name}) is not editable in the form builder but will be preserved in the JSON.
<div className={styles.UnsupportedContent} data-testid="unsupported-content">
<div className={styles.UnsupportedInfo}>
<InfoOutlinedIcon fontSize="small" style={{ color: '#666' }} />
<span>
This component (<strong>{item.name}</strong>) is view-only and will be preserved on
export.
</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/containers/WhatsAppForms/Configure/FormBuilder/ContentItem/ContentItemComponent.tsx | head -80

Repository: glific/glific-frontend

Length of output: 3663


🏁 Script executed:

# Check if this file has any i18next imports
rg "useTranslation|i18next" src/containers/WhatsAppForms/Configure/FormBuilder/ContentItem/ContentItemComponent.tsx

Repository: glific/glific-frontend

Length of output: 48


🏁 Script executed:

# Check other component files in the same directory for i18next pattern
ls -la src/containers/WhatsAppForms/Configure/FormBuilder/ContentItem/

Repository: glific/glific-frontend

Length of output: 387


🏁 Script executed:

# Check sibling components to see how they handle localization
rg "useTranslation" src/containers/WhatsAppForms/Configure/FormBuilder/ -l | head -5

Repository: glific/glific-frontend

Length of output: 48


🏁 Script executed:

# Check which files in FormBuilder use useTranslation
rg "useTranslation" src/containers/WhatsAppForms/Configure/FormBuilder/ -l

Repository: glific/glific-frontend

Length of output: 48


🏁 Script executed:

# Check content of one of the types to see if they use i18next
cat -n src/containers/WhatsAppForms/Configure/FormBuilder/ContentItem/types/TextContent.tsx | head -50

Repository: glific/glific-frontend

Length of output: 2112


🏁 Script executed:

# Check how widespread i18next adoption is in src/containers
rg "useTranslation" src/containers/ -l | wc -l

Repository: glific/glific-frontend

Length of output: 69


🏁 Script executed:

# Sample a few files that do use i18next to confirm pattern
rg "useTranslation" src/containers/ -l | head -3 | xargs -I {} sh -c 'echo "=== {} ===" && head -15 {}'

Repository: glific/glific-frontend

Length of output: 2222


Localize hardcoded user-facing strings via i18next.

This component adds two hardcoded strings at lines 61–63 and 69 that need i18n support. The codebase standardly uses useTranslation() from react-i18next across 100+ files in src/containers, and these strings must follow that pattern for extraction to Lokalise.

Suggested fix
+import { useTranslation } from 'react-i18next';

 export const ContentItemComponent = ({
   item,
   isExpanded,
   onToggleExpanded,
   onDelete,
   onUpdate,
   isViewOnly = false,
 }: ContentItemComponentProps) => {
+  const { t } = useTranslation();
   const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
     id: item.id,
   });

   // ...

   case 'Unsupported':
     return (
       <div className={styles.UnsupportedContent} data-testid="unsupported-content">
         <div className={styles.UnsupportedInfo}>
           <InfoOutlinedIcon fontSize="small" style={{ color: '#666' }} />
           <span>
-            This component (<strong>{item.name}</strong>) is view-only and will be preserved on
-            export.
+            {t('This component ({{name}}) is view-only and will be preserved on export.', {
+              name: item.name,
+            })}
           </span>
         </div>
         {(item.data.rawComponent?.type === 'PhotoPicker' ||
           item.data.rawComponent?.type === 'DocumentPicker') && (
           <div className={styles.EndpointWarning} data-testid="endpoint-warning">
             <WarningIcon fontSize="small" style={{ color: '#ed6c02' }} />
-            <span>This component requires an upload endpoint to be configured.</span>
+            <span>{t('This component requires an upload endpoint to be configured.')}</span>
           </div>
         )}
       </div>
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/containers/WhatsAppForms/Configure/FormBuilder/ContentItem/ContentItemComponent.tsx`
around lines 61 - 63, The two hardcoded user-facing strings in the
ContentItemComponent must be replaced with i18n keys using react-i18next; import
useTranslation and call const { t } = useTranslation() inside the
ContentItemComponent, then replace the literal "This component
(<strong>{item.name}</strong>) is view-only and will be preserved on export."
with t('whatsappForms.contentItem.viewOnly', { name: item.name }) (using
HTML-safe rendering or interpolation as existing code does) and replace the
other literal at line 69 with a separate key such as
t('whatsappForms.contentItem.someOtherKey'); add those keys to the locale
resource files for extraction.

</div>
{(item.data.rawComponent?.type === 'PhotoPicker' ||
item.data.rawComponent?.type === 'DocumentPicker') && (
<div className={styles.EndpointWarning} data-testid="endpoint-warning">
<WarningIcon fontSize="small" style={{ color: '#ed6c02' }} />
<span>This component requires an upload endpoint to be configured.</span>
</div>
)}
</div>
);
default:
return null;
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export interface Screen {
order: number;
content: ContentItem[];
buttonLabel: string;
/** Original WhatsApp Flow screen ID, preserved for round-trip export */
flowId?: string;
/** Original WhatsApp Flow screen.data declarations, preserved for round-trip export */
flowData?: Record<string, any>;
}

export interface ContentItem {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { describe, expect, test } from 'vitest';
import {
convertFlowJSONToFormBuilder,
convertFormBuilderToFlowJSON,
computeFieldNames,
hasContentItemError,
validateFlowJson,
} from './FormBuilder.utils';
Comment on lines +6 to +12
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 14, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use src alias imports instead of relative imports in tests.

Line 8 imports ./FormBuilder.utils; this should use the configured src-relative alias path.

Suggested fix
 import {
   convertFlowJSONToFormBuilder,
   convertFormBuilderToFlowJSON,
   computeFieldNames,
   hasContentItemError,
   validateFlowJson,
-} from './FormBuilder.utils';
+} from 'containers/WhatsAppForms/Configure/FormBuilder/FormBuilder.utils';

As per coding guidelines "Use import aliases relative to src/ with baseUrl configured in tsconfig.json instead of relative imports".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/containers/WhatsAppForms/Configure/FormBuilder/FormBuilder.utils.test.ts`
around lines 2 - 8, Replace the relative import of the utility module with the
project's src alias: update the import that currently pulls
'./FormBuilder.utils' so it uses the configured src alias path (e.g. import {
convertFlowJSONToFormBuilder, convertFormBuilderToFlowJSON, computeFieldNames,
hasContentItemError, validateFlowJson } from
'src/containers/WhatsAppForms/Configure/FormBuilder/FormBuilder.utils'), leaving
the exported symbol names unchanged.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@khushthecoder can you fix this also

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


// ── Fixture: a realistic Flow JSON with unsupported types, custom IDs, and data ──

const fixtureFlowJSON = {
version: '7.3',
screens: [
{
id: 'my_custom_screen',
title: 'Registration',
terminal: false,
data: {
custom_prop: { type: 'string', __example__: 'hello' },
},
layout: {
type: 'SingleColumnLayout',
children: [
{
type: 'Form',
name: 'flow_path',
children: [
{ type: 'TextHeading', text: 'Welcome' },
{ type: 'TextInput', name: 'user_name', label: 'Name', required: true, 'input-type': 'text' },
{ type: 'CalendarPicker', name: 'cal_pick', label: 'Pick a date' },
{ type: 'ChipsSelector', name: 'chips_sel', label: 'Pick chips', 'data-source': [{ id: '1', title: 'A' }] },
{ type: 'EmbeddedLink', text: 'Click here', src: 'https://example.com' },
{ type: 'RichText', text: ['Hello ', { bold: true, text: 'World' }] },
{ type: 'If', condition: '${data.flag}', then: [{ type: 'TextBody', text: 'Yes' }], else: [{ type: 'TextBody', text: 'No' }] },
{ type: 'Switch', value: '${data.status}', cases: { open: [{ type: 'TextBody', text: 'Open' }] } },
{ type: 'PhotoPicker', name: 'photo_pick', label: 'Upload Photo' },
{ type: 'DocumentPicker', name: 'doc_pick', label: 'Upload Doc' },
{ type: 'Footer', label: '${data.custom_prop}', 'on-click-action': { name: 'navigate', next: { name: 'final_screen', type: 'screen' }, payload: {} } },
],
},
],
},
},
{
id: 'final_screen',
title: 'Thank You',
terminal: true,
data: {},
layout: {
type: 'SingleColumnLayout',
children: [
{
type: 'Form',
name: 'flow_path',
children: [
{ type: 'TextBody', text: 'Thanks!' },
{ type: 'Footer', label: 'Done', 'on-click-action': { name: 'complete', payload: {} } },
],
},
],
},
},
],
};

// ── Tests ──

describe('convertFlowJSONToFormBuilder + convertFormBuilderToFlowJSON round-trip', () => {
test('preserves screen IDs through import→export', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const exported = convertFormBuilderToFlowJSON(screens);
expect(exported.screens[0].id).toBe('my_custom_screen');
expect(exported.screens[1].id).toBe('final_screen');
});

test('preserves screen.data declarations through import→export', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const exported = convertFormBuilderToFlowJSON(screens);
expect(exported.screens[0].data).toHaveProperty('custom_prop');
expect(exported.screens[0].data.custom_prop).toEqual({ type: 'string', __example__: 'hello' });
});

test('preserves Footer label with dynamic expression exactly', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const exported = convertFormBuilderToFlowJSON(screens);
const footer = exported.screens[0].layout.children[0].children.find((c: any) => c.type === 'Footer');
expect(footer.label).toBe('${data.custom_prop}');
});

test('stores the Footer label on the screen.buttonLabel without alteration', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
expect(screens[0].buttonLabel).toBe('${data.custom_prop}');
});
});

describe('Unsupported component import', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const unsupported = screens[0].content.filter((item) => item.type === 'Unsupported');

test('imports CalendarPicker as Unsupported with raw JSON', () => {
const cal = unsupported.find((i) => i.name === 'CalendarPicker');
expect(cal).toBeDefined();
expect(cal!.data.rawComponent?.type).toBe('CalendarPicker');
expect(cal!.data.rawComponent?.name).toBe('cal_pick');
});

test('imports ChipsSelector as Unsupported with raw JSON', () => {
const chip = unsupported.find((i) => i.name === 'ChipsSelector');
expect(chip).toBeDefined();
expect(chip!.data.rawComponent?.type).toBe('ChipsSelector');
});

test('imports EmbeddedLink as Unsupported', () => {
const link = unsupported.find((i) => i.name === 'EmbeddedLink');
expect(link).toBeDefined();
expect(link!.data.rawComponent?.src).toBe('https://example.com');
});

test('imports RichText as Unsupported', () => {
const rt = unsupported.find((i) => i.name === 'RichText');
expect(rt).toBeDefined();
expect(rt!.data.rawComponent?.text).toEqual(['Hello ', { bold: true, text: 'World' }]);
});

test('imports If with nested children as Unsupported (preserves structure)', () => {
const ifComp = unsupported.find((i) => i.name === 'If');
expect(ifComp).toBeDefined();
expect(ifComp!.data.rawComponent?.then).toHaveLength(1);
expect(ifComp!.data.rawComponent?.else).toHaveLength(1);
});

test('imports Switch with nested children as Unsupported (preserves structure)', () => {
const sw = unsupported.find((i) => i.name === 'Switch');
expect(sw).toBeDefined();
expect(sw!.data.rawComponent?.cases?.open).toHaveLength(1);
});

test('imports PhotoPicker as Unsupported with raw JSON', () => {
const photo = unsupported.find((i) => i.name === 'PhotoPicker');
expect(photo).toBeDefined();
expect(photo!.data.rawComponent?.name).toBe('photo_pick');
});

test('imports DocumentPicker as Unsupported with raw JSON', () => {
const doc = unsupported.find((i) => i.name === 'DocumentPicker');
expect(doc).toBeDefined();
expect(doc!.data.rawComponent?.name).toBe('doc_pick');
});
});

describe('Unsupported component round-trip export', () => {
test('re-exports all 8 unsupported component types as deep-equal raw JSON', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const exported = convertFormBuilderToFlowJSON(screens);
const formChildren = exported.screens[0].layout.children[0].children;

const original = fixtureFlowJSON.screens[0].layout.children[0].children;
const unsupportedTypes = ['CalendarPicker', 'ChipsSelector', 'EmbeddedLink', 'RichText', 'If', 'Switch', 'PhotoPicker', 'DocumentPicker'];

unsupportedTypes.forEach((typeName) => {
const origComponent = original.find((c) => c.type === typeName);
const exportedComponent = formChildren.find((c: any) => c.type === typeName);
expect(exportedComponent).toEqual(origComponent);
});
});

test('does not mutate original raw component objects during round-trip', () => {
const inputCopy = JSON.parse(JSON.stringify(fixtureFlowJSON));
const screens = convertFlowJSONToFormBuilder(inputCopy);
convertFormBuilderToFlowJSON(screens);
expect(inputCopy).toEqual(fixtureFlowJSON);
});
});

describe('Known component round-trip', () => {
test('preserves known TextHeading through round-trip', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const exported = convertFormBuilderToFlowJSON(screens);
const formChildren = exported.screens[0].layout.children[0].children;
const heading = formChildren.find((c: any) => c.type === 'TextHeading');
expect(heading).toBeDefined();
expect(heading.text).toBe('Welcome');
});

test('preserves known TextInput through round-trip', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const exported = convertFormBuilderToFlowJSON(screens);
const formChildren = exported.screens[0].layout.children[0].children;
const input = formChildren.find((c: any) => c.type === 'TextInput');
expect(input).toBeDefined();
expect(input.label).toBe('Name');
expect(input.required).toBe(true);
});
});

describe('computeFieldNames', () => {
test('tracks unsupported components that have a name property', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const fieldNames = computeFieldNames(screens);
const values = Array.from(fieldNames.values());
expect(values).toContain('cal_pick');
expect(values).toContain('chips_sel');
expect(values).toContain('photo_pick');
expect(values).toContain('doc_pick');
});
});

describe('hasContentItemError', () => {
test('returns false for Unsupported type items (no false errors)', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
const unsupported = screens[0].content.filter((item) => item.type === 'Unsupported');
unsupported.forEach((item) => {
expect(hasContentItemError(item)).toBe(false);
});
});
});

describe('validateFlowJson', () => {
test('validates flow JSON containing new component types without errors', () => {
const result = validateFlowJson(fixtureFlowJSON);
expect(result.errors).toHaveLength(0);
});

test('detects duplicate component names across new input types', () => {
const duplicate = JSON.parse(JSON.stringify(fixtureFlowJSON));
// Add a second CalendarPicker with same name on screen 2
duplicate.screens[1].layout.children[0].children.splice(0, 0, {
type: 'CalendarPicker', name: 'cal_pick', label: 'Dup',
});
const result = validateFlowJson(duplicate);
const dupError = result.errors.find((e: any) => e.message.includes("Duplicate component name 'cal_pick'"));
expect(dupError).toBeDefined();
});
});

describe('Edge cases', () => {
test('uses flowId for imported screens and generates IDs for new screens', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
// Add a new screen without flowId
screens.push({
id: '3',
name: 'New Screen',
order: 2,
content: [],
buttonLabel: 'Continue',
// no flowId — should get generated ID
});
const exported = convertFormBuilderToFlowJSON(screens);
expect(exported.screens[0].id).toBe('my_custom_screen');
expect(exported.screens[1].id).toBe('final_screen');
expect(exported.screens[2].id).toBe('new_screen');
});

test('handles empty flow JSON gracefully', () => {
const screens = convertFlowJSONToFormBuilder({});
expect(screens).toEqual([]);
});

test('preserves flowId and flowData on the imported Screen objects', () => {
const screens = convertFlowJSONToFormBuilder(fixtureFlowJSON);
expect(screens[0].flowId).toBe('my_custom_screen');
expect(screens[0].flowData).toEqual({ custom_prop: { type: 'string', __example__: 'hello' } });
expect(screens[1].flowId).toBe('final_screen');
});

test('does not share references between flowData and original', () => {
const input = JSON.parse(JSON.stringify(fixtureFlowJSON));
const screens = convertFlowJSONToFormBuilder(input);
screens[0].flowData!.custom_prop.__example__ = 'modified';
expect(input.screens[0].data.custom_prop.__example__).toBe('hello');
});
});
Loading