diff --git a/.github/workflows/dhis2-verify-app.yml b/.github/workflows/dhis2-verify-app.yml index 174c8a3b..30a3fe61 100644 --- a/.github/workflows/dhis2-verify-app.yml +++ b/.github/workflows/dhis2-verify-app.yml @@ -69,7 +69,7 @@ jobs: run: yarn d2-app-scripts test --coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 e2e: runs-on: ubuntu-latest @@ -98,8 +98,8 @@ jobs: BROWSER: none GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - CYPRESS_dhis2BaseUrl: https://debug.dhis2.org/dev + CYPRESS_dhis2BaseUrl: https://debug.dhis2.org/2.41dev CYPRESS_dhis2Username: ${{ secrets.CYPRESS_DHIS2_USERNAME }} CYPRESS_dhis2Password: ${{ secrets.CYPRESS_DHIS2_PASSWORD }} - CYPRESS_dhis2ApiVersion: 40 + CYPRESS_dhis2ApiVersion: 41 CYPRESS_networkMode: live diff --git a/.gitignore b/.gitignore index 196a4d6a..732e9121 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ cypress.env.json .env.test.local .env.production.local cypress/screenshots/* -coverage/ \ No newline at end of file +coverage/ +cypress.env.json diff --git a/cypress/e2e/dataElements/New.spec.ts b/cypress/e2e/dataElements/New.spec.ts new file mode 100644 index 00000000..f934cd92 --- /dev/null +++ b/cypress/e2e/dataElements/New.spec.ts @@ -0,0 +1,209 @@ +describe('Data elements', () => { + it('should create a data element with only the required values', () => { + const now = Date.now() + const newDataElementName = `ZZZ ${now}` // Will be at the end, does not pollute the first page of the list + + cy.visit('/') + + // Open data elements group in side nav + cy.get('[data-test="sidenav"] button:contains("Data elements")', { + timeout: 10000, + }).click() + + // Navigate to data element list view + cy.get('[data-test="sidenav"] a:contains("Data element")') + .first() // the selector will also grab "Data element group" and "Data element group set" + .click() + + // Go to New form + cy.get('button:contains("New")').click() + + cy.get('[data-test="formfields-name-content"] input').type( + newDataElementName + ) + cy.get('[data-test="formfields-shortname-content"] input').type( + `shortname ${now}` + ) + cy.get( + '[data-test="formfields-categorycombo"] [data-test="dhis2-uicore-select-input"]' + ).click() + cy.get( + '[data-test="dhis2-uicore-singleselectoption"]:contains("None")' + ).click() + + // Submit form + cy.get('button:contains("Create data element")').click() + + cy.contains('Data element management').should('exist') + }) + + it('should create a data element with all values', () => { + const now = Date.now() + const newDataElementName = `ZZZ ${now}` // Will be at the end, does not pollute the first page of the list + + cy.visit('/') + + // Open data elements group in side nav + cy.get('[data-test="sidenav"] button:contains("Data elements")', { + timeout: 10000, + }).click() + + // Navigate to data element list view + cy.get('[data-test="sidenav"] a:contains("Data element")') + .first() // the selector will also grab "Data element group" and "Data element group set" + .click() + + // Go to New form + cy.get('button:contains("New")').click() + + cy.get('[data-test="formfields-name"] input').type(newDataElementName) + cy.get('[data-test="formfields-shortname"] input').type( + `Short name ${now}` + ) + cy.get('[data-test="formfields-formname"] input').type( + `Form name ${now}` + ) + cy.get('[data-test="formfields-code"] input').type(`Code ${now}`) + cy.get('[data-test="formfields-description"] textarea').type( + `Multiline{enter}description ${now}` + ) + cy.get('[data-test="formfields-url"] input').type( + `https://dhis2.org ${now}` + ) + + // pick color + cy.get('[data-test="colorpicker-trigger"]').click() + cy.get('[title="#b71c1c"]').click() + + // icon color + cy.get('[data-test="iconpicker-trigger"]').click() + cy.get('[data-test="dhis2-uicore-modal"] img[src$="/icon"]') + .first() + .click() + cy.get( + '[data-test="dhis2-uicore-modal"] button:contains("Select")' + ).click() + + cy.get('[data-test="formfields-fieldmask"] input').type( + `000 1111 000 ${now}` + ) + cy.get('[data-test="formfields-zeroissignificant"] input').check() + + // Select value type + cy.get( + '[data-test="formfields-valuetype"] [data-test="dhis2-uicore-select-input"]' + ).click() + cy.get( + '[data-test="dhis2-uicore-singleselectoption"]:contains("Integer")' + ).click() + + // Select aggregation type + cy.get( + '[data-test="formfields-aggregationtype"] [data-test="dhis2-uicore-select-input"]' + ).click() + cy.get( + '[data-test="dhis2-uicore-singleselectoption"]:contains("Sum")' + ).click() + + // Select category combo + cy.get( + '[data-test="formfields-categorycombo"] [data-test="dhis2-uicore-select-input"]' + ).click() + cy.get( + '[data-test="dhis2-uicore-singleselectoption"]:contains("None")' + ).click() + + // Select category combo + cy.get( + '[data-test="formfields-optionset"] [data-test="dhis2-uicore-select-input"]' + ).click() + cy.get( + '[data-test="dhis2-uicore-singleselectoption"]:contains("ARV drugs")' + ).click() + + // Select category combo + cy.get( + '[data-test="formfields-commentoptionset"] [data-test="dhis2-uicore-select-input"]' + ).click() + cy.get( + '[data-test="dhis2-uicore-singleselectoption"]:contains("ARV treatment plan")' + ).click() + + // Select legend sets + cy.get( + '[data-test="formfields-legendsets"] [data-test="dhis2-uicore-transferoption"]:contains("ANC Coverage")' + ).dblclick() + cy.get( + '[data-test="formfields-legendsets"] [data-test="dhis2-uicore-transferoption"]:contains("Age 10y interval")' + ).dblclick() + + // Select aggregation levels + cy.get( + '[data-test="formfields-aggregationlevels"] [data-test="dhis2-uicore-select-input"]' + ).click() + cy.get( + '[data-test="dhis2-uicore-checkbox"]:contains("Chiefdom")' + ).click() + cy.get( + '[data-test="dhis2-uicore-checkbox"]:contains("District")' + ).click() + cy.get('.backdrop').click() + + // Select custom attribute "Classification" + cy.get( + '[data-test="dhis2-uiwidgets-singleselectfield"]:contains("Classification") [data-test="dhis2-uicore-select-input"]' + ).click() + cy.get( + '[data-test="dhis2-uicore-singleselectoption"]:contains("Input")' + ).click() + + cy.get('[name="attributeValues[1].value"]').type( + `Collection{enter}method! ${now}` + ) + cy.get('[name="attributeValues[2].value"]').type(`PEPFAR ID! ${now}`) + cy.get('[name="attributeValues[3].value"]').type(`Rationale! ${now}`) + cy.get('[name="attributeValues[4].value"]').type( + `Unit of measure! ${now}` + ) + + // Submit form + cy.get('button:contains("Create data element")').click() + + cy.contains('Data element management').should('exist') + }) + + it('should not create a DE when reuired fields are missing', () => { + cy.visit('/') + + // Open data elements group in side nav + cy.get('[data-test="sidenav"] button:contains("Data elements")', { + timeout: 10000, + }).click() + + // Navigate to data element list view + cy.get('[data-test="sidenav"] a:contains("Data element")') + .first() // the selector will also grab "Data element group" and "Data element group set" + .click() + + // Go to New form + cy.get('button:contains("New")').click() + + // Submit form + cy.get('button:contains("Create data element")').click() + + // Should have required errors for name, shortname and cat combo + cy.get('[data-test$="-validation"]:contains("Required")').should( + 'have.length', + 3 + ) + cy.get( + '[data-test="formfields-name-validation"]:contains("Required")' + ).should('exist') + cy.get( + '[data-test="formfields-shortname-validation"]:contains("Required")' + ).should('exist') + cy.get( + '[data-test="formfields-categorycombo-validation"]:contains("Required")' + ).should('exist') + }) +}) diff --git a/i18n/en.pot b/i18n/en.pot index 5b64f05d..c151fec8 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-13T00:05:18.041Z\n" -"PO-Revision-Date: 2024-03-13T00:05:18.041Z\n" +"POT-Creation-Date: 2024-04-30T06:34:26.402Z\n" +"PO-Revision-Date: 2024-04-30T06:34:26.402Z\n" msgid "schemas" msgstr "schemas" @@ -75,6 +75,12 @@ msgstr "Save and close" msgid "" msgstr "" +msgid "Custom attributes" +msgstr "Custom attributes" + +msgid "Custom fields for your DHIS2 instance" +msgstr "Custom fields for your DHIS2 instance" + msgid "Code" msgstr "Code" @@ -249,15 +255,45 @@ msgstr "Search by name, code or ID" msgid "Public access" msgstr "Public access" +msgid "Delete" +msgstr "Delete" + +msgid "Successfully deleted {{modelType}} \"{{displayName}}\"" +msgstr "Successfully deleted {{modelType}} \"{{displayName}}\"" + +msgid "Are you sure that you want to delete this {{modelType}}?" +msgstr "Are you sure that you want to delete this {{modelType}}?" + +msgid "Something went wrong deleting the {{modelType}}" +msgstr "Something went wrong deleting the {{modelType}}" + +msgid "Failed to delete {{modelType}} \"{{displayName}}\"! {{messages}}" +msgstr "Failed to delete {{modelType}} \"{{displayName}}\"! {{messages}}" + +msgid "Try again" +msgstr "Try again" + +msgid "Confirm deletion" +msgstr "Confirm deletion" + msgid "Show details" msgstr "Show details" +msgid "Sharing settings" +msgstr "Sharing settings" + msgid "At least one column must be selected" msgstr "At least one column must be selected" msgid "At least one filter must be selected" msgstr "At least one filter must be selected" +msgid "Columns" +msgstr "Columns" + +msgid "Filters" +msgstr "Filters" + msgid "Available columns" msgstr "Available columns" @@ -552,6 +588,12 @@ msgstr "Locale" msgid "Locales" msgstr "Locales" +msgid "You do not have access to edit this item." +msgstr "You do not have access to edit this item." + +msgid "You do not have access to delete this item." +msgstr "You do not have access to delete this item." + msgid "Sum" msgstr "Sum" @@ -723,9 +765,6 @@ msgstr "This field requires a unique value, please choose another one" msgid "Required" msgstr "Required" -msgid "Custom attributes" -msgstr "Custom attributes" - msgid "Exit without saving" msgstr "Exit without saving" @@ -756,21 +795,9 @@ 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" @@ -860,8 +887,8 @@ msgstr "Disaggregation and Option sets" msgid "Set up disaggregation and predefined options." msgstr "Set up disaggregation and predefined options." -msgid "LegendSet" -msgstr "LegendSet" +msgid "Legend set" +msgstr "Legend set" msgid "" "Visualize values for this data element in Analytics app. Multiple legendSet " diff --git a/src/app/sidebar/sidenav/Sidenav.tsx b/src/app/sidebar/sidenav/Sidenav.tsx index accc79a0..399db122 100644 --- a/src/app/sidebar/sidenav/Sidenav.tsx +++ b/src/app/sidebar/sidenav/Sidenav.tsx @@ -4,7 +4,9 @@ import React, { PropsWithChildren } from 'react' import styles from './Sidenav.module.css' export const Sidenav = ({ children }: PropsWithChildren) => ( - + ) export const SidenavItems = ({ children }: PropsWithChildren) => ( diff --git a/src/components/ColorAndIconPicker/ColorPicker.module.css b/src/components/ColorAndIconPicker/ColorPicker.module.css index 3ffea902..75526909 100644 --- a/src/components/ColorAndIconPicker/ColorPicker.module.css +++ b/src/components/ColorAndIconPicker/ColorPicker.module.css @@ -7,6 +7,7 @@ background: var(--colors-white); width: 68px; cursor: pointer; + align-items: center; } .chosenColor { diff --git a/src/components/ColorAndIconPicker/ColorPicker.tsx b/src/components/ColorAndIconPicker/ColorPicker.tsx index 1fe97075..b50d188e 100644 --- a/src/components/ColorAndIconPicker/ColorPicker.tsx +++ b/src/components/ColorAndIconPicker/ColorPicker.tsx @@ -17,22 +17,23 @@ export function ColorPicker({ return ( <> -
setShowPicker(true)} className={cx(classes.container, { [classes.hasColor]: !!color, })} + data-test="colorpicker-trigger" > -
-
+ {showPicker ? : } -
-
+ + {showPicker && ( setShowPicker(false)} translucent> diff --git a/src/components/ColorAndIconPicker/IconPicker.module.css b/src/components/ColorAndIconPicker/IconPicker.module.css index 5fc0e2d3..1c1ab747 100644 --- a/src/components/ColorAndIconPicker/IconPicker.module.css +++ b/src/components/ColorAndIconPicker/IconPicker.module.css @@ -7,6 +7,7 @@ background: var(--colors-white); width: 68px; cursor: pointer; + align-items: center; } .chosenIcon { diff --git a/src/components/ColorAndIconPicker/IconPicker.tsx b/src/components/ColorAndIconPicker/IconPicker.tsx index b4e966cc..a927bf67 100644 --- a/src/components/ColorAndIconPicker/IconPicker.tsx +++ b/src/components/ColorAndIconPicker/IconPicker.tsx @@ -18,13 +18,14 @@ export function IconPicker({ return ( <> -
setShowPicker(true)} className={cx(classes.container, { [classes.hasIcon]: !!icon, })} + data-test="iconpicker-trigger" > -
+ {selectedIcon && ( )} -
+ -
+ {showPicker ? : } -
-
+ + {showPicker && ( (null) const navigate = useNavigate() - const listPath = getSectionPath(section) + const listPath = `/${getSectionPath(section)}` useEffect(() => { if (submitError) { formErrorRef.current?.scrollIntoView({ behavior: 'smooth' }) diff --git a/src/components/form/attributes/CustomAttributes.tsx b/src/components/form/attributes/CustomAttributes.tsx index c02ba003..d897f869 100644 --- a/src/components/form/attributes/CustomAttributes.tsx +++ b/src/components/form/attributes/CustomAttributes.tsx @@ -2,7 +2,11 @@ import i18n from '@dhis2/d2-i18n' import { InputFieldFF, SingleSelectFieldFF, TextAreaFieldFF } from '@dhis2/ui' import * as React from 'react' import { Field as FieldRFF, useFormState } from 'react-final-form' -import { StandardFormSection } from '../..' +import { + StandardFormSection, + StandardFormSectionDescription, + StandardFormSectionTitle, +} from '../..' import { Attribute, AttributeValue } from '../../../types/generated' const inputWidth = '440px' @@ -78,7 +82,7 @@ function CustomAttribute({ attribute, index }: CustomAttributeProps) { throw new Error(`Implement value type "${attribute.valueType}"!`) } -export function CustomAttributes() { +export function CustomAttributesSection() { const formState = useFormState({ subscription: { initialValues: true }, }) @@ -86,9 +90,19 @@ export function CustomAttributes() { const customAttributes = formState.initialValues.attributeValues?.map( (av) => av.attribute ) + if (!customAttributes || customAttributes?.length < 1) { + return null + } return ( - <> + + + {i18n.t('Custom attributes')} + + + + {i18n.t('Custom fields for your DHIS2 instance')} + {customAttributes?.map((customAttribute, index) => { return ( ) })} - + ) } diff --git a/src/components/form/attributes/index.ts b/src/components/form/attributes/index.ts index ae4e404a..8604c2c0 100644 --- a/src/components/form/attributes/index.ts +++ b/src/components/form/attributes/index.ts @@ -1,2 +1,2 @@ -export { CustomAttributes } from './CustomAttributes' +export { CustomAttributesSection } from './CustomAttributes' export { useCustomAttributesQuery } from './useCustomAttributesQuery' diff --git a/src/components/sectionList/SectionListWrapper.tsx b/src/components/sectionList/SectionListWrapper.tsx index 56101986..80cd5069 100644 --- a/src/components/sectionList/SectionListWrapper.tsx +++ b/src/components/sectionList/SectionListWrapper.tsx @@ -24,12 +24,14 @@ type SectionListWrapperProps = { data: ModelCollection | undefined pager: Pager | undefined error: FetchError | undefined + refetch: () => void } export const SectionListWrapper = ({ data, error, pager, + refetch, }: SectionListWrapperProps) => { const { columns: headerColumns } = useModelListView() const schema = useSchemaFromHandle() @@ -97,9 +99,10 @@ export const SectionListWrapper = ({ model={model} onShowDetailsClick={handleDetailsClick} onOpenSharingClick={setSharingDialogId} + onDeleteSuccess={refetch} /> ), - [handleDetailsClick, setSharingDialogId] + [handleDetailsClick, setSharingDialogId, refetch] ) const isAllSelected = data ? checkAllSelected(data) : false diff --git a/src/components/sectionList/filters/filterSelectors/IdentifiableFilter.tsx b/src/components/sectionList/filters/filterSelectors/IdentifiableFilter.tsx index 75cf841a..160f01fc 100644 --- a/src/components/sectionList/filters/filterSelectors/IdentifiableFilter.tsx +++ b/src/components/sectionList/filters/filterSelectors/IdentifiableFilter.tsx @@ -1,23 +1,30 @@ import i18n from '@dhis2/d2-i18n' import { Input, InputEventPayload } from '@dhis2/ui' import React, { useEffect, useState } from 'react' -import { - useDebounce, - IDENTIFIABLE_FILTER_KEY, - useSectionListFilter, -} from '../../../../lib' +import { useDebouncedCallback } from 'use-debounce' +import { IDENTIFIABLE_FILTER_KEY, useSectionListFilter } from '../../../../lib' import css from './Filters.module.css' export const IdentifiableFilter = () => { const [filter, setFilter] = useSectionListFilter(IDENTIFIABLE_FILTER_KEY) const [value, setValue] = useState(filter || '') - const debouncedValue = useDebounce(value, 200) - useEffect(() => { - setFilter(debouncedValue || undefined) // convert empty string to undefined - }, [debouncedValue, setFilter]) + const debouncedSetFilter = useDebouncedCallback( + (debouncedFilter: string) => + // convert empty string to undefined + // to prevent empty-value like "identifiable=" in URL + setFilter(debouncedFilter || undefined), + 200 + ) + + const handleSetValue = (event: InputEventPayload) => { + const eventValue = event.value ?? '' + setValue(eventValue) + debouncedSetFilter(eventValue) + } useEffect(() => { + // clear input-value when "Clear all filters" if (!filter) { setValue('') } @@ -28,9 +35,7 @@ export const IdentifiableFilter = () => { - setValue(value.value ?? '') - } + onChange={handleSetValue} value={value} dataTest="input-search-name" dense diff --git a/src/components/sectionList/listActions/DefaultListActions.tsx b/src/components/sectionList/listActions/DefaultListActions.tsx index a78054f9..8fd5b966 100644 --- a/src/components/sectionList/listActions/DefaultListActions.tsx +++ b/src/components/sectionList/listActions/DefaultListActions.tsx @@ -1,30 +1,34 @@ import React from 'react' -import { canEditModel } from '../../../lib/models/access' -import { BaseIdentifiableObject } from '../../../types/generated' +import { BaseListModel } from '../../../lib' +import { canEditModel, canDeleteModel } from '../../../lib/models/access' import { ListActions, ActionEdit, ActionMore } from './SectionListActions' -type ModelWithAccess = Pick - type DefaultListActionProps = { - model: ModelWithAccess - onShowDetailsClick: (model: ModelWithAccess) => void + model: BaseListModel + onShowDetailsClick: (model: BaseListModel) => void onOpenSharingClick: (id: string) => void + onDeleteSuccess: () => void } export const DefaultListActions = ({ model, onShowDetailsClick, onOpenSharingClick, + onDeleteSuccess, }: DefaultListActionProps) => { - const editAccess = canEditModel(model) + const deletable = canDeleteModel(model) + const editable = canEditModel(model) + return ( onShowDetailsClick(model)} onOpenSharingClick={() => onOpenSharingClick(model.id)} + onDeleteSuccess={onDeleteSuccess} /> ) diff --git a/src/components/sectionList/listActions/DeleteAction.module.css b/src/components/sectionList/listActions/DeleteAction.module.css new file mode 100644 index 00000000..c61f208a --- /dev/null +++ b/src/components/sectionList/listActions/DeleteAction.module.css @@ -0,0 +1,8 @@ +.deleteButtonLoadingIcon { + display: inline-block; + margin-right: 8px; +} + +.deleteButtonLoadingIcon :global([role="progressbar"]) { + border-color: rgba(110, 122, 138, 0.15) rgba(110, 122, 138, 0.15) white; +} diff --git a/src/components/sectionList/listActions/DeleteAction.tsx b/src/components/sectionList/listActions/DeleteAction.tsx new file mode 100644 index 00000000..01b952b2 --- /dev/null +++ b/src/components/sectionList/listActions/DeleteAction.tsx @@ -0,0 +1,166 @@ +import { useAlert } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { + Button, + ButtonStrip, + CircularLoader, + IconDelete16, + MenuItem, + Modal, + ModalActions, + ModalContent, + ModalTitle, + NoticeBox, +} from '@dhis2/ui' +import React, { useState } from 'react' +import { + BaseListModel, + useDeleteModelMutation, + useSchemaSectionHandleOrThrow, +} from '../../../lib' +import classes from './DeleteAction.module.css' + +export function DeleteAction({ + disabled, + model, + onCancel, + onDeleteSuccess, +}: { + disabled: boolean + model: BaseListModel + onCancel: () => void + onDeleteSuccess: () => void +}) { + const [showConfirmationDialog, setShowConfirmationDialog] = useState(false) + const deleteAndClose = () => { + setShowConfirmationDialog(false) + onDeleteSuccess() + } + const closeAndCancel = () => { + setShowConfirmationDialog(false) + onCancel() + } + + return ( + <> + } + onClick={() => setShowConfirmationDialog(true)} + /> + + {showConfirmationDialog && ( + + )} + + ) +} + +function ConfirmationDialog({ + model, + onCancel, + onDeleteSuccess, +}: { + model: BaseListModel + onCancel: () => void + onDeleteSuccess: () => void +}) { + const section = useSchemaSectionHandleOrThrow() + + const deleteModelMutation = useDeleteModelMutation(section.namePlural, { + onSuccess: () => { + showDeletionSuccess() + onDeleteSuccess() + }, + }) + + const { show: showDeletionSuccess } = useAlert( + () => + i18n.t('Successfully deleted {{modelType}} "{{displayName}}"', { + displayName: model.displayName, + modelType: section.title, + }), + { success: true } + ) + + const errorReports = + deleteModelMutation.error?.details?.response?.errorReports + return ( + + + {i18n.t( + 'Are you sure that you want to delete this {{modelType}}?', + { modelType: section.title } + )} + + + {!!deleteModelMutation.error && ( + + +
+ {i18n.t( + 'Failed to delete {{modelType}} "{{displayName}}"! {{messages}}', + { + displayName: model.displayName, + modelType: section.title, + } + )} +
+ + {!!errorReports?.length && ( +
    + {errorReports.map(({ message }) => ( +
  • {message}
  • + ))} +
+ )} +
+
+ )} + + + + + + + + +
+ ) +} diff --git a/src/components/sectionList/listActions/SectionListActions.tsx b/src/components/sectionList/listActions/SectionListActions.tsx index 5eeec25a..c133c408 100644 --- a/src/components/sectionList/listActions/SectionListActions.tsx +++ b/src/components/sectionList/listActions/SectionListActions.tsx @@ -12,9 +12,10 @@ import { } from '@dhis2/ui' import React, { useRef, useState } from 'react' import { useHref, useLinkClickHandler } from 'react-router-dom' -import { TOOLTIPS } from '../../../lib' +import { TOOLTIPS, BaseListModel } from '../../../lib' import { LinkButton } from '../../LinkButton' import { TooltipWrapper } from '../../tooltip' +import { DeleteAction } from './DeleteAction' import css from './SectionListActions.module.css' export const ListActions = ({ children }: React.PropsWithChildren) => { @@ -30,22 +31,26 @@ export const ActionEdit = ({ modelId }: { modelId: string }) => { } type ActionMoreProps = { - modelId: string - editAccess: boolean + deletable: boolean + editable: boolean + model: BaseListModel onShowDetailsClick: () => void onOpenSharingClick: () => void + onDeleteSuccess: () => void } export const ActionMore = ({ - modelId, - editAccess, + deletable, + editable, + model, onOpenSharingClick, onShowDetailsClick, + onDeleteSuccess, }: ActionMoreProps) => { const [open, setOpen] = useState(false) const ref = useRef(null) - const href = useHref(modelId, { relative: 'path' }) + const href = useHref(model.id, { relative: 'path' }) - const handleEditClick = useLinkClickHandler(modelId) + const handleEditClick = useLinkClickHandler(model.id) return (
@@ -54,7 +59,7 @@ export const ActionMore = ({ secondary onClick={() => setOpen(!open)} icon={} - > + /> {open && ( + /> + } onClick={() => { onOpenSharingClick() setOpen(false) }} - > + /> + + + + { + onDeleteSuccess() + setOpen(false) + }} + onCancel={() => setOpen(false)} + /> diff --git a/src/components/standardForm/StandardFormSectionDescription.module.css b/src/components/standardForm/StandardFormSectionDescription.module.css index 3bbb171f..118f3b75 100644 --- a/src/components/standardForm/StandardFormSectionDescription.module.css +++ b/src/components/standardForm/StandardFormSectionDescription.module.css @@ -3,4 +3,5 @@ font-size: 14px; line-height: 19px; color: var(--colors-grey800); + max-width: 600px; } diff --git a/src/lib/constants/tooltips.ts b/src/lib/constants/tooltips.ts index f044a1ff..3d617ba6 100644 --- a/src/lib/constants/tooltips.ts +++ b/src/lib/constants/tooltips.ts @@ -2,4 +2,5 @@ import i18n from '@dhis2/d2-i18n' export const TOOLTIPS = { noEditAccess: i18n.t('You do not have access to edit this item.'), + noDeleteAccess: i18n.t('You do not have access to delete this item.'), } diff --git a/src/lib/dataStore/DataStore.ts b/src/lib/dataStore/DataStore.ts deleted file mode 100644 index 314dbe15..00000000 --- a/src/lib/dataStore/DataStore.ts +++ /dev/null @@ -1 +0,0 @@ -export class DataStore {} diff --git a/src/lib/models/access.ts b/src/lib/models/access.ts index b3d11d5b..d083387c 100644 --- a/src/lib/models/access.ts +++ b/src/lib/models/access.ts @@ -1,10 +1,14 @@ import type { Access, BaseIdentifiableObject } from '../../types/generated' export const hasWriteAccess = (access: Partial) => !!access.write +export const hasDeleteAccess = (access: Partial) => !!access.delete export const canEditModel = (model: Pick) => hasWriteAccess(model.access) +export const canDeleteModel = (model: Pick) => + hasDeleteAccess(model.access) + export type ParsedAccessPart = { read: boolean write: boolean diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts index c8bc5f7c..dafa0a81 100644 --- a/src/lib/models/index.ts +++ b/src/lib/models/index.ts @@ -1,5 +1,6 @@ export { isValidUid } from './uid' export * from './access' export { getIn, stringToPathArray, getFieldFilterFromPath } from './path' +export { useDeleteModelMutation } from './useDeleteModelMutation' export { useIsFieldValueUnique } from './useIsFieldValueUnique' export { useCheckMaxLengthFromSchema } from './useCheckMaxLengthFromSchema' diff --git a/src/lib/models/useDeleteModelMutation.ts b/src/lib/models/useDeleteModelMutation.ts new file mode 100644 index 00000000..836cf662 --- /dev/null +++ b/src/lib/models/useDeleteModelMutation.ts @@ -0,0 +1,35 @@ +import { FetchError, useDataEngine } from '@dhis2/app-runtime' +import { useMutation, UseMutationOptions } from 'react-query' +import { ImportSummary } from '../../types' + +type MutationFnArgs = { + id: string + displayName: string +} + +type DeleteMutationError = Omit & { + details: ImportSummary +} + +type Options = Omit< + UseMutationOptions, + 'mutationFn' +> + +export function useDeleteModelMutation( + schemaResource: string, + options?: Options +) { + const engine = useDataEngine() + + return useMutation({ + ...options, + mutationFn: (variables) => { + return engine.mutate({ + resource: schemaResource, + id: variables.id, + type: 'delete', + }) as Promise + }, + }) +} diff --git a/src/lib/sectionList/fieldFilters.ts b/src/lib/sectionList/fieldFilters.ts index 5e996b00..5c293a3f 100644 --- a/src/lib/sectionList/fieldFilters.ts +++ b/src/lib/sectionList/fieldFilters.ts @@ -1,6 +1,6 @@ import { BaseIdentifiableObject } from '../../types/generated' -export const DEFAULT_FIELD_FILTERS = ['id', 'access'] as const +export const DEFAULT_FIELD_FILTERS = ['id', 'access', 'displayName'] as const export type DefaultFields = (typeof DEFAULT_FIELD_FILTERS)[number] export type BaseListModel = Pick diff --git a/src/lib/sectionList/filters/parseFiltersToQueryParams.ts b/src/lib/sectionList/filters/parseFiltersToQueryParams.ts index a804e461..0fcb7e84 100644 --- a/src/lib/sectionList/filters/parseFiltersToQueryParams.ts +++ b/src/lib/sectionList/filters/parseFiltersToQueryParams.ts @@ -16,11 +16,14 @@ type FilterToQueryParamsMap = { const inFilter = (filterPath: string, value: string[]) => `${filterPath}:in:[${value.join(',')}]` -const defaultFilter = (key: FilterKey, value: AllValues): string => { - const isArray = Array.isArray(value) - const valuesString = isArray ? `[${value.join(',')}]` : value?.toString() - const operator = isArray ? 'in' : 'eq' - return `${key}:${operator}:${valuesString}` +const defaultFilter = ( + key: FilterKey, + value: NonNullable +): string => { + if (Array.isArray(value)) { + return inFilter(key, value) + } + return `${key}:eq:${value}` } /* Override how to resolve the actual queryParam (when used in a request) for a filter */ diff --git a/src/pages/dataElementGroupSets/form/DataElementGroupSetFormFields.tsx b/src/pages/dataElementGroupSets/form/DataElementGroupSetFormFields.tsx index 85884b1f..d99dff16 100644 --- a/src/pages/dataElementGroupSets/form/DataElementGroupSetFormFields.tsx +++ b/src/pages/dataElementGroupSets/form/DataElementGroupSetFormFields.tsx @@ -1,7 +1,7 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' import { - CustomAttributes, + CustomAttributesSection, StandardFormSection, StandardFormSectionTitle, StandardFormSectionDescription, @@ -60,7 +60,8 @@ export function DataElementGroupSetFormFields() { - {i18n.t('@TODO')} + {/* TODO: ADD DESCRIPTION */} + {''} @@ -68,17 +69,7 @@ export function DataElementGroupSetFormFields() { - - - {i18n.t('Custom attributes')} - - - - {i18n.t('Custom fields for your DHIS2 instance')} - - - - + ) } diff --git a/src/pages/dataElementGroups/fields/CodeField.tsx b/src/pages/dataElementGroups/fields/CodeField.tsx deleted file mode 100644 index fedc4eb8..00000000 --- a/src/pages/dataElementGroups/fields/CodeField.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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/DescriptionField.tsx b/src/pages/dataElementGroups/fields/DescriptionField.tsx deleted file mode 100644 index c11298b8..00000000 --- a/src/pages/dataElementGroups/fields/DescriptionField.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index b6c38724..00000000 --- a/src/pages/dataElementGroups/fields/NameField.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 7e1845d5..00000000 --- a/src/pages/dataElementGroups/fields/ShortNameField.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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/form/DataElementGroupFormFields.tsx b/src/pages/dataElementGroups/form/DataElementGroupFormFields.tsx index 8e61e43b..c639c26f 100644 --- a/src/pages/dataElementGroups/form/DataElementGroupFormFields.tsx +++ b/src/pages/dataElementGroups/form/DataElementGroupFormFields.tsx @@ -5,7 +5,7 @@ import { StandardFormSectionTitle, StandardFormSectionDescription, StandardFormField, - CustomAttributes, + CustomAttributesSection, } from '../../../components' import { DefaultIdentifiableFields, @@ -48,7 +48,8 @@ export function DataElementGroupFormFields() { - {i18n.t('@TODO')} + {/* TODO: ADD DESCRIPTION */} + {''} @@ -56,17 +57,7 @@ export function DataElementGroupFormFields() { - - - {i18n.t('Custom attributes')} - - - - {i18n.t('Custom fields for your DHIS2 instance')} - - - - + ) } diff --git a/src/pages/dataElements/List.tsx b/src/pages/dataElements/List.tsx index 7dea09ac..51c39765 100644 --- a/src/pages/dataElements/List.tsx +++ b/src/pages/dataElements/List.tsx @@ -57,6 +57,7 @@ export const Component = () => { error={error} data={data?.result.dataElements} pager={data?.result.pager} + refetch={refetch} />
) diff --git a/src/pages/dataElements/form/DataElementFormFields.tsx b/src/pages/dataElements/form/DataElementFormFields.tsx index 7884ac35..3c8825a3 100644 --- a/src/pages/dataElements/form/DataElementFormFields.tsx +++ b/src/pages/dataElements/form/DataElementFormFields.tsx @@ -5,7 +5,7 @@ import { StandardFormSectionTitle, StandardFormSectionDescription, StandardFormField, - CustomAttributes, + CustomAttributesSection, } from '../../../components' import { CodeField, @@ -121,7 +121,7 @@ export function DataElementFormFields() { - {i18n.t('LegendSet')} + {i18n.t('Legend set')} @@ -151,17 +151,7 @@ export function DataElementFormFields() { - - - {i18n.t('Custom attributes')} - - - - {i18n.t('Custom fields for your DHIS2 instance')} - - - - + ) } diff --git a/src/types/fixedModels.ts b/src/types/fixedModels.ts new file mode 100644 index 00000000..2a36334d --- /dev/null +++ b/src/types/fixedModels.ts @@ -0,0 +1,24 @@ +import type { ErrorReportLegacy } from './generated' +// Some of the generated models are wrong, or outdated +// The import summaries and error reports changed in 2.41 +export type ImportSummary = { + httpStatus: string + httpStatusCode: number + message?: string + status: string + response: ImportResponse +} + +export type ImportResponse = { + errorReports: ErrorReport[] + klass: string + responseType: string + uid: string +} + +export type ErrorReport = Pick< + ErrorReportLegacy, + 'errorCode' | 'errorProperties' | 'errorKlass' | 'mainKlass' | 'message' +> & { + args: string[] +} diff --git a/src/types/generated/models.ts b/src/types/generated/models.ts index ed712852..14b8e7c8 100644 --- a/src/types/generated/models.ts +++ b/src/types/generated/models.ts @@ -551,7 +551,7 @@ export type BulkSmsGatewayConfig = { export type CascadeSharingReport = { countUpdatedDashboardItems: number - errorReports: Array + errorReports: Array updateObjects: Record> } @@ -2481,7 +2481,7 @@ export type Error = { uid: string } -export type ErrorReport = { +export type ErrorReportLegacy = { errorCode: ErrorReport.errorCode errorKlass: string errorProperties: Array> @@ -4555,7 +4555,7 @@ export type ImportSummaries = { deleted: number ignored: number importOptions: ImportOptions - importSummaries: Array + importSummaries: Array imported: number responseType: string status: ImportSummaries.status @@ -4571,7 +4571,7 @@ export namespace ImportSummaries { } } -export type ImportSummary = { +export type ImportSummaryLegacy = { conflicts: Array dataSetComplete: string description: string @@ -5945,7 +5945,7 @@ export type ObjectCount = { export type ObjectReport = { displayName: string - errorReports: Array + errorReports: Array index: number klass: string uid: string diff --git a/src/types/index.ts b/src/types/index.ts index ec7356f7..fc76aafd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,3 +8,4 @@ export * from './section' export type * from './query' export * from './ui' export * from './systemSettings' +export * from './fixedModels'