diff --git a/i18n/en.pot b/i18n/en.pot index 83e93b16..05d207ee 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-03-05T18:46:53.934Z\n" -"PO-Revision-Date: 2024-03-05T18:46:53.934Z\n" +"POT-Creation-Date: 2024-03-05T22:55:26.244Z\n" +"PO-Revision-Date: 2024-03-05T22:55:26.244Z\n" msgid "schemas" msgstr "schemas" @@ -72,6 +72,30 @@ msgstr "Something went wrong when submitting the form" msgid "Save and close" msgstr "Save and close" +msgid "" +msgstr "" + +msgid "Code" +msgstr "Code" + +msgid "Description" +msgstr "Description" + +msgid "A name should be concise and easy to recognize." +msgstr "A name should be concise and easy to recognize." + +msgid "{{fieldLabel}} (required)" +msgstr "{{fieldLabel}} (required)" + +msgid "Name" +msgstr "Name" + +msgid "Often used in reports where space is limited" +msgstr "Often used in reports where space is limited" + +msgid "Short name" +msgstr "Short name" + msgid "Failed to load {{label}}" msgstr "Failed to load {{label}}" @@ -87,12 +111,15 @@ msgstr "Category combo" msgid "None" msgstr "None" +msgid "Filter data element groups" +msgstr "Filter data element groups" + +msgid "Filter data elements" +msgstr "Filter data elements" + msgid "Filter legend sets" msgstr "Filter legend sets" -msgid "" -msgstr "" - msgid "Option set" msgstr "Option set" @@ -120,12 +147,6 @@ msgstr "An error occurred while loading the items." msgid "{{modelName}} management" msgstr "{{modelName}} management" -msgid "Short name" -msgstr "Short name" - -msgid "Code" -msgstr "Code" - msgid "Created by" msgstr "Created by" @@ -621,9 +642,6 @@ msgstr "Favorite" msgid "Domain type" msgstr "Domain type" -msgid "Name" -msgstr "Name" - msgid "Sharing" msgstr "Sharing" @@ -648,6 +666,48 @@ msgstr "Custom attributes" msgid "Exit without saving" msgstr "Exit without saving" +msgid "Create data element group" +msgstr "Create data element group" + +msgid "Compulsory" +msgstr "Compulsory" + +msgid "Data dimension" +msgstr "Data dimension" + +msgid "Selected data element groups" +msgstr "Selected data element groups" + +msgid "Refresh list" +msgstr "Refresh list" + +msgid "Add new" +msgstr "Add new" + +msgid "Basic information" +msgstr "Basic information" + +msgid "Set up the information for this data element group" +msgstr "Set up the information for this data element group" + +msgid "Explain the purpose of this data element group." +msgstr "Explain the purpose of this data element group." + +msgid "@TODO" +msgstr "@TODO" + +msgid "Custom fields for your DHIS2 instance" +msgstr "Custom fields for your DHIS2 instance" + +msgid "Selected data elements" +msgstr "Selected data elements" + +msgid "Explain the purpose of this data element and how it's measured." +msgstr "Explain the purpose of this data element and how it's measured." + +msgid "A data element name should be concise and easy to recognize." +msgstr "A data element name should be concise and easy to recognize." + msgid "Create data element" msgstr "Create data element" @@ -660,9 +720,6 @@ msgstr "The default way to aggregate this data element in analytics." msgid "Disabled for the selected value type." msgstr "Disabled for the selected value type." -msgid "{{fieldLabel}} (required)" -msgstr "{{fieldLabel}} (required)" - msgid "Aggregation type" msgstr "Aggregation type" @@ -676,18 +733,6 @@ msgstr "" "A color and icon are helpful for identifying data elements in " "information-dense screens." -msgid "Loading custom attributes" -msgstr "Loading custom attributes" - -msgid "Something went wrong with retrieving the custom attributes" -msgstr "Something went wrong with retrieving the custom attributes" - -msgid "Description" -msgstr "Description" - -msgid "Explain the purpose of this data element and how it's measured." -msgstr "Explain the purpose of this data element and how it's measured." - msgid "Domain" msgstr "Domain" @@ -703,8 +748,8 @@ msgstr "Use a pattern to limit what information can be entered." msgid "e.g. 999-000-0000" msgstr "e.g. 999-000-0000" -msgid "StandardForm name" -msgstr "StandardForm name" +msgid "Form name" +msgstr "Form name" msgid "An alternative name used in section or automatic data entry forms." msgstr "An alternative name used in section or automatic data entry forms." @@ -712,15 +757,6 @@ msgstr "An alternative name used in section or automatic data entry forms." msgid "Selected legends" msgstr "Selected legends" -msgid "Refresh list" -msgstr "Refresh list" - -msgid "Add new" -msgstr "Add new" - -msgid "A data element name should be concise and easy to recognize." -msgstr "A data element name should be concise and easy to recognize." - msgid "Option set comment" msgstr "Option set comment" @@ -730,9 +766,6 @@ msgstr "Choose a set of predefined comment for data entry" msgid "Choose a set of predefined options for data entry" msgstr "Choose a set of predefined options for data entry" -msgid "Often used in reports where space is limited" -msgstr "Often used in reports where space is limited" - msgid "Url" msgstr "Url" @@ -752,12 +785,12 @@ msgstr "" msgid "Store zero data values" msgstr "Store zero data values" -msgid "Basic information" -msgstr "Basic information" - msgid "Set up the information for this data element" msgstr "Set up the information for this data element" +msgid "Explain the purpose of this data element." +msgstr "Explain the purpose of this data element." + msgid "Disaggregation and Option sets" msgstr "Disaggregation and Option sets" @@ -792,9 +825,6 @@ msgstr "" "included. PHU will still be available for the PHU level, but not included " "in the aggregations to the levels above." -msgid "Custom fields for your DHIS2 instance" -msgstr "Custom fields for your DHIS2 instance" - msgid "Metadata management" msgstr "Metadata management" diff --git a/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx b/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx index 91c2cf91..ce165dbf 100644 --- a/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx +++ b/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx @@ -50,7 +50,7 @@ type OnFilterChange = ({ value }: { value: string }) => void interface SearchableSingleSelectPropTypes { onChange: OnChange onFilterChange: OnFilterChange - onEndReached: () => void + onEndReached?: () => void onRetryClick: () => void dense?: boolean options: Option[] @@ -103,7 +103,7 @@ export const SearchableSingleSelect = ({ const [{ isIntersecting }] = entries if (isIntersecting) { - onEndReached() + onEndReached?.() } }, { threshold: 0.8 } @@ -141,6 +141,7 @@ export const SearchableSingleSelect = ({
setFilterValue(value ?? '')} placeholder={i18n.t('Filter options')} diff --git a/src/components/form/DefaultFormContents.module.css b/src/components/form/DefaultFormContents.module.css new file mode 100644 index 00000000..de17044a --- /dev/null +++ b/src/components/form/DefaultFormContents.module.css @@ -0,0 +1,15 @@ +.form { + background: var(--colors-white); + padding: var(--spacers-dp16); + padding-bottom: var(--spacers-dp32); +} + +.formActions { + position: fixed; + left: 0; + bottom: 0; + width: 100vw; + padding: var(--spacers-dp16); + box-shadow: 0 0 3px rgba(0, 0, 0, 0.8); + background: var(--colors-white); +} diff --git a/src/components/form/DefaultFormContents.tsx b/src/components/form/DefaultFormContents.tsx new file mode 100644 index 00000000..2f0bd6af --- /dev/null +++ b/src/components/form/DefaultFormContents.tsx @@ -0,0 +1,58 @@ +import i18n from '@dhis2/d2-i18n' +import { NoticeBox } from '@dhis2/ui' +import React, { useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { getSectionPath } from '../../lib' +import { ModelSection } from '../../types' +import { StandardFormSection, StandardFormActions } from '../standardForm' +import classes from './DefaultFormContents.module.css' + +export function DefaultFormContents({ + children, + section, + submitError, + submitting, +}: { + children: React.ReactNode + section: ModelSection + submitting: boolean + submitError?: string +}) { + const formErrorRef = useRef(null) + const navigate = useNavigate() + + const listPath = getSectionPath(section) + useEffect(() => { + if (submitError) { + formErrorRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + }, [submitError]) + + return ( + <> +
{children}
+ {submitError && ( + +
+ + {submitError} + +
+
+ )} +
+ navigate(listPath)} + /> +
+ + ) +} diff --git a/src/pages/dataElements/fields/CustomAttributes.tsx b/src/components/form/attributes/CustomAttributes.tsx similarity index 72% rename from src/pages/dataElements/fields/CustomAttributes.tsx rename to src/components/form/attributes/CustomAttributes.tsx index 4329151b..c02ba003 100644 --- a/src/pages/dataElements/fields/CustomAttributes.tsx +++ b/src/components/form/attributes/CustomAttributes.tsx @@ -1,18 +1,16 @@ import i18n from '@dhis2/d2-i18n' -import { - InputFieldFF, - NoticeBox, - SingleSelectFieldFF, - TextAreaFieldFF, -} from '@dhis2/ui' +import { InputFieldFF, SingleSelectFieldFF, TextAreaFieldFF } from '@dhis2/ui' import * as React from 'react' -import { Field as FieldRFF } from 'react-final-form' -import { StandardFormSection } from '../../../components' -import { Attribute } from '../../../types/generated' -import { useCustomAttributesQuery } from './useCustomAttributesQuery' +import { Field as FieldRFF, useFormState } from 'react-final-form' +import { StandardFormSection } from '../..' +import { Attribute, AttributeValue } from '../../../types/generated' const inputWidth = '440px' +type ValuesWithAttributes = { + attributeValues: AttributeValue[] +} + type CustomAttributeProps = { attribute: Attribute index: number @@ -81,30 +79,17 @@ function CustomAttribute({ attribute, index }: CustomAttributeProps) { } export function CustomAttributes() { - const customAttributes = useCustomAttributesQuery() - const loading = customAttributes.loading - const error = customAttributes.error - - if (loading) { - return <>{i18n.t('Loading custom attributes')} - } + const formState = useFormState({ + subscription: { initialValues: true }, + }) - if (error) { - return ( - - {error.toString()} - - ) - } + const customAttributes = formState.initialValues.attributeValues?.map( + (av) => av.attribute + ) return ( <> - {customAttributes.data?.map((customAttribute, index) => { + {customAttributes?.map((customAttribute, index) => { return ( ) => ({ fields: [ 'id', 'mandatory', @@ -14,8 +15,8 @@ const CUSTOM_ATTRIBUTES_QUERY = { 'optionSet[options[id,displayName,name,code]]', ], paging: false, - filter: 'dataElementAttribute:eq:true', - }, + filter: `${modelName}Attribute:eq:true`, + }), }, } @@ -26,8 +27,10 @@ interface QueryResponse { } export function useCustomAttributesQuery() { + const schemaSection = useSchemaSectionHandleOrThrow() const customAttributes = useDataQuery( - CUSTOM_ATTRIBUTES_QUERY + CUSTOM_ATTRIBUTES_QUERY, + { variables: { modelName: schemaSection.name } } ) return useMemo( diff --git a/src/components/form/fields/CodeField.tsx b/src/components/form/fields/CodeField.tsx new file mode 100644 index 00000000..60093076 --- /dev/null +++ b/src/components/form/fields/CodeField.tsx @@ -0,0 +1,21 @@ +import i18n from '@dhis2/d2-i18n' +import { InputFieldFF } from '@dhis2/ui' +import React from 'react' +import { Field as FieldRFF } from 'react-final-form' +import { SchemaSection, useCheckMaxLengthFromSchema } from '../../../lib' + +export function CodeField({ schemaSection }: { schemaSection: SchemaSection }) { + const validate = useCheckMaxLengthFromSchema(schemaSection.name, 'code') + + return ( + + ) +} diff --git a/src/components/form/fields/DefaultIdentifibleFIelds.tsx b/src/components/form/fields/DefaultIdentifibleFIelds.tsx new file mode 100644 index 00000000..b1604800 --- /dev/null +++ b/src/components/form/fields/DefaultIdentifibleFIelds.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { useSchemaSectionHandleOrThrow } from '../../../lib' +import { StandardFormField } from '../../standardForm' +import { CodeField } from './CodeField' +import { NameField } from './NameField' +import { ShortNameField } from './ShortNameField' + +export const DefaultIdentifiableFields = () => { + const schemaSection = useSchemaSectionHandleOrThrow() + + return ( + <> + + + + + + + + + + + + + ) +} diff --git a/src/components/form/fields/DescriptionField.tsx b/src/components/form/fields/DescriptionField.tsx new file mode 100644 index 00000000..4cd08751 --- /dev/null +++ b/src/components/form/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 { SchemaSection, useCheckMaxLengthFromSchema } from '../../../lib' + +export function DescriptionField({ + helpText, + schemaSection, +}: { + helpText?: string + schemaSection: SchemaSection +}) { + const validate = useCheckMaxLengthFromSchema(schemaSection.name, 'formName') + + return ( + + ) +} diff --git a/src/components/form/fields/NameField.tsx b/src/components/form/fields/NameField.tsx new file mode 100644 index 00000000..0b745d68 --- /dev/null +++ b/src/components/form/fields/NameField.tsx @@ -0,0 +1,70 @@ +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, + SchemaSection, +} from '../../../lib' + +function useValidator({ schemaSection }: { schemaSection: SchemaSection }) { + const params = useParams() + const modelId = params.id as string + const checkIsValueTaken = useIsFieldValueUnique({ + model: schemaSection.namePlural, + field: 'name', + id: modelId, + }) + + const checkMaxLength = useCheckMaxLengthFromSchema( + schemaSection.name, + 'name' + ) + + return useMemo( + () => + composeAsyncValidators([ + checkIsValueTaken, + checkMaxLength, + required, + ]), + [checkIsValueTaken, checkMaxLength] + ) +} + +export function NameField({ + schemaSection, + helpText, +}: { + helpText?: string + schemaSection: SchemaSection +}) { + const validator = useValidator({ schemaSection }) + const { meta } = useField('name', { + subscription: { validating: true }, + }) + + const helpString = + helpText || i18n.t('A name should be concise and easy to recognize.') + + return ( + + loading={meta.validating} + component={InputFieldFF} + dataTest="formfields-name" + required + inputWidth="400px" + label={i18n.t('{{fieldLabel}} (required)', { + fieldLabel: i18n.t('Name'), + })} + name="name" + helpText={helpString} + validate={(name?: string) => validator(name)} + validateFields={[]} + /> + ) +} diff --git a/src/components/form/fields/ShortNameField.tsx b/src/components/form/fields/ShortNameField.tsx new file mode 100644 index 00000000..fa257132 --- /dev/null +++ b/src/components/form/fields/ShortNameField.tsx @@ -0,0 +1,70 @@ +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 { + SchemaSection, + composeAsyncValidators, + required, + useCheckMaxLengthFromSchema, + useIsFieldValueUnique, +} from '../../../lib' + +function useValidator({ schemaSection }: { schemaSection: SchemaSection }) { + const params = useParams() + const modelId = params.id as string + const checkIsValueTaken = useIsFieldValueUnique({ + model: schemaSection.namePlural, + field: 'name', + id: modelId, + }) + + const checkMaxLength = useCheckMaxLengthFromSchema( + schemaSection.name, + 'shortName' + ) + + return useMemo( + () => + composeAsyncValidators([ + checkIsValueTaken, + checkMaxLength, + required, + ]), + [checkIsValueTaken, checkMaxLength] + ) +} + +export function ShortNameField({ + helpText, + schemaSection, +}: { + helpText?: string + schemaSection: SchemaSection +}) { + const validator = useValidator({ schemaSection }) + const { meta } = useField('shortName', { + subscription: { validating: true }, + }) + + const helpString = + helpText || i18n.t('Often used in reports where space is limited') + + return ( + + loading={meta.validating} + component={InputFieldFF} + dataTest="formfields-shortname" + required + inputWidth="400px" + label={i18n.t('{{fieldLabel}} (required)', { + fieldLabel: i18n.t('Short name'), + })} + name="shortName" + helpText={helpString} + validate={(name?: string) => validator(name)} + validateFields={[]} + /> + ) +} diff --git a/src/components/form/fields/index.ts b/src/components/form/fields/index.ts new file mode 100644 index 00000000..cb566237 --- /dev/null +++ b/src/components/form/fields/index.ts @@ -0,0 +1,5 @@ +export { DefaultIdentifiableFields } from './DefaultIdentifibleFIelds' +export { NameField } from './NameField' +export { ShortNameField } from './ShortNameField' +export { CodeField } from './CodeField' +export { DescriptionField } from './DescriptionField' diff --git a/src/components/form/index.ts b/src/components/form/index.ts new file mode 100644 index 00000000..9e37a7b4 --- /dev/null +++ b/src/components/form/index.ts @@ -0,0 +1,3 @@ +export * from './fields' +export { DefaultFormContents } from './DefaultFormContents' +export * from './attributes' diff --git a/src/components/index.tsx b/src/components/index.tsx index 2eb9ed12..07bef110 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -1,5 +1,6 @@ export * from './ColorAndIconPicker' export * from './EditableFieldWrapper' +export * from './form' export { HidePreventUnmount } from './HidePreventUnmount' export { Loader } from './loading' export * from './metadataFormControls' diff --git a/src/components/metadataFormControls/DataElementGroupsTransfer/DataElementGroupsTransfer.tsx b/src/components/metadataFormControls/DataElementGroupsTransfer/DataElementGroupsTransfer.tsx new file mode 100644 index 00000000..7ac44e19 --- /dev/null +++ b/src/components/metadataFormControls/DataElementGroupsTransfer/DataElementGroupsTransfer.tsx @@ -0,0 +1,154 @@ +import i18n from '@dhis2/d2-i18n' +import { Transfer } from '@dhis2/ui' +import React, { + ReactElement, + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, +} from 'react' +import { SelectOption } from '../../../types' +import { useInitialOptionQuery } from './useInitialOptionQuery' +import { useOptionsQuery } from './useOptionsQuery' + +function computeDisplayOptions({ + selected, + selectedOptions, + options, +}: { + options: SelectOption[] + selected: string[] + selectedOptions: SelectOption[] +}): SelectOption[] { + // This happens only when we haven't fetched the lable for an initially + // selected value. Don't show anything to prevent error that an option is + // missing + if (!selectedOptions.length && selected.length) { + return [] + } + + const missingSelectedOptions = selectedOptions.filter((selectedOption) => { + return !options?.find((option) => option.value === selectedOption.value) + }) + + return [...options, ...missingSelectedOptions] +} + +interface DataElementGroupsSelectProps { + onChange: ({ selected }: { selected: string[] }) => void + selected: string[] + rightHeader?: ReactElement + rightFooter?: ReactElement + leftFooter?: ReactElement + leftHeader?: ReactElement +} + +export const DataElementGroupsTransfer = forwardRef( + function DataElementGroupsSelect( + { + onChange, + selected, + rightHeader, + rightFooter, + leftFooter, + leftHeader, + }: DataElementGroupsSelectProps, + ref + ) { + // Using a ref because we don't want to react to changes. + // We're using this value only when imperatively calling `refetch`, + // nothing that depends on the render-cycle depends on this value + const [searchTerm, setSearchTerm] = useState('') + const pageRef = useRef(0) + + // We need to persist the selected option so we can display an