mui 7
Massimo Melina committed
Mar 18, 2026 at 10:22 UTC
ead678197c0d0763184a3ee4d0134c89b1492632
15 files changed
+93
-49
admin/package.json
+8
-8
@@ -9,16 +9,16 @@
9
},
10
"dependencies": {
11
"@emotion/react": "^11.11.3",
12
- "@hfs/mui-grid-form": "*",
13
- "@hfs/shared": "*",
12
"@emotion/styled": "^11.11.5",
15
- "@mui/icons-material": "^6.4.11",
16
- "@mui/lab": "^6.0.1-beta.34",
17
- "@mui/material": "^6.4.11",
18
- "@mui/x-data-grid": "^7.29.0",
19
- "@mui/x-date-pickers": "^7.29.0",
20
- "@mui/x-tree-view": "^7.29.0",
13
"@gregoranders/csv": "^0.0.13",
14
+ "@hfs/mui-grid-form": "*",
15
+ "@hfs/shared": "*",
16
+ "@mui/icons-material": "^7.3.10",
17
+ "@mui/lab": "^7.0.1-beta.24",
18
+ "@mui/material": "^7.3.9",
19
+ "@mui/x-data-grid": "^8.27.5",
20
+ "@mui/x-date-pickers": "^8.27.2",
21
+ "@mui/x-tree-view": "^8.27.2",
22
"dayjs": "^1.11.10",
23
"prismjs": "^1.29.0",
24
"qr-creator": "^1.0.0",
admin/src/AccountsPage.ts
+2
-2
@@ -81,7 +81,7 @@ export default function AccountsPage() {
81
const [showTree, showTreeBtn] = useToggleButton("Show tree", "Show list", () => ({ icon: AccountTree }), accountsAsTree)
82
state.accountsAsTree = showTree
83
return element || h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
84
- h(Grid, { item: true, xs: 12, [sideBreakpoint]: 5, lg: 4, xl: 5, ...scrollProps },
84
+ h(Grid, { size: { xs: 12, [sideBreakpoint]: 5, lg: 4, xl: 5 } as any, ...scrollProps },
85
h(Box, {
86
display: 'flex',
87
flexWrap: 'wrap',
@@ -150,7 +150,7 @@ export default function AccountsPage() {
150
})(showTree ? list.filter(ac => !list.some(x => x.members?.includes(ac.username))) : list)
151
)
152
),
153
- isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%', ...scrollProps },
153
+ isSideBreakpoint && sideContent && h(Grid, { size: 'grow', maxWidth: '100%', ...scrollProps },
154
h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
155
h(CardContent, {}, sideContent)) )
156
)
admin/src/App.ts
+1
-7
@@ -26,13 +26,7 @@ function App() {
26
h(ApplyTheme, {},
27
h(LocalizationProvider, { dateAdapter: AdapterDayjs, adapterLocale: getLocale() },
28
h(LoginRequired, {},
29
- h(HashRouter, {
30
- // opt in to v7 behavior now to remove migration warnings in dev
31
- future: {
32
- v7_startTransition: true,
33
- v7_relativeSplatPath: true,
34
- }
35
- },
29
+ h(HashRouter, {},
30
h(Dialogs, {
31
style: {
32
display: 'flex', flexDirection: 'column',
admin/src/ArrayField.ts
+7
-3
@@ -129,6 +129,7 @@ export function ArrayField<T extends object>({
129
const title = "Modify"
130
return [
131
h(GridActionsCellItem as any, {
132
+ key: 'edit',
133
icon: h(Edit),
134
label: title,
135
title,
@@ -150,28 +151,31 @@ export function ArrayField<T extends object>({
151
}
152
}),
153
h(GridActionsCellItem as any, {
154
+ key: 'delete',
155
icon: h(Delete),
156
label: "Delete",
157
showInMenu: reorder,
156
- onClick: ev => {
158
+ onClick(ev: any) {
159
ev.stopPropagation()
160
set(valueA.filter((_rec, i) => i !== $idx), ev)
161
},
162
}),
163
reorder && $idx && h(GridActionsCellItem as any, {
164
+ key: 'up',
165
icon: h(ArrowUpward),
166
label: "Move up",
167
showInMenu: true,
165
- onClick: ev => {
168
+ onClick(ev: any) {
169
ev.stopPropagation()
170
set(swap(valueA.slice(), $idx, $idx - 1), ev)
171
},
172
}),
173
reorder && $idx < rows.length - 1 && h(GridActionsCellItem as any, {
174
+ key: 'down',
175
icon: h(ArrowDownward),
176
label: "Move down",
177
showInMenu: true,
174
- onClick: ev => {
178
+ onClick(ev: any) {
179
ev.stopPropagation()
180
set(swap(valueA.slice(), $idx, $idx + 1), ev)
181
},
admin/src/DataTable.ts
+1
-1
@@ -164,7 +164,7 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
164
onCellClick({ field, row }) {
165
if (field === ACTIONS || details === false) return
166
if (window.getSelection()?.type === 'Range') return // not a click but a drag
167
- const visibleInList = merged + apiRef.current.getVisibleColumns().length
167
+ const visibleInList = merged + (apiRef.current?.getVisibleColumns().length || 0)
168
const showInDialog = manipulatedColumns.filter(x =>
169
!x.dialogHidden && (x.renderCell || x.valueGetter || x.field === ACTIONS || row[x.field] !== undefined))
170
if (showInDialog.length <= visibleInList) return // no need for dialog
admin/src/HomePage.ts
+2
-1
@@ -108,7 +108,8 @@ export default function HomePage() {
108
x?.isNewer && h(Update, { info: x, fromAuto: true, bodyCollapsed: true, title: "An update has been found" }) ),
109
pluginUpdates.length > 0 && entry('success', "Updates available for plugin(s): " + pluginUpdates.map(p => p.id).join(', ')),
110
h(ConfigForm, {
111
- gridProps: { sx: { mt: 1, display: 'flex', columnGap: 1, alignitems: 'center', '&>div.MuiGrid2-root': { width: 'auto', px: .5, py: 0 }, '.MuiCheckbox-root': { pl: '2px' } } },
111
+ // MUI 7 folded Grid2 into Grid, so the generated class name changed with the import path.
112
+ gridProps: { sx: { mt: 1, display: 'flex', columnGap: 1, alignitems: 'center', '&>div.MuiGrid-root': { width: 'auto', px: .5, py: 0 }, '.MuiCheckbox-root': { pl: '2px' } } },
113
saveOnChange: true,
114
form: {
115
fields: [
admin/src/LogsPage.ts
+2
-2
@@ -160,8 +160,8 @@ export function LogFile({ file, footerSide, hidden, limit, filter, ...rest }: Lo
160
headerName: "Timestamp",
161
type: 'dateTime',
162
width: 96,
163
- valueGetter: (value) => new Date(value as string),
164
- renderCell: ({ value }) => h(Fragment, {}, value.toLocaleDateString(), h('br'), value.toLocaleTimeString())
163
+ valueGetter: v => new Date(v),
164
+ renderCell: ({ value }) => h(Fragment, {}, value.toLocaleDateString(), h('br'), value.toLocaleTimeString()),
165
}
166
const ipColumn: DataTableColumn = {
167
field: 'ip',
admin/src/MonitorPage.ts
+2
-2
@@ -142,7 +142,7 @@ function Connections() {
142
wantLogButton),
143
),
144
h(Grid, { container: true, flex: 1, columnSpacing: 1 },
145
- h(Grid, { item: true, xs: 12 - logSize, sx: fillFlexParentSx },
145
+ h(Grid, { size: 12 - logSize, sx: fillFlexParentSx },
146
h(DataTable, {
147
persist: 'connections',
148
error,
@@ -267,7 +267,7 @@ function Connections() {
267
]
268
}),
269
),
270
- logAble && wantLog && h(Grid, { item: true, xs: logSize, ...fillFlexParentSx },
270
+ logAble && wantLog && h(Grid, { size: logSize, ...fillFlexParentSx },
271
h(LogFile, {
272
file: `${CFG.log}|${CFG.error_log}`,
273
filter: monitorOnlyFiles ? (row => !row.uri.startsWith(SPECIAL_URI)) : undefined,
admin/src/VfsPage.ts
+2
-2
@@ -168,9 +168,9 @@ export default function VfsPage({ setTitleSide }: PageProps) {
168
}
169
const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
170
return h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
171
- h(Grid, { item: true, xs: 12, [sideBreakpoint]: 5, lg: 6, xl: 5, ...scrollProps },
171
+ h(Grid, { size: { xs: 12, [sideBreakpoint]: 5, lg: 6, xl: 5 } as any, ...scrollProps },
172
h(VfsTree, { statusApi }) ),
173
- isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%', ...scrollProps },
173
+ isSideBreakpoint && sideContent && h(Grid, { size: 'grow', maxWidth: '100%', ...scrollProps },
174
h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
175
h(CardContent, {}, sideContent)) )
176
)
admin/src/VfsTree.ts
+5
-3
@@ -34,9 +34,6 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
34
ref.current.firstElementChild?.classList.toggle('Mui-selected', !(selectedFiles.length && !_.find(selectedFiles, { id: '/' })))
35
return h(TreeItem, {
36
ref(el) {
37
- if (el)
38
- getOrSet(el.dataset, 'hfsFocus', () => // workaround to permit drag&drop with mui5's tree
39
- void el.addEventListener('focusin', (e: any) => e.stopImmediatePropagation()))
37
ref.current = el
38
},
39
onKeyUp(ev) {
@@ -141,6 +138,11 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
138
vfs && h(SimpleTreeView, {
139
ref,
140
expandedItems: toMutable(expanded),
141
+ expansionTrigger: 'iconContainer',
142
+ onExpandedItemsChange(_ev, ids) {
143
+ // keep placeholder helper rows out of expansion state to avoid persisting fake ids
144
+ state.expanded = wantArray(ids).filter((x): x is string => typeof x === 'string' && !x.startsWith(SPECIAL_TREE_ITEM))
145
+ },
146
selectedItems: selectedFiles.map(x => x.id),
147
multiSelect: true,
148
id: treeId,
e2e/admin-vfs.spec.ts
+37
-1
@@ -173,7 +173,7 @@ test('undo toggles with single-level redo behavior', async ({ page }) => {
173
await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
174
175
const folderName = 'for-disabled'
176
- const undoButton = page.locator('svg[data-testid="UndoIcon"]').first().locator('xpath=ancestor::button[1]')
176
+ const undoButton = page.getByRole('button', { name: 'Undo' }).first()
177
await expect(undoButton).toBeDisabled()
178
179
await selectVfsNode(page, folderName, '/for-disabled/')
@@ -306,3 +306,39 @@ test('apply refreshes inherited permissions for descendants in-memory', async ({
306
inheritedCanRead: false,
307
})
308
})
309
+
310
+test('tree expands from icon click and ArrowRight', async ({ page }) => {
311
+ await page.goto(ADMIN_URL)
312
+ await page.getByRole('textbox', { name: 'Username' }).fill(username)
313
+ await page.getByRole('textbox', { name: 'Password' }).fill(password)
314
+ await page.getByRole('textbox', { name: 'Password' }).press('Enter')
315
+ await clickAdminMenu(page, /Shared files/)
316
+ await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
317
+
318
+ const nodeId = '/'
319
+ const nodeName = 'Home folder'
320
+ const item = page.getByRole('treeitem', { name: nodeName, exact: true })
321
+
322
+ // start from collapsed to verify the actual interaction path
323
+ await page.evaluate(id => {
324
+ const state = (window as any).state
325
+ if (!state) return
326
+ state.expanded = state.expanded.filter((x: string) => x !== id)
327
+ }, nodeId)
328
+
329
+ await item.locator(':scope > .MuiTreeItem-content > .MuiTreeItem-iconContainer').click()
330
+ await expect.poll(() => page.evaluate(id => (window as any).state?.expanded?.includes(id) || false, nodeId))
331
+ .toBe(true)
332
+
333
+ if (!['Android', 'iPhone 6'].includes(test.info().project.name)) {
334
+ await page.evaluate(id => {
335
+ const state = (window as any).state
336
+ if (!state) return
337
+ state.expanded = state.expanded.filter((x: string) => x !== id)
338
+ }, nodeId)
339
+ await item.click()
340
+ await item.press('ArrowRight')
341
+ await expect.poll(() => page.evaluate(id => (window as any).state?.expanded?.includes(id) || false, nodeId))
342
+ .toBe(true)
343
+ }
344
+})
e2e/frontend.spec.ts
+2
-1
@@ -262,7 +262,7 @@ test('admin1', async ({ page }) => {
262
await dataTableLoading()
263
await screenshot(page)
264
await clickIconBtn('Options', page)
265
- await page.locator('div').filter({ hasText: 'ServedRequests are logged here. Empty to disable it.Not servedWrite errors in a different file. Empty to use same file.' }).nth(3).click()
265
+ await page.getByRole('textbox', { name: 'Served', exact: true }).click()
266
await clickIconBtn('Close', page)
267
await clickAdminMenu(page, 'Language')
268
await dataTableLoading()
@@ -287,6 +287,7 @@ test('admin1', async ({ page }) => {
287
})
288
289
async function screenshot(page: Page, selectorForMask = '') {
290
+ if (process.env.NO_SS) return
291
if (selectorForMask)
292
selectorForMask = ',' + selectorForMask
293
await wait(1000) // this accounts especially for our DataTable component which takes time to set the layout
e2e/serial.spec.ts
+16
-10
@@ -99,7 +99,7 @@ test('admin2', async ({ page }) => {
99
await passwordField.fill('admin2-temp-pass')
100
await page.getByRole('textbox', { name: 'Repeat password' }).fill('admin2-temp-pass')
101
await expect(usernameField).toHaveValue('admin2-temp-user')
102
- const adminAccess = page.getByRole('checkbox', { name: 'Admin-panel access' })
102
+ const adminAccess = page.getByRole('switch', { name: 'Admin-panel access' })
103
await adminAccess.check()
104
await expect(adminAccess).toBeChecked()
105
await page.getByRole('textbox', { name: 'Notes' }).fill('admin2 expanded interactions')
@@ -110,16 +110,15 @@ test('admin2', async ({ page }) => {
110
await expect(page.getByText('Correctly working on port')).toBeVisible()
111
await page.getByRole('button', { name: 'Reload' }).click()
112
await page.getByRole('row', { name: /^Blocked/ }).getByRole('button', { name: /Add/ }).click()
113
- const addDialog = page.getByRole('dialog').filter({ hasText: 'Add' })
113
+ const addDialog = page.getByRole('dialog', { name: /Add/ })
114
await addDialog.getByRole('textbox', { name: 'Blocked IP' }).fill('5.6.7.8')
115
if (!isPhone) {
116
// This field uses a masked input: selecting from picker is more reliable than typing.
117
await addDialog.getByRole('button', { name: 'Choose date' }).click()
118
- const picker = page.locator('.MuiPickersPopper-root[role="dialog"]')
119
- await picker.locator('button.MuiPickersDay-root:not([disabled])').first().click()
120
- // Close the popper so it doesn't intercept clicks on the Add dialog buttons.
118
+ const picker = page.getByRole('dialog', { name: 'Expire' })
119
+ // desktop calendars render hidden fillers and disabled days as gridcells too, so click a real enabled day button
120
+ await picker.locator('button[role="gridcell"]:not([disabled]):not([aria-disabled="true"])').first().click()
121
await page.keyboard.press('Escape')
122
- await expect(addDialog.getByRole('textbox', { name: 'Expire' })).not.toHaveValue('MM/DD/YYYY hh:mm aa')
122
}
123
await addDialog.getByRole('button').last().click()
124
await expect(addDialog).not.toBeVisible()
@@ -150,7 +149,7 @@ test('admin2', async ({ page }) => {
149
await clickIconBtn('Options', page)
150
if (!isPhone) {
151
const logsDialog = page.getByRole('dialog', { name: /Log options/ })
153
- const logApisToggle = logsDialog.getByRole('checkbox', { name: 'Log API requests' })
152
+ const logApisToggle = logsDialog.getByRole('switch', { name: 'Log API requests' })
153
await expect(logApisToggle).toBeChecked()
154
await logApisToggle.click()
155
await expect(logApisToggle).not.toBeChecked()
@@ -164,8 +163,14 @@ test('admin2', async ({ page }) => {
163
const pluginTabs = page.getByRole('tab')
164
await pluginTabs.nth(0).click()
165
if (!isPhone) {
167
- await clickIconBtn('Start download-counter', page)
168
- await clickIconBtn('Options', page)
166
+ const downloadCounterRow = page.getByRole('row', { name: /download-counter/ })
167
+ const startDownloadCounter = downloadCounterRow.getByRole('button', { name: 'Start download-counter' })
168
+ const stopDownloadCounter = downloadCounterRow.getByRole('button', { name: 'Stop download-counter' })
169
+ // keep the test independent from whatever state a previous run left this plugin in
170
+ const wasRunning = await stopDownloadCounter.isVisible()
171
+ if (!wasRunning)
172
+ await startDownloadCounter.click()
173
+ await downloadCounterRow.getByRole('button', { name: 'Options' }).click()
174
const whereField = page.getByRole('combobox', { name: 'Where to display counter' })
175
await whereField.click()
176
await page.getByRole('option', { name: 'list', exact: true }).click()
@@ -173,7 +178,8 @@ test('admin2', async ({ page }) => {
178
const pluginSaveBtn = pluginOptionsDialog.locator('button:has-text("Save")').first()
179
await expect(pluginSaveBtn).toBeEnabled()
180
await clickIconBtn('Close', page)
176
- await clickIconBtn('Stop download-counter', page)
181
+ if (!wasRunning)
182
+ await stopDownloadCounter.click()
183
}
184
await pluginTabs.nth(1).click() // get more
185
await page.getByRole('textbox', { name: 'Search text' }).fill('download')
mui-grid-form/index.ts
+1
-1
@@ -8,7 +8,7 @@ import { Box, BoxProps, Button, Tooltip } from '@mui/material'
8
import { Save } from '@mui/icons-material'
9
import _ from 'lodash'
10
import { StringField } from './StringField'
11
-import Grid, { Grid2Props as GridProps } from '@mui/material/Grid2'
11
+import Grid, { GridProps } from '@mui/material/Grid'
12
import { useDebounce } from 'usehooks-ts'
13
export * from './SelectField'
14
export * from './misc-fields'
mui-grid-form/package.json
+5
-5
@@ -1,11 +1,11 @@
1
{
2
"name": "@hfs/mui-grid-form",
3
"main": "index.ts",
4
- "dependencies": {
5
- "@mui/icons-material": "*",
6
- "@mui/lab": "*",
7
- "@mui/material": "*",
8
- "lodash": "*"
4
+ "peerDependencies": {
5
+ "@mui/icons-material": "^7",
6
+ "@mui/lab": "^7",
7
+ "@mui/material": "^7",
8
+ "lodash": "^4.18.1"
9
},
10
"devDependencies": {
11
"@types/lodash": "*",