Skip to content
Open
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
48 changes: 34 additions & 14 deletions .github/workflows/deploy-to-feature-environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,25 +294,45 @@ jobs:
});

console.log(result);
await new Promise(resolve => setTimeout(resolve, 10000));

const runs = await github.rest.actions.listWorkflowRunsForRepo({
owner: 'opencrvs',
repo: 'e2e',
event: 'repository_dispatch',
per_page: 1
});
// Record the time just before dispatching so we can ignore any
// pre-existing runs when polling below.
const dispatchedAt = new Date();

if (runs.data.workflow_runs.length > 0) {
const runId = runs.data.workflow_runs[0].id;
console.log(`Captured runId: ${runId}`);
// GitHub Actions can take variable time to register a newly dispatched
// run in the API. Poll every 5 seconds for up to 60 seconds, and only
// accept a run whose created_at is >= dispatchedAt to avoid picking up
// a stale run from a previous dispatch (e.g. one triggered by farajaland).
let runId;
for (let i = 0; i < 12; i++) {
await new Promise(resolve => setTimeout(resolve, 5000));

// Set the runId as an output
core.setOutput('run_id', runId);
} else {
throw new Error('No workflow run found.');
const runs = await github.rest.actions.listWorkflowRunsForRepo({
owner: 'opencrvs',
repo: 'e2e',
event: 'repository_dispatch',
per_page: 5
});

const newRun = runs.data.workflow_runs.find(
r => new Date(r.created_at) >= dispatchedAt
);

if (newRun) {
runId = newRun.id;
console.log(`Captured runId: ${runId} (created at ${newRun.created_at})`);
break;
}

console.log(`Run not visible yet, retrying... (attempt ${i + 1}/12)`);
}

if (!runId) {
throw new Error('No workflow run found after 60 seconds.');
}
Comment on lines +298 to +332
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added it because we found a stale link here: https://github.com/opencrvs/opencrvs-core/actions/runs/25014351156/job/73261242356#step:10:1

Reason: The newly triggered run hadn't appeared in the API yet after the 10-second wait. GitHub Actions has variable latency before a freshly dispatched run shows up in listWorkflowRunsForRepo. When the list was fetched, the new run wasn't there yet, so it returned the previous run from hours ago.

Fix result example: https://github.com/opencrvs/opencrvs-core/actions/runs/25017463464/job/73269507432?pr=12459#step:9:97


core.setOutput('run_id', runId);

- name: Print link to E2E workflow run
id: print-links
run: |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
Expand Down Expand Up @@ -530,6 +531,100 @@ export const AlternateVariantDefaultIsUsed: Story = {
}
}

const hiddenListenerFields = [
{
id: 'form.type',
type: FieldType.SELECT,
required: true,
label: generateTranslationConfig('place type'),
options: [
{
label: generateTranslationConfig('Private home'),
value: 'PRIVATE_HOME'
},
{
label: generateTranslationConfig('Health facility'),
value: 'HEALTH_FACILITY'
}
]
},
{
id: 'form.address',
type: FieldType.TEXT,
label: generateTranslationConfig('address'),
defaultValue: 'district-123',
parent: field('form.type'),
conditionals: [
{
type: ConditionalType.SHOW,
conditional: field('form.type').isEqualTo('PRIVATE_HOME')
}
]
},
{
id: 'form.derivedId',
type: FieldType.TEXT,
label: generateTranslationConfig('derived id'),
parent: field('form.type'),
value: field('form.address')
}
] satisfies FieldConfig[]

