diff --git a/i18n/en.pot b/i18n/en.pot index 9866e147..07aee14b 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-10-23T11:07:53.225Z\n" -"PO-Revision-Date: 2024-10-23T11:07:53.225Z\n" +"POT-Creation-Date: 2024-11-04T20:16:04.917Z\n" +"PO-Revision-Date: 2024-11-04T20:16:04.918Z\n" msgid "schemas" msgstr "schemas" @@ -123,21 +123,6 @@ msgstr "Failed to load {{label}}" msgid "Failed to load" msgstr "Failed to load" -msgid "Download" -msgstr "Download" - -msgid "Merge" -msgstr "Merge" - -msgid "Delete source data element values" -msgstr "Delete source data element values" - -msgid "Last updated" -msgstr "Last updated" - -msgid "Discard" -msgstr "Discard" - msgid "Aggregation level(s)" msgstr "Aggregation level(s)" @@ -237,6 +222,9 @@ msgstr "Created" msgid "Last updated by" msgstr "Last updated by" +msgid "Last updated" +msgstr "Last updated" + msgid "Id" msgstr "Id" @@ -258,6 +246,9 @@ msgstr "Details" msgid "Failed to load details" msgstr "Failed to load details" +msgid "Download" +msgstr "Download" + msgid "Download {{section}}" msgstr "Download {{section}}" @@ -297,6 +288,9 @@ msgstr "Clear all filters" msgid "Category" msgstr "Category" +msgid "Category option" +msgstr "Category option" + msgid "Category option group" msgstr "Category option group" @@ -435,9 +429,6 @@ msgstr "Search for a user or group" msgid "Categories" msgstr "Categories" -msgid "Category option" -msgstr "Category option" - msgid "Category options" msgstr "Category options" @@ -855,9 +846,15 @@ msgstr "Zero is significant" msgid "Data dimension type" msgstr "Data dimension type" +msgid "Ignore data approval" +msgstr "Ignore data approval" + msgid "This field requires a unique value, please choose another one" msgstr "This field requires a unique value, please choose another one" +msgid "{{label}} (required)" +msgstr "{{label}} (required)" + msgid "No changes to be saved" msgstr "No changes to be saved" @@ -879,23 +876,27 @@ msgstr "Basic information" msgid "Set up the basic information for this category." msgstr "Set up the basic information for this category." -msgid "Explain the purpose of this category." -msgstr "Explain the purpose of this category." +msgid "Explain the purpose of this category option group." +msgstr "Explain the purpose of this category option group." msgid "Data configuration" msgstr "Data configuration" -msgid "Choose how this category will be used to capture and analyze" -msgstr "Choose how this category will be used to capture and analyze" +msgid "Choose how this category option group will be used to capture and analyze" +msgstr "Choose how this category option group will be used to capture and analyze" msgid "Use as data dimension" msgstr "Use as data dimension" -msgid "Category will be available to the analytics as another dimension" -msgstr "Category will be available to the analytics as another dimension" +msgid "" +"Category option group will be available to the analytics as another " +"dimension" +msgstr "" +"Category option group will be available to the analytics as another " +"dimension" -msgid "Choose the category options to include in this category." -msgstr "Choose the category options to include in this category." +msgid "Choose the category options to include in this category option group." +msgstr "Choose the category options to include in this category option group." msgid "Available category options" msgstr "Available category options" @@ -933,6 +934,15 @@ msgstr "Filter selected categories" msgid "At least one category is required" msgstr "At least one category is required" +msgid "Set up the basic information for this category option group." +msgstr "Set up the basic information for this category option group." + +msgid "Choose how this category option will be used to capture and analyze" +msgstr "Choose how this category option will be used to capture and analyze" + +msgid "Choose the category options to include in this category." +msgstr "Choose the category options to include in this category." + msgid "Set up the basic information for this category option." msgstr "Set up the basic information for this category option." @@ -1081,6 +1091,24 @@ msgstr "" "included. PHU will still be available for the PHU level, but not included " "in the aggregations to the levels above." +msgid "Setup" +msgstr "Setup" + +msgid "Data" +msgstr "Data" + +msgid "Periods" +msgstr "Periods" + +msgid "Organisation Units" +msgstr "Organisation Units" + +msgid "Form" +msgstr "Form" + +msgid "Advanced" +msgstr "Advanced" + msgid "Upload an image" msgstr "Upload an image" @@ -1136,11 +1164,11 @@ msgstr "Latitude" msgid "Longitude" msgstr "Longitude" -msgid "Reference assignments" -msgstr "Reference assignments" +msgid "Reference assignment" +msgstr "Reference assignment" -msgid "Assign the organisation unit to related models." -msgstr "Assign the organisation unit to related models." +msgid "Assign the organisation unit to related objects." +msgstr "Assign the organisation unit to related objects." msgid "Available data sets" msgstr "Available data sets" diff --git a/src/components/form/FormBase.tsx b/src/components/form/FormBase.tsx index 55acf870..7abccc1c 100644 --- a/src/components/form/FormBase.tsx +++ b/src/components/form/FormBase.tsx @@ -19,7 +19,7 @@ type OwnProps> = { includeAttributes?: boolean } -type FormBaseProps = FormProps & OwnProps +export type FormBaseProps = FormProps & OwnProps export function FormBase({ children, diff --git a/src/components/sectionedForm/DefaultSectionedFormSidebar.tsx b/src/components/sectionedForm/DefaultSectionedFormSidebar.tsx new file mode 100644 index 00000000..c77f7905 --- /dev/null +++ b/src/components/sectionedForm/DefaultSectionedFormSidebar.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { useSectionedFormDescriptor, useSelectedSection } from '../../lib' +import { + SectionedFormSidebar, + SectionedFormSidebarItem, +} from './SectionedFormSidebar' + +export const DefaultSectionedFormSidebar = () => { + const { sections } = useSectionedFormDescriptor() + + const [selected] = useSelectedSection() + + const items = sections.map((section) => ( + + {section.label} + + )) + return {items} +} diff --git a/src/components/sectionedForm/SectionForm.module.css b/src/components/sectionedForm/SectionForm.module.css new file mode 100644 index 00000000..c91132ea --- /dev/null +++ b/src/components/sectionedForm/SectionForm.module.css @@ -0,0 +1,26 @@ +.defaultFormFooter { +} + +.footerWrapper { + display: flex; + justify-content: space-between; + padding: 0 16px; + align-self: center; + gap: 4px; +} + +.sectionActions { + display: flex; + gap: var(--spacers-dp8); +} + +.verticalDivider { + border-left: 1px solid var(--colors-grey300); + height: 100%; + width: 20px; +} + +.submitActions { + display: flex; + gap: 8px; +} diff --git a/src/components/sectionedForm/SectionFormSidebar.module.css b/src/components/sectionedForm/SectionFormSidebar.module.css new file mode 100644 index 00000000..156a22f3 --- /dev/null +++ b/src/components/sectionedForm/SectionFormSidebar.module.css @@ -0,0 +1,109 @@ +.sidebar { + overflow: auto; + height: 100%; +} + +.listItem { + box-shadow: 0px -1px 0px 0px var(--colors-grey300) inset; + padding: var(--spacers-dp16); + gap: 10px; + display: flex; + justify-content: space-between; + cursor: pointer; +} + +.listItem:hover { + background-color: var(--colors-grey050); +} + +.listItem.selected { + background-color: var(--colors-grey100); + box-shadow: 4px 0px 0px 0px var(--colors-blue700) inset; + cursor: initial; +} + +.listItem header { + font-size: 14px; + font-weight: 500; +} + +.listItem .checkInfo { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 8px; +} + +.listItem .subtitle { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + font-size: 12px; + gap: 4px; + color: var(--colors-grey700); +} + +.noItemsMessage { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + font-size: 14px; + color: var(--colors-grey700); +} + +.subtitleSection { + flex-shrink: 0; +} + +.statusIcon { + flex-shrink: 0; +} + +.listToolbar { + display: flex; + gap: 10px; + align-items: center; + padding: var(--spacers-dp8) var(--spacers-dp8) var(--spacers-dp8) + var(--spacers-dp16); + border-width: 1px 0px 1px; + border-style: solid; + border-color: var(--colors-grey400); +} + +.toolbarTabs { + border-width: 1px 0px 0px; + border-style: solid; + border-color: var(--colors-grey400); + box-shadow: none; +} + +.slowCheckInfo { + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; + border-inline-start: 1px solid var(--colors-grey300); + padding-inline-start: 6px; + color: var(--colors-grey700); +} + +/* disable borders and shadows from UI TabBar and Tab +Prevents "double" border +*/ +.toolbarTabs div, +.toolbarTabs button { + box-shadow: none; + border: none !important; +} + +.errorIcon { + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; +} + +.searchInput { + width: 180px; +} diff --git a/src/components/sectionedForm/SectionedFormBase.tsx b/src/components/sectionedForm/SectionedFormBase.tsx new file mode 100644 index 00000000..a596eb5d --- /dev/null +++ b/src/components/sectionedForm/SectionedFormBase.tsx @@ -0,0 +1 @@ +export const SectionedFormBase = () => {} diff --git a/src/components/sectionedForm/SectionedFormContext.tsx b/src/components/sectionedForm/SectionedFormContext.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/sectionedForm/SectionedFormFooter.tsx b/src/components/sectionedForm/SectionedFormFooter.tsx new file mode 100644 index 00000000..f231c12d --- /dev/null +++ b/src/components/sectionedForm/SectionedFormFooter.tsx @@ -0,0 +1,55 @@ +import i18n from '@dhis2/d2-i18n' +import { Button, ButtonStrip } from '@dhis2/ui' +import React from 'react' +import { useSectionedFormDescriptor, useSelectedSection } from '../../lib' +import css from './SectionForm.module.css' + +export const DefaultSectionedFormFooter = () => { + const descriptor = useSectionedFormDescriptor() + const [selected, setSelectedSection] = useSelectedSection() + + const currSelectedIndex = descriptor.sections.findIndex( + (s) => s.name === selected + ) + const prevSection = descriptor.sections[currSelectedIndex - 1] + const nextSection = descriptor.sections[currSelectedIndex + 1] + + const handleNavigateBack = () => { + if (prevSection) { + setSelectedSection(prevSection.name) + } + } + const handleNavigateNext = () => { + if (nextSection) { + setSelectedSection(nextSection.name) + } + } + + return ( +
+
+ {prevSection && ( + + )} + {nextSection && ( + + )} +
+ | +
+ + +
+
+ ) +} diff --git a/src/components/sectionedForm/SectionedFormLayout.module.css b/src/components/sectionedForm/SectionedFormLayout.module.css new file mode 100644 index 00000000..d6736498 --- /dev/null +++ b/src/components/sectionedForm/SectionedFormLayout.module.css @@ -0,0 +1,70 @@ +@value m-medium: (min-width: 768px); + +/* The purpose of this wrappper is for the */ +.layoutWrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: auto; +} +.wrapper { + display: grid; + height: 100%; + grid-template-areas: 'sidebar' 'main' 'footer'; + grid-template-rows: auto 1fr 68px; + overflow: auto; + /* border-color: var(--colors-grey400); + border-style: solid; + border-width: 1px; */ + border: 1px solid var(--colors-grey300); + /* box-shadow: 0 -1px 0 0 var(--colors-grey300) inset; */ +} +.main { + padding: var(--spacers-dp16); + grid-area: main; + background-color: var(--colors-white); + width: 100%; + display: flex; + flex-direction: column; + height: 100%; +} + +.main > div { + overflow-y: auto; +} + +.sidebar { + grid-area: sidebar; + background-color: #fff; + width: 240px; + /* min-width: 240px; */ + overflow: auto; + height: 100%; + border-radius: 0; + border-inline-end: 1px solid var(--colors-grey300); +} + +.footer { + grid-area: footer; + width: 100%; + background: var(--colors-white); + border-top: 1px solid var(--colors-grey300); + display: flex; +} + +@media m-medium { + .wrapper { + height: 100%; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr 68px; + grid-template-areas: 'sidebar main' 'footer footer'; + } + .sidebar { + /* width: 240px; */ + overflow: auto; + } + .main { + overflow-y: auto; + } +} diff --git a/src/components/sectionedForm/SectionedFormLayout.tsx b/src/components/sectionedForm/SectionedFormLayout.tsx new file mode 100644 index 00000000..7bb43fb1 --- /dev/null +++ b/src/components/sectionedForm/SectionedFormLayout.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import css from './SectionedFormLayout.module.css' + +export type SectionedFormLayoutProps = { + children: React.ReactNode + footer: React.ReactNode + sidebar: React.ReactNode +} +export const SectionedFormLayout = ({ + children, + footer, + sidebar, +}: SectionedFormLayoutProps) => { + return ( +
+
+
{sidebar}
+
{children}
+
{footer}
+
+
+ ) +} diff --git a/src/components/sectionedForm/SectionedFormSection.tsx b/src/components/sectionedForm/SectionedFormSection.tsx new file mode 100644 index 00000000..e9731814 --- /dev/null +++ b/src/components/sectionedForm/SectionedFormSection.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { useSelectedSection } from '../../lib' + +export type SectionedFormSectionProps = { + children: React.ReactNode + active: boolean +} + +export const SectionedFormSection = ({ + children, + active, +}: SectionedFormSectionProps) => { + if (!active) { + return null + } + return
{children}
+} diff --git a/src/components/sectionedForm/SectionedFormSidebar.tsx b/src/components/sectionedForm/SectionedFormSidebar.tsx new file mode 100644 index 00000000..2280068a --- /dev/null +++ b/src/components/sectionedForm/SectionedFormSidebar.tsx @@ -0,0 +1,64 @@ +import i18n from '@dhis2/d2-i18n' +import cx from 'classnames' +import React, { useMemo, useState } from 'react' +import { + FORM_SECTION_PARAM_KEY, + getSectionSearchParam, + SectionedFormDescriptor, + useSelectedSection, +} from '../../lib' +import css from './SectionFormSidebar.module.css' +import { + createSearchParams, + Link, + NavLink, + useParams, + useSearchParams, +} from 'react-router-dom' + +export const SectionedFormSidebar = ({ + children, +}: { + children: React.ReactNode +}) => { + return +} + +export type SectionedFormSidebarItemProps = { + children: React.ReactNode + selected: boolean + sectionName: string +} + +export const SectionedFormSidebarItem = ({ + children, + selected, + sectionName, +}: SectionedFormSidebarItemProps) => { + const [searchParams] = useSearchParams() + + // we want to use a link in the list (due to accessbility reasons) + // thus we need to create new SearchParams with the section parameter + // in case we want to preserve other search-params + const toWithSectionParam = useMemo( + () => + createSearchParams({ + ...searchParams, + [FORM_SECTION_PARAM_KEY]: sectionName, + }).toString(), + [searchParams, sectionName] + ) + + return ( + + {children} + + ) +} + +export type FormSidebarFromDescriptor = { + formDescriptor: SectionedFormDescriptor +} diff --git a/src/components/sectionedForm/index.ts b/src/components/sectionedForm/index.ts new file mode 100644 index 00000000..045e93be --- /dev/null +++ b/src/components/sectionedForm/index.ts @@ -0,0 +1 @@ +export * from './SectionedFormSection' diff --git a/src/lib/form/formDescriptor.ts b/src/lib/form/formDescriptor.ts new file mode 100644 index 00000000..6ca58934 --- /dev/null +++ b/src/lib/form/formDescriptor.ts @@ -0,0 +1,49 @@ +export type FieldDescriptor = { + label: string + // keyof T | (string & {}) allows auto-completion for keys of T, while also allowing + // any other string to be used as a key. This allows fields that not necessarily map to the model-property + name: keyof T | (string & {}) +} + +export type SectionDescriptor = { + label: string + name: string + // keyof T | (string & {}) allows auto-completion for fields, while also allowing + // any other string to be used as a key + fields: FieldDescriptor[] +} + +export type SectionedFormDescriptor = { + name: string + label: string + sections: SectionDescriptor[] +} + +export type ExtractFieldNames = T extends { + sections: { fields: { name: infer N }[] }[] +} + ? N + : never + +export const getLabelForField = + >( + descriptor: T + ) => + (field: F, section?: string) => { + if (section) { + const sectionDescriptor = descriptor.sections.find( + (s) => s.name === section + ) + if (sectionDescriptor) { + const fieldDescriptor = sectionDescriptor.fields.find( + (f) => f.name === field + ) + if (fieldDescriptor) { + return fieldDescriptor.label + } + } + } + return descriptor.sections + .flatMap((s) => s.fields) + .find((f) => f.name === field)?.label + } diff --git a/src/lib/form/index.ts b/src/lib/form/index.ts index 0a796e6c..b08c644d 100644 --- a/src/lib/form/index.ts +++ b/src/lib/form/index.ts @@ -6,3 +6,4 @@ export { validate, createFormValidate } from './validate' export { useOnSubmitEdit, useOnSubmitNew } from './useOnSubmit' export { modelFormSchemas } from './modelFormSchemas' export * from './sectionedForm' +export * from './formDescriptor' diff --git a/src/lib/form/modelFormSchemas.ts b/src/lib/form/modelFormSchemas.ts index b6857d87..0654f512 100644 --- a/src/lib/form/modelFormSchemas.ts +++ b/src/lib/form/modelFormSchemas.ts @@ -27,10 +27,16 @@ const withAttributeValues = z.object({ attributeValues: attributeValues, }) +const style = z.object({ + color: z.string().optional(), + icon: z.string().optional(), +}) + export const modelFormSchemas = { objectReference: modelReference, referenceCollection, identifiable, attributeValues, withAttributeValues, + style, } diff --git a/src/lib/form/sectionedForm/SectionedFormDescriptorProvider.tsx b/src/lib/form/sectionedForm/SectionedFormDescriptorProvider.tsx new file mode 100644 index 00000000..5e69e51f --- /dev/null +++ b/src/lib/form/sectionedForm/SectionedFormDescriptorProvider.tsx @@ -0,0 +1,89 @@ +import React, { createContext, useState } from 'react' +import { SectionDescriptor, SectionedFormDescriptor } from '../formDescriptor' +import { DataSetFormDescriptor } from '../../../pages/dataSets/form/formDescriptor' + +/* Some of the types in this file may look complex. + However they are here to help type-safety and autocommpletion for consumers. + + The only thing consumers need to do is pass the type of the formdescriptor to use the context. + useSectionedFormDescriptor() + This helps usage in specific form components. +*/ + +type AllFieldNames = + T['sections'][number]['fields'][number]['name'] + +/* Helper to avoid returning undefined from a map when we know we have the value from the type. +And conversely - add undefined to TType if we dont have a specifc type for T*/ +type EnforceIfInferrable< + T extends SectionedFormDescriptor, + TType +> = T extends SectionedFormDescriptor + ? unknown extends U + ? TType | undefined + : TType + : never + +function createContextValue(descriptor: T) { + const fieldLabels = Object.fromEntries( + descriptor.sections.flatMap((section) => + section.fields.map((f) => [f.name, f.label] as const) + ) + ) as Record, EnforceIfInferrable> + + const sectionMap = Object.fromEntries( + descriptor.sections.map((s) => [s.name, s]) + ) as Record< + T['sections'][number]['name'], + EnforceIfInferrable + > + + const sections: T['sections'] = descriptor.sections + return { + formName: descriptor.name, + formLabel: descriptor.label, + sections, + getSection: (name: T['sections'][number]['name']) => sectionMap[name], + getFieldLabel: (field: AllFieldNames) => { + return fieldLabels[field] + }, + } +} +const con = createContextValue(DataSetFormDescriptor) +type SectionFormContextValue = ReturnType< + typeof createContextValue +> + +export const SectionedFormContext = createContext | null>(null) + +export const SectionedFormDescriptorProvider = < + T extends SectionedFormDescriptor +>({ + children, + initialValue, +}: { + initialValue: T + children: React.ReactNode +}) => { + const [contextValue] = useState(() => createContextValue(initialValue)) + + return ( + + {children} + + ) +} + +export const useSectionedFormDescriptor = < + T extends SectionedFormDescriptor +>() => { + const context = React.useContext(SectionedFormContext) + if (!context) { + throw new Error( + 'useSectionedFormDescriptor must be used within a SectionedFormDescriptorProvider' + ) + } + return context as SectionFormContextValue +} diff --git a/src/lib/form/sectionedForm/index.ts b/src/lib/form/sectionedForm/index.ts index 5a68f14a..d22f6e6d 100644 --- a/src/lib/form/sectionedForm/index.ts +++ b/src/lib/form/sectionedForm/index.ts @@ -2,3 +2,5 @@ export { SectionFormField } from './SectionedFormField' export { SectionedFormSection } from './SectionedFormSection' export { SectionedFormBase } from './SectionedFormBase' export { useSectionedFormState } from './useSectionedFormState' +export * from './useSelectedSection' +export * from './SectionedFormDescriptorProvider' diff --git a/src/lib/form/sectionedForm/useSelectedSection.ts b/src/lib/form/sectionedForm/useSelectedSection.ts new file mode 100644 index 00000000..96fe3866 --- /dev/null +++ b/src/lib/form/sectionedForm/useSelectedSection.ts @@ -0,0 +1,52 @@ +import { useMemo } from 'react' +import { + useQueryParam, + StringParam, + createEnumParam, + withDefault, +} from 'use-query-params' +import { useSectionedFormDescriptor } from './SectionedFormDescriptorProvider' + +export const FORM_SECTION_PARAM_KEY = 'section' + +export const getSectionSearchParam = (section: string) => { + return `${FORM_SECTION_PARAM_KEY}=${section}` +} + +export const useSelectedSection = () => { + const { sections } = useSectionedFormDescriptor() + + const paramConfig = useMemo( + () => + withDefault( + createEnumParam(sections.map((s) => s.name)), + sections[0].name + ), + [sections] + ) + + console.log({ paramConfig }) + return useQueryParam(FORM_SECTION_PARAM_KEY, paramConfig, { + removeDefaultsFromUrl: true, + }) +} + +/* Helper to create a type-safe hook for useSelectedSection */ +export const createUseSelectedSection = < + TSection extends string, + TDefault extends TSection +>( + sections: TSection[], + defaultSection: TDefault +) => { + const queryParamType = withDefault( + createEnumParam(sections), + defaultSection + ) + const useSelectedSection = () => { + return useQueryParam(FORM_SECTION_PARAM_KEY, queryParamType, { + removeDefaultsFromUrl: true, + }) + } + return useSelectedSection +} diff --git a/src/lib/form/types.ts b/src/lib/form/types.ts new file mode 100644 index 00000000..70990b4d --- /dev/null +++ b/src/lib/form/types.ts @@ -0,0 +1,138 @@ +import { Field } from '@dhis2/ui' +import { DataSet } from '../../types/generated' + +// type AllowAnyString +type FieldDescriptor = { + label: string +} + +type SectionDescriptor = { + label: string + // keyof T | (string & {}) allows auto-completion for fields, while also allowing + // any other string to be used as a key + fields: Partial> + // fields: Partial> +} + +type FormDescriptor = { + name: string + sections: Record> +} + +const DataSetDescriptor = { + name: 'DataSet', + sections: { + basic: { + label: 'Basic information', + fields: { + name: { + label: 'Name', + }, + code: { + label: 'Code', + }, + someOtherField: { + label: 'hello', + }, + access: { + label: 'Access', + }, + style: { + label: 'Style', + }, + someOtherField: { + label: 'Some other field', + }, + }, + }, + }, +} as const satisfies FormDescriptor + +type FieldDescriptorAlt = { + label: string + // keyof T | (string & {}) allows auto-completion for keys of T, while also allowing + // any other string to be used as a key. This allows fields that not necessarily map to the model-property + name: keyof T | (string & {}) +} + +type SectionDescriptorAlt = { + label: string + name: string + // keyof T | (string & {}) allows auto-completion for fields, while also allowing + // any other string to be used as a key + fields: FieldDescriptorAlt[] +} + +type DescriptorAlt = { + name: string + label: string + sections: SectionDescriptorAlt[] +} + +const DataSetDescriptorAlt = { + name: 'DataSet', + label: 'Data Set', + sections: [ + { + name: 'basic', + label: 'Basic information', + fields: [ + { + name: 'name', + label: 'Name', + }, + { + name: 'code', + label: 'Code', + }, + { + name: 'sharing', + label: 'Access', + }, + { + name: 'style', + label: 'Style', + }, + { + name: 'someOtherField', + label: 'Some other field', + }, + ], + }, + ], +} satisfies DescriptorAlt + +type ExtractFieldNames = T extends { + sections: { fields: { name: infer N }[] }[] +} + ? N + : never + +const getLabelForField = < + T extends DescriptorAlt, + F extends ExtractFieldNames +>( + field: F, + descriptor: T, + section?: string +) => { + if (section) { + const sectionDescriptor = descriptor.sections.find( + (s) => s.name === section + ) + if (sectionDescriptor) { + const fieldDescriptor = sectionDescriptor.fields.find( + (f) => f.name === field + ) + if (fieldDescriptor) { + return fieldDescriptor.label + } + } + } + return descriptor.sections + .flatMap((s) => s.fields) + .find((f) => f.name === field)?.label +} +const label = getLabelForField('style', DataSetDescriptorAlt) + +const nameLabel = DataSetDescriptor.sections.basic.fields.name.label diff --git a/src/pages/dataSets/New.tsx b/src/pages/dataSets/New.tsx index 88c51d6d..6bb79627 100644 --- a/src/pages/dataSets/New.tsx +++ b/src/pages/dataSets/New.tsx @@ -1,63 +1,34 @@ import React from 'react' +import { FormBase } from '../../components' +import { DefaultSectionedFormSidebar } from '../../components/sectionedForm/DefaultSectionedFormSidebar' +import { DefaultSectionedFormFooter } from '../../components/sectionedForm/SectionedFormFooter' +import { SectionedFormLayout } from '../../components/sectionedForm/SectionedFormLayout' import { - SectionedFormBase, - SectionedFormSection, - SectionFormField, - useSectionedFormState, + SectionedFormDescriptorProvider, + SECTIONS_MAP, + useOnSubmitEdit, + useOnSubmitNew, } from '../../lib' -import { LinkButton } from '../../components/LinkButton' -import { NavLink } from 'react-router-dom' -export const Component = () => { - const [inc, setInc] = React.useState(0) - - return ( -
-

New Data Set

-

This is where you can create a new data set

-
- - - -
- - - -
-
- -
- - - -
-
-
- setInc(inc + 1)} - /> -
-
- ) -} +import { DataSetFormContents } from './form/DataSetFormContents' +import { initialValues, validate } from './form/dataSetFormSchema' +import { DataSetFormDescriptor } from './form/formDescriptor' -const SideBar = () => { - const sections = useSectionedFormState((state) => state.sections) - const state = useSectionedFormState() - console.log({ sections }) - const sectionForField = state.getSectionsForField('name') - console.log({ sectionForField }) +const section = SECTIONS_MAP.dataSet +export const Component = () => { return ( -
-

Sections

-
    - {sections.map((section) => ( -
  • {section.label}
  • - ))} - Overview - Overview -
-
+ + } + footer={} + > + + + + + ) } diff --git a/src/pages/dataSets/form/BasicSection.tsx b/src/pages/dataSets/form/BasicSection.tsx new file mode 100644 index 00000000..5bd27fa7 --- /dev/null +++ b/src/pages/dataSets/form/BasicSection.tsx @@ -0,0 +1 @@ +export const BasicSection = () => {} diff --git a/src/pages/dataSets/form/DataSetFormContents.tsx b/src/pages/dataSets/form/DataSetFormContents.tsx new file mode 100644 index 00000000..4879456d --- /dev/null +++ b/src/pages/dataSets/form/DataSetFormContents.tsx @@ -0,0 +1,67 @@ +import i18n from '@dhis2/d2-i18n' +import React from 'react' +import { useFormState } from 'react-final-form' +import { + DefaultIdentifiableFields, + DescriptionField, + ModelTransferField, + StandardFormField, + StandardFormSectionDescription, + StandardFormSectionTitle, +} from '../../../components' +import { SectionedFormSection } from '../../../components/sectionedForm' +import { + SECTIONS_MAP, + useSectionedFormDescriptor, + useSelectedSection, +} from '../../../lib' +import { DataSetFormDescriptor } from './formDescriptor' +import { CategoryComboField } from '../../dataElements/fields' + +const section = SECTIONS_MAP.dataSet +export const DataSetFormContents = () => { + const descriptor = + useSectionedFormDescriptor() + + const [selectedSection] = useSelectedSection() + const form = useFormState() + + console.log({ form }) + return ( + <> + + + {i18n.t('Basic information')} + + + {i18n.t('Set up the basic information for this data set.')} + + + + + + + {i18n.t('Configure data elements')} + + + {i18n.t('Choose what data is collected for this data set.')} + + + + + + + + + + ) +} diff --git a/src/pages/dataSets/form/dataSetFormSchema.ts b/src/pages/dataSets/form/dataSetFormSchema.ts new file mode 100644 index 00000000..6a5605bf --- /dev/null +++ b/src/pages/dataSets/form/dataSetFormSchema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { getDefaults, modelFormSchemas } from '../../../lib' +import { createFormValidate } from '../../../lib/form/validate' + +const { withAttributeValues, identifiable, style, referenceCollection } = + modelFormSchemas + +export const dataSetFormSchema = identifiable + .merge(withAttributeValues) + .extend({ + id: z.string().optional(), + code: z.string().trim().optional(), + description: z.string().trim().optional(), + style, + dataElements: referenceCollection.default([]), + categoryCombo: z.object({ id: z.string() }), + }) + +export const initialValues = getDefaults(dataSetFormSchema) + +export const validate = createFormValidate(dataSetFormSchema) diff --git a/src/pages/dataSets/form/fieldFilters.ts b/src/pages/dataSets/form/fieldFilters.ts new file mode 100644 index 00000000..e61ffc82 --- /dev/null +++ b/src/pages/dataSets/form/fieldFilters.ts @@ -0,0 +1,37 @@ +import { + ATTRIBUTE_VALUES_FIELD_FILTERS, + DEFAULT_FIELD_FILTERS, +} from '../../../lib' +import { DataSet, PickWithFieldFilters } from '../../../types/generated' + +const fieldFilters = [ + ...DEFAULT_FIELD_FILTERS, + ...ATTRIBUTE_VALUES_FIELD_FILTERS, + 'categoryCombos[id,displayName]', + 'dataElementDecoration', + 'dataEntryForm[id]', + 'dataInputPeriods', + 'dataSetElements[id,dataElement[id,displayName]]', + 'dimensionItem', + 'displayOptions', + 'fieldCombinationRequired', + 'indicators[id,displayName]', + 'name', + 'organisationUnits[id,displayName]', + 'periodType', + 'renderAsTabs', + 'renderHorizontally', + 'sections', + 'skipOffline', + 'timelyDays', + 'validCommpleteOnly', +] as const + +// DisplayOptions are handld by client only +// TODO: this should have a zod-schema and validation +type DisplayOptions = Record + +export type DataSetFormValues = PickWithFieldFilters< + DataSet, + typeof fieldFilters +> & { displayOptions: DisplayOptions } diff --git a/src/pages/dataSets/form/formDescriptor.tsx b/src/pages/dataSets/form/formDescriptor.tsx new file mode 100644 index 00000000..4f360977 --- /dev/null +++ b/src/pages/dataSets/form/formDescriptor.tsx @@ -0,0 +1,71 @@ +import i18n from '@dhis2/d2-i18n' +import { + createUseSelectedSection, + SectionedFormDescriptor, + useSelectedSection, +} from '../../../lib' +import { DataSetFormValues } from './fieldFilters' + +export const DataSetFormDescriptor = { + name: 'DataSet', + label: 'Data Set', + sections: [ + { + name: 'setup', + label: i18n.t('Setup'), + fields: [ + { + name: 'name', + label: i18n.t('Name'), + }, + { + name: 'shortName', + label: i18n.t('Short name'), + }, + { + name: 'code', + label: i18n.t('Code'), + }, + { + name: 'description', + label: i18n.t('Description'), + }, + { + name: 'style', + label: i18n.t('Color and icon'), + }, + ], + }, + { + name: 'data', + label: i18n.t('Data'), + fields: [ + { + name: 'dataElements', + label: i18n.t('Data Elements'), + }, + { + name: 'categoryCombo', + label: 'Category Combination', + }, + { + name: 'indicators', + label: i18n.t('Indicators'), + }, + ], + }, + { + name: 'periods', + label: i18n.t('Periods'), + fields: [], + }, + { name: 'validation', label: i18n.t('Validation'), fields: [] }, + { + name: 'organisationUnits', + label: i18n.t('Organisation Units'), + fields: [], + }, + { name: 'form', label: i18n.t('Form'), fields: [] }, + { name: 'advanced', label: i18n.t('Advanced'), fields: [] }, + ], +} as const satisfies SectionedFormDescriptor