Skip to content

Commit 6992856

Browse files
authored
Merge pull request #1608 from andrewnicols/MDL-88507
[docs] Document the theming of Components
2 parents ad1363b + 33a6e7d commit 6992856

3 files changed

Lines changed: 283 additions & 25 deletions

File tree

‎docs/guides/javascript/react/testing.md‎

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,34 +43,53 @@ npm test -- --coverage
4343

4444
Test files must match the glob `**/esm/tests/**/*.test.{ts,tsx}`. Place them alongside the source they test:
4545

46-
```
47-
public
48-
└── lib
49-
└── js
50-
└── esm
51-
├── src
52-
│ └── output
53-
│ └── ExampleComponent.tsx
54-
└── tests
55-
└── output
56-
└── ExampleComponent.test.ts
57-
```
46+
import FileTree from '@site/src/components/FileTree';
47+
48+
<FileTree structure={{
49+
"public": {
50+
"lib": {
51+
"js": {
52+
"esm": {
53+
"src": {
54+
"output": {
55+
"ExampleComponent.tsx": "The source file being tested"
56+
}
57+
},
58+
"tests": {
59+
"output": {
60+
"ExampleComponent.test.ts": "The test for ExampleComponent.tsx"
61+
}
62+
}
63+
}
64+
}
65+
}
66+
}
67+
}} />
5868

5969
The same convention applies to plugin components:
6070

61-
```
62-
public
63-
└── mod
64-
└── forum
65-
└── js
66-
└── esm
67-
├── src
68-
│ └── output
69-
│ └── ExampleComponent.tsx
70-
└── tests
71-
└── output
72-
└── ExampleComponent.test.ts
73-
```
71+
<FileTree structure={{
72+
"public": {
73+
"mod": {
74+
"forum": {
75+
"js": {
76+
"esm": {
77+
"src": {
78+
"output": {
79+
"ExampleComponent.tsx": "The source file being tested"
80+
}
81+
},
82+
"tests": {
83+
"output": {
84+
"ExampleComponent.test.ts": "The test for ExampleComponent.tsx"
85+
}
86+
}
87+
}
88+
}
89+
}
90+
}
91+
}
92+
}} />
7493

7594
## Writing a test
7695

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
---
2+
title: Theming Components
3+
tags:
4+
- react
5+
- javascript
6+
- theming
7+
- theme
8+
description: How to theme React Components
9+
---
10+
11+
<Since version="5.2" issueNumber="MDL-88507" />
12+
13+
Moodle supports the theming of React components using dynamic path-resolution for React components based on the user's active theme. It allows theme developers to fully override React components for both Moodle core, and any Moodle plugin, while providing mechanisms to extend or fall back to original implementations.
14+
15+
## How Path Resolution Works
16+
17+
When a component is requested using the standard naming format (`@moodle/lms/<component>/<path>`), the system dynamically checks the user's **current theme** before falling back to the standard location.
18+
19+
### Resolution Logic
20+
21+
If a user's current theme is set to **`boost`**, a request for `@moodle/lms/core/ExampleComponent` triggers the following lookup sequence:
22+
23+
1. Check for a theme-specific override:
24+
25+
```
26+
public/theme/boost/js/esm/src/overrides/core/ExampleComponent.tsx
27+
```
28+
29+
2. Fall back to Core (if no theme overrides exist)
30+
31+
```
32+
public/lib/js/esm/src/ExampleComponent.tsx
33+
```
34+
35+
Similarly, if a user's current theme is set to **`classic`**, a request for `@moodle/lms/mod_assign/local/grader/GradingPanel` triggers the following lookup sequence:
36+
37+
1. Check for a theme-specific override:
38+
39+
```
40+
public/theme/classic/js/esm/src/overrides/mod_assign/local/grader/GradingPanel.tsx
41+
```
42+
43+
2. Fall back to the plugin (if no theme overrides exist)
44+
45+
```
46+
public/mod_assign/js/esm/src/local/grader/GradingPanel.tsx
47+
```
48+
49+
:::danger[Theme parent fallbacks]
50+
51+
It is worth noting that components **do not** fall back to a parent theme.
52+
53+
A request for a component in a child theme **does not** check the parent theme automatically.
54+
55+
:::
56+
57+
### The Override File Structure
58+
59+
To override a component, the file must be placed within the theme's override directory using the following structure:
60+
61+
```text
62+
public/theme/<themename>/js/esm/src/overrides/<component>/<path>
63+
```
64+
65+
## Importing Original or Parent Components
66+
67+
Theme developers often need to wrap or extend an existing component rather than rewriting it from scratch. To prevent infinite loop resolution errors, specific aliases are provided to target the core or a parent theme explicitly.
68+
69+
### Targeting the Core/Original Component
70+
71+
To pull in the un-themed, baseline version of a component, use the `theme-original` scope modifier:
72+
73+
```typescript title="public/theme/boost/js/esm/src/overrides/core/ExampleComponent.ts"
74+
import {ExampleComponent as OriginalComponent} from '@moodle/lms/theme-original/core/ExampleComponent';
75+
76+
export const ExampleComponent = (props) => {
77+
// Add custom theme wrappers around the original core component
78+
return (
79+
<div className="boost-theme-wrapper">
80+
<OriginalComponent {...props}/>
81+
</div>
82+
);
83+
};
84+
```
85+
86+
### Targeting a Parent Theme Component
87+
88+
If your theme inherits from a hierarchy of themes, you can choose which overrides to attempt to load. For example, in the given theme structure:
89+
90+
import FileTree from '@site/src/components/FileTree';
91+
92+
<FileTree structure={{
93+
"boost": {
94+
"classic": {
95+
"retro": "Your theme is the grandchild"
96+
}
97+
}
98+
}} />
99+
100+
You can explicitly fetch either:
101+
102+
- a version for the `classic` theme using `@moodle/lms/theme-classic/mod_myfirstplugin/ExampleComponent`; or
103+
- a version for the `boost` theme using `@moodle/lms/theme-boost/mod_myfirstplugin/ExampleComponent`; or
104+
- the version shipped with the plugin using `@moodle/lms/theme-original/mod_myfirstplugin/ExampleComponent`.
105+
106+
```typescript title="public/theme/retro/js/esm/src/overrides/mod_myfirstplugin/ExampleComponent.ts"
107+
import {ExampleComponent as ClassicComponent} from '@moodle/lms/theme-classic/mod_myfirstplugin/ExampleComponent';
108+
109+
export const ExampleComponent = (props) => {
110+
// Extend the classic theme's implementation
111+
return <ClassicComponent boostEnhanced={true} {...props}/>;
112+
};
113+
```
114+
115+
## Loading components from an override
116+
117+
When overriding a component you must be careful not to introduce circular dependencies by loading the themed version of the _same_ component.
118+
119+
:::critical
120+
121+
Never use the bare `@moodle/lms/` alias to load the wrapped code inside an override file to reference the component you are overriding.
122+
123+
:::
124+
125+
```typescript title="public/theme/retro/js/esm/src/overrdies/mod_myfirstplugin/ExampleComponent.ts"
126+
// We must use the `theme-original` variant to load the original here:
127+
import {
128+
ExampleComponent as OriginalComponent,
129+
} from '@moodle/lms/theme-original/mod_myfirstplugin/ExampleComponent';
130+
131+
// But for loading _other_ components, we can use the standard import:
132+
import {UnrelatedComponent} from '@moodle/lms/mod_myfirstplugin/UnrelatedComponent';
133+
134+
export const ExampleComponent = (props) => {
135+
return (
136+
<UnrelatedComponent>
137+
<OriginalComponent boostEnhanced={true} {...props}/>;
138+
</UnrelatedComponent>
139+
);
140+
};
141+
142+
```
143+
144+
## Testing theme overrides
145+
146+
You can write tests for your theme-specific overrides in the same way that tests can be written for other components.
147+
148+
When importing your component under test, you must use the `theme-<themename>` import, for example:
149+
150+
```typescript title="public/theme/boost/js/esm/tests/overrides/core/ExampleComponent.test.ts"
151+
import {ExampleComponent} from '@moodle/lms/theme-boost/core/ExampleComponent';
152+
```

