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": "*",