Merge branch 'DRVWEB-5047-select-in-rename-modal' into 'main'

[DRVWEB-5047] Ensure finename extension is not selected in rename modal

See merge request web/clients!21674
This commit is contained in:
MargeBot 2026-01-09 18:59:30 +00:00
commit db44606ce3
10 changed files with 167 additions and 81 deletions

View file

@ -32,6 +32,7 @@ const RenameButton = ({ link, showRenameModal, close }: Props) => {
isDoc: isProtonDocsDocument(link.mimeType),
name: link.name,
volumeId: link.volumeId,
mediaType: link.mimeType,
linkId: link.linkId,
onSubmit: (formattedName) =>
renameLink(new AbortController().signal, link.rootShareId, link.linkId, formattedName),

View file

@ -71,6 +71,7 @@ const ActionsDropdown = ({ volumeId, shareId, selectedLinks, permissions, trashL
showRenameModal({
isFile: selectedLinks[0].isFile,
name: selectedLinks[0].name,
mediaType: selectedLinks[0].mimeType,
isDoc: isProtonDocsDocument(selectedLinks[0].mimeType),
volumeId: selectedLinks[0].volumeId,
linkId: selectedLinks[0].linkId,

View file

@ -26,6 +26,7 @@ export const RenameButton = ({ link, showRenameModal, close }: Props) => {
isFile: link.isFile,
name: link.name,
isDoc: isProtonDocsDocument(link.mimeType),
mediaType: link.mimeType,
volumeId: link.volumeId,
linkId: link.linkId,
onSubmit: (formattedName) =>

View file

@ -36,6 +36,7 @@ const RenameButton = ({ selectedLinks, renameLink }: RenameButtonProps) => {
isFile: selectedLinks[0].isFile,
name: selectedLinks[0].name,
isDoc: isProtonDocsDocument(selectedLinks[0].mimeType),
mediaType: selectedLinks[0].mimeType,
volumeId: selectedLinks[0].volumeId,
linkId: selectedLinks[0].linkId,
onSubmit: (formattedName) =>

View file

@ -26,7 +26,9 @@ export type RenameModalViewProps = {
isFile: boolean;
onClose?: () => void;
name: string;
ignoreExtension: boolean;
// The name (or part of it) that will need to be highlighted at
// focus time in the modal's input.
nameToFocus: string;
isDoc?: boolean;
};
@ -34,7 +36,7 @@ export const RenameModalView = ({
handleSubmit,
name: originalName,
isFile,
ignoreExtension,
nameToFocus,
// modalProps
onClose,
onExit,
@ -48,11 +50,8 @@ export const RenameModalView = ({
if (autofocusDone) {
return;
}
e.target.setSelectionRange(0, nameToFocus.length);
setAutofocusDone(true);
if (ignoreExtension) {
return e.target.select();
}
e.target.setSelectionRange(0, originalName.length);
};
const onBlur = ({ target }: FocusEvent<HTMLInputElement>) => {

View file

@ -1,63 +1,152 @@
import { MemberRole, type NodeEntity, NodeType } from '@proton/drive/index';
import { renderHook } from '@testing-library/react-hooks';
import { PROTON_DOCS_DOCUMENT_MIMETYPE, PROTON_DOCS_SPREADSHEET_MIMETYPE } from '@proton/shared/lib/helpers/mimetype';
import { getIgnoreExtension } from './useRenameModalState';
import { useRenameModalState } from './useRenameModalState';
export const mockNode = (overrides: Partial<NodeEntity> = {}): NodeEntity => ({
uid: 'mock-uid',
parentUid: 'parent-uid',
name: 'mock-file.txt',
keyAuthor: {
ok: true,
value: 'key-author-1',
},
nameAuthor: {
ok: true,
value: 'name-author-1',
},
directRole: MemberRole.Admin,
type: NodeType.File,
mediaType: 'application/octet-stream',
isShared: false,
isSharedPublicly: false,
creationTime: new Date('2024-01-01T00:00:00Z'),
modificationTime: new Date('2024-01-01T00:00:00Z'),
trashTime: undefined,
totalStorageSize: 12345,
activeRevision: undefined,
folder: {
claimedModificationTime: new Date('2024-01-01T00:00:00Z'),
},
treeEventScopeId: 'tree-event-scope-id',
...overrides,
});
jest.mock('@proton/components', () => ({
useNotifications: jest.fn(() => ({
createNotification: jest.fn(),
})),
}));
describe('getIgnoreExtension', () => {
jest.mock('@proton/drive/index', () => ({
useDrive: jest.fn(() => ({
drive: {
renameNode: jest.fn(),
},
})),
}));
const defaultProps = {
onClose: () => {},
onExit: () => {},
onSubmit: (_newName: string) => Promise.resolve(),
volumeId: 'VOLUME_ID',
linkId: 'LINK_ID',
open: true,
name: 'DEFAULT_NAME',
isFile: true,
isDoc: false,
mediaType: 'DEFAULT_MEDIATYPE',
};
describe('useRenameModalState', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns true if node is null', () => {
expect(getIgnoreExtension(null, 'file.docx')).toBe(true);
afterEach(() => {
jest.clearAllMocks();
});
it('returns true if node is a folder', () => {
expect(getIgnoreExtension(mockNode({ type: NodeType.Folder, mediaType: undefined }), 'folder')).toBe(true);
});
describe('Compute the filename to focus', () => {
it('should focus the filename without the png extension', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: 'myimage.png',
mediaType: 'image/png',
})
);
it('returns true if mediaType is Proton Docs', () => {
expect(getIgnoreExtension(mockNode({ mediaType: PROTON_DOCS_DOCUMENT_MIMETYPE }), 'file.docx')).toBe(true);
});
expect(result.current.nameToFocus).toBe('myimage');
});
it('returns true if mediaType is Proton Sheets', () => {
expect(getIgnoreExtension(mockNode({ mediaType: PROTON_DOCS_SPREADSHEET_MIMETYPE }), 'file.docx')).toBe(true);
});
it('should focus the filename without the vcf extension', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: 'myvcard.vcf',
mediaType: 'text/vcard',
})
);
it('returns true if name doesnt include an extension', () => {
expect(getIgnoreExtension(mockNode({ mediaType: 'text/plain' }), '.txt')).toBe(true);
});
expect(result.current.nameToFocus).toBe('myvcard');
});
it('returns false in case of file with extention', () => {
expect(getIgnoreExtension(mockNode({ mediaType: 'text/plain' }), 'file.txt')).toBe(false);
it('should focus the entire filename when the name is missing', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: '.txt',
mediaType: 'plain/text',
})
);
expect(result.current.nameToFocus).toBe('.txt');
});
it('should focus the filename without the docx extension', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: 'mydocument.docx',
mediaType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
);
expect(result.current.nameToFocus).toBe('mydocument');
});
it('should focus the entire name for folder', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: 'myfolder',
isFile: false,
})
);
expect(result.current.nameToFocus).toBe('myfolder');
});
it('should focus the entire name for folder, even with a dot inside', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: 'myfolder.withdot',
isFile: false,
})
);
expect(result.current.nameToFocus).toBe('myfolder.withdot');
});
it('should focus the entire name for proton documents', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: 'myprotondocument.with_dot_inside',
mediaType: PROTON_DOCS_DOCUMENT_MIMETYPE,
})
);
expect(result.current.nameToFocus).toBe('myprotondocument.with_dot_inside');
});
it('should focus the entire name for proton sheets', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: 'myprotonsheets.with_dot_inside',
mediaType: PROTON_DOCS_SPREADSHEET_MIMETYPE,
})
);
expect(result.current.nameToFocus).toBe('myprotonsheets.with_dot_inside');
});
it('should focus the entire name when missing the mediatype', async () => {
const { result } = renderHook(() =>
useRenameModalState({
...defaultProps,
name: 'somefile.mp3',
mediaType: undefined,
})
);
expect(result.current.nameToFocus).toBe('somefile.mp3');
});
});
});

