Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,7 @@ const config = {
ecmaFeatures: { jsx: true },
},
plugins: ['@typescript-eslint'],
extends: [
'plugin:@typescript-eslint/recommended',
],
extends: ['plugin:@typescript-eslint/recommended'],
settings: {
'import/resolver': {
typescript: {
Expand All @@ -63,9 +61,15 @@ const config = {
// Disable JS-only rules that conflict with TS
'react/prop-types': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
// Keep consistent with existing code style
'react/no-unknown-property': ['error', { ignore: ['jsx', 'global'] }],
'react/no-unknown-property': [
'error',
{ ignore: ['jsx', 'global'] },
],
// Allow .js extension imports in TS files (Babel resolves .tsx -> .js)
'import/extensions': 'off',
},
Expand Down
60 changes: 33 additions & 27 deletions MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ This document describes the approach for incrementally migrating `@dhis2/ui` com

## Key Findings

- **Build system**: `d2-app-scripts build` uses Babel with `@babel/preset-typescript` already included — it can compile `.ts`/`.tsx` out of the box.
- **styled-jsx**: Ships TypeScript definitions that augment React's `StyleHTMLAttributes` with `jsx` and `global` props. Works in `.tsx` files with the `"types": ["styled-jsx"]` tsconfig option.
- **Existing types**: Each component has hand-written `.d.ts` files in a `types/` directory. After migration, these can be auto-generated from source via `tsc --declaration`.
- **ESLint**: The project uses `@dhis2/cli-style` (ESLint 7.x). TypeScript linting works via `@typescript-eslint/parser` v6 + `@typescript-eslint/eslint-plugin` v6.
- **One quirk**: `d2-app-scripts build` doesn't rename `.tsx` → `.js` in build output. A `post-build-rename.js` script handles this.
- **Build system**: `d2-app-scripts build` uses Babel with `@babel/preset-typescript` already included — it can compile `.ts`/`.tsx` out of the box.
- **styled-jsx**: Ships TypeScript definitions that augment React's `StyleHTMLAttributes` with `jsx` and `global` props. Works in `.tsx` files with the `"types": ["styled-jsx"]` tsconfig option.
- **Existing types**: Each component has hand-written `.d.ts` files in a `types/` directory. After migration, these can be auto-generated from source via `tsc --declaration`.
- **ESLint**: The project uses `@dhis2/cli-style` (ESLint 7.x). TypeScript linting works via `@typescript-eslint/parser` v6 + `@typescript-eslint/eslint-plugin` v6.
- **One quirk**: `d2-app-scripts build` doesn't rename `.tsx` → `.js` in build output. A `post-build-rename.js` script handles this.

## How to Migrate a Component

Expand All @@ -23,26 +23,32 @@ This document describes the approach for incrementally migrating `@dhis2/ui` com
"rootDir": "./src"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "build", "**/*.stories.*", "**/*.test.*", "**/*.e2e.*"]
"exclude": [
"node_modules",
"build",
"**/*.stories.*",
"**/*.test.*",
"**/*.e2e.*"
]
}
```

### 2. Convert source files from `.js` → `.tsx` (or `.ts`)

- Replace `PropTypes` with TypeScript interfaces
- Remove `prop-types` and `@dhis2/prop-types` imports
- Add explicit types for props, state, and function parameters
- Keep `styled-jsx` usage as-is (it works in `.tsx`)
- Keep import paths using `.js` extensions (Babel + Node resolve these to `.tsx`)
- Leave `.stories.js` and `.feature` test files as JavaScript
- Replace `PropTypes` with TypeScript interfaces
- Remove `prop-types` and `@dhis2/prop-types` imports
- Add explicit types for props, state, and function parameters
- Keep `styled-jsx` usage as-is (it works in `.tsx`)
- Keep import paths using `.js` extensions (Babel + Node resolve these to `.tsx`)
- Leave `.stories.js` and `.feature` test files as JavaScript

### 3. Update `d2.config.js` entry point

```js
module.exports = {
type: 'lib',
entryPoints: {
lib: 'src/index.ts', // was src/index.js
lib: 'src/index.ts', // was src/index.js
},
}
```
Expand Down Expand Up @@ -73,20 +79,20 @@ cd components/<name> && yarn build

## Files Added/Modified

| File | Purpose |
|------|---------|
| `tsconfig.json` (root) | Base TypeScript config for the whole repo |
| `components/<name>/tsconfig.json` | Per-component TS config extending root |
| `.eslintrc.js` | Added TypeScript override block for `.ts`/`.tsx` files |
| `scripts/ts-check.js` | Unified feedback pipeline (tsc + eslint + prettier) |
| `scripts/post-build-rename.js` | Renames `.tsx`/`.ts` → `.js` in build output |
| File | Purpose |
| --------------------------------- | ------------------------------------------------------ |
| `tsconfig.json` (root) | Base TypeScript config for the whole repo |
| `components/<name>/tsconfig.json` | Per-component TS config extending root |
| `.eslintrc.js` | Added TypeScript override block for `.ts`/`.tsx` files |
| `scripts/ts-check.js` | Unified feedback pipeline (tsc + eslint + prettier) |
| `scripts/post-build-rename.js` | Renames `.tsx`/`.ts` → `.js` in build output |

## Dev Dependencies Added

- `typescript` ~5.4.5
- `@typescript-eslint/parser` ^6
- `@typescript-eslint/eslint-plugin` ^6
- `eslint-import-resolver-typescript` ^3
- `typescript` ~5.4.5
- `@typescript-eslint/parser` ^6
- `@typescript-eslint/eslint-plugin` ^6
- `eslint-import-resolver-typescript` ^3

## Migration Order Recommendation

Expand All @@ -103,6 +109,6 @@ Start with leaf components (no internal deps) and work up:

## Notes

- Stories and E2E feature files can stay as `.js` — they don't need to be migrated immediately.
- The `types/index.d.ts` hand-written files can eventually be replaced by auto-generated declarations from `tsc --declaration`.
- The `import/extensions` ESLint rule is disabled for `.ts`/`.tsx` files because the codebase convention is to import with `.js` extensions (which Babel resolves to the actual `.tsx` files).
- Stories and E2E feature files can stay as `.js` — they don't need to be migrated immediately.
- The `types/index.d.ts` hand-written files can eventually be replaced by auto-generated declarations from `tsc --declaration`.
- The `import/extensions` ESLint rule is disabled for `.ts`/`.tsx` files because the codebase convention is to import with `.js` extensions (which Babel resolves to the actual `.tsx` files).
4 changes: 2 additions & 2 deletions collections/forms/i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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: 2026-01-12T11:22:32.560Z\n"
"PO-Revision-Date: 2026-01-12T11:22:32.562Z\n"
"POT-Creation-Date: 2026-04-13T21:27:29.445Z\n"
"PO-Revision-Date: 2026-04-13T21:27:29.445Z\n"

msgid "Upload file"
msgstr "Upload file"
Expand Down
3 changes: 2 additions & 1 deletion components/alert/src/alert-bar/alert-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ const AlertBar = ({
const pauseDisplayTimeout = () => {
if (shouldAutoHide) {
clearAllTimeouts()
const elapsedTime = Date.now() - (displayStartTime.current as number)
const elapsedTime =
Date.now() - (displayStartTime.current as number)
displayTimeRemaining.current =
(displayTimeRemaining.current as number) - elapsedTime
}
Expand Down
9 changes: 8 additions & 1 deletion components/alert/src/alert-bar/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ interface IconProps {
warning?: boolean
}

const Icon = ({ icon, success, warning, critical, info, dataTest }: IconProps) => {
const Icon = ({
icon,
success,
warning,
critical,
info,
dataTest,
}: IconProps) => {
if (icon === false) {
return null
}
Expand Down
8 changes: 7 additions & 1 deletion components/alert/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,11 @@
"rootDir": "./src"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "build", "**/*.stories.*", "**/*.test.*", "**/*.e2e.*"]
"exclude": [
"node_modules",
"build",
"**/*.stories.*",
"**/*.test.*",
"**/*.e2e.*"
]
}
8 changes: 7 additions & 1 deletion components/box/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,11 @@
"rootDir": "./src"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "build", "**/*.stories.*", "**/*.test.*", "**/*.e2e.*"]
"exclude": [
"node_modules",
"build",
"**/*.stories.*",
"**/*.test.*",
"**/*.e2e.*"
]
}
25 changes: 20 additions & 5 deletions components/button/src/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,28 @@ export interface ButtonProps {
* Callback to trigger on de-focus (blur).
* Called with same args as `onClick`
*/
onBlur?: (payload: ButtonCallbackPayload, event: React.FocusEvent<HTMLButtonElement>) => void
onBlur?: (
payload: ButtonCallbackPayload,
event: React.FocusEvent<HTMLButtonElement>
) => void
/**
* Callback to trigger on click.
* Called with args `({ value, name }, event)`
*/
onClick?: (payload: ButtonCallbackPayload, event: React.MouseEvent<HTMLButtonElement>) => void
onClick?: (
payload: ButtonCallbackPayload,
event: React.MouseEvent<HTMLButtonElement>
) => void
/** Callback to trigger on focus. Called with same args as `onClick` */
onFocus?: (payload: ButtonCallbackPayload, event: React.FocusEvent<HTMLButtonElement>) => void
onFocus?: (
payload: ButtonCallbackPayload,
event: React.FocusEvent<HTMLButtonElement>
) => void
/** Callback to trigger on key-down. Called with same args as `onClick` */
onKeyDown?: (payload: ButtonCallbackPayload, event: React.KeyboardEvent<HTMLButtonElement>) => void
onKeyDown?: (
payload: ButtonCallbackPayload,
event: React.KeyboardEvent<HTMLButtonElement>
) => void
[key: string]: unknown
}

Expand Down Expand Up @@ -107,7 +119,10 @@ export const Button = ({
}
}, [initialFocus, ref.current])

const { 'aria-label': ariaLabel, title } = otherProps as Record<string, unknown>
const { 'aria-label': ariaLabel, title } = otherProps as Record<
string,
unknown
>

if (!children && !title && !ariaLabel) {
console.debug(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react'
import { mount } from 'enzyme'
import React from 'react'
import { act } from 'react-dom/test-utils'
import { Modal } from '../../../../modal/src/modal/modal.js'
import { Modal } from '../../../../modal/src/modal/modal.tsx'
import { Button } from '../../index.ts'
import { DropdownButton } from '../dropdown-button.tsx'

Expand Down
24 changes: 20 additions & 4 deletions components/button/src/dropdown-button/dropdown-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,20 @@ export interface DropdownButtonProps {
* Called with signature `({ name: string, value: string, open: bool }, event)`
* Is required when using the `open` prop to override the internal state.
*/
onClick?: (payload: DropdownButtonCallbackPayload, event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent) => void
onClick?: (
payload: DropdownButtonCallbackPayload,
event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent
) => void
}

interface DropdownButtonState {
open: boolean
}

class DropdownButton extends Component<DropdownButtonProps, DropdownButtonState> {
class DropdownButton extends Component<
DropdownButtonProps,
DropdownButtonState
> {
state: DropdownButtonState = {
open: false,
}
Expand All @@ -137,7 +143,10 @@ class DropdownButton extends Component<DropdownButtonProps, DropdownButtonState>
}
}

onClickHandler = ({ name, value }: { name?: string; value?: string }, event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent) => {
onClickHandler = (
{ name, value }: { name?: string; value?: string },
event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent
) => {
const handleClick = (open: boolean) => {
if (this.props.onClick) {
this.props.onClick(
Expand Down Expand Up @@ -210,7 +219,14 @@ class DropdownButton extends Component<DropdownButtonProps, DropdownButtonState>
</Button>

{open && (
<Layer onBackdropClick={(_payload, event) => this.onClickHandler({}, event as unknown as React.SyntheticEvent)}>
<Layer
onBackdropClick={(_payload, event) =>
this.onClickHandler(
{},
event as unknown as React.SyntheticEvent
)
}
>
<Popper
dataTest={`${dataTest}-popper`}
placement="bottom-start"
Expand Down
3 changes: 0 additions & 3 deletions components/button/src/locales/index.d.ts

This file was deleted.

32 changes: 24 additions & 8 deletions components/button/src/split-button/split-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,22 @@ export interface SplitButtonProps {
* Callback triggered when the main button is clicked.
* Called with signature `({ name: string, value: string, open: bool }, event)`
*/
onClick?: (payload: SplitButtonCallbackPayload, event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent) => void
onClick?: (
payload: SplitButtonCallbackPayload,
event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent
) => void
/**
* Callback triggered when the dropdown is toggled (by clicking the chevron, pressing Escape, or clicking the backdrop).
* Called with signature `({ name: string, value: string, open: bool }, event)`.
* Required if `open` prop is used (controlled component).
*/
onToggle?: (payload: SplitButtonCallbackPayload, event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent | KeyboardEvent) => void
onToggle?: (
payload: SplitButtonCallbackPayload,
event:
| React.MouseEvent<HTMLButtonElement>
| React.SyntheticEvent
| KeyboardEvent
) => void
}

interface SplitButtonState {
Expand Down Expand Up @@ -119,7 +128,10 @@ class SplitButton extends Component<SplitButtonProps, SplitButtonState> {
}
}

handlePrimaryAction = (payload: { name?: string; value?: string }, event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent) => {
handlePrimaryAction = (
payload: { name?: string; value?: string },
event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent
) => {
if (this.props.onClick) {
this.props.onClick(
{
Expand All @@ -134,7 +146,10 @@ class SplitButton extends Component<SplitButtonProps, SplitButtonState> {
}
}

handleToggle = (payload: { name?: string; value?: string }, event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent) => {
handleToggle = (
payload: { name?: string; value?: string },
event: React.MouseEvent<HTMLButtonElement> | React.SyntheticEvent
) => {
if (this.isControlled()) {
if (this.props.onToggle) {
this.props.onToggle(
Expand All @@ -151,7 +166,10 @@ class SplitButton extends Component<SplitButtonProps, SplitButtonState> {
}
}

handleBackdropClick = (_payload: Record<string, never>, event: React.MouseEvent<HTMLDivElement>) => {
handleBackdropClick = (
_payload: Record<string, never>,
event: React.MouseEvent<HTMLDivElement>
) => {
if (this.isControlled()) {
if (this.props.onToggle) {
this.props.onToggle(
Expand Down Expand Up @@ -234,9 +252,7 @@ class SplitButton extends Component<SplitButtonProps, SplitButtonState> {
</Button>

{open && (
<Layer
onBackdropClick={this.handleBackdropClick}
>
<Layer onBackdropClick={this.handleBackdropClick}>
<Popper
dataTest={`${dataTest}-menu`}
placement="bottom-end"
Expand Down
8 changes: 7 additions & 1 deletion components/button/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,11 @@
"rootDir": "./src"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "build", "**/*.stories.*", "**/*.test.*", "**/*.e2e.*"]
"exclude": [
"node_modules",
"build",
"**/*.stories.*",
"**/*.test.*",
"**/*.e2e.*"
]
}
5 changes: 1 addition & 4 deletions components/calendar/src/calendar-input/calendar-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,7 @@ export const CalendarInput = ({
setPartialDate(e.value)
}

const handleBlur = (
_: unknown,
e: React.FocusEvent<HTMLInputElement>
) => {
const handleBlur = (_: unknown, e: React.FocusEvent<HTMLInputElement>) => {
if (
e.relatedTarget &&
(calendarRef.current?.contains(e.relatedTarget as Node) ||
Expand Down
Loading