Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/webapp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,9 @@
"cells.newItemMenuModal.primaryAction": "Create",
"cells.newItemMenuModal.secondaryAction": "Cancel",
"cells.newItemMenuModalForm.alreadyExistsError": "A file or folder with this name already exists",
"cells.newItemMenuModalForm.genericError": "Something went wrong. Please try again",
"cells.newItemMenuModalForm.genericError": "Something went wrong",
"cells.newItemMenuModalForm.invalidCharactersError": "Don't start with a . and don't use /\\ \"",
"cells.newItemMenuModalForm.maxLengthError": "Use a name shorter than 64 characters",
"cells.newItemMenuModalForm.nameRequired": "Name is required",
"cells.noNodes.description": "You'll find all files and folders shared in this conversation here.",
"cells.noNodes.global.description": "For conversations that use file collaboration, you'll find shared files here.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {getClientSideNodeNameError, getErrorStatus, isClientSideNodeNameError, NODE_NAME_MAX_LENGTH} from './cellsNodeFormUtils';

jest.mock('Util/localizerUtil', () => ({
t: (key: string) => key,
}));

describe('cellsNodeFormUtils', () => {
describe('getClientSideNodeNameError', () => {
it('returns required error for empty name', () => {
expect(getClientSideNodeNameError('').unwrapOr(null)).toBe('cells.newItemMenuModalForm.nameRequired');
});

it('returns max length error for names longer than the maximum', () => {
expect(getClientSideNodeNameError('a'.repeat(NODE_NAME_MAX_LENGTH + 1)).unwrapOr(null)).toBe(
'cells.newItemMenuModalForm.maxLengthError',
);
});

it('returns max length error before invalid character error', () => {
const tooLongNameWithInvalidCharacter = `${'a'.repeat(NODE_NAME_MAX_LENGTH)}/`;
expect(getClientSideNodeNameError(tooLongNameWithInvalidCharacter).unwrapOr(null)).toBe(
'cells.newItemMenuModalForm.maxLengthError',
);
});

it('returns invalid character error for names starting with "."', () => {
expect(getClientSideNodeNameError('.hidden').unwrapOr(null)).toBe('cells.newItemMenuModalForm.invalidCharactersError');
expect(getClientSideNodeNameError('.').unwrapOr(null)).toBe('cells.newItemMenuModalForm.invalidCharactersError');
});

it('returns invalid character error for forbidden characters', () => {
expect(getClientSideNodeNameError('report/name').unwrapOr(null)).toBe('cells.newItemMenuModalForm.invalidCharactersError');
expect(getClientSideNodeNameError('report\\name').unwrapOr(null)).toBe('cells.newItemMenuModalForm.invalidCharactersError');
expect(getClientSideNodeNameError('report"name').unwrapOr(null)).toBe('cells.newItemMenuModalForm.invalidCharactersError');
});

it('accepts names containing dots when dot is not at the beginning', () => {
expect(getClientSideNodeNameError('file.txt').isNothing).toBe(true);
expect(getClientSideNodeNameError('my.folder').isNothing).toBe(true);
expect(getClientSideNodeNameError('v1.2.report').isNothing).toBe(true);
});
});

describe('isClientSideNodeNameError', () => {
it('returns true for node name validation errors', () => {
expect(isClientSideNodeNameError('cells.newItemMenuModalForm.nameRequired').unwrapOr(false)).toBe(true);
expect(isClientSideNodeNameError('cells.newItemMenuModalForm.maxLengthError').unwrapOr(false)).toBe(true);
expect(isClientSideNodeNameError('cells.newItemMenuModalForm.invalidCharactersError').unwrapOr(false)).toBe(true);
});

it('returns false for non-validation errors', () => {
expect(isClientSideNodeNameError(null).isNothing).toBe(true);
expect(isClientSideNodeNameError('cells.newItemMenuModalForm.genericError').unwrapOr(false)).toBe(false);
});
});

describe('getErrorStatus', () => {
it('returns status for axios-like errors', () => {
expect(getErrorStatus({response: {status: 409}}).unwrapOr(-1)).toBe(409);
expect(getErrorStatus({isAxiosError: true, response: {status: 500}}).unwrapOr(-1)).toBe(500);
});

it('returns Nothing for non-axios errors or missing status', () => {
expect(getErrorStatus(new Error('network')).isNothing).toBe(true);
expect(getErrorStatus({response: {}}).isNothing).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,52 @@
import {isAxiosError} from 'Util/typePredicateUtil';

export const ITEM_ALREADY_EXISTS_ERROR = 409;
export const NODE_NAME_MAX_LENGTH = 64;

export const getNameValidationError = (name: string): string | null => {
const INVALID_NODE_NAME_PATTERN = /[\\/"]/u;
const LEADING_DOT_PATTERN = /^\./u;

export const getNameValidationError = (name: string): Maybe<string> => {
if (!name) {
return t('cells.newItemMenuModalForm.nameRequired');
return Maybe.just(t('cells.newItemMenuModalForm.nameRequired'));
}

return Maybe.nothing();
};

export const getClientSideNodeNameError = (name: string): Maybe<string> => {
const requiredNameError = getNameValidationError(name);
if (requiredNameError.isJust) {
return requiredNameError;
}

if (name.length > NODE_NAME_MAX_LENGTH) {
return Maybe.just(t('cells.newItemMenuModalForm.maxLengthError'));
}

if (LEADING_DOT_PATTERN.test(name) || INVALID_NODE_NAME_PATTERN.test(name)) {

Check warning on line 49 in apps/webapp/src/script/components/Conversation/ConversationCells/common/useCellsNewNodeForm/cellsNodeFormUtils.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'String#startsWith' method instead.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZ1DoTVSs9dkVuN-BWBl&open=AZ1DoTVSs9dkVuN-BWBl&pullRequest=20902
return Maybe.just(t('cells.newItemMenuModalForm.invalidCharactersError'));
}

return null;
return Maybe.nothing();
};

export const isClientSideNodeNameError = (error: string | null): Maybe<boolean> => {
const clientSideNameErrors = new Set([
t('cells.newItemMenuModalForm.nameRequired'),
t('cells.newItemMenuModalForm.maxLengthError'),
t('cells.newItemMenuModalForm.invalidCharactersError'),
]);

return Maybe.of(error).map(errorMessage => clientSideNameErrors.has(errorMessage));
};

export const getErrorStatus = (error: unknown): number | undefined => {
return Maybe.of(error)
.andThen(caughtError => {
if (!isAxiosError(caughtError)) {
return Maybe.nothing();
}
export const getErrorStatus = (error: unknown): Maybe<number> => {
return Maybe.of(error).andThen(caughtError => {
if (!isAxiosError(caughtError)) {
return Maybe.nothing();
}

return Maybe.of(caughtError.response?.status);
})
.unwrapOr(undefined);
return Maybe.of(caughtError.response?.status);
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ describe('useCellsNewFileForm', () => {
expect(mockCellsRepository.createFile).not.toHaveBeenCalled();
});

it('does not submit when name validation fails', async () => {
const {result} = renderUseCellsNewFileForm();

act(() => {
result.current.handleChange({currentTarget: {value: 'file/name'}} as ChangeEvent<HTMLInputElement>);
});

await act(async () => {
await result.current.handleSubmit(createEvent());
});

expect(result.current.error).toBe('cells.newItemMenuModalForm.invalidCharactersError');
expect(mockCellsRepository.createFile).not.toHaveBeenCalled();
});

it('maps 409 responses to already-exists error', async () => {
mockCellsRepository.createFile.mockRejectedValueOnce({
response: {status: 409},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import {QualifiedId} from '@wireapp/api-client/lib/user';
import {CellsRepository} from 'Repositories/cells/cellsRepository';
import {t} from 'Util/localizerUtil';

import {ITEM_ALREADY_EXISTS_ERROR, getErrorStatus, getNameValidationError} from './cellsNodeFormUtils';
import {
ITEM_ALREADY_EXISTS_ERROR,
getClientSideNodeNameError,
getErrorStatus,
isClientSideNodeNameError,
} from './cellsNodeFormUtils';

import {getCellsApiPath} from '../getCellsApiPath/getCellsApiPath';

Expand Down Expand Up @@ -67,8 +72,10 @@ export const useCellsNewFileForm = ({
await cellsRepository.createFile({path, name: fileName});
onSuccess();
} catch (err: unknown) {
const status = getErrorStatus(err);
if (status === ITEM_ALREADY_EXISTS_ERROR) {
const isAlreadyExistsError = getErrorStatus(err)
.map(status => status === ITEM_ALREADY_EXISTS_ERROR)
.unwrapOr(false);
if (isAlreadyExistsError) {
setError(t('cells.newItemMenuModalForm.alreadyExistsError'));
} else {
setError(t('cells.newItemMenuModalForm.genericError'));
Expand All @@ -84,7 +91,7 @@ export const useCellsNewFileForm = ({
}

const trimmedName = name.trim();
const validationError = getNameValidationError(trimmedName);
const validationError = getClientSideNodeNameError(trimmedName).unwrapOr(null);
if (validationError) {
setError(validationError);
return;
Expand All @@ -102,7 +109,7 @@ export const useCellsNewFileForm = ({

const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setName(event.currentTarget.value);
if (error === t('cells.newItemMenuModalForm.nameRequired')) {
if (isClientSideNodeNameError(error).unwrapOr(false)) {
setError(null);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {QualifiedId} from '@wireapp/api-client/lib/user';
import {CellsRepository} from 'Repositories/cells/cellsRepository';
import {t} from 'Util/localizerUtil';

import {ITEM_ALREADY_EXISTS_ERROR, getErrorStatus, getNameValidationError} from './cellsNodeFormUtils';
import {ITEM_ALREADY_EXISTS_ERROR, getClientSideNodeNameError, getErrorStatus} from './cellsNodeFormUtils';

import {getCellsApiPath} from '../getCellsApiPath/getCellsApiPath';

Expand Down Expand Up @@ -52,8 +52,10 @@ export const useCellsNewFolderForm = ({
await cellsRepository.createFolder({path, name: folderName});
onSuccess();
} catch (err: unknown) {
const status = getErrorStatus(err);
if (status === ITEM_ALREADY_EXISTS_ERROR) {
const isAlreadyExistsError = getErrorStatus(err)
.map(status => status === ITEM_ALREADY_EXISTS_ERROR)
.unwrapOr(false);
if (isAlreadyExistsError) {
setError(t('cells.newItemMenuModalForm.alreadyExistsError'));
} else {
setError(t('cells.newItemMenuModalForm.genericError'));
Expand All @@ -68,8 +70,8 @@ export const useCellsNewFolderForm = ({
return;
}

const normalizedName = name.trim();
const validationError = getNameValidationError(normalizedName);
const trimmedName = name.trim();
const validationError = getClientSideNodeNameError(trimmedName).unwrapOr(null);
if (validationError) {
setError(validationError);
return;
Expand All @@ -79,7 +81,7 @@ export const useCellsNewFolderForm = ({
setIsSubmitting(true);

try {
await createFolder(normalizedName);
await createFolder(trimmedName);
} finally {
setIsSubmitting(false);
}
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/src/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,9 @@ declare module 'I18n/en-US.json' {
'cells.newItemMenuModal.primaryAction': `Create`;
'cells.newItemMenuModal.secondaryAction': `Cancel`;
'cells.newItemMenuModalForm.alreadyExistsError': `A file or folder with this name already exists`;
'cells.newItemMenuModalForm.genericError': `Something went wrong. Please try again`;
'cells.newItemMenuModalForm.genericError': `Something went wrong`;
'cells.newItemMenuModalForm.invalidCharactersError': `Don\'t start with a . and don\'t use /\\ "`;
'cells.newItemMenuModalForm.maxLengthError': `Use a name shorter than 64 characters`;
'cells.newItemMenuModalForm.nameRequired': `Name is required`;
'cells.noNodes.description': `You\'ll find all files and folders shared in this conversation here.`;
'cells.noNodes.global.description': `For conversations that use file collaboration, you\'ll find shared files here.`;
Expand Down
Loading