/**
* Regression test for the bug where changing a parent field to a value that hides a listener
* field would cause the listener field's defaultValue to be applied to the hidden field,
* which would then propagate to aggregator fields via their `value` reference.
*
* Fix: hidden listener fields are cleared to `null` instead of having `defaultValue` applied.
*/
export const HiddenListenerDefaultNotPropagated: Story = {
name: 'Parent change clears hidden listener field instead of applying its default value',
parameters: {
layout: 'centered',
chromatic: { disableSnapshot: true }
},
render: function Component(args) {
return (
<StyledFormFieldGenerator
{...args}
fields={hiddenListenerFields}
formValues={{ 'form.type': 'PRIVATE_HOME' }}
id="my-form"
/>
)
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement)

await step(
'Renders with address field visible and its default value applied',
async () => {
await expect(
await canvas.findByTestId('text__form____address')
).toHaveValue('district-123')
}
)

await step(
'Changes type to Health facility, hiding the address field',
async () => {
await userEvent.click(await canvas.findByText('Private home'))
await userEvent.click(await canvas.findByText('Health facility'))
}
)

await step(
'Address field is hidden and derived id is cleared — not the hidden default',
async () => {
await expect(canvas.queryByText('address')).not.toBeInTheDocument()
await expect(canvas.getByTestId('text__form____derivedId')).toHaveValue(
''
)
}
)
}
}

export const CustomRequiredValidationMessage: Story = {
name: 'Custom required validation message',
parameters: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ import {
import { useOnlineStatus } from '@client/utils'
import { useDefaultValue } from '@client/v2-events/hooks/useDefaultValue'
import { useEventFormData } from '@client/v2-events/features/events/useEventFormData'
import { makeFormikFieldIdsOpenCRVSCompatible, resolveSyncedFieldValue } from './utils'
import {
makeFormikFieldIdsOpenCRVSCompatible,
resolveSyncedFieldValue
} from './utils'
import { FormItem, GeneratedInputField } from './GeneratedInputField'

type AllProps = {
Expand Down Expand Up @@ -206,7 +209,9 @@ export function FormSectionComponent({
const getDefaultValue = useDefaultValue()
const { cacheHiddenFieldValue, popHiddenFieldValue } = useEventFormData()

const fullFormFields = eventConfig ? findAllFields(eventConfig).concat(pageFields) : pageFields
const fullFormFields = eventConfig
? findAllFields(eventConfig).concat(pageFields)
: pageFields
const listenerFieldsByParentId = getParentsOfListenerFields(fullFormFields)

/** Sets the value for fields that listen to another field via `parent` and `value` properties */
Expand All @@ -219,18 +224,42 @@ export function FormSectionComponent({
makeFormFieldIdFormikCompatible
)

const firstNonFalsyValue = resolveSyncedFieldValue(listenerField, (syncRef) =>
get(
fieldValues,
flattenFieldReference(syncRef).map(makeFormFieldIdFormikCompatible)
)
const firstNonFalsyValue = resolveSyncedFieldValue(
listenerField,
(syncRef) =>
get(
fieldValues,
flattenFieldReference(syncRef).map(makeFormFieldIdFormikCompatible)
)
)

if (firstNonFalsyValue) {
set(fieldValues, formikCompatibleListenerFieldPath, firstNonFalsyValue)
return
}

const formContext = {
...fullForm,
...makeFormikFieldIdsOpenCRVSCompatible(fieldValues)
}

// Hidden listener fields are cleared to undefined so their stale values
// don't leak into other fields that read from them (e.g. via `value` refs).
// We return early to skip applying the defaultValue, which would otherwise
// pollute the form state for fields that aren't currently relevant.

// Must be undefined, never null:
// null has a specific semantic in the declaration payload — it signals an
// intentional field removal and is preserved by getCleanedDeclarationDiff
// (via omitHiddenPaginatedFields with retainNullValues=true). Sending null
// for fields that were never part of the current action (e.g. correction
// form fields appearing in a declare payload) corrupts the event state.
// undefined is omitted from JSON serialisation and is therefore safe.
if (!isFieldVisible(listenerField, formContext, validatorContext)) {
set(fieldValues, formikCompatibleListenerFieldPath, undefined)
return
}

const defaultValue = getDefaultValue(listenerField)

set(fieldValues, formikCompatibleListenerFieldPath, defaultValue)
Expand Down
Loading