1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Add device deletion support

This commit is contained in:
Bill Thornton 2025-01-30 12:43:55 -05:00
parent 538c0b64ff
commit e10aef9933
8 changed files with 286 additions and 120 deletions

View file

@ -10,9 +10,25 @@ interface TablePageProps<T extends MRT_RowData> extends PageProps {
table: MRT_TableInstance<T> table: MRT_TableInstance<T>
} }
export const DEFAULT_TABLE_OPTIONS = {
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,
// Sticky header/footer
enableStickyFooter: true,
enableStickyHeader: true,
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
}
};
const TablePage = <T extends MRT_RowData>({ const TablePage = <T extends MRT_RowData>({
title, title,
table, table,
children,
...pageProps ...pageProps
}: TablePageProps<T>) => { }: TablePageProps<T>) => {
return ( return (
@ -39,6 +55,7 @@ const TablePage = <T extends MRT_RowData>({
</Box> </Box>
<MaterialReactTable table={table} /> <MaterialReactTable table={table} />
</Box> </Box>
{children}
</Page> </Page>
); );
}; };

View file

@ -1,4 +1,5 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import type { SxProps, Theme } from '@mui/material';
import IconButton from '@mui/material/IconButton/IconButton'; import IconButton from '@mui/material/IconButton/IconButton';
import React, { type FC } from 'react'; import React, { type FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -7,14 +8,21 @@ import UserAvatar from 'components/UserAvatar';
interface UserAvatarButtonProps { interface UserAvatarButtonProps {
user?: UserDto user?: UserDto
sx?: SxProps<Theme>
} }
const UserAvatarButton: FC<UserAvatarButtonProps> = ({ user }) => ( const UserAvatarButton: FC<UserAvatarButtonProps> = ({
user,
sx
}) => (
user?.Id ? ( user?.Id ? (
<IconButton <IconButton
size='large' size='large'
color='inherit' color='inherit'
sx={{ padding: 0 }} sx={{
padding: 0,
...sx
}}
title={user.Name || undefined} title={user.Name || undefined}
component={Link} component={Link}
to={`/dashboard/users/profile?userId=${user.Id}`} to={`/dashboard/users/profile?userId=${user.Id}`}

View file

@ -0,0 +1,24 @@
import type { DevicesApiDeleteDeviceRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useDevices';
export const useDeleteDevice = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: DevicesApiDeleteDeviceRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getDevicesApi(api!)
.deleteDevice(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View file

@ -6,6 +6,8 @@ import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
export const QUERY_KEY = 'Devices';
const fetchDevices = async ( const fetchDevices = async (
api?: Api, api?: Api,
requestParams?: DevicesApiGetDevicesRequest, requestParams?: DevicesApiGetDevicesRequest,
@ -28,7 +30,7 @@ export const useDevices = (
) => { ) => {
const { api } = useApi(); const { api } = useApi();
return useQuery({ return useQuery({
queryKey: ['Devices', requestParams], queryKey: [QUERY_KEY, requestParams],
queryFn: ({ signal }) => queryFn: ({ signal }) =>
fetchDevices(api, requestParams, { signal }), fetchDevices(api, requestParams, { signal }),
enabled: !!api enabled: !!api

View file

@ -0,0 +1,24 @@
import type { DevicesApiUpdateDeviceOptionsRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useDevices';
export const useUpdateDevice = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: DevicesApiUpdateDeviceOptionsRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getDevicesApi(api!)
.updateDeviceOptions(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View file

@ -1,26 +1,23 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level'; import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import ToggleButton from '@mui/material/ToggleButton'; import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import TablePage from 'apps/dashboard/components/TablePage'; import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/TablePage';
import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries'; import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries';
import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell'; import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell';
import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell'; import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell';
import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell'; import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell';
import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton'; import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton';
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell'; import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
import { useUsers } from 'hooks/useUsers'; import { type UsersRecords, useUsersDetails } from 'hooks/useUsers';
import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
import globalize from 'lib/globalize'; import globalize from 'lib/globalize';
import { toBoolean } from 'utils/string'; import { toBoolean } from 'utils/string';
type UsersRecords = Record<string, UserDto>;
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const VIEW_PARAM = 'useractivity'; const VIEW_PARAM = 'useractivity';
@ -53,29 +50,7 @@ const Activity = () => {
pageSize: DEFAULT_PAGE_SIZE pageSize: DEFAULT_PAGE_SIZE
}); });
const { data: usersData, isLoading: isUsersLoading } = useUsers(); const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
const users: UsersRecords = useMemo(() => {
if (!usersData) return {};
return usersData.reduce<UsersRecords>((acc, user) => {
const userId = user.Id;
if (!userId) return acc;
return {
...acc,
[userId]: user
};
}, {});
}, [ usersData ]);
const userNames = useMemo(() => {
const names: string[] = [];
usersData?.forEach(user => {
if (user.Name) names.push(user.Name);
});
return names;
}, [ usersData ]);
const UserCell = getUserCell(users); const UserCell = getUserCell(users);
@ -175,22 +150,11 @@ const Activity = () => {
}, [ activityView, searchParams, setSearchParams ]); }, [ activityView, searchParams, setSearchParams ]);
const table = useMaterialReactTable({ const table = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
columns, columns,
data: logEntries?.Items || [], data: logEntries?.Items || [],
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,
// Sticky header/footer
enableStickyFooter: true,
enableStickyHeader: true,
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
},
// State // State
initialState: { initialState: {
density: 'compact' density: 'compact'

View file

@ -5,41 +5,113 @@ import Box from '@mui/material/Box/Box';
import Button from '@mui/material/Button/Button'; import Button from '@mui/material/Button/Button';
import IconButton from '@mui/material/IconButton/IconButton'; import IconButton from '@mui/material/IconButton/IconButton';
import Tooltip from '@mui/material/Tooltip/Tooltip'; import Tooltip from '@mui/material/Tooltip/Tooltip';
import React, { useMemo } from 'react'; import React, { FC, useCallback, useMemo, useState } from 'react';
import TablePage from 'apps/dashboard/components/TablePage'; import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/TablePage';
import { useDevices } from 'apps/dashboard/features/devices/api/useDevices'; import { useDevices } from 'apps/dashboard/features/devices/api/useDevices';
import globalize from 'lib/globalize'; import globalize from 'lib/globalize';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; import { type MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table';
import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
import { getDeviceIcon } from 'utils/image'; import { getDeviceIcon } from 'utils/image';
import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton'; import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton';
import { useUsers } from 'hooks/useUsers'; import { type UsersRecords, useUsersDetails } from 'hooks/useUsers';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; import { useUpdateDevice } from 'apps/dashboard/features/devices/api/useUpdateDevice';
import { useDeleteDevice } from 'apps/dashboard/features/devices/api/useDeleteDevice';
import ConfirmDialog from 'components/ConfirmDialog';
type UsersRecords = Record<string, UserDto>; interface DeviceInfoCell {
renderedCellValue: React.ReactNode
row: MRT_Row<DeviceInfoDto>
}
const DeviceNameCell: FC<DeviceInfoCell> = ({ row, renderedCellValue }) => (
<>
<img
alt={row.original.AppName || undefined}
src={getDeviceIcon(row.original)}
style={{
display: 'inline-block',
maxWidth: '1.5em',
maxHeight: '1.5em',
marginRight: '1rem'
}}
/>
{renderedCellValue}
</>
);
const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellValue, row }: DeviceInfoCell) {
return (
<>
<UserAvatarButton
user={row.original.LastUserId && users[row.original.LastUserId] || undefined}
sx={{ mr: '1rem' }}
/>
{renderedCellValue}
</>
);
};
const DevicesPage = () => { const DevicesPage = () => {
const { api } = useApi(); const { api } = useApi();
const { data: devices, isLoading: isDevicesLoading } = useDevices({}); const { data: devices, isLoading: isDevicesLoading } = useDevices({});
const { data: usersData, isLoading: isUsersLoading } = useUsers(); const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false);
const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false);
const [ pendingDeleteDeviceId, setPendingDeleteDeviceId ] = useState<string>();
const deleteDevice = useDeleteDevice();
const updateDevice = useUpdateDevice();
const isLoading = isDevicesLoading || isUsersLoading; const isLoading = isDevicesLoading || isUsersLoading;
const users: UsersRecords = useMemo(() => { const onDeleteDevice = useCallback((id: string | null | undefined) => () => {
if (!usersData) return {}; if (id) {
setPendingDeleteDeviceId(id);
setIsDeleteConfirmOpen(true);
}
}, []);
return usersData.reduce<UsersRecords>((acc, user) => { const onCloseDeleteConfirmDialog = useCallback(() => {
const userId = user.Id; setPendingDeleteDeviceId(undefined);
if (!userId) return acc; setIsDeleteConfirmOpen(false);
}, []);
return { const onConfirmDelete = useCallback(() => {
...acc, if (pendingDeleteDeviceId) {
[userId]: user deleteDevice.mutate({
}; id: pendingDeleteDeviceId
}, {}); }, {
}, [ usersData ]); onSettled: onCloseDeleteConfirmDialog
});
}
}, [ deleteDevice, onCloseDeleteConfirmDialog, pendingDeleteDeviceId ]);
const onDeleteAll = useCallback(() => {
setIsDeleteAllConfirmOpen(true);
}, []);
const onCloseDeleteAllConfirmDialog = useCallback(() => {
setIsDeleteAllConfirmOpen(false);
}, []);
const onConfirmDeleteAll = useCallback(() => {
if (devices?.Items) {
Promise
.all(devices.Items.map(item => {
if (api && item.Id && api.deviceInfo.id === item.Id) {
return deleteDevice.mutateAsync({ id: item.Id });
}
return Promise.resolve();
}))
.finally(() => {
onCloseDeleteAllConfirmDialog();
});
}
}, [ api, deleteDevice, devices?.Items, onCloseDeleteAllConfirmDialog ]);
const UserCell = getUserCell(users);
const columns = useMemo<MRT_ColumnDef<DeviceInfoDto>[]>(() => [ const columns = useMemo<MRT_ColumnDef<DeviceInfoDto>[]>(() => [
{ {
@ -56,21 +128,7 @@ const DevicesPage = () => {
accessorFn: row => row.CustomName || row.Name, accessorFn: row => row.CustomName || row.Name,
header: globalize.translate('LabelDevice'), header: globalize.translate('LabelDevice'),
size: 200, size: 200,
Cell: ({ row, renderedCellValue }) => ( Cell: DeviceNameCell
<>
<img
alt={row.original.AppName || undefined}
src={getDeviceIcon(row.original)}
style={{
display: 'inline-block',
maxWidth: '1.5em',
maxHeight: '1.5em',
marginRight: '1rem'
}}
/>
{renderedCellValue}
</>
)
}, },
{ {
id: 'App', id: 'App',
@ -86,35 +144,21 @@ const DevicesPage = () => {
header: globalize.translate('LabelUser'), header: globalize.translate('LabelUser'),
size: 120, size: 120,
enableEditing: false, enableEditing: false,
Cell: ({ row, renderedCellValue }) => ( Cell: UserCell,
<> filterVariant: 'multi-select',
<UserAvatarButton user={row.original.LastUserId && users[row.original.LastUserId] || undefined} /> filterSelectOptions: userNames
{renderedCellValue}
</>
)
} }
], [ users ]); ], [ UserCell, userNames ]);
const mrTable = useMaterialReactTable({ const mrTable = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
columns, columns,
data: devices?.Items || [], data: devices?.Items || [],
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,
enableEditing: true,
// Sticky header/footer
enableStickyFooter: true,
enableStickyHeader: true,
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
},
// State // State
initialState: { initialState: {
density: 'compact',
pagination: { pagination: {
pageIndex: 0, pageIndex: 0,
pageSize: 25 pageSize: 25
@ -124,32 +168,73 @@ const DevicesPage = () => {
isLoading isLoading
}, },
// Editing device name
enableEditing: true,
onEditingRowSave: ({ table, row, values }) => {
const newName = values.Name?.trim();
const hasChanged = row.original.CustomName ?
newName !== row.original.CustomName :
newName !== row.original.Name;
// If the name has changed, save it as the custom name
if (row.original.Id && hasChanged) {
updateDevice.mutate({
id: row.original.Id,
deviceOptionsDto: {
CustomName: newName || undefined
}
});
}
table.setEditingRow(null); //exit editing mode
},
// Custom actions // Custom actions
enableRowActions: true, enableRowActions: true,
positionActionsColumn: 'last', positionActionsColumn: 'last',
renderRowActions: ({ row, table }) => ( renderRowActions: ({ row, table }) => {
<Box sx={{ display: 'flex', gap: 1 }}> const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id;
<Tooltip title={globalize.translate('Edit')}> return (
<IconButton <Box sx={{ display: 'flex', gap: 1 }}>
onClick={() => table.setEditingRow(row)} <Tooltip title={globalize.translate('Edit')}>
> <IconButton
<Edit /> // eslint-disable-next-line react/jsx-no-bind
</IconButton> onClick={() => table.setEditingRow(row)}
</Tooltip> >
<Tooltip title={globalize.translate('Delete')}> <Edit />
<IconButton </IconButton>
color='error' </Tooltip>
disabled={api && api.deviceInfo.id === row.original.Id} {/* Don't include Tooltip when disabled */}
> {isDeletable ? (
<Delete /> <IconButton
</IconButton> color='error'
</Tooltip> disabled
</Box> >
), <Delete />
</IconButton>
) : (
<Tooltip title={globalize.translate('Delete')}>
<IconButton
color='error'
onClick={onDeleteDevice(row.original.Id)}
>
<Delete />
</IconButton>
</Tooltip>
)}
</Box>
);
},
// Custom toolbar contents // Custom toolbar contents
renderTopToolbarCustomActions: () => ( renderTopToolbarCustomActions: () => (
<Button color='error'>{globalize.translate('DeleteAll')}</Button> <Button
color='error'
startIcon={<Delete />}
onClick={onDeleteAll}
>
{globalize.translate('DeleteAll')}
</Button>
) )
}); });
@ -159,7 +244,26 @@ const DevicesPage = () => {
title={globalize.translate('HeaderDevices')} title={globalize.translate('HeaderDevices')}
className='mainAnimatedPage type-interior' className='mainAnimatedPage type-interior'
table={mrTable} table={mrTable}
/> >
<ConfirmDialog
open={isDeleteConfirmOpen}
title={globalize.translate('HeaderDeleteDevice')}
text={globalize.translate('DeleteDeviceConfirmation')}
onCancel={onCloseDeleteConfirmDialog}
onConfirm={onConfirmDelete}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
/>
<ConfirmDialog
open={isDeleteAllConfirmOpen}
title={globalize.translate('HeaderDeleteDevices')}
text={globalize.translate('DeleteDevicesConfirmation')}
onCancel={onCloseDeleteAllConfirmDialog}
onConfirm={onConfirmDeleteAll}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
/>
</TablePage>
); );
}; };

View file

@ -1,11 +1,13 @@
import type { AxiosRequestConfig } from 'axios'; import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk'; import type { Api } from '@jellyfin/sdk';
import type { UserApiGetUsersRequest } from '@jellyfin/sdk/lib/generated-client'; import type { UserApiGetUsersRequest, UserDto } from '@jellyfin/sdk/lib/generated-client';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api'; import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useApi } from './useApi'; import { useApi } from './useApi';
export type UsersRecords = Record<string, UserDto>;
const fetchUsers = async ( const fetchUsers = async (
api?: Api, api?: Api,
requestParams?: UserApiGetUsersRequest, requestParams?: UserApiGetUsersRequest,
@ -32,3 +34,24 @@ export const useUsers = (requestParams?: UserApiGetUsersRequest) => {
enabled: !!api enabled: !!api
}); });
}; };
export const useUsersDetails = () => {
const { data: users, ...rest } = useUsers();
const usersById: UsersRecords = {};
const names: string[] = [];
if (users) {
users.forEach(user => {
const userId = user.Id;
if (userId) usersById[userId] = user;
if (user.Name) names.push(user.Name);
});
}
return {
users,
usersById,
names,
...rest
};
};