diff --git a/apps/webapp/src/i18n/en-US.json b/apps/webapp/src/i18n/en-US.json index ac96d1ca27e..6ec6213df8c 100644 --- a/apps/webapp/src/i18n/en-US.json +++ b/apps/webapp/src/i18n/en-US.json @@ -418,6 +418,10 @@ "cells.newItemMenuModal.headlineFile": "Create file", "cells.newItemMenuModal.headlineFolder": "Create folder", "cells.newItemMenuModal.label": "Name", + "cells.newItemMenuModal.typeLabel": "File type", + "cells.newItemMenuModal.typeDocument": "Document (.docx)", + "cells.newItemMenuModal.typeSpreadsheet": "Spreadsheet (.xlsx)", + "cells.newItemMenuModal.typePresentation": "Presentation (.pptx)", "cells.newItemMenuModal.placeholderFile": "Enter file name", "cells.newItemMenuModal.placeholderFolder": "Enter folder name", "cells.newItemMenuModal.primaryAction": "Create", diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsHeader/CellsNewMenu/CellsNewItemModal/CellsNewItemModal.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsHeader/CellsNewMenu/CellsNewItemModal/CellsNewItemModal.tsx index ef480ab7ec2..05f9a59473d 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsHeader/CellsNewMenu/CellsNewItemModal/CellsNewItemModal.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsHeader/CellsNewMenu/CellsNewItemModal/CellsNewItemModal.tsx @@ -17,6 +17,8 @@ * */ +import {useState} from 'react'; + import {QualifiedId} from '@wireapp/api-client/lib/user'; import {CellsNewNodeForm} from 'Components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm'; @@ -27,6 +29,7 @@ import {t} from 'Util/LocalizerUtil'; import {descriptionStyles} from './CellsNewItemModal.styles'; +import {useCellsFilePreviewModal} from '../../../CellsTable/common/CellsFilePreviewModalContext/CellsFilePreviewModalContext'; import {CellsModal} from '../../../common/CellsModal/CellsModal'; interface CellsNewItemModalProps { @@ -49,6 +52,14 @@ export const CellsNewItemModal = ({ currentPath, }: CellsNewItemModalProps) => { const isFolder = type === 'folder'; + const {handleOpenFile} = useCellsFilePreviewModal(); + + const fileTypeOptions = [ + {value: 'docx', label: t('cells.newItemMenuModal.typeDocument')}, + {value: 'xlsx', label: t('cells.newItemMenuModal.typeSpreadsheet')}, + {value: 'pptx', label: t('cells.newItemMenuModal.typePresentation')}, + ]; + const [selectedFileType, setSelectedFileType] = useState(fileTypeOptions[0]); const {name, error, isSubmitting, handleSubmit, handleChange} = useCellsNewItemForm({ type, @@ -56,15 +67,17 @@ export const CellsNewItemModal = ({ conversationQualifiedId, onSuccess, currentPath, + fileExtension: type === 'file' ? selectedFileType.value : undefined, + onCreatedFile: type === 'file' ? file => handleOpenFile(file, true) : undefined, }); return ( - {t(isFolder ? 'cells.newItemMenuModal.headlineFolder' : 'cells.newItemMenuModal.headlineFile')} + {t(!isFolder ? 'cells.newItemMenuModal.headlineFolder' : 'cells.newItemMenuModal.headlineFile')}

- {t(isFolder ? 'cells.newItemMenuModal.descriptionFolder' : 'cells.newItemMenuModal.descriptionFile')} + {t(!isFolder ? 'cells.newItemMenuModal.descriptionFolder' : 'cells.newItemMenuModal.descriptionFile')}

diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsHeader/CellsNewMenu/CellsNewMenu.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsHeader/CellsNewMenu/CellsNewMenu.tsx index 9f8e66c13ab..84708bd91b3 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsHeader/CellsNewMenu/CellsNewMenu.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsHeader/CellsNewMenu/CellsNewMenu.tsx @@ -57,6 +57,9 @@ export const CellsNewMenu = ({cellsRepository, conversationQualifiedId, onRefres + openModal(CellNodeType.FILE)}> + {t('cells.newItemMenu.file')} + openModal(CellNodeType.FOLDER)}> {t('cells.newItemMenu.folder')} diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx index d7c717f83f9..963f4598ac4 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx @@ -23,7 +23,6 @@ import {QualifiedId} from '@wireapp/api-client/lib/user/'; import {CellsRepository} from 'Repositories/cells/CellsRepository'; import {CellNode} from 'src/script/types/cellNode'; -import {CellsFilePreviewModal} from './CellsFilePreviewModal/CellsFilePreviewModal'; import { headerCellStyles, tableActionsCellStyles, @@ -33,7 +32,6 @@ import { wrapperStyles, } from './CellsTable.styles'; import {getCellsTableColumns} from './CellsTableColumns/CellsTableColumns'; -import {CellsFilePreviewModalProvider} from './common/CellsFilePreviewModalContext/CellsFilePreviewModalContext'; interface CellsTableProps { nodes: Array; @@ -64,51 +62,48 @@ export const CellsTable = ({ const rows = table.getRowModel().rows; return ( - -
- - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => { - return ( - - ); - })} +
+
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
+ + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + return ( + + ); + })} + + ))} + + {rows.length > 0 && ( + + {rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} ))} - - {rows.length > 0 && ( - - {rows.map(row => ( - - {row.getVisibleCells().map(cell => ( - - ))} - - ))} - - )} -
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- -
-
+ + )} + + ); }; diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx index 2ebe697eded..fd504eb313c 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx @@ -32,7 +32,9 @@ import {CellsHeader} from './CellsHeader/CellsHeader'; import {CellsLoader} from './CellsLoader/CellsLoader'; import {CellsPagination} from './CellsPagination/CellsPagination'; import {CellsStateInfo} from './CellsStateInfo/CellsStateInfo'; +import {CellsFilePreviewModal} from './CellsTable/CellsFilePreviewModal/CellsFilePreviewModal'; import {CellsTable} from './CellsTable/CellsTable'; +import {CellsFilePreviewModalProvider} from './CellsTable/common/CellsFilePreviewModalContext/CellsFilePreviewModalContext'; import {isInRecycleBin} from './common/recycleBin/recycleBin'; import {useCellsStore} from './common/useCellsStore/useCellsStore'; import {wrapperStyles} from './ConversationCells.styles'; @@ -122,36 +124,39 @@ export const ConversationCells = memo( const isEmptyRecycleBin = isInRecycleBin() && emptyView && !isLoading; return ( -
- - {isTableVisible && ( - +
+ - )} - {isCellsStatePending && !isRefreshing && ( - - )} - {isNoNodesVisible && ( - - )} - {isEmptyRecycleBin && } - {(isLoadingVisible || isRefreshing) && } - {isError && } - {isPaginationVisible && } -
+ {isTableVisible && ( + + )} + {isCellsStatePending && !isRefreshing && ( + + )} + {isNoNodesVisible && ( + + )} + {isEmptyRecycleBin && } + {(isLoadingVisible || isRefreshing) && } + {isError && } + {isPaginationVisible && } + +
+ ); }, ); diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm.styles.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm.styles.ts index 3012d91933e..0c8a52057cc 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm.styles.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm.styles.ts @@ -26,3 +26,11 @@ export const inputWrapperStyles: CSSObject = { padding: '16px', gap: '8px', }; + +export const selectWrapperStyles: CSSObject = { + width: '100%', +}; + +export const selectStyles: CSSObject = { + width: '100%', +}; diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm.tsx index e6ea7856572..f9a5a34825e 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/common/CellsNewNodeForm/CellsNewNodeForm.tsx @@ -19,12 +19,12 @@ import {ChangeEvent, FormEvent} from 'react'; -import {ErrorMessage, Input, Label} from '@wireapp/react-ui-kit'; +import {ErrorMessage, Input, Label, Select} from '@wireapp/react-ui-kit'; import {CellNode} from 'src/script/types/cellNode'; import {t} from 'Util/LocalizerUtil'; -import {inputWrapperStyles} from './CellsNewNodeForm.styles'; +import {inputWrapperStyles, selectStyles, selectWrapperStyles} from './CellsNewNodeForm.styles'; import {useInputAutoFocus} from '../useInputAutoFocus/useInputAutoFocus'; @@ -35,14 +35,47 @@ interface CellsNewNodeFormProps { onChange: (event: ChangeEvent) => void; error: string | null; isOpen: boolean; + fileTypeOptions?: Array<{value: string; label: string}>; + selectedFileType?: {value: string; label: string}; + onFileTypeChange?: (option: {value: string; label: string}) => void; } -export const CellsNewNodeForm = ({type, onSubmit, inputValue, onChange, error, isOpen}: CellsNewNodeFormProps) => { +export const CellsNewNodeForm = ({ + type, + onSubmit, + inputValue, + onChange, + error, + isOpen, + fileTypeOptions, + selectedFileType, + onFileTypeChange, +}: CellsNewNodeFormProps) => { const {inputRef} = useInputAutoFocus({enabled: isOpen}); return (
+ {type === 'file' && fileTypeOptions && selectedFileType && onFileTypeChange && ( + <> + +
+ void; currentPath: string; + fileExtension?: string; + onCreatedFile?: (file: CellFile) => void; } const ITEM_ALREADY_EXISTS_ERROR = 409; @@ -44,21 +50,49 @@ export const useCellsNewItemForm = ({ conversationQualifiedId, onSuccess, currentPath, + fileExtension, + onCreatedFile, }: UseCellsNewItemFormProps) => { const [name, setName] = useState(''); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const resolveFileName = (rawName: string) => { + if (type !== 'file' || !fileExtension) { + return rawName; + } + + const currentExtension = getFileExtension(rawName); + if (currentExtension.toLowerCase() === fileExtension.toLowerCase()) { + return rawName; + } + + return `${rawName}.${fileExtension}`; + }; + const createNode = async (name: string) => { const path = getCellsApiPath({conversationQualifiedId, currentPath}); + const resolvedName = resolveFileName(name); + const filePath = `${path || ''}/${resolvedName}`; try { if (type === 'folder') { - await cellsRepository.createFolder({path, name}); + await cellsRepository.createFolder({path, name: resolvedName}); } else { - await cellsRepository.createFile({path, name}); + await cellsRepository.createFile({path, name: resolvedName}); } onSuccess(); + if (type === 'file' && onCreatedFile) { + try { + const createdNode = (await cellsRepository.lookupNodeByPath({path: filePath})) as RestNode; + const [createdFile] = transformDataToCellsNodes({nodes: [createdNode], users: []}); + if (createdFile?.type === 'file') { + onCreatedFile(createdFile as CellFile); + } + } catch (err) { + // Ignore lookup failures; the file is already created and listed. + } + } } catch (error) { if (isAxiosError(error) && error.response?.status === ITEM_ALREADY_EXISTS_ERROR) { setError(t('cells.newItemMenuModalForm.alreadyExistsError')); @@ -78,14 +112,15 @@ export const useCellsNewItemForm = ({ setError(null); setIsSubmitting(true); - if (!name.trim()) { + const trimmedName = name.trim(); + if (!trimmedName) { setError(t('cells.newItemMenuModalForm.nameRequired')); setIsSubmitting(false); return; } try { - await createNode(name); + await createNode(trimmedName); } finally { setIsSubmitting(false); } diff --git a/apps/webapp/src/script/components/FileFullscreenModal/FileEditor/FileEditor.tsx b/apps/webapp/src/script/components/FileFullscreenModal/FileEditor/FileEditor.tsx index c94901cc3e0..8d8cdf60cb4 100644 --- a/apps/webapp/src/script/components/FileFullscreenModal/FileEditor/FileEditor.tsx +++ b/apps/webapp/src/script/components/FileFullscreenModal/FileEditor/FileEditor.tsx @@ -46,6 +46,9 @@ export const FileEditor = ({id}: FileEditorProps) => { const fetchNode = useCallback(async () => { try { + if (!id) { + throw new Error('No ID provided'); + } setIsLoading(true); setIsError(false); const fetchedNode = await cellsRepository.getNode({uuid: id, flags: ['WithEditorURLs']}); diff --git a/apps/webapp/src/script/repositories/cells/CellsRepository.ts b/apps/webapp/src/script/repositories/cells/CellsRepository.ts index d9151b4c784..cee7b703210 100644 --- a/apps/webapp/src/script/repositories/cells/CellsRepository.ts +++ b/apps/webapp/src/script/repositories/cells/CellsRepository.ts @@ -20,6 +20,7 @@ import {NodeFlags, RestShareLink} from '@wireapp/api-client/lib/cells'; import {container, singleton} from 'tsyringe'; +import {getFileExtension} from 'Util/util'; import {createUuid} from 'Util/uuid'; import {APIClient} from '../../service/APIClientSingleton'; @@ -46,11 +47,18 @@ type SortDirection = 'asc' | 'desc'; const DEFAULT_MAX_FILES_LIMIT = 100; +type TemplateMap = { + docx?: string; + xlsx?: string; + pptx?: string; +}; + @singleton() export class CellsRepository { private readonly basePath = 'wire-cells-web'; private isInitialized = false; private uploadControllers: Map = new Map(); + private templatesPromise?: Promise; constructor(private readonly apiClient = container.resolve(APIClient)) {} @@ -176,7 +184,62 @@ export class CellsRepository { const uuid = createUuid(); const versionId = createUuid(); - return this.apiClient.api.cells.createFile({path: filePath, uuid, versionId}); + const fileExtension = getFileExtension(name).toLowerCase(); + const templateUuid = await this.getTemplateUuidForExtension(fileExtension); + const contentType = this.getContentTypeForExtension(fileExtension); + + return this.apiClient.api.cells.createFile({path: filePath, uuid, versionId, templateUuid, contentType}); + } + + private async getTemplateUuidForExtension(extension: string): Promise { + if (!extension) { + return undefined; + } + + if (!this.templatesPromise) { + this.templatesPromise = this.fetchTemplateMap(); + } + + const templates = await this.templatesPromise; + return templates[extension as keyof TemplateMap]; + } + + private async fetchTemplateMap(): Promise { + try { + const response = await this.apiClient.api.cells.listTemplates(); + const templates = response?.Templates || []; + + return templates.reduce((acc, template) => { + const label = (template.Label || '').toLowerCase(); + const path = (template.Node as {Node?: {Path?: string}} | undefined)?.Node?.Path || ''; + const extension = getFileExtension(path).toLowerCase(); + + if (extension === 'docx' || label.includes('document')) { + acc.docx = acc.docx || template.UUID; + } else if (extension === 'xlsx' || label.includes('spreadsheet')) { + acc.xlsx = acc.xlsx || template.UUID; + } else if (extension === 'pptx' || label.includes('presentation')) { + acc.pptx = acc.pptx || template.UUID; + } + + return acc; + }, {}); + } catch (error) { + return {}; + } + } + + private getContentTypeForExtension(extension: string): string | undefined { + switch (extension) { + case 'docx': + return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + case 'xlsx': + return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + case 'pptx': + return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + default: + return undefined; + } } async createPublicLink({ diff --git a/apps/webapp/src/types/i18n.d.ts b/apps/webapp/src/types/i18n.d.ts index da1677dc46c..a4ef8c708ef 100644 --- a/apps/webapp/src/types/i18n.d.ts +++ b/apps/webapp/src/types/i18n.d.ts @@ -376,6 +376,7 @@ declare module 'I18n/en-US.json' { 'callingRestrictedConferenceCallTeamMemberModalTitle': `Feature unavailable`; 'cameraStatusOff': `off`; 'cameraStatusOn': `on`; + 'cells.breadcrumb.files': `{conversationName} files`; 'cells.clearFilters.button': `Clear all`; 'cells.deleteModal.description': `This will permanently delete the file {name} for all participants.`; 'cells.deleteModal.error': `Something went wrong, please try again later and refresh the list.`; @@ -401,8 +402,6 @@ declare module 'I18n/en-US.json' { 'cells.filtersModal.title': `Filters`; 'cells.folderBreadcrumbCombained': `Show more`; 'cells.heading': `Files`; - 'cells.sharedDrive.title': `Shared Drive`; - 'cells.sharedDrive.description': `Find any file or folder in this conversation`; 'cells.imageFullScreenModal.closeButton': `Close`; 'cells.imageFullScreenModal.downloadButton': `Download`; 'cells.modal.closeButton': `Close`; @@ -423,6 +422,10 @@ declare module 'I18n/en-US.json' { 'cells.newItemMenuModal.headlineFile': `Create file`; 'cells.newItemMenuModal.headlineFolder': `Create folder`; 'cells.newItemMenuModal.label': `Name`; + 'cells.newItemMenuModal.typeLabel': `File type`; + 'cells.newItemMenuModal.typeDocument': `Document (.docx)`; + 'cells.newItemMenuModal.typeSpreadsheet': `Spreadsheet (.xlsx)`; + 'cells.newItemMenuModal.typePresentation': `Presentation (.pptx)`; 'cells.newItemMenuModal.placeholderFile': `Enter file name`; 'cells.newItemMenuModal.placeholderFolder': `Enter folder name`; 'cells.newItemMenuModal.primaryAction': `Create`; @@ -479,7 +482,6 @@ declare module 'I18n/en-US.json' { 'cells.search.closeButton': `Close`; 'cells.search.failed': `Something went wrong, please try again later.`; 'cells.search.placeholder': `Search files and folders`; - 'cells.breadcrumb.files': `{conversationName} files`; 'cells.selfDeletingMessage.info': `The feature is not available for conversations with a shared Drive.`; 'cells.shareModal.changePassword': `Change Password`; 'cells.shareModal.copyLink': `Copy Link`; @@ -506,6 +508,8 @@ declare module 'I18n/en-US.json' { 'cells.shareModal.password.error.required': `Enter a password`; 'cells.shareModal.password.label': `Set password`; 'cells.shareModal.primaryAction': `Save`; + 'cells.sharedDrive.description': `Find any file or folder in this conversation`; + 'cells.sharedDrive.title': `Shared Drive`; 'cells.sidebar.heading': `Drive`; 'cells.sidebar.title': `Files`; 'cells.tableRow.actions': `More options`; @@ -1019,8 +1023,8 @@ declare module 'I18n/en-US.json' { 'federationDelete': `[bold]Your backend[/bold] stopped federating with [bold]{backendUrl}.[/bold]`; 'fileCardDefaultCloseButtonLabel': `Close`; 'fileFullscreenModal.editor.error': `Failed to load edit preview`; - 'fileFullscreenModal.editor.errorTitle': `Unable to open file edit mode`; 'fileFullscreenModal.editor.errorDescription': `There was a problem connecting to the server. Please try again.`; + 'fileFullscreenModal.editor.errorTitle': `Unable to open file edit mode`; 'fileFullscreenModal.editor.iframeTitle': `Document editor`; 'fileFullscreenModal.noPreviewAvailable.callToAction': `Download File`; 'fileFullscreenModal.noPreviewAvailable.description': `There is no preview available for this file. Download the file instead.`; diff --git a/libraries/api-client/src/cells/CellsAPI.ts b/libraries/api-client/src/cells/CellsAPI.ts index a7c6689b95a..e5146354764 100644 --- a/libraries/api-client/src/cells/CellsAPI.ts +++ b/libraries/api-client/src/cells/CellsAPI.ts @@ -24,6 +24,7 @@ import { RestDeleteVersionResponse, RestNode, RestNodeCollection, + RestListTemplatesResponse, RestPerformActionResponse, RestPromoteVersionResponse, RestPublicLinkDeleteSuccess, @@ -314,6 +315,16 @@ export class CellsAPI { return node; } + async listTemplates({templateType}: {templateType?: string} = {}): Promise { + if (!this.client || !this.storageService) { + throw new Error(CONFIGURATION_ERROR); + } + + const result = await this.client.templates(templateType); + + return result.data; + } + async getNodeVersions({uuid, flags}: {uuid: string; flags?: Array}): Promise { if (!this.client || !this.storageService) { throw new Error(CONFIGURATION_ERROR); @@ -439,11 +450,15 @@ export class CellsAPI { uuid, type, versionId = '', + templateUuid, + contentType, }: { path: NonNullable; uuid: NonNullable; type: RestIncomingNode['Type']; versionId?: RestIncomingNode['VersionId']; + templateUuid?: RestIncomingNode['TemplateUuid']; + contentType?: RestIncomingNode['ContentType']; }): Promise { if (!this.client || !this.storageService) { throw new Error(CONFIGURATION_ERROR); @@ -456,6 +471,8 @@ export class CellsAPI { Locator: {Path: path.normalize('NFC')}, ResourceUuid: uuid, VersionId: versionId, + ...(templateUuid ? {TemplateUuid: templateUuid} : {}), + ...(contentType ? {ContentType: contentType} : {}), }, ], }); @@ -467,16 +484,22 @@ export class CellsAPI { path, uuid, versionId, + templateUuid, + contentType, }: { path: NonNullable; uuid: NonNullable; versionId: NonNullable; + templateUuid?: RestIncomingNode['TemplateUuid']; + contentType?: RestIncomingNode['ContentType']; }): Promise { return this.createNode({ path, uuid, type: 'LEAF', versionId, + templateUuid, + contentType, }); }