View file

@ -1,22 +1,19 @@
import { useEffect, useState } from 'react';
import { c } from 'ttag';
import { type ModalStateProps, useNotifications } from '@proton/components';
import type { NodeEntity } from '@proton/drive';
import { generateNodeUid, useDrive } from '@proton/drive';
import { splitExtension } from '@proton/shared/lib/helpers/file';
import { isProtonDocsDocument, isProtonDocsSpreadsheet } from '@proton/shared/lib/helpers/mimetype';
import { useDriveEventManager } from '../../store';
import { useSdkErrorHandler } from '../../utils/errorHandling/useSdkErrorHandler';
import { getNodeEntity } from '../../utils/sdk/getNodeEntity';
export type RenameModalInnerProps = {
onSuccess?: (newName: string) => Promise<void>;
volumeId: string;
linkId: string;
name: string;
mediaType: string | undefined;
isFile: boolean; // isFile could come from getNode but it will be slow with noticeable delay in the modal
/** @deprecated used only on legacy, it will not be handled here */
onSubmit?: (newName: string) => Promise<void>;
@ -36,14 +33,20 @@ const splitLinkName = (linkName: string) => {
return splitExtension(linkName);
};
export const getIgnoreExtension = (node: null | NodeEntity, name: string) => {
const isFile = node?.type === 'file';
let ignoreExtension = !isFile;
if (node !== null && node.mediaType) {
const [namePart] = splitLinkName(name);
ignoreExtension = isProtonDocsDocument(node.mediaType) || isProtonDocsSpreadsheet(node.mediaType) || !namePart;
const computeFilenameToFocus = (name: string, isFile: boolean, mediaType: string | undefined) => {
if (!isFile || !mediaType) {
return name;
}
return ignoreExtension;
if (isProtonDocsDocument(mediaType) || isProtonDocsSpreadsheet(mediaType)) {
return name;
}
const [namePart] = splitLinkName(name);
if (namePart) {
return namePart;
}
return name;
};
export const useRenameModalState = ({
@ -52,28 +55,14 @@ export const useRenameModalState = ({
linkId,
name,
isFile,
mediaType,
onSuccess,
...modalProps
}: UseRenameModalProps) => {
const { drive } = useDrive();
const events = useDriveEventManager();
const { createNotification } = useNotifications();
const [node, setNode] = useState<null | NodeEntity>(null);
const ignoreExtension = getIgnoreExtension(node, name);
const { handleError } = useSdkErrorHandler();
useEffect(() => {
const fetchNode = async () => {
try {
const maybeNode = await drive.getNode(generateNodeUid(volumeId, linkId));
const { node } = getNodeEntity(maybeNode);
setNode(node);
} catch (e) {
handleError(e);
onClose();
}
};
void fetchNode();
}, [volumeId, linkId, createNotification, drive, handleError, onClose]);
const handleSubmit = async (newName: string) => {
const nodeUid = generateNodeUid(volumeId, linkId);
@ -97,7 +86,7 @@ export const useRenameModalState = ({
handleSubmit,
onClose,
name,
ignoreExtension,
nameToFocus: computeFilenameToFocus(name, isFile, mediaType),
isFile,
};
};

View file

@ -79,6 +79,7 @@ export const ActionsDropdown = ({ volumeId, shareId, selectedItems, role }: Prop
isDoc: isProtonDocsDocument(selectedItems[0].mimeType),
volumeId: selectedItems[0].volumeId,
linkId: selectedItems[0].linkId,
mediaType: selectedItems[0].mimeType,
onSubmit: (formattedName) =>
renameLink(
new AbortController().signal,

View file

@ -106,7 +106,10 @@ export const useFolderActions = ({ allSortedItems, selectedItems, shareId, linkI
return;
}
showRenameModal(item);
showRenameModal({
...item,
mediaType: item.mimeType,
});
};
const showDetails = () => {

View file

@ -140,6 +140,7 @@ export const SharedByMeActions = ({
name: singleItem.name,
volumeId: splitedSingleItemUid.volumeId,
linkId: splitedSingleItemUid.nodeId,
mediaType: singleItem.mediaType,
onSubmit: (formattedName) =>
renameLink(
new AbortController().signal,