Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 12 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ jobs:
# - theme tokens on journey/segment screens (CRM-520): a fixed
# gray/white/black class only looks right in one theme; the guard
# fails on any new one, with no baseline.
# - trigger filter contract (CRM-519): every key the event trigger offers
# as a filter has a label and help in all locales, a select for ids and
# closed sets, and no timestamp/object, and every lookup asks for a full
# page instead of the endpoint default. A new catalog key that misses one
# of these only shows up as a raw key in the user's face. journey-parity
# is the ONLY guard for the six-locale event-switch keys (i18n-parity
# skips journey.json), and EventBasicConfig owns the switch notice/Undo.
# The whole list runs in ~40s and is deterministic.
#
# Running the whole suite here was tried and reverted: 160 jsdom files in
Expand Down Expand Up @@ -197,7 +204,11 @@ jobs:
src/components/ai_agents/shared/BodyParamsEditor.spec.tsx \
src/components/customTools/CustomToolForm.spec.tsx \
src/components/customTools/CustomToolWizardModal.spec.tsx \
src/components/journey/theme-tokens.spec.ts
src/components/journey/theme-tokens.spec.ts \
src/components/journey/shared/EventPropertiesForm/filterFields.spec.ts \
src/components/journey/shared/EventPropertiesForm/EventPropertiesForm.spec.tsx \
src/components/journey/nodes/trigger/components/EventBasicConfig.spec.tsx \
src/i18n/locales/journey-parity.spec.ts

# Lint gate scoped to the files the PR touched.
eslint:
Expand Down
52 changes: 35 additions & 17 deletions src/components/journey/nodes/trigger/JourneyTriggerPanel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,40 +143,58 @@ describe('JourneyTriggerPanel — event trigger tabs (EVO-1276)', () => {
const user = userEvent.setup();
const { onUpdate, onClose } = renderPanel();

await selectEvent(user, /contact created|contato criado/i);
await user.type(screen.getByLabelText(/^id\s*\*?$/i), 'id-1');
await user.type(screen.getByLabelText(/^source\s*\*?$/i), 'crm');
await selectEvent(user, /message created|mensagem criada/i);
// CRM-519: filters are optional and live behind the "+ Add filter" picker
// (third combobox: trigger type, event selector, picker).
await user.click(screen.getAllByRole('combobox')[2]);
await user.click(within(await screen.findByRole('listbox')).getByText('content'));
await user.type(screen.getByRole('textbox', { name: /content/i }), 'oi');

await user.click(screen.getByRole('button', { name: 'Save' }));

expect(onUpdate).toHaveBeenCalledTimes(1);
const saved = onUpdate.mock.calls[0][1] as JourneyTriggerNodeData;
expect(saved.eventProperties).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'id' }),
expect.objectContaining({ path: 'source' }),
]),
);
expect(saved.eventProperties).toEqual([
{ path: 'content', operator: { type: 'Equals', value: 'oi' } },
]);
expect(saved.variableMappings ?? []).toHaveLength(0);
expect(onClose).toHaveBeenCalledTimes(1);
});

