): JsonPatchOperation[] {
+ return Object.keys(dirtyFields).map((name) => ({
+ op: get(name, originalValue) ? 'replace' : 'add',
+ path: `/${name.replace(/[.]/g, '/')}`,
+ value: get(name, values) || '',
+ }))
+}
diff --git a/src/pages/dataElementGroups/edit/index.ts b/src/pages/dataElementGroups/edit/index.ts
new file mode 100644
index 00000000..0069ca37
--- /dev/null
+++ b/src/pages/dataElementGroups/edit/index.ts
@@ -0,0 +1 @@
+export { createJsonPatchOperations } from './createJsonPatchOperations'
diff --git a/src/pages/dataElementGroups/fields/CodeField.tsx b/src/pages/dataElementGroups/fields/CodeField.tsx
new file mode 100644
index 00000000..fedc4eb8
--- /dev/null
+++ b/src/pages/dataElementGroups/fields/CodeField.tsx
@@ -0,0 +1,25 @@
+import i18n from '@dhis2/d2-i18n'
+import { InputFieldFF } from '@dhis2/ui'
+import React from 'react'
+import { Field as FieldRFF } from 'react-final-form'
+import { useCheckMaxLengthFromSchema } from '../../../lib'
+import type { SchemaName } from '../../../types'
+
+export function CodeField() {
+ const validate = useCheckMaxLengthFromSchema(
+ 'dataElement' as SchemaName,
+ 'code'
+ )
+
+ return (
+
+ )
+}
diff --git a/src/pages/dataElementGroups/fields/DataElementsField.module.css b/src/pages/dataElementGroups/fields/DataElementsField.module.css
new file mode 100644
index 00000000..f40ea288
--- /dev/null
+++ b/src/pages/dataElementGroups/fields/DataElementsField.module.css
@@ -0,0 +1,8 @@
+.dataElementsOptionsFooter {
+ padding: var(--spacers-dp8) 0;
+}
+
+.dataElementsPickedHeader {
+ padding: var(--spacers-dp8) 0;
+ margin: 0;
+}
diff --git a/src/pages/dataElementGroups/fields/DataElementsField.tsx b/src/pages/dataElementGroups/fields/DataElementsField.tsx
new file mode 100644
index 00000000..391f7cba
--- /dev/null
+++ b/src/pages/dataElementGroups/fields/DataElementsField.tsx
@@ -0,0 +1,70 @@
+import i18n from '@dhis2/d2-i18n'
+import { ButtonStrip, Button, Field } from '@dhis2/ui'
+import React, { useRef } from 'react'
+import { useField } from 'react-final-form'
+import { useHref } from 'react-router'
+import { DataElementsTransfer } from '../../../components'
+import classes from './DataElementsField.module.css'
+
+/**
+ *
+ * DataElements
+ *
+ */
+export function DataElementsField() {
+ const name = 'dataElements'
+ const { input, meta } = useField(name, {
+ multiple: true,
+ format: (dataElements: { id: string }[]) =>
+ dataElements?.map((dataElements) => dataElements.id),
+ parse: (ids: string[]) => ids.map((id) => ({ id })),
+ validateFields: [],
+ })
+
+ const newDataElementsLink = useHref('/dataElements/new')
+ const dataElementsHandle = useRef({
+ refetch: () => {
+ throw new Error('Not initialized')
+ },
+ })
+
+ const rightHeader = (
+
+ {i18n.t('Selected data elements')}
+
+ )
+
+ const leftFooter = (
+
+
+
+
+
+
+
+ )
+
+ return (
+
+ input.onChange(selected)}
+ rightHeader={rightHeader}
+ leftFooter={leftFooter}
+ />
+
+ )
+}
diff --git a/src/pages/dataElementGroups/fields/DescriptionField.tsx b/src/pages/dataElementGroups/fields/DescriptionField.tsx
new file mode 100644
index 00000000..c11298b8
--- /dev/null
+++ b/src/pages/dataElementGroups/fields/DescriptionField.tsx
@@ -0,0 +1,28 @@
+import i18n from '@dhis2/d2-i18n'
+import { TextAreaFieldFF } from '@dhis2/ui'
+import React from 'react'
+import { Field as FieldRFF } from 'react-final-form'
+import { useCheckMaxLengthFromSchema } from '../../../lib'
+import type { SchemaName } from '../../../types'
+
+export function DescriptionField() {
+ const validate = useCheckMaxLengthFromSchema(
+ 'dataElement' as SchemaName,
+ 'formName'
+ )
+
+ return (
+
+ )
+}
diff --git a/src/pages/dataElementGroups/fields/NameField.tsx b/src/pages/dataElementGroups/fields/NameField.tsx
new file mode 100644
index 00000000..b6c38724
--- /dev/null
+++ b/src/pages/dataElementGroups/fields/NameField.tsx
@@ -0,0 +1,64 @@
+import i18n from '@dhis2/d2-i18n'
+import { InputFieldFF } from '@dhis2/ui'
+import React, { useMemo } from 'react'
+import { Field as FieldRFF, useField } from 'react-final-form'
+import { useParams } from 'react-router-dom'
+import {
+ composeAsyncValidators,
+ required,
+ useCheckMaxLengthFromSchema,
+ useIsFieldValueUnique,
+} from '../../../lib'
+import { SchemaName } from '../../../types'
+import type { FormValues } from '../form'
+
+function useValidator() {
+ const params = useParams()
+ const dataElementId = params.id as string
+ const checkIsValueTaken = useIsFieldValueUnique({
+ model: 'dataElements',
+ field: 'name',
+ id: dataElementId,
+ })
+
+ const checkMaxLength = useCheckMaxLengthFromSchema(
+ SchemaName.dataElement,
+ 'name'
+ )
+
+ return useMemo(
+ () =>
+ composeAsyncValidators([
+ checkIsValueTaken,
+ checkMaxLength,
+ required,
+ ]),
+ [checkIsValueTaken, checkMaxLength]
+ )
+}
+
+export function NameField() {
+ const validator = useValidator()
+ const { meta } = useField('name', {
+ subscription: { validating: true },
+ })
+
+ return (
+
+ loading={meta.validating}
+ component={InputFieldFF}
+ dataTest="dataelementsformfields-name"
+ required
+ inputWidth="400px"
+ label={i18n.t('{{fieldLabel}} (required)', {
+ fieldLabel: i18n.t('Name'),
+ })}
+ name="name"
+ helpText={i18n.t(
+ 'A data element name should be concise and easy to recognize.'
+ )}
+ validate={(name?: string) => validator(name)}
+ validateFields={[]}
+ />
+ )
+}
diff --git a/src/pages/dataElementGroups/fields/ShortNameField.tsx b/src/pages/dataElementGroups/fields/ShortNameField.tsx
new file mode 100644
index 00000000..7e1845d5
--- /dev/null
+++ b/src/pages/dataElementGroups/fields/ShortNameField.tsx
@@ -0,0 +1,62 @@
+import i18n from '@dhis2/d2-i18n'
+import { InputFieldFF } from '@dhis2/ui'
+import React, { useMemo } from 'react'
+import { Field as FieldRFF, useField } from 'react-final-form'
+import { useParams } from 'react-router-dom'
+import {
+ composeAsyncValidators,
+ required,
+ useCheckMaxLengthFromSchema,
+ useIsFieldValueUnique,
+} from '../../../lib'
+import type { SchemaName } from '../../../types'
+import type { FormValues } from '../form'
+
+function useValidator() {
+ const params = useParams()
+ const dataElementId = params.id as string
+ const checkIsValueTaken = useIsFieldValueUnique({
+ model: 'dataElements',
+ field: 'name',
+ id: dataElementId,
+ })
+
+ const checkMaxLength = useCheckMaxLengthFromSchema(
+ 'dataElement' as SchemaName,
+ 'formName'
+ )
+
+ return useMemo(
+ () =>
+ composeAsyncValidators([
+ checkIsValueTaken,
+ checkMaxLength,
+ required,
+ ]),
+ [checkIsValueTaken, checkMaxLength]
+ )
+}
+
+export function ShortNameField() {
+ const validator = useValidator()
+ const { meta } = useField('shortName', {
+ subscription: { validating: true },
+ })
+
+ return (
+
+ loading={meta.validating}
+ component={InputFieldFF}
+ dataTest="dataelementsformfields-shortname"
+ required
+ inputWidth="400px"
+ label={i18n.t('{{fieldLabel}} (required)', {
+ fieldLabel: i18n.t('Short name'),
+ })}
+ name="shortName"
+ helpText={i18n.t('Often used in reports where space is limited')}
+ validate={(name?: string) => validator(name)}
+ validateFields={[]}
+ />
+ )
+}
diff --git a/src/pages/dataElementGroups/fields/index.ts b/src/pages/dataElementGroups/fields/index.ts
new file mode 100644
index 00000000..7907b2d4
--- /dev/null
+++ b/src/pages/dataElementGroups/fields/index.ts
@@ -0,0 +1,5 @@
+export { CodeField } from './CodeField'
+export { DataElementsField } from './DataElementsField'
+export { DescriptionField } from './DescriptionField'
+export { NameField } from './NameField'
+export { ShortNameField } from './ShortNameField'
diff --git a/src/pages/dataElementGroups/form/DataElementGroupFormFields.tsx b/src/pages/dataElementGroups/form/DataElementGroupFormFields.tsx
new file mode 100644
index 00000000..55fcdfc9
--- /dev/null
+++ b/src/pages/dataElementGroups/form/DataElementGroupFormFields.tsx
@@ -0,0 +1,63 @@
+import i18n from '@dhis2/d2-i18n'
+import React from 'react'
+import {
+ StandardFormSection,
+ StandardFormSectionTitle,
+ StandardFormSectionDescription,
+ StandardFormField,
+} from '../../../components'
+import {
+ CodeField,
+ DataElementsField,
+ DescriptionField,
+ NameField,
+ ShortNameField,
+} from '../fields'
+
+export function DataElementGroupFormFields() {
+ return (
+ <>
+
+
+ {i18n.t('Basic information')}
+
+
+
+ {i18n.t(
+ 'Set up the information for this data element group'
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {i18n.t('Data elements')}
+
+
+
+ {i18n.t('@TODO')}
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/pages/dataElementGroups/form/dataElementGroupSchema.ts b/src/pages/dataElementGroups/form/dataElementGroupSchema.ts
new file mode 100644
index 00000000..8e409109
--- /dev/null
+++ b/src/pages/dataElementGroups/form/dataElementGroupSchema.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod'
+
+export const dataElementGroupSchema = z
+ .object({
+ name: z.string().trim(),
+ shortName: z.string().trim(),
+ code: z.string().trim(),
+ description: z.string().trim(),
+ dataElements: z.array(z.object({ id: z.string() })),
+ })
+ .partial()
diff --git a/src/pages/dataElementGroups/form/index.ts b/src/pages/dataElementGroups/form/index.ts
new file mode 100644
index 00000000..e1ecc676
--- /dev/null
+++ b/src/pages/dataElementGroups/form/index.ts
@@ -0,0 +1,3 @@
+export { DataElementGroupFormFields } from './DataElementGroupFormFields'
+export type { FormValues } from './types'
+export { validate } from './validate'
diff --git a/src/pages/dataElementGroups/form/types.ts b/src/pages/dataElementGroups/form/types.ts
new file mode 100644
index 00000000..824c4514
--- /dev/null
+++ b/src/pages/dataElementGroups/form/types.ts
@@ -0,0 +1,3 @@
+import { DataElementGroup } from '../../../types/generated'
+
+export type FormValues = DataElementGroup
diff --git a/src/pages/dataElementGroups/form/validate.ts b/src/pages/dataElementGroups/form/validate.ts
new file mode 100644
index 00000000..b9b9fcd5
--- /dev/null
+++ b/src/pages/dataElementGroups/form/validate.ts
@@ -0,0 +1,27 @@
+import { setIn } from 'final-form'
+import { dataElementGroupSchema } from './dataElementGroupSchema'
+import type { FormValues } from './types'
+
+// @TODO: Figure out if there's a utility for this? I couldn't find one
+function segmentsToPath(segments: Array) {
+ return segments.reduce((path, segment) => {
+ return typeof segment === 'number'
+ ? `${path}[${segment}]`
+ : `${path}.${segment}`
+ }) as string
+}
+
+export function validate(values: FormValues) {
+ const zodResult = dataElementGroupSchema.safeParse(values)
+
+ if (zodResult.success !== false) {
+ return undefined
+ }
+
+ const allFormErrors = zodResult.error.issues.reduce((formErrors, error) => {
+ const errorPath = segmentsToPath(error.path)
+ return setIn(formErrors, errorPath, error.message)
+ }, {})
+
+ return allFormErrors
+}
diff --git a/src/pages/dataElements/New.tsx b/src/pages/dataElements/New.tsx
index b505aead..a97b3d3a 100644
--- a/src/pages/dataElements/New.tsx
+++ b/src/pages/dataElements/New.tsx
@@ -150,6 +150,7 @@ export const Component = () => {
navigate(listPath)}
/>
)}
@@ -160,13 +161,14 @@ export const Component = () => {
function FormContents({
submitError,
+ onCancelClick,
submitting,
}: {
submitting: boolean
+ onCancelClick: () => void
submitError?: string
}) {
const formErrorRef = useRef(null)
- const navigate = useNavigate()
useEffect(() => {
if (submitError) {
@@ -199,7 +201,7 @@ function FormContents({
cancelLabel={i18n.t('Exit without saving')}
submitLabel={i18n.t('Create data element')}
submitting={submitting}
- onCancelClick={() => navigate(listPath)}
+ onCancelClick={onCancelClick}
/>
diff --git a/src/pages/dataElements/index.ts b/src/pages/dataElements/index.ts
new file mode 100644
index 00000000..e69de29b