‎src/components/FileTree/index.tsx‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Copyright (c) Moodle Pty Ltd.
3+
*
4+
* Moodle is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* Moodle is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
import React from 'react';
19+
import CodeBlock from '@theme/CodeBlock';
20+
21+
function parseObjectTree(tree: DirectoryStructure, prefix = ''): string {
22+
const keys = Object.keys(tree);
23+
let result = '';
24+
25+
keys.forEach((key, index) => {
26+
const isLast = index === keys.length - 1;
27+
const pointer = isLast ? '└── ' : '├── ';
28+
const nextPrefix = prefix + (isLast ? ' ' : '│ ');
29+
30+
const value = tree[key];
31+
32+
let comment = '';
33+
let children: DirectoryStructure | undefined;
34+
35+
// Determine the format at runtime
36+
if (value !== null && typeof value === 'object') {
37+
if ('_comment' in value || '_children' in value) {
38+
// It's using the expanded object format
39+
const expanded = value as ExpandedItem;
40+
comment = expanded._comment ? ` # ${expanded._comment}` : ''; // eslint-disable-line no-underscore-dangle
41+
children = expanded._children; // eslint-disable-line no-underscore-dangle
42+
} else {
43+
// It's using the simple nested directory format
44+
children = value as DirectoryStructure;
45+
}
46+
} else if (typeof value === 'string') {
47+
// Inline shorthand string comment support: "filename": "comment string"
48+
comment = ` # ${value}`;
49+
}
50+
51+
// 1. Append the node line
52+
result += `${prefix}${pointer}${key}${comment}\n`;
53+
54+
// 2. Recurse into children if they exist
55+
if (children) {
56+
result += parseObjectTree(children, nextPrefix);
57+
}
58+
});
59+
60+
return result;
61+
}
62+
63+
type FileValue = string | null;
64+
65+
export interface ExpandedItem {
66+
_comment?: string;
67+
_children?: DirectoryStructure;
68+
}
69+
70+
// A node can now be a primitive file, a simple nested directory, or an expanded item
71+
export type TreeItem = FileValue | DirectoryStructure | ExpandedItem;
72+
73+
export interface DirectoryStructure {
74+
[nodeName: string]: TreeItem;
75+
}
76+
77+
export type FileTreeProps = {
78+
structure: DirectoryStructure;
79+
};
80+
81+
export default function FileTree({ structure }: FileTreeProps): JSX.Element {
82+
return (
83+
<CodeBlock>
84+
{parseObjectTree(structure)}
85+
</CodeBlock>
86+
);
87+
}

0 commit comments

Comments
 (0)