it('AC5: clicking Save with a required property empty while on Avançado snaps back to Básico without saving', async () => {
it('AC5 (CRM-519): Save from Avançado with an event chosen and no filters persists and closes', async () => {
const user = userEvent.setup();
const { onUpdate, onClose } = renderPanel();

// Select an event with required fields but leave them empty → invalid.
await selectEvent(user, /contact created|contato criado/i);

// Move to Avançado, then attempt to save.
await user.click(screen.getByRole('tab', { name: /Advanced|Avançado|Avanzado|Avanzato|Avancé/ }));
await user.click(screen.getByRole('button', { name: 'Save' }));

// Bounced back to Básico; nothing persisted/closed.
const basicTab = screen.getByRole('tab', { name: /Basic|Básico|Base|Basique/ });
expect(basicTab.getAttribute('aria-selected')).toBe('true');
expect(onUpdate).toHaveBeenCalledTimes(1);
const saved = onUpdate.mock.calls[0][1] as JourneyTriggerNodeData;
expect(saved.eventName).toBe('contact.created');
expect(saved.eventProperties ?? []).toHaveLength(0);
expect(onClose).toHaveBeenCalledTimes(1);
});

it('AC5b (CRM-519): Save stays disabled with no event chosen or a custom event without a name, with the reason under the field', async () => {
const user = userEvent.setup();
const { onUpdate } = renderPanel();

const save = () => screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement;
expect(save().disabled).toBe(true);
expect(screen.getByText(/choose an event|escolha um evento/i)).toBeTruthy();

await selectEvent(user, /custom event|evento personalizado/i);
expect(save().disabled).toBe(true);
expect(screen.getByText(/type the custom event name to save|digite o nome do evento personalizado para salvar/i)).toBeTruthy();
await user.click(save());
expect(onUpdate).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();

await user.type(screen.getByPlaceholderText(/custom event name|nome do evento custom/i), 'button_clicked');
expect(save().disabled).toBe(false);
await user.click(save());
expect(onUpdate).toHaveBeenCalledTimes(1);
expect((onUpdate.mock.calls[0][1] as JourneyTriggerNodeData).eventName).toBe('button_clicked');
});

it('AC6: selecting "Custom event" in Básico reveals the free-text custom-name input', async () => {
Expand Down
18 changes: 10 additions & 8 deletions src/components/journey/nodes/trigger/JourneyTriggerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ export function JourneyTriggerPanel({
const [showPipelineStageChangedConfig, setShowPipelineStageChangedConfig] = useState(
data.triggerType === 'pipelineStageChanged',
);
// Required-field validity reported by EventBasicConfig. True (non-blocking)
// whenever the event config isn't shown, so other trigger types can always Save.
// Event-config validity reported by EventBasicConfig: an event is chosen, and
// a custom one has its name. True (non-blocking) whenever the event config
// isn't shown, so other trigger types can always Save.
const [eventPropsValid, setEventPropsValid] = useState(true);
// Active tab for the event trigger's Básico/Avançado layout (EVO-1276).
const [activeTab, setActiveTab] = useState<'basico' | 'avancado'>('basico');
Expand All @@ -80,9 +81,9 @@ export function JourneyTriggerPanel({
}, [data]);

const handleSave = () => {
// Event-tabs path uses an enabled Save + this guard (instead of saveDisabled)
// so an invalid save snaps the user back to Básico where the inline
// required-field error lives, rather than silently doing nothing. See EVO-1276.
// Save is disabled while the event config is invalid (CRM-519, same
// dirty && isValid pattern as the action panels); this guard only keeps a
// programmatic call from persisting a half-configured trigger.
if (showEventConfig && !eventPropsValid) {
setActiveTab('basico');
return;
Expand Down Expand Up @@ -216,15 +217,16 @@ export function JourneyTriggerPanel({
onCancel: onClose,
onSave: handleSave,
dirty,
// Event trigger: no event chosen (or custom without a name) keeps Save off;
// the inline message under the field says what is missing (CRM-519).
saveDisabled: showEventConfig && !eventPropsValid,
saveLabel: t('panels.actions.save'),
cancelLabel: t('panels.actions.cancel'),
savingAriaLabel: t('modal.actions.saving'),
contentClassName: 'max-w-[800px]',
};

// Event trigger type: Básico/Avançado tabs (EVO-1276). Save is enabled and the
// empty-required-field case is handled by handleSave's guard, so the user is
// bounced to Básico where the inline error is visible.
// Event trigger type: Básico/Avançado tabs (EVO-1276).
if (showEventConfig) {
const advancedBadge =
variableMappingsCount > 0 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import '@/i18n/config';
// Focused coverage for the extracted Básico half (EVO-1276). The broader event
// flow is already locked in by EventConfiguration.spec.tsx (which now drives this
// component through composition); these tests assert the pieces this subcomponent
// directly owns: validity reporting, the required-field marker, and the
// preserve/clear switch dialog.
// directly owns: validity reporting, the optional-filter picker, and the
// event-switch notice with Undo.

interface HarnessProps {
initialEventName?: string;
Expand Down Expand Up @@ -49,44 +49,95 @@ async function selectEvent(user: ReturnType<typeof userEvent.setup>, label: RegE
}

describe('EventBasicConfig (EVO-1276)', () => {
it('reports validity = false with no event selected and flips to true once required fields are filled', async () => {
it('reports validity = false with no event selected and true as soon as one is chosen', async () => {
const onValidityChange = vi.fn();
const user = userEvent.setup();
render(<Harness onValidityChange={onValidityChange} />);

expect(onValidityChange).toHaveBeenLastCalledWith(false);

// CRM-519: properties are optional filters, never required inputs.
await selectEvent(user, /contact created|contato criado/i);
expect(onValidityChange).toHaveBeenLastCalledWith(false); // id + source still empty

await user.type(screen.getByLabelText(/^id\s*\*?$/i), 'id-1');
await user.type(screen.getByLabelText(/^source\s*\*?$/i), 'crm');
expect(onValidityChange).toHaveBeenLastCalledWith(true);
});

it('renders required schema fields with a "*" marker', async () => {
it('offers schema keys as filters without a required marker or the event ids', async () => {
const user = userEvent.setup();
render(<Harness />);
await selectEvent(user, /message delivered|mensagem entregue/i);

const label = screen.getByText('message_id');
expect(within(label).getByText('*')).toBeTruthy();
expect(screen.queryByText('*')).toBeNull();
expect(screen.queryByText('message_id')).toBeNull();
expect(screen.getAllByRole('combobox').length).toBeGreaterThan(1); // "+ Add filter" picker
});

it('prompts preserve/clear when switching events with existing values', async () => {
it('keeps compatible filters on an event switch, reports the dropped ones and undoes on request', async () => {
const onEventPropertiesChange = vi.fn();
const user = userEvent.setup();
render(
<Harness
initialEventName="message.delivered"
initialEventProperties={[
{ path: 'message_id', operator: { type: 'Equals', value: 'm-1' } },
{ path: 'channel_type', operator: { type: 'Equals', value: 'wa' } },
]}
onEventPropertiesChange={onEventPropertiesChange}
/>,
);

await selectEvent(user, /conversation created|conversa criada/i);
expect(
await screen.findByText(/preserve compatible values|preservar valores compatíveis/i),
).toBeTruthy();

// No dialog: channel_type (same key + type) stays, message_id is dropped and reported.
expect(onEventPropertiesChange).toHaveBeenLastCalledWith([
{ path: 'channel_type', operator: { type: 'Equals', value: 'wa' } },
]);
const notice = screen.getByRole('status');
expect(notice.textContent).toMatch(/1 filtro removido|1 filter removed/i);

await user.click(within(notice).getByRole('button', { name: /desfazer|undo/i }));
expect(onEventPropertiesChange).toHaveBeenLastCalledWith([
{ path: 'message_id', operator: { type: 'Equals', value: 'm-1' } },
{ path: 'channel_type', operator: { type: 'Equals', value: 'wa' } },
]);
expect(screen.queryByRole('status')).toBeNull();
expect(screen.getByRole('textbox', { name: /message_id/ })).toHaveProperty('value', 'm-1');
});

it('drops the Undo offer once the user edits a filter after the switch', async () => {
const user = userEvent.setup();
render(
<Harness
initialEventName="message.delivered"
initialEventProperties={[
{ path: 'message_id', operator: { type: 'Equals', value: 'm-1' } },
{ path: 'content', operator: { type: 'Equals', value: 'oi' } },
]}
/>,
);

// conversation.activity keeps `content` and drops `message_id`.
await selectEvent(user, /conversation activity|atividade na conversa/i);
expect(screen.getByRole('status')).toBeTruthy();

await user.type(screen.getByRole('textbox', { name: /content/ }), '!');
expect(screen.queryByRole('status')).toBeNull();
});

it('custom mode is only valid once a name is typed, and says so under the field', async () => {
const onValidityChange = vi.fn();
const user = userEvent.setup();
render(<Harness onValidityChange={onValidityChange} />);

// No event yet: the message sits under the selector.
expect(screen.getByText(/choose an event|escolha um evento/i)).toBeTruthy();

await selectEvent(user, /custom event|evento personalizado/i);
expect(onValidityChange).toHaveBeenLastCalledWith(false);
expect(screen.queryByText(/choose an event|escolha um evento/i)).toBeNull();
expect(screen.getByText(/type the custom event name to save|digite o nome do evento personalizado para salvar/i)).toBeTruthy();

await user.type(screen.getByPlaceholderText(/custom event name|nome do evento custom/i), 'button_clicked');
expect(onValidityChange).toHaveBeenLastCalledWith(true);
expect(screen.queryByText(/type the custom event name to save|digite o nome do evento personalizado para salvar/i)).toBeNull();
});
});
Loading
Loading