) => void
stop: () => void
}
}
declare module 'cozy-viewer/dist/Panel/AI/AIAssistantPanel' {
const AIAssistantPanel: React.ComponentType<{
className?: string
}>
export default AIAssistantPanel
}
declare module 'cozy-viewer/dist/hoc/withViewerLocales' {
const withViewerLocales: (
Component: React.ComponentType
) => React.ComponentType
export { withViewerLocales }
}
declare module 'cozy-viewer/dist/providers/ViewerProvider' {
export const useViewer: () => {
isOpenAiAssistant: boolean
}
}
================================================
FILE: src/hooks/helpers.d.ts
================================================
export declare function changeLocation(url: string): void
export declare function displayedFolderOrRootFolder(displayedFolder: unknow): {
id: string
}
export declare function isEditableTarget(target: EventTarget | null): boolean
export declare function shouldBlockKeyboardShortcuts(
target: EventTarget | null
): boolean
export declare function normalizeKey(
event: KeyboardEvent,
isApple: boolean
): string
================================================
FILE: src/hooks/helpers.js
================================================
import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config'
/**
* This helper function is used to change the location of the current window
* This main purpose is to help for testing
* @param {string} url - The url to change the location to
*/
export const changeLocation = url => {
window.location = url
}
/**
* Returns displayed folder or root folder if no display folder (like in recent or sharing)
* or if trash folder
* @param {object} displayedFolder
* @returns {object}
*/
export const displayedFolderOrRootFolder = displayedFolder =>
!displayedFolder || displayedFolder._id === TRASH_DIR_ID
? { id: ROOT_DIR_ID }
: displayedFolder
/**
* Check if targeted element can editable
* @param {EventTarget | null} target
* @returns {boolean}
*/
export const isEditableTarget = target =>
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
/**
* Check if targeted element can editable except checkbox
* @param {EventTarget | null} target
* @returns {boolean}
*/
export const shouldBlockKeyboardShortcuts = target => {
if (!target || !(target instanceof HTMLElement)) return false
const tag = target.tagName.toLowerCase()
const type = target.getAttribute('type')?.toLowerCase()
if (
tag === 'input' &&
type !== 'checkbox' &&
!target.readOnly &&
!target.disabled
) {
return true
}
if (tag === 'textarea' && !target.readOnly && !target.disabled) {
return true
}
if (target.isContentEditable) {
return true
}
return false
}
/**
* Normalize shortcut keys
* @param {KeyboardEvent} event
* @param {boolean} isApple
* @returns {string}
*/
export const normalizeKey = (event, isApple) => {
const keys = []
if (isApple ? event.metaKey : event.ctrlKey) keys.push('Ctrl')
const key = event.key.toLowerCase()
if (key === 'delete' || key === 'del' || (isApple && key === 'backspace')) {
keys.push('delete')
} else {
keys.push(key)
}
return keys.join('+')
}
================================================
FILE: src/hooks/index.js
================================================
export { default as useCurrentFileId } from './useCurrentFileId'
export { default as useCurrentFolderId } from './useCurrentFolderId'
export { default as useDisplayedFolder } from './useDisplayedFolder'
export { default as useParentFolder } from './useParentFolder'
export { useRedirectLink } from './useRedirectLink'
export { useFolderSort } from './useFolderSort'
export { useRecentIcons, addRecentIcon } from './useRecentIcons'
================================================
FILE: src/hooks/useCurrentFileId.jsx
================================================
import { useParams } from 'react-router-dom'
const useCurrentFileId = () => {
const { fileId } = useParams()
if (fileId) {
return fileId
}
return null
}
export default useCurrentFileId
================================================
FILE: src/hooks/useCurrentFileId.spec.jsx
================================================
import ReactRouter from 'react-router-dom'
import useCurrentFileId from './useCurrentFileId'
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn()
}))
describe('useCurrentFileId', () => {
it('should return file id if in params', () => {
jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ fileId: 'file-id' })
const currentFileId = useCurrentFileId()
expect(currentFileId).toBe('file-id')
})
it('should return null id if not in params', () => {
jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})
const currentFileId = useCurrentFileId()
expect(currentFileId).toBe(null)
})
})
================================================
FILE: src/hooks/useCurrentFolderId.jsx
================================================
import { useParams, useLocation } from 'react-router-dom'
import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config'
const useCurrentFolderId = () => {
const { folderId } = useParams()
const { pathname = '' } = useLocation()
if (folderId) {
return folderId
} else if (pathname.startsWith('/folder/io.cozy.files.shared-drives-dir')) {
return 'io.cozy.files.shared-drives-dir'
} else if (pathname === '/folder') {
return ROOT_DIR_ID
} else if (pathname === '/trash') {
return TRASH_DIR_ID
}
return null
}
export default useCurrentFolderId
================================================
FILE: src/hooks/useCurrentFolderId.spec.jsx
================================================
import ReactRouter from 'react-router-dom'
import useCurrentFolderId from './useCurrentFolderId'
import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config'
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
useLocation: jest.fn()
}))
describe('useCurrentFolderId', () => {
it('should return file id if in params', () => {
jest
.spyOn(ReactRouter, 'useParams')
.mockReturnValue({ folderId: 'folder-id' })
jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({})
const currentFolderId = useCurrentFolderId()
expect(currentFolderId).toBe('folder-id')
})
it('should return ROOT_DIR_ID if in /folder', () => {
jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})
jest
.spyOn(ReactRouter, 'useLocation')
.mockReturnValue({ pathname: '/folder' })
const currentFolderId = useCurrentFolderId()
expect(currentFolderId).toBe(ROOT_DIR_ID)
})
it('should return TRASH_DIR_ID if in /trash', () => {
jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})
jest
.spyOn(ReactRouter, 'useLocation')
.mockReturnValue({ pathname: '/trash' })
const currentFolderId = useCurrentFolderId()
expect(currentFolderId).toBe(TRASH_DIR_ID)
})
it('should return io.cozy.files.shared-drives-dir if in /folder/io.cozy.files.shared-drives-dir', () => {
jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})
jest
.spyOn(ReactRouter, 'useLocation')
.mockReturnValue({ pathname: '/folder/io.cozy.files.shared-drives-dir' })
const currentFolderId = useCurrentFolderId()
expect(currentFolderId).toBe('io.cozy.files.shared-drives-dir')
})
it('should return null', () => {
jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})
jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({})
const currentFolderId = useCurrentFolderId()
expect(currentFolderId).toBe(null)
})
})
================================================
FILE: src/hooks/useDebounce.jsx
================================================
import { useEffect, useState } from 'react'
const useDebounce = (value, { delay, ignore }) => {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (ignore) return setDebouncedValue(value)
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay, ignore])
return debouncedValue
}
export default useDebounce
================================================
FILE: src/hooks/useDisplayedFolder.spec.jsx
================================================
import { useQuery } from 'cozy-client'
import useCurrentFolderId from './useCurrentFolderId'
import useDisplayedFolder from './useDisplayedFolder'
import { ROOT_DIR_ID } from '@/constants/config'
jest.mock('cozy-client', () => ({
...jest.requireActual('cozy-client'),
useQuery: jest.fn()
}))
jest.mock('./useCurrentFolderId')
describe('useDisplayedFolder', () => {
it('should return file folder if current folder exists', () => {
const FOLDER = {
id: 'folder-id',
name: 'Folder name'
}
useQuery.mockReturnValue({ data: FOLDER })
useCurrentFolderId.mockReturnValue(FOLDER.id)
const { displayedFolder } = useDisplayedFolder()
expect(displayedFolder).toBe(FOLDER)
})
it("should return root dir if current folder isn't found", () => {
const FOLDER = {
id: ROOT_DIR_ID,
name: 'Root'
}
useQuery.mockReturnValue({ data: FOLDER })
useCurrentFolderId.mockReturnValue(null)
const { displayedFolder } = useDisplayedFolder()
expect(displayedFolder).toBe(FOLDER)
})
})
================================================
FILE: src/hooks/useDisplayedFolder.tsx
================================================
import { useQuery } from 'cozy-client'
import { IOCozyFile } from 'cozy-client/types/types'
import { ROOT_DIR_ID } from '@/constants/config'
import useCurrentFolderId from '@/hooks/useCurrentFolderId'
import { buildFileOrFolderByIdQuery } from '@/queries'
interface DisplayedFolderResult {
isNotFound: boolean
displayedFolder: IOCozyFile | null
initialDirId: string | null
}
const useDisplayedFolder = (): DisplayedFolderResult => {
const folderId = useCurrentFolderId() ?? ROOT_DIR_ID
const folderQuery = buildFileOrFolderByIdQuery(folderId)
const folderResult = useQuery(
folderQuery.definition,
folderQuery.options
) as unknown as {
data?: IOCozyFile | null
fetchStatus: string
lastError: { status: number }
}
const displayedFolder = folderResult.data ?? null
const initialDirId = displayedFolder?.id ?? null
if (folderId) {
const isNotFound =
folderResult.fetchStatus === 'failed' &&
folderResult.lastError.status === 404
return {
isNotFound,
displayedFolder,
initialDirId
}
}
return {
isNotFound: true,
displayedFolder: null,
initialDirId: null
}
}
export default useDisplayedFolder
================================================
FILE: src/hooks/useFolderSort/index.spec.jsx
================================================
import { renderHook, act, waitFor } from '@testing-library/react'
import { useClient } from 'cozy-client'
import flag from 'cozy-flags'
import { useFolderSort } from './index'
import { DEFAULT_SORT, SORT_BY_UPDATE_DATE } from '@/config/sort'
import { TRASH_DIR_ID } from '@/constants/config'
import { DOCTYPE_DRIVE_SETTINGS } from '@/lib/doctypes'
import logger from '@/lib/logger'
import { usePublicContext } from '@/modules/public/PublicProvider'
jest.mock('cozy-client', () => ({
useClient: jest.fn(),
Q: jest.fn().mockReturnValue('mocked-query'),
useQuery: jest.fn()
}))
jest.mock('cozy-flags', () => jest.fn())
jest.mock('@/lib/logger', () => ({
warn: jest.fn(),
info: jest.fn(),
error: jest.fn()
}))
jest.mock('@/modules/public/PublicProvider', () => ({
usePublicContext: jest.fn()
}))
const mockUseClient = useClient
const mockFlag = flag
const mockUsePublicContext = usePublicContext
describe('useFolderSort', () => {
let mockClient
let consoleErrorSpy
beforeEach(() => {
jest.clearAllMocks()
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
mockClient = {
save: jest.fn().mockResolvedValue({}),
query: jest.fn().mockResolvedValue({ data: [] })
}
mockUseClient.mockReturnValue(mockClient)
mockFlag.mockImplementation(flagName => {
if (flagName === 'drive.save-sort-choice.enabled') {
return true
}
return false
})
mockUsePublicContext.mockReturnValue({
isPublic: false
})
})
afterEach(() => {
consoleErrorSpy.mockRestore()
})
describe('default sort behavior', () => {
it('should use DEFAULT_SORT for regular folders', () => {
const folderId = 'regular-folder-id'
mockClient.query.mockResolvedValue({
data: []
})
const { result } = renderHook(() => useFolderSort(folderId))
const [currentSort] = result.current
expect(currentSort).toEqual(DEFAULT_SORT)
})
it('should use SORT_BY_UPDATE_DATE for trash folder', () => {
const folderId = TRASH_DIR_ID
mockClient.query.mockResolvedValue({
data: []
})
const { result } = renderHook(() => useFolderSort(folderId))
const [currentSort] = result.current
expect(currentSort).toEqual(SORT_BY_UPDATE_DATE)
})
it('should use SORT_BY_UPDATE_DATE for recent folder', () => {
const folderId = 'recent'
mockClient.query.mockResolvedValue({
data: []
})
const { result } = renderHook(() => useFolderSort(folderId))
const [currentSort] = result.current
expect(currentSort).toEqual(SORT_BY_UPDATE_DATE)
})
})
describe('loading existing sorting settings', () => {
it('should load and apply existing sorting settings when available', async () => {
const folderId = 'test-folder'
const existingSettings = {
_id: 'settings-id',
_type: DOCTYPE_DRIVE_SETTINGS,
attributes: {
attribute: 'updated_at',
order: 'desc'
}
}
mockClient.query.mockResolvedValue({
data: [existingSettings]
})
const { result } = renderHook(() => useFolderSort(folderId))
await waitFor(() => expect(result.current[2]).toBe(true))
await waitFor(() =>
expect(result.current[0]).toEqual({
attribute: 'updated_at',
order: 'desc'
})
)
const [currentSort] = result.current
expect(currentSort).toEqual({
attribute: 'updated_at',
order: 'desc'
})
})
it('should use default values when settings exist but attributes are missing', () => {
const folderId = 'test-folder'
const existingSettings = {
_id: 'settings-id',
_type: DOCTYPE_DRIVE_SETTINGS
}
mockClient.query.mockResolvedValue({
data: [existingSettings]
})
const { result } = renderHook(() => useFolderSort(folderId))
const [currentSort] = result.current
expect(currentSort).toEqual(DEFAULT_SORT)
})
it('should return consistent sort values on multiple renders', async () => {
const folderId = 'test-folder'
const existingSettings = {
_id: 'settings-id',
_type: DOCTYPE_DRIVE_SETTINGS,
attributes: {
attribute: 'name',
order: 'asc'
}
}
mockClient.query.mockResolvedValue({
data: [existingSettings]
})
const { result, rerender } = renderHook(() => useFolderSort(folderId))
await waitFor(() => expect(result.current[2]).toBe(true))
await waitFor(() =>
expect(result.current[0]).toEqual({ attribute: 'name', order: 'asc' })
)
const [firstSort] = result.current
expect(firstSort).toEqual({
attribute: 'name',
order: 'asc'
})
rerender()
const [secondSort] = result.current
expect(secondSort).toEqual({
attribute: 'name',
order: 'asc'
})
})
})
describe('persisting settings', () => {
it('should persist new sorting settings when no existing settings', async () => {
const folderId = 'test-folder'
const newSort = { attribute: 'updated_at', order: 'desc' }
mockClient.query.mockResolvedValue({
data: []
})
const { result } = renderHook(() => useFolderSort(folderId))
const [, setSortOrder] = result.current
await act(async () => {
await setSortOrder(newSort)
})
expect(mockClient.save).toHaveBeenCalledWith({
_type: DOCTYPE_DRIVE_SETTINGS,
attributes: {
...DEFAULT_SORT,
attribute: 'updated_at',
order: 'desc'
}
})
expect(logger.info).toHaveBeenCalledWith(
'Sort settings persisted',
newSort
)
})
it('should update existing sorting settings', async () => {
const folderId = 'test-folder'
const existingSettings = {
_id: 'settings-id',
_type: DOCTYPE_DRIVE_SETTINGS,
attributes: {
attribute: 'name',
order: 'asc'
}
}
const newSort = { attribute: 'updated_at', order: 'desc' }
mockClient.query.mockResolvedValue({
data: [existingSettings]
})
const { result } = renderHook(() => useFolderSort(folderId))
// Wait for the settings to load
await waitFor(() => expect(result.current[2]).toBe(true))
await waitFor(() =>
expect(result.current[0]).toEqual({ attribute: 'name', order: 'asc' })
)
const [, setSortOrder] = result.current
await act(async () => {
await setSortOrder(newSort)
})
expect(mockClient.save).toHaveBeenCalledWith({
...existingSettings,
attributes: {
attribute: 'updated_at',
order: 'desc'
}
})
expect(logger.info).toHaveBeenCalledWith(
'Sort settings persisted',
newSort
)
})
it('should handle save errors gracefully', async () => {
const folderId = 'test-folder'
const newSort = { attribute: 'updated_at', order: 'desc' }
const saveError = new Error('Save failed')
mockClient.save.mockRejectedValue(saveError)
mockClient.query.mockResolvedValue({
data: []
})
const { result } = renderHook(() => useFolderSort(folderId))
const [, setSortOrder] = result.current
await act(async () => {
await setSortOrder(newSort)
})
expect(logger.error).toHaveBeenCalledWith(
'Failed to save sorting preference:',
saveError
)
})
})
describe('public context behavior', () => {
it('should not load settings in public view', async () => {
const folderId = 'test-folder'
const existingSettings = {
_id: 'settings-id',
_type: DOCTYPE_DRIVE_SETTINGS,
attributes: {
attribute: 'updated_at',
order: 'desc'
}
}
mockUsePublicContext.mockReturnValue({
isPublic: true
})
mockClient.query.mockResolvedValue({
data: [existingSettings]
})
const { result } = renderHook(() => useFolderSort(folderId))
const [currentSort] = result.current
expect(currentSort).toEqual(DEFAULT_SORT)
expect(mockClient.query).not.toHaveBeenCalled()
})
it('should not persist settings in public view', async () => {
const folderId = 'test-folder'
const newSort = { attribute: 'updated_at', order: 'desc' }
mockUsePublicContext.mockReturnValue({
isPublic: true
})
const { result } = renderHook(() => useFolderSort(folderId))
const [, setSortOrder] = result.current
await act(async () => {
await setSortOrder(newSort)
})
expect(logger.warn).toHaveBeenCalledWith(
'Cannot persist sort: in public view'
)
expect(mockClient.save).not.toHaveBeenCalled()
expect(mockClient.query).not.toHaveBeenCalled()
})
})
})
================================================
FILE: src/hooks/useFolderSort/index.ts
================================================
import { useCallback, useEffect, useState } from 'react'
import { useClient, Q } from 'cozy-client'
import flag from 'cozy-flags'
import { DEFAULT_SORT, SORT_BY_UPDATE_DATE } from '@/config/sort'
import { RECENT_FOLDER_ID, TRASH_DIR_ID } from '@/constants/config'
import { DOCTYPE_DRIVE_SETTINGS } from '@/lib/doctypes'
import logger from '@/lib/logger'
import { usePublicContext } from '@/modules/public/PublicProvider'
export interface Sort {
attribute: string
order: string
}
interface DriveSettings {
_type?: string
attributes: Sort
}
interface QueryResult {
data?: DriveSettings[]
fetchStatus?: string
}
const useFolderSort = (
folderId: string
): [Sort, (props: Sort) => void, boolean] => {
const defaultSort: Sort =
folderId === TRASH_DIR_ID || folderId === RECENT_FOLDER_ID
? SORT_BY_UPDATE_DATE
: DEFAULT_SORT
const client = useClient()
const { isPublic } = usePublicContext()
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
const [currentSort, setCurrentSort] = useState(defaultSort)
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
const load = async (): Promise => {
if (!client || !flag('drive.save-sort-choice.enabled') || isPublic) {
setIsSettingsLoaded(true)
return
}
try {
const { data } = (await client.query(
Q(DOCTYPE_DRIVE_SETTINGS)
)) as QueryResult
if (!data?.length) return
setCurrentSort(data[0]?.attributes)
} catch (error) {
logger.error('Failed to load settings:', error)
} finally {
setIsSettingsLoaded(true)
}
}
void load()
}, [client, isPublic])
const setSortOrder = useCallback(
async ({ attribute, order }: Sort) => {
setCurrentSort({ attribute, order })
if (!flag('drive.save-sort-choice.enabled')) {
logger.warn(
'Cannot persist sort: flag drive.save-sort-choice.enabled is not enabled'
)
return
}
if (!client) {
logger.warn('Cannot persist sort: client unavailable')
return
}
if (isPublic) {
logger.warn('Cannot persist sort: in public view')
return
}
if (isSaving) {
logger.warn('Cannot persist sort: already saving')
return
}
setIsSaving(true)
try {
const { data } = (await client.query(
Q(DOCTYPE_DRIVE_SETTINGS)
)) as QueryResult
const settingsToSave: DriveSettings = data?.length
? {
...data[0],
attributes: { attribute, order }
}
: {
_type: DOCTYPE_DRIVE_SETTINGS,
attributes: { attribute, order }
}
await client.save(settingsToSave)
logger.info('Sort settings persisted', { attribute, order })
} catch (error) {
logger.error('Failed to save sorting preference:', error)
} finally {
setIsSaving(false)
}
},
[client, isSaving, isPublic, setIsSaving]
)
return [currentSort, setSortOrder, isSettingsLoaded]
}
export { useFolderSort }
================================================
FILE: src/hooks/useKeyboardShortcuts.spec.jsx
================================================
import '@testing-library/jest-dom'
import { renderHook, act } from '@testing-library/react'
import React from 'react'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
jest.mock('cozy-client/dist/models/file', () => ({
isFile: jest.fn()
}))
jest.mock('cozy-ui/transpiled/react/providers/Alert', () => ({
useAlert: jest.fn()
}))
jest.mock('twake-i18n', () => ({
useI18n: jest.fn(),
translate: jest.fn(key => key),
createUseI18n: jest.fn(() => () => ({ t: key => key })),
I18nProvider: ({ children }) => children,
withOnlyLocales: jest.fn(() => Component => Component),
withLocales: jest.fn(() => Component => Component),
useExtendI18n: jest.fn()
}))
jest.mock('./helpers', () => ({
isEditableTarget: jest.fn(),
shouldBlockKeyboardShortcuts: jest.fn(),
normalizeKey: jest.fn()
}))
jest.mock('@/components/pushClient', () => ({
isMacOS: jest.fn()
}))
jest.mock('@/contexts/ClipboardProvider', () => ({
useClipboardContext: jest.fn()
}))
jest.mock('@/hooks', () => ({
useDisplayedFolder: jest.fn()
}))
jest.mock('@/modules/drive/rename', () => ({
startRenamingAsync: jest.fn()
}))
jest.mock('@/modules/nextcloud/hooks/useNextcloudCurrentFolder', () => ({
useNextcloudCurrentFolder: jest.fn()
}))
jest.mock('@/modules/paste', () => ({
handlePasteOperation: jest.fn()
}))
jest.mock('@/modules/selection/SelectionProvider', () => ({
useSelectionContext: jest.fn()
}))
jest.mock('cozy-flags', () => jest.fn())
jest.mock('cozy-sharing', () => ({
SharedDocument: ({ children }) =>
children({ isSharedByMe: false, link: null, recipients: [] }),
SharedRecipientsList: () => null,
withLocales: component => component
}))
jest.mock('@/modules/upload/NewItemHighlightProvider', () => ({
useNewItemHighlightContext: jest.fn(() => ({ addItems: jest.fn() }))
}))
import { isFile } from 'cozy-client/dist/models/file'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { shouldBlockKeyboardShortcuts, normalizeKey } from './helpers'
import { useKeyboardShortcuts } from './useKeyboardShortcuts.tsx'
import { isMacOS } from '@/components/pushClient'
import {
OPERATION_COPY,
OPERATION_CUT,
useClipboardContext
} from '@/contexts/ClipboardProvider'
import { useDisplayedFolder } from '@/hooks'
import { startRenamingAsync } from '@/modules/drive/rename'
import { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder'
import { handlePasteOperation } from '@/modules/paste'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
describe('useKeyboardShortcuts', () => {
let mockDispatch
let mockShowAlert
let mockT
let mockCopyFiles
let mockCutFiles
let mockClearClipboard
let mockSelectAll
let mockClearSelection
let mockHideSelectionBar
let mockShowMoveValidationModal
let mockOnPaste
let mockClient
let mockCurrentFolder
let mockSelectedItems
let mockItems
let store
const createWrapper = () => {
const mockReducer = (state = {}) => state
store = createStore(mockReducer)
return ({ children }) => {children}
}
beforeEach(() => {
mockT = jest.fn((key, options) => {
if (options && options.count !== undefined) {
return `${key}_${options.count}`
}
return key
})
mockCopyFiles = jest.fn()
mockCutFiles = jest.fn()
mockClearClipboard = jest.fn()
mockSelectAll = jest.fn()
mockClearSelection = jest.fn()
mockHideSelectionBar = jest.fn()
mockShowMoveValidationModal = jest.fn()
mockOnPaste = jest.fn()
mockDispatch = jest.fn()
mockShowAlert = jest.fn()
mockClient = {
save: jest.fn(),
query: jest.fn(),
collection: jest.fn()
}
mockCurrentFolder = {
_id: 'current-folder-id',
name: 'Current Folder'
}
mockSelectedItems = [
{
_id: 'file1',
name: 'test1.txt',
type: 'file',
dir_id: 'parent-folder-1'
},
{
_id: 'file2',
name: 'test2.txt',
type: 'file',
dir_id: 'parent-folder-2'
}
]
mockItems = [
{ _id: 'file1', name: 'test1.txt', type: 'file' },
{ _id: 'file2', name: 'test2.txt', type: 'file' },
{ _id: 'folder1', name: 'Test Folder', type: 'directory' }
]
jest
.spyOn(require('react-redux'), 'useDispatch')
.mockReturnValue(mockDispatch)
useAlert.mockReturnValue({ showAlert: mockShowAlert })
useI18n.mockReturnValue({ t: mockT })
useClipboardContext.mockReturnValue({
clipboardData: {
files: [{ _id: 'clipboard-file', name: 'clipboard.txt' }],
operation: OPERATION_COPY,
sourceFolderIds: new Set(['source-folder-id'])
},
copyFiles: mockCopyFiles,
cutFiles: mockCutFiles,
clearClipboard: mockClearClipboard,
hasClipboardData: true,
showMoveValidationModal: mockShowMoveValidationModal
})
useSelectionContext.mockReturnValue({
selectedItems: mockSelectedItems,
selectAll: mockSelectAll,
hideSelectionBar: mockHideSelectionBar,
clearSelection: mockClearSelection,
isSelectAll: false
})
useDisplayedFolder.mockReturnValue({ displayedFolder: mockCurrentFolder })
useNextcloudCurrentFolder.mockReturnValue(mockCurrentFolder)
isFile.mockReturnValue(true)
shouldBlockKeyboardShortcuts.mockReturnValue(false)
normalizeKey.mockImplementation((event, isApple) => {
const key = event.key.toLowerCase()
const ctrl = isApple ? event.metaKey : event.ctrlKey
if (ctrl && key === 'c') return 'Ctrl+c'
if (ctrl && key === 'x') return 'Ctrl+x'
if (ctrl && key === 'v') return 'Ctrl+v'
if (ctrl && key === 'a') return 'Ctrl+a'
if (key === 'f2') return 'f2'
if (key === 'escape') return 'escape'
if (key === 'delete') return 'delete'
return key
})
isMacOS.mockReturnValue(false)
handlePasteOperation.mockResolvedValue([
{ success: true, file: { _id: 'pasted-file' }, operation: OPERATION_COPY }
])
mockCopyFiles.mockClear()
mockCutFiles.mockClear()
mockClearClipboard.mockClear()
mockSelectAll.mockClear()
mockClearSelection.mockClear()
mockHideSelectionBar.mockClear()
mockShowMoveValidationModal.mockClear()
mockOnPaste.mockClear()
mockDispatch.mockClear()
mockShowAlert.mockClear()
handlePasteOperation.mockClear()
})
describe('Copy Operations (Ctrl+C / Cmd+C)', () => {
it('should copy selected files when Ctrl+C is pressed', () => {
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
allowCopy: true
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'c',
ctrlKey: true,
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockCopyFiles).toHaveBeenCalledWith(
mockSelectedItems,
new Set(['parent-folder-1', 'parent-folder-2'])
)
expect(mockShowAlert).toHaveBeenCalledWith({
message: 'alert.items_copied_2',
severity: 'success'
})
expect(mockClearSelection).toHaveBeenCalled()
})
it('should show alert when copy is not allowed', () => {
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
allowCopy: false
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'c',
ctrlKey: true,
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockCopyFiles).not.toHaveBeenCalled()
expect(mockShowAlert).toHaveBeenCalledWith({
message: 'alert.copy_not_allowed',
severity: 'secondary'
})
})
it('should filter only files for copying', () => {
isFile.mockImplementation(item => item.type === 'file')
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
allowCopy: true
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'c',
ctrlKey: true,
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockCopyFiles).toHaveBeenCalledWith(
mockSelectedItems.filter(item => item.type === 'file'),
new Set(['parent-folder-1', 'parent-folder-2'])
)
})
})
describe('Cut Operations (Ctrl+X / Cmd+X)', () => {
it('should cut selected items when Ctrl+X is pressed', () => {
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'x',
ctrlKey: true,
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockCutFiles).toHaveBeenCalledWith(
mockSelectedItems,
new Set(['parent-folder-1', 'parent-folder-2']),
mockCurrentFolder
)
expect(mockShowAlert).toHaveBeenCalledWith({
message: 'alert.items_cut_2',
severity: 'success'
})
expect(mockClearSelection).toHaveBeenCalled()
})
})
describe('Paste Operations (Ctrl+V / Cmd+V)', () => {
it('should paste files when Ctrl+V is pressed', async () => {
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
canPaste: true,
onPaste: mockOnPaste
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'v',
ctrlKey: true,
bubbles: true
})
await act(async () => {
document.dispatchEvent(event)
})
expect(handlePasteOperation).toHaveBeenCalledWith(
mockClient,
[{ _id: 'clipboard-file', name: 'clipboard.txt' }],
OPERATION_COPY,
undefined,
mockCurrentFolder,
{
sharingContext: null,
showAlert: mockShowAlert,
showMoveValidationModal: mockShowMoveValidationModal,
t: mockT,
isPublic: false
}
)
expect(mockShowAlert).toHaveBeenCalledWith({
message: 'alert.item_pasted',
severity: 'success'
})
expect(mockOnPaste).toHaveBeenCalled()
})
it('should clear clipboard after cut operation', async () => {
useClipboardContext.mockReturnValue({
clipboardData: {
files: [{ _id: 'clipboard-file', name: 'clipboard.txt' }],
operation: OPERATION_CUT,
sourceFolderIds: new Set(['source-folder-id'])
},
copyFiles: mockCopyFiles,
cutFiles: mockCutFiles,
clearClipboard: mockClearClipboard,
hasClipboardData: true,
showMoveValidationModal: mockShowMoveValidationModal
})
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
canPaste: true
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'v',
ctrlKey: true,
bubbles: true
})
await act(async () => {
document.dispatchEvent(event)
})
expect(mockClearClipboard).toHaveBeenCalled()
})
it('should skip paste when cutting and pasting in same folder', async () => {
useClipboardContext.mockReturnValue({
clipboardData: {
files: [
{
_id: 'clipboard-file',
name: 'clipboard.txt'
}
],
operation: OPERATION_CUT,
sourceFolderIds: new Set(['current-folder-id'])
},
copyFiles: mockCopyFiles,
cutFiles: mockCutFiles,
clearClipboard: mockClearClipboard,
hasClipboardData: true,
showMoveValidationModal: mockShowMoveValidationModal
})
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
canPaste: true
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'v',
ctrlKey: true,
bubbles: true
})
await act(async () => {
document.dispatchEvent(event)
})
expect(mockShowAlert).toHaveBeenCalledWith({
message: 'alert.paste_same_folder_skipped',
severity: 'secondary'
})
expect(handlePasteOperation).not.toHaveBeenCalled()
})
})
describe('Move with Validation Modals', () => {
it('should call showMoveValidationModal during paste operation', async () => {
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
canPaste: true,
sharingContext: { isShared: true }
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'v',
ctrlKey: true,
bubbles: true
})
await act(async () => {
document.dispatchEvent(event)
})
expect(handlePasteOperation).toHaveBeenCalledWith(
mockClient,
expect.any(Array),
OPERATION_COPY,
undefined,
mockCurrentFolder,
expect.objectContaining({
sharingContext: { isShared: true },
showMoveValidationModal: mockShowMoveValidationModal
})
)
})
})
describe('Select All (Ctrl+A / Cmd+A)', () => {
it('should select all items when Ctrl+A is pressed', () => {
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockSelectAll).toHaveBeenCalledWith(mockItems)
})
it('should clear selection when all items are already selected', () => {
useSelectionContext.mockReturnValue({
selectedItems: mockSelectedItems,
selectAll: mockSelectAll,
hideSelectionBar: mockHideSelectionBar,
clearSelection: mockClearSelection,
isSelectAll: true
})
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockClearSelection).toHaveBeenCalled()
expect(mockSelectAll).not.toHaveBeenCalled()
})
})
describe('Rename (F2)', () => {
it('should start renaming when F2 is pressed with single selection', () => {
useSelectionContext.mockReturnValue({
selectedItems: [mockSelectedItems[0]], // Single item selected
selectAll: mockSelectAll,
hideSelectionBar: mockHideSelectionBar,
clearSelection: mockClearSelection,
isSelectAll: false
})
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'F2',
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockDispatch).toHaveBeenCalledWith(
startRenamingAsync(mockSelectedItems[0])
)
})
it('should not start renaming when multiple items are selected', () => {
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'F2',
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockDispatch).not.toHaveBeenCalled()
})
})
describe('the delete shortcut key', () => {
it('should show delete confirmation when Delete key is pressed', () => {
const mockPushModal = jest.fn()
const mockPopModal = jest.fn()
const mockRefresh = jest.fn()
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
pushModal: mockPushModal,
popModal: mockPopModal,
refresh: mockRefresh
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'Delete',
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockPushModal).toHaveBeenCalledWith(
expect.objectContaining({
type: expect.any(Function)
})
)
})
it('should not show delete confirmation when no items are selected', () => {
useSelectionContext.mockReturnValue({
selectedItems: [],
selectAll: mockSelectAll,
hideSelectionBar: mockHideSelectionBar,
clearSelection: mockClearSelection,
isSelectAll: false
})
const mockPushModal = jest.fn()
const mockPopModal = jest.fn()
const mockRefresh = jest.fn()
const wrapper = createWrapper()
renderHook(
() =>
useKeyboardShortcuts({
client: mockClient,
items: mockItems,
pushModal: mockPushModal,
popModal: mockPopModal,
refresh: mockRefresh
}),
{ wrapper }
)
const event = new KeyboardEvent('keydown', {
key: 'Delete',
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockPushModal).not.toHaveBeenCalled()
})
})
describe('Shared Drive Operations', () => {
const sharedDriveFiles = [
{
_id: 'shared-file-1',
name: 'shared-doc.pdf',
type: 'file',
dir_id: 'shared-folder-1',
driveId: 'shared-drive-123'
}
]
const sharedDriveFolder = {
_id: 'shared-folder-1',
name: 'Shared Folder',
type: 'directory',
driveId: 'shared-drive-456'
}
beforeEach(() => {
// Reset all mocks
shouldBlockKeyboardShortcuts.mockReturnValue(false)
isFile.mockReturnValue(true)
useDisplayedFolder.mockReturnValue({ displayedFolder: sharedDriveFolder })
useSelectionContext.mockReturnValue({
selectedItems: sharedDriveFiles,
selectAll: mockSelectAll,
clearSelection: mockClearSelection,
isSelectionBarVisible: false
})
})
it('should copy shared drive files when Ctrl+C is pressed', () => {
const wrapper = createWrapper()
renderHook(() => useKeyboardShortcuts({ onPaste: mockOnPaste }), {
wrapper
})
const event = new KeyboardEvent('keydown', {
key: 'c',
ctrlKey: true,
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockCopyFiles).toHaveBeenCalledWith(
sharedDriveFiles,
new Set(['shared-folder-1'])
)
expect(mockShowAlert).toHaveBeenCalledWith({
message: 'alert.item_copied',
severity: 'success'
})
expect(mockClearSelection).toHaveBeenCalled()
})
it('should cut shared drive files when Ctrl+X is pressed', () => {
useDisplayedFolder.mockReturnValue({ displayedFolder: sharedDriveFolder })
const wrapper = createWrapper()
renderHook(() => useKeyboardShortcuts({ onPaste: mockOnPaste }), {
wrapper
})
const event = new KeyboardEvent('keydown', {
key: 'x',
ctrlKey: true,
bubbles: true
})
act(() => {
document.dispatchEvent(event)
})
expect(mockCutFiles).toHaveBeenCalledWith(
sharedDriveFiles,
new Set(['shared-folder-1']),
sharedDriveFolder
)
expect(mockShowAlert).toHaveBeenCalledWith({
message: 'alert.item_cut',
severity: 'success'
})
expect(mockClearSelection).toHaveBeenCalled()
})
it('should handle paste operations with shared drive folders', async () => {
// Test that handlePasteOperation can be called with shared drive folder
// This verifies the integration works correctly
await handlePasteOperation(
mockClient,
[{ _id: 'regular-file', name: 'regular.txt' }],
'copy',
null,
sharedDriveFolder,
{
sharingContext: null,
showAlert: mockShowAlert,
showMoveValidationModal: mockShowMoveValidationModal,
t: mockT
}
)
// Verify that the function was called with shared drive folder
expect(handlePasteOperation).toHaveBeenCalledWith(
mockClient,
[{ _id: 'regular-file', name: 'regular.txt' }],
'copy',
null,
expect.objectContaining({
_id: 'shared-folder-1',
driveId: 'shared-drive-456'
}),
expect.any(Object)
)
})
})
})
================================================
FILE: src/hooks/useKeyboardShortcuts.tsx
================================================
import React, { useEffect, useCallback } from 'react'
import { useDispatch } from 'react-redux'
import { isFile } from 'cozy-client/dist/models/file'
import CozyClient from 'cozy-client/types/CozyClient'
import { IOCozyFile } from 'cozy-client/types/types'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { shouldBlockKeyboardShortcuts, normalizeKey } from './helpers'
import { isMacOS } from '@/components/pushClient'
import { SHARED_DRIVES_DIR_ID } from '@/constants/config'
import {
useClipboardContext,
OPERATION_CUT
} from '@/contexts/ClipboardProvider'
import { useDisplayedFolder } from '@/hooks'
import DeleteConfirm from '@/modules/drive/DeleteConfirm'
import { startRenamingAsync } from '@/modules/drive/rename'
import { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder'
import { handlePasteOperation } from '@/modules/paste'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
// Type for the result returned by copy/move operations from cozy-client
interface PasteResultFile {
data?: IOCozyFile
moved?: IOCozyFile
}
interface PasteOperationResult {
success: boolean
file: PasteResultFile | IOCozyFile
error?: Error
operation: string
}
interface UseKeyboardShortcutsProps {
onPaste?: (() => void) | null
canPaste?: boolean
client?: CozyClient | null
items?: IOCozyFile[]
sharingContext?: unknown
allowCopy?: boolean
allowCut?: boolean
allowDelete?: boolean
isNextCloudFolder?: boolean
isPublic?: boolean
pushModal?: (modal: React.ReactElement) => void
popModal?: () => void
refresh?: () => void
}
export const useKeyboardShortcuts = ({
onPaste = null,
canPaste = false,
client = null,
items = [],
sharingContext = null,
allowCopy = true,
allowCut = true,
allowDelete = true,
isNextCloudFolder = false,
isPublic = false,
pushModal,
popModal,
refresh
}: UseKeyboardShortcutsProps): void => {
const dispatch = useDispatch()
const { t } = useI18n()
const { showAlert } = useAlert()
const {
selectedItems,
selectAll,
hideSelectionBar,
clearSelection,
isSelectAll
} = useSelectionContext() as unknown as {
selectedItems: IOCozyFile[]
selectAll: (items: IOCozyFile[]) => void
hideSelectionBar: () => void
clearSelection: () => void
isSelectAll: boolean
}
const {
clipboardData,
copyFiles,
cutFiles,
clearClipboard,
hasClipboardData,
showMoveValidationModal
} = useClipboardContext()
const { addItems } = useNewItemHighlightContext() as {
addItems: (items: IOCozyFile[]) => void
}
const { displayedFolder } = useDisplayedFolder()
const currentNextCloudFolder = useNextcloudCurrentFolder()
const currentFolder = isNextCloudFolder
? currentNextCloudFolder
: displayedFolder
const isApple = isMacOS()
const handleCopy = useCallback(() => {
if (!allowCopy) {
showAlert({ message: t('alert.copy_not_allowed'), severity: 'secondary' })
return
}
const parentFolderIds = selectedItems.map(item => item.dir_id)
if (parentFolderIds.includes(SHARED_DRIVES_DIR_ID)) {
showAlert({
message: t('alert.cannot_copy_shared_drive'),
severity: 'secondary'
})
return
}
if (!selectedItems.length) return
const filesToCopy = selectedItems.filter(isFile)
if (filesToCopy.length === 0) {
showAlert({ message: t('alert.copy_files_only'), severity: 'secondary' })
return
}
copyFiles(filesToCopy, new Set(parentFolderIds))
const message =
filesToCopy.length === 1
? t('alert.item_copied')
: t('alert.items_copied', { count: filesToCopy.length })
showAlert({ message, severity: 'success' })
clearSelection()
}, [allowCopy, selectedItems, copyFiles, showAlert, t, clearSelection])
const handleCut = useCallback(() => {
if (!selectedItems.length) return
if (!allowCut) {
showAlert({
message: t('alert.cut_not_allowed'),
severity: 'secondary'
})
return
}
const parentFolderIds = selectedItems.map(item => item.dir_id)
if (parentFolderIds.includes(SHARED_DRIVES_DIR_ID)) {
showAlert({
message: t('alert.cannot_move_shared_drive'),
severity: 'secondary'
})
return
}
cutFiles(
selectedItems,
new Set(parentFolderIds),
currentFolder as IOCozyFile
)
const message =
selectedItems.length === 1
? t('alert.item_cut')
: t('alert.items_cut', { count: selectedItems.length })
showAlert({ message, severity: 'success' })
clearSelection()
}, [
selectedItems,
allowCut,
currentFolder,
cutFiles,
t,
showAlert,
clearSelection
])
const handlePaste = useCallback(async () => {
if (!hasClipboardData || !client || !currentFolder) return
if (!canPaste) {
showAlert({
message: t('alert.paste_not_allowed'),
severity: 'secondary'
})
return
}
// Skip operation if cutting and pasting in the same folder
if (
clipboardData.operation === OPERATION_CUT &&
clipboardData.sourceFolderIds?.has(currentFolder._id)
) {
showAlert({
message: t('alert.paste_same_folder_skipped'),
severity: 'secondary'
})
return
}
try {
const results = (await handlePasteOperation(
client,
clipboardData.files,
clipboardData.operation,
clipboardData.sourceDirectory,
currentFolder,
{
showAlert,
t,
sharingContext,
showMoveValidationModal,
isPublic
}
)) as PasteOperationResult[]
const successCount = results.filter(r => r.success).length
const failureCount = results.filter(r => !r.success).length
if (successCount > 0) {
const message =
successCount === 1
? t('alert.item_pasted')
: t('alert.items_pasted', { count: successCount })
showAlert({ message, severity: 'success' })
const successfulFiles = results
.filter(r => r.success)
.map(r => {
const file = r.file
if ('data' in file && file.data) {
return file.data
}
if ('moved' in file && file.moved) {
return file.moved
}
return null
})
.filter((file): file is IOCozyFile => file !== null)
if (successfulFiles.length > 0) {
addItems(successfulFiles)
}
} else if (failureCount > 0) {
showAlert({
message: t('alert.paste_failed'),
severity: 'error'
})
}
if (clipboardData.operation === OPERATION_CUT) {
clearClipboard()
}
onPaste?.()
} catch (_error) {
showAlert({
message: t('alert.paste_error'),
severity: 'error'
})
}
}, [
hasClipboardData,
client,
currentFolder,
canPaste,
clipboardData.operation,
clipboardData.sourceFolderIds,
clipboardData.files,
clipboardData.sourceDirectory,
showAlert,
t,
sharingContext,
showMoveValidationModal,
isPublic,
onPaste,
clearClipboard,
addItems
])
const handleSelectAll = useCallback(() => {
if (isSelectAll) {
clearSelection()
} else {
selectAll(items)
}
}, [isSelectAll, clearSelection, selectAll, items])
const handleRename = useCallback(() => {
if (selectedItems.length === 1) {
dispatch(startRenamingAsync(selectedItems[0]))
}
}, [selectedItems, dispatch])
const handleEscape = useCallback(() => {
hideSelectionBar()
clearClipboard()
}, [hideSelectionBar, clearClipboard])
const handleDelete = useCallback(() => {
if (!selectedItems.length || !pushModal || !popModal || !refresh) return
if (!allowDelete) {
showAlert({
message: t('alert.delete_not_allowed'),
severity: 'secondary'
})
return
}
const driveId = selectedItems[0]?.driveId
pushModal(
)
}, [selectedItems, pushModal, popModal, refresh, allowDelete, showAlert, t])
useEffect(() => {
const shortcuts: Record void | Promise) | undefined> =
{
'Ctrl+c': handleCopy,
'Ctrl+x': handleCut,
'Ctrl+v': handlePaste,
'Ctrl+a': handleSelectAll,
f2: handleRename,
escape: handleEscape,
delete: handleDelete
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (!event.target || shouldBlockKeyboardShortcuts(event.target)) return
const combo = normalizeKey(event, isApple)
const handler = shortcuts[combo]
if (handler) {
event.preventDefault()
void handler()
}
}
document.addEventListener('keydown', handleKeyDown)
return (): void => document.removeEventListener('keydown', handleKeyDown)
}, [
isApple,
handleCopy,
handleCut,
handlePaste,
handleSelectAll,
handleRename,
handleEscape,
handleDelete
])
}
================================================
FILE: src/hooks/useMoreMenuActions.jsx
================================================
import { useState, useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useClient } from 'cozy-client'
import { fetchBlobFileById, isFile } from 'cozy-client/dist/models/file'
import { useWebviewIntent } from 'cozy-intent'
import { useVaultClient } from 'cozy-keys-lib'
import {
useSharingContext,
useNativeFileSharing,
shareNative,
addToCozySharingLink,
syncToCozySharingLink,
useSharingInfos
} from 'cozy-sharing'
import {
makeActions,
print
} from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { useCurrentFolderId } from '@/hooks'
import { useModalContext } from '@/lib/ModalContext'
import { share, download, trash, versions, hr } from '@/modules/actions'
import { addToFavorites } from '@/modules/actions/components/addToFavorites'
import { duplicateTo } from '@/modules/actions/components/duplicateTo'
import { moveTo } from '@/modules/actions/components/moveTo'
import { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites'
import { details } from '@/modules/actions/details'
import { filterActionsByPolicy } from '@/modules/actions/policies'
export const useMoreMenuActions = file => {
const [isPrintAvailable, setIsPrintAvailable] = useState(false)
const client = useClient()
const vaultClient = useVaultClient()
const webviewIntent = useWebviewIntent()
const { t, lang } = useI18n()
const { isMobile } = useBreakpoints()
const navigate = useNavigate()
const { pushModal, popModal } = useModalContext()
const { allLoaded, hasWriteAccess, isOwner, byDocId } = useSharingContext()
const { showAlert } = useAlert()
const { isNativeFileSharingAvailable, shareFilesNative } =
useNativeFileSharing()
const currentFolderId = useCurrentFolderId()
const { isSharingShortcutCreated, addSharingLink, syncSharingLink } =
useSharingInfos()
const location = useLocation()
const canWriteToCurrentFolder = hasWriteAccess(currentFolderId, file.driveId)
const isPDFDoc = file.mime === 'application/pdf'
const showPrintAction = isPDFDoc && isPrintAvailable
const isCozySharing = window.location.pathname === '/preview'
const actions = makeActions(
[
share,
shareNative,
isCozySharing && addToCozySharingLink,
isCozySharing && syncToCozySharingLink,
download,
showPrintAction && print,
details,
hr,
moveTo,
duplicateTo,
addToFavorites,
removeFromFavorites,
hr,
versions,
hr,
trash
],
{
client,
t,
lang,
vaultClient,
pushModal,
popModal,
refresh: () => navigate('..'),
navigate,
hasWriteAccess: canWriteToCurrentFolder,
canMove: canWriteToCurrentFolder,
isPublic: false,
allLoaded,
showAlert,
isOwner,
byDocId,
isNativeFileSharingAvailable,
shareFilesNative,
isSharingShortcutCreated,
openSharingLinkDisplayed: isCozySharing,
syncSharingLink,
isMobile,
fetchBlobFileById,
isFile,
addSharingLink,
driveId: file.driveId,
location
}
)
const filteredActions = filterActionsByPolicy(actions, [file])
useEffect(() => {
const init = async () => {
const isAvailable =
(await webviewIntent?.call('isAvailable', 'print')) ?? true
setIsPrintAvailable(isAvailable)
}
init()
}, [webviewIntent])
return filteredActions
}
================================================
FILE: src/hooks/useOnLongPress/helpers.js
================================================
const DOUBLECLICKDELAY = 400
export const handleClick = ({
event,
file,
disabled,
isRenaming,
openLink,
toggle,
lastClickTime,
setLastClickTime,
setSelectedItems,
onInteractWithFile,
clearHighlightedItems
}) => {
// if default behavior is opening a file, it blocks that to force other bahavior
event.preventDefault()
if (disabled || isRenaming) return
clearHighlightedItems?.()
const currentTime = Date.now()
const isDoubleClick = currentTime - lastClickTime < DOUBLECLICKDELAY
if (isDoubleClick) {
openLink(event)
} else if (event.ctrlKey || event.metaKey) {
toggle(event)
} else {
// we should not use file.index
// we should probablt not use index - 1
// we should use only one func to set things on click, and not 3 setters
setSelectedItems({ [file._id]: file })
}
onInteractWithFile?.(file._id, event)
setLastClickTime(currentTime)
}
export const makeDesktopHandlers = ({
file,
timerId,
disabled,
isRenaming,
openLink,
toggle,
selectionModeActive,
lastClickTime,
setLastClickTime,
clearSelection,
setSelectedItems,
clearHighlightedItems,
onInteractWithFile
}) => {
return {
// first event triggered on Desktop
onMouseDown: () => clearTimeout(timerId.current),
// second event triggered on Desktop
onMouseUp: () => clearTimeout(timerId.current),
// third event triggered on Desktop
onClick: event =>
handleClick({
event,
file,
disabled,
isRenaming,
openLink,
toggle,
selectionModeActive,
lastClickTime,
setLastClickTime,
clearSelection,
setSelectedItems,
clearHighlightedItems,
onInteractWithFile
})
}
}
export const handlePress = ({
event,
disabled,
selectionModeActive,
isLongPress,
isRenaming,
openLink,
toggle,
clearHighlightedItems
}) => {
// if default behavior is opening a file, it blocks that to force other bahavior
event.preventDefault()
// isLongPress is to prevent executing onPress twice while a longpress
// can happen if button is released quickly just after startPressTimer execution
if (disabled || isLongPress.current || isRenaming) return
if (selectionModeActive) {
toggle(event)
} else {
openLink(event)
}
clearHighlightedItems?.()
}
export const makeMobileHandlers = ({
timerId,
disabled,
selectionModeActive,
isRenaming,
isLongPress,
openLink,
toggle,
clearHighlightedItems
}) => {
// used to determine if it's a longpress
// i.e. delay onClick
const startPressTimer = e => {
e.persist()
isLongPress.current = false
timerId.current = setTimeout(() => {
isLongPress.current = true
if (!isRenaming) {
toggle(e)
}
}, 250)
}
return {
// first event triggered on Mobile when taping an item
onTouchStart: startPressTimer,
// second event triggered on Mobile when dragging an item
onTouchMove: () => clearTimeout(timerId.current),
// third event triggered on Mobile when taping an item
onTouchEnd: () => clearTimeout(timerId.current),
// fourth event triggered on Mobile
onClick: event =>
handlePress({
event,
disabled,
selectionModeActive,
isLongPress,
isRenaming,
openLink,
toggle,
clearHighlightedItems
})
}
}
================================================
FILE: src/hooks/useOnLongPress/helpers.spec.jsx
================================================
import MockDate from 'mockdate'
import flag from 'cozy-flags'
import { handlePress, handleClick } from './helpers'
jest.mock('cozy-flags', () => jest.fn())
const mockToggle = jest.fn()
const mockOpenLink = jest.fn()
const ev = { preventDefault: jest.fn() }
describe('handlePress', () => {
const setup = ({
event = ev,
disabled = false,
selectionModeActive = false,
isLongPress = { current: false },
isRenaming = false
}) => {
return {
params: {
event,
disabled,
selectionModeActive,
isLongPress,
isRenaming,
openLink: mockOpenLink,
toggle: mockToggle
}
}
}
afterEach(() => {
jest.clearAllMocks()
})
it('should only toggle if selectionModeActive', () => {
const { params } = setup({ selectionModeActive: true })
handlePress(params)
expect(mockToggle).toHaveBeenCalledWith(ev)
expect(mockOpenLink).not.toHaveBeenCalled()
})
it('should only open link if not renaming', () => {
const { params } = setup({ isRenaming: false })
handlePress(params)
expect(mockToggle).not.toHaveBeenCalledWith()
expect(mockOpenLink).toHaveBeenCalledWith(ev)
})
describe('should do nothing if', () => {
it('disabled is true', () => {
const { params } = setup({ disabled: true })
handlePress(params)
expect(mockToggle).not.toHaveBeenCalled()
expect(mockOpenLink).not.toHaveBeenCalled()
})
it('isRenaming is true', () => {
const { params } = setup({ isRenaming: true })
handlePress(params)
expect(mockToggle).not.toHaveBeenCalledWith()
expect(mockOpenLink).not.toHaveBeenCalled()
})
it('isLongPress is true', () => {
const { params } = setup({ isLongPress: { current: true } })
handlePress(params)
expect(mockToggle).not.toHaveBeenCalledWith()
expect(mockOpenLink).not.toHaveBeenCalled()
})
})
})
describe('handleClick', () => {
const setup = ({
event = ev,
disabled = false,
isRenaming = false,
file = { _id: 'file-id' },
lastClickTime = new Date('2025-01-01T12:00:00.000Z').getTime() // date of the first click
}) => {
return {
params: {
event,
disabled,
isRenaming,
file,
openLink: mockOpenLink,
toggle: mockToggle,
lastClickTime,
setLastClickTime: jest.fn(),
setSelectedItems: jest.fn(),
onInteractWithFile: jest.fn()
}
}
}
afterEach(() => {
jest.clearAllMocks()
MockDate.reset()
})
// should create a real life test to replace toggle by final func
xit('should only toggle by default', () => {
const { params } = setup({})
handleClick(params)
expect(mockToggle).toHaveBeenCalledWith(ev)
expect(mockOpenLink).not.toHaveBeenCalled()
})
describe('should do nothing if', () => {
it('disabled is true', () => {
const { params } = setup({ disabled: true })
handleClick(params)
expect(mockToggle).not.toHaveBeenCalled()
expect(mockOpenLink).not.toHaveBeenCalled()
})
it('isRenaming is true', () => {
const { params } = setup({ isRenaming: true })
handleClick(params)
expect(mockToggle).not.toHaveBeenCalledWith()
expect(mockOpenLink).not.toHaveBeenCalled()
})
})
describe('with dynamic-selection enabled and selectionModeActive', () => {
const file = { _id: 'file-1' }
const mockSetSelectedItems = jest.fn()
const mockOnInteractWithFile = jest.fn()
const setupDynamic = (eventOverrides = {}) => {
flag.mockImplementation(name => {
if (name === 'drive.dynamic-selection.enabled') return true
if (name === 'drive.doubleclick.enabled') return false
return false
})
const event = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
shiftKey: false,
ctrlKey: false,
metaKey: false,
...eventOverrides
}
return {
params: {
event,
file,
disabled: false,
isRenaming: false,
openLink: mockOpenLink,
toggle: mockToggle,
selectionModeActive: true,
lastClickTime: 0,
setLastClickTime: jest.fn(),
setSelectedItems: mockSetSelectedItems,
onInteractWithFile: mockOnInteractWithFile,
clearHighlightedItems: jest.fn()
},
event
}
}
afterEach(() => {
flag.mockReset()
})
it('should replace selection on simple click', () => {
const { params } = setupDynamic()
handleClick(params)
expect(mockSetSelectedItems).toHaveBeenCalledWith({
[file._id]: file
})
expect(mockToggle).not.toHaveBeenCalled()
})
it('should toggle item on Ctrl+Click', () => {
const { params, event } = setupDynamic({ ctrlKey: true })
handleClick(params)
expect(mockToggle).toHaveBeenCalledWith(event)
expect(mockSetSelectedItems).not.toHaveBeenCalled()
})
it('should toggle item on Cmd+Click (metaKey)', () => {
const { params, event } = setupDynamic({ metaKey: true })
handleClick(params)
expect(mockToggle).toHaveBeenCalledWith(event)
expect(mockSetSelectedItems).not.toHaveBeenCalled()
})
})
describe('with doubleclick enabled', () => {
const file = { _id: 'file-1' }
const mockSetSelectedItems = jest.fn()
const mockOnInteractWithFile = jest.fn()
const setupDoubleClick = (eventOverrides = {}) => {
flag.mockImplementation(name => {
if (name === 'drive.doubleclick.enabled') return true
return false
})
const event = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
shiftKey: false,
ctrlKey: false,
metaKey: false,
...eventOverrides
}
return {
params: {
event,
file,
disabled: false,
isRenaming: false,
openLink: mockOpenLink,
toggle: mockToggle,
selectionModeActive: true,
lastClickTime: 0,
setLastClickTime: jest.fn(),
setSelectedItems: mockSetSelectedItems,
onInteractWithFile: mockOnInteractWithFile,
clearHighlightedItems: jest.fn()
},
event
}
}
afterEach(() => {
flag.mockReset()
})
it('should replace selection on simple click', () => {
const { params } = setupDoubleClick()
handleClick(params)
expect(mockSetSelectedItems).toHaveBeenCalledWith({
[file._id]: file
})
expect(mockToggle).not.toHaveBeenCalled()
})
it('should toggle item on Ctrl+Click', () => {
const { params, event } = setupDoubleClick({ ctrlKey: true })
handleClick(params)
expect(mockToggle).toHaveBeenCalledWith(event)
expect(mockSetSelectedItems).not.toHaveBeenCalled()
})
it('should toggle item on Cmd+Click (metaKey)', () => {
const { params, event } = setupDoubleClick({ metaKey: true })
handleClick(params)
expect(mockToggle).toHaveBeenCalledWith(event)
expect(mockSetSelectedItems).not.toHaveBeenCalled()
})
})
describe('for double click', () => {
beforeEach(() => {
MockDate.set('2025-01-01T12:00:00.300Z') // date of the second click
})
it('it should do nothing when renainming', () => {
const { params } = setup({ isRenaming: true })
handleClick(params)
expect(mockToggle).not.toHaveBeenCalled()
expect(mockOpenLink).not.toHaveBeenCalled()
})
it('it should only open link', () => {
const { params } = setup({})
handleClick(params)
expect(mockToggle).not.toHaveBeenCalled()
expect(mockOpenLink).toHaveBeenCalledWith(ev)
})
})
})
================================================
FILE: src/hooks/useOnLongPress/index.js
================================================
import { useRef, useState } from 'react'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { makeDesktopHandlers, makeMobileHandlers } from './helpers'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
export const useLongPress = ({
file,
disabled,
isRenaming,
openLink,
toggle,
onInteractWithFile
}) => {
const timerId = useRef()
const isLongPress = useRef(false)
const [lastClickTime, setLastClickTime] = useState(0)
const { isDesktop } = useBreakpoints()
const {
setSelectedItems,
clearSelection,
isSelectionBarVisible: selectionModeActive
} = useSelectionContext()
const { clearItems: clearHighlightedItems } = useNewItemHighlightContext()
if (isDesktop) {
// eslint-disable-next-line react-hooks/refs
return makeDesktopHandlers({
file,
timerId,
disabled,
isRenaming,
openLink,
toggle,
selectionModeActive,
lastClickTime,
setLastClickTime,
clearSelection,
setSelectedItems,
onInteractWithFile,
clearHighlightedItems
})
}
// eslint-disable-next-line react-hooks/refs
return makeMobileHandlers({
timerId,
disabled,
selectionModeActive,
isRenaming,
isLongPress,
openLink,
toggle,
clearHighlightedItems
})
}
================================================
FILE: src/hooks/useParentFolder.jsx
================================================
import { useClient } from 'cozy-client'
import { DOCTYPE_FILES } from '@/lib/doctypes'
const useParentFolder = parentFolderId => {
const client = useClient()
if (parentFolderId) {
return client.getDocumentFromState(DOCTYPE_FILES, parentFolderId)
}
return null
}
export default useParentFolder
================================================
FILE: src/hooks/useParentFolder.spec.jsx
================================================
import useParentFolder from './useParentFolder'
const mockGetDocumentFromState = jest.fn()
jest.mock('cozy-client', () => ({
...jest.requireActual('cozy-client'),
useClient: () => ({
getDocumentFromState: mockGetDocumentFromState
})
}))
describe('useParentFolder', () => {
it('should return file folder if parent folder exists', () => {
const FOLDER = {
id: 'folder-id',
name: 'Folder name'
}
mockGetDocumentFromState.mockReturnValue(FOLDER)
const parentFolder = useParentFolder(FOLDER.id)
expect(parentFolder).toBe(FOLDER)
})
it('should return null if parent folder does not exist', () => {
const parentFolder = useParentFolder()
expect(parentFolder).toBe(null)
})
})
================================================
FILE: src/hooks/useRecentFiles.jsx
================================================
import { useEffect, useState, useMemo } from 'react'
import { useClient } from 'cozy-client'
import { useDataProxy } from 'cozy-dataproxy-lib'
import logger from '@/lib/logger'
import { buildRecentQuery } from '@/queries'
const useDataProxyRecents = () => {
const [data, setData] = useState([])
const [fetchStatus, setFetchStatus] = useState('loading')
const [error, setError] = useState(null)
const dataProxy = useDataProxy()
const client = useClient()
const recentQuery = useMemo(() => buildRecentQuery(), [])
useEffect(() => {
const fetchRecents = async () => {
setFetchStatus('loading')
setError(null)
if (dataProxy.dataProxyServicesAvailable) {
try {
const data = await dataProxy.recents()
setData(data || [])
setFetchStatus('loaded')
return
} catch (err) {
logger.warn('Error fetching recents from dataproxy', err)
}
}
if (client) {
try {
const result = await client.fetchQueryAndGetFromState({
definition: recentQuery.definition(),
options: recentQuery.options
})
setData(result?.data || [])
setFetchStatus('loaded')
} catch (err) {
logger.warn('Error fetching recents from fallback query', err)
setError(err)
setFetchStatus('error')
}
} else {
setError(new Error('Client not available'))
setFetchStatus('error')
}
}
fetchRecents()
}, [dataProxy, client, recentQuery])
return { data, fetchStatus, error }
}
export default useDataProxyRecents
================================================
FILE: src/hooks/useRecentFiles.spec.jsx
================================================
import { renderHook, waitFor } from '@testing-library/react'
import { useClient } from 'cozy-client'
import { useDataProxy } from 'cozy-dataproxy-lib'
import useDataProxyRecents from './useRecentFiles'
import logger from '@/lib/logger'
import { buildRecentQuery } from '@/queries'
jest.mock('cozy-client', () => ({
useClient: jest.fn()
}))
jest.mock('cozy-dataproxy-lib', () => ({
useDataProxy: jest.fn()
}))
jest.mock('@/lib/logger', () => ({
warn: jest.fn()
}))
jest.mock('@/queries', () => ({
buildRecentQuery: jest.fn()
}))
const mockUseClient = useClient
const mockUseDataProxy = useDataProxy
const mockBuildRecentQuery = buildRecentQuery
describe('useDataProxyRecents', () => {
let mockClient
beforeEach(() => {
jest.clearAllMocks()
mockClient = {
fetchQueryAndGetFromState: jest.fn()
}
mockUseClient.mockReturnValue(mockClient)
mockBuildRecentQuery.mockReturnValue({
definition: jest.fn(() => ({})),
options: {}
})
})
describe('when dataProxy is available and succeeds', () => {
it('should return data from dataProxy', async () => {
const mockData = [
{ id: '1', name: 'file1' },
{ id: '2', name: 'file2' }
]
const mockDataProxy = {
dataProxyServicesAvailable: true,
recents: jest.fn().mockResolvedValue(mockData)
}
mockUseDataProxy.mockReturnValue(mockDataProxy)
const { result } = renderHook(() => useDataProxyRecents())
expect(result.current.fetchStatus).toBe('loading')
expect(result.current.data).toEqual([])
await waitFor(() => expect(result.current.fetchStatus).toBe('loaded'))
expect(result.current.data).toEqual(mockData)
expect(result.current.error).toBe(null)
expect(mockDataProxy.recents).toHaveBeenCalledTimes(1)
expect(mockClient.fetchQueryAndGetFromState).not.toHaveBeenCalled()
expect(logger.warn).not.toHaveBeenCalled()
})
})
describe('when dataProxy throws an error', () => {
it('should use fallback query when dataProxy fails', async () => {
const mockError = new Error('DataProxy error')
const mockDataProxy = {
dataProxyServicesAvailable: true,
recents: jest.fn().mockRejectedValue(mockError)
}
const fallbackData = [
{ id: '3', name: 'file3' },
{ id: '4', name: 'file4' }
]
mockUseDataProxy.mockReturnValue(mockDataProxy)
mockClient.fetchQueryAndGetFromState.mockResolvedValue({
data: fallbackData
})
const { result } = renderHook(() => useDataProxyRecents())
expect(result.current.fetchStatus).toBe('loading')
expect(result.current.data).toEqual([])
// Wait for fallback query to complete
await waitFor(() => expect(result.current.fetchStatus).toBe('loaded'))
expect(result.current.data).toEqual(fallbackData)
expect(result.current.error).toBe(null)
expect(logger.warn).toHaveBeenCalledWith(
'Error fetching recents from dataproxy',
mockError
)
expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1)
expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledWith({
definition: expect.any(Object),
options: expect.any(Object)
})
})
it('should handle fallback query error', async () => {
const mockError = new Error('DataProxy error')
const fallbackError = new Error('Fallback query error')
const mockDataProxy = {
dataProxyServicesAvailable: true,
recents: jest.fn().mockRejectedValue(mockError)
}
mockUseDataProxy.mockReturnValue(mockDataProxy)
mockClient.fetchQueryAndGetFromState.mockRejectedValue(fallbackError)
const { result } = renderHook(() => useDataProxyRecents())
// Wait for fallback query error to be processed
await waitFor(() => expect(result.current.fetchStatus).toBe('error'))
expect(result.current.error).toEqual(fallbackError)
expect(logger.warn).toHaveBeenCalledWith(
'Error fetching recents from dataproxy',
mockError
)
expect(logger.warn).toHaveBeenCalledWith(
'Error fetching recents from fallback query',
fallbackError
)
expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1)
})
})
describe('when dataProxy is not available', () => {
it('should use fallback query when dataProxy is not available', async () => {
const mockDataProxy = {
dataProxyServicesAvailable: false
}
const fallbackData = [
{ id: '5', name: 'file5' },
{ id: '6', name: 'file6' }
]
mockUseDataProxy.mockReturnValue(mockDataProxy)
mockClient.fetchQueryAndGetFromState.mockResolvedValue({
data: fallbackData
})
const { result } = renderHook(() => useDataProxyRecents())
// When dataProxy is not available, the hook should execute fallback query
expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1)
// Wait for fallback query to complete
await waitFor(() => expect(result.current.fetchStatus).toBe('loaded'))
expect(result.current.data).toEqual(fallbackData)
expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledWith({
definition: expect.any(Object),
options: expect.any(Object)
})
})
it('should handle fallback query loading state', async () => {
const mockDataProxy = {
dataProxyServicesAvailable: false
}
mockUseDataProxy.mockReturnValue(mockDataProxy)
// Don't resolve the query immediately to test loading state
mockClient.fetchQueryAndGetFromState.mockImplementation(
() => new Promise(() => {}) // Never resolves
)
const { result } = renderHook(() => useDataProxyRecents())
expect(result.current.fetchStatus).toBe('loading')
expect(result.current.data).toEqual([])
expect(result.current.error).toBe(null)
expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1)
})
})
describe('when client is not available', () => {
it('should set error when client is not available', async () => {
const mockDataProxy = {
dataProxyServicesAvailable: false
}
mockUseDataProxy.mockReturnValue(mockDataProxy)
mockUseClient.mockReturnValue(null)
const { result } = renderHook(() => useDataProxyRecents())
// Wait for error to be set
await waitFor(() => {
expect(result.current.fetchStatus).toBe('error')
})
expect(result.current.error).toEqual(new Error('Client not available'))
expect(result.current.data).toEqual([])
expect(mockClient.fetchQueryAndGetFromState).not.toHaveBeenCalled()
})
})
})
================================================
FILE: src/hooks/useRecentIcons.jsx
================================================
import { useState, useEffect } from 'react'
import logger from '@/lib/logger'
const STORAGE_KEY = 'iconPicker_recent_icons'
const MAX_RECENT_ICONS = 8
/**
* Hook to get recent icons from localStorage
* @returns {string[]} recentIcons - List of recently used icon names
*/
export const useRecentIcons = () => {
const [recentIcons, setRecentIcons] = useState(null)
useEffect(() => {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY))
// eslint-disable-next-line react-hooks/set-state-in-effect
setRecentIcons(Array.isArray(parsed) ? parsed : [])
} catch (error) {
logger.error('Failed to load recent icons from localStorage:', error)
setRecentIcons([])
}
}, [])
return recentIcons
}
/**
* Add an icon to the recent icons list (for use outside of React components)
* This function directly updates localStorage and can be called from anywhere
* @param {string} iconName - Name of the icon to add
*/
export const addRecentIcon = iconName => {
if (!iconName || iconName === 'none') return
try {
const stored = localStorage.getItem(STORAGE_KEY)
let current = []
if (stored) {
const parsed = JSON.parse(stored)
current = Array.isArray(parsed) ? parsed : []
}
// Remove icon if it already exists and add it at the beginning
const filtered = current.filter(icon => icon !== iconName)
const updated = [iconName, ...filtered].slice(0, MAX_RECENT_ICONS)
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
} catch (error) {
logger.error('Failed to save recent icons to localStorage:', error)
}
}
================================================
FILE: src/hooks/useRecentIcons.spec.jsx
================================================
import { renderHook, act } from '@testing-library/react'
import { useRecentIcons, addRecentIcon } from './useRecentIcons'
import logger from '@/lib/logger'
jest.mock('@/lib/logger', () => ({
error: jest.fn()
}))
const STORAGE_KEY = 'iconPicker_recent_icons'
const MAX_RECENT_ICONS = 8
describe('useRecentIcons', () => {
beforeEach(() => {
jest.clearAllMocks()
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('should return [] initially', () => {
const { result } = renderHook(() => useRecentIcons())
expect(result.current).toEqual([])
})
it('should return [] when localStorage is empty', () => {
const { result } = renderHook(() => useRecentIcons())
expect(result.current).toEqual([])
})
it('should return parsed array when localStorage has valid data', async () => {
const icons = ['icon1', 'icon2', 'icon3']
localStorage.setItem(STORAGE_KEY, JSON.stringify(icons))
let result
await act(async () => {
const hook = renderHook(() => useRecentIcons())
result = hook.result
})
expect(result.current).toEqual(icons)
})
it('should return empty array when localStorage has invalid JSON', async () => {
localStorage.setItem(STORAGE_KEY, 'invalid json')
let result
await act(async () => {
const hook = renderHook(() => useRecentIcons())
result = hook.result
})
expect(result.current).toEqual([])
expect(logger.error).toHaveBeenCalledWith(
'Failed to load recent icons from localStorage:',
expect.any(SyntaxError)
)
})
it('should return empty array when localStorage has non-array data', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: 'an array' }))
let result
await act(async () => {
const hook = renderHook(() => useRecentIcons())
result = hook.result
})
expect(result.current).toEqual([])
})
it('should handle localStorage.getItem errors gracefully', async () => {
const error = new Error('localStorage error')
const getItemSpy = jest
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => {
throw error
})
let result
await act(async () => {
const hook = renderHook(() => useRecentIcons())
result = hook.result
// Wait a bit for useEffect to run and state to update
await new Promise(resolve => setTimeout(resolve, 10))
})
expect(result.current).toEqual([])
expect(logger.error).toHaveBeenCalledWith(
'Failed to load recent icons from localStorage:',
error
)
getItemSpy.mockRestore()
})
})
describe('addRecentIcon', () => {
beforeEach(() => {
jest.clearAllMocks()
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('should do nothing when iconName is falsy', () => {
addRecentIcon(null)
addRecentIcon(undefined)
addRecentIcon('')
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('should do nothing when iconName is "none"', () => {
addRecentIcon('none')
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('should add icon to empty localStorage', () => {
addRecentIcon('icon1')
const stored = localStorage.getItem(STORAGE_KEY)
expect(stored).toBe(JSON.stringify(['icon1']))
})
it('should add icon to existing localStorage', () => {
const existingIcons = ['icon1', 'icon2']
localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons))
addRecentIcon('icon3')
const stored = localStorage.getItem(STORAGE_KEY)
expect(stored).toBe(JSON.stringify(['icon3', 'icon1', 'icon2']))
})
it('should move existing icon to the beginning', () => {
const existingIcons = ['icon1', 'icon2', 'icon3']
localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons))
addRecentIcon('icon2')
const stored = localStorage.getItem(STORAGE_KEY)
expect(stored).toBe(JSON.stringify(['icon2', 'icon1', 'icon3']))
})
it('should limit to MAX_RECENT_ICONS', () => {
const existingIcons = Array.from(
{ length: MAX_RECENT_ICONS },
(_, i) => `icon${i + 1}`
)
localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons))
addRecentIcon('newIcon')
const stored = localStorage.getItem(STORAGE_KEY)
const parsed = JSON.parse(stored)
expect(parsed).toHaveLength(MAX_RECENT_ICONS)
expect(parsed[0]).toBe('newIcon')
expect(parsed).not.toContain(existingIcons[existingIcons.length - 1])
})
it('should handle localStorage.getItem errors gracefully', () => {
const error = new Error('localStorage error')
const getItemSpy = jest
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => {
throw error
})
addRecentIcon('icon1')
expect(logger.error).toHaveBeenCalledWith(
'Failed to save recent icons to localStorage:',
error
)
getItemSpy.mockRestore()
})
it('should handle localStorage.setItem errors gracefully', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(['icon1']))
const error = new Error('localStorage setItem error')
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw error
})
addRecentIcon('icon2')
expect(logger.error).toHaveBeenCalledWith(
'Failed to save recent icons to localStorage:',
error
)
setItemSpy.mockRestore()
})
it('should handle invalid JSON in localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'invalid json')
addRecentIcon('icon1')
// When JSON.parse fails, error is caught and logged, but localStorage is not updated
expect(logger.error).toHaveBeenCalledWith(
'Failed to save recent icons to localStorage:',
expect.any(SyntaxError)
)
// localStorage still contains the invalid JSON because the error happened before setItem
const stored = localStorage.getItem(STORAGE_KEY)
expect(stored).toBe('invalid json')
})
it('should handle non-array data in localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: 'an array' }))
addRecentIcon('icon1')
const stored = localStorage.getItem(STORAGE_KEY)
expect(stored).toBe(JSON.stringify(['icon1']))
})
it('should maintain order when adding same icon multiple times', () => {
addRecentIcon('icon1')
addRecentIcon('icon2')
addRecentIcon('icon3')
addRecentIcon('icon1')
const stored = localStorage.getItem(STORAGE_KEY)
expect(stored).toBe(JSON.stringify(['icon1', 'icon3', 'icon2']))
})
})
================================================
FILE: src/hooks/useRedirectLink.jsx
================================================
import { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import {
useClient,
generateWebLink,
deconstructRedirectLink
} from 'cozy-client'
import { changeLocation } from '@/hooks/helpers'
import logger from '@/lib/logger'
/**
* @typedef {object} ReturnRedirectLink
* @property {string} redirectLink - The redirect link
* @property {function} redirectBack - The function to redirect the user
* @property {boolean} canRedirect - True if the user can be redirected
*/
/**
* This hook is used to redirect from an OnlyOffice file
* @param {boolean} isPublic - true if the file is public
* @returns {ReturnRedirectLink} - The redirect link and the function to redirect from an OnlyOffice file
*/
const useRedirectLink = ({ isPublic = false } = {}) => {
const [searchParams] = useSearchParams()
const params = new URLSearchParams(location.search)
const client = useClient()
const navigate = useNavigate()
const isFromPublicFolder = searchParams.get('fromPublicFolder') === 'true'
const [currentMemberInstance, setCurrentMemberInstance] = useState(undefined)
useEffect(() => {
const fetch = async () => {
try {
const permissions = await client
.collection('io.cozy.permissions')
.fetchOwnPermissions()
// We gets in included the member of the sharing, corresponding to the user who accessed the file
// If the file is open on the instance of the share owner, we can retrieve the link to his instance
if (permissions.included?.length > 0) {
setCurrentMemberInstance(permissions.included[0].attributes?.instance)
}
} catch {
logger.warn('Cannot fetch permissions')
}
}
if (isPublic && !isFromPublicFolder) {
fetch()
}
}, [client, isPublic, isFromPublicFolder])
/**
* We search for redirectLink using two methods because
* the searchParam differs depending on the position in the url :
* - for /#hash?searchParam, you need useSearchParams
* - for /?searchParam#hash, you need location.search
*/
const redirectLink =
searchParams.get('redirectLink') || params.get('redirectLink')
const redirectBack = () => {
if (!redirectLink) {
return logger.warn('Cannot find a redirect link')
}
const { slug, pathname, hash } = deconstructRedirectLink(redirectLink)
// As we navigate in the same instance, we can use the react-router-dom navigate
if (!isPublic || isFromPublicFolder) {
return navigate(hash)
}
// If the file is open on the instance of the share owner, we can redirect the user to his instance
if (currentMemberInstance) {
try {
const { subdomain: subDomainType } = client.getInstanceOptions()
const link = generateWebLink({
cozyUrl: currentMemberInstance,
subDomainType,
slug,
pathname,
hash
})
return changeLocation(link)
} catch (e) {
logger.error(`Cannot generate a web link : ${e}`)
}
}
/**
* If file is not open in new tab, we can redirect the user to the previous page
* There is a double redirection for public file :
* 1. To know that the file is a share, the other
* 2. To open it on the host instance
* so there is an additional entry in the history to skip to access the previous page
*/
if (window.history.length > 2) {
return navigate(-2)
}
// We do nothing because we don't know where to redirect the user
}
const canRedirect =
!!redirectLink &&
(!isPublic ||
isFromPublicFolder ||
!!currentMemberInstance ||
window.history.length > 2)
return {
redirectLink,
redirectBack,
canRedirect
}
}
export { useRedirectLink }
================================================
FILE: src/hooks/useRedirectLink.spec.jsx
================================================
import { renderHook, act } from '@testing-library/react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useClient } from 'cozy-client'
import * as helpers from './helpers'
import { useRedirectLink } from './useRedirectLink'
jest.mock('cozy-client', () => ({
...jest.requireActual('cozy-client'),
useClient: jest.fn()
}))
jest.mock('react-router-dom', () => ({
useSearchParams: jest.fn(),
useNavigate: jest.fn()
}))
const originalHistory = window.history
describe('useRedirectLink', () => {
const mockClient = {
collection: jest.fn().mockReturnValue({
fetchOwnPermissions: jest.fn().mockResolvedValue({
included: []
})
}),
getStackClient: jest.fn().mockReturnValue({
uri: 'https://my.cozy.cloud'
}),
getInstanceOptions: jest.fn().mockReturnValue({
subdomain: 'flat'
})
}
const mockNavigate = jest.fn()
beforeEach(() => {
useClient.mockReturnValue(mockClient)
useSearchParams.mockReturnValue([
new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123')
])
useNavigate.mockReturnValue(mockNavigate)
})
afterEach(() => {
window.history = originalHistory
jest.clearAllMocks()
})
it('should redirect with navigate when is not public', async () => {
let render
await act(async () => {
render = renderHook(() => useRedirectLink())
})
render.result.current.redirectBack()
expect(mockNavigate).toHaveBeenCalledWith('/folder/id123')
expect(render.result.current.redirectLink).toBe('drive#/folder/id123')
expect(render.result.current.canRedirect).toBe(true)
})
it('should redirect with navigate when is from a public folder', async () => {
useSearchParams.mockReturnValue([
new URLSearchParams(
'?redirectLink=drive%23%2Ffolder%2Fid123&fromPublicFolder=true'
)
])
let render
await act(async () => {
render = renderHook(() => useRedirectLink({ isPublic: true }))
})
render.result.current.redirectBack()
expect(mockNavigate).toHaveBeenCalledWith('/folder/id123')
expect(render.result.current.redirectLink).toBe('drive#/folder/id123')
expect(render.result.current.canRedirect).toBe(true)
})
it('should redirect with window.location in public when instance is known', async () => {
const spyChangeLocation = jest
.spyOn(helpers, 'changeLocation')
.mockImplementationOnce(() => {})
mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({
included: [
{
attributes: {
instance: 'https://other.cozy.cloud'
}
}
]
})
useSearchParams.mockReturnValue([
new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123')
])
let render
await act(async () => {
render = renderHook(() => useRedirectLink({ isPublic: true }))
})
render.result.current.redirectBack()
expect(mockNavigate).toHaveBeenCalledTimes(0)
expect(spyChangeLocation).toHaveBeenCalledWith(
'https://other-drive.cozy.cloud/#/folder/id123'
)
expect(render.result.current.redirectLink).toBe('drive#/folder/id123')
expect(render.result.current.canRedirect).toBe(true)
})
it('should redirect with navigate(-2) in public when the instance is unknown', async () => {
delete window.history
window.history = Object.defineProperties(
{},
{
...Object.getOwnPropertyDescriptors(originalHistory),
length: {
configurable: true,
value: 3
}
}
)
mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({
included: [
{
attributes: {}
}
]
})
useSearchParams.mockReturnValue([
new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123')
])
let render
await act(async () => {
render = renderHook(() => useRedirectLink({ isPublic: true }))
})
render.result.current.redirectBack()
expect(mockNavigate).toHaveBeenCalledWith(-2)
expect(render.result.current.redirectLink).toBe('drive#/folder/id123')
expect(render.result.current.canRedirect).toBe(true)
})
it('should do nothing when the instance is unknown and the page is opened in new tab', async () => {
mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({
included: [
{
attributes: {}
}
]
})
useSearchParams.mockReturnValue([
new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123')
])
let render
await act(async () => {
render = renderHook(() => useRedirectLink({ isPublic: true }))
})
render.result.current.redirectBack()
expect(mockNavigate).toHaveBeenCalledTimes(0)
expect(render.result.current.redirectLink).toBe('drive#/folder/id123')
expect(render.result.current.canRedirect).toBe(false)
})
})
================================================
FILE: src/hooks/useShiftSelection/helpers.spec.ts
================================================
import { IOCozyFile } from 'cozy-client/types/types'
import {
handleShiftArrow,
handleShiftClick,
FORWARD_DIRECTION,
BACKWARD_DIRECTION,
HandleShiftArrowParams,
HandleShiftClickParams
} from './helpers'
import { SelectedItems } from '@/modules/selection/types'
const createMockFile = (id: string, name = `file-${id}`): IOCozyFile =>
({
_id: id,
_type: 'io.cozy.files',
name,
type: 'file',
dir_id: 'root',
created_at: '2023-01-01T00:00:00.000Z',
updated_at: '2023-01-01T00:00:00.000Z',
size: 1000,
mime: 'text/plain',
class: 'text',
executable: false
}) as IOCozyFile
const mockFiles: IOCozyFile[] = [
createMockFile('1', 'file1.txt'),
createMockFile('2', 'file2.txt'),
createMockFile('3', 'file3.txt'),
createMockFile('4', 'file4.txt'),
createMockFile('5', 'file5.txt')
]
describe('handleShiftArrow', () => {
let mockIsItemSelected: jest.Mock
beforeEach(() => {
mockIsItemSelected = jest.fn()
})
afterEach(() => {
jest.clearAllMocks()
})
describe('when no items are selected', () => {
it('should select the first item when moving forward', () => {
const params: HandleShiftArrowParams = {
direction: FORWARD_DIRECTION,
items: mockFiles,
selectedItems: {},
lastInteractedIdx: 0,
isItemSelected: mockIsItemSelected
}
const result = handleShiftArrow(params)
expect(result).toEqual({
newSelectedItems: { '1': mockFiles[0] },
lastInteractedItemId: '1'
})
})
it('should select the last item when moving backward', () => {
const params: HandleShiftArrowParams = {
direction: BACKWARD_DIRECTION,
items: mockFiles,
selectedItems: {},
lastInteractedIdx: 0,
isItemSelected: mockIsItemSelected
}
const result = handleShiftArrow(params)
expect(result).toEqual({
newSelectedItems: { '5': mockFiles[4] },
lastInteractedItemId: '5'
})
})
})
describe('when items are already selected', () => {
it('should extend selection forward when moving from selected to unselected item', () => {
const selectedItems: SelectedItems = { '2': mockFiles[1] }
mockIsItemSelected.mockImplementation((id: string) => {
if (id === '2') return true // Previous item is selected
if (id === '3') return false // Current item is not selected
return false
})
const params: HandleShiftArrowParams = {
direction: FORWARD_DIRECTION,
items: mockFiles,
selectedItems,
lastInteractedIdx: 1,
isItemSelected: mockIsItemSelected
}
const result = handleShiftArrow(params)
expect(result.newSelectedItems).toEqual({
'2': mockFiles[1],
'3': mockFiles[2]
})
expect(result.lastInteractedItemId).toBe('3')
})
it('should contract selection when moving from selected to selected item', () => {
const selectedItems: SelectedItems = {
'1': mockFiles[0],
'2': mockFiles[1],
'3': mockFiles[2]
}
mockIsItemSelected.mockImplementation((id: string) => {
return ['1', '2', '3'].includes(id)
})
const params: HandleShiftArrowParams = {
direction: BACKWARD_DIRECTION,
items: mockFiles,
selectedItems,
lastInteractedIdx: 2,
isItemSelected: mockIsItemSelected
}
const result = handleShiftArrow(params)
expect(result.newSelectedItems).toEqual({
'1': mockFiles[0],
'2': mockFiles[1]
})
expect(result.lastInteractedItemId).toBe('2')
})
it('should handle boundary conditions at the start of the list', () => {
const selectedItems: SelectedItems = { '1': mockFiles[0] }
mockIsItemSelected.mockImplementation((id: string) => id === '1')
const params: HandleShiftArrowParams = {
direction: BACKWARD_DIRECTION,
items: mockFiles,
selectedItems,
lastInteractedIdx: 0,
isItemSelected: mockIsItemSelected
}
const result = handleShiftArrow(params)
expect(result.newSelectedItems).toEqual({})
expect(result.lastInteractedItemId).toBe('1')
})
it('should handle boundary conditions at the end of the list', () => {
const selectedItems: SelectedItems = { '5': mockFiles[4] }
mockIsItemSelected.mockImplementation((id: string) => id === '5')
const params: HandleShiftArrowParams = {
direction: FORWARD_DIRECTION,
items: mockFiles,
selectedItems,
lastInteractedIdx: 4,
isItemSelected: mockIsItemSelected
}
const result = handleShiftArrow(params)
expect(result.newSelectedItems).toEqual({})
expect(result.lastInteractedItemId).toBe('5')
})
})
})
describe('handleShiftClick', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('range selection behavior', () => {
it('should select all items in range when end item is not selected', () => {
const selectedItems: SelectedItems = {}
const params: HandleShiftClickParams = {
startIdx: 1,
endIdx: 3,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'2': mockFiles[1],
'3': mockFiles[2],
'4': mockFiles[3]
},
lastInteractedItemId: '4'
})
})
it('should deselect all items in range when end item is selected', () => {
const selectedItems: SelectedItems = {
'1': mockFiles[0],
'2': mockFiles[1],
'3': mockFiles[2],
'4': mockFiles[3],
'5': mockFiles[4]
}
const params: HandleShiftClickParams = {
startIdx: 1,
endIdx: 3,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'1': mockFiles[0],
'5': mockFiles[4]
},
lastInteractedItemId: '4'
})
})
it('should handle reverse range selection (endIdx < startIdx)', () => {
const selectedItems: SelectedItems = {}
const params: HandleShiftClickParams = {
startIdx: 3,
endIdx: 1,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'2': mockFiles[1],
'3': mockFiles[2],
'4': mockFiles[3]
},
lastInteractedItemId: '2'
})
})
it('should handle single item selection (startIdx === endIdx)', () => {
const selectedItems: SelectedItems = {}
const params: HandleShiftClickParams = {
startIdx: 2,
endIdx: 2,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'3': mockFiles[2]
},
lastInteractedItemId: '3'
})
})
})
describe('boundary conditions', () => {
it('should handle selection at the beginning of the list', () => {
const selectedItems: SelectedItems = {}
const params: HandleShiftClickParams = {
startIdx: 0,
endIdx: 2,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'1': mockFiles[0],
'2': mockFiles[1],
'3': mockFiles[2]
},
lastInteractedItemId: '3'
})
})
it('should handle selection at the end of the list', () => {
const selectedItems: SelectedItems = {}
const params: HandleShiftClickParams = {
startIdx: 2,
endIdx: 4,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'3': mockFiles[2],
'4': mockFiles[3],
'5': mockFiles[4]
},
lastInteractedItemId: '5'
})
})
it('should handle full list selection', () => {
const selectedItems: SelectedItems = {}
const params: HandleShiftClickParams = {
startIdx: 0,
endIdx: 4,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'1': mockFiles[0],
'2': mockFiles[1],
'3': mockFiles[2],
'4': mockFiles[3],
'5': mockFiles[4]
},
lastInteractedItemId: '5'
})
})
})
describe('mixed selection scenarios', () => {
it('should handle partial existing selection', () => {
const selectedItems: SelectedItems = {
'1': mockFiles[0],
'5': mockFiles[4]
}
const params: HandleShiftClickParams = {
startIdx: 1,
endIdx: 3,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'1': mockFiles[0],
'2': mockFiles[1],
'3': mockFiles[2],
'4': mockFiles[3],
'5': mockFiles[4]
},
lastInteractedItemId: '4'
})
})
it('should preserve items outside the range when deselecting', () => {
const selectedItems: SelectedItems = {
'1': mockFiles[0],
'2': mockFiles[1],
'3': mockFiles[2],
'4': mockFiles[3],
'5': mockFiles[4]
}
const params: HandleShiftClickParams = {
startIdx: 1,
endIdx: 2,
selectedItems,
items: mockFiles
}
const result = handleShiftClick(params)
expect(result).toEqual({
newSelectedItems: {
'1': mockFiles[0],
'4': mockFiles[3],
'5': mockFiles[4]
},
lastInteractedItemId: '3'
})
})
})
})
================================================
FILE: src/hooks/useShiftSelection/helpers.ts
================================================
import cloneDeep from 'lodash/cloneDeep'
import { IOCozyFile } from 'cozy-client/types/types'
import type { SelectedItems } from '@/modules/selection/types'
export const FORWARD_DIRECTION = 1 as const
export const BACKWARD_DIRECTION = -1 as const
interface HandleShiftSelectionResponse {
newSelectedItems: SelectedItems
lastInteractedItemId: string
}
interface FindNextBoundaryIndexParams {
items: IOCozyFile[]
startIdx: number
direction: typeof FORWARD_DIRECTION | typeof BACKWARD_DIRECTION
isMovingToSelect: boolean
isReturnCurrent: boolean
isItemSelected: (id: string) => boolean
}
interface ToggleSelectionParams {
items: IOCozyFile[]
selectedItems: SelectedItems
currentIdx: number
lastInteractedIdx: number
isMovingToSelect: boolean
isItemSelected: (id: string) => boolean
}
export interface HandleShiftArrowParams {
direction: typeof FORWARD_DIRECTION | typeof BACKWARD_DIRECTION
items: IOCozyFile[]
selectedItems: SelectedItems
lastInteractedIdx: number
isItemSelected: (id: string) => boolean
}
export interface HandleShiftClickParams {
startIdx: number
endIdx: number
selectedItems: SelectedItems
items: IOCozyFile[]
}
/**
* Clamps an index value to be within valid array bounds.
* @param {number} maxLength The maximum length of the array
* @param {number} index The index to clamp
*
* @returns {number} The clamped index value between 0 and maxLength-1
*/
const clamp = (maxLength: number, index: number): number =>
Math.max(0, Math.min(maxLength - 1, index))
/**
* Find the next index (in given direction) where selection state flips.
* This defines the next "boundary" for select/deselect operations.
* Used to determine where to stop when selecting or deselecting.
*
* @param {FindNextBoundaryIndexParams} params The parameters object
* @param {IOCozyFile[]} params.items Array of all available items
* @param {number} params.startIdx Starting index to search from
* @param {number} params.direction Direction to search (1 for forward, -1 for backward)
* @param {boolean} params.isMovingToSelect Whether we're moving to select or deselect items
* @param {boolean} params.isReturnCurrent Determine if we have to find next index or not
* @param {function} params.isItemSelected Function to check if an item is selected
*
* @returns {number} The index of the next boundary where selection state changes
*/
const findNextBoundaryIndex = ({
items,
startIdx,
direction,
isMovingToSelect,
isReturnCurrent,
isItemSelected
}: FindNextBoundaryIndexParams): number => {
if (isReturnCurrent) return startIdx
let idx = startIdx + direction
while (
idx >= 0 &&
idx < items.length &&
isMovingToSelect === isItemSelected(items[idx]?._id)
) {
idx += direction
}
return clamp(items.length, idx - direction)
}
/**
* Toggles the selection state of items based on keyboard navigation.
* Handles the complex logic of selection or deselecting selections during Shift+Arrow operations.
*
* @param {ToggleSelectionParams} params The parameters object
* @param {IOCozyFile[]} params.items Array of all available items
* @param {SelectedItems} params.selectedItems Current selected items object
* @param {number} params.currentIdx Current index being navigated to
* @param {number} params.lastInteractedIdx Index of the last interacted item
* @param {boolean} params.isMovingToSelect Whether we're moving to select or deselect items
* @param {function} params.isItemSelected Function to check if an item is selected
*
* @returns {SelectedItems}
*/
const toggleSelection = ({
items,
selectedItems,
currentIdx,
lastInteractedIdx,
isMovingToSelect,
isItemSelected
}: ToggleSelectionParams): SelectedItems => {
// Identify which item to modify (depends on selection direction)
const targetItem = isMovingToSelect
? items[currentIdx]
: isItemSelected(items[lastInteractedIdx]._id)
? items[lastInteractedIdx]
: items[currentIdx]
return Object.entries(selectedItems).reduce(
(acc, [key, value]) => {
if (isMovingToSelect) {
acc[key] = value
acc[targetItem._id] = targetItem
} else {
if (key !== targetItem._id) {
acc[key] = value
}
}
return acc
},
{}
)
}
/**
* Handle Shift + Arrow keyboard selection.
* - If no items are selected, selects the first/last item based on direction
* - Return selected items based on selection state
* - Return focus position for continued navigation
*
* @param {HandleShiftArrowParams} params The parameters object
* @param {number} params.direction Direction of arrow key (FORWARD_DIRECTION or BACKWARD_DIRECTION)
* @param {IOCozyFile[]} [params.items] Array of all available items (defaults to empty array)
* @param {SelectedItems} [params.selectedItems] Current selected items object (defaults to empty object)
* @param {number} params.lastInteractedIdx Index of the last interacted item
* @param {function} params.isItemSelected Function to check if an item is selected by _id
*
* @returns {HandleShiftSelectionResponse}
*/
export const handleShiftArrow = ({
direction,
items,
selectedItems = {},
lastInteractedIdx,
isItemSelected
}: HandleShiftArrowParams): HandleShiftSelectionResponse => {
if (Object.keys(selectedItems).length === 0) {
const autoSelectedItem =
direction === FORWARD_DIRECTION ? items[0] : items[items.length - 1]
return {
newSelectedItems: {
[autoSelectedItem._id]: autoSelectedItem
},
lastInteractedItemId: autoSelectedItem._id
}
}
const nextIdx = lastInteractedIdx + direction
const currentIdx = clamp(items.length, nextIdx)
const prevSelected = isItemSelected(items[lastInteractedIdx]?._id)
const currSelected = isItemSelected(items[currentIdx]?._id)
const isMovingToSelect = prevSelected && !currSelected
const newSelectedItems = toggleSelection({
items,
selectedItems,
currentIdx,
lastInteractedIdx,
isMovingToSelect,
isItemSelected
})
// Updates focus position for continued navigation
const finalIdx = findNextBoundaryIndex({
items,
startIdx: currentIdx,
direction,
isMovingToSelect,
isItemSelected,
isReturnCurrent: Object.keys(newSelectedItems).length <= 1
})
return {
newSelectedItems,
lastInteractedItemId: items[finalIdx]._id
}
}
/**
* Handle Shift + Click range selection.
* - Selects all items in range if end item is not selected
* - Deselects all items in range if end item is already selected
* - Handles reverse ranges (endIdx < startIdx) automatically
* - Return the last interacted item to the clicked item and new selections
*
* @param {HandleShiftClickParams} params The parameters object
* @param {number} params.startIdx Starting index of the selection range (last interacted item)
* @param {number} params.endIdx Ending index of the selection range (last clicked item)
* @param {SelectedItems} params.selectedItems Current selected items object
* @param {IOCozyFile[]} params.items Array of all available items
*
* @returns {HandleShiftSelectionResponse}
*/
export const handleShiftClick = ({
startIdx,
endIdx,
selectedItems,
items
}: HandleShiftClickParams): HandleShiftSelectionResponse => {
const endItem = items[endIdx]
const isMovingToSelect = !Object.hasOwn(selectedItems, endItem._id)
const start = Math.min(startIdx, endIdx)
const end = Math.max(startIdx, endIdx)
const newSelectedItems = Array.from(
{ length: end - start + 1 },
(_, i) => start + i
).reduce((acc, i) => {
const item = items[i]
if (isMovingToSelect) {
acc[item._id] = item
} else {
const { [item._id]: _, ...rest } = acc
return rest
}
return acc
}, cloneDeep(selectedItems))
return {
newSelectedItems,
lastInteractedItemId: items[endIdx]._id
}
}
================================================
FILE: src/hooks/useShiftSelection/index.spec.tsx
================================================
import { renderHook, act } from '@testing-library/react'
import { RefObject } from 'react'
import { IOCozyFile } from 'cozy-client/types/types'
import * as helpers from './helpers'
import { useShiftSelection } from './index'
jest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({
__esModule: true,
default: (): { isMobile: boolean } => ({ isMobile: false })
}))
jest.mock('@/modules/selection/SelectionProvider', () => ({
useSelectionContext: jest.fn()
}))
jest.mock('./helpers', () => ({
handleShiftClick: jest.fn().mockReturnValue({
newSelectedItems: {},
lastInteractedItemId: '1'
}),
handleShiftArrow: jest.fn().mockReturnValue({
newSelectedItems: {},
lastInteractedItemId: '1'
}),
FORWARD_DIRECTION: 1,
BACKWARD_DIRECTION: -1
}))
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
const mockUseSelectionContext = useSelectionContext as jest.Mock
// Get references to mocked functions
const mockHandleShiftArrow = helpers.handleShiftArrow as jest.Mock
const mockHandleShiftClick = helpers.handleShiftClick as jest.Mock
const createMockFile = (id: string): IOCozyFile =>
({
_id: id,
name: `file-${id}`,
type: 'file'
}) as IOCozyFile
const mockFiles = [
createMockFile('1'),
createMockFile('2'),
createMockFile('3')
]
describe('useShiftSelection', () => {
let mockSetSelectedItems: jest.Mock
let mockIsItemSelected: jest.Mock
let mockRef: RefObject
let mockElement: HTMLElement
beforeEach(() => {
mockSetSelectedItems = jest.fn()
mockIsItemSelected = jest.fn()
mockElement = {
focus: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn()
} as unknown as HTMLElement
mockRef = { current: mockElement }
mockUseSelectionContext.mockReturnValue({
selectedItems: [],
setSelectedItems: mockSetSelectedItems,
isItemSelected: mockIsItemSelected,
setIsSelectAll: jest.fn()
})
jest.clearAllMocks()
})
describe('initialization', () => {
it('should return correct interface', () => {
const { result } = renderHook(() =>
useShiftSelection({ items: mockFiles }, mockRef)
)
expect(result.current).toHaveProperty('setLastInteractedItem')
expect(result.current).toHaveProperty('onShiftClick')
expect(typeof result.current.onShiftClick).toBe('function')
expect(typeof result.current.setLastInteractedItem).toBe('function')
})
})
describe('keyboard event handling - list view', () => {
it('should call handleShiftArrow on Shift+ArrowDown in list view', () => {
renderHook(() =>
useShiftSelection({ items: mockFiles, viewType: 'list' }, mockRef)
)
const keydownHandler = (
(mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[]
)[1] as (event: KeyboardEvent) => void
const mockEvent = {
shiftKey: true,
key: 'ArrowDown',
preventDefault: jest.fn()
} as unknown as KeyboardEvent
act(() => {
keydownHandler(mockEvent)
})
expect(mockHandleShiftArrow).toHaveBeenCalledWith({
direction: 1,
items: mockFiles,
selectedItems: {},
lastInteractedIdx: 0,
isItemSelected: mockIsItemSelected
})
})
it('should call handleShiftArrow on Shift+ArrowUp in list view', () => {
renderHook(() =>
useShiftSelection({ items: mockFiles, viewType: 'list' }, mockRef)
)
const keydownHandler = (
(mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[]
)[1] as (event: KeyboardEvent) => void
const mockEvent = {
shiftKey: true,
key: 'ArrowUp',
preventDefault: jest.fn()
} as unknown as KeyboardEvent
act(() => {
keydownHandler(mockEvent)
})
expect(mockHandleShiftArrow).toHaveBeenCalledWith(
expect.objectContaining({ direction: -1 })
)
})
})
describe('keyboard event handling - grid view', () => {
it('should call handleShiftArrow on Shift+ArrowRight in grid view', () => {
renderHook(() =>
useShiftSelection({ items: mockFiles, viewType: 'grid' }, mockRef)
)
const keydownHandler = (
(mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[]
)[1] as (event: KeyboardEvent) => void
const mockEvent = {
shiftKey: true,
key: 'ArrowRight',
preventDefault: jest.fn()
} as unknown as KeyboardEvent
act(() => {
keydownHandler(mockEvent)
})
expect(mockHandleShiftArrow).toHaveBeenCalledWith(
expect.objectContaining({ direction: 1 })
)
})
it('should call handleShiftArrow on Shift+ArrowLeft in grid view', () => {
renderHook(() =>
useShiftSelection({ items: mockFiles, viewType: 'grid' }, mockRef)
)
const keydownHandler = (
(mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[]
)[1] as (event: KeyboardEvent) => void
const mockEvent = {
shiftKey: true,
key: 'ArrowLeft',
preventDefault: jest.fn()
} as unknown as KeyboardEvent
act(() => {
keydownHandler(mockEvent)
})
expect(mockHandleShiftArrow).toHaveBeenCalledWith(
expect.objectContaining({ direction: -1 })
)
})
})
describe('onShiftClick', () => {
it('should call handleShiftClick when shift key is pressed', () => {
const { result } = renderHook(() =>
useShiftSelection({ items: mockFiles }, mockRef)
)
const mockEvent = {
shiftKey: true,
stopPropagation: jest.fn()
} as unknown as KeyboardEvent
act(() => {
result.current.onShiftClick('2', mockEvent)
})
expect(mockHandleShiftClick).toHaveBeenCalledWith({
startIdx: 0,
endIdx: 1,
selectedItems: {},
items: mockFiles
})
})
it('should not call handleShiftClick when shift key is not pressed', () => {
const { result } = renderHook(() =>
useShiftSelection({ items: mockFiles }, mockRef)
)
const mockEvent = {
shiftKey: false,
stopPropagation: jest.fn()
} as unknown as KeyboardEvent
act(() => {
result.current.onShiftClick('2', mockEvent)
})
expect(mockHandleShiftClick).not.toHaveBeenCalled()
})
})
})
================================================
FILE: src/hooks/useShiftSelection/index.tsx
================================================
/* eslint-disable react-hooks/refs */
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
RefObject
} from 'react'
import { IOCozyFile } from 'cozy-client/types/types'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import {
handleShiftClick,
handleShiftArrow,
BACKWARD_DIRECTION,
FORWARD_DIRECTION
} from './helpers'
import { isEditableTarget } from '@/hooks/helpers'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import { SelectedItems } from '@/modules/selection/types'
type ViewType = 'list' | 'grid'
interface UseShiftSelectionParams {
items: IOCozyFile[]
viewType?: ViewType
}
interface UseShiftSelectionReturn {
setLastInteractedItem: (id: string | null) => void
onShiftClick: (clickedItemId: string, event: KeyboardEvent) => void
}
/**
* Custom hook that provides shift-based range selection functionality for file/folder lists.
*
* This hook enables users to:
* - Select ranges of items using Shift+Click (from last interacted item to clicked item)
* - Navigate and extend selection using Shift+Arrow keys (direction depends on viewType)
*
* @param {UseShiftSelectionParams} params - Configuration object containing items and view type
* @param {IOCozyFile[]} params.items - Array of IOCozyFile objects to enable selection on
* @param {ViewType} params.viewType - View type ('list' or 'grid') that determines keyboard navigation behavior
* @param ref - React ref to the container element that should receive keyboard events
*
* @returns {UseShiftSelectionReturn}
*/
const useShiftSelection = (
{ items, viewType = 'list' }: UseShiftSelectionParams,
ref: RefObject
): UseShiftSelectionReturn => {
const { isMobile } = useBreakpoints()
const itemsRef = useRef([])
itemsRef.current = useMemo(() => items, [items])
const { selectedItems, setSelectedItems, isItemSelected, setIsSelectAll } =
useSelectionContext()
const [lastInteractedItem, setLastInteractedItem] = useState(
null
)
const lastInteractedIdx = useMemo(() => {
return lastInteractedItem
? itemsRef.current.findIndex(item => item._id === lastInteractedItem)
: 0
}, [lastInteractedItem])
const selectedItemMap: SelectedItems = useMemo(() => {
return selectedItems.reduce(
(prev: SelectedItems, cur: IOCozyFile) => ({
...prev,
[cur._id]: cur
}),
{}
)
}, [selectedItems])
/**
* Handles shift+click events for range selection.
*
* When shift key is held and an item is clicked, selects or deselects all items
* between the last interacted item and the clicked item (inclusive).
*
* @param {string} clickedItemId - ID of the item that was clicked
* @param {KeyboardEvent} event - The keyboard event (must have shiftKey = true)
*/
const onShiftClick = useCallback(
(clickedItemId: string, event: KeyboardEvent) => {
if (!event.shiftKey) return
event.stopPropagation()
const endIdx = items.findIndex(item => item._id === clickedItemId)
const { newSelectedItems, lastInteractedItemId } = handleShiftClick({
startIdx: lastInteractedIdx,
endIdx,
selectedItems: selectedItemMap,
items
})
setSelectedItems(newSelectedItems)
setLastInteractedItem(lastInteractedItemId)
setIsSelectAll(
Object.keys(newSelectedItems).length === itemsRef.current.length
)
},
[
items,
lastInteractedIdx,
selectedItemMap,
setSelectedItems,
setIsSelectAll,
setLastInteractedItem
]
)
/**
* Handles keyboard events for shift+arrow navigation.
*
* Listens for shift+arrow key combinations and extends/contracts selection
* based on the navigation direction. The specific arrow keys depend on viewType:
* - List view: ArrowUp (backward) / ArrowDown (forward)
* - Grid view: ArrowLeft (backward) / ArrowRight (forward)
*
* @param {KeyboardEvent} event - The keyboard event to handle
*/
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!event.shiftKey) return
const key = event.key
const isListKey =
viewType === 'list' && ['ArrowUp', 'ArrowDown'].includes(key)
const isGridKey =
viewType === 'grid' && ['ArrowLeft', 'ArrowRight'].includes(key)
if (!isListKey && !isGridKey) return
event.preventDefault()
const direction =
key === 'ArrowUp' || key === 'ArrowLeft'
? BACKWARD_DIRECTION
: FORWARD_DIRECTION
const { newSelectedItems, lastInteractedItemId } = handleShiftArrow({
direction,
items: itemsRef.current,
selectedItems: selectedItemMap,
lastInteractedIdx,
isItemSelected
})
setSelectedItems(newSelectedItems)
setLastInteractedItem(lastInteractedItemId)
setIsSelectAll(selectedItems.length === itemsRef.current.length)
},
[
viewType,
selectedItemMap,
lastInteractedIdx,
selectedItems.length,
setSelectedItems,
isItemSelected,
setIsSelectAll,
setLastInteractedItem
]
)
/**
* Sets up keyboard event listeners on the container element.
*
* - Focuses the container to ensure it can receive keyboard events
* - Adds keydown event listener for shift+arrow navigation
* - Skips setup on mobile devices or when no items/container available
*/
useEffect(() => {
if (isMobile || !itemsRef.current.length || !ref.current) return
const container = ref.current
if (!isEditableTarget(document.activeElement)) {
container.focus()
}
container.addEventListener('keydown', handleKeyDown)
return (): void => {
container.removeEventListener('keydown', handleKeyDown)
}
}, [isMobile, ref, handleKeyDown])
return {
setLastInteractedItem,
onShiftClick
}
}
export { useShiftSelection }
================================================
FILE: src/hooks/useTransformFolderListHasSharedDriveShortcuts/index.spec.jsx
================================================
import { renderHook } from '@testing-library/react'
import { useTransformFolderListHasSharedDriveShortcuts } from './index'
import { SHARED_DRIVES_DIR_ID } from '@/constants/config'
jest.mock('cozy-sharing', () => ({
useSharingContext: jest.fn()
}))
jest.mock('@/modules/nextcloud/helpers', () => ({
isNextcloudShortcut: jest.fn()
}))
jest.mock('@/modules/shareddrives/hooks/useSharedDrives', () => ({
useSharedDrives: jest.fn()
}))
const mockUseSharingContext = require('cozy-sharing').useSharingContext
const mockIsNextcloudShortcut =
require('@/modules/nextcloud/helpers').isNextcloudShortcut
const mockUseSharedDrives =
require('@/modules/shareddrives/hooks/useSharedDrives').useSharedDrives
describe('useTransformFolderListHasSharedDriveShortcuts', () => {
beforeEach(() => {
jest.resetAllMocks()
mockUseSharingContext.mockReturnValue({
isOwner: jest.fn(() => false)
})
mockUseSharedDrives.mockReturnValue({
sharedDrives: []
})
mockIsNextcloudShortcut.mockReturnValue(false)
})
describe('transformedSharedDrives', () => {
it('should transform shared drives into directory-like objects', () => {
const mockSharedDrives = [
{
id: 'sharing-1',
rules: [
{
values: ['folder-1'],
title: 'Shared Drive 1'
}
]
}
]
mockUseSharedDrives.mockReturnValue({
sharedDrives: mockSharedDrives
})
const { result } = renderHook(() =>
useTransformFolderListHasSharedDriveShortcuts([])
)
expect(result.current.sharedDrives).toHaveLength(1)
expect(result.current.sharedDrives[0]).toMatchObject({
_id: 'folder-1',
id: 'folder-1',
_type: 'io.cozy.files',
type: 'directory',
name: 'Shared Drive 1',
dir_id: SHARED_DRIVES_DIR_ID,
driveId: 'sharing-1',
path: '/Drives/Shared Drive 1'
})
})
it('should return existing file when user is owner', () => {
const mockSharedDrives = [
{
id: 'sharing-1',
rules: [
{
values: ['folder-1'],
title: 'Shared Drive 1'
}
]
}
]
const mockFolderList = [
{
_id: 'file-1',
id: 'file-1',
name: 'Existing File',
relationships: {
referenced_by: {
data: [{ id: 'sharing-1' }]
}
}
}
]
mockUseSharedDrives.mockReturnValue({
sharedDrives: mockSharedDrives
})
mockUseSharingContext.mockReturnValue({
isOwner: jest.fn(() => true)
})
const { result } = renderHook(() =>
useTransformFolderListHasSharedDriveShortcuts(mockFolderList)
)
expect(result.current.sharedDrives).toHaveLength(1)
expect(result.current.sharedDrives[0]).toMatchObject({
_id: 'file-1',
id: 'file-1',
name: 'Existing File'
})
})
it('should filter out nextcloud shortcuts', () => {
const mockSharedDrives = [
{
id: 'sharing-1',
rules: [
{
values: ['folder-1'],
title: 'Regular Drive'
}
]
},
{
id: 'sharing-2',
rules: [
{
values: ['folder-2'],
title: 'Nextcloud Drive'
}
]
}
]
mockUseSharedDrives.mockReturnValue({
sharedDrives: mockSharedDrives
})
// Mock first drive as regular, second as nextcloud
mockIsNextcloudShortcut
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
const { result } = renderHook(() =>
useTransformFolderListHasSharedDriveShortcuts([])
)
expect(result.current.sharedDrives).toHaveLength(1)
expect(result.current.sharedDrives[0].name).toBe('Regular Drive')
})
})
describe('nonSharedDriveList', () => {
it('should filter out shared drives from folder list', () => {
const mockFolderList = [
{
_id: 'file-1',
name: 'Regular File',
dir_id: 'regular-folder'
},
{
_id: 'file-2',
name: 'Shared Drive File',
dir_id: SHARED_DRIVES_DIR_ID
}
]
const { result } = renderHook(() =>
useTransformFolderListHasSharedDriveShortcuts(mockFolderList)
)
expect(result.current.nonSharedDriveList).toHaveLength(1)
expect(result.current.nonSharedDriveList[0].name).toBe('Regular File')
})
it('should include nextcloud shortcuts when showNextcloudFolder is true', () => {
const mockFolderList = [
{
_id: 'file-1',
name: 'Regular File',
dir_id: 'regular-folder'
},
{
_id: 'file-2',
name: 'Nextcloud File',
dir_id: 'regular-folder'
}
]
mockIsNextcloudShortcut
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
const { result } = renderHook(() =>
useTransformFolderListHasSharedDriveShortcuts(mockFolderList, true)
)
expect(result.current.nonSharedDriveList).toHaveLength(2)
expect(result.current.nonSharedDriveList.map(f => f.name)).toEqual([
'Regular File',
'Nextcloud File'
])
})
it('should exclude files referenced by shared drives to avoid duplicates', () => {
const mockSharedDrives = [
{
id: 'sharing-1',
rules: [
{
values: ['folder-1'],
title: 'Shared Drive 1'
}
]
}
]
const mockFolderList = [
{
_id: 'file-1',
id: 'file-1',
name: 'Regular File',
dir_id: 'regular-folder',
relationships: {}
},
{
_id: 'file-2',
id: 'file-2',
name: 'Shared Drive File',
dir_id: 'regular-folder',
relationships: {
referenced_by: {
data: [{ id: 'sharing-1' }]
}
}
}
]
mockUseSharedDrives.mockReturnValue({
sharedDrives: mockSharedDrives,
isLoaded: true
})
mockUseSharingContext.mockReturnValue({
isOwner: jest.fn(id => id === 'file-2')
})
const { result } = renderHook(() =>
useTransformFolderListHasSharedDriveShortcuts(mockFolderList, true)
)
// The file referenced by the shared drive should not be in nonSharedDriveList
expect(result.current.nonSharedDriveList).toHaveLength(1)
expect(result.current.nonSharedDriveList[0].name).toBe('Regular File')
// But it should be in transformedSharedDrives
expect(result.current.sharedDrives).toHaveLength(1)
expect(result.current.sharedDrives[0].name).toBe('Shared Drive File')
})
})
})
================================================
FILE: src/hooks/useTransformFolderListHasSharedDriveShortcuts/index.tsx
================================================
import { useMemo } from 'react'
import { IOCozyFile } from 'cozy-client/types/types'
import { useSharingContext } from 'cozy-sharing'
import { SHARED_DRIVES_DIR_ID, TRASH_DIR_PATH } from '@/constants/config'
import { isNextcloudShortcut } from '@/modules/nextcloud/helpers'
import { useSharedDrives } from '@/modules/shareddrives/hooks/useSharedDrives'
interface SharingRule {
values?: string[]
title?: string
}
interface SharedDrive {
id: string
rules: SharingRule[]
}
interface TransformedSharedDrive extends IOCozyFile {
driveId: string
}
interface UseTransformFolderListReturn {
sharedDrives: TransformedSharedDrive[]
nonSharedDriveList: IOCozyFile[]
sharedDrivesLoaded: boolean
}
const useTransformFolderListHasSharedDriveShortcuts = (
folderList?: IOCozyFile[],
showNextcloudFolder = false
): UseTransformFolderListReturn => {
const { isOwner } = useSharingContext() as unknown as {
isOwner: (fileId: string) => boolean
}
const { sharedDrives, isLoaded: sharedDrivesLoaded } = useSharedDrives()
/**
* Filter out Nextcloud shortcuts from shared drives.
*/
const filteredSharedDrives = useMemo(
() =>
sharedDrives.filter(
sharing => !isNextcloudShortcut(sharing as unknown as IOCozyFile)
),
[sharedDrives]
)
/**
* The recipient's shared drives are displayed as shortcuts which cannot accessible
* In some cases (like open shared drive from folder picker or sharing section...),
* we want to access to shared drives as directories for both owner and recipient
* The codes below help us to transform the shared drives shortcuts into directory-like objects
*/
const transformedSharedDrives = useMemo(
() =>
filteredSharedDrives.map((sharing: SharedDrive) => {
const [rootFolderId, driveName] = [
sharing.rules[0]?.values?.[0],
sharing.rules[0]?.title ?? ''
]
const fileInSharingSection = folderList?.find(item =>
item.relationships?.referenced_by?.data?.some(
ref => ref.id === sharing.id
)
)
if (fileInSharingSection && isOwner(fileInSharingSection.id ?? ''))
return fileInSharingSection as TransformedSharedDrive
const directoryData = {
type: 'directory' as const,
name: driveName,
dir_id: SHARED_DRIVES_DIR_ID,
driveId: sharing.id
}
return {
...fileInSharingSection,
_id: rootFolderId,
id: rootFolderId,
_type: 'io.cozy.files' as const,
path: `/Drives/${driveName}`,
...directoryData,
attributes: directoryData
} as TransformedSharedDrive
}),
[filteredSharedDrives, folderList, isOwner]
)
/**
* Create a Set of shared drive IDs for efficient lookup
*/
const sharedDriveIds = useMemo(
() => new Set(filteredSharedDrives.map((drive: SharedDrive) => drive.id)),
[filteredSharedDrives]
)
/**
* Exclude shared drives from the folderList,
* since it will be replaced with transformed ones above.
* Also exclude files that are referenced by a shared drive to avoid duplicates.
*/
const nonSharedDriveList = useMemo(
() =>
folderList?.filter(item => {
const referencedByData = item.relationships?.referenced_by?.data ?? []
const isReferencedBySharedDrive = referencedByData.some(ref =>
sharedDriveIds.has(ref.id)
)
return (
item.dir_id !== SHARED_DRIVES_DIR_ID &&
!item.path?.startsWith(TRASH_DIR_PATH) &&
!isReferencedBySharedDrive &&
(!showNextcloudFolder ? !isNextcloudShortcut(item) : true)
)
}) ?? [],
[folderList, sharedDriveIds, showNextcloudFolder]
)
return {
sharedDrives: transformedSharedDrives,
nonSharedDriveList,
sharedDrivesLoaded
}
}
export { useTransformFolderListHasSharedDriveShortcuts }
================================================
FILE: src/hooks/useUpdateFavicon/constants.ts
================================================
export const FAVICON_BY_MIMETYPE: Record = {
text: '/favicons/icon-onlyoffice-text.ico',
sheet: '/favicons/icon-onlyoffice-sheet.ico',
slide: '/favicons/icon-onlyoffice-slide.ico'
}
================================================
FILE: src/hooks/useUpdateFavicon/helpers.spec.js
================================================
import { updateFavicon } from './helpers'
const mockQuerySelectorAll = jest.fn()
const mockAppendChild = jest.fn()
const mockCreateElement = jest.fn()
const createMockLinkElement = (href = '/assets/favicon.ico') => ({
rel: '',
type: '',
href,
setAttribute: jest.fn()
})
describe('updateFavicon', () => {
beforeEach(() => {
jest.clearAllMocks()
Object.defineProperty(document, 'querySelectorAll', {
value: mockQuerySelectorAll,
writable: true
})
Object.defineProperty(document, 'createElement', {
value: mockCreateElement,
writable: true
})
Object.defineProperty(document.head, 'appendChild', {
value: mockAppendChild,
writable: true
})
mockCreateElement.mockReturnValue(createMockLinkElement())
})
it('should return early when faviconUrl is empty', () => {
updateFavicon('')
expect(mockQuerySelectorAll).not.toHaveBeenCalled()
expect(mockAppendChild).not.toHaveBeenCalled()
})
it('should create new favicon link when no links exist in DOM', () => {
const mockNewLink = createMockLinkElement()
mockCreateElement.mockReturnValue(mockNewLink)
mockQuerySelectorAll.mockReturnValue([])
updateFavicon('/favicons/icon-onlyoffice-text.ico')
expect(mockCreateElement).toHaveBeenCalledWith('link')
expect(mockNewLink.rel).toBe('icon')
expect(mockNewLink.type).toBe('image/svg+xml')
expect(mockNewLink.href).toBe('/favicons/icon-onlyoffice-text.ico')
expect(mockAppendChild).toHaveBeenCalledWith(mockNewLink)
})
it('should not update favicon when correct favicon is already applied', () => {
const mockLink = createMockLinkElement('/favicons/icon-onlyoffice-text.ico')
mockQuerySelectorAll.mockReturnValue([mockLink])
updateFavicon('/favicons/icon-onlyoffice-text.ico')
expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico')
})
it('should update favicon when current favicon differs from target', () => {
const mockLink = createMockLinkElement(
'/favicons/icon-onlyoffice-sheet.ico'
)
mockQuerySelectorAll.mockReturnValue([mockLink])
updateFavicon('/favicons/icon-onlyoffice-text.ico')
expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico')
})
it('should update all favicon links when multiple exist', () => {
const mockLink1 = createMockLinkElement('/assets/favicon.ico')
const mockLink2 = createMockLinkElement('/assets/favicon.ico')
mockQuerySelectorAll.mockReturnValue([mockLink1, mockLink2])
updateFavicon('/favicons/icon-onlyoffice-text.ico')
expect(mockLink1.href).toBe('/favicons/icon-onlyoffice-text.ico')
expect(mockLink2.href).toBe('/favicons/icon-onlyoffice-text.ico')
})
it('should restore original favicon', () => {
const mockLink = createMockLinkElement('/favicons/icon-onlyoffice-text.ico')
mockQuerySelectorAll.mockReturnValue([mockLink])
updateFavicon('/assets/favicon.ico')
expect(mockLink.href).toBe('/assets/favicon.ico')
})
})
================================================
FILE: src/hooks/useUpdateFavicon/helpers.ts
================================================
/**
* Updates all favicon link elements in the document head
*
* @param {string}faviconUrl - The URL of the favicon to set
*/
export const updateFavicon = (faviconUrl: string): void => {
if (!faviconUrl) return
const links = document.querySelectorAll("link[rel~='icon']")
if (!links.length) {
const link = document.createElement('link')
link.rel = 'icon'
link.type = 'image/svg+xml'
link.href = faviconUrl
document.head.appendChild(link)
return
}
const currentFavicon = links[0].href
if (currentFavicon === faviconUrl) {
return
}
links.forEach(link => {
link.href = faviconUrl
})
}
================================================
FILE: src/hooks/useUpdateFavicon/index.spec.jsx
================================================
import { renderHook } from '@testing-library/react'
import flag from 'cozy-flags'
import useUpdateFavicon from '.'
jest.mock('cozy-flags')
jest.mock('@/lib/getFileMimetype', () => ({
getFileMimetype: jest.fn()
}))
const mockFlag = flag
const mockQuerySelector = jest.fn()
const mockQuerySelectorAll = jest.fn()
const createMockLinkElement = (href = '/assets/favicon.ico') => ({
rel: '',
type: '',
href
})
describe('useUpdateFavicon', () => {
beforeEach(() => {
jest.clearAllMocks()
Object.defineProperty(document, 'querySelector', {
value: mockQuerySelector,
writable: true
})
Object.defineProperty(document, 'querySelectorAll', {
value: mockQuerySelectorAll,
writable: true
})
mockFlag.mockReturnValue(true)
mockQuerySelector.mockReturnValue(createMockLinkElement())
})
it('should update favicon for OnlyOffice text documents', () => {
const file = {
_id: '1',
name: 'document.docx',
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
}
const { getFileMimetype } = require('@/lib/getFileMimetype')
getFileMimetype.mockReturnValue(() => 'text')
const originalLink = createMockLinkElement('/assets/favicon.ico')
const mockLink = createMockLinkElement('/assets/favicon.ico')
mockQuerySelector.mockReturnValue(originalLink)
mockQuerySelectorAll.mockReturnValue([mockLink])
renderHook(() => useUpdateFavicon(file, 'loaded'))
expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico')
})
it('should update favicon for OnlyOffice spreadsheet documents', () => {
const file = {
_id: '1',
name: 'spreadsheet.xlsx',
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
const { getFileMimetype } = require('@/lib/getFileMimetype')
getFileMimetype.mockReturnValue(() => 'sheet')
const originalLink = createMockLinkElement('/assets/favicon.ico')
const mockLink = createMockLinkElement('/assets/favicon.ico')
mockQuerySelector.mockReturnValue(originalLink)
mockQuerySelectorAll.mockReturnValue([mockLink])
renderHook(() => useUpdateFavicon(file, 'loaded'))
expect(mockLink.href).toBe('/favicons/icon-onlyoffice-sheet.ico')
})
it('should update favicon for OnlyOffice presentation documents', () => {
const file = {
_id: '1',
name: 'presentation.pptx',
mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
}
const { getFileMimetype } = require('@/lib/getFileMimetype')
getFileMimetype.mockReturnValue(() => 'slide')
const originalLink = createMockLinkElement('/assets/favicon.ico')
const mockLink = createMockLinkElement('/assets/favicon.ico')
mockQuerySelector.mockReturnValue(originalLink)
mockQuerySelectorAll.mockReturnValue([mockLink])
renderHook(() => useUpdateFavicon(file, 'loaded'))
expect(mockLink.href).toBe('/favicons/icon-onlyoffice-slide.ico')
})
it('should use original favicon for non-OnlyOffice files', () => {
const file = {
_id: '1',
name: 'image.jpg',
mime: 'image/jpeg'
}
const { getFileMimetype } = require('@/lib/getFileMimetype')
getFileMimetype.mockReturnValue(() => 'image')
const originalFaviconLink = createMockLinkElement('/custom/favicon.ico')
const mockLink = createMockLinkElement('/custom/favicon.ico')
mockQuerySelector.mockReturnValue(originalFaviconLink)
mockQuerySelectorAll.mockReturnValue([mockLink])
renderHook(() => useUpdateFavicon(file, 'loaded'))
expect(mockLink.href).toBe('/custom/favicon.ico')
})
it('should restore original favicon on cleanup', () => {
const file = {
_id: '1',
name: 'document.docx',
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
}
const { getFileMimetype } = require('@/lib/getFileMimetype')
getFileMimetype.mockReturnValue(() => 'text')
const originalFaviconLink = createMockLinkElement('/original/favicon.ico')
const mockLink = createMockLinkElement('/original/favicon.ico')
mockQuerySelector.mockReturnValue(originalFaviconLink)
mockQuerySelectorAll.mockReturnValue([mockLink])
const { unmount } = renderHook(() => useUpdateFavicon(file, 'loaded'))
// Favicon should be updated to OnlyOffice icon
expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico')
unmount()
// Favicon should be restored to original
expect(mockLink.href).toBe('/original/favicon.ico')
})
it('should not update favicon when fetchStatus is not loaded', () => {
const file = {
_id: '1',
name: 'document.docx',
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
}
const { getFileMimetype } = require('@/lib/getFileMimetype')
getFileMimetype.mockReturnValue(() => 'text')
const mockLink = createMockLinkElement('/assets/favicon.ico')
mockQuerySelectorAll.mockReturnValue([mockLink])
renderHook(() => useUpdateFavicon(file, 'loading'))
expect(mockLink.href).toBe('/assets/favicon.ico')
})
it('should not update favicon when file is undefined', () => {
const mockLink = createMockLinkElement('/assets/favicon.ico')
mockQuerySelectorAll.mockReturnValue([mockLink])
renderHook(() => useUpdateFavicon(undefined, 'loaded'))
expect(mockLink.href).toBe('/assets/favicon.ico')
})
})
================================================
FILE: src/hooks/useUpdateFavicon/index.tsx
================================================
import { useEffect, useRef } from 'react'
import { IOCozyFile } from 'cozy-client/types/types'
import { updateFavicon } from './helpers'
import { FAVICON_BY_MIMETYPE } from '@/hooks/useUpdateFavicon/constants'
import { getFileMimetype } from '@/lib/getFileMimetype'
const useUpdateFavicon = (
file: IOCozyFile | undefined,
fetchStatus: string
): void => {
const originalFaviconUrlRef = useRef()
useEffect(() => {
const originalFavicon =
document.querySelector("link[rel~='icon']")
if (originalFavicon) {
originalFaviconUrlRef.current = originalFavicon.href
}
return (): void => {
const originalUrl = originalFaviconUrlRef.current
if (originalUrl) {
updateFavicon(originalUrl)
}
}
}, [])
useEffect(() => {
if (fetchStatus !== 'loaded' || !file) {
return
}
const type = getFileMimetype(FAVICON_BY_MIMETYPE)(
file.mime,
file.name
) as string
const faviconUrl =
FAVICON_BY_MIMETYPE[type] ?? originalFaviconUrlRef.current
if (faviconUrl) {
updateFavicon(faviconUrl)
}
}, [file, fetchStatus])
}
export default useUpdateFavicon
================================================
FILE: src/lib/AcceptingSharingContext.jsx
================================================
import React, { createContext, useState } from 'react'
const AcceptingSharingContext = createContext()
const AcceptingSharingProvider = ({ children }) => {
const [sharingsValue, setSharingsValue] = useState({})
const [fileValue, setFileValue] = useState()
const contextValue = {
sharingsValue,
setSharingsValue,
fileValue,
setFileValue
}
return (
{children}
)
}
export default AcceptingSharingContext
export { AcceptingSharingProvider }
================================================
FILE: src/lib/DriveProvider.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { CozyProvider } from 'cozy-client'
import { DataProxyProvider } from 'cozy-dataproxy-lib'
import {
VaultUnlockProvider,
VaultProvider,
VaultUnlockPlaceholder
} from 'cozy-keys-lib'
import SharingProvider, { NativeFileSharingProvider } from 'cozy-sharing'
import AlertProvider from 'cozy-ui/transpiled/react/providers/Alert'
import { BreakpointsProvider } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import CozyTheme from 'cozy-ui-plus/dist/providers/CozyTheme'
import { I18n } from 'twake-i18n'
import RightClickProvider from '@/components/RightClick/RightClickProvider'
import FabProvider from '@/lib/FabProvider'
import { DOCTYPE_APPS, DOCTYPE_CONTACTS, DOCTYPE_FILES } from '@/lib/doctypes'
import { usePublicContext } from '@/modules/public/PublicProvider'
const DriveProvider = ({ client, lang, polyglot, dictRequire, children }) => {
const { isPublic } = usePublicContext()
return (
{children}
)
}
const DataProxyWrapper = ({ children, isPublic }) => {
if (isPublic) {
// Do not include DataProxy for public sharings
return children
}
return (
{children}
)
}
DriveProvider.propTypes = {
client: PropTypes.object.isRequired,
lang: PropTypes.string.isRequired,
polyglot: PropTypes.object,
dictRequire: PropTypes.func
}
export default DriveProvider
================================================
FILE: src/lib/FabProvider.jsx
================================================
import React, { createContext, useState } from 'react'
export const FabContext = createContext()
const FabProvider = ({ children }) => {
const [isFabDisplayed, setIsFabDisplayed] = useState(false)
return (
{children}
)
}
export default FabProvider
================================================
FILE: src/lib/FuzzyPathSearch.js
================================================
import { remove as removeDiacritics } from 'diacritics'
// Search for keywords inside a list of files and folders, while being permissive regardig the order of words
class FuzzyPathSearch {
constructor(files = []) {
// files must have a `path` and `name` property
this.files = files
this.previousQuery = []
this.previousSuggestions = files
}
search(query) {
if (!query) return []
const queryArray = removeDiacritics(
query.replace(/\//g, ' ').trim().toLowerCase()
).split(' ')
const preparedQuery = queryArray.map(word => ({
word,
isAugmentedWord: false,
isNewWord: true
}))
const isQueryAugmented = this.isAugmentingPreviousQuery(preparedQuery)
const sortedQuery = isQueryAugmented
? this.sortQueryByRevelance(preparedQuery)
: this.sortQuerybyLength(preparedQuery)
let suggestions
if (isQueryAugmented && this.previousSuggestions.length !== 0) {
// the new query is just a more selective version of the previous one, so we narrow down the existing list
suggestions = this.filterAndScore(
this.previousSuggestions,
sortedQuery.map(segment => segment.word)
)
} else {
suggestions = this.filterAndScore(
this.files,
sortedQuery.map(segment => segment.word)
)
}
this.previousQuery = sortedQuery
this.previousSuggestions = suggestions
return suggestions
}
isAugmentingPreviousQuery(query) {
for (let currentQuerySegment of query) {
let isInPreviousQuery = false
for (let previousQuerySegment of this.previousQuery) {
if (currentQuerySegment.word.includes(previousQuerySegment.word)) {
isInPreviousQuery = true
break
}
}
// we found a word in the current query that was not included in the previous query, so we consider it a completely new query
if (isInPreviousQuery === false) return false
}
// all words are in the previous query
return true
}
sortQueryByRevelance(query) {
// query terms are sorted in two categories: those that are new or have changed, and therefore may further reduce the set of results, are prioritzed. Those that were there and have not changed come second.
// finally, longer words are placed first to allow discarding files earlier in the scoring loop
let priorizedWords = []
let wordsFromPreviousQuery = []
for (let currentQuerySegment of query) {
let wasInPreviousQuery = false
for (let previousQuerySegment of this.previousQuery) {
if (currentQuerySegment.word.includes(previousQuerySegment.word)) {
if (currentQuerySegment.word !== previousQuerySegment.word) {
currentQuerySegment.isAugmentedWord = true
priorizedWords.push(currentQuerySegment)
} else {
currentQuerySegment.isNewWord = false
wordsFromPreviousQuery.push(currentQuerySegment)
}
wasInPreviousQuery = true
continue
}
}
// this segment wasn't included in any previous query segment so it's a new word and we prioritize it
if (!wasInPreviousQuery) priorizedWords.push(currentQuerySegment)
}
return this.sortQuerybyLength(priorizedWords).concat(
this.sortQuerybyLength(wordsFromPreviousQuery)
)
}
sortQuerybyLength(query) {
return query.sort((a, b) => b.word.length - a.word.length)
}
filterAndScore(files, words) {
const suggestions = []
files.forEach(file => {
let fileScore = 0
const pathArray = removeDiacritics(
(file.path + '/' + file.name).toLowerCase()
)
.split('/')
.filter(pathChunk => !!pathChunk)
for (let word of words) {
// let the magic begin...
// essentialy, matched words that are at the end of the path get better scores
let wordScore = 0
let wordOccurenceValue = 10000
let firstOccurence = true
const maxDepth = pathArray.length
for (let depth = 0; depth < maxDepth; ++depth) {
let dirName = pathArray[depth]
if (dirName.includes(word)) {
if (firstOccurence) {
wordOccurenceValue = 52428800 // that's 2^19 * 100
wordScore +=
(wordOccurenceValue / 2) * (1 + word.length / dirName.length)
firstOccurence = false
} else {
wordScore -=
wordOccurenceValue * (1 - word.length / dirName.length)
}
wordOccurenceValue /= 2
} else {
wordScore -= wordOccurenceValue
wordOccurenceValue /= 2
if (depth === maxDepth - 1) {
// make the penality bigger if the last part of the path doesn't include the word at all
wordScore /= 2
}
}
wordOccurenceValue /= 2
}
if (wordScore < 0) return
fileScore += wordScore
}
if (fileScore > 0) {
suggestions.push({
file,
score: fileScore
})
}
})
suggestions.sort((a, b) => {
const score = b.score - a.score
return score !== 0 ? score : a.file.path.localeCompare(b.file.path)
})
return suggestions.map(suggestion => suggestion.file)
}
}
export default FuzzyPathSearch
================================================
FILE: src/lib/FuzzyPathSearch.spec.js
================================================
import FuzzyPathSearch from './FuzzyPathSearch'
describe('simple search', () => {
const guitars = [
{ name: 'fender stratocaster', path: '' },
{ name: 'fender telecaster', path: '' },
{ name: 'gibson SG', path: '' }
]
let fps
beforeEach(() => {
fps = new FuzzyPathSearch(guitars)
})
it('should return an exact match', () => {
const query = 'fender telecaster'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(1)
expect(result[0]).toBe(guitars[1])
})
it('should return all possible matches', () => {
const query = 'fender'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(2)
expect(result.includes(guitars[0])).toBe(true)
expect(result.includes(guitars[1])).toBe(true)
})
})
describe('search with path', () => {
const guitars = [
{ name: 'stratocaster', path: '/fender/' },
{ name: 'telecaster', path: '/fender/' },
{ name: 'SG', path: '/Gibson/' }
]
let fps
beforeEach(() => {
fps = new FuzzyPathSearch(guitars)
})
it('should return an exact match', () => {
const query = 'fender telecaster'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(1)
expect(result[0]).toBe(guitars[1])
})
it('should return all possible matches', () => {
const query = 'fender'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(2)
expect(result.includes(guitars[0])).toBe(true)
expect(result.includes(guitars[1])).toBe(true)
})
})
describe('malformed queries', () => {
const guitars = [
{ name: 'fender stratocaster', path: '' },
{ name: 'fender telecaster', path: '' },
{ name: 'gibson SG', path: '' }
]
let fps
beforeEach(() => {
fps = new FuzzyPathSearch(guitars)
})
it('should handle different orders', () => {
const query1 = 'telecaster fender'
const result1 = fps.search(query1)
const query2 = 'fender telecaster'
const result2 = fps.search(query2)
expect(result1).toEqual(result2)
})
it('should handle diacritics', () => {
const query = 'télécaster fender'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(1)
expect(result[0]).toBe(guitars[1])
})
it('should handle extra spaces', () => {
const query = 'fender telecaster'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(1)
expect(result[0]).toBe(guitars[1])
})
it('should not care about casing', () => {
const query = 'FENDER TeLeCASTER'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(1)
expect(result[0]).toBe(guitars[1])
})
})
describe('result ordering', () => {
it('should favor names over pathes', () => {
const guitars = [
{ name: 'telecaster', path: '/fender' },
{ name: 'fender', path: '/telecaster' }
]
const fps = new FuzzyPathSearch(guitars)
const query = 'tele'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(2)
expect(result[0]).toBe(guitars[0])
expect(result[1]).toBe(guitars[1])
})
it('should not care about the input order', () => {
const guitars = [
{ name: 'telecaster', path: '/fender' },
{ name: 'fender', path: '/telecaster' }
]
const fps = new FuzzyPathSearch(guitars)
const query = 'tele'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(2)
expect(result[0]).toBe(guitars[0])
expect(result[1]).toBe(guitars[1])
const reversed = guitars.reverse()
const reversedFps = new FuzzyPathSearch(reversed)
const reversedResult = reversedFps.search(query)
expect(reversedResult).toBeInstanceOf(Array)
expect(reversedResult.length).toEqual(2)
expect(reversedResult[0]).toBe(guitars[1])
expect(reversedResult[1]).toBe(guitars[0])
})
it('should favor matches nearer the start of the path', () => {
const guitars = [
{ name: '2015', path: '/fender/telecaster/stratocaster/' },
{ name: '2015', path: '/fender/stratocaster/' }
]
const fps = new FuzzyPathSearch(guitars)
const query = 'stratocaster'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(2)
expect(result[0]).toBe(guitars[1])
expect(result[1]).toBe(guitars[0])
})
it('should fallback to the shortest path', () => {
const guitars = [
{ name: '2015', path: '/fender/telecaster/stratocaster/' },
{ name: '2015', path: '/fender/stratocaster/' }
]
const fps = new FuzzyPathSearch(guitars)
const query = '2015'
const result = fps.search(query)
expect(result).toBeInstanceOf(Array)
expect(result.length).toEqual(2)
expect(result[0]).toBe(guitars[1])
expect(result[1]).toBe(guitars[0])
})
})
describe('successive searches', () => {
it('should filter results as the query gets longer', () => {
const guitars = [
{ name: 'fender stratocaster', path: '' },
{ name: 'fender telecaster', path: '' },
{ name: 'gibson SG', path: '' }
]
const fps = new FuzzyPathSearch(guitars)
const result1 = fps.search('caster')
expect(result1).toBeInstanceOf(Array)
expect(result1.length).toEqual(2)
expect(result1.includes(guitars[0])).toBe(true)
expect(result1.includes(guitars[1])).toBe(true)
const result2 = fps.search('caster tele')
expect(result2).toBeInstanceOf(Array)
expect(result2.length).toEqual(1)
expect(result2.includes(guitars[1])).toBe(true)
})
it('should reset when queries backtrack', () => {
const guitars = [
{ name: 'fender stratocaster', path: '' },
{ name: 'fender telecaster', path: '' },
{ name: 'gibson SG', path: '' }
]
const fps = new FuzzyPathSearch(guitars)
const result1 = fps.search('telecaster')
expect(result1).toBeInstanceOf(Array)
expect(result1.length).toEqual(1)
expect(result1.includes(guitars[1])).toBe(true)
const result2 = fps.search('caster')
expect(result2).toBeInstanceOf(Array)
expect(result2.length).toEqual(2)
expect(result2.includes(guitars[0])).toBe(true)
expect(result2.includes(guitars[1])).toBe(true)
})
})
================================================
FILE: src/lib/ModalContext.tsx
================================================
import React, { useState, useCallback, useContext, ReactNode } from 'react'
interface TModalContext {
modalStack: JSX.Element[]
pushModal: (modal: JSX.Element) => void
popModal: () => void
}
export const ModalContext = React.createContext(
undefined
)
interface ModalContextProviderProps {
children: ReactNode
}
export const ModalContextProvider: React.FC = ({
children
}) => {
const [modalStack, setModalStack] = useState([])
const pushModal = useCallback((modal: JSX.Element) => {
setModalStack(prevStack => [...prevStack, modal])
}, [])
const popModal = useCallback(() => {
setModalStack(prevStack => prevStack.slice(0, prevStack.length - 1))
}, [])
return (
{children}
)
}
export const useModalContext = (): TModalContext => {
const context = useContext(ModalContext)
if (!context) {
throw new Error(
'useModalContext must be used within a ModalContextProvider'
)
}
return context
}
export const ModalStack = (): JSX.Element | null => {
const { modalStack } = useModalContext()
if (modalStack.length === 0) return null
else return modalStack[modalStack.length - 1]
}
================================================
FILE: src/lib/ThumbnailSizeContext.tsx
================================================
import React, { useState, useCallback, useContext, createContext } from 'react'
interface ThumbnailSizeContextProps {
isBigThumbnail: boolean
toggleThumbnailSize: () => void
}
const ThumbnailSizeContext = createContext({
isBigThumbnail: false,
toggleThumbnailSize: () => {}
})
const ThumbnailSizeContextProvider: React.FC = ({ children }) => {
const [isBigThumbnail, setIsBigThumbnail] = useState(false)
const toggleThumbnailSize = useCallback(
() => setIsBigThumbnail(!isBigThumbnail),
[isBigThumbnail, setIsBigThumbnail]
)
return (
{children}
)
}
const useThumbnailSizeContext = (): ThumbnailSizeContextProps =>
useContext(ThumbnailSizeContext)
export {
ThumbnailSizeContext,
ThumbnailSizeContextProvider,
useThumbnailSizeContext
}
================================================
FILE: src/lib/ViewSwitcherContext.tsx
================================================
import React, { useState, useContext, createContext, useEffect } from 'react'
import { useClient, Q } from 'cozy-client'
import logger from './logger'
import { DOCTYPE_FILES_SETTINGS } from '@/lib/doctypes'
interface QueryResult {
data: [
{
attributes: {
preferredDriveViewType: string
}
}
]
}
// Constants
const DEFAULT_VIEW_TYPE = 'list'
interface ViewSwitcherContextProps {
viewType: string
switchView: (viewTypeParam: string) => Promise
}
const ViewSwitcherContext = createContext({
viewType: DEFAULT_VIEW_TYPE,
switchView: async () => Promise.resolve()
})
const ViewSwitcherContextProvider: React.FC = ({ children }) => {
const client = useClient()
const [viewType, setViewType] = useState(DEFAULT_VIEW_TYPE)
useEffect(() => {
const load = async (): Promise => {
if (!client) return
try {
const result = (await client.query(
Q(DOCTYPE_FILES_SETTINGS)
)) as QueryResult
if (!result?.data) return
const preferred = result?.data?.[0]?.attributes?.preferredDriveViewType
setViewType(preferred || DEFAULT_VIEW_TYPE)
} catch (error) {
logger.error('Failed to load settings:', error)
setViewType(DEFAULT_VIEW_TYPE)
}
}
void load()
}, [client])
const switchView = async (viewTypeParam: string): Promise => {
setViewType(viewTypeParam)
if (!client) {
logger.warn('Client not available')
return
}
try {
const { data } = (await client.query(
Q(DOCTYPE_FILES_SETTINGS)
)) as QueryResult
if (!data) {
logger.warn('Settings not found')
return
}
const existing = data[0]
await client.save({
...(existing || { _type: DOCTYPE_FILES_SETTINGS }),
attributes: {
...(existing?.attributes || {}),
preferredDriveViewType: viewTypeParam
}
})
} catch (error) {
logger.error('Failed to save view preference:', error)
}
}
return (
{children}
)
}
const useViewSwitcherContext = (): ViewSwitcherContextProps =>
useContext(ViewSwitcherContext)
export {
ViewSwitcherContext,
ViewSwitcherContextProvider,
useViewSwitcherContext
}
================================================
FILE: src/lib/appMetadata.js
================================================
import manifest from '../../manifest.webapp'
const appMetadata = {
slug: manifest.slug,
version: manifest.version,
name: manifest.name,
prefix: manifest.name_prefix
}
export default appMetadata
================================================
FILE: src/lib/dacc/dacc-run.js
================================================
import endOfMonth from 'date-fns/endOfMonth'
import format from 'date-fns/format'
import startOfMonth from 'date-fns/startOfMonth'
import subMonths from 'date-fns/subMonths'
import CozyClient from 'cozy-client'
import flag from 'cozy-flags'
import log from 'cozy-logger'
import { aggregateFilesSize, sendToRemoteDoctype } from '@/lib/dacc/dacc'
import { schema } from '@/lib/doctypes'
/**
* This service aggregates files size by createdByApps slug and send them to the DACC.
* See https://github.com/cozy/DACC for more insights about the DACC.
* The service relies on a flag that contains the following information:
* - measureName: the name of the dacc measure
* - remoteDoctype: the remote doctype to use
* - nonExcludedGroupLabel: when set, it is used to aggregate all the slugs not maching the excludedSlug
* - excludedSlug: used to exclude a slug from the total aggregation
*/
export const run = async () => {
log('info', 'Start dacc service')
const client = CozyClient.fromEnv(process.env, { schema })
await flag.initialize(client)
const daccFileSizeFlag = flag('drive.dacc-files-size-by-slug')
if (!daccFileSizeFlag) {
return
}
const {
excludedSlug,
nonExcludedGroupLabel,
measureName,
remoteDoctype,
maxFileDateQuery
} = daccFileSizeFlag
const aggregationDate = new Date(
maxFileDateQuery || endOfMonth(subMonths(new Date(), 1))
)
const sizesBySlug = await aggregateFilesSize(client, aggregationDate, {
excludedSlug,
nonExcludedGroupLabel
})
if (Object.keys(sizesBySlug).length < 1) {
log(
'info',
`No files found to aggregate with date ${aggregationDate.toISOString()}`
)
}
const startDateMeasure = format(startOfMonth(aggregationDate), 'yyyy-LL-dd')
await sendToRemoteDoctype(client, remoteDoctype, sizesBySlug, {
measureName,
startDate: startDateMeasure
})
}
================================================
FILE: src/lib/dacc/dacc-run.spec.js
================================================
import endOfMonth from 'date-fns/endOfMonth'
import subMonths from 'date-fns/subMonths'
import CozyClient from 'cozy-client'
import flag from 'cozy-flags'
import log from 'cozy-logger'
import { run } from './dacc-run'
import { aggregateFilesSize } from '@/lib/dacc/dacc'
jest.mock('cozy-flags')
jest.mock('cozy-client')
jest.mock('cozy-logger')
jest.mock('lib/dacc/dacc')
describe('dacc', () => {
const maxGivenDate = '2022-01-01'
const maxDate = new Date(maxGivenDate)
beforeEach(() => {
flag.mockReturnValue({
excludedSlug: 'excludedSlug',
nonExcludedGroupLabel: 'nonExcludedGroupLabel',
measureName: 'measureName',
remoteDoctype: 'remoteDoctype',
maxFileDateQuery: maxGivenDate
})
})
afterEach(() => {
jest.resetAllMocks()
})
it('should do nothing when no flag is set', async () => {
// Given
flag.mockReturnValueOnce(null)
// When
await run()
// Then
expect(aggregateFilesSize).toHaveBeenCalledTimes(0)
})
it('should aggregateFilesSize with max file date query', async () => {
// Given
const client = 'client'
CozyClient.fromEnv.mockReturnValue(client)
aggregateFilesSize.mockResolvedValueOnce([])
// When
await run()
// Then
expect(aggregateFilesSize).toHaveBeenCalledWith(client, maxDate, {
excludedSlug: 'excludedSlug',
nonExcludedGroupLabel: 'nonExcludedGroupLabel'
})
})
it('should aggregateFilesSize with end date of this month when max file date query not found', async () => {
// Given
const client = 'client'
CozyClient.fromEnv.mockReturnValue(client)
aggregateFilesSize.mockResolvedValueOnce([])
flag.mockReturnValue({
excludedSlug: 'excludedSlug',
nonExcludedGroupLabel: 'nonExcludedGroupLabel',
measureName: 'measureName',
remoteDoctype: 'remoteDoctype'
})
const endOfThisMonth = new Date(endOfMonth(subMonths(new Date(), 1)))
// When
await run()
// Then
expect(aggregateFilesSize).toHaveBeenCalledWith(client, endOfThisMonth, {
excludedSlug: 'excludedSlug',
nonExcludedGroupLabel: 'nonExcludedGroupLabel'
})
})
it('should log when there is no sizes by slug', async () => {
// Given
aggregateFilesSize.mockResolvedValueOnce([])
// When
await run()
const date = new Date(maxDate).toISOString()
// Then
expect(log).toHaveBeenNthCalledWith(
2,
'info',
`No files found to aggregate with date ${date}`
)
})
it('should not log when there are sizes by slug', async () => {
// Given
aggregateFilesSize.mockResolvedValueOnce([{}])
// When
await run()
// Then
expect(log).toHaveBeenCalledTimes(1)
})
})
================================================
FILE: src/lib/dacc/dacc.js
================================================
// @ts-check
import log from 'cozy-logger'
import { queryAllDocsWithFields } from '@/lib/dacc/query'
/**
* @typedef {object} Measure
* See https://github.com/cozy/DACC for more insights
*
* @property {string} [createdBy] - The app slug that created the measure
* @property {string} [measureName] - The measure name
* @property {string} [startDate] - The startDate of the aggregation
* @property {number} [value] - The measure value
* @property {Array} [groups] - The measure groups
*/
const sendMeasureToDACC = async (client, remoteDoctype, measure) => {
try {
log('info', `Send ${JSON.stringify(measure)} to ${remoteDoctype}`)
await client
.getStackClient()
.fetchJSON('POST', `/remote/${remoteDoctype}`, {
data: JSON.stringify(measure),
path: 'measure'
})
} catch (error) {
log(
'error',
`Error while sending measure to remote doctype: ${error.message}`
)
throw error
}
}
/**
* Send measures to a remote doctype
*
* @param {object} client - The CozyClient instance
* @param {string} remoteDoctype - The remote doctype to use
* @param {object} sizesBySlug - The hash table of values by slug
* @param {{startDate, measureName}} params - The measure params
*/
export const sendToRemoteDoctype = async (
client,
remoteDoctype,
sizesBySlug,
{ startDate, measureName }
) => {
const slugs = Object.keys(sizesBySlug)
log(
'info',
`Send ${slugs.length} measures ${measureName} on ${startDate} to ${remoteDoctype}...`
)
for (const slug of slugs) {
const measure = {
createdBy: 'drive',
measureName,
startDate,
value: sizesBySlug[slug],
groups: [{ slug: slug }]
}
await sendMeasureToDACC(client, remoteDoctype, measure)
}
}
const convertFileSizeInMB = file => {
// The size is converted in MB to avoid too large values
return parseInt(file.size) / (1000 * 1000) // Size in million of Bytes (MB)
}
/**
* Aggregate file size values by slug
*
* @param {object} client - The CozyClient instance
* @param {Date} endDate - The max file date to query
* @returns {Promise} The hash table of values by slug
*/
export const aggregateFilesSize = async (
client,
endDate,
{ excludedSlug = '', nonExcludedGroupLabel = '' } = {}
) => {
const sizesBySlug = {
trashed: 0
}
const resp = await queryAllDocsWithFields(client)
for (const entry of resp) {
const file = entry.doc
const uploadedAt = new Date(file?.cozyMetadata?.uploadedAt || Date.now())
if (file.type !== 'file' || uploadedAt > endDate) {
// Skip this doc
continue
}
const slug = file.cozyMetadata?.createdByApp || 'unknown'
const sizeMB = convertFileSizeInMB(file)
if (file.trashed) {
// Special case for trashed files
sizesBySlug.trashed += sizeMB
} else {
if (slug in sizesBySlug) {
sizesBySlug[slug] += sizeMB
} else {
sizesBySlug[slug] = sizeMB
}
}
}
if (excludedSlug && nonExcludedGroupLabel) {
// Aggregate values
const totalNonExcluded = aggregateNonExcludedSlugs(
sizesBySlug,
excludedSlug
)
sizesBySlug[nonExcludedGroupLabel] = totalNonExcluded
}
// Round values
for (const slug of Object.keys(sizesBySlug)) {
sizesBySlug[slug] = Math.round(sizesBySlug[slug] * 1000) / 1000
}
return sizesBySlug
}
/**
* Aggregate all values except for excluded slug
*
* @param {object} sizesBySlug - The hash table of values by slug
* @param {string} exclusionSlug - The slug to exclude
*/
export const aggregateNonExcludedSlugs = (sizesBySlug, exclusionSlug) => {
let totalSize = 0
for (const slug of Object.keys(sizesBySlug)) {
if (!slug.includes(exclusionSlug) && slug !== 'trashed') {
totalSize += sizesBySlug[slug]
}
}
return totalSize
}
================================================
FILE: src/lib/dacc/dacc.spec.js
================================================
import { aggregateFilesSize, aggregateNonExcludedSlugs } from '@/lib/dacc/dacc'
import { queryAllDocsWithFields } from '@/lib/dacc/query'
jest.mock('lib/dacc/query')
const mockedFilesQueryResponse = [
{
doc: {
type: 'file',
size: 1048576,
cozyMetadata: {
createdByApp: 'drive',
uploadedAt: '2021-01-01'
}
}
},
{
doc: {
type: 'file',
size: 3145728,
cozyMetadata: {
createdByApp: 'drive',
uploadedAt: '2021-01-01'
}
}
},
{
doc: {
type: 'file',
size: 4567892,
cozyMetadata: {
createdByApp: 'drive'
}
}
},
{
doc: {
type: 'file',
size: 2097152,
cozyMetadata: {
createdByApp: 'edf',
uploadedAt: '2021-01-01'
}
}
},
{
doc: {
type: 'file',
size: 8388608,
cozyMetadata: {
createdByApp: 'maif',
uploadedAt: '2021-01-01'
}
}
},
{
doc: {
type: 'file',
size: 6291456,
cozyMetadata: {
createdByApp: 'maif-vie',
uploadedAt: '2021-01-01'
}
}
},
{
doc: {
type: 'file',
trashed: true,
size: 2290000,
cozyMetadata: {
createdByApp: 'maif-vie',
uploadedAt: '2021-01-01'
}
}
}
]
describe('aggregateFilesSize', () => {
beforeEach(() => {
queryAllDocsWithFields.mockResolvedValue(mockedFilesQueryResponse)
})
it('should aggregate sizes by slug', async () => {
const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'))
expect(Object.keys(sizesBySlug)).toEqual([
'trashed',
'drive',
'edf',
'maif',
'maif-vie'
])
expect(sizesBySlug['drive']).toEqual(4.194)
expect(sizesBySlug['edf']).toEqual(2.097)
expect(sizesBySlug['maif']).toEqual(8.389)
expect(sizesBySlug['maif-vie']).toEqual(6.291)
expect(sizesBySlug['trashed']).toEqual(2.29)
})
it('should aggregate all sizes but excluded slug', async () => {
const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'), {
excludedSlug: 'maif',
nonExcludedGroupLabel: 'not-maif'
})
const expectedValue =
Math.round((sizesBySlug['drive'] + sizesBySlug['edf']) * 1000) / 1000
expect(sizesBySlug['not-maif']).toEqual(expectedValue)
})
it('should skip docs not file or without uploadedAt', async () => {
queryAllDocsWithFields.mockResolvedValueOnce([
{
doc: {
type: 'file',
size: 4567892,
cozyMetadata: {
createdByApp: 'drive'
}
}
},
{
doc: {
type: 'directory'
}
}
])
const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'))
expect(sizesBySlug).toEqual({ trashed: 0 })
})
})
describe('aggregateNonExcludedSlugs', () => {
it('should aggregate all sizes but excluded slug', async () => {
const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'))
const totalSize = aggregateNonExcludedSlugs(sizesBySlug, 'maif')
expect(totalSize).toEqual(sizesBySlug['drive'] + sizesBySlug['edf'])
})
it('should aggregate nothing when excluded slug is empty', async () => {
const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'))
const totalSize = aggregateNonExcludedSlugs(sizesBySlug, '')
expect(totalSize).toEqual(0)
})
})
================================================
FILE: src/lib/dacc/query.js
================================================
import { DOCTYPE_FILES } from '@/lib/doctypes'
/**
* Query all files by filtering on required fields
*
* @param {object} client - The CozyClient instance
* @returns {Promise} The files array
*/
export const queryAllDocsWithFields = async client => {
const resp = await client
.getStackClient()
.fetchJSON(
'GET',
`/data/${DOCTYPE_FILES}/_all_docs?Fields=_id,trashed,name,size,type,cozyMetadata&DesignDocs=false&include_docs=true`
)
return resp.rows
}
================================================
FILE: src/lib/doctypes.js
================================================
import extraDoctypes from '@/lib/extraDoctypes'
import { Contact, Group } from '@/models'
export const DOCTYPE_FILES = 'io.cozy.files'
export const DOCTYPE_FILES_SETTINGS = 'io.cozy.files.settings'
export const DOCTYPE_DRIVE_SETTINGS = 'io.cozy.drive.settings'
export const DOCTYPE_FILES_ENCRYPTION = 'io.cozy.files.encryption'
export const DOCTYPE_FILES_SHORTCUT = 'io.cozy.files.shortcuts'
export const DOCTYPE_ALBUMS = 'io.cozy.photos.albums'
export const DOCTYPE_PHOTOS_SETTINGS = 'io.cozy.photos.settings'
export const DOCTYPE_APPS = 'io.cozy.apps'
export const DOCTYPE_CONTACTS = 'io.cozy.contacts'
export const DOCTYPE_KONNECTORS = 'io.cozy.konnectors'
export const NEXTCLOUD_MIGRATIONS_DOCTYPE = 'io.cozy.nextcloud.migrations'
export const DOCTYPE_CONTACTS_VERSION = 2
export const schema = {
files: {
doctype: DOCTYPE_FILES,
relationships: {
old_versions: {
type: 'has-many',
doctype: 'io.cozy.files.versions'
},
encryption: {
type: 'io.cozy.files:has-many',
doctype: DOCTYPE_FILES_ENCRYPTION
}
}
},
contacts: {
doctype: Contact.doctype,
doctypeVersion: DOCTYPE_CONTACTS_VERSION
},
groups: { doctype: Group.doctype },
versions: { doctype: 'io.cozy.files.versions' },
...extraDoctypes
}
================================================
FILE: src/lib/entries.js
================================================
/**
* Get type from the entries
* @param {IOCozyFile[]} entries - List of files moved
* @returns {string} - Type from the entries
*/
export const getEntriesType = entries => {
const types = entries.reduce((acc, entry) => {
acc.add(entry.type)
return acc
}, new Set())
if (types.size === 1 && types.has('directory')) {
return 'directory'
}
if (types.size === 1 && types.has('file')) {
return 'file'
}
return 'element'
}
/**
* Get translated type from the entries
* @param {IOCozyFile[]} entries - List of files
* @param {Function} t - Translation function
* @returns {string} - Translated type from the entries
*/
export const getEntriesTypeTranslated = (t, entries) => {
const type = getEntriesType(entries)
return t(`EntriesType.${type}`, entries.length)
}
================================================
FILE: src/lib/entries.spec.js
================================================
import { getEntriesType } from '@/lib/entries'
describe('getEntriesType', () => {
it('should return file for entries only file', () => {
const res = getEntriesType([
{ type: 'file' },
{ type: 'file' },
{ type: 'file' }
])
expect(res).toBe('file')
})
it('should return folder for entries only folder', () => {
const res = getEntriesType([
{ type: 'directory' },
{ type: 'directory' },
{ type: 'directory' }
])
expect(res).toBe('directory')
})
it('should return element for entries with multiples types', () => {
const res = getEntriesType([
{ type: 'file' },
{ type: 'directory' },
{ type: 'file' }
])
expect(res).toBe('element')
})
it('should return element if something else from file or directory', () => {
const res = getEntriesType([
{ type: 'something' },
{ type: 'something' },
{ type: 'something' }
])
expect(res).toBe('element')
})
})
================================================
FILE: src/lib/extraDoctypes.js
================================================
export default {}
================================================
FILE: src/lib/flags.js
================================================
import flag from 'cozy-flags'
export const initFlags = () => {
let activateFlags = flag('switcher') === true ? true : false
if (process.env.NODE_ENV !== 'production' && flag('switcher') === null) {
activateFlags = true
}
const searchParams = new URL(window.location).searchParams
if (!activateFlags && searchParams.get('flags') !== null) {
activateFlags = true
}
if (activateFlags) {
flagsList()
}
}
// flagName should use kebab case
const flagsList = () => {
flag('switcher', true)
flag('debug')
flag('drive.onlyoffice.editorToolbarHeight') // flagName should use kebab case
flag('drive.logger')
flag('drive.dacc-files-size-by-slug')
flag('drive.breadcrumb.showCompleteBreadcrumbOnPublicPage') // flagName should use kebab case
flag('drive.hide-nextcloud-dev')
flag('sharing.auto-open-settings.enabled')
flag('sharing.generate-link-button.enabled')
}
================================================
FILE: src/lib/getFileMimetype.js
================================================
import mime from 'mime-types'
const getMimetypeFromFilename = name => {
return mime.lookup(name) || 'application/octet-stream'
}
const mappingMimetypeSubtype = {
word: 'text',
text: 'text',
zip: 'zip',
pdf: 'pdf',
spreadsheet: 'sheet',
excel: 'sheet',
sheet: 'sheet',
presentation: 'slide',
powerpoint: 'slide'
}
export const getFileMimetype =
collection =>
(mime = '', name = '') => {
const mimetype =
mime === 'application/octet-stream'
? getMimetypeFromFilename(name.toLowerCase())
: mime
const [type, subtype] = mimetype.split('/')
if (collection[type]) {
return type
}
if (type === 'application') {
const existingType = subtype.match(
Object.keys(mappingMimetypeSubtype).join('|')
)
return existingType ? mappingMimetypeSubtype[existingType[0]] : undefined
}
return undefined
}
================================================
FILE: src/lib/getMimeTypeIcon.js
================================================
import get from 'lodash/get'
import IconAudio from 'cozy-ui/transpiled/react/Icons/FileTypeAudio'
import IconBin from 'cozy-ui/transpiled/react/Icons/FileTypeBin'
import IconCode from 'cozy-ui/transpiled/react/Icons/FileTypeCode'
import IconFiles from 'cozy-ui/transpiled/react/Icons/FileTypeFiles'
import IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder'
import IconImage from 'cozy-ui/transpiled/react/Icons/FileTypeImage'
import IconNote from 'cozy-ui/transpiled/react/Icons/FileTypeNote'
import IconPdf from 'cozy-ui/transpiled/react/Icons/FileTypePdf'
import IconSheet from 'cozy-ui/transpiled/react/Icons/FileTypeSheet'
import IconSlide from 'cozy-ui/transpiled/react/Icons/FileTypeSlide'
import IconText from 'cozy-ui/transpiled/react/Icons/FileTypeText'
import IconVideo from 'cozy-ui/transpiled/react/Icons/FileTypeVideo'
import IconZip from 'cozy-ui/transpiled/react/Icons/FileTypeZip'
import IconDocs from '@/assets/icons/icon-docs.svg'
import { getFileMimetype } from '@/lib/getFileMimetype'
/**
* Returns the appropriate icon for a given file based on its mime type.
*
* @param {boolean} isDirectory
* @param {string} name
* @param {string} mime
* @returns {import('react').ReactNode}
*/
const getMimeTypeIcon = (isDirectory, name, mime) => {
if (isDirectory) {
return IconFolder
} else if (/\.cozy-note$/.test(name)) {
return IconNote
} else if (/\.docs-note$/.test(name)) {
return IconDocs
} else {
const iconsByMimeType = {
audio: IconAudio,
bin: IconBin,
code: IconCode,
image: IconImage,
pdf: IconPdf,
slide: IconSlide,
sheet: IconSheet,
text: IconText,
video: IconVideo,
zip: IconZip
}
const type = getFileMimetype(iconsByMimeType)(mime, name)
return get(iconsByMimeType, type, IconFiles)
}
}
export default getMimeTypeIcon
================================================
FILE: src/lib/konnectors.js
================================================
import { getReferencedBy } from 'cozy-client'
import { DOCTYPE_KONNECTORS } from '@/lib/doctypes'
/**
* Returns the slug of the konnector that produced the given file, or null
* if the file is not referenced by any konnector.
*
* Konnector-created files carry an explicit `io.cozy.konnectors/`
* entry in their `referenced_by` list. We read the first such reference
* and strip the doctype prefix to recover the bare slug (e.g. "edf").
*
* `cozyMetadata.createdByApp` is intentionally not used: it is set by any
* app or konnector that creates files (drive, notes, ...), so its value
* can be an app slug that does not exist as a konnector and would 404
* against `GET /konnectors/` on cozy-stack.
*
* @param {import('cozy-client/types/types').IOCozyFile} file - A file doc with its references hydrated.
* @returns {string|null} The konnector slug, or null when the file has no konnector reference.
*/
export const getKonnectorSlugFromFile = file => {
const ref = getReferencedBy(file, DOCTYPE_KONNECTORS)[0]
return ref?.id?.replace(`${DOCTYPE_KONNECTORS}/`, '') ?? null
}
================================================
FILE: src/lib/logger.js
================================================
import minilog from 'cozy-minilog'
const logger = minilog(`cozy-drive`)
minilog.enable()
minilog.suggest.allow(`cozy-drive`, 'log')
minilog.suggest.allow(`cozy-drive`, 'info')
export default logger
================================================
FILE: src/lib/migration/qualification.js
================================================
import { get, has, isEmpty, omit, sortBy } from 'lodash'
import { models, Q } from 'cozy-client'
import log from 'cozy-logger'
const { Qualification } = models.document
const { saveFileQualification } = models.file
/**
* Query the files indexed on their updatedAt date.
*
* @param {object} client - The CozyClient instance
* @param {string} date - The starting date to query
* @param {number} limit - The maximum number of files to return
*/
export const queryFilesFromDate = async (client, date, limit) => {
const query = Q('io.cozy.files')
.where({
type: 'file',
'cozyMetadata.updatedAt': { $gt: date },
trashed: false
})
.indexFields(['type', 'cozyMetadata.updatedAt'])
.limitBy(limit)
.sortBy([{ type: 'asc' }, { 'cozyMetadata.updatedAt': 'asc' }])
return client.query(query)
}
/**
* From a list of files, find the most recent updatedAt value
*
* @param {object} files - The unsorted files
* @returns {string} The most recent updatedAt value
*/
export const getMostRecentUpdatedDate = files => {
const filesWithDate = files.filter(file =>
get(file, 'data.attributes.cozyMetadata.updatedAt')
)
const sortedFiles = sortBy(filesWithDate, [
'data.attributes.cozyMetadata.updatedAt'
])
return sortedFiles.length > 0
? get(
sortedFiles[sortedFiles.length - 1],
'data.attributes.cozyMetadata.updatedAt'
)
: null
}
/**
* Extract the old qualification attributes from a file.
*
* @param {object} file - The file to extract old attributes from
* @returns {object} The old qualification attributes
*/
const oldQualificationAttributes = file => {
const oldQualification = {}
Object.assign(
oldQualification,
has(file, 'metadata.id') ? { id: file.metadata.id } : null,
has(file, 'metadata.label') ? { label: file.metadata.label } : null,
has(file, 'metadata.classification')
? { classification: file.metadata.classification }
: null,
has(file, 'metadata.subClassification')
? { subClassification: file.metadata.subClassification }
: null,
has(file, 'metadata.categorie')
? { categorie: file.metadata.categorie }
: null,
has(file, 'metadata.category')
? { category: file.metadata.category }
: null,
has(file, 'metadata.categories')
? { categories: file.metadata.categories }
: null,
has(file, 'metadata.subject') ? { subject: file.metadata.subject } : null,
has(file, 'metadata.subjects') ? { subjects: file.metadata.subjects } : null
)
return isEmpty(oldQualification) ? null : oldQualification
}
/**
* Keep only the files with old qualification attributes
*
* @param {Array} files - The files to process
* @returns {Array} The list of files having old qualification attributes
*/
export const extractFilesToMigrate = files => {
return files.filter(file => {
const oldAttributes = oldQualificationAttributes(file)
// This case can happen when a file was previously migrated, as we keep
// the id for retro-compatibility
if (has(oldAttributes, 'id') && !has(oldAttributes, 'label')) {
return false
}
return oldAttributes
})
}
/**
* We changed some labels set by cozy-scanner: this method
* transform them with the new one.
*
* @param {string} oldLabel - The old qualification label
* @returns {string} The new qualification label
*/
const getNewLabelSetFromCozyScanner = oldLabel => {
if (oldLabel === 'registration') {
return 'vehicle_registration'
}
if (oldLabel === 'insurance_card') {
return 'national_health_insurance_card'
}
return oldLabel
}
/**
* Remove the old qualification attributes from a file.
*
* @param {object} file - The file with old attributes
* @returns {object} The file without the old attributes
*/
export const removeOldQualificationAttributes = file => {
const oldAttributes = oldQualificationAttributes(file)
// keep the id for retro-compatibility: it is used by cozy-scanner to display the label
if (has(oldAttributes, 'id')) {
delete oldAttributes.id
}
if (oldAttributes) {
const attributesPath = Object.keys(oldAttributes).map(oldAttribute => {
return `metadata.${oldAttribute}`
})
return omit(file, attributesPath)
}
return file
}
/**
* Takes a file with an old qualification set by cozy-scanner and
* returns the new qualification, by the label.
*
* @param {object} file - The file qualified by cozy-scanner
* @returns {Qualification} The new qualification
*/
const getNewQualificationSetFromCozyScanner = file => {
const qualificationLabel = get(file, 'metadata.label')
const label = getNewLabelSetFromCozyScanner(qualificationLabel)
return Qualification.getByLabel(label)
}
/**
* Takes a file with an old qualification set by a konnector and
* returns the new qualification.
* The qualification is fixed by a set of rules primarily based on the
* contentAuthor and old attributes in certain cases.
*
* @param {object} file - The file qualified by a konnector
* @returns {Qualification} The new qualification
*/
const getNewQualificationSetFromKonnector = file => {
const contentAuthor = get(file, 'metadata.contentAuthor')
const classification = get(file, 'metadata.classification')
const categories = get(file, 'metadata.categories')
// See https://github.com/konnectors/cozy-konnector-digiposte/blob/master/src/index.js
// See https://github.com/konnectors/orangeapi/blob/master/src/index.js
if (contentAuthor === 'orange') {
if (classification === 'invoicing') {
if (categories && categories.length > 0) {
if (categories[0] === 'phone') {
return Qualification.getByLabel('phone_invoice')
} else if (categories[0] === 'isp') {
return Qualification.getByLabel('telecom_invoice') // it might be both isp and phone
}
}
} else if (classification === 'payslip') {
return Qualification.getByLabel('pay_sheet')
}
}
// See https://github.com/konnectors/cozy-konnector-sncf/blob/master/src/index.js
else if (contentAuthor === 'sncf') {
return Qualification.getByLabel('transport_invoice')
}
// See https://github.com/konnectors/cozy-konnector-bouyguestelecom/blob/src/index.js
// See https://github.com/konnectors/cozy-konnector-bouyguesbox/blob/src/index.js
else if (contentAuthor === 'bouygues') {
return Qualification.getByLabel('telecom_invoice')
}
// See https://github.com/konnectors/cozy-konnector-free-mobile/blob/master/src/index.js
// See https://github.com/konnectors/cozy-konnector-free/blob/master/src/index.js
if (contentAuthor === 'free') {
if (categories && categories.length > 0) {
if (categories[0] === 'isp') {
return Qualification.getByLabel('isp_invoice')
} else if (categories[0] === 'phone') {
return Qualification.getByLabel('phone_invoice')
}
}
}
// See https://github.com/konnectors/edf/blob/master/src/index.js
if (contentAuthor === 'edf') {
return Qualification.getByLabel('energy_invoice')
}
// https://github.com/konnectors/cozy-konnector-ameli/blob/master/src/index.js
if (contentAuthor === 'ameli') {
return Qualification.getByLabel('health_invoice')
}
// https://github.com/konnectors/impots/blob/master/src/metadata.js
if (contentAuthor === 'impots.gouv') {
if (classification === 'tax_notice') {
return Qualification.getByLabel('tax_notice')
} else if (classification === 'tax_return') {
return Qualification.getByLabel('tax_return')
} else if (classification === 'tax_timetable') {
return Qualification.getByLabel('tax_timetable')
} else if (classification === 'mail') {
return Qualification.getByLabel('receipt')
.setSourceCategory('gov')
.setSourceSubCategory('tax')
.setSubjects(['tax'])
}
}
return null
}
/**
* Get the new qualification from a file with old qualification attributes.
*
* @param {object} file - The file to requalify
* @returns {object} The new qualification
*/
export const getFileRequalification = file => {
try {
const hasQualificationLabel = has(file, 'metadata.label')
// cozy-scanner stores the qualification label but konnectors don't
return hasQualificationLabel
? getNewQualificationSetFromCozyScanner(file)
: getNewQualificationSetFromKonnector(file)
} catch (e) {
log('error', `The file cannot be migrated. ${e}`)
return null
}
}
/**
* Migrate files by removing old qualification attributes and
* setting the new qualification.
*
* @param {object} client - The CozyClient instance
* @param {Array} files - The files to migrate
* @returns {Array} The saved files
*/
export const migrateQualifiedFiles = async (client, files) => {
let updatedFiles = []
for (const file of files) {
const newQualification = getFileRequalification(file)
if (newQualification) {
const cleanedFile = removeOldQualificationAttributes(file)
const newFile = await saveFileQualification(
client,
cleanedFile,
newQualification
)
updatedFiles.push(newFile)
} else {
log('warn', `No migration case found for the file ${file._id}`)
}
}
return updatedFiles
}
================================================
FILE: src/lib/migration/qualification.spec.js
================================================
import log from 'cozy-logger'
import {
extractFilesToMigrate,
getFileRequalification,
getMostRecentUpdatedDate,
removeOldQualificationAttributes
} from '@/lib/migration/qualification'
jest.mock('cozy-logger', () => jest.fn())
describe('qualification migration', () => {
it('should extract files to migrate based on qualification attributes', () => {
const fileNoQualif = {
metadata: {
datetime: '2020-01-01'
}
}
const fileFullQualif = {
metadata: {
id: '1',
label: 'dummy',
classification: 'dummy',
subClassification: 'dummy',
categorie: 'dummy',
category: 'dummy',
categories: ['dummies'],
subject: 'dummy',
subjects: ['dummy']
}
}
const files = [fileNoQualif, fileFullQualif]
const filesToMigrate = extractFilesToMigrate(files)
expect(filesToMigrate).toHaveLength(1)
expect(filesToMigrate[0]).toEqual(fileFullQualif)
})
it('should not extract files with id but not label attributes', () => {
const file = {
metadata: {
id: '1',
qualification: {}
}
}
expect(extractFilesToMigrate([file])).toHaveLength(0)
})
it('should get the new qualification for a file qualified by cozy-client', () => {
const file = {
metadata: {
id: '22',
classification: 'invoicing',
categorie: 'health',
label: 'health_invoice'
}
}
const qualif = getFileRequalification(file)
expect(qualif).toEqual({
icon: 'heart',
label: 'health_invoice',
purpose: 'invoice',
sourceCategory: 'health'
})
})
it('should get the new qualification for a file qualified by a konnector', () => {
const file = {
metadata: {
contentAuthor: 'ameli',
classification: 'invoicing',
categorie: 'health',
label: 'health_invoice'
}
}
const qualif = getFileRequalification(file)
expect(qualif).toEqual({
icon: 'heart',
label: 'health_invoice',
purpose: 'invoice',
sourceCategory: 'health'
})
})
it('should log an error null when no qualification is possible', () => {
const file = {
metadata: {
label: 'fake_label'
}
}
expect(getFileRequalification(file)).toBeNull()
expect(log).toHaveBeenCalledWith('error', expect.anything())
})
it('should remove old qualification attributes', () => {
const file = {
metadata: {
id: 1,
label: 'label',
classification: 'classification',
subClassification: 'subClassification',
categorie: 'categorie',
category: 'category',
categories: 'categories',
subject: 'subject',
subjects: 'subjects',
datetime: '2020-10-10'
}
}
expect(removeOldQualificationAttributes(file)).toEqual({
metadata: {
id: 1,
datetime: '2020-10-10'
}
})
file.metadata = {}
expect(removeOldQualificationAttributes(file)).toEqual(file)
})
it('should find the most recent date in a list of files', () => {
let files = []
expect(getMostRecentUpdatedDate(files)).toBeNull()
files = [{}, {}]
expect(getMostRecentUpdatedDate(files)).toBeNull()
files = [
{},
{
_id: '456',
data: {
attributes: {
cozyMetadata: {
updatedAt: '2020-01-01'
}
}
}
}
]
expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-01')
files = [
{},
{},
{
_id: '123',
data: {
attributes: {
cozyMetadata: {
updatedAt: '2020-01-01'
}
}
}
}
]
expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-01')
files = [
{
_id: '123',
data: {
attributes: {
cozyMetadata: {
updatedAt: '2020-01-02'
}
}
}
},
{},
{},
{
_id: '456',
data: {
attributes: {
cozyMetadata: {
updatedAt: '2020-01-01'
}
}
}
}
]
expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-02')
})
})
================================================
FILE: src/lib/path.js
================================================
/**
* Join two paths together ensuring there is only one slash between them
* @param {string} start
* @param {string} end
* @returns
*/
export function joinPath(start, end) {
return `${start}${start.endsWith('/') ? '' : '/'}${end}`
}
/**
* Get the parent folder path from a given path
* @param {string} path The path to get the parent folder from
* @returns {string|undefined} The path of the parent folder or undefined if the path is the root folder
*/
export const getParentPath = path => {
if (path === '/') return undefined
const parts = path.split('/')
parts.pop()
return parts.length === 1 ? '/' : parts.join('/')
}
================================================
FILE: src/lib/path.spec.js
================================================
import { getParentPath } from './path'
it('getParentPath', () => {
expect(getParentPath('/')).toBeUndefined()
expect(getParentPath('/folder1')).toEqual('/')
expect(getParentPath('/folder1/folder2/folder3')).toEqual('/folder1/folder2')
expect(getParentPath('/folder1/folder2/file1.png')).toEqual(
'/folder1/folder2'
)
expect(getParentPath('/folder1/folder2')).toEqual('/folder1')
})
================================================
FILE: src/lib/queries.js
================================================
import { hasQueryBeenLoaded } from 'cozy-client'
/**
* Check if the query has been loaded and if it has data
*
* @param {import('cozy-client/types/types').UseQueryReturnValue} queryResult
* @returns {boolean}
*/
export const hasDataLoaded = queryResult => {
return hasQueryBeenLoaded(queryResult) && queryResult.data
}
export const parseFolderQueryId = maybeFolderQueryId => {
const splitted = maybeFolderQueryId.split(' ')
if (splitted.length !== 4) {
return null
}
return {
type: splitted[0],
folderId: splitted[1],
sortAttribute: splitted[2],
sortOrder: splitted[3]
}
}
export const formatFolderQueryId = (
type,
folderId,
sortAttribute,
sortOrder,
driveId = ''
) => {
return `${type} ${folderId} ${sortAttribute} ${sortOrder} ${driveId}`.trim()
}
/**
* Get the query for folder if given the query for files
* and vice versa.
*
* If given the queryId `directory id123 name desc`, will return
* the query `files id123 name desc`.
*/
export const getMirrorQueryId = queryId => {
const { type, folderId, sortAttribute, sortOrder } =
parseFolderQueryId(queryId)
const otherType = type === 'directory' ? 'file' : 'directory'
const otherQueryId = formatFolderQueryId(
otherType,
folderId,
sortAttribute,
sortOrder
)
return otherQueryId
}
================================================
FILE: src/lib/react-cozy-helpers/ModalManager.jsx
================================================
import React from 'react'
import { connect } from 'react-redux'
const SHOW_MODAL = 'SHOW_MODAL'
const HIDE_MODAL = 'HIDE_MODAL'
const reducer = (state = { show: false, component: null }, action) => {
switch (action.type) {
case SHOW_MODAL:
return { show: true, component: action.component }
case HIDE_MODAL:
return { show: false, component: null }
default:
return state
}
}
export default reducer
export const showModal = component => ({
type: SHOW_MODAL,
component,
meta: {
hideActionMenu: true
}
})
const hideModal = (meta = {}) => ({
type: HIDE_MODAL,
meta
})
export const ModalManager = connect(state => ({
...state.ui.modal
}))(({ show, component, dispatch }) => {
if (!show) return null
return React.cloneElement(component, {
onClose: meta => dispatch(hideModal(meta))
})
})
================================================
FILE: src/lib/react-cozy-helpers/QueryParameter.js
================================================
const arrToObj = (obj = {}, [key, val = true]) => {
obj[key] = decodeURIComponent(val)
return obj
}
const getQueryParameter = () =>
window.location.search
.substring(1)
.split('&')
.map(varval => varval.split('='))
.reduce(arrToObj, {})
export default getQueryParameter
================================================
FILE: src/lib/react-cozy-helpers/QueryParameter.spec.js
================================================
import getQueryParameter from './QueryParameter'
describe('getQueryParameter', () => {
afterEach(() => {
window.history.replaceState({}, '', '/')
})
it('should decode URI string', () => {
window.history.replaceState({}, '', '?username=N%C3%B6%C3%A9')
const { username } = getQueryParameter()
expect(username).toBe('Nöé')
})
it('should keep string with accent unchanged', () => {
window.history.replaceState({}, '', '?username=N%C3%B6%C3%A9')
const { username } = getQueryParameter()
expect(username).toBe('Nöé')
})
it('should not modify string with special characters', () => {
window.history.replaceState(
{},
'',
'?sharecode=eyJ_hbGc%2FiOiJ.S3mJz-B90iu.8D0%23JwCK'
)
const { sharecode } = getQueryParameter()
expect(sharecode).toBe('eyJ_hbGc/iOiJ.S3mJz-B90iu.8D0#JwCK')
})
})
================================================
FILE: src/lib/react-cozy-helpers/index.js
================================================
import { combineReducers } from 'redux'
import modalReducer from './ModalManager'
export default combineReducers({ modal: modalReducer })
export { ModalManager, showModal } from './ModalManager'
export { default as getQueryParameter } from './QueryParameter'
================================================
FILE: src/lib/registerClientPlugins.js
================================================
import flag from 'cozy-flags'
import { RealtimePlugin } from 'cozy-realtime'
const registerClientPlugins = client => {
client.registerPlugin(RealtimePlugin)
client.registerPlugin(flag.plugin)
}
export default registerClientPlugins
================================================
FILE: src/lib/sentry.js
================================================
import * as Sentry from '@sentry/react'
import { useEffect } from 'react'
import {
Routes,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes
} from 'react-router-dom'
import appMetadata from '@/lib/appMetadata'
Sentry.init({
dsn: 'https://05f3392b39bb4504a179c95aa5b0e8f6@errors.cozycloud.cc/41',
environment: process.env.NODE_ENV,
release: appMetadata.version,
integrations: [
// We also want to capture the `console.error` to, among other things,
// report the logs present in the `try/catch
Sentry.captureConsoleIntegration({ levels: ['error'] }),
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes
})
],
tracesSampleRate: 0.1,
// React log these warnings(bad Proptypes), in a console.error,
// it is not relevant to report this type of information to Sentry
ignoreErrors: [/^Warning: /]
})
export const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes)
================================================
FILE: src/locales/ar.json
================================================
{
"Nav": {
"item_drive": "القرص",
"item_recent": "الحديثة",
"item_activity": "النشاط",
"item_settings": "الإعدادات",
"btn-client-web": "تحصّل على كوزي",
"btn-client-mobile": "تحصّل على كوزي لجهازك المحمول !",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8"
},
"breadcrumb": {
"title_drive": "القرص",
"title_recent": "الحديثة",
"title_shared": "التي شاركتها",
"title_activity": "النشاط"
},
"Toolbar": {
"more": "المزيد"
},
"toolbar": {
"item_more": "المزيد",
"menu_select": "تحديد العناصر",
"menu_download_folder": "مُجلّد التنزيل",
"share": "شارك",
"select_all": "تحديد الكل",
"select_all_mobile": "الكل",
"clear_selection": "مسح التحديد",
"clear_selection_mobile": "مسح",
"delete_shared_drive": "حذف محرك الأقراص المشترك",
"sharings_tab_all": "الكل",
"sharings_tab_drives": "محركات الأقراص"
},
"Files": {
"share": {
"cta": "شارك",
"details": {
"title": "تفاصيل المشاركة"
},
"sharedWithMe": "مُشارَك معي",
"shareByEmail": {
"subtitle": "عبر البريد الإلكتروني",
"email": "إلى :",
"send": "إرسل"
},
"sharingLink": {
"title": "رابط المشاركة",
"copy": "نسخ",
"copied": "تم نسخه"
},
"protectedShare": {
"title": "قريبًا !"
},
"close": "غلق",
"gettingLink": "جارٍ جلب رابطك …"
}
},
"table": {
"head_name": "الإسم",
"head_update": "آخر تحديث",
"head_size": "الحجم",
"row_size_symbols": {
"B": "ب",
"KB": "كب",
"MB": "مب",
"GB": "جب",
"TB": "تب"
},
"load_more": "عرض المزيد"
},
"Storage": {
"title": "التخزين",
"availability": "متاح %{smart_count} جيجابايت",
"increase": "زيادة مساحتك"
},
"SelectionBar": {
"share": "مشاركة",
"download": "تنزيل",
"trash": "حذف",
"rename": "تعديل التسمية",
"restore": "إسترجاع",
"close": "غلق"
},
"DeleteConfirm": {
"cancel": "إلغاء",
"delete": "حذف"
},
"emptytrashconfirmation": {
"cancel": "إلغاء",
"delete": "حذف الكل"
},
"DestroyConfirm": {
"cancel": "إلغاء"
},
"quotaalert": {
"confirm": "نعم"
},
"loading": {
"message": "تحميل"
},
"error": {
"download_file": {
"offline": "يتوجب أن تكون متصلا لتنزيل هذا الملف",
"missing": "إنّ الملف مفقود"
}
},
"alert": {
"could_not_open_file": "لقد تعذّر فتح هذا الملف",
"item_copied": "تم نسخ عنصر واحد",
"items_copied": "تم نسخ %{count} عنصر",
"item_cut": "تم قص عنصر واحد",
"items_cut": "تم قص %{count} عنصر",
"item_moved": "تم نقل عنصر واحد",
"items_moved": "تم نقل %{count} عنصر",
"item_pasted": "تم نقل عنصر واحد",
"items_pasted": "تم نقل %{count} عنصر",
"copy_files_only": "لا يمكن نسخ المجلدات",
"copy_not_allowed": "عملية النسخ غير مسموحة في هذا العرض.",
"cut_not_allowed": "عملية القص غير مسموحة في هذا العرض.",
"paste_error": "حدث خطأ أثناء لصق الملفات",
"paste_failed": "فشل في لصق الملفات",
"paste_sharing_error": "لا يمكن لصق الملفات بسبب قيود المشاركة. يرجى استخدام إجراء النقل بدلاً من ذلك.",
"paste_same_folder_skipped": "لا يمكن نقل العناصر إلى نفس المجلد الذي توجد فيه بالفعل.",
"paste_not_allowed": "لا يمكنك اللصق في هذا المجلد",
"cannot_move_shared_drive": "لا يمكنك نقل مجلد القرص المشترك",
"cannot_copy_shared_drive": "لا يمكنك نسخ مجلد محرك الأقراص المشترك"
},
"UploadQueue": {
"close": "غلق",
"item": {
"pending": "معلق"
}
},
"Viewer": {
"close": "إغلاق",
"noviewer": {
"download": "نزِّل هذا الملف"
},
"actions": {
"download": "تنزيل"
},
"loading": {
"retry": "إعادة المحاولة"
}
},
"actions": {
"details": "تفاصيل",
"personalizeFolder": {
"label": "تخصيص المجلد"
},
"summariseByAI": "تلخيص"
},
"FolderCustomizer": {
"title": "تخصيص المجلد",
"description": "اختر لونًا محددًا لمجلدك",
"cancel": "إلغاء",
"apply": "تطبيق",
"error": "حدث خطأ، يرجى المحاولة مرة أخرى.",
"tabs": {
"colors": "الألوان",
"icons": "الأيقونات"
},
"iconPicker": {
"recents": "المستخدمة مؤخراً",
"chooseCustomIcon": "اختر أيقونة مخصصة"
}
}
}
================================================
FILE: src/locales/de.json
================================================
{
"Nav": {
"item_drive": "Laufwerk",
"item_recent": "Zuletzt",
"item_sharings": "Freigaben",
"item_shared": "Von mir geteilt",
"item_activity": "Aktivität",
"item_trash": "Papierkorb",
"item_settings": "Einstellungen",
"item_collect": "Verwaltung",
"btn-client": "Hol' dir Twake für den Desktop!",
"btn-client-web": "Hol' dir Twake!",
"btn-client-mobile": "Hol' dir %{name} auf dein Handy!",
"banner-txt-client": "Hol' dir %{name} für den Desktop und synchronisiere deine Dateien sicher, um jederzeit auf sie zuzugreifen.",
"banner-btn-client": "Herunterladen",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8",
"link-client-web": "https://cozy.io/try-it"
},
"breadcrumb": {
"title_drive": "Laufwerk",
"title_recent": "Neueste",
"title_sharings": "Freigaben",
"title_shared": "Von mir geteilt",
"title_activity": "Aktivität",
"title_trash": "Papierkorb"
},
"Toolbar": {
"more": "Mehr"
},
"toolbar": {
"menu_upload": "Dateien hochladen",
"item_more": "Mehr",
"menu_new_folder": "Ordner",
"menu_select": "Elemente auswählen",
"menu_share_folder": "Ordner freigeben",
"menu_download": "Herunterladen",
"menu_sync_cozy": "In mein Twake synchronisieren",
"add_to_mine": "Meinem Twake hinzufügen",
"menu_download_folder": "Ordner herunterladen",
"menu_download_file": "Diese Datei herunterladen",
"menu_create_note": "Notizen",
"menu_create_shortcut": "Abkürzung",
"empty_trash": "Papierkorb leeren",
"share": "Freigeben",
"trash": "Entfernen",
"delete_shared_drive": "Gemeinsames Laufwerk löschen",
"leave": "Geteilten Ordner verlassen & löschen",
"menu_add": "Hinzufügen",
"menu_create": "Erstellen",
"menu_onlyOffice": {
"text": "Textdokument",
"spreadsheet": "Tabellenkalkulation",
"slide": "Präsentation"
},
"select_all": "Alle auswählen",
"clear_selection": "Auswahl aufheben",
"sharings_tab_all": "Alle",
"sharings_tab_drives": "Laufwerke"
},
"Share": {
"create-cozy": "Meinen Twake erstellen"
},
"Files": {
"share": {
"cta": "Freigeben",
"title": "Freigeben",
"details": {
"title": "Details freigeben",
"createdAt": "Am %{date}",
"ro": "Kann lesen",
"rw": "Darf ändern",
"desc": {
"ro": "Du kannst diesen Inhalt sehen, herunterladen und deinem Twake hinzufügen. Du wirst Aktualisierungen des Besitzers erhalten, selbst jedoch keine Änderungen vornehmen können.",
"rw": "Du kannst diesen Inhalt sehen, ändern, löschen und deinem Twake hinzufügen. Deine Änderungen sind auf anderen Cozies sichtbar."
}
},
"sharedByMe": "Von mir geteilt",
"sharedWithMe": "Mit mir geteilt",
"sharedBy": "Geteilt von %{name}",
"shareByLink": {
"subtitle": "Über öffentlichen Link",
"desc": "Jeder der den Link kennt, kann deine Dateien sehen und herunterladen.",
"creating": "Erstellen deines Links...",
"copy": "Link kopieren",
"copied": "Der Link wurde in deine Zwischenablage kopiert",
"failed": "Unfähig in deine Zwischenablage zu kopieren"
},
"shareByEmail": {
"subtitle": "Per E-Mail",
"email": "An:",
"emailPlaceholder": "Gib die E-Mail-Adresse oder den Namen des Empfängers ein",
"send": "Senden",
"genericSuccess": "Du hast eine Einladung an %{count} Kontakte gesendet.",
"success": "Du hast eine Einladung an %{email} gesendet.",
"comingsoon": "Bald verfügbar: Du wirst mit nur einem Klick Fotos und Dokumente deiner Familie, deinen Freunden und sogar deinen Kollegen freigeben können. Keine Sorge, wir benachrichtigen dich sobald es soweit ist!",
"onlyByLink": "Dieser %{type} kann nur als Link geteilt werden, da",
"type": {
"file": "Datei",
"folder": "Ordner"
},
"hasSharedParent": "hat einen geteilten Ursprung",
"hasSharedChild": "enthält ein geteiltes Element"
},
"revoke": {
"title": "Freigabe aufheben",
"desc": "Dieser Kontakt behält eine Kopie. Änderungen werden jedoch nicht mehr synchronisiert.",
"success": "Du hast diese geteilte Datei von %{email} entfernt."
},
"revokeSelf": {
"title": "Entferne mich von der Freigabe",
"desc": "Du behältst den Inhalt. Er wird aber nicht mehr in deinem Twake erneuert.",
"success": "Du wurdest von dieser Freigabe entfernt."
},
"sharingLink": {
"title": "Link zum Freigeben",
"copy": "Kopieren",
"copied": "Kopiert"
},
"whoHasAccess": {
"title": "1 Person hat Zugriff |||| %{smart_count} Personen haben Zugriff"
},
"protectedShare": {
"title": "Bald verfügbar!",
"desc": "Teile alles per E-Mail mit deiner Familie und Freunden!"
},
"close": "Schließen",
"gettingLink": "Erstelle deinen Link ...",
"error": {
"generic": "Beim Erstellen des Dateifreigabelinks ist ein Fehler aufgetreten, bitte versuche es erneut.",
"revoke": "Hoppla, das hat nicht geklappt. Bitte kontaktiere uns, damit wir den Fehler so schnell wie möglich beheben können."
},
"specialCase": {
"base": "Dieser %{type} kann nur als Link geteilt werden, da",
"isInSharedFolder": "ist in einem geteilten Ordner",
"hasSharedFolder": "enthält einen geteilten Ordner"
}
},
"viewer-fallback": "Sobald der Download begonnen hat, kannst du mich schließen.",
"dropzone": {
"teaser": "Ziehe Dateien hierher um sie hochzuladen:",
"noFolderSupport": "Ordner drag&drop wird von deinem Browser derzeit nicht unterstützt. Lade deine Dateien bitte manuell hoch."
}
},
"table": {
"head_name": "Name",
"head_update": "Letzte Änderung",
"head_size": "Größe",
"head_status": "Teilen",
"head_thumbnail_size": "Wechsele die Größe des Vorschaubildes",
"row_update_format": "dd.LL.yyyy",
"row_update_format_full": "dd.LL.yyyy",
"row_read_only": "Freigeben (nur Lesen)",
"row_read_write": "Freigeben (Lesen & Schreiben)",
"row_size_symbols": {
"B": "Byte",
"KB": "Kilobyte",
"MB": "Megabyte",
"GB": "Gigabyte",
"TB": "Terabyte",
"PB": "Petabyte",
"EB": "Exabyte",
"ZB": "Zettabyte",
"YB": "Yottabyte"
},
"load_more": "Mehr laden",
"mobile": {
"head_name_asc": "A-Z",
"head_name_desc": "Z-A",
"head_updated_at_asc": "Älteste zuerst",
"head_updated_at_desc": "Neueste zuerst",
"head_size_asc": "Kleinste zuerst",
"head_size_desc": "Größte zuerst"
},
"tooltip": {
"carbonCopy": {
"title": "Durchschlag",
"caption": "Zeigt an, ob das Dokument von Twake Workplace, dem Host Ihrer Twake, als \"authentisch und original\" definiert wird, da es behaupten kann, dass es direkt von einem Drittanbieterdienst stammt, ohne dass es verändert wurde."
},
"electronicSafe": {
"title": "Elektronischer Tresor",
"caption": "Gibt an, ob das Originaldokument in Ihrem persönlichen digitalen Tresor mit den Zertifizierungen, die ihm Beweiskraft verleihen, und einer 50-jährigen Aufbewahrungsgarantie über die Hinterlegung hinaus gesichert ist."
}
}
},
"Storage": {
"title": "Speicher",
"availability": "%{smart_count} GB verfügbar",
"increase": "Speicherplatz erweitern"
},
"SelectionBar": {
"selected_count": "Element ausgewählt |||| Elemente ausgewählt",
"share": "Freigeben",
"download": "Herunterladen",
"trash": "Entfernen",
"destroy": "Dauerhaft löschen",
"rename": "Umbenennen",
"restore": "Wiederherstellen",
"close": "Schließen",
"openWith": "Öffnen mit...",
"applePreview": "Apple Vorschau",
"forward": "Weiterleiten",
"forwardTo": "Weiterleiten an...",
"moveto": "Verschieben nach...",
"moveto_mobile": "Verschieben",
"phone-download": "Offline verfügbar machen",
"qualify": "Kategorisieren",
"history": "Verlauf"
},
"DeleteConfirm": {
"title": "Dieses Element löschen? |||| Diese Elemente löschen?",
"trash": "Es wird in den Papierkorb verschoben. |||| Sie werden in den Papierkorb verschoben.",
"restore": "Du kannst es jederzeit wiederherstellen. |||| Du kannst sie jederzeit wiederherstellen.",
"link": "Link Freigabe wird nicht länger aktiv sein",
"referenced": "Einige der Dateien innerhalb der Auswahl beziehen sich auf ein Fotoalbum. Sie werden aus ihm entfernt, wenn du sie in den Müll verschiebst.",
"cancel": "Abbrechen",
"delete": "Entfernen"
},
"emptytrashconfirmation": {
"title": " Dauerhaft löschen? ",
"forbidden": "Du kannst nicht mehr auf diese Dateien zugreifen.",
"restore": "Du kannst diese Dateien nicht wiederherstellen, wenn du keine Sicherung gemacht hast.",
"cancel": "Abbrechen",
"delete": "Alles löschen"
},
"DestroyConfirm": {
"title": "Dauerhaft löschen?",
"forbidden": "Du kannst nicht mehr auf diese Datei zugreifen. |||| Du kannst nicht mehr auf diese Dateien zugreifen.",
"restore": "Du kannst diese Datei nicht wiederherstellen, wenn du keine Sicherung gemacht hast. |||| Du kannst diese Dateien nicht wiederherstellen, wenn du keine Sicherung gemacht hast.",
"cancel": "Abbrechen",
"delete": "Dauerhaft löschen"
},
"quotaalert": {
"title": "Dein Speicherplatz ist voll :(",
"desc": "Bitte entferne Dateien, leere deinen Mülleiemer oder erhöhe dein Speicherkontingent bevor du wieder Dateien hochlädtst.",
"confirm": "OK",
"increase": "Erhöhe dein Speicherkontingent"
},
"loading": {
"message": "Lädt",
"onlyOfficeCreateInProgress": "Erstellen der aktuellen Datei..."
},
"empty": {
"title": "Du hast keine Dateien in diesem Ordner.",
"text": "Wählen Sie Dateien auf Ihrem Computer aus oder ziehen Sie sie hierher.",
"mobile_text": "Wählen Sie Dateien auf Ihrem Gerät aus.",
"trash_title": "Du hast keine gelöschten Dateien.",
"trash_text": "Verschiebe Dateien, die du nicht länger benötigst in den Papierkorb und lösche Elemente dauerhaft, um Speicherplatz freizumachen."
},
"error": {
"open_folder": "Beim Öffnen des Ordners ist etwas schief gelaufen.",
"open_file": "Beim Öffnen der Datei ist etwas schief gelaufen.",
"button": {
"reload": "Jetzt aktualisieren"
},
"download_file": {
"offline": "Du solltest verbunden sein, um diese Datei herunterzuladen.",
"missing": "Diese Datei fehlt"
}
},
"Error": {
"public_unshared_title": "Entschuldige, dieser Links ist nicht länger verfügbar.",
"public_unshared_text": "Dieser Link ist abgelaufen oder vom Besitzer entfernt worden. Lass' es ihn wissen, dass du ihn verpasst hast.",
"generic": "Etwas ist schiefgelaufen. Warte ein paar Minuten und versuche es erneut."
},
"alert": {
"could_not_open_file": "Diese Datei konnte nicht geöffnet werden",
"try_again": "Ein Fehler ist aufgetreten, bitte versuche es gleich noch einmal.",
"restore_file_success": "Die Auswahl wurde erfolgreich wiederhergestellt.",
"trash_file_success": "Die Auswahl wurde in den Papierkorb verschoben.",
"destroy_file_success": "Die Auswahl wurde endgültig gelöscht.",
"empty_trash_progress": "Dein Papierkorb wird entleert. Dies kann einen Augenblick dauern.",
"empty_trash_success": "Der Papierkorb wurde entleert.",
"folder_name": "Das Element %{folderName} existiert bereits, bitte wähle einen neuen Namen.",
"file_name": "Das Element %{fileName} existiert bereits, bitte wähle einen neuen Namen.",
"file_name_missing": "Der Dateiname ist falsch, bitte geben Sie einen neuen Namen ein.",
"file_name_illegal_name": "Der Name %{fileName} ist ungültig, bitte wählen Sie einen neuen Namen.",
"file_name_illegal_characters": "Das Element %{fileName} enthält ungültige Zeichen: %{characters}",
"folder_generic": " Ein Fehler ist aufgetreten, bitte versuche es noch einmal.",
"folder_abort": "Du musst deinem neuen Ordner einen Namen hinzufügen, wenn du ihn speichern möchtest. Deine Daten wurden nicht gespeichert.",
"offline": "Diese Funktion ist offline nicht verfügbar.",
"preparing": "Deine Dateien werden vorbereitet...",
"item_copied": "1 Element kopiert",
"items_copied": "%{count} Elemente kopiert",
"item_cut": "1 Element ausgeschnitten",
"items_cut": "%{count} Elemente ausgeschnitten",
"item_moved": "1 Element wurde verschoben",
"items_moved": "%{count} Elemente wurden verschoben",
"item_pasted": "1 Element wurde verschoben",
"items_pasted": "%{count} Elemente wurden verschoben",
"copy_files_only": "Ordner können nicht kopiert werden",
"copy_not_allowed": "Der Kopiervorgang ist in dieser Ansicht nicht erlaubt.",
"cut_not_allowed": "Der Ausschneiden-Vorgang ist in dieser Ansicht nicht erlaubt.",
"paste_error": "Beim Einfügen der Dateien ist ein Fehler aufgetreten",
"paste_failed": "Einfügen der Dateien fehlgeschlagen",
"paste_sharing_error": "Dateien können aufgrund von Freigabebeschränkungen nicht eingefügt werden. Bitte verwenden Sie stattdessen die Verschieben-Aktion.",
"paste_same_folder_skipped": "Elemente können nicht in denselben Ordner verschoben werden, in dem sie sich bereits befinden.",
"paste_not_allowed": "Sie können nicht in diesen Ordner einfügen",
"cannot_move_shared_drive": "Sie können den freigegebenen Laufwerksordner nicht verschieben",
"cannot_copy_shared_drive": "Du kannst keinen freigegebenen Laufwerksordner kopieren"
},
"upload": {
"label": "Hochladen",
"alert": {
"network": "Du bist zurzeit offline. Bitte versuche es erneut, sobald du wieder verbunden bist."
}
},
"intents": {
"alert": {
"error": "Unfähig, die Datei automatisch hochzuladen, bitte lade sie manuell über das Hochlademenü hoch."
},
"picker": {
"select": "Auswählen",
"cancel": "Abbrechen",
"new_folder": "Neuer Ordner",
"instructions": "Wähle ein Ziel"
}
},
"UploadQueue": {
"header": "Hochladen von %{smart_count} Foto in dein Twake Drive |||| Hochladen von %{smart_count} Fotos in dein Twake Drive",
"header_mobile": "Hochladen %{done} von %{total}",
"header_done": "Hochladen %{done} aus %{total} erfolgreich",
"close": "Schließen",
"item": {
"pending": "Ausstehend"
}
},
"Viewer": {
"close": "Schließen",
"noviewer": {
"download": "Diese Datei herunterladen",
"openWith": "Öffnen mit...",
"openInOnlyOffice": "Öffnen mit Only Office",
"cta": {
"saveTime": "Spare etwas Zeit!",
"installDesktop": "Installiere das Synchronisationstool für deinen Computer",
"accessFiles": "Greife direkt von deinem Computer auf deine Datein zu"
}
},
"actions": {
"download": "Herunterladen",
"forward": "Weiterleiten"
},
"loading": {
"error": "Diese Datei konnte nicht geladen werden. Hast du eine funktionierende Internetverbindung?",
"retry": "Wiederholen"
},
"error": {
"noapp": "Keine Anwendung auf Ihrem Gerät kann diese Datei verarbeiten.",
"generic": "Ein Fehler ist beim Öffnen dieser Datei aufgetreten, bitte versuche es erneut.",
"noNetwork": "Du bist derzeit offline."
},
"panel": {
"title": "Nützliche Informationen"
}
},
"Move": {
"to": "Verschiebe zu:",
"action": "Verschieben",
"cancel": "Abbrechen",
"modalTitle": "Verschieben",
"title": "%{smart_count} Element |||| %{smart_count} Elemente",
"success": "%{subject} wurde in %{target} verschoben. |||| %{smart_count} Elemente wurden in %{target} verschoben.",
"error": "Etwas ist beim Verschieben dieses Elements schiefgelaufen, bitte versuche es später erneut. |||| Etwas ist beim Verschieben dieser Elemente schiefgelaufen, bitte versuche es später erneut.",
"cancelled": "%{subject} wurde zurück an seinen Ursprungsort geschoben. |||| %{smart_count} Elemente wurden zurück an ihren Ursprungsort geschoben.",
"cancelledWithRestoreErrors": "%{subject} wurde zurück an seinen Ursprungsort geschoben, aber es gab einen Fehler beim Wiederherstellen der Datei aus dem Papierkorb. |||| %{smart_count} Elemente wurden zurück an ihren Ursprungsort geschoben, aber es gab %{restoreErrorsCount} Fehler beim Wiederherstellen der Datei(en) aus dem Papierkorb.",
"cancelled_error": "Entschuldige, es gab einen Fehler beim Zurückschieben dieses Elements. |||| Entschuldige, es gab einen Fehler beim Zurückschieben dieser Elemente."
},
"ImportToDrive": {
"title": "%{smart_count} Element |||| %{smart_count} Elemente",
"to": "Speichern in:",
"action": "Speichern",
"cancel": "Abbrechen",
"success": "%{smart_count} gesicherte Datei |||| %{smart_count} gesicherte Dateien",
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut"
},
"FileOpenerExternal": {
"fileNotFoundError": "Fehler: Datei nicht gefunden"
},
"TOS": {
"updated": {
"title": "GDPR wird Realität!",
"detail": "Im Rahmen der General Data Protection Regulation (GDPR), [wurden unsere Nutzungsbedingungen aktualisiert](%{link}) und werden ab dem 25. März 2018 auf alle unsere Nutzer angewandt.",
"cta": "TOS akzeptieren und fortfahren",
"disconnect": "Ablehnen und trennen",
"error": "Etwas ist schiefgelaufen. Bitte versuche es später erneut"
}
},
"manifest": {
"permissions": {
"contacts": {
"description": "Erforderlich, um deinen Kontakten Dateien freizugeben"
},
"groups": {
"description": "Erforderlich, um deinen Gruppen Dateien freizugeben"
}
}
},
"models": {
"contact": {
"defaultDisplayName": "Anonym"
}
},
"Scan": {
"scan_a_doc": "Scanne ein Dokument",
"save_doc": "Speichere das Dokument",
"filename": "Dateiname",
"save": "Speichern",
"cancel": "Abbrechen",
"qualify": "Kategorisieren",
"apply": "Anwenden",
"error": {
"offline": "Du bist derzeit offline und kannst diese Funktion nicht nutzen. Versuche es später erneut",
"uploading": "Du lädst bereits eine Datei hoch. Warte bis zur Fertigstellung und versuche es erneut.",
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut"
},
"successful": {
"qualified_ok": "Du hast die Datei erfolgreich kategorisiert!"
}
},
"History": {
"description": "Die letzten 20 Versionen deiner Dateien werden automatisch behalten. Wähle eine Version aus, um sie herunterzuladen.",
"current_version": "Aktuelle Version",
"loading": "Lädt...",
"noFileVersionEnabled": "Dein Twake wird bald dazu in der Lage sein, deine letzten Dateiänderungen zu archivieren, um einem Verlust vorzubeugen"
},
"External": {
"redirection": {
"title": "Weiterleitung",
"text": "Du wirst gleich weitergeleitet...",
"error": "Fehler während der Weiterleitung. Im Allgemeinen deutet dies auf ein falsches Format deines Inhalts hin."
}
},
"RenameModal": {
"title": "Umbenennen",
"description": "Du bist dabei, die Dateiendung zu ändern. Möchtest du fortfahren?",
"continue": "Fortsetzen",
"cancel": "Abbrechen"
},
"Shortcut": {
"title_modal": "Erstelle eine Verknüpfung",
"filename": "Dateiname",
"url": "URL",
"cancel": "Abbrechen",
"create": "Erstellen",
"created": "Deine Verknüpfung wurde erstellt",
"errored": "Ein Fehler ist aufgetreten",
"filename_error_ends": "Der Name sollte mit .url enden",
"needs_info": "Die Verknüpfung benötigt mindestens eine URL und einen Dateinamen",
"url_badformat": "Deine URL hat nicht das richtige Format"
},
"OnlyOffice": {
"Error": {
"title": "Etwas geht schief",
"text": "Bitte versuchen Sie, die Seite neu zu laden"
},
"readOnly": {
"title": "Nur lesen",
"tooltip": "Sie sind nur berechtigt, dieses Dokument anzusehen. Kontaktieren Sie den Eigentümer, um Schreibrechte zu erhalten."
},
"createFileName": {
"text": "Neues Textdokument",
"spreadsheet": " Neue Tabellenkalkulation",
"slide": "Neue Präsentation"
}
},
"Migration": {
"title": "Aktualisierte Twake Drive",
"content": "Twake Drive muss aktualisiert werden, um seine Leistung zu verbessern. Dies kann bis zu mehreren Minuten dauern, während derer Sie Ihre App nicht nutzen können. Möchten Sie es jetzt tun? Wenn Sie sich weigern, werden wir Sie beim nächsten Mal wieder fragen",
"confirm": "Okay, los geht's!",
"cancel": "Nein, nicht jetzt"
},
"searchbar": {
"placeholder": "Alle Dateien durchsuchen",
"empty": "Es wurde kein Ergebnis für die Suche \"%{query}\" gefunden"
},
"actions": {
"details": "Details",
"personalizeFolder": {
"label": "Ordner personalisieren"
},
"summariseByAI": "Zusammenfassen"
},
"FolderCustomizer": {
"title": "Ordner personalisieren",
"description": "Wählen Sie eine bestimmte Farbe für Ihren Ordner",
"cancel": "Abbrechen",
"apply": "Anwenden",
"error": "Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut.",
"tabs": {
"colors": "Farben",
"icons": "Symbole"
},
"iconPicker": {
"recents": "Zuletzt verwendet",
"chooseCustomIcon": "Wählen Sie ein benutzerdefiniertes Symbol"
}
}
}
================================================
FILE: src/locales/en.json
================================================
{
"Nav": {
"item_drive": "My Drive",
"item_recent": "Recents",
"item_sharings": "Sharings",
"item_shared": "Shared by me",
"item_activity": "Activity",
"item_trash": "Bin",
"item_migration": "Migration",
"item_settings": "Settings",
"item_collect": "Administrative",
"item_shared_drives": "Shared drives",
"item_favorites": "Favorites",
"item_external_drives": "External drives",
"item_my_drive": "My Drive",
"btn-client": "Get Twake Drive for desktop",
"btn-client-web": "Get Twake",
"btn-client-mobile": "Take your personnal cloud with you: install %{name} on all your devices!",
"banner-txt-client": "Get %{name} for Desktop and synchronise your files safely to make them accessible at all times.",
"banner-btn-client": "Download",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile",
"link-client-ios": "https://apps.apple.com/app/cloud-personnel-cozy/id1600636174",
"link-client-web": "https://cozy.io/try-it",
"view_more": "View more",
"view_less": "View less",
"item_nextcloud": "Nextcloud"
},
"breadcrumb": {
"title_drive": "Files",
"title_recent": "Recent",
"title_sharings": "Sharings",
"title_shared": "Shared by me",
"title_activity": "Activity",
"title_trash": "Trash",
"label": "Show path",
"title_shared_drives": "Drives",
"title_favorites": "Favorites"
},
"Toolbar": {
"more": "More"
},
"toolbar": {
"menu_manage_access": "Manage access",
"menu_leave_shared_drive": "Leave shared folder",
"menu_upload": "Upload files",
"item_more": "More",
"menu_new_folder": "Folder",
"menu_new_shared_drive": "Shared drive",
"menu_select": "Select items",
"menu_share_folder": "Share folder",
"menu_download": "Download",
"menu_sync_cozy": "Synchronise to my Twake",
"add_to_mine": "Add to my Twake",
"menu_download_folder": "Download folder",
"menu_download_file": "Download this file",
"menu_create_note": "Note",
"menu_create_docs": "Docs",
"menu_create_shortcut": "Shortcut",
"share": "Share",
"trash": "Remove",
"delete_shared_drive": "Delete shared drive",
"leave": "Leave shared folder & delete it",
"menu_add": "Add",
"menu_create": "Create",
"menu_add_item": "Add an item",
"menu_onlyOffice": {
"text": "Text document",
"spreadsheet": "Spreadsheet",
"slide": "Presentation"
},
"select_all": "Select all",
"select_all_mobile": "all",
"clear_selection": "Clear Selection",
"clear_selection_mobile": "Clear",
"sharings_tab_all": "All",
"sharings_tab_drives": "Drives"
},
"Share": {
"create-cozy": "Create my Twake"
},
"Files": {
"share": {
"cta": "Share",
"title": "Share",
"details": {
"title": "Sharing details",
"createdAt": "On %{date}",
"ro": "Can read",
"rw": "Can change",
"desc": {
"ro": "You can view, download, and add this content to your Twake. You will get updates by the owner, but you won't be able to update this content yourself.",
"rw": "You can view, update, delete and add this content to your Twake. Updates you make will be seen on other Cozies."
}
},
"shared": "Shared",
"sharedByMe": "Shared by me",
"sharedWithMe": "Shared with me",
"sharedBy": "Shared by %{name}",
"shareByLink": {
"subtitle": "By public link",
"desc": "Anyone with the provided link can see and download your files.",
"creating": "Creating your link...",
"copy": "Copy link",
"copied": "Link has been copied to clipboard",
"failed": "Unable to copy to clipboard"
},
"shareByEmail": {
"subtitle": "By email",
"email": "To:",
"emailPlaceholder": "Enter the email address or name of the recipient",
"send": "Send",
"genericSuccess": "You sent an invite to %{count} contacts.",
"success": "You sent an invite to %{email}.",
"comingsoon": "Coming soon! You will be able to share documents and photos in a single click with your family, your friends, and even your coworkers. Don't worry, we'll let you know when it's ready!",
"onlyByLink": "This %{type} can only be shared by link, because",
"type": {
"file": "file",
"folder": "folder"
},
"hasSharedParent": "it has a shared parent",
"hasSharedChild": "it contains a shared element"
},
"revoke": {
"title": "Remove from sharing",
"desc": "This contact will keep a copy but the changes won't be synchrnoized anymore.",
"success": "You removed this shared file from %{email}."
},
"revokeSelf": {
"title": "Remove me from sharing",
"desc": "You keep the content but it won't be updated between your Twake anymore.",
"success": "You were removed from this sharing."
},
"sharingLink": {
"title": "Link to share",
"copy": "Copy",
"copied": "Copied"
},
"whoHasAccess": {
"title": "1 person has access |||| %{smart_count} people have access"
},
"protectedShare": {
"title": "Coming soon!",
"desc": "Share anything by email with your family and friends!"
},
"close": "Close",
"gettingLink": "Getting your link...",
"error": {
"generic": "An error occurred when creating the file share link, please try again.",
"revoke": "Woops, an error occurred. Please contact us so we can fix this issue as soon as possible."
},
"specialCase": {
"base": "This %{type} cannot be shared but with a link as it",
"isInSharedFolder": "is in a shared folder",
"hasSharedFolder": "contains a shared folder"
}
},
"viewer-fallback": "If the file has started downloading, you can close this.",
"dropzone": {
"teaser": "Drop files to upload them to:",
"noFolderSupport": "Folder drag&drop is currently not supported by your browser. Please upload your files manually."
}
},
"table": {
"head_name": "Name",
"head_update": "Last update",
"head_size": "Size",
"head_status": "Share",
"head_thumbnail_size": "Switch thumbnail size",
"head_view_mode": "View mode",
"head_view_list": "List view",
"head_view_grid": "Grid view",
"row_update_format": "LLL d, yyyy",
"row_update_format_full": "LLLL d, yyyy",
"row_read_only": "Share (Read only)",
"row_read_write": "Share (Read & Write)",
"row_size_symbols": {
"B": "B",
"KB": "KB",
"MB": "MB",
"GB": "GB",
"TB": "TB",
"PB": "PB",
"EB": "EB",
"ZB": "ZB",
"YB": "YB"
},
"row_sharing_shortcut_aria_label": "New sharing shortcut",
"load_more": "Load More",
"mobile": {
"head_name_asc": "A-Z",
"head_name_desc": "Z-A",
"head_updated_at_asc": "Oldest first",
"head_updated_at_desc": "Most recent first",
"head_size_asc": "Lightest first",
"head_size_desc": "Heavier first"
},
"tooltip": {
"carbonCopy": {
"title": "Carbon Copy",
"caption": "Indicates whether the document is defined as \"authentic and original\" by Twake Workplace, the host of your Twake, as it can claim that it comes directly from a third-party service, without having undergone any modification."
},
"electronicSafe": {
"title": "Electronic Safe",
"caption": "Indicates whether the original document is secured by your personal digital safe with the certifications that give it probative value and a 50-year retention guarantee beyond its deposit."
}
}
},
"Storage": {
"title": "Storage",
"availability": "%{smart_count} GB available",
"increase": "Increase the space"
},
"SelectionBar": {
"selected_count": "item selected |||| items selected",
"share": "Share",
"download": "Download",
"copy": "Copy",
"cut": "Cut",
"paste": "Paste",
"trash": "Remove",
"trash_all": "Remove all",
"destroy": "Delete permanently",
"rename": "Rename",
"restore": "Restore",
"close": "Close",
"openWith": "Open with...",
"applePreview": "Apple preview",
"forward": "Forward",
"forwardTo": "Forward to...",
"moveto": "Move to…",
"moveto_mobile": "Move",
"phone-download": "Make available offline",
"qualify": "Categorize",
"history": "History",
"more": "More",
"openWithinNextcloud": "Open within Nextcloud"
},
"DeleteConfirm": {
"title": "Delete %{filename}? |||| Delete %{smart_count} %{type}?",
"trash": "It will be moved to the Trash. |||| They will be moved to the Trash.",
"restore": "You can still restore it whenever you want. |||| You can still restore them whenever you want.",
"share_accepted": "Sharing will be stopped. The following contacts will keep a copy, but your changes will no longer be synchronised:",
"share_waiting": "Sharing will be stopped. The following contacts will no longer be able to accept sharing and will no longer be able to access shared content:",
"share_both": "Sharing will be stopped. This means that contacts who have stored files in their Twake will keep a copy, while other contacts will no longer be able to access shared content:",
"link": "Link sharing will no longer be active",
"referenced": "Some of the files within the selection are related to a photo album. They will be removed from it if you proceed to trash them.",
"cancel": "Cancel",
"delete": "Remove"
},
"EmptyTrashConfirm": {
"title": "Permanently delete?",
"forbidden": "You won't be able to access these files anymore.",
"restore": "You won't be able to restore these files if you didn't make a backup.",
"cancel": "Cancel",
"delete": "Delete all",
"processing": "Your trash is being emptied. This might take a few moments.",
"success": "The trash has been emptied.",
"error": "An error occurred, please try again."
},
"DestroyConfirm": {
"title": "Delete %{filename}? |||| Delete %{smart_count} %{type}?",
"forbidden": "You won't be able to access this %{type} anymore. |||| You won't be able to access these %{type} anymore.",
"restore": "You won't be able to restore this %{type} if you didn't make a backup. |||| You won't be able to restore these %{type} if you didn't make a backup.",
"cancel": "Cancel",
"delete": "Delete permanently",
"success": "The %{type} has been deleted permanently. |||| %{smart_count} %{type} have been deleted permanently.",
"error": "An error occurred, please try again.",
"processing": "The deletion is in progress. This might take a few moments."
},
"quotaalert": {
"title": "Your disk space is full :(",
"desc": "Please remove files, empty your trash or increase your disk space before uploading files again.",
"confirm": "OK",
"increase": "Increase your disk space"
},
"loading": {
"message": "Loading",
"onlyOfficeCreateInProgress": "Creating the current file..."
},
"empty": {
"title": "You don’t have any files in this folder.",
"text": "Select files on your computer or drag them here.",
"mobile_text": "Select files on your device.",
"trash_title": "You don’t have any deleted files.",
"trash_text": "Move files you don't need anymore to the Trash and permanently delete items to free up storage page.",
"shared-drive_text": "Create and share your first drive."
},
"error": {
"open_folder": "Something went wrong when opening the folder.",
"open_file": "Something went wrong when opening the file.",
"button": {
"reload": "Refresh now"
},
"download_file": {
"offline": "You should be connected to download this file",
"missing": "This file is missing"
},
"paste_failed": "Failed to paste files. Please try again."
},
"Error": {
"public_unshared_title": "Sorry, this link is no longer available.",
"public_unshared_text": "This link has expired, or it was removed by its owner. Let him or her know that you missed it!",
"generic": "Something went wrong. Wait a few minutes and retry."
},
"alert": {
"could_not_open_file": "The file could not be opened",
"try_again": "An error has occurred, please try again in a moment.",
"restore_file_success": "The selection has been successfully restored.",
"trash_file_success": "The selection has been moved to the Trash.",
"trash_file_processing": "The move to Trash is in progress...",
"trash_shared_drive_success": "The shared drive has been moved to the Trash.",
"destroy_file_success": "The selection has been deleted permanently.",
"folder_name": "The element %{folderName} already exists, please choose a new name.",
"file_name": "The element %{fileName} already exists, please choose a new name.",
"file_name_missing": "The file name is missing, please choose a new name.",
"file_name_illegal_name": "The name %{fileName} is invalid, please choose a new name.",
"file_name_illegal_characters": "The element %{fileName} contains invalid characters: %{characters}",
"folder_generic": "An error occurred, please try again.",
"folder_abort": "You need to add a name to your new folder if you would like to save it. Your information has not been saved.",
"offline": "This feature is not available offline.",
"preparing": "Preparing your files…",
"item_copied": "1 item copied",
"items_copied": "%{count} items copied",
"item_cut": "1 item cut",
"items_cut": "%{count} items cut",
"item_moved": "1 item was moved",
"items_moved": "%{count} items were moved",
"item_pasted": "1 item was moved",
"items_pasted": "%{count} items were moved",
"copy_files_only": "Cannot copy folders",
"copy_not_allowed": "Copy operation is not allowed in this view.",
"cut_not_allowed": "Cut operation is not allowed in this view.",
"delete_not_allowed": "Delete operation is not allowed in this view.",
"paste_error": "An error occurred while pasting files",
"paste_failed": "Failed to paste files",
"paste_sharing_error": "Cannot paste files due to sharing restrictions. Please use the Move action instead.",
"paste_same_folder_skipped": "Cannot move items to the same folder they are already in.",
"paste_not_allowed": "You cannot paste into this folder",
"cannot_move_shared_drive": "You cannot move shared drive folder",
"cannot_copy_shared_drive": "You cannot copy shared drive folder"
},
"upload": {
"label": "Upload",
"documentType": {
"file": "file",
"directory": "folder",
"element": "element"
},
"alert": {
"success": "%{smart_count} %{type} uploaded with success. |||| %{smart_count} %{type} uploaded with success.",
"success_conflicts": "%{smart_count} %{type} uploaded with %{conflictNumber} conflict(s). |||| %{smart_count} %{type} uploaded with %{conflictNumber} conflict(s).",
"success_updated": "%{smart_count} %{type} uploaded and %{updatedCount} updated. |||| %{smart_count} %{type} uploaded and %{updatedCount} updated.",
"success_updated_conflicts": "%{smart_count} %{type} uploaded, %{updatedCount} updated and %{conflictCount} conflict(s). |||| %{smart_count} %{type} uploaded, %{updatedCount} updated and %{conflictCount} conflict(s).",
"updated": "%{smart_count} %{type} updated. |||| %{smart_count} %{type} updated.",
"updated_conflicts": "%{smart_count} %{type} updated with %{conflictCount} conflict(s). |||| %{smart_count} %{type} updated with %{conflictCount} conflict(s).",
"errors": "Errors occurred during the %{type} upload.",
"network": "You are currenly offline. Please try again once you're connected.",
"fileTooLargeErrors": "File too large. Maximum file size: %{max_size_value} GB",
"unreadable_files": "Some files could not be read. The file path may be too long or the folder was modified during the transfer."
},
"limit": {
"title": "You cannot upload more than %{limit} files at a time.",
"content": "Need to upload more? Consider downloading the synchronization tool to your computer",
"content_public": "Please reduce the number of files and try again.",
"cancel": "Cancel",
"close": "Close",
"download_desktop": "Download on Desktop"
}
},
"intents": {
"alert": {
"error": "Unable to automatically upload the file, please upload it manually with the upload menu."
},
"picker": {
"select": "Select",
"cancel": "Cancel",
"new_folder": "New folder",
"instructions": "Select a target"
}
},
"UploadQueue": {
"header": "Uploading %{smart_count} item to Twake Drive |||| Uploading %{smart_count} items to Twake Drive",
"header_preparing": "Preparing %{smart_count} item for upload |||| Preparing %{smart_count} items for upload",
"header_mobile": "Uploading %{done} of %{total}",
"header_done": "Uploaded %{done} out of %{total} successfully",
"success_flagship": "%{smart_count} file uploaded with success. |||| %{smart_count} files uploaded with success.",
"close": "close",
"item": {
"pending": "Pending",
"preparing": "Preparing"
}
},
"Viewer": {
"close": "Close",
"noviewer": {
"download": "Download this file",
"openWith": "Open with...",
"openInOnlyOffice": "Open with Only Office",
"cta": {
"saveTime": "Save some time!",
"installDesktop": "Install the synchronization tool for your computer",
"accessFiles": "Access your files directly on your computer"
}
},
"actions": {
"download": "Download",
"forward": "Forward"
},
"loading": {
"error": "This file could not be loaded. Do you have a working internet connection right now?",
"retry": "Retry"
},
"error": {
"noapp": "No application on your device can handle this file.",
"generic": "An error occurred when opening this file, please try again.",
"noNetwork": "You're currently offline."
},
"panel": {
"title": "Useful information"
}
},
"Move": {
"to": "Move to:",
"action": "Move",
"cancel": "Cancel",
"modalTitle": "Move",
"title": "%{smart_count} element |||| %{smart_count} elements",
"success": "%{subject} has been moved to %{target}. |||| %{smart_count} elements have been moved to %{target}.",
"error": "Something went wrong while moving this element, please try again later. |||| Something went wrong while moving these elements, please try again later.",
"cancelled": "%{subject} has been moved back to it's original location. |||| %{smart_count} elements have been moved back to their original location.",
"cancelledWithRestoreErrors": "%{subject} has been moved back to it's original location but there was an error while restoring the file from trash. |||| %{smart_count} elements have been moved back to their original location but there was %{restoreErrorsCount} error(s) while restoring the file(s) from trash.",
"cancelled_error": "Sorry, there was an error while moving the element back. |||| Sorry, there was an error while moving these elements back.",
"multipleEntries": "%{smart_count} element |||| %{smart_count} elements",
"addFolder": "Add a folder",
"outsideSharedFolder": {
"title": "Moving outside the %{sharedFolder} folder",
"content_1": "Warning, you want to move %{name} out of the shared %{sharedFolder} folder. |||| Warning, you want to move %{smart_count} %{type} out of the shared %{sharedFolder} folder.",
"content_2": "This move, will remove the %{type} %{name} from the share. This %{type} will therefore be trashed for all members of the share. |||| This move, will remove %{smart_count} %{type} from the share. These %{type} will therefore be trashed for all members of the share.",
"cancel": "Cancel",
"confirm": "I understand"
},
"insideSharedFolder": {
"title": "Move to a shared folder?",
"content": "All members with access to %{destination} will also have access to %{source}. |||| All members with access to %{destination} will also have access to the selected %{type}.",
"cancel": "Cancel",
"confirm": "Ok"
},
"sharedFolderInsideAnother": {
"title": "Cannot be moved",
"content_1": "You want to move a shared element into a shared folder. This type of move is not allowed.",
"content_2": "If you still wish to move %{source} to %{destination}, please stop sharing :",
"cancel": "Cancel move",
"confirm": "Stop sharing"
}
},
"ImportToDrive": {
"title": "%{smart_count} element |||| %{smart_count} elements",
"to": "Save in:",
"action": "Save",
"cancel": "Cancel",
"success": "%{smart_count} saved file |||| %{smart_count} saved files",
"error": "Something went wrong. Please try again"
},
"FileOpenerExternal": {
"fileNotFoundError": "Error: file not found"
},
"TOS": {
"updated": {
"title": "GDPR comes into reality !",
"detail": "In the context of the General Data Protection Regulation, [our Terms of Service have been updated](%{link}) and will apply to all our Twake users on May 25, 2018.",
"cta": "Accept TOS and continue",
"disconnect": "Refuse and disconnect",
"error": "Something went wrong, please try again later"
}
},
"manifest": {
"permissions": {
"contacts": {
"description": "Required to share files with your contacts"
},
"groups": {
"description": "Required to share files with your groups"
}
}
},
"models": {
"contact": {
"defaultDisplayName": "Anonymous"
}
},
"Scan": {
"none": "Nothing",
"scan_a_doc": "Scan a doc",
"save_doc": "Save the doc",
"filename": "Filename",
"save": "Save",
"cancel": "Cancel",
"qualify": "Categorize",
"requalify": "Re-categorize",
"apply": "Apply",
"error": {
"offline": "You are currently offline and you can't use this functionnality. Try it later",
"uploading": "You are already uploading a file. Wait until the end of this upload and try again.",
"generic": "Something went wrong. Please try again."
},
"successful": {
"qualified_ok": "You just have successfully categorized your file! "
}
},
"History": {
"description": "The last 20 versions of your files are automatically kept. Select a version to download it.",
"current_version": "Current version",
"loading": "Loading...",
"noFileVersionEnabled": "Your Twake will soon be able to archive the last modifications of a file to never risk losing them again"
},
"External": {
"redirection": {
"title": "Redirection",
"text": "You're about to be redirected…",
"error": "Error during the redirection. Generally, this means that the content of the file is not in the correct format."
}
},
"RenameModal": {
"title": "Rename",
"description": "You're about to change the file's extension. Do you want to continue?",
"continue": "Continue",
"cancel": "Cancel"
},
"Shortcut": {
"title_modal": "Create a shortcut",
"filename": "Filename",
"url": "URL",
"cancel": "Cancel",
"create": "Create",
"created": "Your shortcut has been created",
"errored": "An error occured",
"filename_error_ends": "The name should end with .url",
"needs_info": "Shorcut needs at least an url and a filename",
"url_badformat": "Your url is not in the right format"
},
"OnlyOffice": {
"Error": {
"title": "Something goes wrong",
"text": "Please try to reload the page"
},
"readOnly": {
"title": "Read only",
"tooltip": "You are only authorized to view this document. Contact the owner to obtain writing privileges."
},
"createFileName": {
"text": "New text document",
"spreadsheet": "New spreadsheet",
"slide": "New presentation"
},
"toolbar": {
"goToHome": "Go to home"
},
"actions": {
"edit": "Edit",
"validate": "Validate"
},
"tooltip": {
"title": "Edit document",
"text": "The document is currently read-only. You can modify it by clicking here.",
"actions": {
"ok": "Ok",
"hide": "Do not display"
}
}
},
"Migration": {
"title": "Update Twake Drive",
"content": "Twake Drive needs to update in order to improve its performances. This might take up to several minutes during which you cannot use your app. Do you want to do it now? If you refuse, we will ask you again next time",
"confirm": "Ok, let's do it!",
"cancel": "No, not now"
},
"searchbar": {
"placeholder": "Search anything",
"empty": "No result has been found for the query “%{query}”"
},
"button": {
"back": "Back",
"add": "Add",
"create": "Create"
},
"search": {
"action": "Search",
"empty": {
"title": "No result",
"subtitle": "No result has been found for the query “%{query}”"
}
},
"PushBanner": {
"quota": {
"text": "You've almost run out of storage space. If you reach the limit, you won't be able to add any more files. You can delete files, empty your bin or change your offer.",
"actions": {
"first": "I understand",
"second": "Check our plans"
}
}
},
"FileDivergedModal": {
"title": "Someone has modified this file",
"content": "Someone has modified the file outside Twake while you were editing it, you can retrieve their modifications instead of yours or continue your editing in a new file.",
"confirm": "Continue editing",
"cancel": "See its changes",
"error": "An error occurred, please try again.",
"confirmReload": {
"title": "See the changes",
"content": "When you access the new file, your changes will be cancelled.",
"cancel": "Cancel",
"confirm": "Ok, I get it"
},
"viewMode": {
"title": "Someone has modified this file",
"content": "Someone has changed the contents of this file. You can retrieve these changes.",
"confirm": "See the changes"
}
},
"FileDeletedModal": {
"title": "Someone has deleted this file",
"content": "Someone has deleted this file while you were editing it. You can stop editing or restore the file to continue editing.",
"confirm": "Restore file",
"cancel": "Undo changes",
"error": "An error occurred, please try again."
},
"TrashedBanner": {
"text": "The item is in your trash",
"destroy": "Delete permanently",
"restore": "Restore",
"restoreSuccess": "The item has been restored",
"restoreError": "An error has occurred, please try again.",
"destroySuccess": "The item has been deleted"
},
"MigrationProgressBanner": {
"title": "Migration from Nextcloud in progress",
"percent": "%{percent}% complete",
"importing": "Importing %{count} files from Nextcloud...",
"cancel": "Cancel",
"done": {
"title": "Migration Complete!",
"body": "Successfully imported %{count} files from Nextcloud"
}
},
"EntriesType": {
"file": "file |||| files",
"directory": "folder |||| folders",
"element": "element |||| elements"
},
"NotFound": {
"title": "The element cannot be found",
"text": "We have not found anything at this address. This may be a typing error."
},
"NextcloudBreadcrumb": {
"root": "Shared Drives",
"trash": "Trash"
},
"NextcloudToolbar": {
"share": "Share"
},
"NextcloudDeleteConfirm": {
"title": "Delete %{filename}? |||| Delete %{smart_count} %{type}?",
"trash": "This item will be moved to the Nextcloud trash. |||| These items will be moved to the Nextcloud trash.",
"restore": "You can always restore it whenever you want from Nextcloud.",
"error": "An error occurred, please try again.",
"cancel": "Cancel",
"delete": "Delete"
},
"FileName": {
"sharedDrive": "Drives",
"trash": "Trash"
},
"NextcloudBanner": {
"title": "The items below are displayed from a NextCloud drive and are not stored in your Twake."
},
"favorites": {
"label": {
"add": "Add to favorites",
"addMobile": "Favorites",
"remove": "Remove from favorites"
},
"error": "An error occurred, please try again.",
"success": {
"add": "%{filename} has been added to favorites |||| These items have been added to favorites",
"remove": "%{filename} has been removed from favorites |||| These items have been removed from favorites"
}
},
"TrashToolbar": {
"emptyTrash": "Empty trash"
},
"RestoreNextcloudFile": {
"label": "Restore",
"success": "The item has been restored",
"error": "An error occurred, please try again."
},
"actions": {
"details": "Details",
"infos": "Details and qualification",
"infosMobile": "Details",
"duplicateTo": {
"label": "Duplicate to…"
},
"duplicateToMobile": {
"label": "Duplicate"
},
"personalizeFolder": {
"label": "Personalize folder"
},
"summariseByAI": "Summarise"
},
"DuplicateModal": {
"subTitle": "Duplicate to:",
"confirmLabel": "Duplicate here",
"success": "%{fileName} has been duplicated to %{destinationName}. |||| %{smart_count} elements have been duplicated to %{destinationName}.",
"error": "An error occurred, please try again."
},
"OpenFolderButton": {
"label": "Open directory"
},
"LastUpdate": {
"titleFormat": "LLLL dd, yyyy, HH:MM"
},
"AddMenu": {
"readOnlyFolder": "This is a read-only folder. You cannot perform this action."
},
"PublicNoteRedirect": {
"error": {
"title": "Unable to access document",
"subtitle": "The share link appears to be missing or invalid. Please ask the document owner to check access"
}
},
"FolderCustomizer": {
"title": "Personalize folder",
"description": "Choose a specific color for your folder",
"cancel": "Cancel",
"apply": "Apply",
"error": "An error occurred, please try again.",
"tabs": {
"colors": "Colors",
"icons": "Icons"
},
"iconPicker": {
"recents": "Recents",
"chooseCustomIcon": "Choose a custom icon"
}
},
"antivirus": {
"infectedFile": "This file is infected with a virus",
"popover": {
"title": "Downloading and sharing is blocked for security reasons",
"description": "Twake system detected a virus"
}
}
}
================================================
FILE: src/locales/es.json
================================================
{
"Nav": {
"item_drive": "Drive",
"item_recent": "Recientes",
"item_sharings": "Compartidos",
"item_shared": "Compartido por mí",
"item_activity": "Actividad",
"item_trash": "Papelera",
"item_settings": "Parámetros",
"item_collect": "Administración",
"btn-client": "Descargar Twake Drive para ordenador",
"btn-client-web": "Descargar Twake",
"btn-client-mobile": "Descargar %{name} en su celular",
"banner-txt-client": "Descargue %{name} para ordenador y sincronice sus archivos con toda seguridad para que les puedan ser accesibles todo el tiempo.",
"banner-btn-client": "Descargar",
"link-client": "https://cozy.io/es/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8",
"link-client-web": "https://cozy.io/try-it"
},
"breadcrumb": {
"title_drive": "Drive",
"title_recent": "Recientes",
"title_sharings": "Compartidos",
"title_shared": "Mis archivos compartidos",
"title_activity": "Actividad",
"title_trash": "Papelera"
},
"Toolbar": {
"more": "Más"
},
"toolbar": {
"menu_upload": "Cargar archivos",
"item_more": "Más",
"menu_new_folder": "Carpeta",
"menu_select": "Seleccionar los items",
"menu_share_folder": "Compartir carpeta",
"menu_download": "Descargar",
"menu_sync_cozy": "Sincronizar con mi Twake",
"add_to_mine": "Añadir a mi Twake",
"menu_download_folder": "Descargar carpeta",
"menu_download_file": "Descargar este archivo",
"menu_create_note": "Nota",
"empty_trash": "Vaciar la papelera",
"share": "Compartir",
"trash": "Suprimir",
"delete_shared_drive": "Eliminar unidad compartida",
"leave": "Salir de la carpeta compartida & borrarla",
"select_all": "Seleccionar todo",
"select_all_mobile": "todos",
"clear_selection": "Borrar selección",
"clear_selection_mobile": "Cancelar",
"sharings_tab_all": "Todo",
"sharings_tab_drives": "Unidades"
},
"Share": {
"create-cozy": "Crear mi Twake"
},
"Files": {
"share": {
"cta": "Compartir",
"title": "Compartir",
"details": {
"title": "Detalles de lo compartido",
"createdAt": "El %{date}",
"ro": "Puede leerlo",
"rw": "Puede cambiar",
"desc": {
"ro": "Usted puede consultar, descargar y añadir el contenido a su Coz. Recibirá las modificaciones que el propietario haga, pero usted no podrá modificarlo. ",
"rw": "Usted puede consultar, modificar y suprimir el contenido. Las modificaciones del contenido se repercutirán automaticamente entre sus Twake."
}
},
"sharedByMe": "Compartido por mí",
"sharedWithMe": "Compartido conmigo",
"sharedBy": "Compartido por %{name}",
"shareByLink": {
"subtitle": "Por enlace público",
"desc": "Quien disponga del enlace suministrado puede mirar y descargar sus archivos.",
"creating": "Creando el enlace...",
"copy": "Copiar el enlace",
"copied": "El enlace ha sido copiado en el portapapeles",
"failed": "No se puede copiar en el portapapeles"
},
"shareByEmail": {
"subtitle": "Por correo electrónico",
"email": "Para:",
"emailPlaceholder": "Entre la dirección email o el nombre del destinatario",
"send": "Enviar",
"genericSuccess": "Usted envía una invitación a %{count} contactos",
"success": "Ustad envía una invitación a %{email}.",
"comingsoon": "Dentro de poco, podrá compartir documentos y fotos en un solo clic con su familia, sus amigos e incluso sus compañeros de trabajo. No se preocupe, ¡le avisaremos cuando esté listo!",
"onlyByLink": "Este %{type} no se puede compartir con un enlace, ya que",
"type": {
"file": "Archivo",
"folder": "carpeta"
},
"hasSharedParent": "se encuentra en una carpeta compartida",
"hasSharedChild": "contiene un elemento compartido"
},
"revoke": {
"title": "Parar el intercambio",
"desc": "Su contacto conservará una copia pero los cambios que haga no se sincronizarán.",
"success": "Usted ha borrado este archivo compartido desde %{email}"
},
"revokeSelf": {
"title": "Parar el intercambio",
"desc": "Usted conservará el contenido pero no se actualizará más entre sus Twake.",
"success": "Usted fue borrado desde este compartir"
},
"sharingLink": {
"title": "Enlace a compartir",
"copy": "Copiar",
"copied": "Copiado"
},
"whoHasAccess": {
"title": "1 persona accede |||| %{smart_count} personas acceden"
},
"protectedShare": {
"title": "Vendrá pronto!",
"desc": "Compartir algo por email con su familia y sus amigos!"
},
"close": "Cerrar",
"gettingLink": "Creación del enlace...",
"error": {
"generic": "Ha ocurrido un error al usted crear el link para compartir el archivo, por favor vuelva a ensayar.",
"revoke": "Epa, Ha ocurrido un error. Contactos para resolver el problema cuanto antes."
},
"specialCase": {
"base": "ste %{type} no se puede compartir sino con un enlace como éste",
"isInSharedFolder": "está en una carpeta compartida",
"hasSharedFolder": "contiene una carpeta compartida"
}
},
"viewer-fallback": "Si el archivo ha comenzado a descargarse, puede cerrar esta ventana..",
"dropzone": {
"teaser": "Ponga los archivos para subirlos en:",
"noFolderSupport": "Por el momento, su navegador no acepta las funciones de arrastar-soltar de carpetas. Por favor, suba los archivos manualmente."
}
},
"table": {
"head_name": "Nombre",
"head_update": "Ultima actualización",
"head_size": "Tamaño",
"head_status": "Compartir",
"head_thumbnail_size": "Cambiar el tamaño de las miniaturas",
"row_update_format": "LLL d, yyyy",
"row_update_format_full": "LLL d, yyyy",
"row_read_only": "Compartido (sólo en lectura)",
"row_read_write": "Compartido (Lectura & Escritura)",
"row_size_symbols": {
"B": "o",
"KB": "Ko",
"MB": "Mo",
"GB": "Go",
"TB": "To",
"PB": "Po",
"EB": "Eo",
"ZB": "Zo",
"YB": "Yo"
},
"load_more": "Cargar más archivos",
"mobile": {
"head_name_asc": "A-Z",
"head_name_desc": "Z-A",
"head_updated_at_asc": "El más viejo primero",
"head_updated_at_desc": "El más reciente primero",
"head_size_asc": "El más liviano primero",
"head_size_desc": "El más pesado primero"
},
"tooltip": {
"carbonCopy": {
"title": "Copia Carbón",
"caption": "Indica si Twake Workplace, su sitio de hospedaje, define el documento como \"auténtico y original\", ya que puede afirmar que proviene directamente de un servicio de terceros, sin haber sufrido ninguna modificación."
},
"electronicSafe": {
"title": "Seguridad electrónica",
"caption": "Indica si el documento original está protegido por su seguridad digital personal con las certificaciones que le dan valor probatorio y una garantía de retención de 50 años más allá de su depósito si el documento es definido como \"auténtico y original\" por Twake Workplace, donde se hospeda su Twake, ya que puede afirmar que proviene directamente de un servicio de terceros, sin haber sufrido ninguna modificación.\n\n"
}
}
},
"Storage": {
"title": "Almacenamiento",
"availability": "%{smart_count} GB disponibles",
"increase": "Aumenta tu espacio"
},
"SelectionBar": {
"selected_count": "item seleccionado |||| items seleccionados",
"share": "Compartir",
"download": "Descargar",
"trash": "Borrar",
"destroy": "Borrar definitivamente",
"rename": "Cambiar el nombre",
"restore": "Restaurar",
"close": "Cerrar",
"openWith": "Abir con...",
"applePreview": "Vista preliminar de Apple",
"forward": "Enviar",
"forwardTo": "Enviar a...",
"moveto": "Trasladar a...",
"moveto_mobile": "Trasladar",
"phone-download": "Hacerla disponible cuando esté desconectado",
"qualify": "Clasificar",
"history": "Historia"
},
"DeleteConfirm": {
"title": "¿Suprimir este elemento? |||| ¿Suprimir estos elementos?",
"trash": "Será desplazado a la Papelera. ||| Serán desplazados a la Papelera.",
"restore": "Usted puede restaurarlo cuando lo desee. ||| Usted puede restaurarlos cuando lo desee.",
"link": "El intercambio de enlaces ya no estará activo",
"referenced": "Algunos de los archivos incluidos en la selección se refieren a un álbum de fotos. Se borrarán si usted procede a enviarlos a la papelera.",
"cancel": "Anular",
"delete": "Suprimir"
},
"emptytrashconfirmation": {
"title": "¿Suprimir definitivamente?",
"forbidden": "Usted no podrá acceder más a estos archivos.",
"restore": "Usted no podrá recuperar estos archivos si no ha hecho una copia de seguridad.",
"cancel": "Anular",
"delete": "Suprimir definitivamente"
},
"DestroyConfirm": {
"title": "¿Suprimir definitivamente?",
"forbidden": "Usted no podrá acceder más a este archivo. ||| Usted no podrá acceder más a estos archivos.",
"restore": "Usted no podrá recuperar este archivo si no ha hecho una copia de seguridad. ||| Usted no podrá recuperar estos archivos si no ha hecho una copia de seguridad.",
"cancel": "Anular",
"delete": "Suprimir definitivamente"
},
"quotaalert": {
"title": "Su espacio disco está lleno :(",
"desc": "Por favor, suprima archivos, vacíe su basura o aumente su espacio en el disco antes de volver a subir archivos.",
"confirm": "OK",
"increase": "Aumente su espacio disco"
},
"loading": {
"message": "Cargando"
},
"empty": {
"title": "No hay archivos en esta carpeta.",
"text": "Selecciona archivos en tu computadora o arrástralos aquí.",
"mobile_text": "Selecciona archivos en tu dispositivo.",
"trash_title": "Usted no tiene ningún archivo borrado.",
"trash_text": "Los archivos que no necesita más échelos a la Papelera y suprímalos definitivamente para liberar espacio de almacenamiento."
},
"error": {
"open_folder": "Algo ha fallado al abrir la carpeta.",
"button": {
"reload": "Actualizar ahora"
},
"download_file": {
"offline": "Usted debe estar conectado para descargar este archivo",
"missing": "Este archivo no existe"
}
},
"Error": {
"public_unshared_title": "Lo sentimos, este enlace ya no es válido.",
"public_unshared_text": "Este enlace ha caducado o ha sido eliminado por su propietario. Hágale saber a él o ella que lo ha perdido!",
"generic": "Algo ha fallado. Espere algunos minutos y vuelva a ensayar."
},
"alert": {
"could_not_open_file": "El archivo no se puede abrir",
"try_again": "Ha ocurrido un error, por favor ensaye más tarde.",
"restore_file_success": "La selección ha sido restaurada con éxito.",
"trash_file_success": "La selección ha sido desplazada a la Papelera.",
"destroy_file_success": "Se ha suprimido definitivamente la selección.",
"empty_trash_progress": "Su papelera se está vaciando. Esto puede tomar poco tiempo.",
"empty_trash_success": "La papelera ha sido vaciada.",
"folder_name": "El elemento %{folderName} ya existe, por favor escoger otro nombre.",
"file_name": "El elemento %{fileName} ya existe, por favor escoger otro nombre.",
"folder_generic": "Ha ocurrido un error, por favor vuelva a ensayar.",
"folder_abort": "Se requiere poner un nombre a la nueva carpeta si desea guardarla. Su información no ha sido guardada.",
"offline": "Esta función no esta disponible cuando usted está desconectado.",
"preparing": "Preparando sus archivos...",
"item_copied": "1 elemento copiado",
"items_copied": "%{count} elementos copiados",
"item_cut": "1 elemento cortado",
"items_cut": "%{count} elementos cortados",
"item_moved": "1 elemento ha sido movido",
"items_moved": "%{count} elementos han sido movidos",
"item_pasted": "1 elemento ha sido movido",
"items_pasted": "%{count} elementos han sido movidos",
"copy_files_only": "No se pueden copiar carpetas",
"copy_not_allowed": "La operación de copia no está permitida en esta vista.",
"cut_not_allowed": "La operación de corte no está permitida en esta vista.",
"paste_error": "Ha ocurrido un error al pegar los archivos",
"paste_failed": "Error al pegar los archivos",
"paste_sharing_error": "No se pueden pegar los archivos debido a restricciones de compartición. Por favor use la acción Mover en su lugar.",
"paste_same_folder_skipped": "No se pueden mover elementos a la misma carpeta en la que ya se encuentran.",
"paste_not_allowed": "No puedes pegar en esta carpeta",
"cannot_move_shared_drive": "No puedes mover la carpeta de unidad compartida",
"cannot_copy_shared_drive": "No puedes copiar la carpeta de la unidad compartida"
},
"upload": {
"label": "Subir",
"alert": {
"network": "Usted no dispone de una conexión internet. Vuelva a ensayar cuando disponga de una."
}
},
"intents": {
"alert": {
"error": "La recuperación del archivo ha fallado. Súbalo manualmente con ayuda del menú de Twake."
},
"picker": {
"select": "Seleccionar",
"cancel": "Anular",
"new_folder": "Nueva carpeta",
"instructions": "Seleccionar un blanco"
}
},
"UploadQueue": {
"header": "Subiendo %{smart_count} foto a Twake Drive |||| Subiendo %{smart_count} fotos a Twake Drive",
"header_mobile": "Subiendo %{done} de %{total}",
"header_done": "Subidos %{done} de %{total} con éxito",
"close": "cerrar",
"item": {
"pending": "Pendiente"
}
},
"Viewer": {
"close": "Cerrar",
"noviewer": {
"download": "Descargar este archivo",
"openWith": "Abir con...",
"cta": {
"saveTime": "¡Gane tiempo!",
"installDesktop": "Instale la herramienta de sincronización para su ordenador",
"accessFiles": "Acceda a sus archivos directamente desde su ordenador"
}
},
"actions": {
"download": "Descargar",
"forward": "Reenviar"
},
"loading": {
"error": "Este archivo no se puede cargar. ¿Tienes alguna conexión a Internet funcionando ahora?",
"retry": "Reinténtelo"
},
"error": {
"generic": "Se ha producido un error al abrir este archivo, por favor inténtelo de nuevo.",
"noNetwork": "Actualmente usted está desconectado."
},
"panel": {
"title": "Información útil"
}
},
"Move": {
"to": "Trasladar a:",
"action": "Trasladar",
"cancel": "Anular",
"modalTitle": "Trasladar",
"title": "%{smart_count} elemento |||| %{smart_count} elementos",
"success": "%{subject} ha sido desplazado a %{target}. |||| %{smart_count} elementos han sido desplazados a %{target}.",
"error": "Ha ocurrido un error al desplazar este elemento, por favor vuelva a ensayar. |||| Ha ocurrido un error al desplazar estos elementos, por favor vuelva a ensayar.",
"cancelled": "%{subject} ha sido devuelto a su carpeta de origen. |||| %{smart_count} elementos han sido devueltos a sus carpetas de origen.",
"cancelledWithRestoreErrors": "%{subject} ha sido desplazado a su ubicación original pero hubo un error al restaurar el archivo de la papelera. |||| %{smart_count} elementos han sido desplazados a su ubicación original pero hubo %{restoreErrorsCount} error(es) al restaurar los archivos de la papelera.",
"cancelled_error": "Lo sentimos, ha ocurrido un error al anular el desplazamiento. |||| Lo sentimos, un error ha ocurrido al anular los desplazamientos."
},
"ImportToDrive": {
"title": "%{smart_count} elemento |||| %{smart_count} elementos",
"to": "Guardado en:",
"action": "Guardar",
"cancel": "Anular",
"success": "%{smart_count} archivo guardado |||| %{smart_count} archivos guardados",
"error": "Algo ha fallado, vuelva a ensayar"
},
"FileOpenerExternal": {
"fileNotFoundError": "Error: archivo no encontrado"
},
"TOS": {
"updated": {
"title": "Lo nuevo en el RGPD",
"detail": "En el marco de la Reglamento General de Protección de Datos (RGPD), [nuestras Condiciones Generales de Utilización se han actualizado](%{link}) y se aplicarán a partir del 25 de mayo de 2018.",
"cta": "Aceptar CGU y continuar",
"disconnect": "Rechazar y desconectarse",
"error": "Algo ha fallado, vuelva a ensayar más tarde"
}
},
"manifest": {
"permissions": {
"contacts": {
"description": "Necesario para compartir archivos con sus contactos"
},
"groups": {
"description": "Necesario para compartir archivos con sus grupos"
}
}
},
"models": {
"contact": {
"defaultDisplayName": "Anónimo"
}
},
"Scan": {
"scan_a_doc": "Escanear un doc",
"save_doc": "Guardar el doc",
"filename": "Nombre del archivo",
"save": "Guardar",
"cancel": "Anular",
"qualify": "Clasificar",
"apply": "Aplicar",
"error": {
"offline": "Usted está actualmente fuera de línea y no puede utilizar esta funcionalidad. Vuelva a ensayarlo más tarde",
"uploading": "Ya está cargando un archivo. Espere hasta el final de la carga e inténtelo de nuevo.",
"generic": "Algo ha fallado, vuelva a ensayar."
},
"successful": {
"qualified_ok": "¡Usted ha clasificado exitosamente su archivo!"
}
},
"History": {
"description": "Las últimas 20 versiones de sus archivos se guardan automáticamente. Seleccione una versión para descargarla.",
"current_version": "Versión actual",
"loading": "Cargando...",
"noFileVersionEnabled": "Su Twake pronto podrá archivar las últimas modificaciones de un archivo para no arriesgarse a perderlas en el futuro."
},
"External": {
"redirection": {
"title": "Redireccionar",
"text": "Está a punto de ser redireccionado...",
"error": "Error durante la redirección. Generalmente, esto significa que el contenido del archivo no está en el formato correcto."
}
},
"RenameModal": {
"title": "Cambiar el nombre",
"description": "Está a punto de cambiar la extensión del archivo. ¿Quiere continuar?",
"continue": "Continuar",
"cancel": "Anular"
},
"Shortcut": {
"title_modal": "Crear un atajo",
"filename": "Nombre del archivo",
"url": "URL",
"cancel": "Anular",
"create": "Crear",
"created": "Su atajo ha sido creado",
"errored": "Ha ocurrido un error",
"filename_error_ends": "El nombre debe terminar con .url",
"needs_info": "El atajo necesita al menos una url y un nombre de archivo",
"url_badformat": "Su url no está en el formato correcto"
},
"searchbar": {
"placeholder": "Buscar",
"empty": "No se ha encontrado ningún resultado para su consulta “%{query}”"
},
"search": {
"empty": {
"subtitle": "No se ha encontrado ningún resultado para su consulta “%{query}”"
}
},
"actions": {
"details": "Detalles",
"personalizeFolder": {
"label": "Personalizar carpeta"
},
"summariseByAI": "Resumir"
},
"FolderCustomizer": {
"title": "Personalizar carpeta",
"description": "Elija un color específico para su carpeta",
"cancel": "Cancelar",
"apply": "Aplicar",
"error": "Se ha producido un error, por favor inténtelo de nuevo.",
"tabs": {
"colors": "Colores",
"icons": "Iconos"
},
"iconPicker": {
"recents": "Recientes",
"chooseCustomIcon": "Elegir un icono personalizado"
}
}
}
================================================
FILE: src/locales/fr.json
================================================
{
"Nav": {
"item_drive": "Mon Drive",
"item_recent": "Récents",
"item_sharings": "Partages",
"item_shared": "Partagés",
"item_activity": "Activité",
"item_trash": "Corbeille",
"item_migration": "Migration",
"item_settings": "Paramètres",
"item_collect": "Administratif",
"item_shared_drives": "Drives partagés",
"item_favorites": "Favoris",
"item_external_drives": "Disques externes",
"item_my_drive": "Mon Drive",
"btn-client": "Télécharger Twake Drive ",
"btn-client-web": "Obtenez un Twake",
"btn-client-mobile": "Emportez votre cloud personnel avec vous : installez notre app %{name} !",
"banner-txt-client": "Installez %{name} pour ordinateur et synchronisez vos fichiers pour les rendre accessibles à tout moment.",
"banner-btn-client": "Télécharger",
"link-client": "https://cozy.io/fr/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile",
"link-client-ios": "https://apps.apple.com/app/cloud-personnel-cozy/id1600636174",
"link-client-web": "https://cozy.io/try-it",
"view_more": "Voir plus",
"view_less": "Voir moins",
"item_nextcloud": "Nextcloud"
},
"breadcrumb": {
"title_drive": "Fichiers",
"title_recent": "Récents",
"title_sharings": "Partages",
"title_shared": "Mes fichiers partagés",
"title_activity": "Activité",
"title_trash": "Corbeille",
"label": "Voir le chemin",
"title_shared_drives": "Drives",
"title_favorites": "Favoris"
},
"Toolbar": {
"more": "Plus"
},
"toolbar": {
"menu_manage_access": "Gérer les accès",
"menu_leave_shared_drive": "Quitter le partage",
"menu_upload": "Importer des fichiers",
"item_more": "Plus",
"menu_new_folder": "Dossier",
"menu_new_shared_drive": "Drive partagé",
"menu_select": "Sélectionner les éléments",
"menu_share_folder": "Partager le dossier",
"menu_download": "Télécharger",
"menu_sync_cozy": "Synchroniser dans mon Twake",
"add_to_mine": "Ajouter à mon Twake",
"menu_download_folder": "Télécharger le dossier",
"menu_download_file": "Télécharger ce fichier",
"menu_create_note": "Note",
"menu_create_docs": "Docs",
"menu_create_shortcut": "Raccourci",
"share": "Partager",
"trash": "Supprimer",
"delete_shared_drive": "Supprimer le drive partagé",
"leave": "Quitter le partage et supprimer le dossier",
"menu_add": "Ajouter",
"menu_create": "Créer",
"menu_add_item": "Ajouter un élément",
"menu_onlyOffice": {
"text": "Document texte",
"spreadsheet": "Feuille de calcul",
"slide": "Présentation"
},
"select_all": "Tout sélectionner",
"select_all_mobile": "Tout",
"clear_selection": "Effacer la sélection",
"clear_selection_mobile": "Annuler",
"sharings_tab_all": "Tout",
"sharings_tab_drives": "Drives"
},
"Share": {
"create-cozy": "Créer mon Twake"
},
"Files": {
"share": {
"cta": "Partager",
"title": "Partager",
"details": {
"title": "Détails du partage",
"createdAt": "Depuis le %{date}",
"ro": "Peut consulter",
"rw": "Peut modifier",
"desc": {
"ro": "Vous pouvez consulter, télécharger, et ajouter ce contenu à votre Twake. Vous recevrez les modifications faites par le propriétaire, mais vous ne pourrez pas le modifier.",
"rw": "Vous pouvez consulter, modifier et supprimer du contenu. Les modifications sur le contenu seront répercutées automatiquement entre vos Twake."
}
},
"shared": "Partagé",
"sharedByMe": "Partagé",
"sharedWithMe": "Partagé avec moi",
"sharedBy": "Partagé par %{name}",
"shareByLink": {
"subtitle": "Par lien public",
"desc": "Chaque personne possédant le lien fourni peut voir et télécharger vos fichiers.",
"creating": "Création du lien...",
"copy": "Copier le lien",
"copied": "Lien copié dans le presse-papiers.",
"failed": "Impossible de copier dans le presse papier"
},
"shareByEmail": {
"subtitle": "Par email",
"email": "À :",
"emailPlaceholder": "Saisissez le courriel ou le nom du destinataire.",
"send": "Envoyer",
"genericSuccess": "Vous avez invité %{count} contacts.",
"success": "Vous avez envoyé une invitation à %{email}.",
"comingsoon": "Bientôt disponible ! Vous pourrez partager un document et vos photos en un seul clic avec votre famille, vos amis, et même vos collaborateurs. Ne vous inquiétez pas, on vous prévient quand ce sera prêt !",
"onlyByLink": "Ce %{type} ne peut être partagé que sous la forme d'un lien, car il",
"type": {
"file": "fichier",
"folder": "dossier"
},
"hasSharedParent": "se trouve dans un dossier partagé.",
"hasSharedChild": "contient un élément partagé."
},
"revoke": {
"title": "Arrêter le partage",
"desc": "Votre contact conservera une copie mais vos changements ne seront plus synchronisés.",
"success": "Vous avez cessé de partager ce fichier avec %{email}."
},
"revokeSelf": {
"title": "Arrêter le partage",
"desc": "Vous conservez le contenu mais il ne sera plus mis à jour entre vos Twake.",
"success": "Vous avez été retiré de ce partage."
},
"sharingLink": {
"title": "Partager",
"copy": "Copier",
"copied": "Copié"
},
"whoHasAccess": {
"title": "1 personne y a accès |||| %{smart_count} personnes y ont accès"
},
"protectedShare": {
"title": "Prochainement !",
"desc": "Partagez ce que vous souhaitez par email avec votre famille et vos amis !"
},
"close": "Fermer",
"gettingLink": "Création du lien…",
"error": {
"generic": "Une erreur est survenue lors de la création du lien de partage, merci de réessayer",
"revoke": "Oups, une erreur est survenue. Contactez-nous pour que nous résolvions la situation au plus vite.\n"
},
"specialCase": {
"base": "Ce %{type} ne peut être partagé que sous la forme d'un lien, car il",
"isInSharedFolder": "se trouve dans un dossier partagé.",
"hasSharedFolder": "contient un dossier partagé."
}
},
"viewer-fallback": "Le fichier est en cours de téléchargement, vous pouvez fermer cette fenêtre.",
"dropzone": {
"teaser": "Déposez des fichiers pour les importer vers :",
"noFolderSupport": "Votre navigateur ne prend pas en charge le glisser-déposer de dossier pour le moment. Veuillez importer les fichiers manuellement."
}
},
"table": {
"head_name": "Nom",
"head_update": "Mise à jour",
"head_size": "Taille",
"head_status": "Partage",
"head_thumbnail_size": "Changer la taille des miniatures",
"head_view_mode": "Mode d'affichage",
"head_view_list": "Vue liste",
"head_view_grid": "Vue grille",
"row_update_format": "d LLL yyyy",
"row_update_format_full": "d LLLL yyyy",
"row_read_only": "Partagé (lecture seule)",
"row_read_write": "Partagé (lecture & écriture)",
"row_size_symbols": {
"B": "o",
"KB": "Ko",
"MB": "Mo",
"GB": "Go",
"TB": "To",
"PB": "Po",
"EB": "Eo",
"ZB": "Zo",
"YB": "Yo"
},
"row_sharing_shortcut_aria_label": "Nouveau raccourci de partage",
"load_more": "Plus de fichiers",
"mobile": {
"head_name_asc": "A-Z",
"head_name_desc": "Z-A",
"head_updated_at_asc": "Plus anciens en premier",
"head_updated_at_desc": "Plus récents en premier",
"head_size_asc": "Plus légers en premier",
"head_size_desc": "Plus lourds en premier"
},
"tooltip": {
"carbonCopy": {
"title": "Copie conforme",
"caption": "Le document est défini \"authentique et original\" par Twake Workplace, l'hébergeur de votre Twake, car il peut affirmer qu'il provient directement des services de son émetteur sans avoir subi aucune modification."
},
"electronicSafe": {
"title": "Coffre-fort numérique",
"caption": "Indique si le document original est sécurisé par votre coffre-fort numérique personnel avec les certifications qui lui confèrent une valeur probante et une garantie de conservation de 50 ans au-delà de son dépôt."
}
}
},
"Storage": {
"title": "Stockage",
"availability": "%{smart_count} Go disponible",
"increase": "Augmenter l'espace"
},
"SelectionBar": {
"selected_count": "élément sélectionné |||| éléments sélectionnés",
"share": "Partager",
"download": "Télécharger",
"trash": "Supprimer",
"trash_all": "Supprimer tout",
"destroy": "Supprimer définitivement",
"rename": "Renommer",
"restore": "Restaurer",
"close": "Fermer",
"openWith": "Ouvrir avec...",
"applePreview": "Aperçu Apple",
"forward": "Transférer",
"forwardTo": "Transférer vers...",
"moveto": "Déplacer vers…",
"moveto_mobile": "Déplacer",
"phone-download": "Rendre accessible hors-ligne",
"requalify": "Requalifier",
"qualify": "Qualifier",
"history": "Versions",
"more": "Afficher plus d'action",
"openWithinNextcloud": "Ouvrir dans Nextcloud"
},
"DeleteConfirm": {
"title": "Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?",
"trash": "Cet élément sera déplacé dans la corbeille. |||| Ces éléments seront déplacés dans la corbeille.",
"restore": "Vous pouvez toujours le restaurer quand vous voulez.",
"share_accepted": "Le partage sera arrêté. Ainsi, les contacts suivant conserveront une copie mais vos changements ne seront plus synchronisés :",
"share_waiting": "Le partage sera arrêté. Ainsi, les contacts suivant ne pourront donc plus accepter le partage et ne pourront plus accéder aux contenus partagés :",
"share_both": "Le partage sera arrêté. Ainsi, les contacts ayant stocké les fichiers dans leur Twake conserveront une copie, les autres contacts ne pourront plus accéder aux contenus partagés :",
"link": "Le partage par lien ne sera plus actif.",
"referenced": "Des photos de la sélection sont dans un album. Elles seront retirées de l'album si vous confirmez.",
"cancel": "Annuler",
"delete": "Supprimer"
},
"EmptyTrashConfirm": {
"title": "Supprimer définitivement ?",
"forbidden": "Vous ne pourrez plus accéder à ces fichiers.",
"restore": "Vous ne pourrez pas restaurer ces fichiers.",
"cancel": "Annuler",
"delete": "Tout supprimer",
"processing": "Votre corbeille est en train de se vider. Cela peut prendre quelques instants.",
"success": "La corbeille a été vidée.",
"error": "Une erreur est survenue, merci de réessayer."
},
"DestroyConfirm": {
"title": "Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?",
"forbidden": "Vous ne pourrez plus accéder à ce %{type}. |||| Vous ne pourrez plus accéder à ces %{type}.",
"restore": "Vous ne pourrez pas restaurer ce %{type}. |||| Vous ne pourrez pas restaurer ces %{type}.",
"cancel": "Annuler",
"delete": "Supprimer définitivement",
"success": "Le %{type} a été supprimé définitivement. |||| %{smart_count} %{type} ont été supprimés définitivement.",
"error": "Une erreur est survenue, merci de réessayer.",
"processing": "La suppression est en cours. Cela peut prendre quelques instants."
},
"quotaalert": {
"title": "Votre espace disque est plein :(",
"desc": "Veuillez supprimer des fichiers, vider votre corbeille ou augmenter votre espace disque avant d'importer de nouveau fichier.",
"confirm": "OK",
"increase": "Augmenter votre espace disque"
},
"loading": {
"message": "Chargement",
"onlyOfficeCreateInProgress": "Création du fichier en cours..."
},
"empty": {
"title": "Vous n'avez aucun fichier dans ce dossier.",
"text": "Sélectionnez les fichiers sur votre ordinateur ou faites-les glisser ici.",
"mobile_text": "Sélectionnez les fichiers sur votre appareil.",
"trash_title": "Vous n'avez aucun fichier supprimé.",
"trash_text": "Déplacez les fichiers dont vous n'avez plus besoin dans la corbeille et supprimez-les définitivement pour récupérer de l'espace de stockage.",
"shared-drive_text": "Créez et partagez votre premier drive."
},
"error": {
"open_folder": "Une erreur est survenue pendant l'ouverture du dossier.",
"open_file": "Une erreur est survenue pendant l'ouverture du fichier.",
"button": {
"reload": "Rafraîchir"
},
"download_file": {
"offline": "Vous devez être connecté pour pouvoir ouvrir ce fichier",
"missing": "Le fichier n'existe pas"
}
},
"Error": {
"public_unshared_title": "Désolé, ce lien n'est plus disponible.",
"public_unshared_text": "Ce lien a expiré ou il a été supprimé par le ou la propriétaire. Signalez-lui que vous voulez accéder à son contenu !",
"generic": "Une erreur s'est produite. Attendez quelques minutes et recommencez."
},
"alert": {
"could_not_open_file": "Impossible d'ouvrir le fichier",
"try_again": "Une erreur est survenue, merci de réessayer dans un instant.",
"restore_file_success": "La sélection a été restaurée avec succès.",
"trash_file_success": "La sélection a été déplacée dans la Corbeille.",
"trash_file_processing": "Le déplacement vers la Corbeille est en cours...",
"trash_shared_drive_success": "Le drive partagé a été déplacé dans la Corbeille.",
"destroy_file_success": "La sélection a été supprimée définitivement.",
"folder_name": "L'élément %{folderName} existe déjà, merci de choisir un nouveau nom.",
"file_name": "L'élément %{fileName} existe déjà, utilisez un nouveau nom",
"file_name_missing": "Le nom du fichier est manquant, veuillez choisir un nouveau nom.",
"file_name_illegal_name": "Le nom du fichier %{fileName} est invalide, veuillez choisir un nouveau nom.",
"file_name_illegal_characters": "Le nom du fichier %{fileName} est invalide, il contient les caractères interdits suivants : %{characters}",
"folder_generic": "Une erreur est survenue, merci de réessayer.",
"folder_abort": "Vous devez nommer votre dossier si vous voulez le sauvegarder. Vos informations n'ont pas été enregistrées.",
"offline": "Cette fonctionnalité n’est pas disponible en mode hors-ligne.",
"preparing": "Préparation de vos fichiers...",
"item_copied": "1 élément copié",
"items_copied": "%{count} éléments copiés",
"item_cut": "1 élément coupé",
"items_cut": "%{count} éléments coupés",
"item_moved": "1 élément a été déplacé",
"items_moved": "%{count} éléments ont été déplacés",
"item_pasted": "1 élément a été déplacé",
"items_pasted": "%{count} éléments ont été déplacés",
"copy_files_only": "Impossible de copier les dossiers",
"copy_not_allowed": "L'opération de copie n'est pas autorisée dans cette vue.",
"cut_not_allowed": "L'opération de coupe n'est pas autorisée dans cette vue.",
"delete_not_allowed": "L'opération de suppression n'est pas autorisée dans cette vue.",
"paste_error": "Une erreur s'est produite lors du collage des fichiers",
"paste_failed": "Échec du collage des fichiers",
"paste_sharing_error": "Impossible de coller les fichiers en raison de restrictions de partage. Veuillez utiliser l'action Déplacer à la place.",
"paste_same_folder_skipped": "Impossible de déplacer les éléments dans le même dossier où ils se trouvent déjà.",
"paste_not_allowed": "Vous ne pouvez pas coller dans ce dossier",
"cannot_move_shared_drive": "Vous ne pouvez pas déplacer le dossier de lecteur partagé",
"cannot_copy_shared_drive": "Vous ne pouvez pas copier le dossier du lecteur partagé"
},
"upload": {
"label": "Importer",
"documentType": {
"file": "fichier",
"directory": "dossier",
"element": "élément"
},
"alert": {
"success": "%{smart_count} %{type} importé. |||| %{smart_count} %{type} importés.",
"success_conflicts": "%{smart_count} %{type} importé avec %{conflictNumber} conflit(s). |||| %{smart_count} %{type} importés avec %{conflictNumber} conflit(s).",
"success_updated": "%{smart_count} %{type} importé et %{updatedCount} mis à jour. |||| %{smart_count} %{type} importés et %{updatedCount} mis à jour.",
"success_updated_conflicts": "%{smart_count} %{type} importé, %{updatedCount} mis à jour et %{conflictCount} conflit(s). |||| %{smart_count} %{type} importés, %{updatedCount} mis à jour et %{conflictCount} conflit(s).",
"updated": "%{smart_count} %{type} mis à jour. |||| %{smart_count} %{type} mis à jour.",
"updated_conflicts": "%{smart_count} %{type} mis à jour avec %{conflictCount} conflit(s). |||| %{smart_count} %{type} mis à jour avec %{conflictCount} conflit(s).",
"errors": "Une erreur est survenue lors de l’import du %{type}, merci de réessayer plus tard.",
"network": "Vous ne disposez pas d'une connexion internet. Merci de réessayer quand ce sera le cas.",
"fileTooLargeErrors": "Fichier trop volumineux. Taille maximale autorisée par fichier : %{max_size_value} Go",
"unreadable_files": "Certains fichiers n'ont pas pu être lus. Le chemin est peut-être trop long ou le dossier a été modifié pendant le transfert."
},
"limit": {
"title": "Importation impossible : Ce dossier contient plus de %{limit} fichiers",
"content": "Pour une importation de cette taille, nous vous recommandons d'utiliser l'application de synchronisation sur ordinateur.",
"content_public": "Veuillez réduire le nombre de fichiers et réessayer.",
"cancel": "Annuler",
"close": "Fermer",
"download_desktop": "Installer l'application"
}
},
"intents": {
"alert": {
"error": "La récupération du fichier a échoué. Téléchargez le fichier manuellement puis ajoutez-le à Twake. "
},
"picker": {
"select": "Sélectionner",
"cancel": "Annuler",
"new_folder": "Nouveau dossier",
"instructions": "Choisir une cible"
}
},
"UploadQueue": {
"header": "Import de %{smart_count} élément dans votre Twake |||| Import de %{smart_count} éléments dans votre Twake",
"header_preparing": "Préparation de %{smart_count} élément |||| Préparation de %{smart_count} éléments",
"header_mobile": "Import de %{done} sur %{total}",
"header_done": "%{done} sur %{total} élément(s) importé(s)",
"success_flagship": "%{smart_count} fichier importé avec succès. |||| %{smart_count} fichiers importés avec succès.",
"close": "Fermer",
"item": {
"pending": "En attente",
"preparing": "En préparation"
}
},
"Viewer": {
"close": "Fermer",
"noviewer": {
"download": "Télécharger ce fichier",
"openWith": "Ouvrir avec...",
"openInOnlyOffice": "Ouvrir avec Only Office",
"cta": {
"saveTime": "Gagnez du temps !",
"installDesktop": "Installez l'outil de synchronisation pour ordinateur",
"accessFiles": "Accédez à vos fichiers directement sur votre ordinateur"
}
},
"actions": {
"download": "Télécharger",
"forward": "Transférer"
},
"loading": {
"error": "Ce fichier n'a pas pu être chargé. Avez-vous une connexion internet qui fonctionne actuellement ?",
"retry": "Réessayer"
},
"error": {
"noapp": "Votre téléphone n'a identifié aucune application pour lire ce type de fichier.",
"generic": "Une erreur est survenue lors de l'ouverture de ce fichier, merci de réessayer.",
"noNetwork": "Vous êtes actuellement hors ligne."
},
"panel": {
"title": "Informations utiles"
}
},
"Move": {
"to": "Déplacer vers :",
"action": "Déplacer",
"cancel": "Annuler",
"modalTitle": "Déplacer",
"title": "%{smart_count} élément |||| %{smart_count} éléments",
"success": "%{subject} a été déplacé dans %{target}. |||| %{smart_count} éléments ont été déplacés dans %{target}.",
"error": "Une erreur est survenue pendant le déplacement de cet élément, merci de réessayer plus tard. |||| Une erreur est survenue pendant le déplacement de ces éléments, merci de réessayer plus tard.",
"cancelled": "%{subject} a été rapatrié dans son dossier d’origine. |||| %{smart_count} éléments ont été rapatriés dans leur dossiers d’origine.",
"cancelledWithRestoreErrors": "%{subject} a été rapatrié dans son dossier d'origine mais il y a eu une erreur lors de la restauration du fichier depuis la corbeille. |||| %{smart_count} éléments ont été rapatriés dans leur dossiers d'origine mais il y a eu %{restoreErrorsCount} erreur(s) lors de la restauration des fichiers depuis la corbeille.",
"cancelled_error": "Une erreur est survenue lors de l’annulation du déplacement. |||| Une erreur est survenue lors de l’annulation de ces déplacements.",
"multipleEntries": "%{smart_count} élément |||| %{smart_count} éléments",
"addFolder": "Ajouter un dossier",
"outsideSharedFolder": {
"title": "Déplacement en dehors du dossier %{sharedFolder}",
"content_1": "Attention, vous souhaitez déplacer %{name} en dehors du dossier partagé %{sharedFolder}. |||| Attention, vous souhaitez déplacer %{smart_count} %{type} en dehors du dossier partagé %{sharedFolder}.",
"content_2": "Ce déplacement, va retirer le %{type} %{name} du partage. Ce %{type} va donc être mis à la corbeille pour l'ensemble des membres du partage. |||| Ce déplacement, va retirer les %{smart_count} %{type} du partage. Ces %{type} vont donc être mis à la corbeille pour l'ensemble des membres du partage.",
"cancel": "Annuler",
"confirm": "J'ai compris"
},
"insideSharedFolder": {
"title": "Déplacer vers un dossier partagé ?",
"content": "Tous les membres ayant accès à %{destination} auront également accès à %{source}. |||| Tous les membres ayant accès à %{destination} auront également accès aux %{type} sélectionnés.",
"cancel": "Annuler",
"confirm": "Ok"
},
"sharedFolderInsideAnother": {
"title": "Déplacement impossible",
"content_1": "Vous souhaitez déplacer un élément partagé dans un dossier lui-même partagé. Ce type déplacement n'est pas autorisé.",
"content_2": "Si vous souhaitez tout de même déplacer %{source} dans %{destination}, veuillez arrêter le partage de :",
"cancel": "Annuler le déplacement",
"confirm": "Arrêter le partage"
}
},
"ImportToDrive": {
"title": "%{smart_count} fichier |||| %{smart_count} fichiers",
"to": "Enregistrer dans :",
"action": "Enregistrer",
"cancel": "Annuler",
"success": "%{smart_count} fichier enregistré |||| %{smart_count} fichiers enregistrés",
"error": "Une erreur s'est produite. Merci de recommencer. "
},
"FileOpenerExternal": {
"fileNotFoundError": "Erreur : fichier non trouvé"
},
"TOS": {
"updated": {
"title": "Du nouveau avec le RGPD !",
"detail": "Dans le cadre du Règlement Général de la Protection des Données (RGPD), [nos CGU sont actualisées](%{link}) et s’appliquent pour vous à partir du 25 mai 2018.",
"cta": "Accepter les CGU et continuer",
"disconnect": "Refuser et se déconnecter",
"error": "Une erreur est survenue, merci de réessayer plus tard"
}
},
"manifest": {
"permissions": {
"contacts": {
"description": "Utilisé pour partager des éléments à vos contacts"
},
"groups": {
"description": "Utilisé pour partager des éléments à vos groupes"
}
}
},
"models": {
"contact": {
"defaultDisplayName": "Anonyme"
}
},
"Scan": {
"none": "Aucune",
"scan_a_doc": "Numériser un doc",
"save_doc": "Enregistrer le document",
"filename": "Nom du fichier",
"save": "Sauvegarder",
"cancel": "Annuler",
"qualify": "Qualifier",
"requalify": "Requalifier",
"apply": "Appliquer",
"error": {
"offline": "Vous êtes actuellement déconnecté, vous ne pouvez donc pas utiliser cette fonctionnalité. Connectez-vous à internet et recommencez. ",
"uploading": "Vous avez déjà un fichier en cours de téléchargement. Attendez la fin et recommencez.",
"generic": "Un problème est survenu. Veuillez réessayer. "
},
"successful": {
"qualified_ok": "Fichier qualifié avec succès !"
}
},
"History": {
"description": "Les 20 dernières versions de vos fichiers sont conservées automatiquement. Sélectionnez une version pour la télécharger.",
"current_version": "Version actuelle",
"loading": "Chargement...",
"noFileVersionEnabled": "Nouveauté : votre Twake pourra prochainement archiver les dernières modifications d'un fichier pour ne plus jamais risquer de les perdre"
},
"External": {
"redirection": {
"title": "Redirection",
"text": "Vous êtes sur le point d'être redirigé... ",
"error": "Erreur pendant la redirection. Généralement cela signifie que le contenu du fichier n'est pas dans le bon format. "
}
},
"RenameModal": {
"title": "Renommer",
"description": "Vous êtes sur le point de changer l'extension du fichier. Voulez-vous continuer ? ",
"continue": "Continuer",
"cancel": "Annuler"
},
"Shortcut": {
"title_modal": "Créer un raccourci",
"filename": "Nom du fichier",
"url": "URL",
"cancel": "Annuler",
"create": "Créer",
"created": "Le raccourci a été créé",
"errored": "Une erreur s'est produite",
"filename_error_ends": "Le nom du fichier doit se terminer par .url",
"needs_info": "Un raccourci a besoin d'un nom et d'une URL",
"url_badformat": "L'URL saisie n'est pas dans le bon format"
},
"OnlyOffice": {
"Error": {
"title": "Quelque chose n'a pas fonctionné",
"text": "Essayez de recharger la page s'il vous plaît"
},
"readOnly": {
"title": "Lecture seule",
"tooltip": "Vous êtes uniquement autorisé à visualiser ce document. Contactez le propriétaire pour obtenir des droits d'écriture."
},
"createFileName": {
"text": "Nouveau document texte",
"spreadsheet": "Nouvelle feuille de calcul",
"slide": "Nouvelle présentation"
},
"toolbar": {
"goToHome": "Aller à l'accueil"
},
"actions": {
"edit": "Modifier",
"validate": "Valider"
},
"tooltip": {
"title": "Modifier le document",
"text": "Le document est actuellement en lecture seule, Vous pouvez le modifier en cliquant ici.",
"actions": {
"ok": "Ok",
"hide": "Ne plus afficher"
}
}
},
"Migration": {
"title": "Mettre à jour Twake Drive",
"content": "Twake Drive doit être mis à jour afin d'améliorer ses performances. Cela peut prendre jusqu'à plusieurs minutes durant lesquelles vous ne pourrez pas utiliser l'application. Souhaitez-vous le faire maintenant ? Si vous refusez, nous vous redemanderons la prochaine fois.",
"confirm": "Ok, c'est parti !",
"cancel": "Non, pas maintenant"
},
"searchbar": {
"placeholder": "Rechercher",
"empty": "Aucun résultat trouvé pour la requête \"%{query}\""
},
"button": {
"back": "Retour",
"add": "Ajouter",
"create": "Créer"
},
"search": {
"action": "Rechercher",
"empty": {
"title": "Aucun résultat",
"subtitle": "Aucun résultat trouvé pour la requête \"%{query}\""
}
},
"PushBanner": {
"quota": {
"text": "Vous n'avez presque plus d'espace de stockage. Si vous atteignez la limite, vous ne pourrez plus ajouter de fichiers. Vous pouvez supprimer des fichiers, vider votre corbeille ou changer d'offre.",
"actions": {
"first": "J'ai compris",
"second": "Voir les offres"
}
}
},
"FileDivergedModal": {
"title": "Quelqu’un a modifié ce fichier",
"content": "Quelqu’un a modifié le contenu de ce fichier pendant que vous l'éditiez. Vous pouvez récupérer ces changements ou continuer votre édition sur un nouveau fichier.",
"confirm": "Continuer d'éditer",
"cancel": "Voir les changements",
"error": "Une erreur est survenue, merci de réessayer.",
"confirmReload": {
"title": "Voir les changements",
"content": "En accédant au nouveau fichier, vos modifications seront annulées.",
"cancel": "Annuler",
"confirm": "Ok, j’ai compris"
},
"viewMode": {
"title": "Quelqu’un a modifié ce fichier",
"content": "Quelqu’un a modifié le contenu de ce fichier. Vous pouvez récupérer ces changements.",
"confirm": "Voir les changements"
}
},
"FileDeletedModal": {
"title": "Quelqu’un a supprimé ce fichier",
"content": "Quelqu’un a supprimé ce fichier pendant que vous l'éditiez. Vous pouvez arrêter vos modifications ou restaurer ce fichier pour continuer vos modifications.",
"confirm": "Restaurer le fichier",
"cancel": "Annuler l'édition",
"error": "Une erreur est survenue, merci de réessayer."
},
"TrashedBanner": {
"text": "Cet élément est dans la corbeille",
"destroy": "Supprimer définitivement",
"restore": "Restaurer",
"restoreSuccess": "L’élément a bien été restauré",
"restoreError": "Une erreur est survenue, merci de réessayer.",
"destroySuccess": "L’élément a bien été supprimé"
},
"MigrationProgressBanner": {
"title": "Migration depuis Nextcloud en cours",
"percent": "%{percent}% terminé",
"importing": "Importation de %{count} fichiers depuis Nextcloud ...",
"cancel": "Annuler",
"done": {
"title": "Migration terminée !",
"body": "%{count} fichiers importés avec succès depuis Nextcloud"
}
},
"EntriesType": {
"file": "fichier |||| fichiers",
"directory": "dossier |||| dossiers",
"element": "élément |||| éléments"
},
"NotFound": {
"title": "L’élément est introuvable",
"text": "Nous n’avons trouvé aucun élément à cette adresse. Il s’agit peut-être d’une erreur de frappe."
},
"NextcloudBreadcrumb": {
"root": "Drive partagés",
"trash": "Corbeille"
},
"NextcloudToolbar": {
"share": "Partager"
},
"NextcloudDeleteConfirm": {
"title": "Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?",
"trash": "Cet élément sera déplacé dans la corbeille de Nextcloud. |||| Ces éléments seront déplacés dans la corbeille de Nextcloud.",
"restore": "Vous pouvez toujours le restaurer quand vous voulez depuis Nextcloud.",
"error": "Une erreur est survenue, merci de réessayer.",
"cancel": "Annuler",
"delete": "Supprimer"
},
"FileName": {
"sharedDrive": "Drives",
"trash": "Corbeille"
},
"NextcloudBanner": {
"title": "Les éléments ci-dessous sont affichés depuis un drive NextCloud et ne sont pas stockés dans votre Twake."
},
"favorites": {
"label": {
"add": "Ajouter aux favoris",
"addMobile": "Favoris",
"remove": "Retirer des favoris"
},
"error": "Une erreur est survenue, merci de réessayer.",
"success": {
"add": "%{filename} a été ajouté aux favoris |||| Ces éléments ont été ajoutés aux favoris",
"remove": "%{filename} a été retiré des favoris |||| Ces éléments ont été retirés des favoris"
}
},
"TrashToolbar": {
"emptyTrash": "Vider la corbeille"
},
"RestoreNextcloudFile": {
"label": "Restaurer",
"success": "L'élément a bien été restauré",
"error": "Une erreur est survenue, merci de réessayer."
},
"actions": {
"details": "Détails",
"infos": "Détails et qualification",
"infosMobile": "Détails",
"duplicateTo": {
"label": "Dupliquer vers…"
},
"duplicateToMobile": {
"label": "Dupliquer"
},
"personalizeFolder": {
"label": "Personnaliser le dossier"
},
"summariseByAI": "Résumer"
},
"FolderCustomizer": {
"title": "Personnaliser le dossier",
"description": "Choisissez une couleur spécifique pour votre dossier",
"cancel": "Annuler",
"apply": "Appliquer",
"error": "Une erreur est survenue, merci de réessayer.",
"tabs": {
"colors": "Couleurs",
"icons": "Icônes"
},
"iconPicker": {
"recents": "Récents",
"chooseCustomIcon": "Choisir une icône personnalisée"
}
},
"DuplicateModal": {
"subTitle": "Dupliquer vers :",
"confirmLabel": "Dupliquer ici",
"success": "%{fileName} a été dupliqué dans %{destinationName}. |||| %{smart_count} éléments ont été dupliqués dans %{destinationName}.",
"error": "Une erreur est survenue, merci de réessayer."
},
"OpenFolderButton": {
"label": "Ouvrir le dossier"
},
"LastUpdate": {
"titleFormat": "dd LLLL yyyy, HH:MM"
},
"AddMenu": {
"readOnlyFolder": "Ce dossier est en lecture seule. Vous ne pouvez pas effectuer cette action."
},
"PublicNoteRedirect": {
"error": {
"title": "Impossible d'accéder au document",
"subtitle": "Le lien de partage semble manquant ou invalide. Merci de demander au propriétaire du document de vérifier les accès"
}
},
"antivirus": {
"infectedFile": "Ce fichier est infecté par un virus",
"popover": {
"title": "Le téléchargement et le partage sont bloqués pour des raisons de sécurité",
"description": "Le système Twake a détecté un virus"
}
}
}
================================================
FILE: src/locales/index.js
================================================
import { getI18n } from 'twake-i18n'
import ar from './ar.json'
import de from './de.json'
import en from './en.json'
import es from './es.json'
import fr from './fr.json'
import it from './it.json'
import ja from './ja.json'
import ko from './ko.json'
import nl from './nl.json'
import nl_NL from './nl_NL.json'
import pl from './pl.json'
import ru from './ru.json'
import zh_CN from './zh_CN.json'
import zh_TW from './zh_TW.json'
export const locales = {
ar,
de,
en,
es,
fr,
it,
ja,
ko,
nl,
nl_NL,
pl,
ru,
zh_CN,
zh_TW
}
export const getDriveI18n = () => getI18n(undefined, lang => locales[lang])
================================================
FILE: src/locales/it.json
================================================
{
"Nav": {
"item_drive": "Drive",
"item_recent": "Recenti",
"item_sharings": "Condivisioni",
"item_shared": "Condiviso da me",
"item_activity": "Attività",
"item_trash": "Cestino",
"item_settings": "Impostazioni",
"item_collect": "Amministrativo",
"btn-client": "Ottieni Twake Drive per desktop",
"btn-client-web": "Ottieni Twake",
"btn-client-mobile": "Ottieni %{name} sul tuo telefono!",
"banner-txt-client": "Ottieni %{name} per Desktop e sincronizza i tuoi file in modo sicuro per renderli accessibili in qualsiasi momento.",
"banner-btn-client": "Scarica",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8",
"link-client-web": "https://cozy.io/try-it"
},
"breadcrumb": {
"title_drive": "Drive",
"title_recent": "Recenti",
"title_sharings": "Condivisioni",
"title_shared": "Condiviso da me",
"title_activity": "Attività",
"title_trash": "Cestino",
"label": "Mostra percorso"
},
"Toolbar": {
"more": "Altro"
},
"toolbar": {
"menu_upload": "Carica files",
"item_more": "Altro",
"menu_new_folder": "Cartella",
"menu_select": "Seleziona oggetti",
"menu_share_folder": "Condividi cartella",
"menu_download": "Scarica",
"menu_sync_cozy": "Sincronizza con il mio Twake",
"add_to_mine": "Aggiungi al mio Twake",
"menu_download_folder": "Cartella download",
"menu_download_file": "Scarica questo file",
"menu_create_note": "Nota",
"menu_create_shortcut": "Collegamento",
"empty_trash": "Svuota cestino",
"share": "Condividi",
"trash": "Rimuovi",
"delete_shared_drive": "Elimina unità condivisa",
"leave": "Lascia la cartella condivisa ed eliminala",
"menu_add": "Aggiungi",
"menu_create": "Creare",
"menu_onlyOffice": {
"text": "Documento di testo",
"spreadsheet": "Foglio di calcolo",
"slide": "Presentazione"
},
"select_all": "Seleziona tutto",
"clear_selection": "Cancella selezione",
"sharings_tab_all": "Tutti",
"sharings_tab_drives": "Unità"
},
"Share": {
"create-cozy": "Crea il mio Twake"
},
"Files": {
"share": {
"cta": "Condividi",
"title": "Condividi",
"details": {
"title": "Dettagli condivisione",
"createdAt": "Il %{date}",
"ro": "Può visualizzare",
"rw": "Può modificare",
"desc": {
"ro": "È possibile visualizzare, scaricare e aggiungere questo contenuto al proprio Twake. Riceverao gli aggiornamenti da parte del proprietario, ma non potrai aggiornarli tu stesso.",
"rw": "Puoi visualizzare, aggiornare, cancellare e aggiungere questo contenuto al tuo Twake. Gli aggiornamenti apportati saranno visibili anche agli altri Twake."
}
},
"sharedByMe": "Condiviso da me",
"sharedWithMe": "Condiviso con me",
"sharedBy": "Condiviso da %{name}",
"shareByLink": {
"subtitle": "Tramite link pubblico",
"desc": "Chiunque abbia il link fornito può vedere e scaricare i tuoi file.",
"creating": "Creazione del link...",
"copy": "Copia link",
"copied": "Il link è stato copiato negli appunti",
"failed": "Impossibile copiare negli appunti"
},
"shareByEmail": {
"subtitle": "Tramite email",
"email": "A:",
"emailPlaceholder": "Inserire l'indirizzo e-mail o il nome del destinatario",
"send": "Invia",
"genericSuccess": "Hai inviato un invito a %{count} contatti.",
"success": "Hai inviato un invito a %{email}.",
"comingsoon": "Prossimamente! Potrai condividere documenti e foto con un solo clic con la tua famiglia, i tuoi amici e persino i tuoi colleghi. Non preoccuparti, ti faremo sapere quando sarà disponibile!",
"onlyByLink": "Questo %{tipo} può essere condiviso solo tramite link, perché",
"type": {
"file": "file",
"folder": "cartella"
},
"hasSharedParent": "ha un genitore condiviso",
"hasSharedChild": "contiene un elemento condiviso"
},
"revoke": {
"title": "Rimuovi dalla condivisione",
"desc": "Questo contatto manterrà una copia, ma le modifiche non saranno più sincronizzate.",
"success": "Hai rimosso questo file condiviso da %{email}."
},
"revokeSelf": {
"title": "Rimuovimi dalla condivisione",
"desc": "Il contenuto viene mantenuto, ma non verrà più aggiornato sul tuo Twake.",
"success": "Sei stato rimosso da questa condivisione."
},
"sharingLink": {
"title": "Link per la condivisione",
"copy": "Copia",
"copied": "Copiato"
},
"whoHasAccess": {
"title": "1 persona ha accesso |||| %{smart_count} persone hanno accesso"
},
"protectedShare": {
"title": "In arrivo!",
"desc": "Condividi qualsiasi cosa via e-mail con la tua famiglia e i tuoi amici!"
},
"close": "Chiudi",
"gettingLink": "Ottenendo il tuo link",
"specialCase": {
"isInSharedFolder": "è in una cartella condivisa",
"hasSharedFolder": "contiene una cartella condivisa"
}
}
},
"table": {
"head_name": "Nome",
"head_update": "Ultimo aggiornamento",
"head_size": "Dimensione",
"row_update_format": "LLL d, yyyy",
"row_update_format_full": "LLLL d, yyyy",
"row_read_only": "Condividi (Solo Lettura)",
"row_read_write": "Condividi (Lettura e Scrittura)",
"row_size_symbols": {
"B": "B",
"KB": "KB",
"MB": "MB",
"GB": "GB",
"TB": "TB",
"PB": "PB",
"EB": "EB",
"ZB": "ZB",
"YB": "YB"
},
"load_more": "Carica altro",
"mobile": {
"head_name_asc": "A-Z",
"head_name_desc": "Z-A"
}
},
"Storage": {
"title": "Archiviazione",
"availability": "%{smart_count} GB disponibili",
"increase": "Aumenta il tuo spazio"
},
"SelectionBar": {
"share": "Condividi",
"download": "Scarica",
"trash": "Rimuovi",
"destroy": "Elimina permanentemente",
"rename": "Rinomina",
"restore": "Ripristina",
"close": "Chiudi",
"openWith": "Apri con..."
},
"DeleteConfirm": {
"cancel": "Annulla",
"delete": "Rimuovi"
},
"emptytrashconfirmation": {
"title": "Eliminare permanentemente?",
"forbidden": "Non sarai più in grado di accedere a questi file.",
"cancel": "Annulla",
"delete": "Elimina tutto"
},
"DestroyConfirm": {
"title": "Eliminare permanentemente?",
"cancel": "Annulla",
"delete": "Elimina permanentemente"
},
"quotaalert": {
"confirm": "OK"
},
"loading": {
"message": "Caricamento"
},
"empty": {
"title": "Non hai nessun file in questa cartella."
},
"error": {
"button": {
"reload": "Aggiorna adesso"
},
"download_file": {
"offline": "Devi essere connesso per scaricare questo file"
}
},
"alert": {
"could_not_open_file": "Il file non può essere aperto",
"empty_trash_success": "Il cestino è stato svuotato",
"folder_generic": "Si è verificato un errore, per favore riprova.",
"offline": "Questa caratteristica non è disponibile offline.",
"item_copied": "1 elemento copiato",
"items_copied": "%{count} elementi copiati",
"item_cut": "1 elemento tagliato",
"items_cut": "%{count} elementi tagliati",
"item_moved": "1 elemento è stato spostato",
"items_moved": "%{count} elementi sono stati spostati",
"item_pasted": "1 elemento è stato spostato",
"items_pasted": "%{count} elementi sono stati spostati",
"copy_files_only": "Non è possibile copiare le cartelle",
"copy_not_allowed": "L'operazione di copia non è consentita in questa vista.",
"cut_not_allowed": "L'operazione di taglio non è consentita in questa vista.",
"paste_error": "Si è verificato un errore durante l'incollaggio dei file",
"paste_failed": "Incollaggio dei file fallito",
"paste_sharing_error": "Impossibile incollare i file a causa di restrizioni di condivisione. Si prega di utilizzare l'azione Sposta invece.",
"paste_same_folder_skipped": "Impossibile spostare gli elementi nella stessa cartella in cui si trovano già.",
"paste_not_allowed": "Non puoi incollare in questa cartella",
"cannot_move_shared_drive": "Non puoi spostare la cartella dell'unità condivisa",
"cannot_copy_shared_drive": "Non puoi copiare la cartella dell’unità condivisa"
},
"UploadQueue": {
"close": "chiudi",
"item": {
"pending": "In attesa"
}
},
"Viewer": {
"close": "Chiudi",
"noviewer": {
"download": "Scarica questo file",
"openWith": "Apri con..."
},
"actions": {
"download": "Scarica"
},
"loading": {
"retry": "Riprova"
}
},
"ImportToDrive": {
"action": "Salva"
},
"FileOpenerExternal": {
"fileNotFoundError": "Errore: file non trovato"
},
"models": {
"contact": {
"defaultDisplayName": "Anonimo"
}
},
"Scan": {
"save_doc": "Salva il documento",
"save": "Salva"
},
"History": {
"current_version": "Versione corrente",
"loading": "Caricamento..."
},
"External": {
"redirection": {
"text": "Stai per essere reindirizzato..."
}
},
"RenameModal": {
"title": "Rinomina"
},
"Shortcut": {
"url": "URL",
"errored": "Si è verificato un errore"
},
"OnlyOffice": {
"readOnly": {
"title": "Sola lettura"
},
"createFileName": {
"text": "Nuovo documento di testo",
"spreadsheet": "Nuovo foglio di calcolo",
"slide": "Nuova presentazione"
}
},
"searchbar": {
"placeholder": "Cerca",
"empty": "Nessun risultato trovato per la richiesta “%{query}”"
},
"search": {
"empty": {
"subtitle": "Nessun risultato trovato per la richiesta “%{query}”"
}
},
"actions": {
"details": "Dettagli",
"personalizeFolder": {
"label": "Personalizza cartella"
},
"summariseByAI": "Riassumere"
},
"FolderCustomizer": {
"title": "Personalizza cartella",
"description": "Scegli un colore specifico per la tua cartella",
"cancel": "Annulla",
"apply": "Applica",
"error": "Si è verificato un errore, riprova.",
"tabs": {
"colors": "Colori",
"icons": "Icone"
},
"iconPicker": {
"recents": "Recenti",
"chooseCustomIcon": "Scegli un'icona personalizzata"
}
}
}
================================================
FILE: src/locales/ja.json
================================================
{
"Nav": {
"item_drive": "ドライブ",
"item_recent": "最近使用したファイル",
"item_sharings": "共有",
"item_shared": "自分が共有した",
"item_activity": "アクティビティ",
"item_trash": "ゴミ箱",
"item_settings": "設定",
"item_collect": "管理",
"btn-client": "デスクトップ用 Twake ドライブを入手",
"btn-client-web": "Twake を入手する",
"btn-client-mobile": "お使いのモバイルで %{name} ドライブを入手しましょう!",
"banner-txt-client": "デスクトップ用 %{name} ドライブを入手して、ファイルに安全に同期していつでもアクセスできるようにしましょう。",
"banner-btn-client": "ダウンロード",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8",
"link-client-web": "https://cozy.io/try-it"
},
"breadcrumb": {
"title_drive": "ドライブ",
"title_recent": "最近使用したファイル",
"title_sharings": "共有",
"title_shared": "自分が共有した",
"title_activity": "アクティビティ",
"title_trash": "ゴミ箱"
},
"Toolbar": {
"more": "さらに"
},
"toolbar": {
"item_more": "さらに",
"menu_select": "アイテムを選択",
"menu_share_folder": "フォルダーを共有",
"menu_download_folder": "ダウンロードフォルダー",
"menu_download_file": "このファイルをダウンロード",
"empty_trash": "ゴミ箱を空にする",
"share": "共有",
"trash": "削除",
"delete_shared_drive": "共有ドライブを削除",
"leave": "共有されたフォルダーから離れて削除する",
"select_all": "すべて選択",
"clear_selection": "選択をクリア",
"sharings_tab_all": "すべて",
"sharings_tab_drives": "ドライブ"
},
"Share": {
"create-cozy": "自分の Twake を作成する"
},
"Files": {
"share": {
"cta": "共有",
"title": "共有",
"details": {
"title": "共有の詳細",
"createdAt": "日付 %{date}",
"ro": "読み取り可能",
"rw": "変更可能",
"desc": {
"ro": "このコンテンツを表示、ダウンロード、あなたの Twake に追加することができます。 所有者による更新を受け取りますが、あなた自身でこのコンテンツを更新することはできません。",
"rw": "このコンテンツを表示、更新、削除、あなたの Twake に追加することができます。 行った更新は他の Twake でも見られます。"
}
},
"sharedByMe": "自分が共有した",
"sharedWithMe": "自分と共有",
"sharedBy": "%{name} が共有しました",
"shareByLink": {
"subtitle": "公開リンクで",
"desc": "提供されたリンクを持つ人は、誰でもあなたのファイルを見たりダウンロードしたりすることができます。",
"creating": "リンクを作成中...",
"copy": "リンクをコピー",
"copied": "リンクをクリップボードにコピーしました",
"failed": "クリップボードにコピーできません"
},
"shareByEmail": {
"subtitle": "メールで",
"email": "宛先:",
"emailPlaceholder": "メールアドレスまたは受信者の名前を入力してください",
"send": "送信",
"genericSuccess": "%{count} 連絡先に招待状を送信しました。",
"success": "招待状を %{email} に送信しました。",
"comingsoon": "まもなく登場します! 家族や友達、さらには同僚ともワンクリックで文書や写真を共有できます。 ご心配なく、準備ができたらお知らせします!",
"onlyByLink": "この %{type} はリンクを共有することだけできます。",
"type": {
"file": "ファイル",
"folder": "フォルダー"
},
"hasSharedParent": "共有した親があります",
"hasSharedChild": "共有した要素を含みます"
},
"revoke": {
"title": "共有から削除",
"desc": "この連絡先はコピーを保存しますが、変更は同期されません。",
"success": "この共有済ファイルを %{email} から削除しました。"
},
"revokeSelf": {
"title": "共有から自分を削除",
"desc": "コンテンツを保存しますが、もうお使いの Twake 間で更新されません。",
"success": "この共有から削除されました。"
},
"sharingLink": {
"title": "共有するリンク",
"copy": "コピー",
"copied": "コピーしました"
},
"whoHasAccess": {
"title": "1 人がアクセスできます |||| %{smart_count} 人がアクセスできます"
},
"protectedShare": {
"title": "まもなく登場します!",
"desc": "あなたの家族や友達とメールで何でも共有してください!"
},
"close": "閉じる",
"gettingLink": "リンクの取得中...",
"error": {
"generic": "ファイル共有リンクの作成中にエラーが発生しました。もう一度やり直してください。",
"revoke": "エラーが発生しました。 できるだけ早くこの問題を解決できるように、私たちにご連絡ください。"
},
"specialCase": {
"base": "この %{type} は共有できませんが、リンクできます",
"isInSharedFolder": "共有フォルダーの中にあります",
"hasSharedFolder": "共有フォルダーを含みます"
}
},
"viewer-fallback": "ファイルのダウンロードが始まったら、これを閉じることができます。",
"dropzone": {
"teaser": "ファイルをドラッグ&ドロップするとアップロードします:",
"noFolderSupport": "現在お使いのブラウザーでフォルダーのドラッグ&ドロップはサポートされていません。 手動でファイルをアップロードしてください。"
}
},
"table": {
"head_name": "名前",
"head_update": "最終更新",
"head_size": "サイズ",
"head_thumbnail_size": "サムネイルのサイズを切り替え",
"row_update_format": "yyyy/LL/dd",
"row_update_format_full": "yyyy/LL/dd",
"row_read_only": "共有 (読み取り専用)",
"row_read_write": "共有 (読み書き)",
"row_size_symbols": {
"B": "B",
"KB": "KB",
"MB": "MB",
"GB": "GB",
"TB": "TB",
"PB": "PB",
"EB": "EB",
"ZB": "ZB",
"YB": "YB"
},
"load_more": "さらに読み込む",
"mobile": {
"head_name_asc": "A-Z",
"head_name_desc": "Z-A",
"head_updated_at_asc": "古いものが先頭",
"head_updated_at_desc": "最近使用したものが先頭",
"head_size_asc": "小さいものが先頭",
"head_size_desc": "大きなものが先頭"
}
},
"Storage": {
"title": "ストレージ",
"availability": "%{smart_count} GB 利用可能",
"increase": "スペースを増やす"
},
"SelectionBar": {
"selected_count": "アイテム選択 |||| アイテム選択",
"share": "共有",
"download": "ダウンロード",
"trash": "削除",
"destroy": "完全に削除",
"rename": "名前の変更",
"restore": "復元",
"close": "閉じる",
"moveto": "移動…",
"moveto_mobile": "移動",
"phone-download": "オフラインで利用可能にする",
"qualify": "分類",
"history": "履歴"
},
"DeleteConfirm": {
"title": "この要素を削除しますか? |||| これらの要素を削除しますか?",
"trash": "ゴミ箱に移動されます。 |||| ゴミ箱に移動されます。",
"restore": "いつでも元に戻すことができます。 |||| いつでも元に戻すことができます。",
"referenced": "選択範囲内の一部のファイルがフォトアルバムに関連しています。それらはゴミ箱に移動すると、削除されます。",
"cancel": "キャンセル",
"delete": "削除"
},
"emptytrashconfirmation": {
"title": "完全に削除しますか?",
"forbidden": "これらのファイルにもうアクセスすることはできません。",
"restore": "バックアップを作成していない場合、これらのファイルを復元することはできません。",
"cancel": "キャンセル",
"delete": "すべて削除"
},
"DestroyConfirm": {
"title": "完全に削除しますか?",
"forbidden": "このファイルにもうアクセスすることはできません。 |||| これらのファイルにもうアクセスすることはできません。",
"restore": "バックアップを作成していない場合、このファイルを復元することはできません。 |||| バックアップを作成していない場合、これらのファイルを復元することはできません。",
"cancel": "キャンセル",
"delete": "完全に削除"
},
"quotaalert": {
"title": "お使いのディスク容量が一杯です :(",
"desc": "ファイルを再度アップロードする前に、ファイルを削除するか、ゴミ箱を空にするか、ディスク容量を増やしてください。",
"confirm": "OK",
"increase": "ディスク容量を増やす"
},
"loading": {
"message": "読み込み中"
},
"empty": {
"title": "このフォルダーにファイルはありません。",
"text": "コンピューター上のファイルを選択するか、ここにドラッグアンドドロップしてください。",
"mobile_text": "デバイス上のファイルを選択してください。",
"trash_title": "削除されたファイルはありません。",
"trash_text": "不要になったファイルをゴミ箱に移動し、アイテムを完全に削除するとストレージページを解放します。"
},
"error": {
"open_folder": "フォルダーを開くときに何か問題が発生しました。",
"button": {
"reload": "今すぐ更新"
},
"download_file": {
"offline": "このファイルをダウンロードするには接続している必要があります",
"missing": "このファイルが見つかりません"
}
},
"Error": {
"public_unshared_title": "申し訳ありません。このリンクはもう利用できません。",
"public_unshared_text": "このリンクは有効期限が切れているか、所有者によって削除されています。 見つからないことを彼または彼女に知らせてください!",
"generic": "エラーが発生しました。数分待ってからもう一度やり直してください。"
},
"alert": {
"could_not_open_file": "ファイルを開くことができません",
"try_again": "エラーが発生しました。しばらくしてからもう一度やり直してください。",
"restore_file_success": "選択を正常に復元しました。",
"trash_file_success": "選択をゴミ箱に移動しました。",
"destroy_file_success": "選択を完全に削除しました。",
"empty_trash_progress": "ゴミ箱を空にしています。これは数分かかることがあります。",
"empty_trash_success": "ゴミ箱を空にしました。",
"folder_name": "要素 %{folderName} はすでに存在します。新しい名前を選んでください。",
"folder_generic": "エラーが発生しました。もう一度やり直してください。",
"folder_abort": "保存したい場合、新しいフォルダーに名前を追加する必要があります。 情報は保存されていません。",
"offline": "この機能はオフラインでは利用できません。",
"preparing": "ファイルを準備しています…",
"item_copied": "1個のアイテムをコピーしました",
"items_copied": "%{count}個のアイテムをコピーしました",
"item_cut": "1個のアイテムを切り取りました",
"items_cut": "%{count}個のアイテムを切り取りました",
"item_moved": "1個のアイテムが移動されました",
"items_moved": "%{count}個のアイテムが移動されました",
"item_pasted": "1個のアイテムが移動されました",
"items_pasted": "%{count}個のアイテムが移動されました",
"copy_files_only": "フォルダーはコピーできません",
"copy_not_allowed": "このビューではコピー操作は許可されていません。",
"cut_not_allowed": "このビューでは切り取り操作は許可されていません。",
"paste_error": "ファイルの貼り付け中にエラーが発生しました",
"paste_failed": "ファイルの貼り付けに失敗しました",
"paste_sharing_error": "共有制限のためファイルを貼り付けることができません。代わりに移動アクションを使用してください。",
"paste_same_folder_skipped": "アイテムを既に存在する同じフォルダに移動することはできません。",
"paste_not_allowed": "このフォルダに貼り付けることはできません",
"cannot_move_shared_drive": "共有ドライブフォルダを移動することはできません",
"cannot_copy_shared_drive": "共有ドライブのフォルダをコピーできません"
},
"upload": {
"label": "アップロード",
"alert": {
"network": "現在オフラインです。 接続したらもう一度やり直してください。"
}
},
"intents": {
"alert": {
"error": "ファイルを自動的にアップロードできません。アップロードメニューで手動でアップロードしてください。"
},
"picker": {
"select": "選択",
"cancel": "キャンセル",
"new_folder": "新しいフォルダー",
"instructions": "対象を選択"
}
},
"UploadQueue": {
"header": "%{smart_count} 枚の写真を Twake ドライブにアップロード中 |||| %{smart_count} 枚の写真を Twake ドライブにアップロード中",
"header_mobile": "アップロード中 %{done} / %{total}",
"header_done": "%{done} / %{total} を正常にアップロードしました",
"close": "閉じる",
"item": {
"pending": "保留"
}
},
"Viewer": {
"close": "閉じる",
"noviewer": {
"download": "このファイルをダウンロード",
"openWith": "...で開く",
"cta": {
"saveTime": "時間を節約しましょう!",
"installDesktop": "コンピュータに同期ツールをインストール",
"accessFiles": "自分のコンピュータ上のファイルに直接アクセス"
}
},
"actions": {
"download": "ダウンロード"
},
"loading": {
"error": "このファイルを読み込めませんでした。 現在、インターネットに接続していますか?",
"retry": "再試行"
},
"error": {
"generic": "このファイルを開くときにエラーが発生しました。もう一度やり直してください。",
"noNetwork": "現在オフラインです。"
}
},
"Move": {
"to": "移動先:",
"action": "移動",
"cancel": "キャンセル",
"modalTitle": "移動",
"title": "%{smart_count} アイテム |||| %{smart_count} アイテム",
"success": "%{subject} を %{target} に移動しました。 |||| %{smart_count} アイテムを %{target} に移動しました。",
"error": "このアイテムを移動中に問題が発生しました。後でもう一度やり直してください。 |||| これらのアイテムを移動中に問題が発生しました。後でもう一度やり直してください。",
"cancelled": "%{subject} を元の場所にもどしました。 |||| %{smart_count} アイテムを元の場所に戻しました。",
"cancelledWithRestoreErrors": "%{subject} を元の場所に戻しましたが、ゴミ箱からファイルを復元する時にエラーが発生しました。 |||| %{smart_count} 件を元の場所に戻しましたが、ゴミ箱からファイルを復元する時に %{restoreErrorsCount} エラーが発生しました。",
"cancelled_error": "アイテムを戻す際にエラーが発生しました。 |||| アイテムを戻す際にエラーが発生しました。"
},
"ImportToDrive": {
"title": "%{smart_count} アイテム |||| %{smart_count} アイテム",
"to": "保存先:",
"action": "保存",
"cancel": "キャンセル",
"success": "%{smart_count} 保存済ファイル |||| %{smart_count} 保存済ファイル",
"error": "何か問題が発生しました。もう一度やり直してください"
},
"FileOpenerExternal": {
"fileNotFoundError": "エラー: ファイルが見つかりません"
},
"TOS": {
"updated": {
"title": "GDPR が現実のものになります !",
"detail": "一般データ保護規則に従って、[利用規約が更新されました](%{link}) 、2018 年 5 月 25 日にすべての Twake ユーザーに適用されます。",
"cta": "利用規約に同意して続行する",
"disconnect": "拒否して切断する",
"error": "問題が発生しました。後でもう一度やり直してください"
}
},
"manifest": {
"permissions": {
"contacts": {
"description": "連絡先とファイルを共有するために必要です"
},
"groups": {
"description": "グループとファイルを共有するために必要です"
}
}
},
"models": {
"contact": {
"defaultDisplayName": "匿名"
}
},
"Scan": {
"scan_a_doc": "ドキュメントをスキャン",
"save_doc": "ドキュメントを保存",
"filename": "ファイル名",
"save": "保存",
"cancel": "キャンセル",
"qualify": "分類",
"apply": "適用",
"error": {
"offline": "現在オフラインのため、この機能は使用できません。 後でもう一度やり直してください",
"uploading": "すでにファイルをアップロードしています。 このアップロードが終了するまで待ってから、もう一度やり直してください。",
"generic": "何か問題が発生しました。もう一度やり直してください。"
},
"successful": {
"qualified_ok": "ファイルの分類ができました!"
}
},
"History": {
"description": "ファイルの最新の20バージョンが自動的に保存されます。 ダウンロードするバージョンを選択してください。",
"current_version": "現在のバージョン",
"loading": "読み込んでいます...",
"noFileVersionEnabled": "Twake は、ファイルの最後の変更をすぐにアーカイブできるので、もう失う危険はありません。"
},
"External": {
"redirection": {
"title": "リダイレクト",
"text": "リダイレクトしています…",
"error": "リダイレクト中にエラーが発生しました。 通常、これはファイルの内容が正しい形式ではないことを意味します。"
}
},
"RenameModal": {
"title": "名前の変更",
"description": "ファイルの拡張子を変更しようとしています。 続行してもよろしいですか?",
"continue": "続行",
"cancel": "キャンセル"
},
"Shortcut": {
"title_modal": "ショートカットの作成",
"filename": "ファイル名",
"url": "URL",
"cancel": "キャンセル",
"create": "作成",
"created": "ショートカットを作成しました",
"errored": "エラーが発生しました",
"filename_error_ends": "名前は .url で終了する必要があります",
"needs_info": "ショートカットは URL とファイル名である必要があります",
"url_badformat": "URL が正しい形式ではありません"
},
"searchbar": {
"placeholder": "検索します",
"empty": "問い合わせ “%{query}” の結果が見つかりません"
},
"actions": {
"details": "詳細",
"personalizeFolder": {
"label": "フォルダをカスタマイズ"
},
"summariseByAI": "要約"
},
"FolderCustomizer": {
"title": "フォルダをカスタマイズ",
"description": "フォルダの特定の色を選択します",
"cancel": "キャンセル",
"apply": "適用",
"error": "エラーが発生しました。もう一度お試しください。",
"tabs": {
"colors": "色",
"icons": "アイコン"
},
"iconPicker": {
"recents": "最近使用",
"chooseCustomIcon": "カスタムアイコンを選択"
}
}
}
================================================
FILE: src/locales/ko.json
================================================
{
"Nav": {
"item_drive": "드라이브",
"item_recent": "최근",
"item_activity": "활동",
"item_settings": "설정",
"banner-btn-client": "다운로드",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8",
"link-client-web": "https://cozy.io/try-it"
},
"breadcrumb": {
"title_drive": "드라이브",
"title_recent": "최근",
"title_activity": "활동"
},
"Toolbar": {
"more": "더보기"
},
"toolbar": {
"item_more": "더보기",
"menu_new_folder": "폴더",
"menu_download": "다운로드",
"sharings_tab_all": "모두",
"sharings_tab_drives": "드라이브"
},
"Files": {
"share": {
"shareByLink": {
"creating": "링크 생성",
"copy": "링크 복사",
"copied": "링크를 클립보드에 복사했습니다."
},
"sharingLink": {
"copy": "복사"
},
"error": {
"generic": "파일 공유 링크 생성 중에 오류가 발생했습니다. 나중에 다시 시도하세요.",
"revoke": "이런, 오류가 발생했습니다. 저희에게 알려 주시면 최대한 빨리 문제를 해결하겠습니다."
}
}
},
"table": {
"head_name": "이름",
"head_size": "크기"
},
"error": {
"open_folder": "폴더를 여는 동안 문제가 발생했습니다.",
"button": {
"reload": "지금 새로고침"
}
},
"Error": {
"public_unshared_title": "죄송합니다. 이 링크는 더이상 이용할 수 없습니다.",
"generic": "오류가 발생했습니다. 나중에 다시 시도하세요."
},
"alert": {
"could_not_open_file": "이 파일을 열 수 없습니다.",
"item_copied": "1개 항목이 복사되었습니다",
"items_copied": "%{count}개 항목이 복사되었습니다",
"item_cut": "1개 항목이 잘라내기되었습니다",
"items_cut": "%{count}개 항목이 잘라내기되었습니다",
"item_moved": "1개 항목이 이동되었습니다",
"items_moved": "%{count}개 항목이 이동되었습니다",
"items_pasted": "%{count}개 항목이 이동되었습니다",
"copy_files_only": "폴더는 복사할 수 없습니다",
"copy_not_allowed": "이 보기에서는 복사 작업이 허용되지 않습니다.",
"cut_not_allowed": "이 보기에서는 잘라내기 작업이 허용되지 않습니다.",
"paste_error": "파일 붙여넣기 중 오류가 발생했습니다",
"paste_failed": "파일 붙여넣기에 실패했습니다",
"paste_sharing_error": "공유 제한으로 인해 파일을 붙여넣을 수 없습니다. 대신 이동 작업을 사용하십시오.",
"paste_same_folder_skipped": "항목을 이미 있는 동일한 폴더로 이동할 수 없습니다.",
"paste_not_allowed": "이 폴더에 붙여넣을 수 없습니다",
"cannot_move_shared_drive": "공유 드라이브 폴더를 이동할 수 없습니다",
"cannot_copy_shared_drive": "공유 드라이브 폴더를 복사할 수 없습니다"
},
"History": {
"loading": "불러오는 중..."
},
"OnlyOffice": {
"createFileName": {
"text": "새 문서 만들기",
"spreadsheet": "새 스프레드시트 만들기",
"slide": "새 프레젠테이션 만들기"
}
},
"actions": {
"details": "세부 정보",
"personalizeFolder": {
"label": "폴더 개인화"
},
"summariseByAI": "요약"
},
"FolderCustomizer": {
"title": "폴더 개인화",
"description": "폴더의 특정 색상을 선택하세요",
"cancel": "취소",
"apply": "적용",
"error": "오류가 발생했습니다. 다시 시도해 주세요.",
"tabs": {
"colors": "색상",
"icons": "아이콘"
},
"iconPicker": {
"recents": "최근 항목",
"chooseCustomIcon": "사용자 지정 아이콘 선택"
}
}
}
================================================
FILE: src/locales/nl.json
================================================
{
"breadcrumb": {
"title_drive": "Schijf",
"title_recent": "Recent",
"title_shared": "Gedeeld door mij",
"title_activity": "Activiteit",
"title_trash": "Prullenbak"
},
"toolbar": {
"menu_select": "Selecteer items",
"empty_trash": "Leeg de prullenbak",
"select_all": "Alles selecteren",
"delete_shared_drive": "Gedeelde schijf verwijderen",
"sharings_tab_all": "Alles",
"sharings_tab_drives": "Stations"
},
"table": {
"head_name": "Naam",
"head_update": "Laatst bijgewerkt",
"head_size": "Grootte",
"row_read_only": "Delen (alleen lezen)",
"row_read_write": "Delen (Lezen en schrijven)",
"row_size_symbols": {
"B": "B",
"KB": "KB",
"MB": "MB",
"GB": "GB",
"TB": "TB",
"PB": "PB",
"EB": "EB",
"ZB": "ZB",
"YB": "YB"
}
},
"DeleteConfirm": {
"title": "Verwijder dit element? |||| Verwijder deze elementen?",
"trash": "Het zal worden verplaatst naar de Prullenbak. ||| Ze zullen worden verplaatst naar de Prullenbak.",
"restore": "Je kunt het nog steeds terughalen als je wilt. |||| Je kunt ze nog steeds terughalen als je wilt.",
"cancel": "Annuleren",
"delete": "Verwijderen"
},
"emptytrashconfirmation": {
"title": "Permanent verwijderen?",
"forbidden": "Je kunt deze bestanden niet meer benaderen.",
"restore": "Als je geen back-up gemaakt hebt, kun je deze bestanden niet meer terugzetten.",
"cancel": "Annuleren",
"delete": "Verwijder alles"
},
"DestroyConfirm": {
"title": "Verwijder permanent?",
"forbidden": "Je kunt dit bestand net meer benaderen. |||| Je kunt deze bestanden niet meer benaderen.",
"restore": "Als je geen back-up gemaakt hebt, kun je dit bestand niet meer terugzetten. |||| Als je geen back-up gemaakt hebt, kun je deze bestanden niet meer terugzetten.",
"cancel": "Annuleren",
"delete": "Verwijder permanent"
},
"quotaalert": {
"title": "Jouw schijfruimte is vol :(",
"confirm": "OK"
},
"loading": {
"message": "Laden"
},
"empty": {
"title": "Er staan geen bestanden in deze map."
},
"error": {
"open_folder": "Er is is fout gegaan bij het openen van de map.",
"button": {
"reload": "Nu verversen"
},
"download_file": {
"offline": "Je moet verbonden zijn om dit bestand te downloaden",
"missing": "Dit bestand bestaat niet"
}
},
"alert": {
"try_again": "Er is een fout opgetreden, probeer het later nog eens.",
"restore_file_success": "De selectie is succesvol herstelt.",
"trash_file_success": "De selectie is verplaatst naar de Prullenbak.",
"destroy_file_success": "De selectie is permanent verwijderd.",
"folder_name": "Het element %{foldername} bestaat al, kies een andere naam.",
"folder_generic": "Er is een fout opgetreden, probeer het opnieuw.",
"folder_abort": "Je moet de nieuwe map een naam geven als je het wilt opslaan. De gegevens zijn niet opgeslagen.",
"offline": "Deze mogelijkheid is niet beschikbaar offline.",
"item_copied": "1 item gekopieerd",
"items_copied": "%{count} items gekopieerd",
"item_cut": "1 item geknipt",
"items_cut": "%{count} items geknipt",
"item_moved": "1 item is verplaatst",
"items_moved": "%{count} items zijn verplaatst",
"item_pasted": "1 item is verplaatst",
"items_pasted": "%{count} items zijn verplaatst",
"copy_files_only": "Mappen kunnen niet worden gekopieerd",
"copy_not_allowed": "Kopieerbewerking is niet toegestaan in deze weergave.",
"cut_not_allowed": "Knipbewerking is niet toegestaan in deze weergave.",
"paste_error": "Er is een fout opgetreden bij het plakken van bestanden",
"paste_failed": "Plakken van bestanden mislukt",
"paste_sharing_error": "Kan bestanden niet plakken vanwege deelbeperkingen. Gebruik in plaats daarvan de actie Verplaatsen.",
"paste_same_folder_skipped": "Kan items niet verplaatsen naar dezelfde map waar ze al in staan.",
"paste_not_allowed": "Je kunt niet plakken in deze map",
"cannot_move_shared_drive": "Je kunt de gedeelde schijfmap niet verplaatsen",
"cannot_copy_shared_drive": "Je kunt de gedeelde schijfmap niet kopiëren"
},
"actions": {
"details": "Details",
"personalizeFolder": {
"label": "Map personaliseren"
},
"summariseByAI": "Samenvatten"
},
"FolderCustomizer": {
"title": "Map personaliseren",
"description": "Kies een specifieke kleur voor uw map",
"cancel": "Annuleren",
"apply": "Toepassen",
"error": "Er is een fout opgetreden, probeer het opnieuw.",
"tabs": {
"colors": "Kleuren",
"icons": "Pictogrammen"
},
"iconPicker": {
"recents": "Recente",
"chooseCustomIcon": "Kies een aangepast pictogram"
}
}
}
================================================
FILE: src/locales/nl_NL.json
================================================
{
"Nav": {
"item_drive": "Schijf",
"item_recent": "Recent",
"item_sharings": "Gedeelde items",
"item_shared": "Door mij gedeeld",
"item_activity": "Activiteit",
"item_trash": "Prullenbak",
"item_settings": "Instellingen",
"item_collect": "Administratie",
"btn-client": "Download Twake Schijf voor je computer",
"btn-client-web": "Download Twake",
"btn-client-mobile": "Download %{name} Schijf op je telefoon!",
"banner-txt-client": "Download %{name} Schijf voor je computer en synchroniseer veilig je bestanden om ze overal beschikbaar te maken.",
"banner-btn-client": "Downloaden",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8",
"link-client-web": "https://cozy.io/try-it"
},
"breadcrumb": {
"title_drive": "Schijf",
"title_recent": "Recent",
"title_sharings": "Gedeelde items",
"title_shared": "Door mij gedeeld",
"title_activity": "Activiteit",
"title_trash": "Prullenbak",
"label": "Locatie tonen"
},
"Toolbar": {
"more": "Meer"
},
"toolbar": {
"menu_upload": "Bestanden uploaden",
"item_more": "Meer",
"menu_new_folder": "Map",
"menu_select": "Items selecteren",
"menu_share_folder": "Map delen",
"menu_download": "Downloaden",
"menu_sync_cozy": "Synchroniseren naar mijn Twake",
"add_to_mine": "Toevoegen aan mijn Twake",
"menu_download_folder": "Map downloaden",
"menu_download_file": "Download dit bestand",
"menu_create_note": "Notitie",
"menu_create_shortcut": "Snelkoppeling",
"empty_trash": "Prullenbak legen",
"share": "Delen",
"trash": "Verwijderen",
"delete_shared_drive": "Gedeelde schijf verwijderen",
"leave": "Gedeelde map verlaten en verwijderen",
"menu_add": "Toevoegen",
"menu_create": "Creëren",
"menu_add_item": "Item toevoegen",
"menu_onlyOffice": {
"text": "Tekstdocumen",
"spreadsheet": "Werkblad",
"slide": "Presentatie"
},
"select_all": "Alles selecteren",
"sharings_tab_all": "Alles",
"sharings_tab_drives": "Stations"
},
"Share": {
"create-cozy": "Maak mijn Twake"
},
"Files": {
"share": {
"cta": "Delen",
"title": "Delen",
"details": {
"title": "Deelinformatie",
"createdAt": "Op %{date}",
"ro": "Mag bekijken",
"rw": "Mag wijzigen",
"desc": {
"ro": "Je kunt deze inhoud bekijken, downloaden op en toevoegen aan je Twake. Je ontvangt bijgewerkte versies van de eigenaar, maar je kunt zelfs niks aanpassen.",
"rw": "Je kunt deze inhoud bekijken, downloaden op en toevoegen aan je Twake. Bijgewerkte versies zijn beschikbaar op andere Cozies."
}
},
"sharedByMe": "Door mij gedeeld",
"sharedWithMe": "Met mij gedeeld",
"sharedBy": "Gedeeld door %{name}",
"shareByLink": {
"subtitle": "Via openbare link",
"desc": "Iedereen die de link heeft kan je bestanden bekijken en downloaden.",
"creating": "Bezig met maken van je link…",
"copy": "Link kopiëren",
"copied": "Link is gekopieerd naar het klembord",
"failed": "Kan niet kopiëren naar klembord"
},
"shareByEmail": {
"subtitle": "Via e-mail",
"email": "Aan:",
"emailPlaceholder": "Voer het e-mailadres of de naam in van de ontvanger",
"send": "Versturen",
"genericSuccess": "Je hebt een uitnodiging verstuurd aan %{count} contactpersonen.",
"success": "Je hebt een uitnodiging verstuurd aan %{email}.",
"comingsoon": "Binnenkort kun je documenten en foto's met één klik delen met je familie, vrienden en zelfs met je collega's! Geen zorgen, we laten je weten wanneer dit beschikbaar is.",
"onlyByLink": "Dit %{type} kan niet worden gedeeld via een link omdat het",
"type": {
"file": "bestand",
"folder": "map"
},
"hasSharedParent": "een gedeelde bovenliggende map bevat",
"hasSharedChild": "een gedeeld itembevat"
},
"revoke": {
"title": "Verwijderen uit gedeelde items",
"desc": "De contactpersoon behoudt de kopie, maar aanpassingen worden niet langer gesynchroniseerd.",
"success": "Je hebt dit gedeelde bestand verwijderd uit %{email}."
},
"revokeSelf": {
"title": "Verwijder mij uit gedeelde items",
"desc": "De inhoud blijft bewaard, maar wordt niet langer bijgewerkt tussen je Twake-apparaten.",
"success": "Je bent verwijderd uit deze gedeelde items."
},
"sharingLink": {
"title": "Link om te delen",
"copy": "Kopiëren",
"copied": "Gekopieerd"
},
"whoHasAccess": {
"title": "1 persoon heeft toegang |||| %{smart_count} personen hebben toegang"
},
"protectedShare": {
"title": "Binnenkort!",
"desc": "Deel van alles via e-mail met je familie en vrienden!"
},
"close": "Sluiten",
"gettingLink": "Bezig met ophalen van je link…",
"error": {
"generic": "Er is een fout opgetreden tijdens het creëren van de link. Probeer het opnieuw.",
"revoke": "Oeps, er is een fout opgetreden. Neem contact met ons op zodat we het probleem z.s.m. kunnen verhelpen."
},
"specialCase": {
"base": "Dit %{type} kan niet worden gedeeld met een link omdat het",
"isInSharedFolder": "zich bevindt in een gedeelde map",
"hasSharedFolder": "een gedeelde map bevat"
}
},
"viewer-fallback": "Je kunt dit sluiten zodra het downloaden is gestart.",
"dropzone": {
"teaser": "Versleep bestanden om ze te uploaden naar:",
"noFolderSupport": "Je browser heeft geen ondersteuning voor slepen-en-neerzetten. Upload de bestanden handmatig."
}
},
"table": {
"head_name": "Naam",
"head_update": "Laatst bijgewerkt",
"head_size": "Grootte",
"head_status": "Delen",
"head_thumbnail_size": "Miniatuurgrootte aanpassen",
"row_update_format": "LLL d, yyyy",
"row_update_format_full": "LLLL d, yyyy",
"row_read_only": "Delen (alleen-lezen)",
"row_read_write": "Delen (lezen en bewerken)",
"row_size_symbols": {
"B": "B",
"KB": "KB",
"MB": "MB",
"GB": "GB",
"TB": "TB",
"PB": "PB",
"EB": "EB",
"ZB": "ZB",
"YB": "YB"
},
"load_more": "Meer laden",
"mobile": {
"head_name_asc": "A-Z",
"head_name_desc": "Z-A",
"head_updated_at_asc": "Oudste eerst",
"head_updated_at_desc": "Recentste eerst",
"head_size_asc": "Kleinste eerst",
"head_size_desc": "Grootste eerst"
},
"tooltip": {
"carbonCopy": {
"title": "Carbon Copy",
"caption": "Toont aan of het document ‘authentiek en origineel’ is verklaard door Twake Workplace, de hoster van je Twake. Het kan namelijk zo zijn dat de claim is gedaan door een externe partij zonder enige aanpassing."
},
"electronicSafe": {
"title": "Elektronische kluis",
"caption": "Toont aan of het oorspronkelijke document veilig is opgeslagen in je persoonlijke digitale kluis, voorzien van alle bijbehorende certificeringen en 50 jaar garantie."
}
}
},
"Storage": {
"title": "Opslag",
"availability": "%{smart_count} GB beschikbaar",
"increase": "Vergroot je ruimte"
},
"SelectionBar": {
"selected_count": "item geselecteerd |||| items geselecteerd",
"share": "Delen",
"download": "Downloaden",
"trash": "Verwijderen",
"destroy": "Permanent verwijderen",
"rename": "Naam wijzigen",
"restore": "Herstellen",
"close": "Sluiten",
"openWith": "Openen met…",
"applePreview": "Apple-voorbeeld",
"forward": "Vooruit",
"forwardTo": "Doorsturen naar…",
"moveto": "Verplaatsen naar…",
"moveto_mobile": "Verplaatsen",
"phone-download": "Offline beschikbaar maken",
"qualify": "Categoriseren",
"history": "Geschiedenis",
"more": "Meer"
},
"DeleteConfirm": {
"title": "Dit item verwijderen? |||| Deze items verwijderen?",
"trash": "Het wordt verplaatst naar de prullenbak. |||| Ze worden verplaatst naar de prullenbak.",
"restore": "Je kunt het ten allen tijde herstellen. |||| Je kunt ze ten allen tijde herstellen.",
"link": "De gedeelde link komt te vervallen",
"referenced": "Sommige geselecteerde bestanden horen bij een foto-album. Als je doorgaat, dan worden ze verwijderd.",
"cancel": "Annuleren",
"delete": "Verwijderen"
},
"emptytrashconfirmation": {
"title": "Permanent verwijderen?",
"forbidden": "Je hebt dan geen toegang meer tot deze bestanden.",
"restore": "Je kunt deze bestanden niet herstellen als je geen back-up hebt gemaakt.",
"cancel": "Annuleren",
"delete": "Alles verwijderen"
},
"DestroyConfirm": {
"title": "Permanent verwijderen?",
"forbidden": "Je hebt dan geen toegang meer tot dit bestand. |||| Je hebt dat geen toegang meer tot deze bestanden.",
"restore": "Je kunt dit bestand niet herstellen als je geen back-up hebt gemaakt. |||| Je kunt deze bestanden niet herstellen als je geen back-up hebt gemaakt.",
"cancel": "Annuleren",
"delete": "Permanent verwijderen"
},
"quotaalert": {
"title": "Je hebt geen vrije schijfruimte meer :(",
"desc": "Verwijder bestanden en leeg de prullenbak voordat je wéér probeert om bestanden te uploaden.",
"confirm": "Oké",
"increase": "Vergroot je schijfruimte"
},
"loading": {
"message": "Bezig met laden…",
"onlyOfficeCreateInProgress": "Bezig met aanmaken van huidig bestand…"
},
"empty": {
"title": "Deze map bevat geen bestanden.",
"text": "Selecteer bestanden op uw computer of sleep ze hierheen.",
"mobile_text": "Selecteer bestanden op je apparaat.",
"trash_title": "Je hebt geen verwijderde bestanden.",
"trash_text": "Verplaats bestanden die je niet langer nodig hebt naar de prullenbak en verwijder items permanent om ruimte vrij te maken."
},
"error": {
"open_folder": "Er is iets misgegaan tijdens het openen van de map.",
"open_file": "Er is iets misgegaan tijdens het openen van het bestand.",
"button": {
"reload": "Nu herladen"
},
"download_file": {
"offline": "Je moet verbonden zijn om dit bestand te kunnen downloaden",
"missing": "Dit bestand ontbreekt"
}
},
"Error": {
"public_unshared_title": "Sorry, deze link niet langer beschikbaar.",
"public_unshared_text": "Deze link is verlopen of verwijderd door de eigenaar. Stel hem of haar hiervan op de hoogte!",
"generic": "Er is iets misgegaan. Wacht een paar minuten en probeer het opnieuw."
},
"alert": {
"could_not_open_file": "Het bestand kan niet worden geopend",
"try_again": "Er is een fout opgetreden; probeer het later opnieuw.",
"restore_file_success": "De selectie is hersteld.",
"trash_file_success": "De selectie is verplaatst naar de prullenbak.",
"destroy_file_success": "De selectie is permanent verwijderd.",
"empty_trash_progress": "De prullenbak wordt geleegd; dit kan even duren.",
"empty_trash_success": "De prullenbak is geleegd.",
"folder_name": "‘%{folderName}’ bestaat al. Kies een andere naam.",
"file_name": "‘%{fileName}’ bestaat al. Kies een andere naam.",
"file_name_missing": "De bestandsnaam ontbreekt - geef een naam op.",
"file_name_illegal_name": "‘%{fileName}’ is ongeldig. Kies een andere naam.",
"file_name_illegal_characters": "%{fileName} bevat ongeldige tekens: %{characters}",
"folder_generic": "Er is een fout opgetreden; probeer het opnieuw.",
"folder_abort": "Als je je nieuwe map wilt opslaan, dan moet je deze een naam geven. Je informatie is niet opgeslagen.",
"offline": "Deze functie is niet offline beschikbaar.",
"preparing": "Bezig met voorbereiden van je bestanden…",
"item_copied": "1 item gekopieerd",
"items_copied": "%{count} items gekopieerd",
"item_cut": "1 item geknipt",
"items_cut": "%{count} items geknipt",
"item_moved": "1 item is verplaatst",
"items_moved": "%{count} items zijn verplaatst",
"item_pasted": "1 item is verplaatst",
"items_pasted": "%{count} items zijn verplaatst",
"copy_files_only": "Mappen kunnen niet worden gekopieerd",
"copy_not_allowed": "Kopieerbewerking is niet toegestaan in deze weergave.",
"cut_not_allowed": "Knipbewerking is niet toegestaan in deze weergave.",
"paste_error": "Er is een fout opgetreden bij het plakken van bestanden",
"paste_failed": "Plakken van bestanden mislukt",
"paste_sharing_error": "Kan bestanden niet plakken vanwege deelbeperkingen. Gebruik in plaats daarvan de actie Verplaatsen.",
"paste_same_folder_skipped": "Kan items niet verplaatsen naar dezelfde map waar ze al in staan.",
"paste_not_allowed": "U kunt niet plakken in deze map",
"cannot_move_shared_drive": "U kunt de gedeelde schijfmap niet verplaatsen",
"cannot_copy_shared_drive": "Je kunt de gedeelde schijfmap niet kopiëren"
},
"upload": {
"label": "Uploaden",
"alert": {
"network": "Je bent momenteel offline. Maak verbinding en probeer het opnieuw."
}
},
"intents": {
"alert": {
"error": "Het bestand kan niet automatisch worden geüpload. Doe het handmatig via het uploadmenu."
},
"picker": {
"select": "Selecteren",
"cancel": "Annuleren",
"new_folder": "Nieuwe map",
"instructions": "Kies een doel"
}
},
"UploadQueue": {
"header": "Bezig met uploaden van %{smart_count} foto naar Twake Drive |||| Bezig met uploaden van %{smart_count} foto's naar Twake Drive",
"header_mobile": "Bezig met uploaden - %{done} van %{total}…",
"header_done": "%{done} van de %{total} geüpload",
"close": "sluiten",
"item": {
"pending": "In wachtrij"
}
},
"Viewer": {
"close": "Sluiten",
"noviewer": {
"download": "Download dit bestand",
"openWith": "Openen met…",
"openInOnlyOffice": "Openen met OnlyOffice",
"cta": {
"saveTime": "Bespaar wat tijd!",
"installDesktop": "Installeer de synchronisatie-app op je computer",
"accessFiles": "Direct toegang tot je bestanden vanaf je computer"
}
},
"actions": {
"download": "Downloaden",
"forward": "Vooruit"
},
"loading": {
"error": "Dit bestand kan niet worden geladen. Ben je verbonden met het internet?",
"retry": "Opnieuw proberen"
},
"error": {
"noapp": "Er is geen app die dit bestand in behandeling kan nemen.",
"generic": "Er is een fout opgetreden tijdens het openen van dit bestand. Probeer het opnieuw.",
"noNetwork": "Je bent momenteel offline."
},
"panel": {
"title": "Nuttige informatie"
}
},
"Move": {
"to": "Verplaatsen naar:",
"action": "Verplaatsen",
"cancel": "Annuleren",
"modalTitle": "Verplaatsen",
"title": "%{smart_count} item |||| %{smart_count} items",
"success": "%{subject} is verplaatst naar %{target}. ||| %{smart_count} items zijn verplaatst naar %{target}.",
"error": "Er is iets misgegaan tijdens het verplaatsen van dit item; probeer het later opnieuw. |||| Er is iets misgegaan tijdens het verplaatsen van deze items; probeer het later opnieuw.",
"cancelled": "%{subject} is teruggeplaatst op de oorspronkelijke locatie. ||| %{smart_count} items zijn teruggeplaatst op hun oorspronkelijke locatie.",
"cancelledWithRestoreErrors": "%{subject} is teruggeplaatst op de oorspronkelijke locatie, maar er is een fout opgetreden. ||| %{smart_count} items zijn teruggeplaatst op hun oorspronkelijke locatie, maar er zijn %{restoreErrorsCount} fouten opgetreden.",
"cancelled_error": "Sorry, er is iets misgegaan tijdens het terughalen van dit item. |||| Sorry, er is iets misgegaan tijdens terughalen van deze items.",
"multipleEntries": "%{smart_count} item |||| %{smart_count} items",
"outsideSharedFolder": {
"title": "Verplaatsen buiten de map ‘%{sharedFolder}’",
"cancel": "Annuleren",
"confirm": "Ik begrijp het"
}
},
"ImportToDrive": {
"title": "%{smart_count} item |||| %{smart_count} items",
"to": "Opslaan in:",
"action": "Opslaan",
"cancel": "Annuleren",
"success": "%{smart_count} opgeslagen bestand |||| %{smart_count} opgeslagen bestanden",
"error": "Er is iets misgegaan; probeer het opnieuw."
},
"FileOpenerExternal": {
"fileNotFoundError": "Fout: bestand niet gevonden"
},
"TOS": {
"updated": {
"title": "De GDPR is werkelijkheid geworden!",
"detail": "In verband met de General Data Protection Regulation, ook wel AVG, [zijn onze algemene voorwaarden bijgewerkt](%{link}) en van toepassing op alle Twake-gebruikers vanaf 25 mei 2018.",
"cta": "Voorwaarden accepteren en doorgaan",
"disconnect": "Weigeren en verbinding verbreken",
"error": "Er is iets misgegaan; probeer het later opnieuw."
}
},
"manifest": {
"permissions": {
"contacts": {
"description": "Vereist om bestanden te kunnen delen met je contactpersonen"
},
"groups": {
"description": "Vereist om bestanden te kunnen delen in je groepen"
}
}
},
"models": {
"contact": {
"defaultDisplayName": "Anoniem"
}
},
"Scan": {
"scan_a_doc": "Document scannen",
"save_doc": "Document opslaan",
"filename": "Bestandsnaam",
"save": "Opslaan",
"cancel": "Annuleren",
"qualify": "Categoriseren",
"apply": "Toepassen",
"error": {
"offline": "Je kunt deze functie momenteel niet gebruiken omdat je offline bent. Probeer het later opnieuw.",
"uploading": "Je bent al een bestand aan het uploaden. Wacht tot dat is afgerond en probeer het dan opnieuw.",
"generic": "Er is iets misgegaan; probeer het opnieuw."
},
"successful": {
"qualified_ok": "Je hebt je eerste bestand gecategoriseerd!"
}
},
"History": {
"description": "De laatste 20 versies van je bestanden worden automatisch bewaard. Selecteer een versie om deze te downloaden.",
"current_version": "Huidige versie",
"loading": "Bezig met laden…",
"noFileVersionEnabled": "Je Twake kan binnenkort de recenste bestandsaanpassingen archiveren zodat je nooit meer een bestand kwijtraakt"
},
"External": {
"redirection": {
"title": "Doorverwijzing",
"text": "Je wordt doorverwezen…",
"error": "Doorverwijzing mislukt. Normaliter betekent dit dat de bestandsinhoud niet goed is opgemaakt."
}
},
"RenameModal": {
"title": "Naam wijzigen",
"description": "Je staat op het punt om de bestandsextensie te wijzigen. Wil je doorgaan?",
"continue": "Doorgaan",
"cancel": "Annuleren"
},
"Shortcut": {
"title_modal": "Snelkoppeling maken",
"filename": "Bestandsnaam",
"url": "URL",
"cancel": "Annuleren",
"create": "Maken",
"created": "De snelkoppeling is gemaakt",
"errored": "Er is een fout opgetreden",
"filename_error_ends": "De naam moet eindigen op .url",
"needs_info": "De snelkoppeling moet op zijn minst voorzien zijn van een url en bestandsnaam",
"url_badformat": "De url is onjuist opgemaakt"
},
"OnlyOffice": {
"Error": {
"title": "Er is iets misgegaan",
"text": "Herlaad de pagina"
},
"readOnly": {
"title": "Alleen-lezen",
"tooltip": "Je bent alleen bevoegd om dit document te lezen - neem contact op met de eigenaar als je het ook wilt kunnen bewerken."
},
"createFileName": {
"text": "Nieuw tekstdocument",
"spreadsheet": "Nieuw werkblad",
"slide": "Nieuwe presentatie"
},
"toolbar": {
"goToHome": "Ga naar overzicht"
},
"actions": {
"edit": "Bewerken",
"validate": "Verifiëren"
}
},
"Migration": {
"title": "Twake Schijf bijwerken",
"content": "Twake Schijf moet worden bijgewerkt om de prestaties ervan te verbeteren - dit kan enkele minuten duren. Gedurende deze periode kun je de app niet gebruiken. Wil je Twake Schijf nu bijwerken? Als je dat niet wilt, dan vragen we het volgende keer opnieuw.",
"confirm": "Ja, doe maar!",
"cancel": "Nee, niet nu"
},
"searchbar": {
"placeholder": "Zoeken naar iets",
"empty": "Geen resultaten gevonden voor de zoekopdracht \"%{query}\""
},
"button": {
"back": "Terug",
"add": "Toevoegen",
"create": "Creëren"
},
"search": {
"action": "Zoeken",
"empty": {
"title": "Geen zoekresultaten",
"subtitle": "Geen resultaten gevonden voor de zoekopdracht \"%{query}\""
}
},
"PushBanner": {
"quota": {
"text": "Je hebt nog weinig opslagruimte. Als je het limiet bereikt, dan kun je geen bestanden meer toevoegen. Je kunt bestanden verwijderen, de prullenbak legen of een ander abonnement kiezen om ruimte vrij te maken.",
"actions": {
"first": "Ik begrijp het",
"second": "Abonnementen bekijken"
}
}
},
"EntriesType": {
"file": "bestand |||| bestanden",
"directory": "map |||| mappen",
"element": "item |||| items"
},
"actions": {
"details": "Details",
"personalizeFolder": {
"label": "Map personaliseren"
},
"summariseByAI": "Samenvatten"
},
"FolderCustomizer": {
"title": "Map personaliseren",
"description": "Kies een specifieke kleur voor uw map",
"cancel": "Annuleren",
"apply": "Toepassen",
"error": "Er is een fout opgetreden, probeer het opnieuw.",
"tabs": {
"colors": "Kleuren",
"icons": "Pictogrammen"
},
"iconPicker": {
"recents": "Recente",
"chooseCustomIcon": "Kies een aangepast pictogram"
}
}
}
================================================
FILE: src/locales/pl.json
================================================
{
"Nav": {
"item_drive": "Dysk",
"item_recent": "Bieżące",
"item_shared": "Udostępnione przeze mnie",
"item_activity": "Aktywności",
"item_trash": "Kosze",
"item_settings": "Ustawienia",
"btn-client-web": "Pobierz Twake",
"btn-client-mobile": "Pobierz Twake Drive na urządzenie mobilne",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8"
},
"breadcrumb": {
"title_drive": "Dysk",
"title_recent": "Bieżące",
"title_shared": "Udostępnione przeze mnie",
"title_activity": "Aktywności",
"title_trash": "Kosze"
},
"Toolbar": {
"more": "Więcej"
},
"toolbar": {
"item_more": "Więcej",
"menu_select": "Wybierz elementy",
"menu_download_folder": "Pobierz folder",
"empty_trash": "Opróżnij kosz",
"share": "Udostępnij",
"leave": "Opuść udostępniony folder i usuń go",
"select_all": "Zaznacz wszystko",
"sharings_tab_all": "Wszystko",
"sharings_tab_drives": "Dyski"
},
"Files": {
"share": {
"cta": "Udostępnij",
"title": "Udostępnij",
"details": {
"title": "Szczegóły udostępniania",
"createdAt": "Utworzone %{date}"
},
"sharedByMe": "Udostępnione przeze mnie",
"sharedWithMe": "Udostępnione dla mnie",
"shareByLink": {
"desc": "Każdy posiadający ten lim może zobaczyć i pobrać Twoje pliki."
},
"shareByEmail": {
"email": "Do:",
"send": "Wyślij",
"genericSuccess": "Wysłałeś zaproszenie do %{count} kontaktów.",
"success": "Wysłałeś zaproszenie do %{email}."
}
}
},
"searchbar": {
"placeholder": "Szukaj czegokolwiek",
"empty": "Brak wyników dla wyszukania \"%{query}\""
},
"search": {
"empty": {
"subtitle": "Brak wyników dla wyszukania \"%{query}\""
}
},
"alert": {
"item_copied": "1 element skopiowany",
"items_copied": "%{count} elementów skopiowanych",
"item_cut": "1 element wycięty",
"items_cut": "%{count} elementów wyciętych",
"item_moved": "1 element został przeniesiony",
"items_moved": "%{count} elementów zostało przeniesionych",
"item_pasted": "1 element został przeniesiony",
"items_pasted": "%{count} elementów zostało przeniesionych",
"copy_files_only": "Nie można kopiować folderów",
"copy_not_allowed": "Operacja kopiowania nie jest dozwolona w tym widoku.",
"cut_not_allowed": "Operacja wycinania nie jest dozwolona w tym widoku.",
"paste_error": "Wystąpił błąd podczas wklejania plików",
"paste_failed": "Wklejanie plików nie powiodło się",
"paste_sharing_error": "Nie można wkleić plików z powodu ograniczeń udostępniania. Zamiast tego użyj akcji Przenieś.",
"paste_same_folder_skipped": "Nie można przenieść elementów do tego samego folderu, w którym już się znajdują.",
"paste_not_allowed": "Nie możesz wkleić do tego folderu",
"cannot_move_shared_drive": "Nie możesz przenieść folderu dysku udostępnionego",
"cannot_copy_shared_drive": "Nie możesz skopiować folderu dysku współdzielonego"
},
"actions": {
"details": "Szczegóły",
"personalizeFolder": {
"label": "Personalizuj folder"
},
"summariseByAI": "Podsumuj"
},
"FolderCustomizer": {
"title": "Personalizuj folder",
"description": "Wybierz konkretny kolor dla swojego folderu",
"cancel": "Anuluj",
"apply": "Zastosuj",
"error": "Wystąpił błąd, spróbuj ponownie.",
"tabs": {
"colors": "Kolory",
"icons": "Ikony"
},
"iconPicker": {
"recents": "Ostatnie",
"chooseCustomIcon": "Wybierz niestandardową ikonę"
}
}
}
================================================
FILE: src/locales/ru.json
================================================
{
"Nav": {
"item_drive": "Мой диск",
"item_recent": "Недавние",
"item_sharings": "Общие",
"item_shared": "Мои отправленные файлы",
"item_activity": "Активность",
"item_trash": "Корзина",
"item_migration": "Миграция",
"item_settings": "Настройки",
"item_collect": "Администрирование",
"item_shared_drives": "Общие диски",
"item_favorites": "Избранное",
"item_my_drive": "Мой диск",
"btn-client": "Получить достуа к Twake Drive для ПК",
"support-us": "Посмотреть предложения",
"support-us-description": "Хотите получить больше места или просто поддержать Cozy?",
"btn-client-web": "Получить доступ к Twake",
"btn-client-mobile": "Возьмите свой персональный облачный сервис с собой: установите %{name} на все устройства!",
"banner-txt-client": "Получите %{name} для ПК и безопасно синхронизируйте свои файлы, чтобы они всегда были доступны.",
"banner-btn-client": "Скачать",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile",
"link-client-ios": "https://apps.apple.com/app/cloud-personnel-cozy/id1600636174",
"link-client-web": "https://cozy.io/try-it",
"view_more": "Показать больше",
"view_less": "Показать меньше",
"item_nextcloud": "Nextcloud"
},
"breadcrumb": {
"title_drive": "Файлы",
"title_recent": "Недавние",
"title_sharings": "Общие",
"title_shared": "Мои отправленные файлы",
"title_activity": "Активность",
"title_trash": "Корзина",
"label": "Показать путь",
"title_shared_drives": "Диски",
"title_favorites": "Избранное"
},
"Toolbar": {
"more": "Ещё"
},
"toolbar": {
"menu_upload": "Загрузить файлы",
"item_more": "Ещё",
"menu_new_folder": "Папка",
"menu_select": "Выбрать элементы",
"menu_share_folder": "Поделиться папкой",
"menu_download": "Скачать",
"menu_sync_cozy": "Синхронизировать с моим Twake",
"add_to_mine": "Добавить в мой Twake",
"menu_download_folder": "Скачать папку",
"menu_download_file": "Скачать этот файл",
"menu_create_note": "Заметка",
"menu_create_shortcut": "Ярлык",
"share": "Поделиться",
"trash": "Удалить",
"delete_shared_drive": "Удалить общий диск",
"leave": "Покинуть доступную папку и удалить ее.",
"menu_add": "Добавить",
"menu_create": "Создать",
"menu_add_item": "Добавить элемент",
"menu_onlyOffice": {
"text": "Текстовый документ",
"spreadsheet": "Таблица",
"slide": "Презентация"
},
"select_all": "Выбрать всё",
"select_all_mobile": "все",
"clear_selection": "Очистить выбор",
"clear_selection_mobile": "отмена",
"sharings_tab_all": "Всё",
"sharings_tab_drives": "Диски"
},
"Share": {
"create-cozy": "Создать Twake Drive"
},
"Files": {
"share": {
"cta": "Поделиться",
"title": "Поделиться",
"details": {
"title": "Информация об общем доступе",
"createdAt": "%{date}",
"ro": "Можно читать",
"rw": "Можно изменять",
"desc": {
"ro": "Вы можете просматривать, скачивать и добавлять эти файлы на свой диск. Вы будете получать обновления от владельца, но не сможете вносить изменения.",
"rw": "Вы можете просматривать, редактировать, удалять и добавлять файлы на свой диск. Ваши изменения будут видны другим пользователям."
}
},
"shared": "Общий доступ",
"sharedByMe": "Мои отправленные файлы",
"sharedWithMe": "Доступно мне",
"sharedBy": "Доступ предоставлен %{name}",
"shareByLink": {
"subtitle": "По публичной ссылке",
"desc": "Любой, у кого есть эта ссылка, может просматривать и скачивать ваши файлы.",
"creating": "Создание ссылки...",
"copy": "Копировать ссылку",
"copied": "Ссылка скопирована в буфер обмена",
"failed": "Не удалось скопировать в буфер обмена"
},
"shareByEmail": {
"subtitle": "По email",
"email": "Кому:",
"emailPlaceholder": "Введите email или имя получателя",
"send": "Отправить",
"genericSuccess": "Вы отправили %{count} приглашений контактам.",
"success": "Вы отправили приглашение %{email}.",
"comingsoon": "Скоро вы сможете делиться документами и фотографиями в один клик с семьёй, друзьями и коллегами. Мы сообщим, когда функция будет доступна!",
"onlyByLink": "%{type} можно отправить только по ссылке, потому что",
"type": {
"file": "файл",
"folder": "папка"
},
"hasSharedParent": "он находится в общей папке",
"hasSharedChild": "он содержит общий элемент"
},
"revoke": {
"title": "Прекратить общий доступ",
"desc": "Этот контакт сохранит копию, но изменения больше не будут синхронизироваться.",
"success": "Вы прекратили общий доступ к файлу для %{email}."
},
"revokeSelf": {
"title": "Прекратить мой доступ",
"desc": "Вы сохраните контент, но он больше не будет обновляться между вашими дисками.",
"success": "Ваш доступ к этому общему ресурсу отменён."
},
"sharingLink": {
"title": "Ссылка для общего доступа",
"copy": "Копировать",
"copied": "Скопировано"
},
"whoHasAccess": {
"title": "1 человек имеет доступ |||| %{smart_count} человек имеют доступ"
},
"protectedShare": {
"title": "Скоро!",
"desc": "Делитесь любыми файлами по электронной почте с семьёй и друзьями!"
},
"close": "Закрыть",
"gettingLink": "Создание ссылки...",
"error": {
"generic": "Произошла ошибка при создании ссылки для общего доступа, попробуйте ещё раз.",
"revoke": "Произошла ошибка. Пожалуйста, свяжитесь с нами, чтобы мы могли решить эту проблему как можно скорее."
},
"specialCase": {
"base": "Этот %{type} можно отправить только по ссылке, так как он",
"isInSharedFolder": "находится в общей папке",
"hasSharedFolder": "содержит общую папку"
}
},
"viewer-fallback": "Если файл начал загружаться, вы можете закрыть это окно.",
"dropzone": {
"teaser": "Перетащите файлы для загрузки в:",
"noFolderSupport": "Ваш браузер не поддерживает перетаскивание папок. Пожалуйста, загрузите файлы вручную."
}
},
"table": {
"head_name": "Имя",
"head_update": "Последнее обновление",
"head_size": "Размер",
"head_status": "Общий доступ",
"head_thumbnail_size": "Изменить размер миниатюр",
"head_view_mode": "Режим просмотра",
"head_view_list": "Список",
"head_view_grid": "Плитка",
"row_update_format": "LLL d, yyyy",
"row_update_format_full": "LLLL d, yyyy",
"row_read_only": "Общий доступ (Только чтение)",
"row_read_write": "Общий доступ (Чтение и запись)",
"row_size_symbols": {
"B": "Б",
"KB": "КБ",
"MB": "МБ",
"GB": "ГБ",
"TB": "ТБ",
"PB": "ПБ",
"EB": "ЭБ",
"ZB": "ЗБ",
"YB": "ЙБ"
},
"row_sharing_shortcut_aria_label": "Новый ярлык общего доступа",
"load_more": "Загрузить ещё",
"mobile": {
"head_name_asc": "А-Я",
"head_name_desc": "Я-А",
"head_updated_at_asc": "Сначала старые",
"head_updated_at_desc": "Сначала новые",
"head_size_asc": "Сначала лёгкие",
"head_size_desc": "Сначала тяжёлые"
},
"tooltip": {
"carbonCopy": {
"title": "Копия",
"caption": "Указывает, является ли документ \"аутентичным и оригинальным\" согласно Twake Workplace, так как может утверждаться, что получен напрямую от стороннего сервиса без изменений."
},
"electronicSafe": {
"title": "Электронный сейф",
"caption": "Указывает, защищён ли оригинальный документ вашим личным цифровым сейфом с сертификатами, которые придают ему доказательную силу и гарантируют хранение в течение 50 лет после загрузки."
}
}
},
"Storage": {
"title": "Хранилище",
"availability": "Доступно %{smart_count} ГБ",
"increase": "Увеличить пространство"
},
"SelectionBar": {
"selected_count": "выбран 1 элемент |||| выбрано %{smart_count} элементов",
"share": "Поделиться",
"download": "Скачать",
"trash": "Удалить",
"destroy": "Удалить навсегда",
"rename": "Переименовать",
"restore": "Восстановить",
"close": "Закрыть",
"openWith": "Открыть с помощью...",
"applePreview": "Просмотр Apple",
"forward": "Переслать",
"forwardTo": "Переслать...",
"moveto": "Переместить в...",
"moveto_mobile": "Переместить",
"phone-download": "Сделать доступным офлайн",
"qualify": "Категоризировать",
"history": "История",
"more": "Ещё",
"openWithinNextcloud": "Открыть в Nextcloud"
},
"DeleteConfirm": {
"title": "Удалить %{filename}? |||| Удалить %{smart_count} %{type}?",
"trash": "Элемент будет перемещён в корзину. |||| Элементы будут перемещены в корзину.",
"restore": "Вы сможете восстановить его в любое время. |||| Вы сможете восстановить их в любое время.",
"share_accepted": "Общий доступ будет остановлен. Указанные ниже контакты сохранят копию, но ваши изменения больше не будут синхронизироваться:",
"share_waiting": "Общий доступ будет остановлен. Указанные ниже контакты больше не смогут принять приглашение или получить доступ к файлам:",
"share_both": "Общий доступ будет остановлен. Это означает, что контакты, сохранившие файлы на своем диске, сохранят их копию, а другие контакты больше не смогут получить доступ к общим файлам:",
"link": "Общий доступ по ссылке больше не будет активен",
"referenced": "Некоторые файлы в выборке связаны с фотоальбомом. Они будут удалены из него, если вы переместите их в корзину.",
"cancel": "Отмена",
"delete": "Удалить"
},
"EmptyTrashConfirm": {
"title": "Удалить навсегда?",
"forbidden": "Вы больше не сможете получить доступ к этим файлам.",
"restore": "Вы не сможете восстановить эти файлы, если у вас нет резервной копии.",
"cancel": "Отмена",
"delete": "Удалить всё",
"processing": "Корзина очищается. Это может занять некоторое время.",
"success": "Корзина очищена.",
"error": "Произошла ошибка, попробуйте ещё раз."
},
"DestroyConfirm": {
"title": "Удалить %{filename}? |||| Удалить %{smart_count} %{type}?",
"forbidden": "Вы больше не сможете получить доступ к этому(-ой) %{type}. |||| Вы больше не сможете получить доступ к этим %{type}.",
"restore": "Вы не сможете восстановить этот(-у) %{type}, если у вас нет резервной копии. |||| Вы не сможете восстановить эти %{type}, если у вас нет резервной копии.",
"cancel": "Отмена",
"delete": "Удалить навсегда",
"success": "%{type} удален(-а) навсегда. |||| %{smart_count} %{type} удалены навсегда.",
"error": "Произошла ошибка, попробуйте ещё раз."
},
"quotaalert": {
"title": "Ваше дисковое пространство заполнено :(",
"desc": "Пожалуйста, удалите файлы, очистите корзину или увеличьте дисковое пространство перед загрузкой новых файлов.",
"confirm": "OK",
"increase": "Увеличить дисковое пространство"
},
"loading": {
"message": "Загрузка",
"onlyOfficeCreateInProgress": "Создание файла..."
},
"empty": {
"title": "В этой папке нет файлов.",
"text": "Выберите файлы на вашем компьютере или перетащите их сюда.",
"mobile_text": "Выберите файлы на вашем устройстве.",
"trash_title": "У вас нет удалённых файлов.",
"trash_text": "Перемещайте ненужные файлы в корзину и удаляйте их навсегда, чтобы освободить место.",
"shared-drive_text": "Создайте и поделитесь вашим первым Диск."
},
"error": {
"open_folder": "Произошла ошибка при открытии папки.",
"open_file": "Произошла ошибка при открытии файла.",
"button": {
"reload": "Обновить сейчас"
},
"download_file": {
"offline": "Для скачивания файла необходимо подключение к интернету",
"missing": "Этот файл отсутствует"
}
},
"Error": {
"public_unshared_title": "Извините, эта ссылка больше недоступна.",
"public_unshared_text": "Срок действия ссылки истёк, или владелец удалил её. Сообщите ему, что вы не смогли получить доступ",
"generic": "Что-то пошло не так. Подождите несколько минут и попробуйте снова."
},
"alert": {
"could_not_open_file": "Не удалось открыть файл",
"try_again": "Произошла ошибка, попробуйте ещё раз через некоторое время.",
"restore_file_success": "Выбранные элементы успешно восстановлены.",
"trash_file_success": "Выбранные элементы перемещены в корзину.",
"destroy_file_success": "Выбранные элементы удалены навсегда.",
"folder_name": "Элемент %{folderName} уже существует, выберите другое имя.",
"file_name": "Элемент %{fileName} уже существует, выберите другое имя.",
"file_name_missing": "Отсутствует имя файла, выберите другое имя.",
"file_name_illegal_name": "Имя %{fileName} недопустимо, выберите другое имя.",
"file_name_illegal_characters": "Элемент %{fileName} содержит недопустимые символы: %{characters}",
"folder_generic": "Произошла ошибка, попробуйте ещё раз.",
"folder_abort": "Чтобы сохранить новую папку, необходимо указать её имя. Ваши данные не сохранены.",
"offline": "Эта функция недоступна офлайн.",
"preparing": "Подготовка файлов…",
"item_copied": "1 элемент скопирован",
"items_copied": "%{count} элементов скопированы",
"item_cut": "1 элемент вырезан",
"items_cut": "%{count} элементов вырезаны",
"item_moved": "1 элемент был перемещён",
"items_moved": "%{count} элементов было перемещено",
"item_pasted": "1 элемент был перемещён",
"items_pasted": "%{count} элементов было перемещено",
"copy_files_only": "Невозможно скопировать папки",
"copy_not_allowed": "Операция копирования не разрешена в этом представлении.",
"cut_not_allowed": "Операция вырезания не разрешена в этом представлении.",
"paste_error": "Произошла ошибка при вставке файлов",
"paste_failed": "Не удалось вставить файлы",
"paste_sharing_error": "Невозможно вставить файлы из-за ограничений общего доступа. Используйте действие Переместить вместо этого.",
"paste_same_folder_skipped": "Невозможно переместить элементы в ту же папку, в которой они уже находятся.",
"paste_not_allowed": "Вы не можете вставить в эту папку",
"cannot_move_shared_drive": "Вы не можете переместить папку общего диска",
"cannot_copy_shared_drive": "Вы не можете скопировать папку общего диска"
},
"upload": {
"label": "Загрузить",
"documentType": {
"file": "файл",
"directory": "папка",
"element": "элемент"
},
"alert": {
"success": "%{smart_count} %{type} успешно загружен. |||| %{smart_count} %{type} успешно загружены.",
"success_conflicts": "%{smart_count} %{type} загружен с %{conflictNumber} конфликтом(-ами). |||| %{smart_count} %{type} загружены с %{conflictNumber} конфликтом(-ами).",
"success_updated": "%{smart_count} %{type} загружен и %{updatedCount} обновлён. |||| %{smart_count} %{type} загружены и %{updatedCount} обновлены.",
"success_updated_conflicts": "%{smart_count} %{type} загружен, %{updatedCount} обновлён, c %{conflictCount} конфликтом(-ами). |||| %{smart_count} %{type} загружены, %{updatedCount} обновлены, с %{conflictCount} конфликтом(-ами).",
"updated": "%{smart_count} %{type} обновлён. |||| %{smart_count} %{type} обновлены.",
"updated_conflicts": "%{smart_count} %{type} обновлён с %{conflictCount} конфликтом(-ами). |||| %{smart_count} %{type} обновлены с %{conflictCount} конфликтом(-ами).",
"errors": "При загрузке %{type} произошли ошибки.",
"network": "Вы находитесь офлайн. Попробуйте снова после подключения.",
"fileTooLargeErrors": "Файл слишком большой. Максимальный размер файла: %{max_size_value} ГБ"
}
},
"intents": {
"alert": {
"error": "Не удалось автоматически загрузить файл, пожалуйста, загрузите его вручную через меню загрузки."
},
"picker": {
"select": "Выбрать",
"cancel": "Отмена",
"new_folder": "Новая папка",
"instructions": "Выберите цель"
}
},
"UploadQueue": {
"header": "Загрузка %{smart_count} фото в Twake Drive |||| Загрузка %{smart_count} фото в Twake Drive",
"header_mobile": "Загружено %{done} из %{total}",
"header_done": "Успешно загружено %{done} из %{total}",
"success_flagship": "%{smart_count} файл успешно загружен. |||| %{smart_count} файла(-ов) успешно загружены.",
"close": "закрыть",
"item": {
"pending": "В ожидании"
}
},
"Viewer": {
"close": "Закрыть",
"noviewer": {
"download": "Скачать этот файл",
"openWith": "Открыть с помощью...",
"openInOnlyOffice": "Открыть в Only Office",
"cta": {
"saveTime": "Сэкономьте время!",
"installDesktop": "Установите инструмент синхронизации для вашего компьютера",
"accessFiles": "Получайте доступ к файлам прямо с компьютера"
}
},
"actions": {
"download": "Скачать",
"forward": "Переслать"
},
"loading": {
"error": "Не удалось загрузить файл. Проверьте подключение к интернету.",
"retry": "Повторить"
},
"error": {
"noapp": "На вашем устройстве нет приложения для открытия этого файла.",
"generic": "Произошла ошибка при открытии файла, попробуйте ещё раз.",
"noNetwork": "Вы находитесь офлайн."
},
"panel": {
"title": "Полезная информация"
}
},
"Move": {
"to": "Переместить в:",
"action": "Переместить",
"cancel": "Отмена",
"modalTitle": "Переместить",
"title": "%{smart_count} элемент |||| %{smart_count} элементов",
"success": "%{subject} перемещён в %{target}. |||| %{smart_count} элементов перемещены в %{target}.",
"error": "Произошла ошибка при перемещении элемента, попробуйте позже. |||| Произошла ошибка при перемещении элементов, попробуйте позже.",
"cancelled": "%{subject} возвращён в исходное расположение. |||| %{smart_count} элементов возвращены в исходное расположение.",
"cancelledWithRestoreErrors": "%{subject} возвращён в исходное расположение, но произошла ошибка при восстановлении из корзины. |||| %{smart_count} элементов возвращены в исходное расположение, но произошло %{restoreErrorsCount} ошибок при восстановлении из корзины.",
"cancelled_error": "Извините, произошла ошибка при возврате элемента. |||| Извините, произошла ошибка при возврате элементов.",
"multipleEntries": "%{smart_count} элемент |||| %{smart_count} элементов",
"addFolder": "Добавить папку",
"outsideSharedFolder": {
"title": "Перемещение за пределы папки %{sharedFolder}",
"content_1": "Внимание: вы хотите переместить %{name} из общей папки %{sharedFolder}. |||| Внимание: вы хотите переместить %{smart_count} %{type} из общей папки %{sharedFolder}.",
"content_2": "Это перемещение отменит общий доступ к %{type} %{name}. Этот %{type} будет перемещён в корзину для всех участников общего доступа. |||| Это перемещение отменит общий доступ к %{smart_count} %{type}. Эти %{type} будут перемещены в корзину для всех участников общего доступа.",
"cancel": "Отмена",
"confirm": "Я понимаю"
},
"insideSharedFolder": {
"title": "Переместить в общую папку?",
"content": "Все, кто имеет доступ к %{destination}, также получат доступ к %{source}. |||| Все, кто имеет доступ к %{destination}, также получат доступ к выбранным %{type}.",
"cancel": "Отмена",
"confirm": "ОК"
},
"sharedFolderInsideAnother": {
"title": "Невозможно переместить",
"content_1": "Вы пытаетесь переместить общий элемент в общую папку. Это действие запрещено.",
"content_2": "Если вы всё же хотите переместить %{source} в %{destination}, отмените общий доступ:",
"cancel": "Отменить перемещение",
"confirm": "Отменить общий доступ"
}
},
"ImportToDrive": {
"title": "%{smart_count} элемент |||| %{smart_count} элементов",
"to": "Сохранить в:",
"action": "Сохранить",
"cancel": "Отмена",
"success": "%{smart_count} сохранённый файл |||| %{smart_count} сохранённых файла(-ов)",
"error": "Произошла ошибка. Попробуйте ещё раз"
},
"FileOpenerExternal": {
"fileNotFoundError": "Ошибка: файл не найден"
},
"TOS": {
"updated": {
"title": "GDPR вступает в силу!",
"detail": "В рамках Общего регламента по защите данных наши Условия обслуживания были обновлены и будут применяться ко всем пользователям Twake Workplace с 25 мая 2018 года.",
"cta": "Принять Условия и продолжить",
"disconnect": "Отказаться и выйти",
"error": "Произошла ошибка, попробуйте позже"
}
},
"manifest": {
"permissions": {
"contacts": {
"description": "Необходимо, чтобы делиться файлами с вашими контактами"
},
"groups": {
"description": "Необходимо, чтобы делиться файлами с вашими группами"
}
}
},
"models": {
"contact": {
"defaultDisplayName": "Анонимно"
}
},
"Scan": {
"none": "Ничего",
"scan_a_doc": "Сканировать документ",
"save_doc": "Сохранить документ",
"filename": "Имя файла",
"save": "Сохранить",
"cancel": "Отмена",
"qualify": "Категоризировать",
"requalify": "Перекатегоризировать",
"apply": "Применить",
"error": {
"offline": "Вы находитесь офлайн и не можете использовать эту функцию. Попробуйте позже.",
"uploading": "Вы уже загружаете файл. Дождитесь завершения загрузки и попробуйте снова.",
"generic": "Произошла ошибка. Попробуйте ещё раз."
},
"successful": {
"qualified_ok": "Вы успешно категоризировали файл!"
}
},
"History": {
"description": "Последние 20 версий ваших файлов сохраняются автоматически. Выберите версию для скачивания.",
"current_version": "Текущая версия",
"loading": "Загрузка...",
"noFileVersionEnabled": "Вскоре Twake Drive сможет архивировать последние изменения файлов, чтобы вы больше не рисковали их потерять"
},
"External": {
"redirection": {
"title": "Перенаправление",
"text": "Вы будете перенаправлены…",
"error": "Ошибка при перенаправлении. Обычно это означает, что содержимое файла имеет неверный формат."
}
},
"RenameModal": {
"title": "Переименовать",
"description": "Вы собираетесь изменить расширение файла. Продолжить?",
"continue": "Продолжить",
"cancel": "Отмена"
},
"Shortcut": {
"title_modal": "Создать ярлык",
"filename": "Имя файла",
"url": "URL",
"cancel": "Отмена",
"create": "Создать",
"created": "Ярлык создан",
"errored": "Произошла ошибка",
"filename_error_ends": "Имя должно заканчиваться на .url",
"needs_info": "Для создания ярлыка необходимы URL и имя файла",
"url_badformat": "URL имеет неверный формат"
},
"OnlyOffice": {
"Error": {
"title": "Что-то пошло не так",
"text": "Попробуйте перезагрузить страницу"
},
"readOnly": {
"title": "Только чтение",
"tooltip": "Вы можете только просматривать этот документ. Обратитесь к владельцу для получения прав на редактирование."
},
"createFileName": {
"text": "Новый текстовый документ",
"spreadsheet": "Новая таблица",
"slide": "Новая презентация"
},
"toolbar": {
"goToHome": "На главную"
},
"actions": {
"edit": "Редактировать",
"validate": "Подтвердить"
},
"tooltip": {
"title": "Редактировать документ",
"text": "Документ доступен только для чтения. Вы можете изменить его, нажав здесь.",
"actions": {
"ok": "ОК",
"hide": "Не показывать"
}
}
},
"Migration": {
"title": "Обновление Twake Drive",
"content": "Twake Drive необходимо обновить для улучшения производительности. Это может занять несколько минут, в течение которых приложение будет недоступно. Хотите сделать это сейчас? Если вы откажетесь, мы спросим вас снова в следующий раз.",
"confirm": "Да, сделаем это",
"cancel": "Нет, не сейчас"
},
"searchbar": {
"placeholder": "Поиск",
"empty": "По запросу “%{query}” ничего не найдено"
},
"button": {
"back": "Назад",
"add": "Добавить",
"create": "Создать"
},
"search": {
"action": "Поиск",
"empty": {
"title": "Нет результатов",
"subtitle": "По запросу “%{query}” ничего не найдено"
}
},
"PushBanner": {
"quota": {
"text": "У вас почти закончилось место в хранилище. При достижении лимита вы не сможете добавлять новые файлы. Вы можете удалить файлы, очистить корзину или изменить тарифный план.",
"actions": {
"first": "Я понимаю",
"second": "Посмотреть планы"
}
}
},
"FileDivergedModal": {
"title": "Кто-то изменил этот файл",
"content": "Кто-то изменил файл вне Twake Drive во время вашего редактирования. Вы можете получить эти изменения вместо своих или продолжить редактирование в новом файле.",
"confirm": "Продолжить редактирование",
"cancel": "Посмотреть изменения",
"error": "Произошла ошибка, попробуйте ещё раз.",
"confirmReload": {
"title": "Посмотреть изменения",
"content": "При доступе к новому файлу ваши изменения будут отменены.",
"cancel": "Отмена",
"confirm": "ОК, я понял"
},
"viewMode": {
"title": "Кто-то изменил этот файл",
"content": "Кто-то изменил содержимое этого файла. Вы можете получить доступ к этим изменениям.",
"confirm": "Посмотреть изменения"
}
},
"FileDeletedModal": {
"title": "Кто-то удалил этот файл",
"content": "Кто-то удалил этот файл во время вашего редактирования. Вы можете прекратить редактирование или восстановить файл, чтобы продолжить.",
"confirm": "Восстановить файл",
"cancel": "Отменить изменения",
"error": "Произошла ошибка, попробуйте ещё раз."
},
"TrashedBanner": {
"text": "Элемент находится в корзине",
"destroy": "Удалить навсегда",
"restore": "Восстановить",
"restoreSuccess": "Элемент восстановлен",
"restoreError": "Произошла ошибка, попробуйте ещё раз.",
"destroySuccess": "Элемент удалён"
},
"MigrationProgressBanner": {
"title": "Миграция с Nextcloud в процессе",
"percent": "%{percent}% завершено",
"importing": "Импорт %{count} файлов из Nextcloud...",
"cancel": "Отменить",
"done": {
"title": "Миграция завершена!",
"body": "Успешно импортировано %{count} файлов из Nextcloud"
}
},
"EntriesType": {
"file": "файл |||| файлы",
"directory": "папка |||| папки",
"element": "элемент |||| элементы"
},
"NotFound": {
"title": "Элемент не найден",
"text": "По этому адресу ничего не найдено. Возможно, есть ошибка в написании."
},
"NextcloudBreadcrumb": {
"root": "Общие диски",
"trash": "Корзина"
},
"NextcloudToolbar": {
"share": "Поделиться"
},
"NextcloudDeleteConfirm": {
"title": "Удалить %{filename}? |||| Удалить %{smart_count} %{type}?",
"trash": "Этот элемент будет перемещён в корзину Nextcloud. |||| Эти элементы будут перемещены в корзину Nextcloud.",
"restore": "Вы всегда можете восстановить его из корзины Nextcloud.",
"error": "Произошла ошибка, попробуйте ещё раз.",
"cancel": "Отмена",
"delete": "Удалить"
},
"FileName": {
"sharedDrive": "Диски",
"trash": "Корзина"
},
"NextcloudBanner": {
"title": "Элементы ниже отображаются из диска NextCloud и не хранятся в вашем Twake."
},
"favorites": {
"label": {
"add": "Добавить в избранное",
"addMobile": "Избранное",
"remove": "Удалить из избранного"
},
"error": "Произошла ошибка, попробуйте ещё раз.",
"success": {
"add": "%{filename} добавлен в избранное |||| Эти элементы добавлены в избранное",
"remove": "%{filename} удалён из избранного |||| Эти элементы удалены из избранного"
}
},
"TrashToolbar": {
"emptyTrash": "Очистить корзину"
},
"RestoreNextcloudFile": {
"label": "Восстановить",
"success": "Элемент восстановлен",
"error": "Произошла ошибка, попробуйте ещё раз."
},
"actions": {
"details": "Подробности",
"infos": "Детали и категоризация",
"infosMobile": "Детали",
"duplicateTo": {
"label": "Дублировать в…"
},
"duplicateToMobile": {
"label": "Дублировать"
},
"personalizeFolder": {
"label": "Персонализировать папку"
},
"summariseByAI": "Резюмировать"
},
"FolderCustomizer": {
"title": "Персонализировать папку",
"description": "Выберите определенный цвет для вашей папки",
"cancel": "Отмена",
"apply": "Применить",
"error": "Произошла ошибка, попробуйте ещё раз.",
"tabs": {
"colors": "Цвета",
"icons": "Иконки"
},
"iconPicker": {
"recents": "Недавние",
"chooseCustomIcon": "Выберите пользовательскую иконку"
}
},
"DuplicateModal": {
"subTitle": "Дублировать в:",
"confirmLabel": "Дублировать здесь",
"success": "%{fileName} дублирован в %{destinationName}. |||| %{smart_count} элементов дублированы в %{destinationName}.",
"error": "Произошла ошибка, попробуйте ещё раз."
},
"OpenFolderButton": {
"label": "Открыть папку"
},
"LastUpdate": {
"titleFormat": "LLLL dd, yyyy, HH:MM"
},
"AddMenu": {
"readOnlyFolder": "Это папка только для чтения. Вы не можете выполнить это действие."
},
"PublicNoteRedirect": {
"error": {
"title": "Не удалось получить доступ к документу",
"subtitle": "Ссылка для общего доступа отсутствует или недействительна. Попросите владельца документа проверить доступ."
}
}
}
================================================
FILE: src/locales/vi.json
================================================
{
"Nav": {
"item_drive": "Ổ đĩa của tôi",
"item_recent": "Gần đây",
"item_sharings": "Chia sẻ",
"item_shared": "Chia sẻ bởi tôi",
"item_activity": "Hoạt động",
"item_trash": "Thùng rác",
"item_migration": "Di chuyển",
"item_settings": "Cài đặt",
"item_collect": "Quản trị",
"item_shared_drives": "Ổ đĩa dùng chung",
"item_favorites": "Yêu thích",
"item_my_drive": "Ổ đĩa của tôi",
"btn-client": "Tải TwakeDrive cho máy tính",
"support-us": "Xem những ưu đãi",
"support-us-description": "Bạn muốn có thêm dung lượng hay đơn giản chỉ muốn ủng hộ Cozy??",
"btn-client-web": "Tải Twake",
"btn-client-mobile": "Mang đám mây cá nhân theo bạn mọi nơi: cài đặt %{name} trên tất cả thiết bị của bạn!",
"banner-txt-client": "Tải %{name} cho máy tính và đồng bộ hóa tệp của bạn một cách an toàn để luôn có thể truy cập mọi lúc",
"banner-btn-client": "Tải xuống",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile",
"link-client-ios": "https://apps.apple.com/app/cloud-personnel-cozy/id1600636174",
"link-client-web": "https://cozy.io/try-it",
"view_more": "Hiển thị thêm",
"view_less": "Hiển thị ít hơn",
"item_nextcloud": "Nextcloud"
},
"breadcrumb": {
"title_drive": "Tệp",
"title_recent": "Gần đây",
"title_sharings": "Chia sẻ",
"title_shared": "Chia sẻ bởi tôi",
"title_activity": "Hoạt động",
"title_trash": "Thùng rác",
"label": "Hiển thị đường dẫ",
"title_shared_drives": "Ổ đĩa",
"title_favorites": "Yêu thích"
},
"Toolbar": {
"more": "Thêm"
},
"toolbar": {
"menu_upload": "Tải lên tệp tin",
"item_more": "Thêm",
"menu_new_folder": "Thư mục",
"menu_select": "Chọn mục",
"menu_share_folder": "Chia sẻ thư mục",
"menu_download": "Tải xuống",
"menu_sync_cozy": "Đồng bộ với Twake của tôi",
"add_to_mine": "Thêm vào Twake của tôi",
"menu_download_folder": "Tải thư mục xuống",
"menu_download_file": "Tải tệp này xuống",
"menu_create_note": "Ghi chú",
"menu_create_shortcut": "Lối tắt",
"share": "Chia sẻ",
"trash": "Xoá",
"leave": "Rời khỏi thư mục được chia sẻ & xoá",
"menu_add": "Thêm",
"menu_create": "Tạo",
"menu_add_item": "Thêm mục",
"menu_onlyOffice": {
"text": "Tài liệu văn bản",
"spreadsheet": "Bảng tính",
"slide": "Bài thuyết trình"
},
"select_all": "Chọn tất cả",
"select_all_mobile": "Tất cả",
"clear_selection": "Xóa lựa chọn",
"clear_selection_mobile": "Hủy",
"sharings_tab_all": "Tất cả",
"sharings_tab_drives": "Ổ đĩa"
},
"Share": {
"create-cozy": "Tạo Twake của tôi"
},
"Files": {
"share": {
"cta": "Chia sẻ",
"title": "Chia sẻ",
"details": {
"title": "Chi tiết chia sẻ",
"createdAt": "On %{date}",
"ro": "Có thể đọc",
"rw": "Có quyền chỉnh sửa",
"desc": {
"ro": "Bạn có thể xem, tải xuống và thêm nội dung này vào Twake của mình. Bạn sẽ nhận được các bản cập nhật từ chủ sở hữu, nhưng bạn sẽ không thể tự cập nhật nội dung này.",
"rw": "Bạn có thể xem, cập nhật, xóa và thêm nội dung này vào Twake của mình. Những cập nhật bạn thực hiện sẽ hiển thị với các Cozy khác."
}
},
"shared": "Đã chia sẻ",
"sharedByMe": "Chia sẻ bởi tôi",
"sharedWithMe": "Chia sẻ với tôi",
"sharedBy": "Chia sẻ bởi %{name}",
"shareByLink": {
"subtitle": "Bằng liên kết công khai",
"desc": "Bất kỳ ai có liên kết đều có thể xem và tải xuống tệp của bạn.",
"creating": "Đang tạo liên kết...",
"copy": "Sao chép liên kết",
"copied": "Liên kết đã được sao chép vào bộ nhớ tạm",
"failed": "Không thể sao chép vào bộ nhớ tạm"
},
"shareByEmail": {
"subtitle": "Bằng email",
"email": "Tới:",
"emailPlaceholder": "Nhập địa chỉ email hoặc tên người nhận",
"send": "Gửi",
"genericSuccess": "Bạn đã gửi lời mời đến %{count} liên hệ.",
"success": "Bạn đã gửi lời mời đến %{email}.",
"comingsoon": "Sắp ra mắt! Bạn sẽ có thể chia sẻ tài liệu và hình ảnh chỉ với một cú nhấp chuột với gia đình, bạn bè và cả đồng nghiệp. Đừng lo, chúng tôi sẽ thông báo khi tính năng này sẵn sàng!",
"onlyByLink": "Chỉ có thể chia sẻ %{type} này bằng liên kết, vì",
"type": {
"file": "tệp",
"folder": "thư mục"
},
"hasSharedParent": "nó thuộc một thư mục đã được chia sẻ",
"hasSharedChild": "nó chứa phần tử đã được chia sẻ"
},
"revoke": {
"title": "Gỡ chia sẻ",
"desc": "Liên hệ này sẽ giữ một bản sao, nhưng các thay đổi sẽ không còn được đồng bộ.",
"success": "Bạn đã gỡ tệp chia sẻ khỏi %{email}."
},
"revokeSelf": {
"title": "Gỡ tôi khỏi chia sẻ",
"desc": "Bạn sẽ giữ lại nội dung nhưng sẽ không còn được cập nhật giữa các Twake của bạn.",
"success": "Bạn đã được gỡ khỏi chia sẻ này."
},
"sharingLink": {
"title": "Liên kết chia sẻ",
"copy": "Sao chép",
"copied": "Đã sao chép"
},
"whoHasAccess": {
"title": "1 người có quyền truy cập |||| %{smart_count} người có quyền truy cập"
},
"protectedShare": {
"title": "Sắp ra mắt!",
"desc": "Chia sẻ mọi thứ qua email với gia đình và bạn bè!"
},
"close": "Đóng",
"gettingLink": "Đang khởi tạo liên kết...",
"error": {
"generic": "Đã xảy ra lỗi khi tạo liên kết chia sẻ tệp, vui lòng thử lại.",
"revoke": "Rất tiếc, đã xảy ra lỗi. Vui lòng liên hệ với chúng tôi để khắc phục sự cố này sớm nhất có thể."
},
"specialCase": {
"base": "%{type} này chỉ có thể chia sẻ qua liên kết vì",
"isInSharedFolder": "nằm trong một thư mục đã được chia sẻ",
"hasSharedFolder": "chứa một thư mục đã được chia sẻ"
}
},
"viewer-fallback": "Nếu tệp đã bắt đầu tải xuống, bạn có thể đóng cửa sổ này.",
"dropzone": {
"teaser": "Thả tệp vào đây để tải lên:",
"noFolderSupport": "Trình duyệt của bạn hiện không hỗ trợ kéo & thả thư mục. Vui lòng tải tệp lên theo cách thủ công."
}
},
"table": {
"head_name": "Tên",
"head_update": "Cập nhật lần cuối",
"head_size": "Kích thước",
"head_status": "Chia sẻ",
"head_thumbnail_size": "Chuyển kích thước hình thu nhỏ",
"head_view_mode": "Chế độ xem",
"head_view_list": "Chế độ danh sách",
"head_view_grid": "Chế độ lưới",
"row_update_format": "LLL d, yyyy",
"row_update_format_full": "LLLL d, yyyy",
"row_read_only": "Chia sẻ (Chỉ đọc)",
"row_read_write": "Chia sẻ (Đọc & Ghi)",
"row_size_symbols": {
"B": "B",
"KB": "KB",
"MB": "MB",
"GB": "GB",
"TB": "TB",
"PB": "PB",
"EB": "EB",
"ZB": "ZB",
"YB": "YB"
},
"row_sharing_shortcut_aria_label": "Phím tắt chia sẻ mới",
"load_more": "Tải thêm",
"mobile": {
"head_name_asc": "A-Z",
"head_name_desc": "Z-A",
"head_updated_at_asc": "Cũ nhất trước",
"head_updated_at_desc": "Mới nhất trước",
"head_size_asc": "Nhẹ nhất trước",
"head_size_desc": "Nặng nhất trước"
},
"tooltip": {
"carbonCopy": {
"title": "Bản sao gốc",
"caption": "Chỉ ra rằng tài liệu này được xác định là \\“xác thực và nguyên bản\\” bởi Twake Workplace, đơn vị lưu trữ Twake của bạn, vì nó có thể chứng minh rằng tài liệu đến trực tiếp từ dịch vụ bên thứ ba mà không bị chỉnh sửa."
},
"electronicSafe": {
"title": "Két điện tử",
"caption": "Chỉ ra rằng tài liệu gốc được bảo vệ trong két điện tử cá nhân của bạn, kèm theo các chứng nhận mang lại giá trị pháp lý và đảm bảo lưu trữ trong 50 năm kể từ thời điểm nộp."
}
}
},
"Storage": {
"title": "Dung lượng",
"availability": "Còn %{smart_count} GB trống",
"increase": "Tăng dung lượng"
},
"SelectionBar": {
"selected_count": "1 mục đã chọn |||| %{smart_count} mục đã chọn",
"share": "Chia sẻ",
"download": "tải xuống",
"trash": "Xoá bỏ",
"destroy": "Xoá vĩnh viễn",
"rename": "Đổi tên",
"restore": "Khôi phục",
"close": "Đóng",
"openWith": "Mở bằng...",
"applePreview": "Xem trước với Aplle",
"forward": "Chuyển tiếp",
"forwardTo": "Chuyển tiếp đến...",
"moveto": "Di chuyển tới…",
"moveto_mobile": "Di chuyển",
"phone-download": "Tải về để dùng ngoại tuyến",
"qualify": "Phân loại",
"history": "Lịch sử",
"more": "Thêm",
"openWithinNextcloud": "Mở bằng Nextcloud"
},
"DeleteConfirm": {
"title": "Xóa %{filename}? |||| Xóa %{smart_count} %{type}?",
"trash": "Tệp sẽ được chuyển vào Thùng rác. |||| Các tệp sẽ được chuyển vào Thùng rác.",
"restore": "Bạn có thể khôi phục bất cứ lúc nào. |||| Bạn có thể khôi phục chúng bất cứ lúc nào.",
"share_accepted": "Chia sẻ sẽ bị dừng. Những liên hệ sau sẽ giữ một bản sao, nhưng thay đổi của bạn sẽ không còn được đồng bộ:",
"share_waiting": "Chia sẻ sẽ bị dừng. Những liên hệ sau sẽ không còn có thể chấp nhận chia sẻ và truy cập nội dung nữa:",
"share_both": "Chia sẻ sẽ bị dừng. Một số liên hệ đã lưu tệp vào Twake sẽ giữ bản sao, còn những người khác sẽ mất quyền truy cập:",
"link": "Liên kết chia sẻ sẽ bị vô hiệu hóa",
"referenced": "Một số tệp trong lựa chọn liên kết với album ảnh. Nếu tiếp tục xóa, chúng sẽ bị loại khỏi album.",
"cancel": "Huỷ",
"delete": "Xoá"
},
"EmptyTrashConfirm": {
"title": "Xóa vĩnh viễn?",
"forbidden": "Bạn sẽ không thể truy cập các tệp này nữa.",
"restore": "Bạn sẽ không thể khôi phục nếu chưa sao lưu.",
"cancel": "Huỷ",
"delete": "Xoá tất cả",
"processing": "Đang dọn Thùng rác. Việc này có thể mất vài phút.",
"success": "Thùng rác đã được làm trống.",
"error": "Đã xảy ra lỗi, vui lòng thử lại."
},
"DestroyConfirm": {
"title": "Xóa %{filename}? |||| Xóa %{smart_count} %{type}?",
"forbidden": "Bạn sẽ không thể truy cập %{type} này nữa. |||| Bạn sẽ không thể truy cập các %{type} này nữa.",
"restore": "Không thể khôi phục nếu bạn chưa sao lưu. |||| Không thể khôi phục các %{type} này nếu chưa sao lưu.",
"cancel": "Huỷ",
"delete": "Xóa vĩnh viễn",
"success": "%{type} đã được xóa vĩnh viễn. |||| %{smart_count} %{type} đã được xóa vĩnh viễn.",
"error": "Đã xảy ra lỗi, vui lòng thử lại."
},
"quotaalert": {
"title": "Dung lượng của bạn đã đầy :(",
"desc": "Vui lòng xóa tệp, dọn Thùng rác hoặc nâng cấp dung lượng trước khi tải thêm tệp.",
"confirm": "OK",
"increase": "Tăng dung lượng"
},
"loading": {
"message": "Đang tải",
"onlyOfficeCreateInProgress": "Đang tạo tệp..."
},
"empty": {
"title": "Bạn chưa có tệp nào trong thư mục này.",
"text": "Chọn tệp trên máy tính của bạn hoặc kéo chúng vào đây.",
"mobile_text": "Chọn tệp trên thiết bị của bạn.",
"trash_title": "Bạn chưa có tệp nào trong Thùng rác.",
"trash_text": "Chuyển các tệp không cần thiết vào Thùng rác và xóa vĩnh viễn để giải phóng dung lượng.",
"shared-drive_text": "Tạo và chia sẻ ổ đĩa đầu tiên của bạn."
},
"error": {
"open_folder": "Đã xảy ra lỗi khi mở thư mục.",
"open_file": "Đã xảy ra lỗi khi mở tệp.",
"button": {
"reload": "Tải lại ngay"
},
"download_file": {
"offline": "Bạn cần kết nối mạng để tải tệp này",
"missing": "Tệp này không tồn tại"
}
},
"Error": {
"public_unshared_title": "Rất tiếc, liên kết này không còn tồn tại.",
"public_unshared_text": "Liên kết này đã hết hạn hoặc bị người tạo xóa. Hãy liên hệ với họ nếu bạn bỏ lỡ!",
"generic": "Đã xảy ra lỗi. Vui lòng thử lại sau vài phút."
},
"alert": {
"could_not_open_file": "Không thể mở tệp",
"try_again": "Đã xảy ra lỗi, vui lòng thử lại sau.",
"restore_file_success": "Các mục đã được khôi phục thành công.",
"trash_file_success": "Các mục đã được chuyển vào Thùng rác.",
"destroy_file_success": "Các mục đã được xóa vĩnh viễn.",
"folder_name": "Thư mục %{folderName} đã tồn tại, vui lòng chọn tên khác.",
"file_name": "Tệp %{fileName} đã tồn tại, vui lòng chọn tên khác.",
"file_name_missing": "Thiếu tên tệp, vui lòng đặt tên mới.",
"file_name_illegal_name": "Tên %{fileName} không hợp lệ, vui lòng chọn tên khác.",
"file_name_illegal_characters": "Tên %{fileName} chứa ký tự không hợp lệ: %{characters}",
"folder_generic": "Đã xảy ra lỗi, vui lòng thử lại",
"folder_abort": "Bạn cần đặt tên cho thư mục mới để lưu. Dữ liệu hiện tại chưa được lưu.",
"offline": "Tính năng này không khả dụng khi ngoại tuyến.",
"preparing": "Đang chuẩn bị tệp của bạn…",
"item_copied": "1 mục đã được sao chép",
"items_copied": "%{count} mục đã được sao chép",
"item_cut": "1 mục đã được cắt",
"items_cut": "%{count} mục đã được cắt",
"item_moved": "1 mục đã được di chuyển",
"items_moved": "%{count} mục đã được di chuyển",
"item_pasted": "1 mục đã được di chuyển",
"items_pasted": "%{count} mục đã được di chuyển",
"copy_files_only": "Không thể sao chép thư mục",
"copy_not_allowed": "Thao tác sao chép không được phép trong chế độ xem này.",
"cut_not_allowed": "Thao tác cắt không được phép trong chế độ xem này.",
"paste_error": "Đã xảy ra lỗi khi dán tệp",
"paste_failed": "Dán tệp thất bại",
"paste_sharing_error": "Không thể dán tệp do hạn chế chia sẻ. Vui lòng sử dụng hành động Di chuyển thay thế.",
"paste_same_folder_skipped": "Không thể di chuyển các mục vào cùng một thư mục mà chúng đã có.",
"paste_not_allowed": "Bạn không thể dán vào thư mục này",
"cannot_move_shared_drive": "Bạn không thể di chuyển thư mục ổ đĩa chia sẻ",
"cannot_copy_shared_drive": "Bạn không thể sao chép thư mục ổ đĩa chia sẻ"
},
"upload": {
"label": "Tải lên",
"documentType": {
"file": "tệp",
"directory": "thư mục",
"element": "phần tử"
},
"alert": {
"success": "Đã tải lên %{smart_count} %{type} thành công. |||| Đã tải lên %{smart_count} %{type} thành công.",
"success_conflicts": "Đã tải lên %{smart_count} %{type} với %{conflictNumber} xung đột. |||| Đã tải lên %{smart_count} %{type} với %{conflictNumber} xung đột.",
"success_updated": "Đã tải lên %{smart_count} %{type}, trong đó có %{updatedCount} được cập nhật. |||| Đã tải lên %{smart_count} %{type}, trong đó có %{updatedCount} được cập nhật.",
"success_updated_conflicts": "Đã tải lên %{smart_count} %{type}, %{updatedCount} được cập nhật và có %{conflictCount} xung đột. |||| Đã tải lên %{smart_count} %{type}, %{updatedCount} được cập nhật và có %{conflictCount} xung đột.",
"updated": "Đã cập nhật %{smart_count} %{type}. |||| Đã cập nhật %{smart_count} %{type}.",
"updated_conflicts": "Đã cập nhật %{smart_count} %{type} với %{conflictCount} xung đột. |||| Đã cập nhật %{smart_count} %{type} với %{conflictCount} xung đột.",
"errors": "Đã xảy ra lỗi trong quá trình tải lên %{type}.",
"network": "Bạn hiện đang ngoại tuyến. Vui lòng thử lại khi có kết nối mạng.",
"fileTooLargeErrors": "Tệp quá lớn. Kích thước tệp tối đa: %{max_size_value} GB"
}
},
"intents": {
"alert": {
"error": "Không thể tải tệp lên tự động, vui lòng tải thủ công qua menu tải lên."
},
"picker": {
"select": "Chọn",
"cancel": "Huỷ",
"new_folder": "Thư mục mới",
"instructions": "Chọn thư mục đích"
}
},
"UploadQueue": {
"header": "Đang tải lên %{smart_count} ảnh lên Twake Drive |||| Đang tải lên %{smart_count} ảnh lên Twake Drive",
"header_mobile": "Đã tải lên %{done} / %{total}",
"header_done": "Đã tải lên thành công %{done} trong tổng số %{total}",
"success_flagship": "Đã tải lên %{smart_count} tệp thành công. |||| Đã tải lên %{smart_count} tệp thành công.",
"close": "đóng",
"item": {
"pending": "Đang chờ"
}
},
"Viewer": {
"close": "Đóng",
"noviewer": {
"download": "Tải tệp này xuống",
"openWith": "Mở bằng...",
"openInOnlyOffice": "Mở bằng OnlyOffice",
"cta": {
"saveTime": "Tiết kiệm thời gian!",
"installDesktop": "Cài đặt công cụ đồng bộ hóa cho máy tính của bạn",
"accessFiles": "Truy cập tệp trực tiếp từ máy tính"
}
},
"actions": {
"download": "Tải xuống",
"forward": "Chuyển tiếp"
},
"loading": {
"error": "Không thể tải tệp này. Vui lòng kiểm tra kết nối Internet của bạn.",
"retry": "Thử lại"
},
"error": {
"noapp": "Thiết bị của bạn không có ứng dụng nào có thể mở tệp này.",
"generic": "Đã xảy ra lỗi khi mở tệp, vui lòng thử lại.",
"noNetwork": "Bạn đang ở chế độ ngoại tuyến."
},
"panel": {
"title": "Thông tin hữu ích"
}
},
"Move": {
"to": "Di chuyển đến:",
"action": "Di chuyển",
"cancel": "Huỷ",
"modalTitle": "Di chuyển",
"title": "%{smart_count} phần tử |||| %{smart_count} phần tử",
"success": "%{subject} đã được di chuyển đến %{target}. |||| %{smart_count} phần tử đã được di chuyển đến %{target}.",
"error": "Đã xảy ra lỗi khi di chuyển phần tử này, vui lòng thử lại sau. |||| Đã xảy ra lỗi khi di chuyển các phần tử này, vui lòng thử lại sau.",
"cancelled": "%{subject} đã được đưa trở lại vị trí ban đầu. |||| %{smart_count} phần tử đã được đưa trở lại vị trí ban đầu.",
"cancelledWithRestoreErrors": "%{subject} đã được đưa trở lại vị trí ban đầu nhưng đã xảy ra lỗi khi khôi phục tệp từ thùng rác. |||| %{smart_count} phần tử đã được đưa trở lại vị trí ban đầu nhưng có %{restoreErrorsCount} lỗi khi khôi phục các tệp từ thùng rác.",
"cancelled_error": "Rất tiếc, đã xảy ra lỗi khi đưa phần tử trở lại. |||| Rất tiếc, đã xảy ra lỗi khi đưa các phần tử trở lại.",
"multipleEntries": "%{smart_count} phần tử |||| %{smart_count} phần tử",
"addFolder": "Thêm thư mục",
"outsideSharedFolder": {
"title": "Di chuyển ra khỏi thư mục chia sẻ %{sharedFolder}",
"content_1": "Cảnh báo, bạn đang muốn di chuyển %{name} ra khỏi thư mục chia sẻ %{sharedFolder}. |||| Cảnh báo, bạn đang muốn di chuyển %{smart_count} %{type} ra khỏi thư mục chia sẻ %{sharedFolder}.",
"content_2": "Thao tác này sẽ xóa %{type} %{name} khỏi mục chia sẻ. %{type} này sẽ bị đưa vào thùng rác đối với tất cả thành viên được chia sẻ. |||| Thao tác này sẽ xóa %{smart_count} %{type} khỏi mục chia sẻ. Các %{type} này sẽ bị đưa vào thùng rác đối với tất cả thành viên được chia sẻ.",
"cancel": "Hủy",
"confirm": "Tôi hiểu"
},
"insideSharedFolder": {
"title": "Di chuyển vào thư mục chia sẻ?",
"content": "Tất cả thành viên có quyền truy cập %{destination} cũng sẽ có quyền truy cập %{source}. |||| Tất cả thành viên có quyền truy cập %{destination} cũng sẽ có quyền truy cập các %{type} đã chọn.",
"cancel": "Hủy",
"confirm": "Đồng ý"
},
"sharedFolderInsideAnother": {
"title": "Không thể di chuyển",
"content_1": "Bạn đang muốn di chuyển một phần tử được chia sẻ vào một thư mục chia sẻ khác. Loại thao tác này không được phép.",
"content_2": "Nếu bạn vẫn muốn di chuyển %{source} đến %{destination}, vui lòng ngừng chia sẻ:",
"cancel": "Hủy di chuyển",
"confirm": "Ngừng chia sẻ"
}
},
"ImportToDrive": {
"title": "%{smart_count} phần tử |||| %{smart_count} phần tử",
"to": "Lưu tại:",
"action": "Lưu",
"cancel": "Hủy",
"success": "%{smart_count} tệp đã được lưu |||| %{smart_count} tệp đã được lưu",
"error": "Đã xảy ra lỗi. Vui lòng thử lại"
},
"FileOpenerExternal": {
"fileNotFoundError": "Lỗi: không tìm thấy tệp"
},
"TOS": {
"updated": {
"title": "GDPR chính thức có hiệu lực!",
"detail": "Trong bối cảnh Quy định bảo vệ dữ liệu chung, [Điều khoản dịch vụ của chúng tôi đã được cập nhật](%{link}) và sẽ áp dụng cho tất cả người dùng Twake từ ngày 25 tháng 5 năm 2018.",
"cta": "Chấp nhận Điều khoản và tiếp tục",
"disconnect": "Từ chối và đăng xuất",
"error": "Đã xảy ra lỗi, vui lòng thử lại sau"
}
},
"manifest": {
"permissions": {
"contacts": {
"description": "Cần thiết để chia sẻ tệp với danh bạ của bạn"
},
"groups": {
"description": "Cần thiết để chia sẻ tệp với nhóm của bạn"
}
}
},
"models": {
"contact": {
"defaultDisplayName": "Ẩn danh"
}
},
"Scan": {
"none": "Không có gì",
"scan_a_doc": "Quét tài liệu",
"save_doc": "Lưu tài liệu",
"filename": "Tên tệp",
"save": "Lưu",
"cancel": "Hủy",
"qualify": "Phân loại",
"requalify": "Phân loại lại",
"apply": "Áp dụng",
"error": {
"offline": "Bạn hiện đang ngoại tuyến và không thể sử dụng chức năng này. Vui lòng thử lại sau.",
"uploading": "Bạn đang tải lên một tệp. Vui lòng đợi quá trình này hoàn tất rồi thử lại.",
"generic": "Đã xảy ra lỗi. Vui lòng thử lại."
},
"successful": {
"qualified_ok": "Bạn đã phân loại thành công tệp của mình! "
}
},
"History": {
"description": "20 phiên bản gần nhất của tệp sẽ được lưu tự động. Chọn một phiên bản để tải về.",
"current_version": "Phiên bản hiện tại",
"loading": "Đang tải...",
"noFileVersionEnabled": "Twake của bạn sắp có thể lưu lại những thay đổi cuối cùng để bạn không bao giờ lo mất dữ liệu nữa"
},
"External": {
"redirection": {
"title": "Chuyển hướng",
"text": "Bạn sắp được chuyển hướng…",
"error": "Lỗi trong quá trình chuyển hướng. Thông thường điều này có nghĩa là nội dung của tệp không đúng định dạng."
}
},
"RenameModal": {
"title": "Đổi tên",
"description": "Bạn sắp thay đổi phần mở rộng của tệp. Bạn có muốn tiếp tục không?",
"continue": "Tiếp tục",
"cancel": "Hủy"
},
"Shortcut": {
"title_modal": "Tạo phím tắt",
"filename": "Tên tệp",
"url": "URL",
"cancel": "Hủy",
"create": "Tạo",
"created": "Phím tắt của bạn đã được tạo",
"errored": "Đã xảy ra lỗi",
"filename_error_ends": "Tên tệp phải kết thúc bằng .url",
"needs_info": "Phím tắt cần ít nhất một URL và tên tệp",
"url_badformat": "URL của bạn không đúng định dạng"
},
"OnlyOffice": {
"Error": {
"title": "Đã xảy ra sự cố",
"text": "Vui lòng tải lại trang"
},
"readOnly": {
"title": "Chỉ đọc",
"tooltip": "Bạn chỉ có quyền xem tài liệu này. Hãy liên hệ với chủ sở hữu để được cấp quyền chỉnh sửa."
},
"createFileName": {
"text": "Tài liệu văn bản mới",
"spreadsheet": "Bảng tính mới",
"slide": "Bài thuyết trình mới"
},
"toolbar": {
"goToHome": "Về trang chính"
},
"actions": {
"edit": "Chỉnh sửa",
"validate": "Xác nhận"
},
"tooltip": {
"title": "Chỉnh sửa tài liệu",
"text": "Tài liệu hiện đang ở chế độ chỉ đọc. Bạn có thể chỉnh sửa bằng cách nhấp vào đây.",
"actions": {
"ok": "Đồng ý",
"hide": "Không hiển thị nữa"
}
}
},
"Migration": {
"title": "Cập nhật Twake Drive",
"content": "Twake Drive cần được cập nhật để cải thiện hiệu năng. Quá trình này có thể mất vài phút và trong thời gian đó bạn sẽ không thể sử dụng ứng dụng. Bạn có muốn cập nhật ngay bây giờ không? Nếu từ chối, chúng tôi sẽ nhắc lại vào lần sau.",
"confirm": "Ok, tiến hành cập nhật!",
"cancel": "Không, không phải bây giờ"
},
"searchbar": {
"placeholder": "Tìm kiếm bất kỳ thứ gì",
"empty": "Không tìm thấy kết quả cho truy vấn “%{query}”"
},
"button": {
"back": "Quay lại",
"add": "Thêm",
"create": "Tạo mới"
},
"search": {
"action": "Tìm kiếm",
"empty": {
"title": "Không có kết quả",
"subtitle": "Không tìm thấy kết quả nào cho truy vấn “%{query}”"
}
},
"PushBanner": {
"quota": {
"text": "Bạn sắp hết dung lượng lưu trữ. Nếu vượt quá giới hạn, bạn sẽ không thể thêm tệp mới. Hãy xóa tệp, dọn thùng rác hoặc nâng cấp gói của bạn.",
"actions": {
"first": "Tôi hiểu",
"second": "Xem các gói dịch vụ"
}
}
},
"FileDivergedModal": {
"title": "Tệp đã bị chỉnh sửa bởi người khác",
"content": "Ai đó đã chỉnh sửa tệp bên ngoài Twake trong khi bạn đang làm việc trên đó. Bạn có thể giữ phiên bản của họ hoặc tiếp tục chỉnh sửa bản sao mới.",
"confirm": "Tiếp tục chỉnh sửa",
"cancel": "Xem thay đổi",
"error": "Đã xảy ra lỗi, vui lòng thử lại.",
"confirmReload": {
"title": "Xem thay đổi",
"content": "Khi mở tệp mới, các thay đổi của bạn sẽ bị hủy.",
"cancel": "Hủy",
"confirm": "Ok, tôi hiểu"
},
"viewMode": {
"title": "Tệp đã bị chỉnh sửa bởi người khác",
"content": "Tệp đã bị thay đổi nội dung. Bạn có thể xem những thay đổi này.",
"confirm": "Xem thay đổi"
}
},
"FileDeletedModal": {
"title": "Tệp đã bị xóa bởi người khác",
"content": "Tệp đã bị xóa trong khi bạn đang chỉnh sửa. Bạn có thể dừng chỉnh sửa hoặc khôi phục tệp để tiếp tục.",
"confirm": "Khôi phục tệp",
"cancel": "Hủy thay đổi",
"error": "Đã xảy ra lỗi, vui lòng thử lại."
},
"TrashedBanner": {
"text": "Mục này đang nằm trong thùng rác",
"destroy": "Xóa vĩnh viễn",
"restore": "Khôi phục",
"restoreSuccess": "Mục đã được khôi phục",
"restoreError": "Đã xảy ra lỗi, vui lòng thử lại.",
"destroySuccess": "Mục đã được xóa vĩnh viễn"
},
"MigrationProgressBanner": {
"title": "Đang di chuyển dữ liệu từ Nextcloud",
"percent": "Hoàn tất %{percent}%",
"importing": "Đang nhập %{count} tệp từ Nextcloud...",
"cancel": "Hủy",
"done": {
"title": "Di chuyển hoàn tất !",
"body": "Đã nhập thành công %{count} tệp từ Nextcloud"
}
},
"EntriesType": {
"file": "tệp |||| các tệp",
"directory": "thư mục |||| các thư mục",
"element": "mục |||| các mục"
},
"NotFound": {
"title": "Không tìm thấy mục",
"text": "Không có nội dung nào tại địa chỉ này. Có thể bạn đã nhập sai liên kết."
},
"NextcloudBreadcrumb": {
"root": "Ổ đĩa được chia sẻ",
"trash": "Thùng rác"
},
"NextcloudToolbar": {
"share": "Chia sẻ"
},
"NextcloudDeleteConfirm": {
"title": "Xóa %{filename}? |||| Xóa %{smart_count} %{type}?",
"trash": "Mục này sẽ được chuyển vào thùng rác của Nextcloud. |||| Các mục này sẽ được chuyển vào thùng rác của Nextcloud.",
"restore": "Bạn có thể khôi phục bất kỳ lúc nào từ Nextcloud.",
"error": "Đã xảy ra lỗi, vui lòng thử lại.",
"cancel": "Hủy",
"delete": "Xóa"
},
"FileName": {
"sharedDrive": "Ổ đĩa",
"trash": "Thùng rác"
},
"NextcloudBanner": {
"title": "Các mục bên dưới được hiển thị từ ổ đĩa NextCloud và không được lưu trữ trong Twake."
},
"favorites": {
"label": {
"add": "Thêm vào mục yêu thích",
"addMobile": "Yêu thích",
"remove": "Xóa khỏi mục yêu thích"
},
"error": "Đã xảy ra lỗi, vui lòng thử lại.",
"success": {
"add": "%{filename} đã được thêm vào mục yêu thích |||| Các mục đã được thêm vào mục yêu thích",
"remove": "%{filename} đã được xóa khỏi mục yêu thích |||| Các mục đã được xóa khỏi mục yêu thích"
}
},
"TrashToolbar": {
"emptyTrash": "Dọn thùng rác"
},
"RestoreNextcloudFile": {
"label": "Khôi phục",
"success": "Mục đã được khôi phục",
"error": "Đã xảy ra lỗi, vui lòng thử lại."
},
"actions": {
"details": "Chi tiết",
"infos": "Chi tiết và phân loại",
"infosMobile": "Chi tiết",
"duplicateTo": {
"label": "Nhân bản tới…"
},
"duplicateToMobile": {
"label": "Nhân bản"
},
"personalizeFolder": {
"label": "Cá nhân hóa thư mục"
},
"summariseByAI": "Tóm tắt"
},
"FolderCustomizer": {
"title": "Cá nhân hóa thư mục",
"description": "Chọn màu cụ thể cho thư mục của bạn",
"cancel": "Hủy",
"apply": "Áp dụng",
"error": "Đã xảy ra lỗi, vui lòng thử lại.",
"tabs": {
"colors": "Màu sắc",
"icons": "Biểu tượng"
},
"iconPicker": {
"recents": "Gần đây",
"chooseCustomIcon": "Chọn biểu tượng tùy chỉnh"
}
},
"DuplicateModal": {
"subTitle": "Nhân bản đến:",
"confirmLabel": "Nhân bản tại đây",
"success": "%{fileName} đã được nhân bản đến %{destinationName}. |||| %{smart_count} mục đã được nhân bản đến %{destinationName}.",
"error": "Đã xảy ra lỗi, vui lòng thử lại."
},
"OpenFolderButton": {
"label": "Mở thư mục"
},
"LastUpdate": {
"titleFormat": "LLLL dd, yyyy, HH:MM"
},
"AddMenu": {
"readOnlyFolder": "Đây là thư mục chỉ đọc. Bạn không thể thực hiện hành động này."
},
"PublicNoteRedirect": {
"error": {
"title": "Không thể truy cập tài liệu",
"subtitle": "Liên kết chia sẻ bị thiếu hoặc không hợp lệ. Vui lòng yêu cầu chủ sở hữu tài liệu kiểm tra quyền truy cập"
}
}
}
================================================
FILE: src/locales/zh_CN.json
================================================
{
"Nav": {
"item_drive": "硬盘",
"item_recent": "最近更改",
"item_sharings": "分享",
"item_shared": "由我分享",
"item_activity": "活动",
"item_trash": "垃圾桶",
"item_collect": "管理",
"btn-client": "下载桌面版 Twake Drive",
"btn-client-web": "获取 Twake",
"banner-btn-client": "下载",
"link-client": "https://cozy.io/en/download/",
"link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/",
"link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile",
"link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8",
"link-client-web": "https://cozy.io/try-it"
},
"breadcrumb": {
"title_drive": "硬盘",
"title_recent": "最近更改",
"title_sharings": "分享",
"title_shared": "由我分享",
"title_activity": "活动",
"title_trash": "垃圾桶"
},
"Toolbar": {
"more": "更多"
},
"toolbar": {
"menu_upload": "上传文件",
"item_more": "更多",
"menu_new_folder": "文件夹",
"menu_select": "选择项目",
"menu_share_folder": "分析文件夹",
"menu_download": "下载",
"menu_sync_cozy": "同步到我的 Twake",
"add_to_mine": "添加到我的 Twake",
"menu_download_folder": "下载文件夹",
"menu_download_file": "下载这个文件",
"menu_create_note": "便签",
"empty_trash": "清空垃圾桶",
"share": "分享",
"trash": "移除",
"delete_shared_drive": "删除共享驱动器",
"leave": "离开文件夹并删除",
"menu_add": "添加",
"menu_create": "创造",
"menu_onlyOffice": {
"text": "文本文件",
"spreadsheet": "电子表格",
"slide": "幻灯片"
},
"select_all": "全选",
"clear_selection": "清晰的选择",
"sharings_tab_all": "全部",
"sharings_tab_drives": "驱动器"
},
"Share": {
"create-cozy": "创建我的 Twake"
},
"searchbar": {
"placeholder": "搜索任何内容",
"empty": "没有任何关于“%{query}”的内容"
},
"search": {
"empty": {
"subtitle": "没有任何关于“%{query}”的内容"
}
},
"alert": {
"item_copied": "1个项目已复制",
"items_copied": "%{count}个项目已复制",
"item_cut": "1个项目已剪切",
"items_cut": "%{count}个项目已剪切",
"item_moved": "1个项目已移动",
"items_moved": "%{count}个项目已移动",
"item_pasted": "1个项目已移动",
"items_pasted": "%{count}个项目已移动",
"copy_files_only": "无法复制文件夹",
"copy_not_allowed": "此视图中不允许复制操作。",
"cut_not_allowed": "此视图中不允许剪切操作。",
"paste_error": "粘贴文件时出错",
"paste_failed": "粘贴文件失败",
"paste_sharing_error": "由于共享限制,无法粘贴文件。请改用移动操作。",
"paste_same_folder_skipped": "无法将项目移动到它们已经所在的同一文件夹。",
"paste_not_allowed": "您无法粘贴到此文件夹",
"cannot_move_shared_drive": "您无法移动共享驱动器文件夹",
"cannot_copy_shared_drive": "您无法复制共享云端硬盘文件夹"
},
"actions": {
"details": "详细信息",
"personalizeFolder": {
"label": "个性化文件夹"
},
"summariseByAI": "总结"
},
"FolderCustomizer": {
"title": "个性化文件夹",
"description": "为您的文件夹选择特定颜色",
"cancel": "取消",
"apply": "应用",
"error": "发生错误,请重试。",
"tabs": {
"colors": "颜色",
"icons": "图标"
},
"iconPicker": {
"recents": "最近使用",
"chooseCustomIcon": "选择自定义图标"
}
}
}
================================================
FILE: src/locales/zh_TW.json
================================================
{
"Nav": {
"item_drive": "硬碟",
"item_recent": "最近更改",
"item_sharings": "分享",
"item_shared": "由我分享",
"item_activity": "活動",
"item_trash": "垃圾桶",
"item_settings": "設定",
"item_collect": "管理",
"btn-client": "下載桌面版 Twake Drive",
"btn-client-web": "取得 Twake",
"btn-client-mobile": "在您的手機上下載 %{name}",
"banner-txt-client": "下載桌面版 %{name} 來安全地同步您的檔案並隨時存取它們",
"banner-btn-client": "下載"
},
"breadcrumb": {
"title_drive": "硬碟",
"title_recent": "最近更改",
"title_sharings": "分享",
"title_shared": "由我分享",
"title_activity": "活動",
"title_trash": "垃圾桶"
},
"Toolbar": {
"more": "更多"
},
"toolbar": {
"item_more": "更多",
"menu_select": "選擇項目",
"menu_download_folder": "下載資料夾",
"menu_download_file": "下載這個檔案",
"empty_trash": "清空垃圾桶",
"share": "分享",
"trash": "移除",
"delete_shared_drive": "刪除共用磁碟機",
"leave": "離開分享資料夾並刪除",
"select_all": "全選",
"sharings_tab_all": "全部",
"sharings_tab_drives": "磁碟機"
},
"Share": {
"create-cozy": "建立我的 Twake"
},
"Files": {
"share": {
"cta": "分享",
"title": "分享",
"details": {
"title": "分享的詳細資料",
"createdAt": "在 %{date}",
"ro": "可以檢視",
"rw": "可以修改"
},
"sharedByMe": "由我分享",
"sharedWithMe": "與我分享",
"sharedBy": "由 %{name} 分享",
"shareByLink": {
"subtitle": "由公開連結",
"desc": "任何人擁有這個連結可以檢視和下載您的檔案。",
"creating": "正在建立您的連結...",
"copy": "複製連結",
"copied": "連結已經複製到剪切版",
"failed": "無法複製到剪切版"
},
"shareByEmail": {
"subtitle": "由電郵",
"email": "收件人:",
"emailPlaceholder": "輸入收件人的電郵地址或名稱",
"send": "傳送",
"genericSuccess": "您傳送了邀請函給 %{count} 個聯絡人。",
"success": "您傳送了邀請函至 %{email}",
"type": {
"file": "檔案",
"folder": "資料夾"
}
},
"sharingLink": {
"copy": "複製",
"copied": "已複製"
},
"protectedShare": {
"title": "即將推出"
},
"close": "關閉",
"gettingLink": "正在取得您的連結..."
}
},
"table": {
"head_name": "名稱",
"head_update": "最後更新",
"head_size": "大小",
"row_read_only": "分享(唯讀模式)",
"row_read_write": "分享(讀寫模式)",
"load_more": "載入更多",
"mobile": {
"head_updated_at_asc": "最舊優先",
"head_updated_at_desc": "最新優先",
"head_size_asc": "最小優先",
"head_size_desc": "最大優先"
}
},
"Storage": {
"title": "儲存空間",
"availability": "可用 %{smart_count} GB",
"increase": "增加您的空間"
},
"SelectionBar": {
"share": "分享",
"download": "下載",
"trash": "移除",
"destroy": "永久刪除",
"rename": "重新命名",
"restore": "還原",
"close": "關閉",
"moveto": "移動到...",
"moveto_mobile": "移動到",
"phone-download": "使離線可用"
},
"DeleteConfirm": {
"cancel": "取消",
"delete": "移除"
},
"emptytrashconfirmation": {
"title": "永久刪除嗎?",
"cancel": "取消",
"delete": "全部刪除"
},
"DestroyConfirm": {
"title": "永久刪除嗎?",
"cancel": "取消",
"delete": "永久刪除"
},
"quotaalert": {
"title": "您的硬碟已滿 :(",
"confirm": "OK"
},
"loading": {
"message": "載入中"
},
"empty": {
"title": "您在此資料夾中沒有任何檔案。",
"trash_title": "您沒有任何已刪除的檔案。"
},
"error": {
"button": {
"reload": "現在重新整理"
},
"download_file": {
"offline": "您需要連線才能下載此檔案",
"missing": "此檔案已遺失"
}
},
"Error": {
"public_unshared_title": "抱歉,但此連結已不可用。"
},
"alert": {
"could_not_open_file": "此檔案無法開啟",
"item_copied": "1個項目已複製",
"items_copied": "%{count}個項目已複製",
"item_cut": "1個項目已剪下",
"items_cut": "%{count}個項目已剪下",
"item_moved": "1個項目已移動",
"items_moved": "%{count}個項目已移動",
"item_pasted": "1個項目已移動",
"items_pasted": "%{count}個項目已移動",
"copy_files_only": "無法複製資料夾",
"copy_not_allowed": "此檢視中不允許複製操作。",
"cut_not_allowed": "此檢視中不允許剪下操作。",
"paste_error": "貼上檔案時發生錯誤",
"paste_failed": "貼上檔案失敗",
"paste_sharing_error": "由於共享限制,無法貼上檔案。請改用移動操作。",
"paste_same_folder_skipped": "無法將項目移動到它們已經所在的同一資料夾。",
"paste_not_allowed": "您無法貼上到此資料夾",
"cannot_move_shared_drive": "您無法移動共享磁碟機資料夾",
"cannot_copy_shared_drive": "您無法複製共用雲端硬碟資料夾"
},
"actions": {
"details": "詳細資訊",
"personalizeFolder": {
"label": "個性化資料夾"
},
"summariseByAI": "總結"
},
"FolderCustomizer": {
"title": "個性化資料夾",
"description": "為您的資料夾選擇特定顏色",
"cancel": "取消",
"apply": "套用",
"error": "發生錯誤,請重試。",
"tabs": {
"colors": "顏色",
"icons": "圖示"
},
"iconPicker": {
"recents": "最近使用",
"chooseCustomIcon": "選擇自訂圖示"
}
}
}
================================================
FILE: src/models/Contact.js
================================================
import { getInitials, getDisplayName } from 'cozy-client/dist/models/contact'
import { Contact as DoctypeContact } from 'cozy-doctypes'
class Contact extends DoctypeContact {
static getInitials(contactOrRecipient, defaultValue = '') {
if (Contact.isContact(contactOrRecipient)) {
return getInitials(contactOrRecipient)
} else {
const s =
contactOrRecipient.public_name ||
contactOrRecipient.name ||
contactOrRecipient.email
return (s && s[0].toUpperCase()) || defaultValue
}
}
static getDisplayName(contact, defaultValue = '') {
if (Contact.isContact(contact)) {
return getDisplayName(contact)
} else {
return (
contact.public_name || contact.name || contact.email || defaultValue
)
}
}
}
export default Contact
================================================
FILE: src/models/Contact.spec.js
================================================
import Contact from '@/models/Contact'
describe('Contact model', () => {
describe('getInitials method', () => {
it('should return the first letter of public_name if it is an owner recipient', () => {
const recipient = {
name: 'whatever',
public_name: 'janedoe'
}
const result = Contact.getInitials(recipient)
expect(result).toEqual('J')
})
it('should return the first letter of name if it is a recipient', () => {
const recipient = {
name: 'janedoe'
}
const result = Contact.getInitials(recipient)
expect(result).toEqual('J')
})
it('should return the first letter of email if it is a recipient and name/public_name are not defined', () => {
const recipient = {
name: undefined,
public_name: undefined,
email: 'janedoe@example.com'
}
const result = Contact.getInitials(recipient)
expect(result).toEqual('J')
})
it('should return an empty string if name/public_name are undefined', () => {
const recipient = {}
const result = Contact.getInitials(recipient)
expect(result).toEqual('')
})
it('should use a default value if name/public_name are undefined', () => {
const recipient = {}
const result = Contact.getInitials(recipient, 'A')
expect(result).toEqual('A')
})
it('should use the original implementation if a contact is given', () => {
const contact = {
_id: '46b5d129-0296-4466-8c02-9a6a0c17c4cb',
_type: 'io.cozy.contacts',
name: {
givenName: 'Arya',
familyName: 'Stark'
}
}
const result = Contact.getInitials(contact)
expect(result).toEqual('AS')
})
})
describe('getDisplayName method', () => {
it('should use the original implementation if a contact is given', () => {
const contact = {
_id: '46b5d129-0296-4466-8c02-9a6a0c17c4cb',
_type: 'io.cozy.contacts',
fullname: 'Arya Stark',
name: {
givenName: 'Arya',
familyName: 'Stark'
}
}
const result = Contact.getDisplayName(contact)
expect(result).toEqual('Arya Stark')
})
it('should use public_name if available', () => {
const contact = {
email: 'arya@winterfell.westeros',
name: 'Arya Stark',
public_name: 'aryastark'
}
const result = Contact.getDisplayName(contact)
expect(result).toEqual('aryastark')
})
it('should use name if a recipient is given', () => {
const contact = {
name: 'Arya Stark'
}
const result = Contact.getDisplayName(contact)
expect(result).toEqual('Arya Stark')
})
it('should use email if a recipient is given', () => {
const recipient = {
email: 'arya.stark@winterfell.westeros'
}
const result = Contact.getDisplayName(recipient)
expect(result).toEqual('arya.stark@winterfell.westeros')
})
it('should use an empty string as default value if nothing is available', () => {
const recipient = {}
const result = Contact.getDisplayName(recipient)
expect(result).toEqual('')
})
it('should use a default value if nothing is available', () => {
const recipient = {}
const result = Contact.getDisplayName(recipient, 'Anonymous')
expect(result).toEqual('Anonymous')
})
})
})
================================================
FILE: src/models/index.js
================================================
export { CozyFile } from 'cozy-doctypes'
export { Group } from 'cozy-doctypes'
export { default as Contact } from '@/models/Contact'
================================================
FILE: src/modules/actionmenu/ActionMenuWithHeader.jsx
================================================
import React from 'react'
import { isDirectory } from 'cozy-client/dist/models/file'
import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'
import ActionsMenuMobileHeader from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuMobileHeader'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import styles from '@/styles/actionmenu.styl'
import getMimeTypeIcon from '@/lib/getMimeTypeIcon'
import { CozyFile } from '@/models'
export const ActionMenuWithHeader = ({
file,
actions,
onClose,
anchorElRef
}) => {
return (
)
}
const MenuHeaderFile = ({ file }) => {
const { filename, extension } = CozyFile.splitFilename(file)
return (
<>
{filename}
{extension}
>
}
primaryTypographyProps={{ variant: 'h6' }}
/>
>
)
}
================================================
FILE: src/modules/actions/addItems.jsx
================================================
import React, { forwardRef, useContext } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
const addMenuCtx = useContext(AddMenuContext)
const { a11y } = addMenuCtx
return (
props.onClick(addMenuCtx)}
ref={ref}
{...a11y}
>
)
})
Component.displayName = 'AddItems'
return Component
}
export const addItems = ({ t, hasWriteAccess }) => {
const label = t('toolbar.menu_add_item')
const icon = PlusIcon
return {
name: 'addItems',
label,
icon,
displayCondition: () => hasWriteAccess,
action: (_, { isOffline, handleOfflineClick, handleToggle }) => {
return isOffline ? handleOfflineClick() : handleToggle()
},
Component: makeComponent(label, icon)
}
}
================================================
FILE: src/modules/actions/components/addToFavorites.tsx
================================================
import React, { forwardRef } from 'react'
import { splitFilename } from 'cozy-client/dist/models/file'
import CozyClient from 'cozy-client/types/CozyClient'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import StarOutlineIcon from 'cozy-ui/transpiled/react/Icons/StarOutline'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import type { ActionWithPolicy } from '../types'
interface addToFavoritesProps {
t: (key: string, options?: Record) => string
client: CozyClient
isMobile: boolean
showAlert: import('cozy-ui/transpiled/react/providers/Alert').showAlertFunction
}
const addToFavorites = ({
t,
client,
isMobile,
showAlert
}: addToFavoritesProps): ActionWithPolicy => {
const icon = StarOutlineIcon
const label = isMobile
? t('favorites.label.addMobile')
: t('favorites.label.add')
return {
name: 'addToFavourites',
label,
icon,
allowInfectedFiles: false,
displayCondition: docs =>
docs.length > 0 &&
docs.every(doc => !doc.cozyMetadata?.favorite) &&
!docs[0]?.driveId,
action: async (files): Promise => {
try {
for (const file of files) {
await client.save({
...file,
cozyMetadata: {
...file.cozyMetadata,
favorite: true
}
})
}
const { filename } = splitFilename(files[0])
showAlert({
message: t('favorites.success.add', {
filename,
smart_count: files.length
}),
severity: 'success'
})
} catch (_error) {
showAlert({ message: t('favorites.error'), severity: 'error' })
}
},
Component: forwardRef(function AddToFavorites(props, ref) {
return (
)
})
}
}
export { addToFavorites }
================================================
FILE: src/modules/actions/components/duplicateTo.tsx
================================================
import React, { forwardRef } from 'react'
import { isFile } from 'cozy-client/dist/models/file'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import MultiFilesIcon from 'cozy-ui/transpiled/react/Icons/MultiFiles'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { navigateToModalWithMultipleFile } from '../helpers'
import type { ActionWithPolicy } from '../types'
interface duplicateToProps {
t: (key: string, options?: Record) => string
navigate: (to: string) => void
pathname: string
isMobile: boolean
search?: string
canDuplicate?: boolean
}
const duplicateTo = ({
t,
pathname,
navigate,
isMobile,
search,
canDuplicate = true
}: duplicateToProps): ActionWithPolicy => {
const icon = MultiFilesIcon
const label = isMobile
? t('actions.duplicateToMobile.label')
: t('actions.duplicateTo.label')
return {
name: 'duplicateTo',
label,
icon,
allowInfectedFiles: false,
displayCondition: docs =>
docs.length === 1 && isFile(docs[0]) && canDuplicate,
action: (files): void => {
navigateToModalWithMultipleFile({
files,
pathname,
navigate,
path: 'duplicate',
search
})
},
Component: forwardRef(function DuplicateTo(props, ref) {
return (
)
})
}
}
export { duplicateTo }
================================================
FILE: src/modules/actions/components/moveTo.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import MovetoIcon from 'cozy-ui/transpiled/react/Icons/Moveto'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'
import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'
const moveTo = ({
t,
canMove,
pathname,
navigate,
isMobile,
search,
shouldHideIfSharedDriveRecipient
}) => {
const icon = MovetoIcon
const label = isMobile
? t('SelectionBar.moveto_mobile')
: t('SelectionBar.moveto')
return {
name: 'moveTo',
label,
icon,
allowInfectedFiles: false,
displayCondition: docs => {
// special case for rename in sharings tab
const isAllowedForSharedDrive = shouldHideIfSharedDriveRecipient
? docs.every(doc => !isFromSharedDriveRecipient(doc))
: true
return docs.length > 0 && canMove && isAllowedForSharedDrive
},
action: async files => {
navigateToModalWithMultipleFile({
files,
pathname,
navigate,
path: 'move',
search
})
},
Component: forwardRef(function MoveTo(props, ref) {
return (
)
})
}
}
export { moveTo }
================================================
FILE: src/modules/actions/components/personalizeFolder.js
================================================
import React from 'react'
import { makeAction } from 'cozy-ui/transpiled/react/ActionsMenu/Actions/makeAction'
import PaletteIcon from 'cozy-ui/transpiled/react/Icons/Palette'
import { FolderCustomizerModal } from '../../views/Folder/FolderCustomizer'
const personalizeFolder = ({
t,
pushModal,
popModal,
driveId,
hasWriteAccess,
onClose
}) => {
const icon = PaletteIcon
const label = t('actions.personalizeFolder.label')
return makeAction({
name: 'personalizeFolder',
label,
icon,
displayCondition: docs =>
hasWriteAccess &&
docs.length === 1 &&
docs[0].type === 'directory' &&
!driveId,
action: docs => {
if (docs.length === 1 && docs[0].type === 'directory') {
const folderId = docs[0]._id
pushModal(
{
popModal()
onClose?.()
}}
/>
)
}
}
})
}
export { personalizeFolder }
================================================
FILE: src/modules/actions/components/removeFromFavorites.tsx
================================================
import React, { forwardRef } from 'react'
import { splitFilename } from 'cozy-client/dist/models/file'
import CozyClient from 'cozy-client/types/CozyClient'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import StarIcon from 'cozy-ui/transpiled/react/Icons/Star'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import type { ActionWithPolicy } from '../types'
interface removeFromFavoritesProps {
t: (key: string, options?: Record) => string
client: CozyClient
showAlert: import('cozy-ui/transpiled/react/providers/Alert').showAlertFunction
}
const removeFromFavorites = ({
t,
client,
showAlert
}: removeFromFavoritesProps): ActionWithPolicy => {
const label = t('favorites.label.remove')
const icon = StarIcon
return {
name: 'removeFromFavorites',
label,
icon,
allowInfectedFiles: false,
displayCondition: docs =>
docs.length > 0 &&
docs.every(doc => doc.cozyMetadata?.favorite) &&
!docs[0]?.driveId,
action: async (files): Promise => {
try {
for (const file of files) {
await client.save({
...file,
cozyMetadata: {
...file.cozyMetadata,
favorite: false
}
})
}
const { filename } = splitFilename(files[0])
showAlert({
message: t('favorites.success.remove', {
filename,
smart_count: files.length
}),
severity: 'success'
})
} catch (_error) {
showAlert({ message: t('favorites.error'), severity: 'error' })
}
},
Component: forwardRef(function RemoveFromFavorites(props, ref) {
return (
)
})
}
}
export { removeFromFavorites }
================================================
FILE: src/modules/actions/components/selectable.tsx
================================================
import React, { forwardRef } from 'react'
import { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
interface selectableProps {
t: (key: string, options?: Record) => string
showSelectionBar: () => void
}
export const selectable = ({
t,
showSelectionBar
}: selectableProps): Action => {
const label = t('toolbar.menu_select')
const icon = CheckSquareIcon
return {
name: 'selectable',
label,
icon,
action: (): void => {
showSelectionBar()
},
Component: forwardRef(function Selectable(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/actions/details.jsx
================================================
import React, { forwardRef } from 'react'
import flag from 'cozy-flags'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import InfoOutlinedIcon from 'cozy-ui/transpiled/react/Icons/InfoOutlined'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'details'
return Component
}
export const details = ({ t, navigate, location }) => {
const icon = InfoOutlinedIcon
const label = t('actions.details')
return {
name: 'details',
icon,
label,
allowInfectedFiles: false,
displayCondition: () => flag('drive.new-file-viewer-ui.enabled'),
Component: makeComponent(label, icon),
action: () => {
navigate(location.pathname, {
replace: true,
state: {
...location.state,
triggerDetailPanelTime:
(location?.state?.triggerDetailPanelTime || 0) + 1
}
})
}
}
}
================================================
FILE: src/modules/actions/divider.jsx
================================================
import React, { forwardRef } from 'react'
import Divider from 'cozy-ui/transpiled/react/Divider'
export const hr = () => {
return {
name: 'hr',
icon: 'hr',
displayInSelectionBar: false,
Component: forwardRef(function hr(_, ref) {
return
})
}
}
================================================
FILE: src/modules/actions/download.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { downloadFiles } from './utils'
import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'Download'
return Component
}
export const download = ({
client,
t,
showAlert,
shouldHideIfSharedDriveRecipient,
isSelectAll,
displayedFolder
}) => {
const label = t('SelectionBar.download')
const icon = DownloadIcon
return {
name: 'download',
label,
icon,
allowInfectedFiles: false,
displayCondition: files => {
// For sharing tab where we can see multiple shared folders as
// recipient, disable download because we cannot download different
// shared folders at the same time.
if (
shouldHideIfSharedDriveRecipient &&
files.length > 1 &&
files.some(file => isFromSharedDriveRecipient(file))
) {
return false
}
return files.length > 0
},
action: files => {
let selectedFiles = files
if (isSelectAll) {
selectedFiles = [displayedFolder]
}
return downloadFiles(client, selectedFiles, { showAlert, t })
},
Component: makeComponent(label, icon)
}
}
================================================
FILE: src/modules/actions/helpers.js
================================================
import { joinPath } from '@/lib/path'
export const navigateToModal = ({ navigate, pathname, files, path }) => {
const file = Array.isArray(files) ? files[0] : files
return navigate(
pathname ? joinPath(pathname, `file/${file.id}/${path}`) : `v/${path}`
)
}
export const navigateToModalWithMultipleFile = ({
navigate,
pathname,
files,
path,
search
}) => {
return navigate(
{
pathname: pathname ? joinPath(pathname, path) : `v/${path}`,
search: search ? `?${search}` : ''
},
{
state: { fileIds: files.map(file => file.id) }
}
)
}
/**
* Returns the context menu visible actions
*
* @param {Object[]} actions - the list of actions
* @returns {Object[]} - the list of actions to be displayed
*/
export const getContextMenuActions = (actions = []) =>
actions.filter(
action => Object.values(action)[0]?.displayInContextMenu !== false
)
================================================
FILE: src/modules/actions/helpers.spec.js
================================================
import {
navigateToModal,
navigateToModalWithMultipleFile,
getContextMenuActions
} from './helpers'
jest.mock('@/lib/path', () => ({
joinPath: jest.fn((...paths) => paths.join('/'))
}))
describe('actions helpers', () => {
describe('navigateToModal', () => {
let mockNavigate
beforeEach(() => {
mockNavigate = jest.fn()
})
afterEach(() => {
jest.clearAllMocks()
})
it('should navigate to modal with pathname and single file', () => {
const params = {
navigate: mockNavigate,
pathname: '/folder/123',
files: { id: 'file-123', name: 'test.pdf' },
path: 'preview'
}
navigateToModal(params)
expect(mockNavigate).toHaveBeenCalledWith(
'/folder/123/file/file-123/preview'
)
})
it('should navigate to modal with pathname and array of files', () => {
const params = {
navigate: mockNavigate,
pathname: '/folder/456',
files: [
{ id: 'file-1', name: 'first.pdf' },
{ id: 'file-2', name: 'second.pdf' }
],
path: 'edit'
}
navigateToModal(params)
expect(mockNavigate).toHaveBeenCalledWith('/folder/456/file/file-1/edit')
})
})
describe('navigateToModalWithMultipleFile', () => {
let mockNavigate
beforeEach(() => {
mockNavigate = jest.fn()
})
afterEach(() => {
jest.clearAllMocks()
})
it('should navigate with pathname, multiple files, and search params', () => {
const params = {
navigate: mockNavigate,
pathname: '/folder/123',
files: [
{ id: 'file-1', name: 'doc1.pdf' },
{ id: 'file-2', name: 'doc2.pdf' },
{ id: 'file-3', name: 'doc3.pdf' }
],
path: 'share',
search: 'tab=link'
}
navigateToModalWithMultipleFile(params)
expect(mockNavigate).toHaveBeenCalledWith(
{
pathname: '/folder/123/share',
search: '?tab=link'
},
{
state: { fileIds: ['file-1', 'file-2', 'file-3'] }
}
)
})
it('should navigate with pathname and multiple files without search params', () => {
const params = {
navigate: mockNavigate,
pathname: '/recent',
files: [
{ id: 'file-a', name: 'image1.jpg' },
{ id: 'file-b', name: 'image2.jpg' }
],
path: 'move'
}
navigateToModalWithMultipleFile(params)
expect(mockNavigate).toHaveBeenCalledWith(
{
pathname: '/recent/move',
search: ''
},
{
state: { fileIds: ['file-a', 'file-b'] }
}
)
})
it('should handle empty search parameter', () => {
const params = {
navigate: mockNavigate,
pathname: '/folder/456',
files: [
{ id: 'file-1', name: 'test1.pdf' },
{ id: 'file-2', name: 'test2.pdf' }
],
path: 'delete',
search: ''
}
navigateToModalWithMultipleFile(params)
expect(mockNavigate).toHaveBeenCalledWith(
{
pathname: '/folder/456/delete',
search: ''
},
{
state: { fileIds: ['file-1', 'file-2'] }
}
)
})
})
describe('getContextMenuActions', () => {
it('should return all actions when all have displayInContextMenu !== false', () => {
const actions = [
{ download: { displayInContextMenu: true, name: 'Download' } },
{ share: { name: 'Share' } }, // undefined displayInContextMenu should be included
{ rename: { displayInContextMenu: undefined, name: 'Rename' } }
]
const result = getContextMenuActions(actions)
expect(result).toEqual(actions)
expect(result).toHaveLength(3)
})
it('should filter out actions with displayInContextMenu: false', () => {
const actions = [
{ download: { displayInContextMenu: true, name: 'Download' } },
{ share: { displayInContextMenu: false, name: 'Share' } },
{ rename: { name: 'Rename' } },
{ delete: { displayInContextMenu: false, name: 'Delete' } }
]
const result = getContextMenuActions(actions)
expect(result).toEqual([
{ download: { displayInContextMenu: true, name: 'Download' } },
{ rename: { name: 'Rename' } }
])
expect(result).toHaveLength(2)
})
})
})
================================================
FILE: src/modules/actions/index.js
================================================
export { share } from './share'
export { download } from './download'
export { hr } from './divider'
export { trash } from './trash'
export { rename } from './rename'
export { qualify } from './qualify'
export { versions } from './versions'
export { restore } from './restore'
export { select } from './select'
export { infos } from './infos'
export { addItems } from './addItems'
export { selectAllItems } from './selectAll'
export { summariseByAI } from './summariseByAI'
export { filterActionsByPolicy, hasAnyInfectedFile } from './policies'
================================================
FILE: src/modules/actions/index.spec.js
================================================
import { download } from './index'
describe('download', () => {
it('should display for a single file', () => {
const files = [{ type: 'file' }]
const dl = download({ client: {}, t: () => {} })
expect(dl.displayCondition(files)).toBe(true)
})
it('should display for a folder', () => {
const files = [{ type: 'directory' }]
const dl = download({ client: {}, t: () => {} })
expect(dl.displayCondition(files)).toBe(true)
})
it('should display for a mixed selection', () => {
const files = [{ type: 'file' }, { type: 'directory' }]
const dl = download({ client: {}, t: () => {} })
expect(dl.displayCondition(files)).toBe(true)
})
it('should not display for an empty selection', () => {
const dl = download({ client: {}, t: () => {} })
expect(dl.displayCondition([])).toBe(false)
})
})
================================================
FILE: src/modules/actions/infos.jsx
================================================
import React, { forwardRef } from 'react'
import { isFile } from 'cozy-client/dist/models/file'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import InfoIcon from 'cozy-ui/transpiled/react/Icons/Info'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'infos'
return Component
}
export const infos = ({ t, isMobile, navigate }) => {
const icon = InfoIcon
const label = isMobile ? t('actions.infosMobile') : t('actions.infos')
return {
name: 'infos',
icon,
label,
displayCondition: docs => docs.length <= 1 && isFile(docs[0]),
Component: makeComponent(label, icon),
action: docs => {
navigate(`file/${docs[0]._id}`)
}
}
}
================================================
FILE: src/modules/actions/policies.spec.ts
================================================
import type { IOCozyFile } from 'cozy-client/types/types'
import {
filterActionsByPolicy,
hasAnyInfectedFile,
buildPolicyContext,
ACTION_POLICIES
} from './policies'
import type { DriveAction, ActionPolicyContext } from './types'
// Mock cozy-client isDirectory
jest.mock('cozy-client/dist/models/file', () => ({
isDirectory: jest.fn((file: { type?: string }) => file.type === 'directory')
}))
describe('policies', () => {
// Helper to create a wrapped action (as returned by makeActions)
const createWrappedAction = (
name: string,
options: Partial = {}
): Record => ({
[name]: {
name,
...options
}
})
// Helper to create a mock file
const createMockFile = (
id: string,
options: {
infected?: boolean
trashed?: boolean
type?: 'file' | 'directory'
pending?: boolean
} = {}
): Partial => ({
_id: id,
type: options.type ?? 'file',
trashed: options.trashed ?? false,
...(options.infected && { antivirus_scan: { status: 'infected' } }),
...(options.pending && { antivirus_scan: { status: 'pending' } })
})
describe('buildPolicyContext', () => {
it('should detect infected files', () => {
const files = [
createMockFile('file1', { infected: true }),
createMockFile('file2')
] as IOCozyFile[]
const ctx = buildPolicyContext(files)
expect(ctx.hasInfectedFile).toBe(true)
})
it('should detect multiple files', () => {
const files = [
createMockFile('file1'),
createMockFile('file2')
] as IOCozyFile[]
const ctx = buildPolicyContext(files)
expect(ctx.hasMultipleFiles).toBe(true)
})
it('should detect folders', () => {
const files = [
createMockFile('folder1', { type: 'directory' })
] as IOCozyFile[]
const ctx = buildPolicyContext(files)
expect(ctx.hasFolder).toBe(true)
})
it('should detect all trashed files', () => {
const files = [
createMockFile('file1', { trashed: true }),
createMockFile('file2', { trashed: true })
] as IOCozyFile[]
const ctx = buildPolicyContext(files)
expect(ctx.allInTrash).toBe(true)
})
it('should not mark allInTrash if some files are not trashed', () => {
const files = [
createMockFile('file1', { trashed: true }),
createMockFile('file2', { trashed: false })
] as IOCozyFile[]
const ctx = buildPolicyContext(files)
expect(ctx.allInTrash).toBe(false)
})
})
describe('ACTION_POLICIES', () => {
it('should have all expected policies registered', () => {
const policyNames = ACTION_POLICIES.map(p => p.name)
expect(policyNames).toContain('infection')
expect(policyNames).toContain('notScanned')
expect(policyNames).toContain('multipleFiles')
expect(policyNames).toContain('folders')
expect(policyNames).toContain('trashed')
})
describe('infection policy', () => {
const infectionPolicy = ACTION_POLICIES.find(p => p.name === 'infection')
if (!infectionPolicy) {
throw new Error('infection policy not found')
}
it('should allow action when no infected files', () => {
const action = { allowInfectedFiles: false }
const ctx = { hasInfectedFile: false } as ActionPolicyContext
expect(infectionPolicy.allows(action, ctx)).toBe(true)
})
it('should block action when infected files and not allowed', () => {
const action = { allowInfectedFiles: false }
const ctx = { hasInfectedFile: true } as ActionPolicyContext
expect(infectionPolicy.allows(action, ctx)).toBe(false)
})
it('should allow action when infected files and explicitly allowed', () => {
const action = { allowInfectedFiles: true }
const ctx = { hasInfectedFile: true } as ActionPolicyContext
expect(infectionPolicy.allows(action, ctx)).toBe(true)
})
})
describe('notScanned policy', () => {
const notScannedPolicy = ACTION_POLICIES.find(
p => p.name === 'notScanned'
)
if (!notScannedPolicy) {
throw new Error('notScanned policy not found')
}
it('should allow action when no pending files', () => {
const action = { allowNotScannedFiles: false }
const ctx = { hasNotScannedFile: false } as ActionPolicyContext
expect(notScannedPolicy.allows(action, ctx)).toBe(true)
})
it('should block action when pending files and not allowed', () => {
const action = { allowNotScannedFiles: false }
const ctx = { hasNotScannedFile: true } as ActionPolicyContext
expect(notScannedPolicy.allows(action, ctx)).toBe(false)
})
it('should allow action when pending files and explicitly allowed', () => {
const action = { allowNotScannedFiles: true }
const ctx = { hasNotScannedFile: true } as ActionPolicyContext
expect(notScannedPolicy.allows(action, ctx)).toBe(true)
})
})
describe('multipleFiles policy', () => {
const multipleFilesPolicy = ACTION_POLICIES.find(
p => p.name === 'multipleFiles'
)
if (!multipleFilesPolicy) {
throw new Error('multipleFiles policy not found in ACTION_POLICIES')
}
it('should allow action for single file by default', () => {
const action = {}
const ctx = { hasMultipleFiles: false } as ActionPolicyContext
expect(multipleFilesPolicy.allows(action, ctx)).toBe(true)
})
it('should allow action for multiple files by default', () => {
const action = {}
const ctx = { hasMultipleFiles: true } as ActionPolicyContext
expect(multipleFilesPolicy.allows(action, ctx)).toBe(true)
})
it('should block action for multiple files when explicitly disallowed', () => {
const action = { allowMultiple: false }
const ctx = { hasMultipleFiles: true } as ActionPolicyContext
expect(multipleFilesPolicy.allows(action, ctx)).toBe(false)
})
})
describe('trashed policy', () => {
const trashedPolicy = ACTION_POLICIES.find(p => p.name === 'trashed')
if (!trashedPolicy) {
throw new Error('trashedPolicy not found in ACTION_POLICIES')
}
it('should allow action when files not in trash', () => {
const action = {}
const ctx = { allInTrash: false } as ActionPolicyContext
expect(trashedPolicy.allows(action, ctx)).toBe(true)
})
it('should block action when files in trash and not allowed', () => {
const action = {}
const ctx = { allInTrash: true } as ActionPolicyContext
expect(trashedPolicy.allows(action, ctx)).toBe(false)
})
it('should allow action when files in trash and explicitly allowed', () => {
const action = { allowTrashed: true }
const ctx = { allInTrash: true } as ActionPolicyContext
expect(trashedPolicy.allows(action, ctx)).toBe(true)
})
})
})
describe('filterActionsByPolicy', () => {
it('should return all actions when no policy restrictions apply', () => {
const actions = [
createWrappedAction('download'),
createWrappedAction('share'),
createWrappedAction('trash')
]
const files = [createMockFile('file1')] as IOCozyFile[]
const result = filterActionsByPolicy(actions, files)
expect(result).toHaveLength(3)
})
it('should filter out actions blocked by infection policy', () => {
const actions = [
createWrappedAction('download', { allowInfectedFiles: false }),
createWrappedAction('share', { allowInfectedFiles: false }),
createWrappedAction('trash', { allowInfectedFiles: true })
]
const files = [
createMockFile('file1', { infected: true })
] as IOCozyFile[]
const result = filterActionsByPolicy(actions, files)
expect(result).toHaveLength(1)
expect(Object.keys(result[0])[0]).toBe('trash')
})
it('should filter out actions blocked by multiple files policy', () => {
const actions = [
createWrappedAction('download'),
createWrappedAction('rename', { allowMultiple: false }),
createWrappedAction('trash')
]
const files = [
createMockFile('file1'),
createMockFile('file2')
] as IOCozyFile[]
const result = filterActionsByPolicy(actions, files)
expect(result).toHaveLength(2)
expect(result.map(a => Object.keys(a)[0])).toEqual(['download', 'trash'])
})
it('should filter out actions blocked by trashed policy', () => {
const actions = [
createWrappedAction('download'),
createWrappedAction('restore', { allowTrashed: true }),
createWrappedAction('share')
]
const files = [createMockFile('file1', { trashed: true })] as IOCozyFile[]
const result = filterActionsByPolicy(actions, files)
expect(result).toHaveLength(1)
expect(Object.keys(result[0])[0]).toBe('restore')
})
it('should handle empty actions array', () => {
const actions: Record[] = []
const files = [
createMockFile('file1', { infected: true })
] as IOCozyFile[]
const result = filterActionsByPolicy(actions, files)
expect(result).toHaveLength(0)
})
it('should handle empty files array', () => {
const actions = [
createWrappedAction('download'),
createWrappedAction('trash')
]
const files: IOCozyFile[] = []
const result = filterActionsByPolicy(actions, files)
expect(result).toHaveLength(2)
})
it('should allow empty action wrappers (fail-open behavior)', () => {
// Test that empty wrappers are allowed through the filter
// This verifies the contract that getActionFromWrapper can return null
// and isActionAllowedByPolicies is not called for such cases
const actions = [
createWrappedAction('download'),
{} as Record, // Empty wrapper
createWrappedAction('trash')
]
const files = [createMockFile('file1')] as IOCozyFile[]
const result = filterActionsByPolicy(actions, files)
// Empty wrapper should be included in results (fail-open)
expect(result).toHaveLength(3)
expect(result[1]).toEqual({})
})
it('should apply multiple policies together', () => {
const actions = [
createWrappedAction('download', { allowInfectedFiles: false }),
createWrappedAction('rename', {
allowInfectedFiles: true,
allowMultiple: false
}),
createWrappedAction('trash', { allowInfectedFiles: true })
]
// Multiple infected files
const files = [
createMockFile('file1', { infected: true }),
createMockFile('file2', { infected: true })
] as IOCozyFile[]
const result = filterActionsByPolicy(actions, files)
// download blocked by infection, rename blocked by multiple files
expect(result).toHaveLength(1)
expect(Object.keys(result[0])[0]).toBe('trash')
})
})
describe('hasAnyInfectedFile', () => {
it('should return false when no files are infected', () => {
const files = [
createMockFile('file1'),
createMockFile('file2')
] as IOCozyFile[]
const result = hasAnyInfectedFile(files)
expect(result).toBe(false)
})
it('should return true when at least one file is infected', () => {
const files = [
createMockFile('file1', { infected: true }),
createMockFile('file2')
] as IOCozyFile[]
const result = hasAnyInfectedFile(files)
expect(result).toBe(true)
})
it('should return false for empty array', () => {
const files: IOCozyFile[] = []
const result = hasAnyInfectedFile(files)
expect(result).toBe(false)
})
})
})
================================================
FILE: src/modules/actions/policies.ts
================================================
import { isDirectory } from 'cozy-client/dist/models/file'
import type { IOCozyFile } from 'cozy-client/types/types'
import flag from 'cozy-flags'
import type {
ActionPolicyContext,
ActionPolicyDefinition,
DriveAction,
DriveActionPolicyFlags
} from './types'
import { isInfected, isNotScanned } from '@/modules/filelist/helpers'
/**
* Builds the policy context from the selected files.
* This computes all the information needed for policy checks once,
* so we don't have to recompute it for each policy.
*
* @param {IOCozyFile[]} files - The files being acted upon
* @returns {ActionPolicyContext} The policy context with computed file information
*/
export const buildPolicyContext = (
files: IOCozyFile[]
): ActionPolicyContext => {
let hasInfected = false
let hasNotScanned = false
let hasFolder = false
let hasSharedFile = false
let allInTrash = files.length > 0
for (const file of files) {
if (!hasInfected && isInfected(file)) hasInfected = true
if (!hasNotScanned && isNotScanned(file)) hasNotScanned = true
if (!hasFolder && isDirectory(file)) hasFolder = true
if (!hasSharedFile) {
hasSharedFile =
file.referenced_by?.some(ref => ref.type === 'io.cozy.sharings') ??
false
}
if (allInTrash && !file.trashed) allInTrash = false
}
return {
files,
hasInfectedFile: hasInfected,
hasNotScannedFile: flag('drive.not-scanned-file-action.enabled')
? hasNotScanned
: false,
hasMultipleFiles: files.length > 1,
hasFolder,
hasSharedFile,
allInTrash
}
}
/**
* Policy for infected files.
* Actions are blocked for infected files unless they explicitly allow it.
*/
const infectionPolicy: ActionPolicyDefinition = {
name: 'infection',
allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean =>
!ctx.hasInfectedFile || action.allowInfectedFiles === true
}
/**
* Policy for files that haven't been scanned yet.
* Actions are blocked when files are not scanned unless they explicitly allow it.
*/
const notScannedPolicy: ActionPolicyDefinition = {
name: 'notScanned',
allows: (
action: DriveActionPolicyFlags,
ctx: ActionPolicyContext
): boolean => {
const allowed =
!ctx.hasNotScannedFile || action.allowNotScannedFiles === true
return allowed
}
}
/**
* Policy for multiple file selection.
* Actions are blocked for multiple files unless they explicitly allow it.
* Default is true (most actions support multiple files).
*/
const multipleFilesPolicy: ActionPolicyDefinition = {
name: 'multipleFiles',
allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean =>
!ctx.hasMultipleFiles || action.allowMultiple !== false
}
/**
* Policy for folders.
* Actions are blocked for folders unless they explicitly allow it.
* Default is true (most actions support folders).
*/
const foldersPolicy: ActionPolicyDefinition = {
name: 'folders',
allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean =>
!ctx.hasFolder || action.allowFolders !== false
}
/**
* Policy for trashed files.
* Actions are blocked for trashed files unless they explicitly allow it.
*/
const trashedPolicy: ActionPolicyDefinition = {
name: 'trashed',
allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean =>
!ctx.allInTrash || action.allowTrashed === true
}
/**
* All registered policies that will be checked for each action.
* Add new policies here to have them automatically applied.
*/
export const ACTION_POLICIES: ActionPolicyDefinition[] = [
infectionPolicy,
notScannedPolicy,
multipleFilesPolicy,
foldersPolicy,
trashedPolicy
]
/**
* Extracts the action object from a drive action wrapper.
* Actions from makeActions are wrapped as { [actionName]: actionObject }
*/
const getActionFromWrapper = (
wrappedAction: Record
): DriveAction | null => {
const values = Object.values(wrappedAction)
return values.length > 0 ? values[0] : null
}
/**
* Checks if an action is allowed by all policies.
*
* @param action - The action to check
* @param ctx - The policy context
* @returns true if all policies allow the action
*/
const isActionAllowedByPolicies = (
action: DriveAction,
ctx: ActionPolicyContext
): boolean => {
return ACTION_POLICIES.every(policy => policy.allows(action, ctx))
}
/**
* Filters actions based on all registered policies.
* This is the single source of truth for determining which actions
* are available for a given set of files based on their characteristics.
*
* @param actions - Array of wrapped actions from makeActions
* @param files - Array of files to check policies against
* @returns Filtered array of actions that are allowed for the given files
*
* @example
* ```typescript
* const filteredActions = filterActionsByPolicy(actions, selectedFiles)
* ```
*/
export const filterActionsByPolicy = >(
actions: T[],
files: IOCozyFile[]
): T[] => {
// Build the policy context once for all checks
const ctx = buildPolicyContext(files)
const result = actions.filter(wrappedAction => {
// makeActions guarantees wrappers contain an action, so empty wrappers
// cannot occur. This fail-open behavior is safe and intentional.
const action = getActionFromWrapper(wrappedAction)
if (!action) return true
const isAllowed = isActionAllowedByPolicies(action, ctx)
return isAllowed
})
return result
}
/**
* Checks if any of the provided files are infected.
* Useful for UI components that need to show infection indicators.
*
* @param files - Array of files to check
* @returns true if any file is infected
*/
export const hasAnyInfectedFile = (files: IOCozyFile[]): boolean => {
return files.some(file => isInfected(file))
}
/**
* Gets the policy context for the given files.
* Useful for UI components that need to access policy information.
*
* @param files - Array of files to build context for
* @returns The policy context
*/
export const getPolicyContext = (files: IOCozyFile[]): ActionPolicyContext => {
return buildPolicyContext(files)
}
================================================
FILE: src/modules/actions/qualify.jsx
================================================
import React, { forwardRef } from 'react'
import { getQualification } from 'cozy-client/dist/models/document'
import { getBoundT } from 'cozy-client/dist/models/document/locales'
import { isFile } from 'cozy-client/dist/models/file'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import UnqualifyIcon from 'cozy-ui/transpiled/react/Icons/LabelOutlined'
import QualifyIcon from 'cozy-ui/transpiled/react/Icons/Qualify'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { navigateToModal } from '@/modules/actions/helpers'
const makeComponent = ({ label, scannerT, t }) => {
const Component = forwardRef((props, ref) => {
const file = props.docs[0]
const fileQualif = getQualification(file)
return (
{fileQualif && (
)}
)
})
Component.displayName = 'Qualify'
return Component
}
export const qualify = ({ t, lang, navigate, pathname }) => {
const label = t('SelectionBar.qualify')
const scannerT = getBoundT(lang || 'en')
return {
name: 'qualify',
label,
icon: QualifyIcon,
displayCondition: selection => {
return selection.length === 1 && isFile(selection[0])
},
action: files => {
return navigateToModal({ navigate, pathname, files, path: 'qualify' })
},
Component: makeComponent({ label, scannerT, t })
}
}
================================================
FILE: src/modules/actions/rename.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import RenameIcon from 'cozy-ui/transpiled/react/Icons/Rename'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { startRenamingAsync } from '@/modules/drive/rename'
import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'Rename'
return Component
}
export const rename = ({
t,
hasWriteAccess,
dispatch,
shouldHideIfSharedDriveRecipient
}) => {
const label = t('SelectionBar.rename')
const icon = RenameIcon
return {
name: 'rename',
label,
icon,
displayCondition: selection => {
// special case for rename in sharings tab
const isAllowedForSharedDrive = shouldHideIfSharedDriveRecipient
? !isFromSharedDriveRecipient(selection[0])
: true
return selection.length === 1 && hasWriteAccess && isAllowedForSharedDrive
},
action: files => {
// Use setTimeout to defer dispatch until after click event completes
// This prevents focus loss on the rename input
setTimeout(() => {
dispatch(startRenamingAsync(files[0]))
}, 0)
},
Component: makeComponent(label, icon)
}
}
================================================
FILE: src/modules/actions/restore.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { restoreFiles } from './utils'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'Restore'
return Component
}
export const restore = ({ t, refresh, client }) => {
const label = t('SelectionBar.restore')
const icon = RestoreIcon
return {
name: 'restore',
label,
icon,
allowTrashed: true,
action: async files => {
await restoreFiles(client, files)
refresh()
},
Component: makeComponent(label, icon)
}
}
================================================
FILE: src/modules/actions/select.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'Select'
return Component
}
export const select = ({ t, showSelectionBar }) => {
const label = t('toolbar.menu_select')
const icon = CheckSquareIcon
return {
name: 'select',
label,
icon,
displayCondition: files => files.length > 1,
action: () => showSelectionBar(),
Component: makeComponent(label, icon)
}
}
================================================
FILE: src/modules/actions/selectAll.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare'
import CheckboxIcon from 'cozy-ui/transpiled/react/Icons/Checkbox'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'SelectAllItems'
return Component
}
export const selectAllItems = ({ t, selectAll, isSelectAll, isMobile }) => {
const baseKey = isSelectAll ? 'clear_selection' : 'select_all'
const label = t(`toolbar.${baseKey}${isMobile ? '_mobile' : ''}`)
const icon = isSelectAll ? CheckSquareIcon : CheckboxIcon
return {
name: 'selectAllItems',
label,
icon,
displayInSelectionBar: true,
displayInContextMenu: false,
displayCondition: files => files.length > 0,
action: () => selectAll(),
Component: makeComponent(label, icon)
}
}
================================================
FILE: src/modules/actions/share.jsx
================================================
import React, { forwardRef } from 'react'
import { SharedRecipients } from 'cozy-sharing'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { navigateToModal } from '@/modules/actions/helpers'
import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'
const share = ({
t,
shouldHideIfSharedDriveRecipient,
hasWriteAccess,
navigate,
pathname,
allLoaded
}) => {
const label = t('Files.share.cta')
const icon = ShareIcon
return {
name: 'share',
label,
icon,
allowInfectedFiles: false,
displayCondition: files => {
// If shared drive recipient:
// - in sharing view, we hide it because it works differently
// - in shared drive view, we show it
if (files?.length === 1 && isFromSharedDriveRecipient(files[0])) {
return !shouldHideIfSharedDriveRecipient
}
return (
allLoaded && // We need to wait for the sharing context to be completely loaded to avoid race conditions
hasWriteAccess &&
files?.length === 1
)
},
action: files =>
navigateToModal({ navigate, pathname, files, path: 'share' }),
Component: forwardRef(function ShareMenuItemInMenu(props, ref) {
const { isMobile } = useBreakpoints()
return (
{isMobile && props.docs ? (
) : null}
)
})
}
}
export { share }
================================================
FILE: src/modules/actions/summariseByAI.jsx
================================================
import React, { forwardRef } from 'react'
import flag from 'cozy-flags'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ArticleIcon from 'cozy-ui/transpiled/react/Icons/Article'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { isFileSummaryCompatible } from 'cozy-viewer/dist/helpers'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'summariseByAI'
return Component
}
export const summariseByAI = ({ t, hasWriteAccess, navigate, isPublic }) => {
const label = t('actions.summariseByAI')
const icon = ArticleIcon
return {
name: 'summariseByAI',
label,
icon,
displayCondition: files =>
flag('ai.available') &&
isFileSummaryCompatible(files[0]) &&
hasWriteAccess &&
!isPublic,
action: files => {
const file = files[0]
navigate(`file/${file._id}`, {
state: { showAIAssistant: true }
})
},
Component: makeComponent(label, icon)
}
}
================================================
FILE: src/modules/actions/trash.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import DeleteConfirm from '@/modules/drive/DeleteConfirm'
const makeComponent = ({ icon, t, byDocId, isOwner }) => {
const Component = forwardRef((props, ref) => {
const sharedWithMe =
byDocId !== undefined &&
byDocId[props.docs[0].id] &&
!isOwner(props.docs[0].id)
const label = sharedWithMe
? t('toolbar.leave')
: props.docs.length > 1
? t('SelectionBar.trash_all')
: t('SelectionBar.trash')
return (
)
})
Component.displayName = 'Trash'
return Component
}
export const trash = ({
t,
pushModal,
popModal,
hasWriteAccess,
refresh,
byDocId,
isOwner,
driveId
}) => {
const icon = TrashIcon
return {
name: 'trash',
icon,
allowInfectedFiles: true,
allowNotScannedFiles: true,
displayCondition: files => files.length > 0 && hasWriteAccess,
action: files => {
return pushModal(
)
},
Component: makeComponent({ icon, t, byDocId, isOwner })
}
}
================================================
FILE: src/modules/actions/types.ts
================================================
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
import type { IOCozyFile } from 'cozy-client/types/types'
import type { Action as CozyAction } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
/**
* Context containing computed information about the selected files.
* This is built once and passed to all policy checks for efficiency.
*/
export interface ActionPolicyContext {
/** The files being acted upon */
files: IOCozyFile[]
/** Whether any file in the selection is infected */
hasInfectedFile: boolean
/** Whether any file has not been scanned yet */
hasNotScannedFile: boolean
/** Whether multiple files are selected */
hasMultipleFiles: boolean
/** Whether any file is a folder */
hasFolder: boolean
/** Whether any file is shared */
hasSharedFile: boolean
/** Whether all files are in the trash */
allInTrash: boolean
}
/**
* Interface for defining a policy that determines if an action is allowed.
* Each policy checks a specific aspect (infection, read-only, etc.).
*/
export interface ActionPolicyDefinition {
/** Unique name for the policy (for debugging/logging) */
name: string
/**
* Checks if the action is allowed given the policy context.
* @param action - The action being checked
* @param ctx - The policy context with computed file information
* @returns true if the action is allowed, false otherwise
*/
allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext) => boolean
}
/**
* Policy flags that can be set on actions to control their availability.
* Each flag corresponds to a policy check.
*/
export interface DriveActionPolicyFlags {
allowInfectedFiles?: boolean
allowNotScannedFiles?: boolean
allowMultiple?: boolean
allowFolders?: boolean
allowTrashed?: boolean
}
/**
* Context passed to action handlers and components at runtime.
*/
export interface ActionContext {
client?: unknown
t?: (key: string, options?: Record) => string
lang?: string
vaultClient?: unknown
pushModal?: (modal: React.ReactNode) => void
popModal?: () => void
refresh?: () => void
navigate?: (
path: string | { pathname: string; search?: string },
options?: unknown
) => void
hasWriteAccess?: boolean
canMove?: boolean
isPublic?: boolean
allLoaded?: boolean
showAlert?: (options: { message: string; severity: string }) => void
isOwner?: (docId: string) => boolean
byDocId?: Record
isNativeFileSharingAvailable?: boolean
shareFilesNative?: (files: IOCozyFile[]) => void
isSharingShortcutCreated?: boolean
openSharingLinkDisplayed?: boolean
syncSharingLink?: () => void
isMobile?: boolean
fetchBlobFileById?: (client: unknown, fileId: string) => Promise
isFile?: (file: IOCozyFile) => boolean
addSharingLink?: () => void
driveId?: string
pathname?: string
search?: string
canDuplicate?: boolean
isSelectAll?: boolean
displayedFolder?: IOCozyFile
}
/**
* Props passed to action menu item components.
*/
export interface ActionComponentProps {
docs?: IOCozyFile[]
onClick?: (context?: unknown) => void
}
/**
* Drive action definition with policy support.
*/
export interface DriveAction extends DriveActionPolicyFlags {
/** Unique identifier for the action */
name: string
/** Display label for the action */
label?: string
/** Icon component or icon name */
icon?: React.ComponentType | string
/**
* Function to determine if the action should be displayed.
* This is checked AFTER policy checks.
*/
displayCondition?: (docs: IOCozyFile[]) => boolean
/** Whether to show this action in the selection bar. Default: true */
displayInSelectionBar?: boolean
/** Whether to show this action in context menus. Default: true */
displayInContextMenu?: boolean
/** The action handler */
action?: (docs: IOCozyFile[], context?: ActionContext) => void
/** React component to render the action menu item */
Component?: ForwardRefExoticComponent<
ActionComponentProps & RefAttributes
>
}
/**
* Extended Action type that includes policy properties.
* Use this type when you need to return an action that is compatible
* with cozy-ui's Action type but also includes our policy properties.
*/
export type ActionWithPolicy = CozyAction &
DriveActionPolicyFlags
================================================
FILE: src/modules/actions/utils.js
================================================
import { isDirectory } from 'cozy-client/dist/models/file'
import { receiveQueryResult } from 'cozy-client/dist/store'
import { DOCTYPE_FILES } from '@/lib/doctypes'
const isMissingFileError = error => error.status === 404
const downloadFileError = error => {
return isMissingFileError(error)
? 'error.download_file.missing'
: 'error.download_file.offline'
}
/**
* An instance of cozy-client
* @typedef {object} CozyClient
*/
/**
* downloadFiles - Triggers the download of one or multiple files by the browser
*
* @param {CozyClient} client
* @param {array} files One or more files to download
*/
export const downloadFiles = async (client, files, { showAlert, t } = {}) => {
if (files.length === 1 && !isDirectory(files[0])) {
const file = files[0]
const driveId = file.driveId
try {
return await client
.collection(DOCTYPE_FILES, { driveId })
.download(file, null, file.name)
} catch (error) {
showAlert({ message: t(downloadFileError(error)), severity: 'error' })
}
} else {
const ids = files.map(f => f.id)
const driveId = files[0].driveId
return client.collection(DOCTYPE_FILES, { driveId }).downloadArchive(ids)
}
}
const isAlreadyInTrash = err => {
const reasons = err.reason !== undefined ? err.reason.errors : undefined
if (reasons) {
for (const reason of reasons) {
if (reason.detail === 'File or directory is already in the trash') {
return true
}
}
}
return false
}
/**
* trashFiles - Moves a set of files to the cozy trash
*
* @param {CozyClient} client
* @param {array} files One or more files to trash
*/
export const trashFiles = async (client, files, { showAlert, t, driveId }) => {
try {
for (const file of files) {
// TODO we should not go through a FileCollection to destroy the file, but
// only do client.destroy(), I do not know what it did not update the internal
// store correctly when I tried
const { data: updatedFile } = await client
.collection(DOCTYPE_FILES, { driveId })
.destroy(file)
client.store.dispatch(
receiveQueryResult(null, {
data: updatedFile
})
)
client.collection('io.cozy.permissions').revokeSharingLink(file)
}
showAlert({ message: t('alert.trash_file_success'), severity: 'success' })
} catch (err) {
if (!isAlreadyInTrash(err)) {
showAlert({ message: t('alert.try_again'), severity: 'error' })
}
}
}
export const restoreFiles = async (client, files) => {
for (const file of files) {
await client.collection(DOCTYPE_FILES).restore(file.id)
}
}
================================================
FILE: src/modules/actions/utils.spec.js
================================================
import { createMockClient } from 'cozy-client'
import { initQuery, receiveQueryResult } from 'cozy-client/dist/store'
import { trashFiles, downloadFiles } from './utils'
import { generateFile } from 'test/generate'
import { TRASH_DIR_ID } from '@/constants/config'
jest.mock('modules/navigation/AppRoute', () => ({
routes: []
}))
jest.mock('cozy-stack-client/dist/utils', () => ({
forceFileDownload: jest.fn()
}))
const showAlert = jest.fn()
const t = x => x
describe('trashFiles', () => {
const setup = () => {
const client = new createMockClient({})
const store = client.store
store.dispatch(
initQuery('files', {
doctype: 'io.cozy.files'
})
)
const file = generateFile({ i: 0 })
store.dispatch(
receiveQueryResult('files', {
data: file
})
)
return { client, store, file }
}
it('should destroy the file and update queries', async () => {
const { store, client, file } = setup()
const mockedDestroy = jest.fn()
client.collection = jest.fn(() => ({
destroy: mockedDestroy
}))
mockedDestroy.mockResolvedValue({
data: {
...file,
dir_id: TRASH_DIR_ID
}
})
const state = store.getState()
expect(state.cozy.documents['io.cozy.files'][file._id]._id).toEqual(
file._id
)
await trashFiles(client, [file], { showAlert, t })
expect(mockedDestroy).toHaveBeenCalledWith(file)
const state2 = store.getState()
const updatedFile = state2.cozy.documents['io.cozy.files'][file._id]
expect(updatedFile.dir_id).toEqual('io.cozy.files.trash-dir')
})
})
describe('downloadFiles', () => {
const mockClient = createMockClient({})
mockClient.stackClient.uri = 'http://cozy.tools'
const mockDownload = jest.fn()
const mockDownloadArchive = jest.fn()
beforeEach(() => {
mockClient.collection = () => ({
download: mockDownload,
downloadArchive: mockDownloadArchive
})
})
it('downloads a single file', async () => {
const file = {
id: 'file-id-1',
name: 'my-file.pdf',
type: 'file'
}
await downloadFiles(mockClient, [file])
expect(mockDownload).toHaveBeenCalledWith(file, null, file.name)
})
it('downloads a folder', async () => {
const folder = {
id: 'folder-id-1',
name: 'Classified',
type: 'directory'
}
await downloadFiles(mockClient, [folder])
expect(mockDownloadArchive).toHaveBeenCalledWith([folder.id])
})
it('downloads multiple files', async () => {
const files = [
{ id: 'file-id-1', name: 'my-file-1.pdf', type: 'file' },
{ id: 'file-id-2', name: 'my-file-2.pdf', type: 'file' }
]
await downloadFiles(mockClient, files)
expect(mockDownloadArchive).toHaveBeenCalledWith(['file-id-1', 'file-id-2'])
})
})
================================================
FILE: src/modules/actions/versions.jsx
================================================
import React, { forwardRef } from 'react'
import { isFile } from 'cozy-client/dist/models/file'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import HistoryIcon from 'cozy-ui/transpiled/react/Icons/History'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { navigateToModal } from '@/modules/actions/helpers'
const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
)
})
Component.displayName = 'Versions'
return Component
}
export const versions = ({ t, navigate, pathname }) => {
const label = t('SelectionBar.history')
const icon = HistoryIcon
return {
name: 'history',
label,
icon,
allowInfectedFiles: false,
displayCondition: selection => {
return selection.length === 1 && isFile(selection[0])
},
action: files => {
return navigateToModal({ navigate, pathname, files, path: 'revision' })
},
Component: makeComponent(label, icon)
}
}
================================================
FILE: src/modules/breadcrumb/components/Breadcrumb.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import RightIcon from 'cozy-ui/transpiled/react/Icons/Right'
import Spinner from 'cozy-ui/transpiled/react/Spinner'
import { useI18n } from 'twake-i18n'
import styles from '@/modules/breadcrumb/styles/breadcrumb.styl'
const Breadcrumb = ({
path,
onBreadcrumbClick,
opening,
inlined,
className = ''
}) => {
const { t } = useI18n()
const [deployed, setDeployed] = useState(false)
const wrapperRef = useRef(null)
const closeMenu = useCallback(() => {
setDeployed(false)
}, [setDeployed])
const openMenu = useCallback(() => {
setDeployed(true)
}, [setDeployed])
useEffect(() => {
function handleClickOutside(event) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
closeMenu()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [wrapperRef, closeMenu])
const toggleDeploy = () => (deployed ? closeMenu() : openMenu())
if (!path) return false
return (
{path.map((folder, index) => {
const folderName =
folder._id === 'io.cozy.files.shared-drives-dir'
? t('breadcrumb.title_shared_drives')
: folder.name
if (index < path.length - 1) {
return (
{
e.stopPropagation()
onBreadcrumbClick(folder)
}}
key={index}
>
{folderName}
)
} else {
return (
{
e.stopPropagation()
if (path.length >= 2) toggleDeploy()
}}
key={index}
>
{folderName}
{path.length >= 2 && (
)}
{opening && }
)
}
})}
)
}
Breadcrumb.propTypes = {
path: PropTypes.array,
onBreadcrumbClick: PropTypes.func,
opening: PropTypes.bool,
inlined: PropTypes.bool,
className: PropTypes.string
}
export default Breadcrumb
================================================
FILE: src/modules/breadcrumb/components/Breadcrumb.spec.jsx
================================================
import { fireEvent, render } from '@testing-library/react'
import React from 'react'
import Breadcrumb from './Breadcrumb'
import { TestI18n } from 'test/components/AppLike'
import { dummyBreadcrumbPathWithRootLarge } from 'test/dummies/dummyBreadcrumbPath'
describe('Breadcrumbs', () => {
const dummyPath = dummyBreadcrumbPathWithRootLarge()
const setup = ({ path, inlined, onBreadcrumbClick } = {}) => {
return render(
)
}
describe('template', () => {
it('should match snapshot', () => {
// When
const { container } = setup({ path: dummyPath })
// Then
expect(container).toMatchSnapshot()
})
it('should be empty while path is undefined', () => {
// When
const { container } = setup()
// Then
expect(container).toBeEmptyDOMElement()
})
it('should add inlined style while inlined prop true', () => {
// When
const { container } = setup({ path: dummyPath, inlined: true })
// Then
expect(container.querySelector('.inlined')).not.toBeEmptyDOMElement()
})
})
describe('events', () => {
it('should fire on breadcrumb click when link is clicked', () => {
// Given
const onBreadcrumbClick = jest.fn()
const { container } = setup({ path: dummyPath, onBreadcrumbClick })
// When
fireEvent.click(container.querySelector('.fil-path-link'))
// Then
expect(onBreadcrumbClick).toHaveBeenCalledWith({
id: 'io.cozy.files.root-dir',
name: 'Drive'
})
})
it('should toggle deploy on click on current', () => {
// Given
document.addEventListener = jest.fn()
// Given
const { container } = setup({ path: dummyPath })
// When
fireEvent.click(container.querySelector('.fil-path-current'))
// Then
expect(container.querySelector('.deployed')).toBeInTheDocument()
expect(document.addEventListener).toHaveBeenCalledWith(
'mousedown',
expect.any(Function)
)
})
it('should close menu', () => {
// Given
document.removeEventListener = jest.fn()
const { container } = setup({ path: dummyPath })
fireEvent.click(container.querySelector('.fil-path-current'))
expect(container.querySelector('.deployed')).toBeInTheDocument()
// When
fireEvent.click(container.querySelector('.fil-path-current'))
// Then
expect(container.querySelector('.deployed')).not.toBeInTheDocument()
})
})
})
================================================
FILE: src/modules/breadcrumb/components/DesktopBreadcrumb.jsx
================================================
import React, { useEffect, useMemo, useState } from 'react'
import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import BreadcrumbMui from 'cozy-ui/transpiled/react/Breadcrumbs'
import Icon from 'cozy-ui/transpiled/react/Icon'
import FileTypeSharedDriveIcon from 'cozy-ui/transpiled/react/Icons/FileTypeSharedDriveGrey'
import FolderIcon from 'cozy-ui/transpiled/react/Icons/Folder'
import RightIcon from 'cozy-ui/transpiled/react/Icons/Right'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useI18n } from 'twake-i18n'
import styles from '@/modules/breadcrumb/styles/breadcrumb.styl'
import { ROOT_DIR_ID } from '@/constants/config'
import { DesktopBreadcrumbItem } from '@/modules/breadcrumb/components/DesktopBreadcrumbItem'
const DesktopBreadcrumb = ({ onBreadcrumbClick, path }) => {
const { t } = useI18n()
const expandText = useMemo(() => t('breadcrumb.label'), [t])
const [dropdownTrigger, setDropdownTrigger] = useState(
document.querySelector(`[aria-label="${expandText}"]`)
)
const anchorElRef = useMemo(
() => ({ current: dropdownTrigger }),
[dropdownTrigger]
)
const [menuDisplayed, setMenuDisplayed] = useState(false)
const closeMenu = () => setMenuDisplayed(false)
const handleDropdownTriggerClick = e => {
e.stopPropagation()
setMenuDisplayed(true)
}
useEffect(() => {
closeMenu()
setDropdownTrigger(document.querySelector(`[aria-label="${expandText}"]`))
}, [path]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const trigger = anchorElRef.current
if (trigger) {
trigger.addEventListener('click', handleDropdownTriggerClick)
return () => {
closeMenu()
trigger.removeEventListener('click', handleDropdownTriggerClick)
}
}
}, [anchorElRef.current]) // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/refs
const Separator = (
)
// When we are in a shared drive, we want to display the shared drive icon
// in first position to reduce the number of displayed path elements
const pathToDisplay = useMemo(() => {
const sharedDriveIndex = path.findIndex(
item => item.id === 'io.cozy.files.shared-drives-dir'
)
if (sharedDriveIndex !== -1 && path.length > 2) {
return path.slice(sharedDriveIndex)
}
return path
}, [path])
return (
<>
{pathToDisplay.map((breadcrumbPath, index) => {
if (pathToDisplay.length > 1 && breadcrumbPath.id === ROOT_DIR_ID) {
return (
)
}
if (
index === 0 &&
breadcrumbPath.id === 'io.cozy.files.shared-drives-dir'
) {
return (
)
}
return (
)
})}
{menuDisplayed && (
{path.slice(1, -2).map(breadcrumbPath => (
{
e.stopPropagation()
onBreadcrumbClick(breadcrumbPath)
}}
>
))}
)}
>
)
}
export default DesktopBreadcrumb
================================================
FILE: src/modules/breadcrumb/components/DesktopBreadcrumb.spec.jsx
================================================
import { render, fireEvent, act } from '@testing-library/react'
import React from 'react'
import { BreakpointsProvider } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import DesktopBreadcrumb from './DesktopBreadcrumb'
import {
dummyBreadcrumbPathNoRootLarge,
dummyBreadcrumbPathNoRootSmall,
dummyBreadcrumbPathWithRootLarge,
dummyBreadcrumbPathWithRootSmall,
dummyBreadcrumbPathWithSharedDriveLarge,
dummyBreadcrumbPathWithSharedDriveSmall
} from 'test/dummies/dummyBreadcrumbPath'
jest.mock('cozy-ui/transpiled/react/ActionsMenu', () => ({ children }) => (
{children}
))
jest.mock(
'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem',
() =>
({ children }) => {children}
)
jest.mock('twake-i18n')
describe('DesktopBreadcrumb', () => {
beforeEach(() => {
useI18n.mockReturnValue({ t: () => 'Show path' })
})
describe('template', () => {
describe('When parent is ROOT folder', () => {
it('should display breadcrumb with | 📁 > "..." > parent > current | when more than 3 nested folders', () => {
// When
const { container, queryByText } = render(
)
// Then
expect(container.querySelector('[aria-label="Drive"]')).toBeTruthy()
expect(queryByText('grandparent')).toBeFalsy()
expect(queryByText('parent')).toBeTruthy()
expect(queryByText('current')).toBeTruthy()
expect(container.querySelector('[aria-label="Show path"]')).toBeTruthy()
expect(container.querySelector('.fil-path-separator')).toBeTruthy()
})
it('should display breadcrumb with | 📁 > parent > current | when 3 nested folders or less', () => {
// When
const { container, queryByText } = render(
)
// Then
expect(container.querySelector('[aria-label="Drive"]')).toBeTruthy()
expect(queryByText('grandparent')).toBeFalsy()
expect(queryByText('parent')).toBeTruthy()
expect(queryByText('current')).toBeTruthy()
expect(container.querySelector('[aria-label="Show path"]')).toBeFalsy()
expect(container.querySelector('.fil-path-separator')).toBeTruthy()
})
})
describe('When parent is a Shared drive', () => {
it('should display breadcrumb with | 📁 > "..." > parent > current | when more than 3 nested folders', () => {
// When
const { container, queryByText } = render(
)
// Then
expect(
container.querySelector('[aria-label="Shared Drive"]')
).toBeTruthy()
expect(queryByText('grandparent')).toBeFalsy()
expect(queryByText('parent')).toBeTruthy()
expect(queryByText('current')).toBeTruthy()
expect(container.querySelector('[aria-label="Show path"]')).toBeTruthy()
expect(container.querySelector('.fil-path-separator')).toBeTruthy()
})
it('should display breadcrumb with | 📁 > parent > current | when 3 nested folders or less', () => {
// When
const { container, queryByText } = render(
)
// Then
expect(
container.querySelector('[aria-label="Shared Drive"]')
).toBeTruthy()
expect(queryByText('grandparent')).toBeFalsy()
expect(queryByText('parent')).toBeTruthy()
expect(queryByText('current')).toBeTruthy()
expect(container.querySelector('[aria-label="Show path"]')).toBeFalsy()
expect(container.querySelector('.fil-path-separator')).toBeTruthy()
})
})
describe('When parent is nor ROOT nor Shared drive', () => {
it('should display breadcrumb with | Drive > "..." > parent > current | when more than 3 nested folders', () => {
// When
const { container, queryByText } = render(
)
// Then
expect(queryByText('Some Main Folder')).toBeTruthy()
expect(queryByText('grandparent')).toBeFalsy()
expect(queryByText('parent')).toBeTruthy()
expect(queryByText('current')).toBeTruthy()
expect(container.querySelector('[aria-label="Show path"]')).toBeTruthy()
expect(container.querySelector('.fil-path-separator')).toBeTruthy()
})
it('should display breadcrumb with | Drive > parent > current | when 3 nested folders or less', () => {
// When
const { container, queryByText } = render(
)
// Then
expect(queryByText('Some Main Folder')).toBeTruthy()
expect(queryByText('parent')).toBeTruthy()
expect(queryByText('current')).toBeTruthy()
expect(container.querySelector('[aria-label="Show path"]')).toBeFalsy()
expect(container.querySelector('.fil-path-separator')).toBeTruthy()
})
})
it('should have convenient style on Public view - on desktop', () => {
// When
const { container } = render(
)
// Then
expect(container.querySelector('.fil-path-backdrop')).toBeTruthy()
})
})
describe('mount', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
// eslint-disable-next-line no-console
console.error.mockRestore()
})
it('should hide menu displayed while navigating', async () => {
// Given
const { container, queryByTestId, rerender } = await render(
)
act(() => {
container.querySelector('[aria-label="Show path"]').click()
})
expect(queryByTestId('action-menu')).toBeInTheDocument()
// When
rerender( )
// Then
expect(queryByTestId('action-menu')).not.toBeInTheDocument()
})
it('should update dropdown trigger while navigating - on public page', async () => {
// Given
const { container, rerender } = await render(
)
expect(container.querySelector('[aria-label="Show path"]')).toBeNull()
rerender( )
// When
act(() => {
container.querySelector('[aria-label="Show path"]').click()
})
// Then
expect(container.querySelector('[aria-label="Show path"]')).not.toBeNull()
})
})
describe('events', () => {
it('should dispatch on breadcrumb click - on desktop', () => {
// Given
const onBreadcrumbClick = jest.fn()
const path = dummyBreadcrumbPathWithRootLarge()
const { queryByText } = render(
)
// When
queryByText('parent').click()
// Then
expect(onBreadcrumbClick).toHaveBeenCalledWith(path[2])
})
it('should display action menu on click on "..." on desktop', () => {
// Given
const path = dummyBreadcrumbPathWithRootLarge()
const { container, queryByTestId } = render(
)
// When
act(() => {
container.querySelector('[aria-label="Show path"]').click()
})
// Then
expect(queryByTestId('action-menu')).toBeInTheDocument()
expect(queryByTestId('action-menu-item')).toBeInTheDocument()
})
it('should add grandParents only in dropdown - on click on ... on desktop', () => {
// Given
const path = dummyBreadcrumbPathWithRootLarge()
const { container, queryByText } = render(
)
// When
act(() => {
container.querySelector('[aria-label="Show path"]').click()
})
// Then
expect(container.querySelectorAll('.MuiBreadcrumbs-li')[1]).not.toEqual(
'grandParents'
)
expect(queryByText('grandParent')).toBeInTheDocument()
})
it('should handle on click outside on desktop - removing dropdown', () => {
// Given
const path = dummyBreadcrumbPathWithRootLarge()
const { container, queryByText } = render(
)
// When
fireEvent.click(container.querySelector('button'))
// Then
expect(queryByText('grandParent')).not.toBeInTheDocument()
expect(container.querySelector('.dropdown')).not.toBeInTheDocument()
})
})
})
================================================
FILE: src/modules/breadcrumb/components/DesktopBreadcrumbItem.jsx
================================================
import classNames from 'classnames'
import React, { useCallback } from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconButton from 'cozy-ui/transpiled/react/IconButton'
import { useI18n } from 'twake-i18n'
import styles from '@/modules/breadcrumb/styles/breadcrumb.styl'
const DesktopBreadcrumbItem = ({ item, isCurrent, onClick, icon }) => {
const { t } = useI18n()
const handleClick = useCallback(
e => {
e.stopPropagation()
onClick(item)
},
[onClick, item]
)
const itemName =
item.id === 'io.cozy.files.shared-drives-dir'
? t('breadcrumb.title_shared_drives')
: item.name
return (
{icon ? (
) : (
itemName
)}
)
}
export { DesktopBreadcrumbItem }
================================================
FILE: src/modules/breadcrumb/components/MobileAwareBreadcrumb.jsx
================================================
import React from 'react'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import DesktopBreadcrumb from '@/modules/breadcrumb/components/DesktopBreadcrumb'
import MobileBreadcrumb from '@/modules/breadcrumb/components/MobileBreadcrumb'
export const MobileAwareBreadcrumb = props => {
const { isMobile } = useBreakpoints()
return isMobile ? (
) : (
)
}
export default MobileAwareBreadcrumb
================================================
FILE: src/modules/breadcrumb/components/MobileAwareBreadcrumb.spec.jsx
================================================
import { render } from '@testing-library/react'
import React from 'react'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import MobileAwareBreadcrumb from './MobileAwareBreadcrumb'
jest.mock('cozy-ui/transpiled/react/providers/Breakpoints')
jest.mock('modules/breadcrumb/components/DesktopBreadcrumb', () => () => (
))
jest.mock('modules/breadcrumb/components/MobileBreadcrumb', () => () => (
))
describe('MobileAwareBreadcrumb', () => {
it('should return mobile breadcrumb on mobile', () => {
// Given
useBreakpoints.mockReturnValue({ isMobile: true })
// When
const { getByTestId } = render( )
// Then
expect(getByTestId('mobile-breadcrumb')).toBeInTheDocument()
})
it('should return mobile breadcrumb on desktop', () => {
// Given
useBreakpoints.mockReturnValue({ isMobile: false })
// When
const { getByTestId } = render( )
// Then
expect(getByTestId('desktop-breadcrumb')).toBeInTheDocument()
})
})
================================================
FILE: src/modules/breadcrumb/components/MobileBreadcrumb.jsx
================================================
import React, { useCallback } from 'react'
import { BarCenter, BarLeft } from 'cozy-bar'
import BackButton from '@/components/Button/BackButton'
import Breadcrumb from '@/modules/breadcrumb/components/Breadcrumb'
const MobileBreadcrumb = ({ onBreadcrumbClick, path, ...props }) => {
const navigateBack = useCallback(() => {
const parentFolder = path[path.length - 2]
onBreadcrumbClick(parentFolder)
}, [onBreadcrumbClick, path])
return (
{path.length >= 2 && (
)}
)
}
export default MobileBreadcrumb
================================================
FILE: src/modules/breadcrumb/components/MobileBreadcrumb.spec.jsx
================================================
import { render, fireEvent } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import MobileBreadcrumb from './MobileBreadcrumb'
import AppLike from 'test/components/AppLike'
describe('MobileBreadcrumb', () => {
it('works', async () => {
const path = [
{ id: '1', name: 'root folder' },
{ id: '2', name: 'parent folder' },
{ id: '3', name: 'current folder' }
]
const onBreadcrumbClick = jest.fn()
const { findByText } = render(
)
// renders the path
const rootLink = await findByText('root folder')
await findByText('parent folder')
await findByText('current folder')
fireEvent.click(rootLink)
expect(onBreadcrumbClick).toHaveBeenCalledWith({
id: '1',
name: 'root folder'
})
const backButton = document.querySelector('button')
fireEvent.click(backButton)
expect(onBreadcrumbClick).toHaveBeenCalledWith({
id: '2',
name: 'parent folder'
})
})
})
================================================
FILE: src/modules/breadcrumb/components/__snapshots__/Breadcrumb.spec.jsx.snap
================================================
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Breadcrumbs template should match snapshot 1`] = `
Drive
grandParent
parent
current
`;
================================================
FILE: src/modules/breadcrumb/hooks/useBreadcrumbPath.jsx
================================================
import { useEffect, useState } from 'react'
import { useClient } from 'cozy-client'
import log from 'cozy-logger'
import { SHARED_DRIVES_DIR_ID } from '@/constants/config'
import { fetchFolder, useFolder } from '@/modules/breadcrumb/utils/fetchFolder'
/**
* @typedef {Object} BreadcrumbPath
* @property {string} name - The name of the folder.
* @property {string} id - The ID of the folder.
*/
/**
* Custom hook that retrieves the breadcrumb path for a given folder.
*
* @param {Object} options - The options for retrieving the breadcrumb path.
* @param {string} options.currentFolderId - The ID of the current folder.
* @param {BreadcrumbPath} options.rootBreadcrumbPath - The root breadcrumb path object.
* @param {string[]} [options.sharedDocumentIds] - The IDs of shared documents.
* @returns {BreadcrumbPath[]} - The breadcrumb path as an array of objects.
*/
export const useBreadcrumbPath = ({
currentFolderId,
rootBreadcrumbPath,
sharedDocumentIds,
driveId
}) => {
const client = useClient()
const [paths, setPaths] = useState([])
const folder = useFolder({ folderId: currentFolderId, driveId })
const folderAttributes = {
id: folder?.id,
name: folder?.name,
dirId: folder?.dir_id
}
useEffect(() => {
if (rootBreadcrumbPath && currentFolderId === rootBreadcrumbPath.id) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPaths([rootBreadcrumbPath])
return
}
if (!folderAttributes.id || !folderAttributes.name) {
// Optionally set loading state or clear paths
setPaths([])
return
}
const hasAccessToSharedDocument = id => {
if (!sharedDocumentIds) return true
return !sharedDocumentIds.includes(id)
}
let isSubscribed = true
const returnedPaths = [
{ name: folderAttributes.name, id: folderAttributes.id }
]
const shouldContinueLoop = id => {
return (
!!id && id !== rootBreadcrumbPath?.id && id !== SHARED_DRIVES_DIR_ID
)
}
const processFolder = async id => {
const folder = await fetchFolder({ client, driveId, folderId: id })
if (!folder) return undefined
returnedPaths.unshift({ name: folder.name, id: folder.id })
return hasAccessToSharedDocument(folder.id) ? folder.dir_id : undefined
}
const shouldAddRootPath = () => {
return (
rootBreadcrumbPath?.name !== 'Public' &&
returnedPaths[0]?.id !== rootBreadcrumbPath.id
)
}
const handleBreadcrumbError = error => {
if (rootBreadcrumbPath?.name === 'Public') {
if (isSubscribed) {
setPaths(returnedPaths)
}
} else {
if (isSubscribed && rootBreadcrumbPath) {
setPaths([rootBreadcrumbPath])
}
log(
'error',
`Error while fetching folder for breadcrumbs of folder id: ${folderAttributes.id}, here is the error: ${error}`
)
}
}
const fetchBreadcrumbs = async () => {
let id = folderAttributes.dirId
while (shouldContinueLoop(id)) {
id = await processFolder(id)
}
if (isSubscribed) {
if (shouldAddRootPath()) {
returnedPaths.unshift(rootBreadcrumbPath)
}
setPaths(returnedPaths)
}
}
fetchBreadcrumbs().catch(handleBreadcrumbError)
return () => {
isSubscribed = false
}
}, [
client,
sharedDocumentIds,
rootBreadcrumbPath,
driveId,
folderAttributes.id,
folderAttributes.name,
folderAttributes.dirId,
currentFolderId
])
return paths
}
================================================
FILE: src/modules/breadcrumb/hooks/useBreadcrumbPath.spec.jsx
================================================
import { act, renderHook } from '@testing-library/react'
import { useClient } from 'cozy-client'
import log from 'cozy-logger'
import { useBreadcrumbPath } from './useBreadcrumbPath'
import {
dummyBreadcrumbPathWithRootLarge,
dummyRootBreadcrumbPath
} from 'test/dummies/dummyBreadcrumbPath'
import { fetchFolder, useFolder } from '@/modules/breadcrumb/utils/fetchFolder'
jest.mock('cozy-logger')
jest.mock('cozy-client')
jest.mock('modules/breadcrumb/utils/fetchFolder')
describe('useBreadcrumbPath', () => {
const rootBreadcrumbPath = dummyRootBreadcrumbPath()
const createFolder = ({ id, name, dirId }) => ({
id,
name,
dir_id: dirId
})
beforeEach(() => {
jest.resetAllMocks()
useFolder.mockReturnValue(null)
})
it('should get useClient from cozy-client', () => {
// When
renderHook(() => useBreadcrumbPath({}))
// Then
expect(useClient).toHaveBeenCalledWith()
})
it('should return only Drive link when id undefined', async () => {
useFolder.mockReturnValue(
createFolder({
id: rootBreadcrumbPath.id,
name: rootBreadcrumbPath.name,
dirId: undefined
})
)
// When
const { result } = await renderHook(() =>
useBreadcrumbPath({ rootBreadcrumbPath })
)
// Then
expect(result.current).toEqual([rootBreadcrumbPath])
})
it('should return only Drive link when id is root_breadcrumb_path id', async () => {
useFolder.mockReturnValue(
createFolder({
id: rootBreadcrumbPath.id,
name: rootBreadcrumbPath.name,
dirId: undefined
})
)
// When
let render
await act(async () => {
render = await renderHook(() =>
useBreadcrumbPath({
rootBreadcrumbPath,
currentFolderId: rootBreadcrumbPath.id
})
)
})
// Then
expect(render.result.current).toEqual([dummyRootBreadcrumbPath()])
})
it('should return only rootBreadcrumbPath when currentFolderId equals rootBreadcrumbPath.id (early return)', async () => {
const someFolderId = 'some-folder-id'
useFolder.mockReturnValue(
createFolder({
id: someFolderId,
name: 'Some Folder',
dirId: rootBreadcrumbPath.id
})
)
let render
await act(async () => {
render = await renderHook(() =>
useBreadcrumbPath({
rootBreadcrumbPath,
currentFolderId: rootBreadcrumbPath.id
})
)
})
expect(render.result.current).toEqual([rootBreadcrumbPath])
expect(fetchFolder).not.toHaveBeenCalled()
})
it('should call fetch folder', async () => {
// Given
const currentFolderId = '1234'
useClient.mockReturnValue('cozy-client')
const parentFolderId = 'parentFolderId'
useFolder.mockReturnValue(
createFolder({
id: currentFolderId,
name: 'current',
dirId: parentFolderId
})
)
fetchFolder.mockReturnValueOnce({ dir_id: rootBreadcrumbPath.id })
// When
await act(async () => {
await renderHook(() =>
useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })
)
})
// Then
expect(fetchFolder).toHaveBeenCalledWith({
client: 'cozy-client',
folderId: parentFolderId
})
})
it('should log error when fetchFolder rejects error', async () => {
// Given
const currentFolderId = '1234'
useClient.mockReturnValue('cozy-client')
fetchFolder.mockRejectedValue('error')
const parentFolderId = 'parentFolderId'
useFolder.mockReturnValue(
createFolder({
id: currentFolderId,
name: 'current',
dirId: parentFolderId
})
)
// When
let render
await act(async () => {
render = await renderHook(() =>
useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })
)
})
// Then
expect(render.result.current).toEqual([rootBreadcrumbPath])
expect(log).toHaveBeenCalledWith(
'error',
'Error while fetching folder for breadcrumbs of folder id: 1234, here is the error: error'
)
})
it('should not loop when fetchFolder returns undefined', async () => {
// Given
const currentFolderId = '1234'
useClient.mockReturnValue('cozy-client')
fetchFolder.mockReturnValueOnce(undefined)
const parentFolderId = 'parentFolderId'
useFolder.mockReturnValue(
createFolder({
id: currentFolderId,
name: 'current',
dirId: parentFolderId
})
)
// When
await act(async () => {
await renderHook(() =>
useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })
)
})
// Then
expect(fetchFolder).toHaveBeenCalledTimes(1)
})
it('should fetch several folder until rootBreadcrumbPath.id', async () => {
// Given
const currentFolderId = 'currentFolderId'
const parentFolderId = 'parentFolderId'
const grandParentFolderId = 'grandParentFolderId'
useClient.mockReturnValue('cozy-client')
useFolder.mockReturnValue(
createFolder({
id: currentFolderId,
name: 'current',
dirId: parentFolderId
})
)
fetchFolder.mockReturnValueOnce({
id: parentFolderId,
name: 'parent',
dir_id: grandParentFolderId
})
fetchFolder.mockReturnValueOnce({
id: grandParentFolderId,
name: 'grandParent',
dir_id: rootBreadcrumbPath.id
})
// When
let render
await act(async () => {
render = await renderHook(() =>
useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })
)
})
// Then
expect(fetchFolder).toHaveBeenCalledTimes(2)
expect(fetchFolder).toHaveBeenCalledWith({
client: 'cozy-client',
folderId: parentFolderId
})
expect(fetchFolder).toHaveBeenNthCalledWith(2, {
client: 'cozy-client',
folderId: grandParentFolderId
})
expect(render.result.current).toEqual(dummyBreadcrumbPathWithRootLarge())
})
it('should not call fetch folder, on rerender', async () => {
// Given
const currentFolderId = '1234'
useClient.mockReturnValue('cozy-client')
fetchFolder.mockReturnValueOnce({ dir_id: rootBreadcrumbPath.id })
const parentFolderId = 'parentFolderId'
useFolder.mockReturnValue(
createFolder({
id: currentFolderId,
name: 'current',
dirId: parentFolderId
})
)
// When
let render
await act(async () => {
render = await renderHook(() =>
useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })
)
})
expect(fetchFolder).toHaveBeenCalledTimes(1)
render.rerender()
// Then
expect(fetchFolder).toHaveBeenCalledTimes(1)
})
it('should not add rootBreadcrumbPath when name undefined on PublicView', async () => {
// Given
const publicViewRootBreadcrumbPath = {
id: rootBreadcrumbPath.id,
name: 'Public'
}
const currentFolderId = 'currentFolderId'
useClient.mockReturnValue('cozy-client')
useFolder.mockReturnValue(
createFolder({
id: currentFolderId,
name: 'current',
dirId: publicViewRootBreadcrumbPath.id
})
)
// When
let render
await act(async () => {
render = await renderHook(() =>
useBreadcrumbPath({
rootBreadcrumbPath: publicViewRootBreadcrumbPath,
currentFolderId
})
)
})
// Then
expect(render.result.current).toEqual([
{ id: 'currentFolderId', name: 'current' }
])
})
it('should fetch folder until first shared documents on SharingView', async () => {
// Given
const currentFolderId = 'currentFolderId'
const parentFolderId = 'parentFolderId'
const notSharedFolderId = 'notSharedFolderId'
const sharedDocumentIds = [parentFolderId, 'another-id']
useClient.mockReturnValue('cozy-client')
useFolder.mockReturnValue(
createFolder({
id: currentFolderId,
name: 'current',
dirId: parentFolderId
})
)
fetchFolder.mockReturnValueOnce({
id: parentFolderId,
name: 'parent',
dir_id: notSharedFolderId
})
const sharingsViewRootBreadcrumbPath = {
id: rootBreadcrumbPath.id,
name: 'Sharings'
}
// When
let render
await act(async () => {
render = await renderHook(() =>
useBreadcrumbPath({
rootBreadcrumbPath: sharingsViewRootBreadcrumbPath,
currentFolderId,
sharedDocumentIds
})
)
})
// Then
expect(render.result.current).toEqual([
sharingsViewRootBreadcrumbPath,
{ id: 'parentFolderId', name: 'parent' },
{ id: 'currentFolderId', name: 'current' }
])
expect(fetchFolder).toHaveBeenCalledTimes(1)
expect(fetchFolder).toHaveBeenCalledWith({
client: 'cozy-client',
folderId: parentFolderId
})
})
it('should stop at the first shared document even when current is shared', async () => {
// Given
const currentFolderId = 'currentFolderId'
const parentFolderId = 'parentFolderId'
const notSharedFolderId = 'notSharedFolderId'
const sharedDocumentIds = [parentFolderId, currentFolderId, 'another-id']
useClient.mockReturnValue('cozy-client')
useFolder.mockReturnValue(
createFolder({
id: currentFolderId,
name: 'current',
dirId: parentFolderId
})
)
fetchFolder.mockReturnValueOnce({
id: parentFolderId,
name: 'parent',
dir_id: notSharedFolderId
})
const sharingsViewRootBreadcrumbPath = {
id: rootBreadcrumbPath.id,
name: 'Sharings'
}
// When
let render
await act(async () => {
render = await renderHook(() =>
useBreadcrumbPath({
rootBreadcrumbPath: sharingsViewRootBreadcrumbPath,
currentFolderId,
sharedDocumentIds
})
)
})
// Then
expect(render.result.current).toEqual([
sharingsViewRootBreadcrumbPath,
{ id: 'parentFolderId', name: 'parent' },
{ id: 'currentFolderId', name: 'current' }
])
expect(fetchFolder).toHaveBeenCalledTimes(1)
expect(fetchFolder).toHaveBeenCalledWith({
client: 'cozy-client',
folderId: parentFolderId
})
})
})
================================================
FILE: src/modules/breadcrumb/styles/breadcrumb.styl
================================================
@require 'components/popover.styl'
@require 'settings/breakpoints.styl'
@require 'settings/z-index.styl'
@require '../../../styles/coz-bar-size.styl'
.fil-path-backdrop
flex 1 1 auto
width 1%
min-width 0
&:not([override])
margin-right 2rem
ol
flex-wrap nowrap
min-width 0
overflow hidden
text-overflow ellipsis
li
&:last-child
min-width 0
.fil-path-title
margin 0
font-size 1.5rem
overflow hidden
white-space nowrap
text-overflow ellipsis
display block
.fil-path-link
display inline-flex
align-items baseline
font-weight normal
color var(--actionColorActive)
text-decoration none
cursor pointer
.fil-path-separator
margin 0 .25rem
&:hover
color var(--primaryTextColor)
.fil-path-down
display none
min-width .875rem
height .625rem
margin-left .4375rem
border 0
background embedurl('../../../assets/icons/icon-arrow-down.svg') center center no-repeat
.fil-path-current-name
text-overflow ellipsis
overflow hidden
white-space nowrap
color var(--primaryTextColor)
font-weight bold
+small-screen() // @stylint ignore
.fil-path-backdrop
min-width 0
width auto
.fil-path-title
display flex
flex-direction column-reverse
font-size 1.3rem
.fil-path-link
.fil-path-current
display flex
align-items center
box-sizing border-box
height $coz-bar-size
padding 0 .25rem
.fil-path-down
display inline-block
.fil-path-link
.fil-path-separator
display none
.fil-path-backdrop.deployed
margin 0
position fixed
top 0
right 0
bottom 0
left 0
z-index $overlay-index
&.inlined
position absolute
.fil-path-title
z-index $popover-index
box-shadow 0 .0625rem 0 0 var(--actionColorDisabled), 0 .375rem 1.5rem 0 rgba(50, 54, 63, .24)
.fil-path-link
.fil-path-current
padding-left 'calc(%s + .25rem)' % $coz-bar-size
.fil-path-link
display flex
color var(--primaryTextColor)
background var(--paperBackgroundColor)
.fil-path-link-name
text-overflow ellipsis
overflow hidden
white-space nowrap
.fil-path-current
height $coz-bar-size
box-shadow inset 0 -.0625rem 0 0 var(--actionColorDisabled)
padding-right 2.35rem
.fil-path-backdrop.mobile
left 0
================================================
FILE: src/modules/breadcrumb/utils/fetchFolder.js
================================================
import { useQuery } from 'cozy-client'
import {
buildFileOrFolderByIdQuery,
buildSharedDriveFolderQuery
} from '@/queries'
export const fetchFolder = async ({ client, folderId, driveId }) => {
const folderQuery = driveId
? buildSharedDriveFolderQuery({ driveId, folderId })
: buildFileOrFolderByIdQuery(folderId)
const { options, definition } = folderQuery
const folderQueryResults = await client.fetchQueryAndGetFromState({
definition: definition(),
options
})
return folderQueryResults.data
}
/**
* Hook to fetch a folder from cozy stack
*
* @param {Object} params - The parameters for the function.
* @param {string} params.folderId - The ID of the folder to fetch.
* @param {string} [params.driveId] - The ID of the shared drive to fetch the folder from.
* @returns {import('cozy-client/types/types').IOCozyFolder} The folder data.
*/
export const useFolder = ({ folderId, driveId }) => {
const folderQuery = driveId
? buildSharedDriveFolderQuery({ driveId, folderId })
: buildFileOrFolderByIdQuery(folderId)
const { options, definition } = folderQuery
const folderQueryResults = useQuery(definition, options)
return folderQueryResults.data
}
================================================
FILE: src/modules/breadcrumb/utils/fetchFolder.spec.js
================================================
import { fetchFolder } from './fetchFolder'
import { buildFileOrFolderByIdQuery } from '@/queries'
jest.mock('queries')
describe('fetchFolder', () => {
const folderReturnedByCozyClient = 'folder'
const result = { data: folderReturnedByCozyClient }
const client = {
fetchQueryAndGetFromState: jest.fn().mockReturnValue(result)
}
const folderId = '1234'
const definition = jest.fn().mockReturnValue('definition')
beforeEach(() => {
buildFileOrFolderByIdQuery.mockReturnValue({
definition: definition,
options: 'options'
})
})
it('should return answer from fetchQueryAndGetFromState', async () => {
// When
const folder = await fetchFolder({ client, folderId })
// Then
expect(folder).toEqual(folderReturnedByCozyClient)
})
it('should call fetchQueryAndGetFromState with correct definition and options', async () => {
// When
await fetchFolder({ client, folderId })
// Then
expect(definition).toHaveBeenCalledWith()
expect(client.fetchQueryAndGetFromState).toHaveBeenCalledWith({
definition: 'definition',
options: 'options'
})
})
})
================================================
FILE: src/modules/certifications/CertificationTooltip.jsx
================================================
import React from 'react'
import Tooltip from 'cozy-ui/transpiled/react/Tooltip'
import Typography from 'cozy-ui/transpiled/react/Typography'
const CertificationTooltip = ({ body, caption, content }) => {
return (
{body}
{caption}
>
}
>
{content}
)
}
export default CertificationTooltip
================================================
FILE: src/modules/certifications/index.jsx
================================================
import PropTypes from 'prop-types'
import {
CarbonCopy as CarbonCopyCell,
ElectronicSafe as ElectronicSafeCell
} from '@/modules/filelist/cells'
import {
CarbonCopy as CarbonCopyHeader,
ElectronicSafe as ElectronicSafeHeader
} from '@/modules/filelist/headers'
export const extraColumnsSpecs = {
carbonCopy: {
query: ({ queryBuilder, currentFolderId, sharedDocumentIds, attribute }) =>
queryBuilder({ currentFolderId, sharedDocumentIds, attribute }),
condition: ({ conditionBuilder, files, attribute }) =>
conditionBuilder({ files, attribute }),
label: 'carbonCopy',
HeaderComponent: CarbonCopyHeader,
CellComponent: CarbonCopyCell
},
electronicSafe: {
query: ({ queryBuilder, currentFolderId, sharedDocumentIds, attribute }) =>
queryBuilder({ currentFolderId, sharedDocumentIds, attribute }),
condition: ({ conditionBuilder, files, attribute }) =>
conditionBuilder({ files, attribute }),
label: 'electronicSafe',
HeaderComponent: ElectronicSafeHeader,
CellComponent: ElectronicSafeCell
}
}
const extraColumnPropTypes = PropTypes.shape({
query: PropTypes.func,
condition: PropTypes.func,
label: PropTypes.string,
HeaderComponent: PropTypes.func,
CellComponent: PropTypes.func
})
export const extraColumnsPropTypes = PropTypes.arrayOf(extraColumnPropTypes)
/**
* Returns the columns names according to the media
* @param {object} params - Params
* @param {boolean} params.isMobile - Whether the breakpoint is mobile
* @param {string[]} params.mobileExtraColumnsNames - Names of the columns to be shown in mobile
* @param {string[]} params.desktopExtraColumnsNames - Names of the columns to be shown in desktop
* @returns {string[]} Names of the columns
*/
export const makeExtraColumnsNamesFromMedia = ({
isMobile,
mobileExtraColumnsNames,
desktopExtraColumnsNames
}) => (isMobile ? mobileExtraColumnsNames : desktopExtraColumnsNames)
================================================
FILE: src/modules/certifications/useExtraColumns.jsx
================================================
import { useEffect, useMemo } from 'react'
import { useClient } from 'cozy-client'
import { extraColumnsSpecs } from '@/modules/certifications/'
/**
* @typedef {object} ExtraColumn
* @property {function} query - The query function.
* @property {function} condition - The condition function.
* @property {string} label - The label of the column.
* @property {function} HeaderComponent - The header component.
* @property {function} CellComponent - The cell component.
*/
// TODO: some ways to improve:
// instead of passing currentFolderId, sharedDocumentIds (related to the query)
// and files (related to the condition), maybe we could pass
// the query/condition with its parameters
/**
* Custom hook that adds extra columns to a table based on the provided configuration.
*
* @param {object} options - The options for configuring the extra columns.
* @param {string[]} [options.columnsNames] - The names of the columns to add.
* @param {function} [options.queryBuilder] - The query builder for fetching data.
* @param {function} [options.conditionBuilder] - The condition builder for filtering data.
* @param {string} [options.currentFolderId] - The ID of the current folder.
* @param {string[]} [options.sharedDocumentIds] - The IDs of the shared documents.
* @param {object[]} [options.files] - The files to display in the table.
* @returns {object[]} - The extra columns to add to the table.
*/
export const useExtraColumns = ({
columnsNames,
queryBuilder,
conditionBuilder,
currentFolderId,
sharedDocumentIds,
files
}) => {
const client = useClient()
const columnsSpecs = useMemo(
() => columnsNames.map(columnName => extraColumnsSpecs[columnName]),
[columnsNames]
)
useEffect(() => {
if (!queryBuilder) {
return
}
for (let columnSpec of columnsSpecs) {
if (!columnSpec.query) {
continue
}
const opts = {
queryBuilder,
currentFolderId,
sharedDocumentIds,
attribute: columnSpec.label
}
const def = columnSpec.query(opts).definition()
client.query(def, columnSpec.query(opts).options)
}
}, [client, columnsSpecs, currentFolderId, sharedDocumentIds, queryBuilder])
return columnsSpecs.filter(columnSpec => {
if (conditionBuilder) {
const opts = {
conditionBuilder,
files,
attribute: columnSpec.label
}
return columnSpec.condition(opts)
} else if (queryBuilder) {
const opts = {
queryBuilder,
currentFolderId,
sharedDocumentIds,
attribute: columnSpec.label
}
const { fetchStatus, data } = client.getQueryFromState(
columnSpec.query(opts).options.as
)
return fetchStatus === 'loaded' && data.length > 0
} else {
throw new Error(
'useExtraColumns must have queryBuilder or conditionBuilder'
)
}
})
}
================================================
FILE: src/modules/certifications/useExtraColumns.spec.jsx
================================================
import { renderHook } from '@testing-library/react'
import React from 'react'
import { createMockClient, models } from 'cozy-client'
import { useExtraColumns } from './useExtraColumns'
import AppLike from 'test/components/AppLike'
const client = createMockClient({})
client.query = jest.fn()
const setup = ({ columnsNames, queryBuilder, conditionBuilder, files }) => {
const wrapper = ({ children }) => (
{children}
)
return renderHook(
() =>
useExtraColumns({
columnsNames,
queryBuilder,
conditionBuilder,
currentFolderId: '123',
sharedDocumentIds: '456',
files
}),
{
wrapper
}
)
}
describe('useExtraColumns', () => {
it('should return error if no queryBuilder or conditionBuilder passed', () => {
jest.spyOn(console, 'error').mockImplementation()
expect(() => setup({ columnsNames: ['carbonCopy'] })).toThrow(
'useExtraColumns must have queryBuilder or conditionBuilder'
)
})
})
describe('useExtraColumns : queryBuilder', () => {
it('should not query anything if no queryBuilder passed', () => {
jest.spyOn(console, 'error').mockImplementation()
expect(() => setup({ columnsNames: ['carbonCopy'] })).toThrow()
expect(client.query).not.toHaveBeenCalled()
})
it('should execute query if queryBuilder passed', async () => {
setup({
columnsNames: ['carbonCopy'],
queryBuilder: () => ({
definition: () => 'queryDefinition',
options: 'queryOptions'
})
})
expect(client.query).toHaveBeenCalled()
})
it('should return carbonCopy column if the query result returns at least one file', async () => {
// mock returned value for query checking if at least one file as carbonCopy metadata
client.getQueryFromState = jest.fn(() => ({
fetchStatus: 'loaded',
data: [{ id: '01', metadata: { carbonCopy: true } }]
}))
const { result } = setup({
columnsNames: ['carbonCopy'],
queryBuilder: () => ({
definition: () => 'queryDefinition',
options: 'queryOptions'
})
})
expect(
result.current.some(extraColumn => extraColumn.label === 'carbonCopy')
).toBeTruthy()
})
})
describe('useExtraColumns : conditionBuilder', () => {
const conditionBuilder = ({ files, attribute }) =>
files.some(file => models.file.hasMetadataAttribute({ file, attribute }))
it('should return empty array if no files', async () => {
const { result } = setup({
columnsNames: ['carbonCopy'],
conditionBuilder,
files: []
})
expect(result.current).toMatchObject([])
})
it('should return empty array if no columns names', async () => {
const { result } = setup({
columnsNames: [],
conditionBuilder,
files: [{ id: '01' }]
})
expect(result.current).toMatchObject([])
})
it('should return empty array if no files with matching metadata', async () => {
const { result } = setup({
columnsNames: ['carbonCopy'],
conditionBuilder,
files: [{ id: '01' }]
})
expect(result.current).toMatchObject([])
})
it('should return carbonCopy column if at least one file has carbonCopy metadata', async () => {
const { result } = setup({
columnsNames: ['carbonCopy'],
conditionBuilder,
files: [{ id: '01', metadata: { carbonCopy: true } }]
})
expect(
result.current.some(extraColumn => extraColumn.label === 'carbonCopy')
).toBeTruthy()
})
it('should not return carbonCopy column if this column is not wanted, even if a file has carbonCopy metadata', async () => {
const { result } = setup({
columnsNames: ['electronicSafe'],
conditionBuilder,
files: [{ id: '01', metadata: { carbonCopy: true } }]
})
expect(result.current).toMatchObject([])
})
})
================================================
FILE: src/modules/drive/AddMenu/AddMenu.jsx
================================================
import React from 'react'
import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'
import AddMenuContent from '@/modules/drive/AddMenu/AddMenuContent'
const AddMenu = ({
anchorRef,
handleClose,
isUploadDisabled,
canCreateFolder,
canUpload,
refreshFolderContent,
isPublic,
displayedFolder,
isReadOnly,
...actionMenuProps
}) => {
return (
)
}
export default AddMenu
================================================
FILE: src/modules/drive/AddMenu/AddMenuContent.jsx
================================================
import React, { forwardRef } from 'react'
import flag from 'cozy-flags'
import ActionsMenuMobileHeader from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuMobileHeader'
import Divider from 'cozy-ui/transpiled/react/Divider'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import AddFolderItem from '@/modules/drive/Toolbar/components/AddFolderItem'
import CreateDocsItem from '@/modules/drive/Toolbar/components/CreateDocsItem'
import CreateNoteItem from '@/modules/drive/Toolbar/components/CreateNoteItem'
import CreateOnlyOfficeItem from '@/modules/drive/Toolbar/components/CreateOnlyOfficeItem'
import CreateShortcut from '@/modules/drive/Toolbar/components/CreateShortcut'
import { ScannerMenuItem } from '@/modules/drive/Toolbar/components/Scanner/ScannerMenuItem'
import { useScannerContext } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'
import UploadItem from '@/modules/drive/Toolbar/components/UploadItem'
import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'
import { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider'
import { isOfficeEditingEnabled } from '@/modules/views/OnlyOffice/helpers'
const AddMenuContent = forwardRef(
(
{
isUploadDisabled,
canCreateFolder,
canUpload,
refreshFolderContent,
isPublic,
displayedFolder,
onClick,
isReadOnly
},
ref // eslint-disable-line no-unused-vars
) => {
const { t } = useI18n()
const { isDesktop } = useBreakpoints()
const { hasScanner } = useScannerContext()
const { showAlert } = useAlert()
const handleReadOnlyClick = e => {
e.stopPropagation()
e.preventDefault()
showAlert(
t(
'AddMenu.readOnlyFolder',
'This is a read-only folder. You cannot perform this action.'
),
'warning'
)
onClick()
}
const createActionOnClick = isReadOnly ? handleReadOnlyClick : onClick
return (
<>
{canCreateFolder && (
)}
{!isPublic && (
)}
{!isPublic && flag('drive.lasuitedocs.enabled') && (
)}
{canUpload && isOfficeEditingEnabled(isDesktop) && (
<>
>
)}
{!isFromSharedDriveRecipient(displayedFolder) && (
)}
{canUpload && !isUploadDisabled && (
)}
{hasScanner && }
>
)
}
)
AddMenuContent.displayName = 'AddMenuContent'
export default AddMenuContent
================================================
FILE: src/modules/drive/AddMenu/AddMenuContent.spec.jsx
================================================
import { render, waitFor } from '@testing-library/react'
import React from 'react'
import { useAppLinkWithStoreFallback } from 'cozy-client'
import AddMenuContent from './AddMenuContent'
import AppLike from 'test/components/AppLike'
import { setupFolderContent, mockCozyClientRequestQuery } from 'test/setup'
import { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'
jest.mock('cozy-client/dist/hooks/useAppLinkWithStoreFallback', () => jest.fn())
jest.mock('cozy-keys-lib', () => ({
useVaultClient: jest.fn()
}))
mockCozyClientRequestQuery()
const setup = async (
{ folderId = 'directory-foobar0' } = {},
{
isUploadDisabled = false,
canCreateFolder = false,
canUpload = true,
refreshFolderContent = true,
isPublic = false,
isReadOnly = false
} = {}
) => {
const { client, store } = await setupFolderContent({
folderId
})
const displayedFolder = folderId ? { id: folderId } : folderId
client.stackClient.uri = 'http://cozy.localhost'
const root = render(
{}}
isReadOnly={isReadOnly}
/>
)
return { root }
}
describe('AddMenuContent', () => {
describe('Menu', () => {
beforeAll(() => {
useAppLinkWithStoreFallback.mockReturnValue({
fetchStatus: 'loaded',
isInstalled: true
})
})
it('does not display createNote on public Page', async () => {
await waitFor(async () => {
const { root } = await setup(
{ folderId: 'directory-foobar0' },
{ isPublic: true }
)
const { queryByText } = root
expect(queryByText('Note')).toBeNull()
})
})
it('displays createNote on private Page', async () => {
await waitFor(async () => {
const { root } = await setup(
{ folderId: 'directory-foobar0' },
{ isPublic: false }
)
const { queryByText } = root
expect(queryByText('Note')).toBeTruthy()
})
})
})
})
================================================
FILE: src/modules/drive/AddMenu/AddMenuProvider.jsx
================================================
import React, {
useState,
useCallback,
useRef,
useMemo,
createContext
} from 'react'
import useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import logger from '@/lib/logger'
import AddMenu from '@/modules/drive/AddMenu/AddMenu'
import {
closeMenu,
toggleMenu
} from '@/modules/drive/Toolbar/components/MoreMenu'
import { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'
export const AddMenuContext = createContext()
const AddMenuProvider = ({
disabled,
canCreateFolder,
canUpload,
refreshFolderContent,
children,
isPublic,
displayedFolder,
isSelectionBarVisible,
componentsProps,
isReadOnly
}) => {
const [menuIsVisible, setMenuVisible] = useState(false)
const isOffline = useBrowserOffline()
const anchorRef = useRef()
const { showAlert } = useAlert()
const { t } = useI18n()
const handleClose = useCallback(
() => closeMenu(setMenuVisible),
[setMenuVisible]
)
const handleToggle = useCallback(
() => toggleMenu(menuIsVisible, setMenuVisible),
[menuIsVisible, setMenuVisible]
)
const isDisabled = useMemo(
() => disabled || isSelectionBarVisible,
[disabled, isSelectionBarVisible]
)
const handleOfflineClick = useCallback(
e => {
e.stopPropagation()
showAlert({ message: t('alert.offline'), severity: 'error' })
logger.error(
`Offline click on AddMenu button detected. Here is the value of window.navigator.onLine: ${window.navigator.onLine}`
)
},
[showAlert, t]
)
return (
{children}
{menuIsVisible && (
)}
)
}
export default React.memo(AddMenuProvider)
================================================
FILE: src/modules/drive/AddMenu/AddMenuProvider.spec.jsx
================================================
import { fireEvent, render } from '@testing-library/react'
import React, { useContext } from 'react'
import { createMockClient } from 'cozy-client'
import AddMenuProvider, { AddMenuContext } from './AddMenuProvider'
import AppLike from 'test/components/AppLike'
import logger from '@/lib/logger'
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn()
}))
jest.mock('lib/logger', () => ({
error: jest.fn()
}))
const client = createMockClient({})
describe('AddMenuContext', () => {
it('should log exception on click offline on add button', () => {
// Given
const Component = () => {
const { handleOfflineClick } = useContext(AddMenuContext)
return
}
const { container, getByTestId } = render(
)
// When
fireEvent.click(getByTestId('button'))
fireEvent.click(container)
// Then
expect(logger.error).toHaveBeenCalledWith(
'Offline click on AddMenu button detected. Here is the value of window.navigator.onLine: true'
)
})
})
================================================
FILE: src/modules/drive/DeleteConfirm.jsx
================================================
import React, { useCallback, useEffect, useState } from 'react'
import { useClient } from 'cozy-client'
import { splitFilename } from 'cozy-client/dist/models/file'
import { SharedDocument, SharedRecipientsList } from 'cozy-sharing'
import Button from 'cozy-ui/transpiled/react/Buttons'
import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Icon from 'cozy-ui/transpiled/react/Icon'
import Stack from 'cozy-ui/transpiled/react/Stack'
import Typography from 'cozy-ui/transpiled/react/Typography'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { useSelectionContext } from '../selection/SelectionProvider'
import { DOCTYPE_ALBUMS } from '@/lib/doctypes'
import { getEntriesTypeTranslated } from '@/lib/entries'
import { trashFiles } from '@/modules/actions/utils'
import { buildAlbumByIdQuery } from '@/queries'
const Message = ({ type, fileCount }) => {
const icon =
type === 'referenced' ? 'album' : type.includes('share') ? 'people' : type
const { t } = useI18n()
return (
{t(`DeleteConfirm.${type}`, fileCount)}
)
}
export const DeleteConfirm = ({
files,
afterConfirmation,
onClose,
children,
driveId
}) => {
const { t } = useI18n()
const { showAlert } = useAlert()
const fileCount = files.length
const client = useClient()
const [isDeleting, setDeleting] = useState(false)
const [isReferencedByManualAlbum, setIsReferencedByManualAlbum] =
useState(false)
const { setSelectedItems } = useSelectionContext()
useEffect(() => {
const fetchAlbums = async () => {
const albumIdsFromFiles = files.flatMap(file =>
(
(file &&
file.relationships &&
file.relationships.referenced_by &&
file.relationships.referenced_by.data) ||
[]
)
.filter(reference => reference.type === DOCTYPE_ALBUMS)
.map(reference => reference.id)
)
const albums = await Promise.all(
albumIdsFromFiles.map(albumId => {
const albumByIdQuery = buildAlbumByIdQuery(albumId)
return client.fetchQueryAndGetFromState({
definition: albumByIdQuery.definition(),
options: albumByIdQuery.options
})
})
)
setIsReferencedByManualAlbum(
!!albums.filter(album => album && album.data && !album.data.auto).length
)
}
fetchAlbums()
}, [client, files])
const onDelete = useCallback(async () => {
// Prevent double executions
if (isDeleting) return
setDeleting(true)
showAlert({ message: t('alert.trash_file_processing'), severity: 'info' })
onClose()
await trashFiles(client, files, { showAlert, t, driveId })
afterConfirmation()
setSelectedItems(prevSelectedItems => {
const fileIdsToRemove = files.map(file => file.id)
return Object.fromEntries(
Object.entries(prevSelectedItems).filter(
([id]) => !fileIdsToRemove.includes(id)
)
)
})
}, [
client,
files,
afterConfirmation,
onClose,
showAlert,
t,
setSelectedItems,
driveId,
isDeleting
])
const entriesType = getEntriesTypeTranslated(t, files)
return (
{t('DeleteConfirm.title', {
filename: splitFilename(files[0]).filename,
smart_count: fileCount,
type: entriesType
})}
}
content={
{isReferencedByManualAlbum && (
)}
{children}
}
actions={
<>
>
}
/>
)
}
const DeleteConfirmWithSharingContext = ({ files, ...rest }) =>
files.length !== 1 ? (
) : (
{({ isSharedByMe, link, recipients }) => {
const statuses = recipients
.map(recipient => recipient.status)
.filter(status => status !== 'owner')
const isStatusesEqual = statuses.reduce((acc, current) => {
return acc && current === statuses[0]
}, true)
let shareMessageType = !isStatusesEqual
? 'share_both'
: statuses[0] === 'ready'
? 'share_accepted'
: 'share_waiting'
return (
{isSharedByMe && link ? (
) : null}
{isSharedByMe && statuses.length > 0 ? (
) : null}
{isSharedByMe && recipients.length > 0 ? (
) : null}
)
}}
)
export default DeleteConfirmWithSharingContext
================================================
FILE: src/modules/drive/DeleteConfirm.spec.jsx
================================================
import { render, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import { DeleteConfirm } from './DeleteConfirm'
import AppLike from 'test/components/AppLike'
import { generateFile } from 'test/generate'
import { trashFiles } from '@/modules/actions/utils'
const setSelectedItems = jest.fn()
jest.mock('modules/selection/SelectionProvider', () => ({
...jest.requireActual('modules/selection/SelectionProvider'),
useSelectionContext: () => ({
setSelectedItems
})
}))
jest.mock('modules/actions/utils', () => ({
trashFiles: jest.fn().mockResolvedValue({})
}))
describe('DeleteConfirm', () => {
const setup = files => {
const client = createMockClient({})
const afterConfirmation = jest.fn()
const onClose = jest.fn()
const renderResult = render(
)
return { client, afterConfirmation, onClose, ...renderResult }
}
it('tests the component', async () => {
const files = [generateFile({ i: '10', type: 'file' })]
const { client, afterConfirmation, onClose, getByText } = setup(files)
expect(getByText('Delete foobar10?')).toBeTruthy()
const confirmButton = getByText('Remove')
fireEvent.click(confirmButton)
expect(trashFiles).toHaveBeenCalledWith(
client,
files,
expect.objectContaining({})
)
waitFor(() => {
expect(afterConfirmation).toHaveBeenCalled()
expect(setSelectedItems).toHaveLength(0)
expect(onClose).toHaveBeenCalled()
})
})
it('removes only the deletes file from selection', async () => {
const files = Array.from({ length: 10 }, (_, i) =>
generateFile({ i: i + 1, type: 'file' })
)
const selectedItems = {
[files[0].id]: files[0],
[files[1].id]: files[1],
[files[2].id]: files[2]
}
const fileToDelete = [files[4]]
const { client, afterConfirmation, onClose, getByText } =
setup(fileToDelete)
expect(getByText('Delete foobar5?')).toBeTruthy()
fireEvent.click(getByText('Remove'))
expect(trashFiles).toHaveBeenCalledWith(
client,
fileToDelete,
expect.objectContaining({})
)
waitFor(() => {
expect(afterConfirmation).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
expect(setSelectedItems).toHaveBeenCalledWith(expect.any(Function))
const updateFn = setSelectedItems.mock.calls[0][0]
const result = updateFn(selectedItems)
expect(result).toEqual(selectedItems)
})
})
})
================================================
FILE: src/modules/drive/FabWithAddMenuContext.jsx
================================================
import React, { useContext } from 'react'
import { ExtendableFab } from 'cozy-ui/transpiled/react/Fab'
import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'
import { useI18n } from 'twake-i18n'
import { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider'
import { useFabStyles } from '@/modules/drive/helpers'
const FabWithAddMenuContext = ({ noSidebar }) => {
const { t } = useI18n()
const {
anchorRef,
handleToggle,
isDisabled,
handleOfflineClick,
isOffline,
a11y
} = useContext(AddMenuContext)
const styles = useFabStyles({
bottom: noSidebar ? '1rem' : 'calc(var(--sidebarHeight) + 2rem)'
})
return (
)
}
export default React.memo(FabWithAddMenuContext)
================================================
FILE: src/modules/drive/RenameInput.jsx
================================================
import React from 'react'
import { connect } from 'react-redux'
import { useClient } from 'cozy-client'
import useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { abortRenaming } from './rename'
import { CozyFile } from '@/models'
import FilenameInput from '@/modules/filelist/FilenameInput'
// If we set the _rev then CozyClient tries to update. Else
// it tries to create
export const updateFileNameQuery = async (client, file, newName) => {
return client.collection('io.cozy.files', { driveId: file.driveId }).update({
...file,
name: newName,
_rev: file._rev || file.meta.rev
})
}
export const RenameInput = ({
onAbort,
file,
refreshFolderContent,
className,
style,
withoutExtension
}) => {
const client = useClient()
const { showAlert } = useAlert()
const { t } = useI18n()
const { filename, extension } = CozyFile.splitFilename(file)
const name = withoutExtension ? filename : file.name
const isOffline = useBrowserOffline()
return (
{
const newName = withoutExtension ? newValue + extension : newValue
try {
if (isOffline) {
showAlert({ message: t('alert.offline'), severity: 'error' })
} else {
await updateFileNameQuery(client, file, newName)
if (refreshFolderContent) refreshFolderContent()
}
} catch (error) {
if (
error.message.includes(
'NetworkError when attempting to fetch resource.'
)
) {
showAlert({ message: t('upload.alert.network'), severity: 'error' })
} else if (
error.message.includes(
'Invalid filename containing illegal character(s):'
)
) {
showAlert({
message: t('alert.file_name_illegal_characters', {
fileName: newName,
characters: error.message.split(
'Invalid filename containing illegal character(s): '
)[1]
}),
severity: 'error',
duration: 2000
})
} else if (error.message.includes('Invalid filename:')) {
showAlert({
message: t('alert.file_name_illegal_name', { fileName: newName }),
severity: 'error'
})
} else if (error.message.includes('Missing name argument')) {
showAlert({
message: t('alert.file_name_missing'),
severity: 'error'
})
} else {
showAlert({
message: t('alert.file_name', { fileName: newName }),
severity: 'error'
})
}
} finally {
onAbort()
}
}}
onAbort={onAbort}
/>
)
}
const mapDispatchToProps = dispatch => ({
onAbort: () => dispatch(abortRenaming())
})
export default connect(null, mapDispatchToProps)(RenameInput)
================================================
FILE: src/modules/drive/RenameInput.spec.jsx
================================================
import { render, fireEvent, screen, waitFor, act } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { RenameInput } from './RenameInput'
import AppLike from 'test/components/AppLike'
import { generateFile } from 'test/generate'
const showAlert = jest.fn()
jest.mock('cozy-ui/transpiled/react/hooks/useBrowserOffline')
jest.mock('cozy-ui/transpiled/react/providers/Alert', () => ({
...jest.requireActual('cozy-ui/transpiled/react/providers/Alert'),
__esModule: true,
useAlert: jest.fn()
}))
describe('RenameInput', () => {
let client
let onAbort
let file
let mockCollection
let mockSharingsCollection
beforeEach(() => {
jest.resetAllMocks()
mockCollection = {
update: jest.fn()
}
mockSharingsCollection = {
renameSharedDrive: jest.fn()
}
client = {
...createMockClient({}),
collection: jest
.fn()
.mockImplementation(name =>
name === 'io.cozy.sharings' ? mockSharingsCollection : mockCollection
)
}
onAbort = jest.fn()
// Default file without driveId for backward compatibility
file = {
...generateFile({ i: '10', type: 'file' }),
meta: { rev: '1' },
_id: 'file123',
_type: 'io.cozy.files'
// No driveId by default for backward compatibility
}
useAlert.mockReturnValue({ showAlert })
})
const setup = ({ file }) => {
return render(
)
}
it('tests the component', async () => {
const { getByText } = setup({ file })
const inputNode = document.getElementsByTagName('input')[0]
fireEvent.change(inputNode, { target: { value: 'new Name.pdf' } })
expect(inputNode.value).toBe('new Name.pdf')
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
// For backward compatibility, don't expect driveId in the collection call
expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {})
expect(mockCollection.update).toHaveBeenCalledWith(
expect.objectContaining({
name: 'new Name.pdf',
_rev: '1'
})
)
await waitFor(() => expect(onAbort).toHaveBeenCalled())
// Check the Modal to inform that we're changing the file extension
fireEvent.change(inputNode, { target: { value: 'new Name.txt' } })
expect(inputNode.value).toBe('new Name.txt')
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
await waitFor(() => screen.getByRole('dialog'))
fireEvent.click(getByText('Continue'))
// For backward compatibility, don't expect driveId in the collection call
expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {})
expect(mockCollection.update).toHaveBeenCalledWith(
expect.objectContaining({
name: 'new Name.txt',
_rev: '1'
})
)
await waitFor(() => expect(onAbort).toHaveBeenCalled())
})
it('works without meta rev', async () => {
// Test with file that doesn't have meta.rev but has _rev
const fileWithoutMetaRev = {
...file,
_rev: '2',
meta: {}
}
setup({ file: fileWithoutMetaRev })
const inputNode = document.getElementsByTagName('input')[0]
await act(async () => {
fireEvent.change(inputNode, { target: { value: 'new Name.pdf' } })
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
})
// For backward compatibility, don't expect driveId in the collection call
expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {})
expect(mockCollection.update).toHaveBeenCalledWith(
expect.objectContaining({
name: 'new Name.pdf',
_rev: '2'
})
)
})
it('works with driveId', async () => {
// Test with explicit driveId
const fileWithDriveId = {
...file,
driveId: 'special-drive-123',
_rev: '3',
meta: { rev: '3' }
}
setup({ file: fileWithDriveId })
const inputNode = document.getElementsByTagName('input')[0]
await act(async () => {
fireEvent.change(inputNode, { target: { value: 'drive-file.pdf' } })
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
})
// Should include the driveId in the collection options
expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {
driveId: 'special-drive-123'
})
// Should include the file in the update with the correct _rev
expect(mockCollection.update).toHaveBeenCalledWith(
expect.objectContaining({
name: 'drive-file.pdf',
_rev: '3',
driveId: 'special-drive-123'
})
)
})
it('should alert error on illegal characters', async () => {
setup({ file })
const inputNode = document.getElementsByTagName('input')[0]
mockCollection.update.mockRejectedValueOnce({
message: 'Invalid filename containing illegal character(s): /'
})
fireEvent.change(inputNode, { target: { value: 'new/Name.pdf' } })
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error', duration: 2000 })
)
})
})
it('should alert error on incorrect file name', async () => {
setup({ file })
const inputNode = document.getElementsByTagName('input')[0]
mockCollection.update.mockRejectedValueOnce({
message: 'Invalid filename: .'
})
fireEvent.change(inputNode, { target: { value: '..pdf' } })
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
it('should alert error on missing file name', async () => {
setup({ file })
const inputNode = document.getElementsByTagName('input')[0]
mockCollection.update.mockRejectedValueOnce({
message: 'Missing name argument'
})
fireEvent.change(inputNode, { target: { value: ' .pdf' } })
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
it('should alert network error when detected by useBrowserOffline', async () => {
useBrowserOffline.mockReturnValue(true)
setup({ file })
const inputNode = document.getElementsByTagName('input')[0]
fireEvent.change(inputNode, { target: { value: ' .pdf' } })
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
it('should alert network error when not detected by useBrowserOffline', async () => {
setup({ file })
const inputNode = document.getElementsByTagName('input')[0]
mockCollection.update.mockRejectedValueOnce({
message: 'NetworkError when attempting to fetch resource.'
})
fireEvent.change(inputNode, { target: { value: ' .pdf' } })
fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
})
================================================
FILE: src/modules/drive/Toolbar/components/AddButton.jsx
================================================
import React, { useContext } from 'react'
import Button from 'cozy-ui/transpiled/react/Buttons'
import Icon from 'cozy-ui/transpiled/react/Icon'
import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'
import { useI18n } from 'twake-i18n'
import { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider'
export const AddButton = ({ className }) => {
const { t } = useI18n()
const {
anchorRef,
handleToggle,
isDisabled,
handleOfflineClick,
isOffline,
a11y
} = useContext(AddMenuContext)
return (
}
label={t('toolbar.menu_create')}
onClick={handleToggle}
{...a11y}
/>
)
}
export default React.memo(AddButton)
================================================
FILE: src/modules/drive/Toolbar/components/AddFolderItem.jsx
================================================
import React from 'react'
import { connect } from 'react-redux'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { showNewFolderInput } from '@/modules/filelist/duck'
const AddFolderItem = ({ addFolder, onClick, isReadOnly }) => {
const { t } = useI18n()
const { showAlert } = useAlert()
const handleClick = () => {
if (isReadOnly) {
showAlert({
message: t(
'AddMenu.readOnlyFolder',
'This is a read-only folder. You cannot perform this action.'
),
severity: 'warning'
})
onClick()
return
}
addFolder()
onClick()
}
return (
)
}
const mapDispatchToProps = dispatch => ({
addFolder: () =>
setTimeout(() => {
dispatch(showNewFolderInput())
}, 0)
})
export default connect(null, mapDispatchToProps)(AddFolderItem)
================================================
FILE: src/modules/drive/Toolbar/components/AddMenuItem.jsx
================================================
import React, { useContext } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useI18n } from 'twake-i18n'
import { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider'
const AddMenuItem = ({ onClick }) => {
const { t } = useI18n()
const {
anchorRef,
handleToggle,
isDisabled,
handleOfflineClick,
isOffline,
a11y
} = useContext(AddMenuContext)
const handleClick = () => {
isOffline ? handleOfflineClick() : handleToggle()
onClick()
}
return (
} />
)
}
export default AddMenuItem
================================================
FILE: src/modules/drive/Toolbar/components/CreateDocsItem.jsx
================================================
import get from 'lodash/get'
import React from 'react'
import { useClient, generateWebLink, useCapabilities } from 'cozy-client'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import IconDocs from '@/assets/icons/icon-docs.svg'
import { displayedFolderOrRootFolder } from '@/hooks/helpers'
const CreateDocsItem = ({ displayedFolder, isReadOnly, onClick }) => {
const client = useClient()
const { t } = useI18n()
const { capabilities } = useCapabilities(client)
const isFlatDomain = get(capabilities, 'flat_subdomains')
const { showAlert } = useAlert()
const _displayedFolder = displayedFolderOrRootFolder(displayedFolder)
const handleClick = async () => {
if (isReadOnly) {
showAlert({
message: t(
'AddMenu.readOnlyFolder',
'This is a read-only folder. You cannot perform this action.'
),
severity: 'warning'
})
onClick()
return
}
const url = generateWebLink({
slug: 'docs',
cozyUrl: client.getStackClient().uri,
subDomainType: isFlatDomain ? 'flat' : 'nested',
pathname: '',
hash: `/bridge/docs/new/${_displayedFolder._id}`
})
window.location.href = url
}
return (
)
}
export default CreateDocsItem
================================================
FILE: src/modules/drive/Toolbar/components/CreateNoteItem.jsx
================================================
import get from 'lodash/get'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import {
withClient,
generateWebLink,
models,
useAppLinkWithStoreFallback,
useCapabilities
} from 'cozy-client'
import { isFlagshipApp } from 'cozy-device-helper'
import flag from 'cozy-flags'
import { useWebviewIntent } from 'cozy-intent'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconNote from 'cozy-ui/transpiled/react/Icons/FileTypeNote'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { generateUniversalLink } from 'cozy-ui-plus/dist/AppLinker/native'
import { translate } from 'twake-i18n'
import { displayedFolderOrRootFolder } from '@/hooks/helpers'
const CreateNoteItem = ({
client,
t,
displayedFolder,
isReadOnly,
onClick
}) => {
const { capabilities } = useCapabilities(client)
const isFlatDomain = get(capabilities, 'flat_subdomains')
const webviewIntent = useWebviewIntent()
const { showAlert } = useAlert()
const navigate = useNavigate()
const _displayedFolder = displayedFolderOrRootFolder(displayedFolder)
const { driveId, id: folderId } = _displayedFolder
const { fetchStatus, url, isInstalled } = useAppLinkWithStoreFallback(
'notes',
client
)
if (fetchStatus !== 'loaded' || !isInstalled) {
return null
}
const notesAppUrl = url
let returnUrl = ''
if (
(isFlagshipApp() && webviewIntent) ||
flag('cozy.universal-link.disabled')
) {
returnUrl = generateWebLink({
slug: 'drive',
cozyUrl: client.getStackClient().uri,
subDomainType: isFlatDomain ? 'flat' : 'nested',
pathname: '',
hash: `/files/${folderId}`
})
} else {
returnUrl = generateUniversalLink({
slug: 'drive',
cozyUrl: client.getStackClient().uri,
subDomainType: isFlatDomain ? 'flat' : 'nested',
nativePath: driveId
? `/shareddrive/${driveId}/files/${folderId}`
: `/files/${folderId}`
})
}
const handleClick = async () => {
if (isReadOnly) {
showAlert({
message: t(
'AddMenu.readOnlyFolder',
'This is a read-only folder. You cannot perform this action.'
),
severity: 'warning'
})
onClick()
return
}
if (notesAppUrl === undefined) return
const { data: file } = await client
.collection('io.cozy.notes', { driveId })
.create({
dir_id: folderId
})
if (driveId) {
navigate(`/note/${driveId}/${file.id}`)
return
}
const privateUrl = await models.note.generatePrivateUrl(notesAppUrl, file, {
returnUrl
})
/**
* Not using AppLinker here because it would require too much refactoring and would be risky
* Instead we use the webviewIntent programmatically to open the cozy-note app on the note href
*/
if (isFlagshipApp() && webviewIntent)
return webviewIntent.call('openApp', privateUrl, { slug: 'notes' })
window.location.href = privateUrl
}
return (
)
}
export default translate()(withClient(CreateNoteItem))
================================================
FILE: src/modules/drive/Toolbar/components/CreateOnlyOfficeItem.jsx
================================================
import React, { useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config'
import {
makeOnlyOfficeIconByClass,
canWriteOfficeDocument
} from '@/modules/views/OnlyOffice/helpers'
const CreateOnlyOfficeItem = ({ fileClass, isReadOnly, onClick }) => {
const { folderId = ROOT_DIR_ID, driveId = undefined } = useParams()
const { t } = useI18n()
const navigate = useNavigate()
const { showAlert } = useAlert()
const _folderId = folderId === TRASH_DIR_ID ? ROOT_DIR_ID : folderId
const handleClick = useCallback(() => {
if (isReadOnly) {
showAlert({
message: t(
'AddMenu.readOnlyFolder',
'This is a read-only folder. You cannot perform this action.'
),
severity: 'warning'
})
onClick()
return
}
if (canWriteOfficeDocument()) {
navigate(
driveId
? `/onlyoffice/create/${driveId}/${_folderId}/${fileClass}`
: `/onlyoffice/create/${_folderId}/${fileClass}`
)
} else {
navigate(
driveId
? `/onlyoffice/${driveId}/${_folderId}/paywall`
: `/folder/${_folderId}/paywall`
)
}
}, [
isReadOnly,
showAlert,
t,
onClick,
navigate,
driveId,
_folderId,
fileClass
])
const ClassIcon = useMemo(
() => makeOnlyOfficeIconByClass(fileClass),
[fileClass]
)
return (
)
}
export default React.memo(CreateOnlyOfficeItem)
================================================
FILE: src/modules/drive/Toolbar/components/CreateShortcut.jsx
================================================
import React from 'react'
import { connect } from 'react-redux'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import DeviceBrowserIcon from 'cozy-ui/transpiled/react/Icons/DeviceBrowser'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import ShortcutCreationModal from './ShortcutCreationModal'
import { showModal } from '@/lib/react-cozy-helpers'
const CreateShortcutWrapper = ({ openModal, onClick, isReadOnly }) => {
const { t } = useI18n()
const { showAlert } = useAlert()
const handleClick = () => {
if (isReadOnly) {
showAlert({
message: t(
'AddMenu.readOnlyFolder',
'This is a read-only folder. You cannot perform this action.'
),
severity: 'warning'
})
onClick()
return
}
openModal()
onClick()
}
return (
)
}
const mapDispatchToProps = (dispatch, ownProps) => ({
openModal: () =>
dispatch(
showModal( )
)
})
export default connect(null, mapDispatchToProps)(CreateShortcutWrapper)
================================================
FILE: src/modules/drive/Toolbar/components/DownloadButtonItem.jsx
================================================
import React from 'react'
import { useClient } from 'cozy-client'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { downloadFiles } from '@/modules/actions/utils'
const DownloadButtonItem = ({ files }) => {
const { showAlert } = useAlert()
const { t } = useI18n()
const client = useClient()
const handleClick = () => {
downloadFiles(client, files, { showAlert, t })
}
return (
)
}
export default DownloadButtonItem
================================================
FILE: src/modules/drive/Toolbar/components/FavoritesItem.jsx
================================================
import React from 'react'
import { useClient } from 'cozy-client'
import { splitFilename } from 'cozy-client/dist/models/file'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import StarIcon from 'cozy-ui/transpiled/react/Icons/Star'
import StarOutlineIcon from 'cozy-ui/transpiled/react/Icons/StarOutline'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
const FavoritesItem = ({ displayedFolder }) => {
const { showAlert } = useAlert()
const { t } = useI18n()
const client = useClient()
const isFavorite = displayedFolder?.cozyMetadata?.favorite
const labelKey = isFavorite ? 'remove' : 'add'
const handleClick = async () => {
if (!displayedFolder) return
try {
await client.save({
...displayedFolder,
cozyMetadata: {
...displayedFolder.cozyMetadata,
favorite: !isFavorite
}
})
const { filename } = splitFilename(displayedFolder)
showAlert({
message: t(`favorites.success.${labelKey}`, {
filename,
smart_count: 1
}),
severity: 'success'
})
} catch (_error) {
showAlert({ message: t('favorites.error'), severity: 'error' })
}
}
const icon = isFavorite ? StarIcon : StarOutlineIcon
return (
)
}
export default FavoritesItem
================================================
FILE: src/modules/drive/Toolbar/components/InsideRegularFolder.jsx
================================================
import { ROOT_DIR_ID } from '@/constants/config'
/**
* Displays its children only if we are in a normal folder (eg. not the root folder or a special view like sharings or recent)
*/
const InsideRegularFolder = ({ children, displayedFolder, folderId }) => {
const insideRegularFolder =
folderId && displayedFolder && displayedFolder.id !== ROOT_DIR_ID
if (insideRegularFolder) {
return children
}
return null
}
export default InsideRegularFolder
================================================
FILE: src/modules/drive/Toolbar/components/InsideRegularFolder.spec.jsx
================================================
import { render } from '@testing-library/react'
import React from 'react'
import InsideRegularFolder from './InsideRegularFolder'
jest.mock('hooks')
describe('InsideRegularFolder', () => {
it('should return null when insideRegularFolder undefined', () => {
const { container } = render(
)
expect(container).toBeEmptyDOMElement()
})
it('should return children when insideRegularFolder true', () => {
const { container } = render(
)
expect(container).not.toBeEmptyDOMElement()
})
})
================================================
FILE: src/modules/drive/Toolbar/components/LeaveSharedDriveButtonItem.jsx
================================================
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useClient } from 'cozy-client'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import LogoutIcon from 'cozy-ui/transpiled/react/Icons/Logout'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { getSharingIdFromRelationships } from '@/modules/shareddrives/helpers'
const LeaveSharedDriveButtonItem = ({ files }) => {
const { t } = useI18n()
const client = useClient()
const navigate = useNavigate()
const { showAlert } = useAlert()
const handleClick = async () => {
const file = files[0]
const sharingId = getSharingIdFromRelationships(file)
if (sharingId) {
await client.collection('io.cozy.sharings').revokeSelf({ _id: sharingId })
showAlert({
message: t('Files.share.revokeSelf.success'),
severity: 'success'
})
navigate('/sharings')
}
}
return (
)
}
export default LeaveSharedDriveButtonItem
================================================
FILE: src/modules/drive/Toolbar/components/MoreMenu.jsx
================================================
import React, { useState, useCallback, useRef } from 'react'
import { useSharingContext } from 'cozy-sharing'
import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'
import Divider from 'cozy-ui/transpiled/react/Divider'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { MoreButton } from '@/components/Button'
import AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'
import AddMenuItem from '@/modules/drive/Toolbar/components/AddMenuItem'
import DownloadButtonItem from '@/modules/drive/Toolbar/components/DownloadButtonItem'
import FavoritesItem from '@/modules/drive/Toolbar/components/FavoritesItem'
import InsideRegularFolder from '@/modules/drive/Toolbar/components/InsideRegularFolder'
import LeaveSharedDriveButtonItem from '@/modules/drive/Toolbar/components/LeaveSharedDriveButtonItem'
import DeleteItem from '@/modules/drive/Toolbar/delete/DeleteItem'
import MoveItem from '@/modules/drive/Toolbar/move/MoveItem'
import PersonalizeFolderItem from '@/modules/drive/Toolbar/personalizeFolder/PersonalizeFolderItem'
import SelectableItem from '@/modules/drive/Toolbar/selectable/SelectableItem'
import ShareItem from '@/modules/drive/Toolbar/share/ShareItem'
export const openMenu = setMenuVisible => {
setMenuVisible(true)
}
export const closeMenu = setMenuVisible => {
setMenuVisible(false)
}
export const toggleMenu = (menuIsVisible, setMenuVisible) => {
if (menuIsVisible) return closeMenu(setMenuVisible)
openMenu(setMenuVisible)
}
const MoreMenu = ({
isDisabled,
hasWriteAccess,
canUpload,
canCreateFolder,
displayedFolder,
folderId,
showSelectionBar,
isSelectionBarVisible,
isSharedWithMe,
isSharedDriveRecipient
}) => {
const [menuIsVisible, setMenuVisible] = useState(false)
const anchorRef = useRef()
const { isMobile } = useBreakpoints()
const { allLoaded } = useSharingContext() // We need to wait for the sharing context to be completely loaded to avoid race conditions
const handleToggle = useCallback(
() => toggleMenu(menuIsVisible, setMenuVisible),
[menuIsVisible, setMenuVisible]
)
const handleClose = useCallback(
() => closeMenu(setMenuVisible),
[setMenuVisible]
)
return (
{menuIsVisible && (
{allLoaded && (
)}
{!isSharedDriveRecipient && (
)}
{isMobile && hasWriteAccess && }
{hasWriteAccess && !isSharedDriveRecipient && (
)}
{hasWriteAccess && (
)}
{isSharedDriveRecipient && isSharedWithMe && (
)}
)}
)
}
export default React.memo(MoreMenu)
================================================
FILE: src/modules/drive/Toolbar/components/MoreMenu.spec.jsx
================================================
import { render, fireEvent } from '@testing-library/react'
import React from 'react'
import MoreMenu from './MoreMenu'
import AppLike from 'test/components/AppLike'
import { setupFolderContent, mockCozyClientRequestQuery } from 'test/setup'
import { downloadFiles } from '@/modules/actions/utils'
jest.mock('modules/actions/utils', () => ({
downloadFiles: jest.fn().mockResolvedValue()
}))
mockCozyClientRequestQuery()
describe('MoreMenu', () => {
const setup = async ({ folderId = 'directory-foobar0' } = {}) => {
const { client, store } = await setupFolderContent({
folderId
})
client.stackClient.uri = 'http://cozy.tools'
const result = render(
)
const { getByTestId } = result
fireEvent.click(getByTestId('more-button'))
return { ...result, store, client }
}
describe('DownloadButton', () => {
it('download files', async () => {
// TODO: remove it when DeleteItem get props
jest.spyOn(console, 'error').mockImplementation()
// TODO : Fix https://github.com/cozy/cozy-drive/issues/2913
jest.spyOn(console, 'warn').mockImplementation()
const { getByText } = await setup()
fireEvent.click(getByText('Download folder'))
expect(downloadFiles).toHaveBeenCalled()
})
})
})
================================================
FILE: src/modules/drive/Toolbar/components/Scanner/Scanner.spec.tsx
================================================
import { render, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import { useWebviewIntent } from 'cozy-intent'
// @ts-expect-error Component is not typed
import AppLike from 'test/components/AppLike'
import { ScannerMenuItem } from '@/modules/drive/Toolbar/components/Scanner/ScannerMenuItem'
import { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'
import { uploadFiles } from '@/modules/navigation/duck'
const MockApp = ({ id = 'test', onClick = jest.fn() }): JSX.Element => (
)
jest.mock('cozy-device-helper', () => ({
...jest.requireActual('cozy-device-helper'),
isFlagshipApp: (): boolean => true
}))
const mockUseWebviewIntent = useWebviewIntent as jest.Mock
jest.mock('cozy-intent', () => ({
useWebviewIntent: jest.fn()
}))
const mockUploadFiles = uploadFiles as jest.Mock
jest.mock('modules/navigation/duck', () => ({
uploadFiles: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
.mockImplementation(arg => ({ type: 'test', payload: arg }))
}))
jest.spyOn(console, 'log').mockImplementation(() => jest.fn())
// Test suite for the Scanner functionality
describe('Scanner', () => {
// Before each test, clear all mocks to ensure a clean state
beforeEach(() => {
jest.clearAllMocks()
})
// Test case: Ensure that nothing is rendered if the scanner is not available
it('renders nothing if the scanner is not available', () => {
// Mock the useWebviewIntent hook to always return false for scanner availability
mockUseWebviewIntent.mockReturnValue({
call: jest.fn().mockResolvedValue(false)
})
// Render the component under test
const { queryByTestId } = render( )
// Assert that the scan-doc element is not present in the DOM
expect(queryByTestId('scan-doc')).toBeNull()
})
// Test case: Check if an ActionMenuItem is rendered when the scanner is available
it('renders an ActionMenuItem if the folder is available', async () => {
// Mock the useWebviewIntent hook to simulate scanner availability
mockUseWebviewIntent.mockReturnValue({
call: jest.fn((method, arg) => {
if (method === 'isAvailable' && arg === 'scanner') {
return Promise.resolve(true)
}
return Promise.resolve(false)
})
})
// Render the component under test
const { queryByTestId } = render( )
// Wait for the scanner to become available and assert that the scan-doc element is present
await waitFor(() => {
expect(queryByTestId('scan-doc')).not.toBeNull()
})
})
// Test case: Simulate a click event and verify the startScanner function is called
it('calls the startScanner function on click', async () => {
// Mock the useWebviewIntent hook with custom logic for scanner availability and document scanning
mockUseWebviewIntent.mockReturnValue({
call: jest.fn((method, arg) => {
if (method === 'isAvailable' && arg === 'scanner') {
return Promise.resolve(true)
}
if (method === 'scanDocument') {
return Promise.resolve('base64jpeg')
}
return Promise.resolve(false)
})
})
const onClickMock = jest.fn()
// Render the component under test
const { queryByTestId } = render( )
// Wait for the scan-doc element to be clickable and then simulate a click event
await waitFor(() => {
queryByTestId('scan-doc') as HTMLButtonElement
fireEvent.click(
queryByTestId('scan-doc')?.firstChild as HTMLButtonElement
)
})
// Create a mock File object
const mockFile = new File([], 'testfile')
// Assert that mockUploadFiles was called once with the expected arguments
expect(mockUploadFiles).toHaveBeenCalledTimes(1)
const calls = mockUploadFiles.mock.calls as unknown[][]
expect(onClickMock).toHaveBeenCalledTimes(1)
expect(calls[0][0]).toEqual([mockFile]) // File
expect(calls[0][1]).toBe('test') // Directory ID
expect(calls[0][2]).toEqual({ isScanned: true }) // Upload options
expect(typeof calls[0][3]).toBe('function') // Success callback
// Dependencies
expect(calls[0][4]).toMatchObject({
client: expect.anything() as Record,
t: expect.anything() as (key: string) => string
})
})
// Test case: Handle unexpected errors gracefully
it('handles unexpected errors', async () => {
const mockConsoleError = jest
.spyOn(console, 'log')
.mockImplementation(() => {
// noop
})
// Mock the useWebviewIntent hook to throw an error
mockUseWebviewIntent.mockReturnValue({
call: jest.fn((method, arg) => {
if (method === 'isAvailable' && arg === 'scanner') {
return Promise.resolve(true)
}
if (method === 'scanDocument') {
return Promise.reject(new Error('test error'))
}
return Promise.resolve(false)
})
})
// Render the component under test
const { queryByTestId } = render( )
// Wait for the scan-doc element to be clickable and then simulate a click event
await waitFor(() => {
queryByTestId('scan-doc') as HTMLButtonElement
fireEvent.click(
queryByTestId('scan-doc')?.firstChild as HTMLButtonElement
)
})
// Wait for the component to react to the error and assert that the scan-doc element is not present
await waitFor(() => {
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
mockConsoleError.mock.calls.some(call => call[0].includes('test error'))
).toBe(true)
expect(queryByTestId('scan-doc')).not.toBeNull()
})
})
})
================================================
FILE: src/modules/drive/Toolbar/components/Scanner/ScannerMenuItem.tsx
================================================
import React from 'react'
import logger from 'cozy-logger'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CameraIcon from 'cozy-ui/transpiled/react/Icons/Camera'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useI18n } from 'twake-i18n'
import { useScannerContext } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'
const log = logger.namespace('Toolbar/components/Scanner/ScannerMenuItem')
/**
* Renders a scanner menu item.
* @returns The JSX element representing the scanner menu item.
*/
interface ScannerMenuItemProps {
onClick: () => void
}
export const ScannerMenuItem = ({
onClick
}: ScannerMenuItemProps): JSX.Element | null => {
const { t } = useI18n()
const { hasScanner, startScanner } = useScannerContext()
const handleClick = (): void => {
if (startScanner) {
startScanner().catch((error: Error) => {
log('error', `Failed to start scanner: ${error.message}`)
})
}
onClick()
}
return hasScanner ? (
) : null
}
================================================
FILE: src/modules/drive/Toolbar/components/Scanner/ScannerProvider.tsx
================================================
import React, { useContext } from 'react'
import { useScannerService } from '@/modules/drive/Toolbar/components/Scanner/useScannerService'
interface ScannerContextValue {
startScanner?: () => Promise
hasScanner: boolean
}
interface ScannerProviderProps {
children: React.ReactNode
displayedFolder: { id: string }
}
/**
* Context object for the Scanner component.
*/
export const ScannerContext = React.createContext({
startScanner: undefined,
hasScanner: false
})
export const useScannerContext = (): ScannerContextValue =>
useContext(ScannerContext)
/**
* Provides the scanner functionality.
*
* @param props - The component props.
* @returns The scanner provider component.
*/
export const ScannerProvider = ({
children,
displayedFolder
}: ScannerProviderProps): JSX.Element => {
const scanner = useScannerService(displayedFolder)
return (
{children}
)
}
================================================
FILE: src/modules/drive/Toolbar/components/Scanner/useScannerService.ts
================================================
import { useState, useEffect, useCallback } from 'react'
import { useDispatch } from 'react-redux'
import { useClient } from 'cozy-client'
import { useWebviewIntent } from 'cozy-intent'
import logger from 'cozy-logger'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import {
getErrorMessage,
getFileFromBase64,
getUniqueNameFromPrefix
} from '@/modules/drive/helpers'
import { uploadFiles } from '@/modules/navigation/duck'
/**
* Custom hook that provides scanner functionality.
* @returns An object with the following properties:
* - hasScanner: A boolean indicating whether the scanner is available.
* - scanDocument: A function that returns a promise resolving to a string representing the scanned document in base64 format.
*/
export const useScannerService = (displayedFolder: {
id: string
driveId: string
}): {
hasScanner: boolean
startScanner: () => Promise
} => {
const [hasScanner, setHasScanner] = useState(false)
const webviewIntent = useWebviewIntent()
const dispatch = useDispatch()
const client = useClient()
const { t } = useI18n()
const { showAlert } = useAlert()
useEffect(() => {
const initScanner = async (): Promise => {
try {
const res = await webviewIntent?.call('isAvailable', 'scanner')
setHasScanner(Boolean(res))
} catch (error) {
logger('error', `scanner won't be available, ${getErrorMessage(error)}`)
}
}
if (webviewIntent) {
void initScanner()
}
}, [webviewIntent])
const scanDocument = useCallback(async (): Promise => {
logger('info', 'Starting scanner')
const base64 = (await webviewIntent?.call(
'scanDocument'
)) as unknown as string
if (!base64) throw new Error('No base64 returned by scanDocument')
logger('info', `Scan done, base64 trimmed: ${base64.slice(0, 20)}...`)
return base64
}, [webviewIntent])
const startScanner = useCallback(async () => {
try {
if (!displayedFolder) return
const base64 = await scanDocument()
const payload = uploadFiles(
[
getFileFromBase64(
base64,
getUniqueNameFromPrefix('scan'),
'image/jpeg'
)
],
displayedFolder.id,
{ isScanned: true },
() => logger('info', `File uploaded successfully`),
{ client, showAlert, t },
displayedFolder.driveId,
undefined
)
dispatch(payload)
} catch (error) {
logger('error', `startScanner error, ${getErrorMessage(error)}`)
showAlert({ message: t('ImportToDrive.error'), severity: 'error' })
}
}, [displayedFolder, scanDocument, dispatch, client, t, showAlert])
return { hasScanner, startScanner }
}
================================================
FILE: src/modules/drive/Toolbar/components/SearchButton.jsx
================================================
import React, { useCallback } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconButton from 'cozy-ui/transpiled/react/IconButton'
import Magnifier from 'cozy-ui/transpiled/react/Icons/Magnifier'
import { useI18n } from 'twake-i18n'
const SearchButton = () => {
const { t } = useI18n()
const navigate = useNavigate()
const { pathname } = useLocation()
const goToSearch = useCallback(() => {
navigate(`/search?returnPath=${pathname}`)
}, [navigate, pathname])
return (
)
}
export default SearchButton
================================================
FILE: src/modules/drive/Toolbar/components/ShortcutCreationModal.jsx
================================================
import React, { useCallback, useEffect, useState } from 'react'
import { useClient } from 'cozy-client'
import { isIOS } from 'cozy-device-helper'
import Button from 'cozy-ui/transpiled/react/Buttons'
import { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import InputAdornment from 'cozy-ui/transpiled/react/InputAdornment'
import Stack from 'cozy-ui/transpiled/react/Stack'
import TextField from 'cozy-ui/transpiled/react/TextField'
import useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { useDisplayedFolder } from '@/hooks'
import { displayedFolderOrRootFolder } from '@/hooks/helpers'
import { DOCTYPE_FILES_SHORTCUT } from '@/lib/doctypes'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
const ENTER_KEY = 13
const isURLValid = url => {
try {
new URL(url)
return true
} catch (_e) {
return false
}
}
const makeURLValid = str => {
if (isURLValid(str)) return str
else if (isURLValid(`https://${str}`)) return `https://${str}`
return false
}
const ShortcutCreationModal = ({ onClose, onCreated }) => {
const { displayedFolder } = useDisplayedFolder()
const { t } = useI18n()
const [fileName, setFilename] = useState('')
const [url, setUrl] = useState('')
const client = useClient()
const { showAlert } = useAlert()
const isOffline = useBrowserOffline()
const { addItems } = useNewItemHighlightContext()
const _displayedFolder = displayedFolderOrRootFolder(displayedFolder)
const createShortcut = useCallback(async () => {
if (!fileName || !url) {
showAlert({ message: t('Shortcut.needs_info'), severity: 'error' })
return
}
const makedURL = makeURLValid(url)
if (!makedURL) {
showAlert({ message: t('Shortcut.url_badformat'), severity: 'error' })
return
}
try {
if (isOffline) {
showAlert({ message: t('alert.offline'), severity: 'error' })
} else {
const response = await client.save({
_type: DOCTYPE_FILES_SHORTCUT,
dir_id: _displayedFolder.id,
name: fileName.endsWith('.url') ? fileName : fileName + '.url',
url: makedURL
})
const createdShortcut = response?.data ?? response
if (createdShortcut) {
addItems([createdShortcut])
}
showAlert({ message: t('Shortcut.created'), severity: 'success' })
if (onCreated) onCreated()
}
onClose()
} catch (error) {
if (
error.message.includes(
'NetworkError when attempting to fetch resource.'
)
) {
showAlert({ message: t('upload.alert.network'), severity: 'error' })
} else if (
error.message.includes(
'Invalid filename containing illegal character(s):'
)
) {
showAlert({
message: t('alert.file_name_illegal_characters', {
fileName,
characters: error.message.split(
'Invalid filename containing illegal character(s): '
)[1]
}),
severity: 'error',
duration: 2000
})
} else if (error.message.includes('Invalid filename:')) {
showAlert({
message: t('alert.file_name_illegal_name', { fileName }),
severity: 'error'
})
} else if (error.message.includes('Missing name argument')) {
showAlert({ message: t('alert.file_name_missing'), severity: 'error' })
} else {
showAlert({ message: t('Shortcut.errored'), severity: 'error' })
}
}
}, [
client,
fileName,
onClose,
onCreated,
t,
url,
_displayedFolder,
isOffline,
showAlert,
addItems
])
const handleKeyDown = e => {
if (e.keyCode === ENTER_KEY) {
createShortcut()
}
}
useEffect(() => {
const timeout = setTimeout(() => {
if (isIOS()) window.scrollTo(0, 0)
}, 30)
return () => clearTimeout(timeout)
}, [])
return (
setUrl(e.target.value)}
onKeyDown={e => handleKeyDown(e)}
fullWidth
margin="normal"
autoFocus
/>
setFilename(e.target.value)}
fullWidth
margin="normal"
onKeyDown={e => handleKeyDown(e)}
InputProps={{
endAdornment: (
.url
)
}}
/>
}
actions={
<>
>
}
/>
)
}
export default ShortcutCreationModal
================================================
FILE: src/modules/drive/Toolbar/components/ShortcutCreationModal.spec.jsx
================================================
import { fireEvent, render, waitFor } from '@testing-library/react'
import mediaQuery from 'css-mediaquery'
import React from 'react'
import { createMockClient } from 'cozy-client'
import useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import ShortcutCreationModal from './ShortcutCreationModal'
import AppLike from 'test/components/AppLike'
import useDisplayedFolder from '@/hooks/useDisplayedFolder'
import { DOCTYPE_FILES_SHORTCUT } from '@/lib/doctypes'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
const tMock = jest.fn()
const showAlert = jest.fn()
jest.mock('cozy-ui/transpiled/react/hooks/useBrowserOffline')
jest.mock('cozy-ui/transpiled/react/providers/Alert', () => ({
...jest.requireActual('cozy-ui/transpiled/react/providers/Alert'),
__esModule: true,
useAlert: jest.fn()
}))
jest.mock('lib/logger', () => ({
error: jest.fn()
}))
jest.mock('hooks/useDisplayedFolder')
jest.mock('@/modules/upload/NewItemHighlightProvider', () => {
const React = require('react')
return {
__esModule: true,
NewItemHighlightProvider: ({ children }) => <>{children}>,
useNewItemHighlightContext: jest.fn()
}
})
function createMatchMedia(width) {
return query => ({
matches: mediaQuery.match(query, { width }),
addListener: () => {},
removeListener: () => {}
})
}
const client = new createMockClient({})
const onCloseSpy = jest.fn()
const addItemsMock = jest.fn()
const defaultProps = {
displayedFolder: {
id: 'id'
},
onClose: onCloseSpy,
open: true
}
describe('ShortcutCreationModal', () => {
beforeEach(() => {
jest.resetAllMocks()
useDisplayedFolder.mockReturnValue({ displayedFolder: { id: 'id' } })
window.matchMedia = createMatchMedia(window.innerWidth)
tMock.mockImplementation(key => key)
useAlert.mockReturnValue({ showAlert })
addItemsMock.mockReset()
useNewItemHighlightContext.mockReturnValue({
addItems: addItemsMock
})
})
const setup = props => {
const { getByLabelText, getByText } = render(
)
const filenameInput = getByLabelText('Filename')
const submitButton = getByText('Create')
return {
urlInput: getByLabelText('URL'),
filenameInput,
submitButton
}
}
it('should display error when filename is empty', async () => {
// Given
const { urlInput, submitButton } = setup(defaultProps)
fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })
// When
fireEvent.click(submitButton)
// Then
expect(client.save).not.toHaveBeenCalled()
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('should handle correctly success case', async () => {
// Given
const { urlInput, filenameInput, submitButton } = setup(defaultProps)
fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })
fireEvent.change(filenameInput, { target: { value: 'filename.url' } })
client.save.mockResolvedValue({ data: { _id: 'shortcut-id' } })
// When
fireEvent.click(submitButton)
// Then
expect(client.save).toHaveBeenCalledWith({
dir_id: 'id',
name: 'filename.url',
_type: DOCTYPE_FILES_SHORTCUT,
url: 'https://cozy.io'
})
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
expect(addItemsMock).toHaveBeenCalledWith([
expect.objectContaining({ _id: 'shortcut-id' })
])
})
it('should call the optional onCreated prop', async () => {
const onCreatedMock = jest.fn()
const { urlInput, filenameInput, submitButton } = setup({
...defaultProps,
onCreated: onCreatedMock
})
client.save.mockResolvedValue({ data: {} })
fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })
fireEvent.change(filenameInput, { target: { value: 'filename' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
expect(onCreatedMock).toHaveBeenCalled()
})
it('should alert error on illegal characters', async () => {
const { urlInput, filenameInput, submitButton } = setup({
...defaultProps,
onCreated: jest.fn()
})
client.save.mockRejectedValue({
message: 'Invalid filename containing illegal character(s): /'
})
fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })
fireEvent.change(filenameInput, { target: { value: 'file/name' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error', duration: 2000 })
)
})
})
it('should alert error on illegal file name', async () => {
const { urlInput, filenameInput, submitButton } = setup({
...defaultProps,
onCreated: jest.fn()
})
client.save.mockRejectedValue({
message: 'Invalid filename: ..'
})
fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })
fireEvent.change(filenameInput, { target: { value: '..' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
it('should alert error on missing file name', async () => {
const { urlInput, filenameInput, submitButton } = setup({
...defaultProps,
onCreated: jest.fn()
})
client.save.mockRejectedValue({
message: 'Missing name argument'
})
fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })
fireEvent.change(filenameInput, { target: { value: ' ' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
it('should alert network error when detected by useBrowserOffline', async () => {
useBrowserOffline.mockReturnValue(true)
const { urlInput, filenameInput, submitButton } = setup({
...defaultProps,
onCreated: jest.fn()
})
fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })
fireEvent.change(filenameInput, { target: { value: ' ' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
it('should alert network error when not detected by useBrowserOffline', async () => {
const { urlInput, filenameInput, submitButton } = setup({
...defaultProps,
onCreated: jest.fn()
})
client.save.mockRejectedValue({
message: 'NetworkError when attempting to fetch resource.'
})
fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })
fireEvent.change(filenameInput, { target: { value: ' ' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(showAlert).toHaveBeenCalledTimes(1)
expect(showAlert).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
})
================================================
FILE: src/modules/drive/Toolbar/components/UploadItem.jsx
================================================
import React from 'react'
import { useDispatch } from 'react-redux'
import { useClient } from 'cozy-client'
import withSharingState from 'cozy-sharing/dist/hoc/withSharingState'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import FileInput from 'cozy-ui/transpiled/react/FileInput'
import Icon from 'cozy-ui/transpiled/react/Icon'
import UploadIcon from 'cozy-ui/transpiled/react/Icons/Upload'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { useDisplayedFolder } from '@/hooks'
import { uploadFiles } from '@/modules/navigation/duck'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
const UploadItem = ({
onClick,
isReadOnly,
displayedFolder,
sharingState,
onUploaded
}) => {
const client = useClient()
const { showAlert } = useAlert()
const { initialDirId } = useDisplayedFolder()
const { addItems } = useNewItemHighlightContext()
const { t } = useI18n()
const dispatch = useDispatch()
const onUpload = (
client,
files,
initialDirId,
showAlert,
driveId,
addItems
) => {
dispatch(
uploadFiles(
files,
initialDirId,
sharingState,
onUploaded,
{ client, showAlert, t },
driveId,
addItems
)
)
}
const handleMenuItemClick = evt => {
if (isReadOnly) {
evt.preventDefault()
evt.stopPropagation()
showAlert({
message: t(
'AddMenu.readOnlyFolder',
'This is a read-only folder. You cannot perform this action.'
),
severity: 'warning'
})
onClick()
return
}
}
const handleChange = files => {
if (isReadOnly || !files || files.length === 0) return
onUpload(
client,
files,
initialDirId,
showAlert,
displayedFolder?.driveId,
addItems
)
onClick()
}
return (
)
}
export default withSharingState(UploadItem)
================================================
FILE: src/modules/drive/Toolbar/components/ViewSwitcher.jsx
================================================
import React from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ListMinIcon from 'cozy-ui/transpiled/react/Icons/ListMin'
import MosaicMinIcon from 'cozy-ui/transpiled/react/Icons/MosaicMin'
import ToggleButton from 'cozy-ui/transpiled/react/ToggleButton'
import ToggleButtonGroup from 'cozy-ui/transpiled/react/ToggleButtonGroup'
import { useI18n } from 'twake-i18n'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
/**
* ViewSwitcher component for toggling between grid and list views
* @param {Object} props - Component props
* @param {string} props.className - Additional CSS class name
* @returns {JSX.Element} The rendered component
*/
const ViewSwitcher = ({ className }) => {
const { t } = useI18n()
const { viewType, switchView } = useViewSwitcherContext()
// Convert isBigThumbnail to value for ToggleButtonGroup
const value = viewType
const handleChange = (event, newValue) => {
if (newValue !== null) {
switchView(newValue)
}
}
return (
)
}
export default ViewSwitcher
================================================
FILE: src/modules/drive/Toolbar/delete/DeleteItem.jsx
================================================
import compose from 'lodash/flowRight'
import PropTypes from 'prop-types'
import React from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { translate } from 'twake-i18n'
import deleteContainer from './delete'
const DeleteItem = ({ t, isSharedWithMe, trashFolder, displayedFolder }) => {
const handleClick = () => {
trashFolder(displayedFolder)
}
const label = isSharedWithMe ? t('toolbar.leave') : t('toolbar.trash')
return (
)
}
DeleteItem.propTypes = {
t: PropTypes.func.isRequired,
isSharedWithMe: PropTypes.bool.isRequired,
trashFolder: PropTypes.func.isRequired,
displayedFolder: PropTypes.object.isRequired
}
export default compose(translate(), deleteContainer)(DeleteItem)
================================================
FILE: src/modules/drive/Toolbar/delete/DeleteItem.spec.jsx
================================================
import { render, fireEvent } from '@testing-library/react'
import React from 'react'
import DeleteItem from './DeleteItem'
import { EnhancedDeleteConfirm } from './delete'
import AppLike from 'test/components/AppLike'
import { setupStoreAndClient } from 'test/setup'
jest.mock('modules/actions/utils', () => ({
trashFiles: jest.fn().mockResolvedValue()
}))
jest.mock('lib/logger', () => ({
error: jest.fn()
}))
describe('DeleteItem', () => {
const setup = () => {
const displayedFolder = {
_id: 'displayed-folder-id',
name: 'My Folder'
}
const { client, store } = setupStoreAndClient({})
jest.spyOn(store, 'dispatch')
const onLeave = jest.fn()
const container = render(
)
return { container, store, displayedFolder }
}
it('should show a modal', async () => {
const { container, store, displayedFolder } = setup()
const confirmButton = container.getByText('Remove')
fireEvent.click(confirmButton)
expect(store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'SHOW_MODAL',
component: expect.objectContaining({
type: EnhancedDeleteConfirm,
props: expect.objectContaining({
folder: displayedFolder
})
})
})
)
})
})
================================================
FILE: src/modules/drive/Toolbar/delete/delete.jsx
================================================
import React, { useCallback } from 'react'
import { connect } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import DeleteConfirm from '../../DeleteConfirm'
import { showModal } from '@/lib/react-cozy-helpers'
const EnhancedDeleteConfirm = ({ folder, ...rest }) => {
const navigate = useNavigate()
const navigateToParentFolder = useCallback(
() => navigate(`/folder/${folder.dir_id}`),
[navigate, folder]
)
return (
)
}
export { EnhancedDeleteConfirm }
const mapDispatchToProps = dispatch => ({
trashFolder: folder =>
dispatch(showModal( ))
})
const deleteContainer = connect(null, mapDispatchToProps)
export default deleteContainer
================================================
FILE: src/modules/drive/Toolbar/delete/delete.spec.jsx
================================================
import { render, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
import { EnhancedDeleteConfirm } from './delete'
import AppLike from 'test/components/AppLike'
import { setupStoreAndClient } from 'test/setup'
const mockNavigate = jest.fn()
jest.mock('lib/logger', () => ({
error: jest.fn()
}))
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate
}))
describe('EnhancedDeleteConfirm', () => {
const setup = () => {
const folder = {
_id: 'folder-id',
name: 'My folder',
dir_id: 'parent-folder-id'
}
const { client, store } = setupStoreAndClient({})
const mockSharingContext = {
hasWriteAccess: () => true,
getRecipients: () => [],
getSharingLink: () => null
}
const container = render(
null} />
)
return { container, folder, client }
}
it('should trashFiles on confirmation', async () => {
const { container } = setup()
const confirmButton = container.getByText('Remove')
fireEvent.click(confirmButton)
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith('/folder/parent-folder-id')
)
})
})
================================================
FILE: src/modules/drive/Toolbar/index.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
import { SharedDocument, useSharingContext } from 'cozy-sharing'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import styles from '@/styles/toolbar.styl'
import { BarRightOnMobile } from '@/components/Bar'
import { useDisplayedFolder, useCurrentFolderId } from '@/hooks'
import InsideRegularFolder from '@/modules/drive/Toolbar/components/InsideRegularFolder'
import MoreMenu from '@/modules/drive/Toolbar/components/MoreMenu'
import SearchButton from '@/modules/drive/Toolbar/components/SearchButton'
import ViewSwitcher from '@/modules/drive/Toolbar/components/ViewSwitcher'
import ShareButton from '@/modules/drive/Toolbar/share/ShareButton'
import SharedRecipients from '@/modules/drive/Toolbar/share/SharedRecipients'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'
const Toolbar = ({
folderId,
disabled,
canUpload,
canCreateFolder,
hasWriteAccess,
isSharedWithMe,
showShareButton = true
}) => {
const { displayedFolder } = useDisplayedFolder()
const { isMobile } = useBreakpoints()
const { showSelectionBar, isSelectionBarVisible } = useSelectionContext()
const { allLoaded } = useSharingContext() // We need to wait for the sharing context to be completely loaded to avoid race conditions
const isDisabled = disabled || isSelectionBarVisible
const isSharingDisabled = isDisabled || !allLoaded
const isSharedDriveRecipient = isFromSharedDriveRecipient(displayedFolder)
const moreMenuProps = {
isDisabled,
hasWriteAccess,
isSharedWithMe,
canCreateFolder,
canUpload,
folderId,
displayedFolder,
showSelectionBar,
isSelectionBarVisible,
isSharedDriveRecipient
}
if (disabled) {
return null
}
return (
{hasWriteAccess && showShareButton && (
)}
{isMobile && }
)
}
Toolbar.propTypes = {
folderId: PropTypes.string,
disabled: PropTypes.bool,
canUpload: PropTypes.bool,
canCreateFolder: PropTypes.bool,
hasWriteAccess: PropTypes.bool
}
Toolbar.defaultProps = {
canUpload: false,
canCreateFolder: false,
hasWriteAccess: false
}
/**
* Provides the Toolbar with sharing properties of the current folder.
*
* In views where the displayed folder is virtual (eg: Recent files, Sharings),
* no sharing information is provided to the Toolbar.
*/
const ToolbarWithSharingContext = props => {
const folderId = useCurrentFolderId()
const { driveId } = props
return !folderId ? (
) : (
{sharingProps => {
const { hasWriteAccess, isSharedWithMe } = sharingProps
// We do not want to enable write access actions for recipient for shared drive root folder.
// To check if it is shared drive root folder, we check if the document is shared because
// in a shared drive only the share drive root folder has a sharing
const hasWriteAccessExceptSharedDriveRootFolder = driveId
? hasWriteAccess && !isSharedWithMe
: hasWriteAccess
return (
)
}}
)
}
ToolbarWithSharingContext.displayName = 'ToolbarWithSharingContext'
export default ToolbarWithSharingContext
================================================
FILE: src/modules/drive/Toolbar/move/MoveItem.jsx
================================================
import React from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import MovetoIcon from 'cozy-ui/transpiled/react/Icons/Moveto'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'
const MoveItem = ({ displayedFolder, hasWriteAccess }) => {
const { t } = useI18n()
const navigate = useNavigate()
const { pathname, search } = useLocation()
const { isMobile } = useBreakpoints()
if (!hasWriteAccess) {
return null
}
const handleClick = () => {
navigateToModalWithMultipleFile({
files: [displayedFolder],
pathname,
navigate,
path: 'move',
search
})
}
const label = isMobile
? t('SelectionBar.moveto_mobile')
: t('SelectionBar.moveto')
return (
)
}
export default MoveItem
================================================
FILE: src/modules/drive/Toolbar/personalizeFolder/PersonalizeFolderItem.jsx
================================================
import React from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import PaletteIcon from 'cozy-ui/transpiled/react/Icons/Palette'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useI18n } from 'twake-i18n'
import { useModalContext } from '@/lib/ModalContext'
import { FolderCustomizerModal } from '@/modules/views/Folder/FolderCustomizer'
const PersonalizeFolderItem = ({
displayedFolder,
hasWriteAccess,
onClose
}) => {
const { t } = useI18n()
const { pushModal, popModal } = useModalContext()
if (
!hasWriteAccess ||
!displayedFolder ||
displayedFolder.type !== 'directory'
) {
return null
}
const handleClick = () => {
pushModal(
{
popModal()
onClose?.()
}}
/>
)
}
return (
)
}
export default PersonalizeFolderItem
================================================
FILE: src/modules/drive/Toolbar/selectable/SelectableItem.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useI18n } from 'twake-i18n'
/**
* Action to show the selection bar
*/
const SelectableItem = ({ onClick }) => {
const { t } = useI18n()
return (
)
}
SelectableItem.propTypes = {
onClick: PropTypes.func.isRequired
}
export default SelectableItem
================================================
FILE: src/modules/drive/Toolbar/share/ShareButton.jsx
================================================
import React from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { ShareButton } from 'cozy-sharing'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useDisplayedFolder } from '@/hooks'
import { getPathToShareDisplayedFolder } from '@/modules/drive/Toolbar/share/helpers'
const ShareButtonWithProps = ({ isDisabled, className, useShortLabel }) => {
const { displayedFolder } = useDisplayedFolder()
const navigate = useNavigate()
const { pathname } = useLocation()
const { isMobile } = useBreakpoints()
const share = () => {
navigate(getPathToShareDisplayedFolder(pathname))
}
if (!displayedFolder) return null
return (
share(displayedFolder)}
size={isMobile ? 'small' : 'medium'}
/>
)
}
export default ShareButtonWithProps
================================================
FILE: src/modules/drive/Toolbar/share/ShareItem.jsx
================================================
import React from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { SharedDocument } from 'cozy-sharing'
import { AvatarList } from 'cozy-sharing/dist/components/Avatar/AvatarList'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useI18n } from 'twake-i18n'
import { getPathToShareDisplayedFolder } from '@/modules/drive/Toolbar/share/helpers'
const ShareItem = ({ displayedFolder }) => {
const { t } = useI18n()
const navigate = useNavigate()
const { pathname } = useLocation()
const share = () => {
navigate(getPathToShareDisplayedFolder(pathname))
}
return (
{({ isSharedWithMe, recipients, link }) => (
)}
)
}
export default ShareItem
================================================
FILE: src/modules/drive/Toolbar/share/SharedRecipients.jsx
================================================
import React from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { SharedRecipients } from 'cozy-sharing'
import { useDisplayedFolder } from '@/hooks'
import { getPathToShareDisplayedFolder } from '@/modules/drive/Toolbar/share/helpers'
const SharedRecipientsComponent = () => {
const { displayedFolder } = useDisplayedFolder()
const navigate = useNavigate()
const { pathname } = useLocation()
const share = () => {
navigate(getPathToShareDisplayedFolder(pathname))
}
return (
)
}
export default SharedRecipientsComponent
================================================
FILE: src/modules/drive/Toolbar/share/helpers.js
================================================
import { joinPath } from '@/lib/path'
/**
* Get the path to share the displayed folder
* @param {string} pathname Current path
* @returns Next path
*/
export function getPathToShareDisplayedFolder(pathname) {
return joinPath(pathname, 'share')
}
================================================
FILE: src/modules/drive/Toolbar/share/helpers.spec.js
================================================
import { getPathToShareDisplayedFolder } from '@/modules/drive/Toolbar/share/helpers'
describe('getPathToShareDisplayedFolder', () => {
it('should return path to displayed folder share modal', () => {
expect(getPathToShareDisplayedFolder('/path/to/folder/123')).toBe(
'/path/to/folder/123/share'
)
})
it('should return correct path if pathname ends with /', () => {
expect(getPathToShareDisplayedFolder('/path/to/folder/123/')).toBe(
'/path/to/folder/123/share'
)
})
})
================================================
FILE: src/modules/drive/helpers.ts
================================================
import { makeStyles } from 'cozy-ui/transpiled/react/styles'
/* eslint-disable */
export const useFabStyles = makeStyles(() => ({
root: {
position: 'fixed',
right: ({ right = '1rem' }) => right,
bottom: ({ bottom = '1rem' }) => bottom
}
}))
/* eslint-enable */
interface ErrorWithMessage {
message: string
}
const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record).message === 'string'
)
}
const toErrorWithMessage = (maybeError: unknown): ErrorWithMessage => {
if (isErrorWithMessage(maybeError)) return maybeError
try {
return new Error(JSON.stringify(maybeError))
} catch {
// fallback in case there's an error stringifying the maybeError
// like with circular references for example.
return new Error(String(maybeError))
}
}
export const getErrorMessage = (error: unknown): string => {
return toErrorWithMessage(error).message
}
export const getBlobFromBase64 = (base64: string, mimeString: string): Blob => {
const byteString = atob(base64)
const byteNumbers = new Array(byteString.length)
for (let i = 0; i < byteString.length; i++) {
byteNumbers[i] = byteString.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
return new Blob([byteArray], { type: mimeString })
}
export const getFileFromBase64 = (
base64: string,
filename: string,
mimeString: string
): File => {
const blob = getBlobFromBase64(base64, mimeString)
return new File([blob], filename, { type: mimeString })
}
export const getUniqueNameFromPrefix = (prefix: string): string => {
const timestamp = Date.now()
const randomString = Math.random().toString(36).substring(7)
const fileName = `${prefix}_${timestamp}_${randomString}.jpg`
return fileName
}
================================================
FILE: src/modules/drive/rename.js
================================================
// constants
const START_RENAMING = 'START_RENAMING'
const ABORT_RENAMING = 'ABORT_RENAMING'
// reducers
const initialState = { file: null, name: null }
const renameReducer = (state = initialState, action) => {
switch (action.type) {
case START_RENAMING:
return { ...state, file: action.file, name: action.file.name }
case ABORT_RENAMING:
return initialState
default:
return state
}
}
export default renameReducer
// selectors
export const isRenaming = state => state.rename !== initialState
export const getRenamingFile = state => state.rename.file
export const getUpdatedName = state => state.rename.name
// action creators sync
export const startRenaming = file => ({ type: START_RENAMING, file })
export const abortRenaming = () => ({ type: ABORT_RENAMING })
// action creators async
export const startRenamingAsync = file => async dispatch => {
await dispatch(startRenaming(file))
}
================================================
FILE: src/modules/duplicate/components/DuplicateModal.tsx
================================================
import React, { FC, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useClient, models } from 'cozy-client'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { OpenFolderButton } from '@/components/Button/OpenFolderButton'
import { FolderPicker } from '@/components/FolderPicker/FolderPicker'
import { File, FolderPickerEntry } from '@/components/FolderPicker/types'
import { ROOT_DIR_ID } from '@/constants/config'
import { useCancelable } from '@/modules/move/hooks/useCancelable'
import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'
interface DuplicateModalProps {
entries: FolderPickerEntry[]
currentFolder: File
onClose: () => void | Promise
showNextcloudFolder?: boolean
showSharedDriveFolder?: boolean
isPublic?: boolean
}
const DuplicateModal: FC = ({
entries,
currentFolder,
onClose,
showNextcloudFolder,
showSharedDriveFolder,
isPublic
}) => {
const { t } = useI18n()
const { showAlert } = useAlert()
const { registerCancelable } = useCancelable()
const client = useClient()
const navigate = useNavigate()
const [isBusy, setBusy] = useState(false)
const handleConfirm = async (folder: File): Promise => {
try {
setBusy(true)
await Promise.all(
entries.map(async entry => {
await registerCancelable(
models.file.copy(client, entry as Partial, folder, {
driveId: entry.driveId
})
)
})
)
const isCopyingInsideNextcloud =
folder._type === 'io.cozy.remote.nextcloud.files'
if (isCopyingInsideNextcloud) {
refreshNextcloudQueries(folder)
}
showAlert({
message: t('DuplicateModal.success', {
smart_count: entries.length,
fileName: entries[0].name,
destinationName:
folder._id === ROOT_DIR_ID
? t('breadcrumb.title_drive')
: folder.name
}),
severity: 'success',
action:
})
} catch (_e) {
showAlert({
message: t('DuplicateModal.error'),
severity: 'error'
})
} finally {
setBusy(false)
await onClose()
}
}
/**
* The content from nextcloud queries must be refreshed when coping files
* This is only a proxy to Nextcloud queries so we don't have real-time or mutations updates
*/
const refreshNextcloudQueries = (folder: File): void => {
const queryId = computeNextcloudFolderQueryId({
sourceAccount: folder.cozyMetadata?.sourceAccount,
path: folder.path
})
void client?.resetQuery(queryId)
}
return (
)
}
export { DuplicateModal }
================================================
FILE: src/modules/filelist/AddFolder.jsx
================================================
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useClient } from 'cozy-client'
import flag from 'cozy-flags'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import { AddFolderCard } from '@/modules/filelist/AddFolderCard'
import { AddFolderRow } from '@/modules/filelist/AddFolderRow'
import {
isTypingNewFolderName,
hideNewFolderInput
} from '@/modules/filelist/duck'
import AddFolderRowVz from '@/modules/filelist/virtualized/AddFolderRow'
import { createFolder } from '@/modules/navigation/duck'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
export const AddFolder = ({ visible, onSubmit, onAbort, extraColumns }) => {
const { t } = useI18n()
const { showAlert } = useAlert()
const { isMobile } = useBreakpoints()
const { viewType } = useViewSwitcherContext()
if (!visible) {
return null
}
const Comp =
viewType === 'grid'
? AddFolderCard
: flag('drive.virtualization.enabled') && !isMobile
? AddFolderRowVz
: AddFolderRow
return (
onSubmit(name, showAlert, t)}
onAbort={accidental => onAbort(accidental, showAlert, t)}
extraColumns={extraColumns}
/>
)
}
const AddFolderWithState = ({
currentFolderId,
driveId,
extraColumns,
afterSubmit,
afterAbort,
addItems
}) => {
const client = useClient()
const dispatch = useDispatch()
const visible = useSelector(isTypingNewFolderName)
const onSubmit = (name, showAlert, t) =>
dispatch(async dispatch =>
dispatch(
createFolder(
client,
name,
currentFolderId,
{ showAlert, t },
driveId,
addItems
)
).then(() => {
afterSubmit?.() // eslint-disable-line promise/always-return
})
)
const onAbort = (accidental, showAlert, t) => {
if (accidental) {
showAlert({
message: t('alert.folder_abort'),
severity: 'secondary',
noClickAway: true
})
}
afterAbort?.()
}
return (
)
}
const AddFolderWithAfter = ({ refreshFolderContent, ...props }) => {
const dispatch = useDispatch()
const { addItems } = useNewItemHighlightContext()
const handleAfterSubmit = () => {
if (refreshFolderContent) {
refreshFolderContent()
}
dispatch(hideNewFolderInput())
}
const handleAfterAbort = () => {
dispatch(hideNewFolderInput())
}
return (
)
}
export default AddFolderWithAfter
================================================
FILE: src/modules/filelist/AddFolder.spec.jsx
================================================
import { render, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
import { AddFolder } from './AddFolder'
import AppLike from 'test/components/AppLike'
import { setupStoreAndClient } from 'test/setup'
jest.mock('modules/navigation/duck/actions', () => ({
createFolder: jest.fn(() => async () => {})
}))
jest.mock('lib/logger', () => ({
error: jest.fn()
}))
jest.mock('cozy-flags', () => jest.fn())
jest.mock('cozy-keys-lib', () => ({
withVaultClient: jest.fn().mockReturnValue({}),
useVaultClient: jest.fn(),
WebVaultClient: jest.fn().mockReturnValue({})
}))
describe('AddFolder', () => {
const setup = () => {
const { client, store } = setupStoreAndClient({})
const onSubmit = jest.fn()
const container = render(
)
return { container, onSubmit }
}
it('should call onSubmit with folder name', async () => {
const { container, onSubmit } = setup()
const input = await container.findByRole('textbox')
await waitFor(async () => {
fireEvent.change(input, { target: { value: 'Mes photos de chat' } })
input.blur()
})
expect(onSubmit).toHaveBeenCalledWith(
'Mes photos de chat',
expect.anything(),
expect.anything()
)
})
})
================================================
FILE: src/modules/filelist/AddFolderCard.jsx
================================================
import cx from 'classnames'
import React from 'react'
import styles from '@/styles/filelist.styl'
import FilenameInput from '@/modules/filelist/FilenameInput'
import FileThumbnail from '@/modules/filelist/icons/FileThumbnail'
const AddFolderCard = ({ onSubmit, onAbort }) => {
return (
)
}
export { AddFolderCard }
================================================
FILE: src/modules/filelist/AddFolderRow.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { TableRow, TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import styles from '@/styles/filelist.styl'
import FilenameInput from '@/modules/filelist/FilenameInput'
import { Empty as EmptyCell, LastUpdate } from '@/modules/filelist/cells'
import FileThumbnail from '@/modules/filelist/icons/FileThumbnail'
const AddFolderRow = ({ onSubmit, onAbort, extraColumns }) => {
const { isMobile } = useBreakpoints()
return (
{!isMobile && (
<>
{extraColumns &&
extraColumns.map(column => (
))}
>
)}
)
}
export { AddFolderRow }
================================================
FILE: src/modules/filelist/File.jsx
================================================
import cx from 'classnames'
import { filesize } from 'filesize'
import get from 'lodash/get'
import PropTypes from 'prop-types'
import React, { useState, useRef } from 'react'
import { useSelector } from 'react-redux'
import { isDirectory } from 'cozy-client/dist/models/file'
import Box from 'cozy-ui/transpiled/react/Box'
import { TableRow, TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import {
SelectBox,
FileName,
LastUpdate,
Size,
Status,
FileAction,
SharingShortcutBadge
} from './cells'
import styles from '@/styles/filelist.styl'
import { useClipboardContext } from '@/contexts/ClipboardProvider'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader'
import { getContextMenuActions } from '@/modules/actions/helpers'
import { extraColumnsPropTypes } from '@/modules/certifications'
import {
isRenaming as isRenamingReducer,
getRenamingFile
} from '@/modules/drive/rename'
import FileOpener from '@/modules/filelist/FileOpener'
import FileThumbnail from '@/modules/filelist/icons/FileThumbnail'
import { useFormattedUpdatedAt } from '@/modules/filelist/useFormattedUpdatedAt'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
const FileWrapper = ({ children, viewType, className, onContextMenu }) =>
viewType === 'list' ? (
{children}
) : (
{children}
)
const ThumbnailWrapper = ({ children, viewType, className }) =>
viewType === 'list' ? (
{children}
) : (
{children}
)
const File = ({
t,
attributes,
actions,
isRenaming,
withSelectionCheckbox,
withFilePath,
disabled,
styleDisabled,
refreshFolderContent,
isInSyncFromSharing,
extraColumns,
breakpoints: { isMobile },
disableSelection = false,
canInteractWith,
onContextMenu,
onToggleSelect
}) => {
const { viewType } = useViewSwitcherContext()
const [actionMenuVisible, setActionMenuVisible] = useState(false)
const filerowMenuToggleRef = useRef()
const { toggleSelectedItem, isItemSelected } = useSelectionContext()
const { isItemCut } = useClipboardContext()
const toggleActionMenu = () => {
if (actionMenuVisible) return hideActionMenu()
else showActionMenu()
}
const showActionMenu = () => {
setActionMenuVisible(true)
}
const hideActionMenu = () => {
setActionMenuVisible(false)
}
const toggle = e => {
toggleSelectedItem(attributes)
onToggleSelect?.(attributes?._id, e)
}
const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing
const isCut = isItemCut(attributes._id)
const selected = isItemSelected(attributes._id)
const filContentRowSelected = cx(styles['fil-content-row'], {
[styles['fil-content-row-selected']]: selected,
[styles['fil-content-row-actioned']]: actionMenuVisible,
[styles['fil-content-row-disabled']]: styleDisabled || isCut
})
const filContentColumnSelected = cx(styles['fil-content-column'], {
[styles['fil-content-column-selected']]: selected,
[styles['fil-content-column-actioned']]: actionMenuVisible,
[styles['fil-content-column-disabled']]: styleDisabled || isCut
})
const formattedSize =
!isDirectory(attributes) && attributes.size
? filesize(attributes.size, { base: 10 })
: undefined
const updatedAt = attributes.updated_at || attributes.created_at
const formattedUpdatedAt = useFormattedUpdatedAt(updatedAt)
// We don't allow any action on shared drives and trash
// because they are magic folder created by the stack
let canInteractWithFile =
attributes._id &&
attributes._id !== 'io.cozy.files.shared-drives-dir' &&
!attributes._id.endsWith('.trash-dir')
if (typeof canInteractWith === 'function') {
canInteractWithFile &&= canInteractWith(attributes)
}
const contextMenuActions = getContextMenuActions(actions)
return (
0
}
selected={selected}
onClick={toggle}
disabled={
!canInteractWithFile ||
isRowDisabledOrInSyncFromSharing ||
disableSelection ||
isCut
}
/>
{viewType === 'grid' && (
)}
{viewType === 'list' && (
<>
{extraColumns &&
extraColumns.map(column => (
))}
>
)}
{contextMenuActions && canInteractWithFile && (
{
toggleActionMenu()
}}
/>
)}
{contextMenuActions && actionMenuVisible && (
)}
)
}
File.propTypes = {
t: PropTypes.func,
attributes: PropTypes.object.isRequired,
actions: PropTypes.array,
isRenaming: PropTypes.bool,
withSelectionCheckbox: PropTypes.bool.isRequired,
withFilePath: PropTypes.bool,
/** Disables row actions */
disabled: PropTypes.bool,
/** Apply disabled style on row */
styleDisabled: PropTypes.bool,
breakpoints: PropTypes.object.isRequired,
refreshFolderContent: PropTypes.func,
isInSyncFromSharing: PropTypes.bool,
extraColumns: extraColumnsPropTypes,
/** Disables the ability to select a file */
disableSelection: PropTypes.bool,
onToggleSelect: PropTypes.func
}
export const DumbFile = props => {
const { t } = useI18n()
const breakpoints = useBreakpoints()
return
}
export const FileWithSelection = props => {
const isRenaming = useSelector(
state =>
isRenamingReducer(state) &&
get(getRenamingFile(state), '_id') === props.attributes._id
)
return
}
================================================
FILE: src/modules/filelist/File.spec.jsx
================================================
'use strict'
import { render, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import { useSharingContext } from 'cozy-sharing'
import { DumbFile } from './File'
import AppLike from 'test/components/AppLike'
import { folder, actionsMenu } from 'test/data'
jest.mock('cozy-sharing', () => ({
...jest.requireActual('cozy-sharing'),
useSharingContext: jest.fn()
}))
useSharingContext.mockReturnValue({ byDocId: [] })
const client = createMockClient({
clientOptions: {
uri: 'http://cozy.localhost:8080/'
},
clientFunctions: {
getInstanceOptions: () => ({
subdomain: 'nested'
})
}
})
const setup = ({
attributes = folder,
actions = actionsMenu,
selected = false,
withSelectionCheckbox = true,
selectionModeActive = false,
onFileOpen = jest.fn(),
onCheckboxToggle = jest.fn(),
isInSyncFromSharing = false,
disableSelection = false
} = {}) => {
const root = render(
)
return { root }
}
describe('File', () => {
describe('default behavior', () => {
it('should show a select box', () => {
const { root } = setup()
const { getByRole } = root
expect(getByRole('checkbox'))
})
it('should not show spinner', () => {
const { root } = setup()
const { queryByTestId } = root
expect(queryByTestId('fil-file-thumbnail--spinner')).toBeNull()
})
it('should show actions menu when clicking the actionsMenu button', async () => {
// TODO : Fix https://github.com/cozy/cozy-drive/issues/2913
jest.spyOn(console, 'warn').mockImplementation()
let root
await waitFor(async () => {
root = setup().root
})
const { getByRole, findByText } = root
await waitFor(async () => {
fireEvent.click(getByRole('button', { name: 'More' }))
})
expect(await findByText('ActionsMenuItem'))
})
it('should not show select all in actions menu when clicking the actionsMenu button', async () => {
let root
await waitFor(async () => {
root = setup().root
})
const { getByRole, queryByText } = root
await waitFor(async () => {
fireEvent.click(getByRole('button', { name: 'More' }))
})
// "SelectAllMenuItem" should be filtered out by File before rendering
expect(queryByText('SelectAllMenuItem')).toBeNull()
})
it('should show select all in selection bar', () => {
const { root } = setup()
const { getByRole, queryByText } = root
const checkbox = getByRole('checkbox')
fireEvent.click(checkbox)
expect(queryByText('SelectAllMenuItem'))
})
})
describe('In sync from sharing behavior', () => {
it('should show spinner', () => {
const { root } = setup({ isInSyncFromSharing: true })
const { getByTestId } = root
expect(getByTestId('fil-file-thumbnail--spinner'))
})
it('should not show actions menu when clicking the actionsMenu button', async () => {
let root
await waitFor(async () => {
root = setup({ isInSyncFromSharing: true }).root
})
const { getByRole, queryByText } = root
await waitFor(async () => {
fireEvent.click(getByRole('button', { name: 'More' }))
})
expect(queryByText('ActionsMenuItem')).toBeNull()
})
it('should not show a select box when syncing', () => {
const { root } = setup({ isInSyncFromSharing: true })
const { queryByRole } = root
expect(queryByRole('checkbox')).toBeNull()
})
it('should not show a select box when selection disabled', () => {
const { root } = setup({ disableSelection: true })
const { queryByRole } = root
expect(queryByRole('checkbox')).toBeNull()
})
it('should not have clickable sharing avatars', () => {
const { root } = setup({ isInSyncFromSharing: true })
const { getByTestId } = root
expect(getByTestId('fil-content-sharestatus--noAvatar'))
})
})
})
================================================
FILE: src/modules/filelist/FileList.jsx
================================================
import cx from 'classnames'
import React, { forwardRef } from 'react'
import { Table } from 'cozy-ui/transpiled/react/deprecated/Table'
import styles from '@/styles/filelist.styl'
export const FileList = forwardRef(({ children }, ref) => {
return (
)
})
FileList.displayName = 'FileList'
================================================
FILE: src/modules/filelist/FileListBody.jsx
================================================
import cx from 'classnames'
import React, { useContext } from 'react'
import { TableBody } from 'cozy-ui/transpiled/react/deprecated/Table'
import styles from '@/styles/filelist.styl'
import { FabContext } from '@/lib/FabProvider'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
/**
* Renders the body of the file list.
*
* @component
* @param {Object} [props] - The component props.
* @param {string} [props.className] - The CSS class name for the component.
* @param {ReactNode} props.children - The child elements to be rendered inside the component.
* @returns {JSX.Element} The rendered component.
*/
const FileListBody = ({ className, children }) => {
const { isSelectionBarVisible } = useSelectionContext()
const { isFabDisplayed } = useContext(FabContext)
return (
{children}
)
}
export default FileListBody
================================================
FILE: src/modules/filelist/FileListHeader.jsx
================================================
import React from 'react'
import { FileListHeaderDesktop } from '@/modules/filelist/FileListHeaderDesktop'
import { FileListHeaderMobile } from '@/modules/filelist/FileListHeaderMobile'
/**
* @typedef {Object} Props
* @property {string|null} props.folderId - The ID of the folder.
* @property {boolean} props.canSort - Indicates whether sorting is allowed.
* @property {Sort} [props.sort] - The current sorting option.
* @property {Function} [props.onFolderSort] - The function to handle folder sorting.
* @property {Function} [props.toggleViewType] - The function to toggle the view to list or grid.
* @property {Array} [props.extraColumns] - An array of extra columns.
*/
/**
* Renders the header component for the file list.
* The responsive design is handled by CSS media queries.
* @param {Props} props - The component props.
* @returns {JSX.Element} The rendered component.
*/
const FileListHeader = props => {
return (
<>
>
)
}
export { FileListHeader }
================================================
FILE: src/modules/filelist/FileListHeaderDesktop.jsx
================================================
import cx from 'classnames'
import React from 'react'
import {
TableHead,
TableHeader,
TableRow
} from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import HeaderCell from './HeaderCell'
import styles from '@/styles/filelist.styl'
import { SORTABLE_ATTRIBUTES } from '@/config/sort'
const FileListHeaderDesktop = ({
folderId,
canSort,
sort,
onFolderSort,
extraColumns,
viewType
}) => {
const { t } = useI18n()
return (
{SORTABLE_ATTRIBUTES.map(
({ label, attr, css, defaultOrder }, index) => {
if (!canSort) {
return (
)
}
const isActive = sort && sort.attribute === attr
return (
onFolderSort(folderId, attr, order)}
className={styles['fil-content-header--capitalize']}
/>
)
}
)}
{t('table.head_size')}
{extraColumns &&
extraColumns.map(column => (
))}
{t('table.head_status')}
{/** Empty header cell for actions column */}
)
}
export { FileListHeaderDesktop }
================================================
FILE: src/modules/filelist/FileListHeaderMobile.jsx
================================================
import cx from 'classnames'
import React, { useState, useCallback } from 'react'
import Button from 'cozy-ui/transpiled/react/Buttons'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ListIcon from 'cozy-ui/transpiled/react/Icons/List'
import ListMinIcon from 'cozy-ui/transpiled/react/Icons/ListMin'
import {
TableHead,
TableHeader,
TableRow
} from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import MobileSortMenu from './MobileSortMenu'
import styles from '@/styles/filelist.styl'
import { useCurrentFolderId } from '@/hooks'
const FileListHeaderMobile = ({
canSort,
sort,
onFolderSort,
viewType,
switchViewType
}) => {
const { t } = useI18n()
const [isShowingSortMenu, setIsShowingSortMenu] = useState(false)
const folderId = useCurrentFolderId()
const showSortMenu = useCallback(
() => setIsShowingSortMenu(true),
[setIsShowingSortMenu]
)
const hideSortMenu = useCallback(
() => setIsShowingSortMenu(false),
[setIsShowingSortMenu]
)
return (
{canSort ? (
{t(`table.mobile.head_${sort.attribute}_${sort.order}`)}
) : (
// to keep the viewType switch to the right side
)}
{isShowingSortMenu && (
onFolderSort(folderId, attr, order)}
/>
)}
{
switchViewType(viewType === 'list' ? 'grid' : 'list')
}}
label={
}
/>
)
}
export { FileListHeaderMobile }
================================================
FILE: src/modules/filelist/FileListRowsPlaceholder.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import FilePlaceholder from '@/modules/filelist/FilePlaceholder'
const FileListPlaceholder = ({ rows }) => (
{[...new Array(rows)].map((value, index) => (
))}
)
FileListPlaceholder.propTypes = {
rows: PropTypes.number
}
FileListPlaceholder.defaultProps = {
rows: 8
}
export default FileListPlaceholder
================================================
FILE: src/modules/filelist/FileOpener.jsx
================================================
import React, { useRef } from 'react'
import styles from './fileopener.styl'
import { useLongPress } from '@/hooks/useOnLongPress'
import { FileLink } from '@/modules/navigation/components/FileLink'
import { useFileLink } from '@/modules/navigation/hooks/useFileLink'
const FileOpener = ({
file,
toggle,
disabled,
isRenaming,
onInteractWithFile,
children
}) => {
const rowRef = useRef()
const { link, openLink } = useFileLink(file)
const handlers = useLongPress({
file,
disabled,
isRenaming,
openLink,
toggle,
onInteractWithFile
})
if (isRenaming) {
return children
}
return (
{children}
)
}
export default FileOpener
================================================
FILE: src/modules/filelist/FileOpener.spec.jsx
================================================
import { render, screen } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import FileOpener from './FileOpener'
import AppLike from 'test/components/AppLike'
import { generateFile } from 'test/generate'
import { useFileLink } from '@/modules/navigation/hooks/useFileLink'
jest.mock('cozy-client/dist/models/file', () => ({
...jest.requireActual('cozy-client/dist/models/file'),
shouldBeOpenedByOnlyOffice: jest.fn()
}))
jest.mock('modules/navigation/hooks/useFileLink', () => ({
useFileLink: jest.fn()
}))
describe('FileOpener component', () => {
const client = createMockClient({})
const file = generateFile({ i: 1 })
const setup = ({ file, linkApp = 'drive' }) => {
useFileLink.mockReturnValue({
link: {
app: linkApp,
to: '/path/to/file',
href: 'http://cozy.tools:8080/files/123'
}
})
render(
{file.name}
)
}
afterEach(() => {
jest.clearAllMocks()
})
it('renders a Link when link.app is drive', async () => {
setup({ file, linkApp: 'drive' })
const linkElement = await screen.findByText(file.name)
expect(linkElement).toBeInTheDocument()
expect(linkElement.getAttribute('href')).toBe('#/path/to/file')
})
it('renders an anchor when link.app is not drive', async () => {
setup({ file, linkApp: 'other-app' })
const anchorElement = await screen.findByText(file.name)
expect(anchorElement).toBeInTheDocument()
expect(anchorElement.getAttribute('href')).toBe(
'http://cozy.tools:8080/files/123'
)
})
})
================================================
FILE: src/modules/filelist/FilePlaceholder.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
import { TableRow, TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import withBreakpoints from 'cozy-ui/transpiled/react/helpers/withBreakpoints'
import styles from '@/styles/filelist.styl'
// using a seeded PRNG to prevent re-renders from changing the results
const seededRandom = seed => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
const seededRandomBetween = (min, max, seed) =>
min + seededRandom(seed) * (max - min)
const PlaceholderBlock = ({ width }) => (
)
PlaceholderBlock.propTypes = {
width: PropTypes.string
}
PlaceholderBlock.defaultProps = {
width: '100%'
}
const FilePlaceholder = ({ index, breakpoints: { isMobile } }) => (
)
FilePlaceholder.propTypes = {
index: PropTypes.number
}
FilePlaceholder.defaultProps = {
index: 1
}
export default withBreakpoints()(FilePlaceholder)
================================================
FILE: src/modules/filelist/FilenameInput.jsx
================================================
import cx from 'classnames'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { isDirectory } from 'cozy-client/dist/models/file'
import Button from 'cozy-ui/transpiled/react/Buttons'
import { Dialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Spinner from 'cozy-ui/transpiled/react/Spinner'
import { translate } from 'twake-i18n'
import styles from '@/styles/filenameinput.styl'
import { CozyFile } from '@/models'
import { getCaretPositionFromPoint } from '@/modules/filelist/getCaretPositionFromPoint'
const ENTER_KEY = 13
const ESC_KEY = 27
const valueIsEmpty = value => value.toString() === ''
const FilenameInput = ({
name: initialName = '',
file,
onSubmit,
onAbort,
onChange,
t,
className,
style
}) => {
const textInput = useRef()
const [value, setValue] = useState(initialName || '')
const [working, setWorking] = useState(false)
const [error, setError] = useState(false)
const [isModalOpened, setIsModalOpened] = useState(false)
// Use a ref for synchronous guard to prevent race conditions
// when Enter and blur fire in quick succession
const isSubmittingRef = useRef(false)
const isSavingRef = useRef(false)
const save = useCallback(async () => {
if (isSavingRef.current) return
isSavingRef.current = true
if (!onSubmit) {
setWorking(false)
isSubmittingRef.current = false
isSavingRef.current = false
return
}
try {
await onSubmit(value)
} catch (_e) {
setError(true)
} finally {
setWorking(false)
isSubmittingRef.current = false
isSavingRef.current = false
}
}, [onSubmit, value])
const abort = useCallback(
(accidental = false) => {
if (isModalOpened) {
setIsModalOpened(false)
}
onAbort && onAbort(accidental)
isSubmittingRef.current = false
setWorking(false)
},
[isModalOpened, onAbort]
)
const handleKeyDown = e => {
if (e.keyCode === ENTER_KEY) {
if (valueIsEmpty(value)) {
abort(true)
} else {
submit()
}
} else if (e.keyCode === ESC_KEY) {
abort()
}
}
const handleChange = e => {
const newValue = e.target.value
setValue(newValue)
onChange && onChange(newValue)
}
const handleBlur = () => {
if (valueIsEmpty(value)) {
abort(!!initialName)
} else {
submit()
}
}
const submit = () => {
// Use ref for synchronous guard - state updates are async
// so they don't prevent double submission in same event loop
if (isSubmittingRef.current) return
isSubmittingRef.current = true
setWorking(true)
setError(false)
if (!initialName) {
save()
return
}
if (file && !isDirectory(file)) {
const previousExtension = CozyFile.splitFilename({
name: initialName,
type: 'file'
}).extension
const newExtension = CozyFile.splitFilename({
name: value,
type: 'file'
}).extension
if (previousExtension !== newExtension) {
setIsModalOpened(true)
} else {
save()
}
} else {
save()
}
}
const shouldSetSelection = useRef(false)
const handleFocus = () => {
if (!initialName) return
shouldSetSelection.current = true
}
useEffect(() => {
if (!shouldSetSelection.current || !textInput.current) return
if (!initialName) return
const { filename } = CozyFile.splitFilename({
name: initialName,
type: 'file'
})
textInput.current.setSelectionRange(
0,
isDirectory(file) ? initialName.length : filename.length
)
shouldSetSelection.current = false
}, [initialName, file])
// Firefox does not natively reposition the cursor on click in this DOM context.
// Workaround: manually compute and apply the caret position on mouseup.
const handleMouseUp = useCallback(e => {
const input = textInput.current
if (!input || e.target !== input) return
const offset = getCaretPositionFromPoint(e.clientX, e.clientY)
if (offset !== null) {
input.setSelectionRange(offset, offset)
}
}, [])
return (
{working && }
>
}
actionsLayout="row"
/>
)
}
export default translate()(FilenameInput)
================================================
FILE: src/modules/filelist/FilenameInput.spec.jsx
================================================
'use strict'
import '@testing-library/jest-dom'
import { render, fireEvent, screen, act } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import FilenameInput from './FilenameInput'
import AppLike from 'test/components/AppLike'
describe('FilenameInput', () => {
const client = createMockClient({
clientOptions: {
uri: 'http://cozy.localhost:8080/'
}
})
const setup = ({
name = '',
file = null,
onSubmit = jest.fn(),
onAbort = jest.fn(),
onChange = jest.fn()
} = {}) => {
const root = render(
)
return { root, onSubmit, onAbort, onChange }
}
describe('handleKeyDown behavior', () => {
it('should call submit when ENTER_KEY is pressed with non-empty value', async () => {
const { onSubmit } = setup()
const input = screen.getByRole('textbox')
// Type some text
await act(async () => {
fireEvent.change(input, { target: { value: 'test-file' } })
fireEvent.keyDown(input, { keyCode: 13 })
})
expect(onSubmit).toHaveBeenCalledWith('test-file')
})
it('should call abort with accidental=true when ENTER_KEY is pressed with empty value', async () => {
const { onAbort } = setup()
const input = screen.getByRole('textbox')
// Press Enter with empty value
await act(async () => {
fireEvent.keyDown(input, { keyCode: 13 })
})
expect(onAbort).toHaveBeenCalledWith(true)
})
it('should call abort when ESC_KEY is pressed', async () => {
const { onAbort } = setup()
const input = screen.getByRole('textbox')
// Press Escape
await act(async () => {
fireEvent.keyDown(input, { keyCode: 27 })
})
expect(onAbort).toHaveBeenCalled()
})
})
describe('handleBlur behavior', () => {
it('should call submit when blurred with non-empty value', async () => {
const { onSubmit } = setup()
const input = screen.getByRole('textbox')
// Type some text and blur
await act(async () => {
fireEvent.change(input, { target: { value: 'test-file' } })
fireEvent.blur(input)
})
expect(onSubmit).toHaveBeenCalledWith('test-file')
})
it('should call abort when blurred with empty value', async () => {
const { onAbort } = setup()
const input = screen.getByRole('textbox')
// Blur with empty value
await act(async () => {
fireEvent.blur(input)
})
expect(onAbort).toHaveBeenCalled()
})
})
describe('handleChange behavior', () => {
it('should update state and call onChange when input changes', async () => {
const { onChange } = setup()
const input = screen.getByRole('textbox')
await act(async () => {
fireEvent.change(input, { target: { value: 'new-value' } })
})
expect(onChange).toHaveBeenCalledWith('new-value')
expect(input.value).toBe('new-value')
})
})
describe('race condition fix verification', () => {
it('should not show unwanted notification for empty filename on ENTER_KEY', async () => {
const { onAbort } = setup()
const input = screen.getByRole('textbox')
// Simulate pressing Enter with empty value
await act(async () => {
fireEvent.keyDown(input, { keyCode: 13 })
})
// Should call abort with accidental=true, not show unwanted notification
expect(onAbort).toHaveBeenCalledWith(true)
expect(onAbort).toHaveBeenCalledTimes(1)
})
it('should handle blur correctly without race condition', async () => {
const { onSubmit, onAbort } = setup()
const input = screen.getByRole('textbox')
// Type some text and blur
await act(async () => {
fireEvent.change(input, { target: { value: 'valid-file' } })
fireEvent.blur(input)
})
// Should submit without any race condition issues
expect(onSubmit).toHaveBeenCalledWith('valid-file')
expect(onAbort).not.toHaveBeenCalled()
})
})
describe('edge cases', () => {
it('should handle whitespace-only value as non-empty', async () => {
const { onSubmit } = setup()
const input = screen.getByRole('textbox')
// Type whitespace and press Enter
await act(async () => {
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.keyDown(input, { keyCode: 13 })
})
// Whitespace is considered non-empty by the component
expect(onSubmit).toHaveBeenCalledWith(' ')
})
it('should handle Enter followed by blur correctly', async () => {
const { onSubmit, onAbort } = setup()
const input = screen.getByRole('textbox')
// Type a value and press Enter
await act(async () => {
fireEvent.change(input, { target: { value: 'test-file' } })
fireEvent.keyDown(input, { keyCode: 13 })
fireEvent.blur(input)
})
// Should only submit once, not twice
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmit).toHaveBeenCalledWith('test-file')
expect(onAbort).not.toHaveBeenCalled()
})
})
})
================================================
FILE: src/modules/filelist/HeaderCell.jsx
================================================
import cx from 'classnames'
import React, { useCallback } from 'react'
import { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
const HeaderCell = ({
label,
css,
attr,
order = null,
className,
defaultOrder,
onSort
}) => {
const { t } = useI18n()
const sortCallback = useCallback(
() =>
onSort &&
onSort(attr, order ? (order === 'asc' ? 'desc' : 'asc') : defaultOrder),
[onSort, attr, order, defaultOrder]
)
return (
{t(`table.head_${label}`)}
)
}
export default HeaderCell
================================================
FILE: src/modules/filelist/LoadMore.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
import Buttons from 'cozy-ui/transpiled/react/Buttons'
import Spinner from 'cozy-ui/transpiled/react/Spinner'
import { TableRow } from 'cozy-ui/transpiled/react/deprecated/Table'
import { translate } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
const LoadMore = ({ onClick, isLoading, text }) => (
: text}
/>
)
LoadMore.propTypes = {
onClick: PropTypes.func,
isLoading: PropTypes.bool,
text: PropTypes.string.isRequired
}
LoadMore.defaultProps = {
onClick: null,
isLoading: false
}
const withTranslation =
BaseComponent =>
// eslint-disable-next-line
({ t, ...props }) =>
export default translate()(withTranslation(LoadMore))
================================================
FILE: src/modules/filelist/LoadMoreV2.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
import LoadMore from 'cozy-ui/transpiled/react/LoadMore'
import { TableRow } from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
const LoadMoreFiles = ({ fetchMore }) => {
const { t } = useI18n()
return (
)
}
LoadMoreFiles.propTypes = {
fetchMore: PropTypes.func.isRequired
}
export default LoadMoreFiles
================================================
FILE: src/modules/filelist/MobileSortMenu.jsx
================================================
import React from 'react'
import ActionMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import ActionMenuWrapper from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuWrapper'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import Radio from 'cozy-ui/transpiled/react/Radios'
import { useI18n } from 'twake-i18n'
import { SORTABLE_ATTRIBUTES } from '@/config/sort'
const MobileSortMenu = ({ sort, onSort, onClose }) => {
const { t } = useI18n()
return (
{SORTABLE_ATTRIBUTES.map(({ attr }) => [
{ attr, order: 'asc' },
{ attr, order: 'desc' }
])
.reduce((acc, val) => [...acc, ...val], [])
.map(({ attr, order }) => {
const labelId = `sort_by_${attr}_${order}`
return (
{
onSort(attr, order)
onClose()
}}
>
)
})}
)
}
export default MobileSortMenu
================================================
FILE: src/modules/filelist/cells/CarbonCopy.jsx
================================================
import cx from 'classnames'
import get from 'lodash/get'
import React from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CheckIcon from 'cozy-ui/transpiled/react/Icons/Check'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import AppIcon from 'cozy-ui-plus/dist/AppIcon'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
import CertificationTooltip from '@/modules/certifications/CertificationTooltip'
const CarbonCopyIcon = ({ file }) => {
const hasElectronicSafe = get(file, 'metadata.electronicSafe')
const konnectorName = get(file, 'cozyMetadata.uploadedBy.slug')
if (hasElectronicSafe) {
return
}
return
}
const CarbonCopy = ({ file }) => {
const { t } = useI18n()
const hasDataToshow = get(file, 'metadata.carbonCopy')
return (
{hasDataToshow ? (
}
/>
) : (
'—'
)}
)
}
export default CarbonCopy
================================================
FILE: src/modules/filelist/cells/CertificationsIcons.jsx
================================================
import get from 'lodash/get'
import React from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CarbonCopyIcon from 'cozy-ui/transpiled/react/Icons/CarbonCopy'
import AppIcon from 'cozy-ui-plus/dist/AppIcon'
import styles from '@/styles/filelist.styl'
const CertificationsIcons = ({ attributes }) => {
const isCarbonCopy = get(attributes, 'metadata.carbonCopy')
const isElectronicSafe = get(attributes, 'metadata.electronicSafe')
const slug = get(attributes, 'cozyMetadata.uploadedBy.slug')
return (
{(isCarbonCopy || isElectronicSafe) && (
{' - '}
)}
{isCarbonCopy &&
(isElectronicSafe ? (
) : (
))}
{isElectronicSafe && (
)}
)
}
export default CertificationsIcons
================================================
FILE: src/modules/filelist/cells/CertificationsIcons.spec.js
================================================
import { render } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import CertificationsIcons from './CertificationsIcons'
import AppLike from 'test/components/AppLike'
const client = new createMockClient({})
const setup = ({ attributes }) => {
const root = render(
)
return { root }
}
describe('CertificationsIcons', () => {
it('should render only carbon copy app icon', () => {
const { root } = setup({
attributes: {
metadata: { carbonCopy: true, electronicSafe: false },
cozyMetadata: { uploadedBy: { slug: 'pajemploi' } }
}
})
const { queryByTestId } = root
expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeTruthy()
expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy()
expect(
queryByTestId('certificationsIcons-electronicSafeAppIcon')
).toBeFalsy()
})
it('should render only electronic safe app icon', () => {
const { root } = setup({
attributes: {
metadata: { carbonCopy: false, electronicSafe: true },
cozyMetadata: { uploadedBy: { slug: 'pajemploi' } }
}
})
const { queryByTestId } = root
expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy()
expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy()
expect(
queryByTestId('certificationsIcons-electronicSafeAppIcon')
).toBeTruthy()
})
it('should render carbon copy icon and electronic safe app icon', () => {
const { root } = setup({
attributes: {
metadata: { carbonCopy: true, electronicSafe: true },
cozyMetadata: { uploadedBy: { slug: 'pajemploi' } }
}
})
const { queryByTestId } = root
expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy()
expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeTruthy()
expect(
queryByTestId('certificationsIcons-electronicSafeAppIcon')
).toBeTruthy()
})
it('should render no certifications icon', () => {
const { root } = setup({
attributes: {
metadata: { carbonCopy: false, electronicSafe: false },
cozyMetadata: { uploadedBy: { slug: 'pajemploi' } }
}
})
const { queryByTestId } = root
expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy()
expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy()
expect(
queryByTestId('certificationsIcons-electronicSafeAppIcon')
).toBeFalsy()
})
it('should render no certifications icon and not throw error with empty attributes', () => {
const { root } = setup({
attributes: {}
})
const { queryByTestId } = root
expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy()
expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy()
expect(
queryByTestId('certificationsIcons-electronicSafeAppIcon')
).toBeFalsy()
})
})
================================================
FILE: src/modules/filelist/cells/ElectronicSafe.jsx
================================================
import cx from 'classnames'
import get from 'lodash/get'
import React from 'react'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import AppIcon from 'cozy-ui-plus/dist/AppIcon'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
import CertificationTooltip from '@/modules/certifications/CertificationTooltip'
const ElectronicSafe = ({ file }) => {
const { t } = useI18n()
const hasDataToshow = get(file, 'metadata.electronicSafe')
const konnectorName = get(file, 'cozyMetadata.uploadedBy.slug')
return (
{hasDataToshow ? (
}
/>
) : (
'—'
)}
)
}
export default ElectronicSafe
================================================
FILE: src/modules/filelist/cells/Empty.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import styles from '@/styles/filelist.styl'
const Empty = ({ className }) => {
return (
—
)
}
export default Empty
================================================
FILE: src/modules/filelist/cells/FileAction.jsx
================================================
import cx from 'classnames'
import React, { forwardRef } from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconButton from 'cozy-ui/transpiled/react/IconButton'
import DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import styles from '@/styles/filelist.styl'
const FileAction = forwardRef(function FileAction(
{ t, onClick, disabled, isInSyncFromSharing },
ref
) {
return (
)
})
export default FileAction
================================================
FILE: src/modules/filelist/cells/FileName.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { Link } from 'react-router-dom'
import { isDirectory } from 'cozy-client/dist/models/file'
import MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import RenameInput from '@/modules/drive/RenameInput'
import CertificationsIcons from '@/modules/filelist/cells/CertificationsIcons'
import {
getFileNameAndExtension,
makeParentFolderPath
} from '@/modules/filelist/helpers'
const FileName = ({
attributes,
isRenaming,
interactive,
withFilePath,
isMobile,
formattedSize,
formattedUpdatedAt,
refreshFolderContent,
isInSyncFromSharing
}) => {
const { t } = useI18n()
const { viewType } = useViewSwitcherContext()
const classes = cx(
styles['fil-content-cell'],
styles['fil-content-file'],
{ [styles['fil-content-file-openable']]: !isRenaming && interactive },
{ [styles['fil-content-row-disabled']]: isInSyncFromSharing },
{ [styles['fil-content-grid-view']]: viewType === 'grid' }
)
const { title, filename, extension } = getFileNameAndExtension(attributes, t)
const parentFolderPath = makeParentFolderPath(attributes)
return (
{isRenaming ? (
) : (
{filename}
{extension && (
{extension}
)}
{withFilePath &&
parentFolderPath &&
(isMobile ? (
) : (
))}
{!withFilePath &&
(isDirectory(attributes) || (
{`${formattedUpdatedAt}${
formattedSize ? ` - ${formattedSize}` : ''
}`}
{isMobile && }
))}
)}
)
}
export default FileName
================================================
FILE: src/modules/filelist/cells/LastUpdate.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
const LastUpdate = ({ date, formatted = '—' }) => {
const { f, t } = useI18n()
return (
{formatted}
)
}
LastUpdate.propTypes = {
date: PropTypes.string,
formatted: PropTypes.string
}
export default React.memo(LastUpdate)
================================================
FILE: src/modules/filelist/cells/SelectBox.jsx
================================================
import cx from 'classnames'
import React from 'react'
import Checkbox from 'cozy-ui/transpiled/react/Checkbox'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import styles from '@/styles/filelist.styl'
const SelectBox = ({
withSelectionCheckbox,
selected,
onClick,
disabled,
viewType
}) => {
return (
{withSelectionCheckbox && !disabled && (
{
// handled by onClick on the
}}
/>
)}
)
}
export default SelectBox
================================================
FILE: src/modules/filelist/cells/ShareContent.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { SharedStatus, useSharingContext } from 'cozy-sharing'
import styles from '@/styles/filelist.styl'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import { joinPath } from '@/lib/path'
const ShareContent = ({ file, disabled, isInSyncFromSharing }) => {
const navigate = useNavigate()
const { pathname } = useLocation()
const { byDocId } = useSharingContext()
const { viewType } = useViewSwitcherContext()
const handleClick = e => {
// Avoid to trigger row click from FileOpener
e.preventDefault()
e.stopPropagation()
if (!disabled) {
// should be only disabled
navigate(joinPath(pathname, `file/${file._id}/share`))
}
}
const isShared = byDocId[file.id] !== undefined
return (
{isInSyncFromSharing || !isShared ? (
viewType === 'list' ? (
—
) : null
) : (
)}
)
}
export { ShareContent }
================================================
FILE: src/modules/filelist/cells/SharingShortcutBadge.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
import { isSharingShortcutNew } from 'cozy-client/dist/models/file'
import Avatar from 'cozy-ui/transpiled/react/Avatar'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
const SharingShortcutBadge = ({ file }) => {
const { t } = useI18n()
return (
{isSharingShortcutNew(file) ? (
1
) : null}
)
}
SharingShortcutBadge.propTypes = {
file: PropTypes.object,
isInSyncFromSharing: PropTypes.bool
}
export { SharingShortcutBadge }
================================================
FILE: src/modules/filelist/cells/Size.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import styles from '@/styles/filelist.styl'
const _Size = ({ filesize = '—' }) => (
{filesize}
)
const Size = React.memo(_Size)
export default Size
================================================
FILE: src/modules/filelist/cells/Status.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'
import styles from '@/styles/filelist.styl'
import { ShareContent } from '@/modules/filelist/cells/ShareContent'
const Status = ({ file, disabled, isInSyncFromSharing }) => {
return (
)
}
export default Status
================================================
FILE: src/modules/filelist/cells/index.jsx
================================================
export { default as SelectBox } from './SelectBox'
export { default as FileName } from './FileName'
export { default as LastUpdate } from './LastUpdate'
export { default as Size } from './Size'
export { default as Status } from './Status'
export { default as FileAction } from './FileAction'
export { default as CarbonCopy } from './CarbonCopy'
export { default as ElectronicSafe } from './ElectronicSafe'
export { default as Empty } from './Empty'
export { SharingShortcutBadge } from './SharingShortcutBadge'
================================================
FILE: src/modules/filelist/duck.js
================================================
const SHOW_NEW_FOLDER_INPUT = 'SHOW_NEW_FOLDER_INPUT'
const HIDE_NEW_FOLDER_INPUT = 'HIDE_NEW_FOLDER_INPUT'
export const showNewFolderInput = () => ({
type: SHOW_NEW_FOLDER_INPUT
})
export const hideNewFolderInput = () => ({
type: HIDE_NEW_FOLDER_INPUT
})
const initialState = {
isTypingNewFolderName: false
}
const filelist = (state = initialState, action) => {
switch (action.type) {
case SHOW_NEW_FOLDER_INPUT:
return { ...state, isTypingNewFolderName: true }
case HIDE_NEW_FOLDER_INPUT:
return { ...state, isTypingNewFolderName: false }
default:
return state
}
}
export default filelist
export const isTypingNewFolderName = state =>
state.filelist.isTypingNewFolderName
================================================
FILE: src/modules/filelist/fileopener.styl
================================================
@supports (display: contents)
.file-opener
display contents
@supports not (display: contents) // @stylint ignore
.file-opener
display flex
flex 1 1 auto
align-items center
width 100%
.file-opener__a
text-decoration none
color var(--secondaryTextColor)
================================================
FILE: src/modules/filelist/getCaretPositionFromPoint.js
================================================
/**
* Get the caret offset in a text input from mouse coordinates.
*
* Uses `document.caretPositionFromPoint` (standard, Firefox) or
* `document.caretRangeFromPoint` (legacy, Chrome/Safari) to determine
* which character position the user clicked on.
*
* @param {number} x - clientX from the mouse event
* @param {number} y - clientY from the mouse event
* @returns {number|null} The character offset, or null if it cannot be determined
*/
export const getCaretPositionFromPoint = (x, y) => {
if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(x, y)
if (pos) return pos.offset
} else if (document.caretRangeFromPoint) {
const range = document.caretRangeFromPoint(x, y)
if (range) return range.startOffset
}
return null
}
================================================
FILE: src/modules/filelist/headers/CarbonCopy.jsx
================================================
import React from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CarbonCopyIcon from 'cozy-ui/transpiled/react/Icons/CarbonCopy'
import { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
import CertificationTooltip from '@/modules/certifications/CertificationTooltip'
const CarbonCopyHeader = () => {
const { t } = useI18n()
return (
}
/>
)
}
export default CarbonCopyHeader
================================================
FILE: src/modules/filelist/headers/ElectronicSafe.jsx
================================================
import React from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import SafeIcon from 'cozy-ui/transpiled/react/Icons/Safe'
import { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
import CertificationTooltip from '@/modules/certifications/CertificationTooltip'
const ElectronicSafeHeader = () => {
const { t } = useI18n()
return (
}
/>
)
}
export default ElectronicSafeHeader
================================================
FILE: src/modules/filelist/headers/index.jsx
================================================
export { default as CarbonCopy } from './CarbonCopy'
export { default as ElectronicSafe } from './ElectronicSafe'
================================================
FILE: src/modules/filelist/helpers.ts
================================================
import { splitFilename } from 'cozy-client/dist/models/file'
import type { IOCozyFile } from 'cozy-client/types/types'
import type { File } from '@/components/FolderPicker/types'
import {
TRASH_DIR_ID,
ROOT_DIR_ID,
SHARED_DRIVES_DIR_ID,
SHARINGS_VIEW_ROUTE
} from '@/constants/config'
import { isNextcloudShortcut } from '@/modules/nextcloud/helpers'
export const isDriveBackedFile = (file: File): boolean => !!file.driveId
export const makeParentFolderPath = (file: File): string => {
if (file.dir_id === SHARED_DRIVES_DIR_ID) {
return SHARINGS_VIEW_ROUTE
}
if (!file.path) return ''
return file.dir_id === ROOT_DIR_ID
? file.path.replace(file.name, '')
: file.path.replace(`/${file.name}`, '')
}
export const getFileNameAndExtension = (
file: File,
t: (key: string) => string
): {
title: string
filename: string
extension?: string
} => {
if (file._id === TRASH_DIR_ID) {
return {
title: t('FileName.trash'),
filename: t('FileName.trash')
}
}
// we can have ROOT_DIR_ID in some case, like in sharing view when fetching docs for the first time
// in that case we want to do the same trick as for SHARED_DRIVES_DIR_ID
if (file._id === SHARED_DRIVES_DIR_ID || file._id === ROOT_DIR_ID) {
return {
title: t('FileName.sharedDrive'),
filename: t('FileName.sharedDrive')
}
}
const { filename, extension } = splitFilename(file)
if (file._type === 'io.cozy.files' && isNextcloudShortcut(file)) {
return {
title: filename,
filename: filename
}
}
return {
title: file.name,
filename,
extension
}
}
export interface FileWithAntivirusScan {
antivirus_scan?: {
status?: 'clean' | 'infected' | 'skipped' | 'error' | 'pending'
}
}
export const isInfected = (
file?: (FileWithAntivirusScan & Partial) | null
): boolean => {
return file?.antivirus_scan?.status === 'infected'
}
export const isNotScanned = (
file?: (FileWithAntivirusScan & Partial) | null
): boolean => {
const status = file?.antivirus_scan?.status
return status === 'pending' || status === 'skipped' || status === 'error'
}
================================================
FILE: src/modules/filelist/icons/BadgeKonnector.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { isQueryLoading, isReferencedBy, useQuery } from 'cozy-client'
import Badge from 'cozy-ui/transpiled/react/Badge'
import { makeStyles } from 'cozy-ui/transpiled/react/styles'
import AppIcon from 'cozy-ui-plus/dist/AppIcon'
import { DOCTYPE_KONNECTORS } from '@/lib/doctypes'
import { getKonnectorSlugFromFile } from '@/lib/konnectors'
import { buildFileOrFolderByIdQuery } from '@/queries'
const useStyle = makeStyles({
badge: {
backgroundColor: 'var(--white)',
height: '1.5rem',
minWidth: '1.5rem',
borderRadius: '0.375rem',
border: '1px solid var(--borderMainColor)'
},
appIcon: {
width: '75%',
height: '75%'
},
anchorOriginBottomRightCircular: {
bottom: '10px'
}
})
export const BadgeKonnector = ({ file, children }) => {
const { badge, anchorOriginBottomRightCircular, appIcon } = useStyle()
const konnectorSlug = getKonnectorSlugFromFile(file)
// Check if the parent folder is a konnector folder, because if have no file in your account folder, its considered as a konnector folder
const parentFolderQuery = buildFileOrFolderByIdQuery(file.dir_id)
const { data: parentFolder, ...parentFolderQueryLeft } = useQuery(
parentFolderQuery.definition,
parentFolderQuery.options
)
const isParentQueryLoading = isQueryLoading(parentFolderQueryLeft)
const hasKonnectorParentFolder =
isReferencedBy(parentFolder, DOCTYPE_KONNECTORS) ||
// To guarantee the exclusion of account folders
(isReferencedBy(file, DOCTYPE_KONNECTORS) &&
isReferencedBy(file, 'io.cozy.accounts.sourceAccountIdentifier'))
const withoutKonnectorBadge =
isParentQueryLoading ||
hasKonnectorParentFolder ||
!isReferencedBy(file, DOCTYPE_KONNECTORS)
if (withoutKonnectorBadge) {
return <>{children}>
}
return (
}
>
{children}
)
}
BadgeKonnector.propTypes = {
file: PropTypes.object.isRequired
}
================================================
FILE: src/modules/filelist/icons/FileIcon.jsx
================================================
import React from 'react'
import FileImageLoader from 'cozy-ui-plus/dist/FileImageLoader'
import styles from '@/styles/filelist.styl'
import { isDriveBackedFile } from '@/modules/filelist/helpers'
import FileIconMime from '@/modules/filelist/icons/FileIconMime'
import FileIconShortcut from '@/modules/filelist/icons/FileIconShortcut'
const FileIcon = ({ file, size, viewType = 'list' }) => {
const isImage = file.class === 'image'
const isShortcut = file.class === 'shortcut' && !isDriveBackedFile(file)
if (isImage || file.class === 'pdf')
return (
(
)}
renderFallback={() => }
/>
)
else if (isShortcut) return
else return
}
export default FileIcon
================================================
FILE: src/modules/filelist/icons/FileIcon.spec.jsx
================================================
import { render } from '@testing-library/react'
import React from 'react'
import FileIcon from './FileIcon'
jest.mock('cozy-flags', () => () => true)
jest.mock('cozy-ui-plus/dist/FileImageLoader', () => () => (
))
describe('FileIcon', () => {
it('should return file image loader when file is image', () => {
// Given
const file = { class: 'image' }
// When
const { getByTestId } = render( )
// Then
expect(getByTestId('FileImageLoader')).toBeInTheDocument()
})
it('should return file image loader when file is pdf', () => {
// Given
const file = { class: 'pdf' }
// When
const { getByTestId } = render( )
// Then
expect(getByTestId('FileImageLoader')).toBeInTheDocument()
})
})
================================================
FILE: src/modules/filelist/icons/FileIconMime.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { isDirectory } from 'cozy-client/dist/models/file'
import Icon from 'cozy-ui/transpiled/react/Icon'
import getMimeTypeIcon from '@/lib/getMimeTypeIcon'
import { CustomizedIcon } from '@/modules/views/Folder/CustomizedIcon'
const FileIconMime = ({ file, size = 32 }) => {
const isDir = isDirectory(file)
if (
isDir &&
(file.metadata?.decorations?.color || file.metadata?.decorations?.icon)
) {
return (
)
} else {
return (
)
}
}
FileIconMime.propTypes = {
file: PropTypes.shape({
type: PropTypes.string,
mime: PropTypes.string,
name: PropTypes.string
}).isRequired,
size: PropTypes.number
}
export default FileIconMime
================================================
FILE: src/modules/filelist/icons/FileIconShortcut.jsx
================================================
import React, { useState } from 'react'
import { useClient, useFetchShortcut } from 'cozy-client'
import Icon from 'cozy-ui/transpiled/react/Icon'
import GlobeIcon from 'cozy-ui/transpiled/react/Icons/Globe'
const FileIconShortcut = ({ file, size = 32 }) => {
const client = useClient()
const { shortcutImg } = useFetchShortcut(client, file.id)
const [isBroken, setBroken] = useState(null)
return (
<>
{
setBroken(true)
}}
/>
>
)
}
export default FileIconShortcut
================================================
FILE: src/modules/filelist/icons/FileThumbnail.tsx
================================================
import React from 'react'
import { isReferencedBy, models } from 'cozy-client'
import { isDirectory } from 'cozy-client/dist/models/file'
import { SharedBadge, SharingOwnerAvatar } from 'cozy-sharing'
import Badge from 'cozy-ui/transpiled/react/Badge'
import Box from 'cozy-ui/transpiled/react/Box'
import Icon from 'cozy-ui/transpiled/react/Icon'
import FileTypeServerIcon from 'cozy-ui/transpiled/react/Icons/FileTypeServer'
import LinkIcon from 'cozy-ui/transpiled/react/Icons/Link'
import TrashDuotoneIcon from 'cozy-ui/transpiled/react/Icons/TrashDuotone'
import Spinner from 'cozy-ui/transpiled/react/Spinner'
import styles from '@/styles/filelist.styl'
import type { File, FolderPickerEntry } from '@/components/FolderPicker/types'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import { DOCTYPE_KONNECTORS } from '@/lib/doctypes'
import { isInfected, isDriveBackedFile } from '@/modules/filelist/helpers'
import { BadgeKonnector } from '@/modules/filelist/icons/BadgeKonnector'
import FileIcon from '@/modules/filelist/icons/FileIcon'
import FileIconMime from '@/modules/filelist/icons/FileIconMime'
import { SharingShortcutIcon } from '@/modules/filelist/icons/SharingShortcutIcon'
import {
isNextcloudShortcut,
isNextcloudFile
} from '@/modules/nextcloud/helpers'
interface FileThumbnailProps {
file: File | FolderPickerEntry
size?: number
isInSyncFromSharing?: boolean
showSharedBadge?: boolean
componentsProps?: {
sharedBadge?: object
}
}
const FileThumbnail: React.FC = ({
file,
size,
isInSyncFromSharing,
showSharedBadge = false,
componentsProps = {
sharedBadge: {}
}
}) => {
const { viewType } = useViewSwitcherContext()
const fileIcon =
if (isNextcloudFile(file)) {
return
}
if (file._id?.endsWith('.trash-dir')) {
return size && size >= 48 ? (
) : (
)
}
if (isNextcloudShortcut(file)) {
return (
)
}
const isSharingShortcut =
models.file.isSharingShortcut(file) &&
!isInSyncFromSharing &&
!isDriveBackedFile(file)
const isRegularShortcut =
!isSharingShortcut &&
file.class === 'shortcut' &&
!isInSyncFromSharing &&
!isDriveBackedFile(file)
const isSimpleFile =
!isSharingShortcut && !isRegularShortcut && !isInSyncFromSharing
const isFolder = isSimpleFile && isDirectory(file)
const isKonnectorFolder = isReferencedBy(file, DOCTYPE_KONNECTORS)
if (isFolder) {
if (size && size >= 48) {
return (
{isKonnectorFolder ? (
{fileIcon}
{file.class !== 'shortcut' &&
showSharedBadge &&
viewType === 'grid' && (
)}
) : (
<>
{fileIcon}
{file.class !== 'shortcut' &&
showSharedBadge &&
viewType === 'grid' && (
)}
>
)}
)
}
}
if (isKonnectorFolder) {
return {fileIcon}
}
const infected = isInfected(file)
const fileIconWithInfection = infected ? (
}
withBorder={false}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
>
{fileIcon}
) : (
fileIcon
)
return (
<>
{isSimpleFile && fileIconWithInfection}
{isRegularShortcut && (
<>
{viewType !== 'grid' ? (
}
>
{fileIcon}
) : (
fileIcon
)}
>
)}
{isSharingShortcut && (
}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
)}
{isInSyncFromSharing && (
)}
{/**
* @todo
* Since for shortcut we already display a kind of badge we're currently just
* not displaying the sharedBadge. Besides on desktop we have added sharing avatars.
* The next functionnal's task is to work on sharing and we'll remove
* this badge from here. In the meantime, we take this workaround
*/}
{file.class !== 'shortcut' &&
showSharedBadge &&
!isInSyncFromSharing &&
viewType === 'grid' && (
)}
>
)
}
export default FileThumbnail
================================================
FILE: src/modules/filelist/icons/SharingShortcutIcon.jsx
================================================
import React from 'react'
import {
getSharingShortcutTargetMime,
getSharingShortcutTargetDoctype
} from 'cozy-client/dist/models/file'
import Icon from 'cozy-ui/transpiled/react/Icon'
import { DOCTYPE_FILES } from '@/lib/doctypes'
import getMimeTypeIcon from '@/lib/getMimeTypeIcon'
import FileIconShortcut from '@/modules/filelist/icons/FileIconShortcut'
const SharingShortcutIcon = ({ file, size }) => {
const targetMimeType = getSharingShortcutTargetMime(file)
const targetDoctype = getSharingShortcutTargetDoctype(file)
const isShortcut = targetMimeType === 'application/internet-shortcut'
const targetIsDirectory =
targetMimeType === '' && targetDoctype === DOCTYPE_FILES
return isShortcut ? (
) : (
)
}
export { SharingShortcutIcon }
================================================
FILE: src/modules/filelist/useFormattedUpdatedAt.js
================================================
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
/**
* Returns the formatted "last updated" string for a file row, or undefined
* when the date is falsy.
*
* The guard matters: twake-i18n's `f()` calls date-fns `format()`, which
* throws on falsy/invalid dates. The library catches the throw but logs it
* via `console.error('Error in initFormat', ...)`, which our Sentry config
* captures. Synthetic rows in the file list (shared-drive entries, sharing
* placeholders) often lack `updated_at`/`created_at`, so we'd otherwise
* emit a Sentry event for every such row.
*
* @param {string | undefined} updatedAt
* @returns {string | undefined}
*/
export const useFormattedUpdatedAt = updatedAt => {
const { f, t } = useI18n()
const { isExtraLarge } = useBreakpoints()
if (!updatedAt) return undefined
return f(
updatedAt,
isExtraLarge
? t('table.row_update_format_full')
: t('table.row_update_format')
)
}
================================================
FILE: src/modules/filelist/virtualized/AddFolderRow.jsx
================================================
import React from 'react'
import FilenameInput from '@/modules/filelist/FilenameInput'
import FileIconMime from '@/modules/filelist/icons/FileIconMime'
const AddFolderRow = ({ onSubmit, onAbort }) => {
return (
)
}
export default AddFolderRow
================================================
FILE: src/modules/filelist/virtualized/GridFile.jsx
================================================
import cx from 'classnames'
import { filesize } from 'filesize'
import get from 'lodash/get'
import PropTypes from 'prop-types'
import React, { useState, useRef } from 'react'
import { useSelector } from 'react-redux'
import { isDirectory } from 'cozy-client/dist/models/file'
import Box from 'cozy-ui/transpiled/react/Box'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import {
SelectBox,
FileName,
Status,
FileAction,
SharingShortcutBadge
} from '../cells'
import styles from '@/styles/filelist.styl'
import { useClipboardContext } from '@/contexts/ClipboardProvider'
import { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader'
import { getContextMenuActions } from '@/modules/actions/helpers'
import { extraColumnsPropTypes } from '@/modules/certifications'
import {
isRenaming as isRenamingReducer,
getRenamingFile
} from '@/modules/drive/rename'
import FileOpener from '@/modules/filelist/FileOpener'
import FileThumbnail from '@/modules/filelist/icons/FileThumbnail'
import { useFormattedUpdatedAt } from '@/modules/filelist/useFormattedUpdatedAt'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
const GridFile = ({
t,
attributes,
actions,
isRenaming,
withSelectionCheckbox,
withFilePath,
disabled,
refreshFolderContent,
isInSyncFromSharing,
breakpoints: { isMobile },
disableSelection = false,
canInteractWith,
onContextMenu,
isOver,
onInteractWithFile
}) => {
const [actionMenuVisible, setActionMenuVisible] = useState(false)
const filerowMenuToggleRef = useRef()
const { toggleSelectedItem, isItemSelected, isSelectionBarVisible } =
useSelectionContext()
const { isItemCut } = useClipboardContext()
const { isNew } = useNewItemHighlightContext()
const toggleActionMenu = () => {
if (actionMenuVisible) return hideActionMenu()
else showActionMenu()
}
const showActionMenu = () => {
setActionMenuVisible(true)
}
const hideActionMenu = () => {
setActionMenuVisible(false)
}
const toggle = e => {
toggleSelectedItem(attributes)
onInteractWithFile?.(attributes?._id, e)
}
const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing
const selected = isItemSelected(attributes._id)
const isCut = isItemCut(attributes._id)
const formattedSize =
!isDirectory(attributes) && attributes.size
? filesize(attributes.size, { base: 10 })
: undefined
const updatedAt = attributes.updated_at || attributes.created_at
const formattedUpdatedAt = useFormattedUpdatedAt(updatedAt)
// We don't allow any action on shared drives and trash
// because they are magic folder created by the stack
let canInteractWithFile =
attributes._id &&
attributes._id !== 'io.cozy.files.shared-drives-dir' &&
!attributes._id.endsWith('.trash-dir')
if (typeof canInteractWith === 'function') {
canInteractWithFile &&= canInteractWith(attributes)
}
const contextMenuActions = getContextMenuActions(actions)
return (
0
}
selected={selected}
onClick={e => toggle(e)}
disabled={
!canInteractWithFile ||
isRowDisabledOrInSyncFromSharing ||
disableSelection
}
/>
{contextMenuActions && canInteractWithFile && (
{
toggleActionMenu()
}}
/>
)}
{contextMenuActions && actionMenuVisible && (
)}
)
}
GridFile.propTypes = {
t: PropTypes.func,
attributes: PropTypes.object.isRequired,
actions: PropTypes.array,
isRenaming: PropTypes.bool,
withSelectionCheckbox: PropTypes.bool.isRequired,
withFilePath: PropTypes.bool,
onContextMenu: PropTypes.func,
/** Disables row actions */
disabled: PropTypes.bool,
/** Apply disabled style on row */
breakpoints: PropTypes.object.isRequired,
refreshFolderContent: PropTypes.func,
isInSyncFromSharing: PropTypes.bool,
extraColumns: extraColumnsPropTypes,
/** Disables the ability to select a file */
disableSelection: PropTypes.bool,
isOver: PropTypes.bool
}
export const DumbGridFile = props => {
const { t } = useI18n()
const breakpoints = useBreakpoints()
return
}
export const GridFileWithSelection = props => {
const isRenaming = useSelector(
state =>
isRenamingReducer(state) &&
get(getRenamingFile(state), 'id') === props.attributes.id
)
return
}
================================================
FILE: src/modules/filelist/virtualized/cells/Cell.jsx
================================================
import { filesize } from 'filesize'
import get from 'lodash/get'
import React, { useContext, useReducer, useRef } from 'react'
import { useSelector } from 'react-redux'
import { isDirectory } from 'cozy-client/dist/models/file'
import { isSharingShortcut } from 'cozy-client/dist/models/file'
import { useVaultClient } from 'cozy-keys-lib'
import { useSharingContext } from 'cozy-sharing'
import AcceptingSharingContext from '@/lib/AcceptingSharingContext'
import { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader'
import { getContextMenuActions } from '@/modules/actions/helpers'
import { filterActionsByPolicy } from '@/modules/actions/policies'
import {
isRenaming as isRenamingSelector,
getRenamingFile
} from '@/modules/drive/rename'
import AddFolder from '@/modules/filelist/AddFolder'
import FileOpener from '@/modules/filelist/FileOpener'
import { useFormattedUpdatedAt } from '@/modules/filelist/useFormattedUpdatedAt'
import FileAction from '@/modules/filelist/virtualized/cells/FileAction'
import FileName from '@/modules/filelist/virtualized/cells/FileName'
import LastUpdate from '@/modules/filelist/virtualized/cells/LastUpdate'
import Share from '@/modules/filelist/virtualized/cells/Share'
import Size from '@/modules/filelist/virtualized/cells/Size'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import { isReferencedByShareInSharingContext } from '@/modules/views/Folder/syncHelpers'
const Cell = ({
row,
column,
cell,
currentFolderId,
withFilePath,
actions,
onInteractWithFile,
refreshFolderContent,
driveId
}) => {
const vaultClient = useVaultClient()
const { sharingsValue } = useContext(AcceptingSharingContext)
const { byDocId } = useSharingContext()
const filerowMenuToggleRef = useRef()
const { toggleSelectedItem } = useSelectionContext()
const [showActionMenu, toggleShowActionMenu] = useReducer(
state => !state,
false
)
const isRenaming = useSelector(
state =>
isRenamingSelector(state) && get(getRenamingFile(state), 'id') === row.id
)
const updatedAt = row.updated_at || row.created_at
const formattedUpdatedAt = useFormattedUpdatedAt(updatedAt)
if (row.type === 'tempDirectory') {
if (column.id === 'name') {
return (
)
}
if (column.id === 'menu') {
return null
}
return '—'
}
const formattedSize =
!isDirectory(row) && row.size ? filesize(row.size, { base: 10 }) : undefined
const isSharingContextEmpty = Object.keys(sharingsValue).length <= 0
const isInSyncFromSharing =
!isSharingContextEmpty &&
isSharingShortcut(row) &&
isReferencedByShareInSharingContext(row, sharingsValue)
if (column.id === 'name') {
if (!cell) {
return '—'
}
const toggle = e => {
e.stopPropagation()
toggleSelectedItem(row)
}
return (
)
}
if (column.id === 'updated_at') {
if (!cell) {
return '—'
}
return
}
if (column.id === 'size') {
if (!cell) {
return '—'
}
return
}
if (column.id === 'share') {
const isShared = byDocId[row.id] !== undefined
if (isInSyncFromSharing || !isShared) {
return '—'
}
return
}
if (column.id === 'menu') {
// We don't allow any action on shared drives and trash
// because they are magic folder created by the stack
const canInteractWithFile =
row._id &&
row._id !== 'io.cozy.files.shared-drives-dir' &&
!row._id.endsWith('.trash-dir')
if (!actions || !canInteractWithFile) {
return null
}
const filteredActions = filterActionsByPolicy(actions, [row])
const contextMenuActions = getContextMenuActions(filteredActions)
return (
<>
{contextMenuActions && showActionMenu && (
)}
>
)
}
return <>{cell}>
}
const CellMemo = React.memo(Cell)
const CellWrapper = ({
row,
column,
cell,
currentFolderId,
withFilePath,
actions,
onInteractWithFile,
refreshFolderContent,
driveId
}) => {
return (
)
}
export default CellWrapper
================================================
FILE: src/modules/filelist/virtualized/cells/FileAction.jsx
================================================
import React, { forwardRef } from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconButton from 'cozy-ui/transpiled/react/IconButton'
import DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import { useI18n } from 'twake-i18n'
const FileAction = forwardRef(function FileAction({ onClick, disabled }, ref) {
const { t } = useI18n()
return (
)
})
export default FileAction
================================================
FILE: src/modules/filelist/virtualized/cells/FileName.jsx
================================================
import React from 'react'
import { isDirectory } from 'cozy-client/dist/models/file'
import Filename from 'cozy-ui/transpiled/react/Filename'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'
import RenameInput from '@/modules/drive/RenameInput'
import {
getFileNameAndExtension,
isInfected,
makeParentFolderPath
} from '@/modules/filelist/helpers'
import FileThumbnail from '@/modules/filelist/icons/FileThumbnail'
import FileNamePath from '@/modules/filelist/virtualized/cells/FileNamePath'
const FileThumbnailComponent = ({ file, isInSyncFromSharing, isMobile }) => {
const { isBigThumbnail } = useThumbnailSizeContext()
return (
)
}
const FileName = ({
attributes,
isRenaming,
withFilePath,
formattedSize,
formattedUpdatedAt,
refreshFolderContent,
isInSyncFromSharing
}) => {
const { t } = useI18n()
const { title, filename, extension } = getFileNameAndExtension(attributes, t)
const { isMobile } = useBreakpoints()
const parentFolderPath = makeParentFolderPath(attributes)
const hidePath = withFilePath
? !parentFolderPath
: isDirectory(attributes) || !isMobile
const infected = isInfected(attributes)
if (isRenaming) {
return (
)
}
return (
}
variant="body1"
filename={filename}
extension={extension}
midEllipsis
path={
hidePath ? undefined : (
)
}
/>
)
}
export default FileName
================================================
FILE: src/modules/filelist/virtualized/cells/FileNamePath.jsx
================================================
import React from 'react'
import { Link } from 'react-router-dom'
import MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/filelist.styl'
import { SHARINGS_VIEW_ROUTE } from '@/constants/config'
import CertificationsIcons from '@/modules/filelist/cells/CertificationsIcons.jsx'
import { getFileNameAndExtension } from '@/modules/filelist/helpers'
import { getFolderPath } from '@/modules/routeUtils'
const FileNamePath = ({
attributes,
withFilePath,
formattedSize,
formattedUpdatedAt,
parentFolderPath
}) => {
const { isMobile } = useBreakpoints()
const { t } = useI18n()
const { filename, extension } = getFileNameAndExtension(attributes, t)
if (!withFilePath) {
return (
{`${formattedUpdatedAt}${formattedSize ? ` - ${formattedSize}` : ''}`}
)
}
if (isMobile) {
return (
)
}
const to = attributes.driveId
? SHARINGS_VIEW_ROUTE
: getFolderPath(attributes.dir_id)
return (
)
}
export default FileNamePath
================================================
FILE: src/modules/filelist/virtualized/cells/LastUpdate.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { useI18n } from 'twake-i18n'
const LastUpdate = ({ date, formatted }) => {
const { f, t } = useI18n()
return (
{formatted}
)
}
LastUpdate.propTypes = {
date: PropTypes.string,
formatted: PropTypes.string
}
export default React.memo(LastUpdate)
================================================
FILE: src/modules/filelist/virtualized/cells/Share.jsx
================================================
import React from 'react'
import ShareContent from './ShareContent'
import SharingShortcutBadge from './SharingShortcutBadge'
const Share = ({ row, isRowDisabledOrInSyncFromSharing }) => {
return (
<>
>
)
}
export default Share
================================================
FILE: src/modules/filelist/virtualized/cells/ShareContent.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { SharedStatus } from 'cozy-sharing'
import styles from '@/styles/filelist.styl'
import { joinPath } from '@/lib/path'
const ShareContent = ({ file, disabled }) => {
const navigate = useNavigate()
const { pathname } = useLocation()
const handleClick = e => {
// Avoid to trigger row click from FileOpener
e.preventDefault()
e.stopPropagation()
if (!disabled) {
// should be only disabled
navigate(joinPath(pathname, `file/${file._id}/share`))
}
}
return (
)
}
export default ShareContent
================================================
FILE: src/modules/filelist/virtualized/cells/SharingShortcutBadge.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { isSharingShortcutNew } from 'cozy-client/dist/models/file'
import Avatar from 'cozy-ui/transpiled/react/Avatar'
import { useI18n } from 'twake-i18n'
const SharingShortcutBadge = ({ file }) => {
const { t } = useI18n()
if (isSharingShortcutNew(file)) {
return (
1
)
}
return null
}
SharingShortcutBadge.propTypes = {
file: PropTypes.object,
isInSyncFromSharing: PropTypes.bool
}
export default SharingShortcutBadge
================================================
FILE: src/modules/filelist/virtualized/cells/Size.jsx
================================================
import React from 'react'
const _Size = ({ filesize }) => <>{filesize}>
const Size = React.memo(_Size)
export default Size
================================================
FILE: src/modules/folder/components/FolderBody.jsx
================================================
import cx from 'classnames'
import React, { useCallback } from 'react'
import { useVaultClient } from 'cozy-keys-lib'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import styles from '@/styles/folder-view.styl'
import { EmptyDrive } from '@/components/Error/Empty'
import Oops from '@/components/Error/Oops'
import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'
import { useFolderSort } from '@/hooks'
import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import AddFolder from '@/modules/filelist/AddFolder'
import { FileWithSelection as File } from '@/modules/filelist/File'
import { FileList } from '@/modules/filelist/FileList'
import FileListBody from '@/modules/filelist/FileListBody'
import { FileListHeader } from '@/modules/filelist/FileListHeader'
import FileListRowsPlaceholder from '@/modules/filelist/FileListRowsPlaceholder'
import LoadMore from '@/modules/filelist/LoadMoreV2'
import { useNeedsToWait } from '@/modules/folder/hooks/useNeedsToWait'
import { useScrollToTop } from '@/modules/folder/hooks/useScrollToTop'
import SelectionBar from '@/modules/selection/SelectionBar'
/**
* Renders the body of a folder, displaying the list of files and folders within it.
*
* @component
* @param {Object} props - The component props.
* @param {string} props.folderId - The ID of the folder.
* @param {Array} props.queryResults - The results of the queries for the folder content.
* @param {Object} [props.actions] - The actions available for the folder.
* @param {import('modules/certifications/useExtraColumns').ExtraColumn[]} props.extraColumns - The extra columns to display in the file list.
* @param {boolean} [props.canSort] - Indicates whether sorting is enabled for the file list.
* @param {Function} [props.refreshFolderContent] - The function to refresh the folder content.
* @param {boolean} [props.withFilePath] - Indicates whether to display the file path.
* @param {boolean} [props.isInSyncFromSharing] - Indicates whether the folder is in sync from sharing.
* @param {Function} [props.renderEmptyComponent] - The function to render the empty component.
* @param {Function} [props.canInteractWith] - Indicates whether the user can interact with the file.
*/
const FolderBody = ({
folderId,
queryResults,
actions,
extraColumns,
canSort,
refreshFolderContent,
withFilePath,
isInSyncFromSharing,
renderEmptyComponent = () => {
return
},
canInteractWith,
driveId
}) => {
const vaultClient = useVaultClient()
const { isDesktop } = useBreakpoints()
useScrollToTop(folderId)
const [sortOrder, setSortOrder, isSettingsLoaded] = useFolderSort(folderId)
const isError = queryResults.some(query => query.fetchStatus === 'failed')
const hasData =
!isError && queryResults.some(query => query.data && query.data.length > 0)
const isLoading =
!hasData &&
queryResults.some(
query => query.fetchStatus === 'loading' && !query.lastUpdate
) &&
!isSettingsLoaded
const isEmpty = !isError && !isLoading && !hasData
const needsToWait = useNeedsToWait({ isLoading })
const { isBigThumbnail } = useThumbnailSizeContext()
const { viewType, switchView } = useViewSwitcherContext()
const changeSortOrder = useCallback(
(folderId_legacy, attribute, order) => setSortOrder({ attribute, order }),
[setSortOrder]
)
return (
<>
{hasData ? (
) : null}
{!hasData && !needsToWait && (
)}
{isError ? : null}
{isLoading || needsToWait ? : null}
{isEmpty ? renderEmptyComponent() : null}
{hasData && !needsToWait ? (
{queryResults.map((query, queryIndex) => (
{query.data.map(file => {
return (
)
})}
{query.hasMore && }
))}
) : null}
>
)
}
export { FolderBody }
================================================
FILE: src/modules/folder/hooks/useNeedsToWait.jsx
================================================
import { useEffect, useState } from 'react'
/**
* When we mount the component when we already have data in cache,
* the mount is time consuming since we'll render at least 100 lines
* of File.
*
* React seems to batch together the fact that :
* - we change a route
* - we want to render 100 files
* resulting in a non smooth transition between views (Drive / Recent / ...)
*
* In order to bypass this batch, we use a state to first display a much
* more simpler component and then the files
*/
const useNeedsToWait = ({ isLoading }) => {
const [needsToWait, setNeedsToWait] = useState(true)
useEffect(() => {
let timeout = null
if (!isLoading) {
timeout = setTimeout(() => {
setNeedsToWait(false)
}, 50)
}
return () => clearTimeout(timeout)
}, [isLoading])
return needsToWait
}
export { useNeedsToWait }
================================================
FILE: src/modules/folder/hooks/useScrollToTop.jsx
================================================
import { useEffect } from 'react'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
/**
* Since we are not able to restore the scroll correctly,
* and force the scroll to top every time we change the
* current folder. This is to avoid this kind of weird
* behavior:
* - If I go to a sub-folder, if this subfolder has a lot
* of data and I scrolled down until the bottom. If I go
* back, then my folder will also be scrolled down.
*
* This is an ugly hack, yeah.
* */
const useScrollToTop = folderId => {
const { isDesktop } = useBreakpoints()
useEffect(() => {
if (isDesktop) {
const scrollable = document.querySelectorAll(
'[data-testid=fil-content-body]'
)[0]
if (scrollable) {
scrollable.scroll({ top: 0 })
}
} else {
window.scroll({ top: 0 })
}
}, [isDesktop, folderId])
}
export { useScrollToTop }
================================================
FILE: src/modules/layout/DummyLayout.tsx
================================================
import React from 'react'
import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite'
import { Layout } from 'cozy-ui/transpiled/react/Layout'
const DummyLayout: React.FC = ({ children }) => {
return (
{children}
)
}
export { DummyLayout }
================================================
FILE: src/modules/layout/Layout.jsx
================================================
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Outlet, useNavigate } from 'react-router-dom'
import { BarComponent } from 'cozy-bar'
import CozyDevtools from 'cozy-devtools'
import flag from 'cozy-flags'
import FlagSwitcher from 'cozy-flags/dist/FlagSwitcher'
import { useSharingContext } from 'cozy-sharing'
import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite'
import { Layout as LayoutUI } from 'cozy-ui/transpiled/react/Layout'
import Sidebar from 'cozy-ui/transpiled/react/Sidebar'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import Storage from 'cozy-ui-plus/dist/Storage'
import { useI18n } from 'twake-i18n'
import Drive from '@/components/Icons/Drive'
import DriveText from '@/components/Icons/DriveText'
import ButtonClient from '@/components/pushClient/Button'
import { ROOT_DIR_ID } from '@/constants/config'
import { useDisplayedFolder } from '@/hooks'
import { initFlags } from '@/lib/flags'
import AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'
import AddButton from '@/modules/drive/Toolbar/components/AddButton'
import Nav from '@/modules/navigation/Nav'
import { NavProvider, useNavContext } from '@/modules/navigation/NavContext'
import {
wasOperationRedirected,
RESET_OPERATION_REDIRECTED
} from '@/modules/navigation/duck/reducer'
import { SelectionProvider } from '@/modules/selection/SelectionProvider'
import { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider'
import UploadButton from '@/modules/upload/UploadButton'
import UploadQueue from '@/modules/upload/UploadQueue'
initFlags()
const LayoutContent = () => {
const navigate = useNavigate()
const dispatch = useDispatch()
const { isMobile, isDesktop } = useBreakpoints()
const { displayedFolder } = useDisplayedFolder()
const { hasWriteAccess } = useSharingContext()
const { t } = useI18n()
const shouldRedirect = useSelector(wasOperationRedirected)
const [, setLastClicked] = useNavContext()
useEffect(() => {
if (shouldRedirect) {
// Update lastClicked state to ensure sidebar shows the correct active item
setLastClicked(`/folder/${ROOT_DIR_ID}`)
navigate(`/folder/${ROOT_DIR_ID}`)
dispatch({ type: RESET_OPERATION_REDIRECTED })
}
}, [shouldRedirect, navigate, dispatch, setLastClicked])
const isFolderReadOnly = displayedFolder
? !hasWriteAccess(displayedFolder._id, displayedFolder.driveId)
: false
return (
ev.preventDefault()}>
{isDesktop && (
)}
{flag('debug') && }
)
}
const Layout = () => {
return (
)
}
export default Layout
================================================
FILE: src/modules/layout/Main.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { RealTimeQueries } from 'cozy-client'
import { Main as MainUI } from 'cozy-ui/transpiled/react/Layout'
import { MigrationProgressBanner } from '@/components/Migration/MigrationProgressBanner'
import PushBanner from '@/components/PushBanner'
import { NEXTCLOUD_MIGRATIONS_DOCTYPE } from '@/lib/doctypes'
const Main = ({ children, isPublic = false }) => (
{!isPublic && (
<>
>
)}
{children}
)
Main.propTypes = {
isPublic: PropTypes.bool,
children: PropTypes.array
}
export default Main
================================================
FILE: src/modules/layout/Topbar.jsx
================================================
import classNames from 'classnames'
import React from 'react'
import styles from '@/styles/topbar.styl'
const Topbar = ({ children, hideOnMobile = true }) => (
{children}
)
export default Topbar
================================================
FILE: src/modules/move/MoveInsideSharedFolderModal.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { useQuery } from 'cozy-client'
import Buttons from 'cozy-ui/transpiled/react/Buttons'
import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import { useI18n } from 'twake-i18n'
import { LoaderModal } from '@/components/LoaderModal'
import { getEntriesTypeTranslated } from '@/lib/entries'
import {
buildFileOrFolderByIdQuery,
buildSharedDriveFileOrFolderByIdQuery
} from '@/queries'
/**
* Alert the user when is trying to move a folder/file inside of a shared folder
*/
const MoveInsideSharedFolderModal = ({
entries,
folderId,
driveId,
onCancel,
onConfirm
}) => {
const { t } = useI18n()
const folderQuery = driveId
? buildSharedDriveFileOrFolderByIdQuery({ fileId: folderId, driveId })
: buildFileOrFolderByIdQuery(folderId)
const { fetchStatus, data } = useQuery(
folderQuery.definition,
folderQuery.options
)
if (fetchStatus === 'loaded') {
const type = getEntriesTypeTranslated(t, entries)
return (
>
}
/>
)
}
return
}
MoveInsideSharedFolderModal.propTypes = {
/** List of files or folder to move */
entries: PropTypes.array.isRequired,
/** Function called when the user cancels the move action */
onCancel: PropTypes.func.isRequired,
/** Function called when the user confirms the move action */
onConfirm: PropTypes.func.isRequired
}
export { MoveInsideSharedFolderModal }
================================================
FILE: src/modules/move/MoveModal.jsx
================================================
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { useClient } from 'cozy-client'
import { useSharingContext } from 'cozy-sharing'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { useMove } from './hooks/useMove'
import { FolderPicker } from '@/components/FolderPicker/FolderPicker'
import logger from '@/lib/logger'
import { joinPath, getParentPath } from '@/lib/path'
import { MoveInsideSharedFolderModal } from '@/modules/move/MoveInsideSharedFolderModal'
import { MoveOutsideSharedFolderModal } from '@/modules/move/MoveOutsideSharedFolderModal'
import { MoveSharedFolderInsideAnotherModal } from '@/modules/move/MoveSharedFolderInsideAnotherModal'
import { hasOneOfEntriesShared } from '@/modules/move/helpers'
import { useCancelable } from '@/modules/move/hooks/useCancelable'
import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'
import { executeMove } from '@/modules/paste'
/**
* Modal to move a folder to an other
*/
const MoveModal = ({
onClose,
currentFolder,
entries,
showNextcloudFolder,
onMovingSuccess,
isPublic,
showSharedDriveFolder,
driveId
}) => {
const client = useClient()
const {
sharedPaths,
refresh: refreshSharing,
getSharedParentPath,
hasSharedParent,
isOwner,
revokeSelf,
revokeAllRecipients,
byDocId,
allLoaded
} = useSharingContext()
const { registerCancelable } = useCancelable()
const { showSuccess } = useMove({ entries })
const { t } = useI18n()
const { showAlert } = useAlert()
const [folderSelected, setFolderSelected] = useState(null)
const [isMoveInProgress, setMoveInProgress] = useState(false)
const [isMovingOutsideSharedFolder, setMovingOutsideSharedFolder] =
useState(false)
const [
isMovingSharedFolderInsideAnother,
setMovingSharedFolderInsideAnother
] = useState(false)
const [isMovingInsideSharedFolder, setMovingInsideSharedFolder] =
useState(false)
const handleConfirm = async folder => {
setFolderSelected(folder)
const sharedParentPath = getSharedParentPath(entries[0].path)
const targetPath = joinPath(folder.path, entries[0].name)
const areMovedFilesShared = hasOneOfEntriesShared(entries, byDocId)
const isOriginParentShared = hasSharedParent(entries[0].path) || !!driveId
const isTargetShared =
hasSharedParent(targetPath) ||
(!!folder.driveId && folder.driveId !== driveId)
const isInsideSameSharedFolder =
(sharedParentPath && targetPath.startsWith(sharedParentPath)) ||
(!!folder.driveId && !!driveId && folder.driveId === driveId) ||
isPublic
if (isInsideSameSharedFolder) {
moveEntries(folder)
return
}
if (isOriginParentShared && !isTargetShared) {
setMovingOutsideSharedFolder(true)
return
}
if (!areMovedFilesShared && isTargetShared) {
setMovingInsideSharedFolder(true)
return
}
if (areMovedFilesShared && isTargetShared) {
setMovingSharedFolderInsideAnother(true)
return
}
moveEntries(folder)
}
const moveEntries = async folder => {
try {
setMoveInProgress(true)
const trashedFiles = []
const force = !sharedPaths.includes(folder.path)
await Promise.all(
entries.map(async entry => {
const moveResponse = await registerCancelable(
executeMove(client, entry, currentFolder, folder, force)
)
if (moveResponse.deleted) {
trashedFiles.push(moveResponse.deleted)
}
})
)
const isMovingInsideNextcloud =
folder._type === 'io.cozy.remote.nextcloud.files'
const isMovingOutsideNextcloud =
!isMovingInsideNextcloud &&
entries[0]._type === 'io.cozy.remote.nextcloud.files'
refreshNextcloudQueries({
isMovingInsideNextcloud,
isMovingOutsideNextcloud,
folder
})
showSuccess({
folder,
trashedFiles,
refreshSharing,
canCancel: !isMovingInsideNextcloud && !isMovingOutsideNextcloud
})
if (refreshSharing) refreshSharing()
onMovingSuccess?.()
} catch (e) {
logger.warn(e)
showAlert({
message: t('Move.error', { smart_count: entries.length }),
severity: 'error'
})
} finally {
setMoveInProgress(false)
onClose()
}
}
/**
* The content from nextcloud queries must be refreshed when moving files
* This is only a proxy to Nextcloud queries so we don't have real-time or mutations updates
*/
const refreshNextcloudQueries = ({
isMovingOutsideNextcloud,
isMovingInsideNextcloud,
folder
}) => {
if (isMovingInsideNextcloud) {
client.resetQuery(
computeNextcloudFolderQueryId({
sourceAccount: folder.cozyMetadata.sourceAccount,
path: folder.path
})
)
}
if (isMovingOutsideNextcloud) {
client.resetQuery(
computeNextcloudFolderQueryId({
sourceAccount: entries[0].cozyMetadata.sourceAccount,
path: getParentPath(entries[0].path)
})
)
}
}
const handleCancelMovingOutside = () => {
setMovingOutsideSharedFolder(false)
}
const handleConfirmMovingOutside = () => {
setMovingOutsideSharedFolder(false)
moveEntries(folderSelected)
}
const handleCancelMovingInside = () => {
setMovingInsideSharedFolder(false)
}
const handleConfirmMovingInside = () => {
setMovingInsideSharedFolder(false)
moveEntries(folderSelected)
}
const handleMovingSharedFolderInsideAnother = async () => {
setMoveInProgress(true)
entries.forEach(async entry => {
if (byDocId[entry._id] !== undefined) {
if (isOwner(entry._id)) {
await revokeAllRecipients(entry)
} else {
await revokeSelf(entry)
}
}
})
refreshSharing()
moveEntries(folderSelected)
setMovingSharedFolderInsideAnother(false)
}
return (
<>
{isMovingOutsideSharedFolder ? (
) : null}
{isMovingSharedFolderInsideAnother ? (
setMovingSharedFolderInsideAnother(false)}
onConfirm={handleMovingSharedFolderInsideAnother}
/>
) : null}
{isMovingInsideSharedFolder ? (
) : null}
>
)
}
MoveModal.propTypes = {
/** List of files or folder to move */
entries: PropTypes.array,
onMovingSuccess: PropTypes.func
}
export { MoveModal }
export default MoveModal
================================================
FILE: src/modules/move/MoveModal.spec.jsx
================================================
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
import { createMockClient, useQuery } from 'cozy-client'
import { move } from 'cozy-client/dist/models/file'
import { useSharingContext } from 'cozy-sharing'
import { MoveModal } from './MoveModal'
import AppLike from 'test/components/AppLike'
import { ROOT_DIR_ID } from '@/constants/config'
import { CozyFile } from '@/models'
jest.mock('cozy-sharing', () => ({
...jest.requireActual('cozy-sharing'),
useSharingContext: jest.fn()
}))
jest.mock('cozy-doctypes')
CozyFile.doctype = 'io.cozy.files'
const onCloseSpy = jest.fn()
const refreshSpy = jest.fn()
jest.mock('cozy-client/dist/models/file', () => ({
move: jest.fn(),
isFile: jest.fn(),
moveRelateToSharedDrive: jest.fn()
}))
jest.mock('cozy-client', () => ({
...jest.requireActual('cozy-client'),
useQuery: jest.fn()
}))
CozyFile.splitFilename.mockImplementation(({ name }) => ({
filename: name,
extension: ''
}))
jest.mock('components/FolderPicker/FolderPicker', () => ({
FolderPicker: ({ onConfirm, currentFolder, isBusy }) => {
const handleClick = () => {
onConfirm(currentFolder)
}
return (
{currentFolder.name}
Move
Close
)
}
}))
describe('MoveModal component', () => {
const defaultEntries = [
{
_id: 'bill_201901',
dir_id: 'bills',
name: 'bill_201901.pdf',
path: '/bills/bill_201901.pdf'
},
{
_id: 'bill_201902',
dir_id: 'bills',
name: 'bill_201902.pdf',
path: '/bills/bill_201902.pdf'
},
// shared file:
{
_id: 'bill_201903',
dir_id: 'bills',
name: 'bill_201903.pdf',
path: '/bills/bill_201903.pdf'
}
]
const destinationFolder = {
id: 'destinationFolder',
_id: 'destinationFolder',
_type: 'io.cozy.files',
name: 'Destination Folder',
path: '/Destination Folder'
}
const mockClient = createMockClient({
queries: {
'moveOrImport-destinationFolder': {
doctype: 'io.cozy.files',
data: []
},
'io.cozy.files/destinationFolder': {
doctype: 'io.cozy.files',
data: [
{
_id: 'destinationFolder',
dir_id: ROOT_DIR_ID,
name: 'Destination Folder',
type: 'directory'
}
]
},
'io.cozy.files/path/bills': {
doctype: 'io.cozy.files',
data: [
{
_id: 'bills',
dir_id: ROOT_DIR_ID,
name: 'Bills',
type: 'directory'
}
]
}
}
})
const setup = ({
entries = defaultEntries,
sharedPaths = ['/sharedFolder'],
byDocId = {},
getSharedParentPath = () => null,
allLoaded = true,
sharingContext = {},
currentFolder = destinationFolder
} = {}) => {
const props = {
entries,
onClose: onCloseSpy,
classes: { paper: {} }
}
// Mock the useQuery hook for shared folder data
const sharedParentPath = getSharedParentPath(entries[0]?.path || '')
if (sharedParentPath) {
const folderName = sharedParentPath.split('/').pop() || 'Bills'
useQuery.mockReturnValue({
fetchStatus: 'loaded',
data: [{ name: folderName }]
})
} else {
useQuery.mockReturnValue({
fetchStatus: 'loaded',
data: []
})
}
useSharingContext.mockReturnValue({
sharedPaths,
refresh: refreshSpy,
getSharedParentPath,
hasSharedParent: path =>
sharedPaths.filter(sharedPath => path.includes(sharedPath)).length > 0,
byDocId,
allLoaded,
...sharingContext
})
CozyFile.getFullpath.mockImplementation(
(destinationFolder, name) => `/${destinationFolder}/${name}`
)
move.mockImplementation(id => {
if (id === 'bill_201902') {
return Promise.resolve({
deleted: 'other_bill_201902',
moved: { id }
})
} else {
return Promise.resolve({
deleted: null,
moved: { id }
})
}
})
return render(
)
}
describe('MoveModal', () => {
it('should wait for shares to load before authorising moves', async () => {
await waitFor(async () => {
setup({ allLoaded: false })
})
const moveButton = await screen.findByRole('button', {
name: 'Move'
})
expect(moveButton).toBeDisabled()
})
it('should move entries to destination', async () => {
CozyFile.getFullpath.mockImplementation((destinationFolder, name) =>
Promise.resolve(
name === 'bill_201903.pdf' ? '/bills/bill_201903.pdf' : '/whatever'
)
)
setup()
const moveButton = await screen.findByText('Move')
fireEvent.click(moveButton)
await waitFor(() => {
expect(move).toHaveBeenNthCalledWith(
1,
mockClient,
defaultEntries[0],
destinationFolder,
{ force: true }
)
expect(move).toHaveBeenNthCalledWith(
2,
mockClient,
defaultEntries[1],
destinationFolder,
{ force: true }
)
// don't force a shared file
expect(move).toHaveBeenNthCalledWith(
3,
mockClient,
defaultEntries[2],
destinationFolder,
{ force: true }
)
expect(onCloseSpy).toHaveBeenCalled()
expect(refreshSpy).toHaveBeenCalled()
// TODO: check that trashedFiles are passed to cancel button
})
})
})
describe('move outside shared folder', () => {
it('should display an alert when moving files outside a shared folder', async () => {
setup({
sharedPaths: ['/bills'],
getSharedParentPath: path =>
path.includes('/bills') ? '/bills' : null,
byDocId: {}
})
const moveButton = await screen.findByText('Move')
fireEvent.click(moveButton)
await waitFor(() => {
expect(
screen.getByText('Moving outside the bills folder')
).toBeInTheDocument()
})
})
it('should move files when user confirms', async () => {
setup({
sharedPaths: ['/bills'],
getSharedParentPath: path =>
path.includes('/bills') ? '/bills' : null,
byDocId: {}
})
const moveButton = await screen.findByText('Move')
fireEvent.click(moveButton)
await waitFor(() => {
const confirmButton = screen.getByText('I understand')
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(move).toHaveBeenCalled()
expect(onCloseSpy).toHaveBeenCalled()
expect(refreshSpy).toHaveBeenCalled()
})
})
})
describe('move inside shared folder', () => {
it('should display an alert when moving files inside a shared folder', async () => {
setup({
sharedPaths: ['/Destination Folder'],
getSharedParentPath: path =>
path.includes('/Destination Folder') ? '/Destination Folder' : null,
byDocId: {}
})
const moveButton = await screen.findByText('Move')
fireEvent.click(moveButton)
const modalTitle = await screen.findByText('Move to a shared folder?')
expect(modalTitle).toBeInTheDocument()
})
it('should move files when user confirms', async () => {
setup({
sharedPaths: ['/Destination Folder'],
getSharedParentPath: path =>
path.includes('/Destination Folder') ? '/Destination Folder' : null,
byDocId: {}
})
const moveButton = await screen.findByText('Move')
fireEvent.click(moveButton)
const confirmButton = await screen.findByText('Ok')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(move).toHaveBeenCalled()
expect(onCloseSpy).toHaveBeenCalled()
expect(refreshSpy).toHaveBeenCalled()
})
})
})
describe('move shared folder inside another', () => {
it('should display an alert when move shared folder inside another', async () => {
CozyFile.getFullpath.mockImplementation((destinationFolder, name) =>
Promise.resolve(`/${destinationFolder}/${name}`)
)
setup({
sharedPaths: ['/bills', '/Destination Folder'],
byDocId: {
bill_201903: {
permissions: [],
sharings: ['sharing-id-1']
}
},
getSharedParentPath: path =>
path.includes('/bills')
? '/bills'
: path.includes('/Destination Folder')
? '/Destination Folder'
: null
})
const moveButton = await screen.findByText('Move')
fireEvent.click(moveButton)
await waitFor(() => {
expect(screen.getByText('Cannot be moved')).toBeInTheDocument()
})
})
it('should move files after revoke all recipients when folder owner confirms', async () => {
CozyFile.getFullpath.mockImplementation((destinationFolder, name) =>
Promise.resolve(`/${destinationFolder}/${name}`)
)
const revokeAllSpy = jest.fn()
const revokeSelfSpy = jest.fn()
setup({
sharedPaths: ['/bills', '/Destination Folder'],
byDocId: {
bill_201903: {
permissions: [],
sharings: ['sharing-id-1']
}
},
getSharedParentPath: path =>
path.includes('/bills')
? '/bills'
: path.includes('/Destination Folder')
? '/Destination Folder'
: null,
sharingContext: {
isOwner: () => true,
revokeAllRecipients: revokeAllSpy,
revokeSelf: revokeSelfSpy
}
})
const moveButton = await screen.findByText('Move')
fireEvent.click(moveButton)
await waitFor(() => {
const confirmButton = screen.getByText('Stop sharing')
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(move).toHaveBeenCalled()
expect(revokeAllSpy).toHaveBeenCalled()
expect(revokeSelfSpy).not.toHaveBeenCalled()
expect(onCloseSpy).toHaveBeenCalled()
expect(refreshSpy).toHaveBeenCalled()
})
})
it('should move files after revoke self when user confirms', async () => {
CozyFile.getFullpath.mockImplementation((destinationFolder, name) =>
Promise.resolve(`/${destinationFolder}/${name}`)
)
const revokeAllSpy = jest.fn()
const revokeSelfSpy = jest.fn()
setup({
sharedPaths: ['/bills', '/Destination Folder'],
byDocId: {
bill_201903: {
permissions: [],
sharings: ['sharing-id-1']
}
},
getSharedParentPath: path =>
path.includes('/bills')
? '/bills'
: path.includes('/Destination Folder')
? '/Destination Folder'
: null,
sharingContext: {
isOwner: () => false,
revokeAllRecipients: revokeAllSpy,
revokeSelf: revokeSelfSpy
}
})
const moveButton = await screen.findByText('Move')
fireEvent.click(moveButton)
await waitFor(() => {
const confirmButton = screen.getByText('Stop sharing')
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(move).toHaveBeenCalled()
expect(revokeSelfSpy).toHaveBeenCalled()
expect(revokeAllSpy).not.toHaveBeenCalled()
expect(onCloseSpy).toHaveBeenCalled()
expect(refreshSpy).toHaveBeenCalled()
})
})
})
})
================================================
FILE: src/modules/move/MoveOutsideSharedFolderModal.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { useQuery } from 'cozy-client'
import { useSharingContext } from 'cozy-sharing'
import Buttons from 'cozy-ui/transpiled/react/Buttons'
import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Typography from 'cozy-ui/transpiled/react/Typography'
import { useI18n } from 'twake-i18n'
import { LoaderModal } from '@/components/LoaderModal'
import { getEntriesTypeTranslated } from '@/lib/entries'
import { buildFolderByPathQuery } from '@/queries'
/**
* Alert the user when is trying to move a folder/file outside of a shared folder
*/
const MoveOutsideSharedFolderModal = ({
entries,
driveId,
onCancel,
onConfirm
}) => {
const { t } = useI18n()
const { getSharedParentPath } = useSharingContext()
const sharedParentPath = getSharedParentPath(entries[0]?.path || '')
const folderByPathQuery = buildFolderByPathQuery(sharedParentPath)
const { fetchStatus, data } = useQuery(
folderByPathQuery.definition,
folderByPathQuery.options
)
if (fetchStatus === 'loaded') {
const type = getEntriesTypeTranslated(t, entries)
const sharedFolderName = !driveId
? data[0]?.name
: (entries[0]?.path?.split('/')?.[2] ?? '')
return (
{t('Move.outsideSharedFolder.content_1', {
sharedFolder: sharedFolderName,
name: entries[0]?.name,
type,
smart_count: entries.length
})}
{t('Move.outsideSharedFolder.content_2', {
name: entries[0]?.name,
type,
smart_count: entries.length
})}
>
}
actions={
<>
>
}
/>
)
}
return
}
MoveOutsideSharedFolderModal.propTypes = {
/** List of files or folder to move */
entries: PropTypes.array.isRequired,
/** Function called when the user cancels the move action */
onCancel: PropTypes.func.isRequired,
/** Function called when the user confirms the move action */
onConfirm: PropTypes.func.isRequired
}
export { MoveOutsideSharedFolderModal }
================================================
FILE: src/modules/move/MoveSharedFolderInsideAnotherModal.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { useQuery } from 'cozy-client'
import { useSharingContext } from 'cozy-sharing'
import Buttons from 'cozy-ui/transpiled/react/Buttons'
import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Typography from 'cozy-ui/transpiled/react/Typography'
import { useI18n } from 'twake-i18n'
import { LoaderModal } from '@/components/LoaderModal'
import { getEntriesName } from '@/modules/move/helpers'
import {
buildFileOrFolderByIdQuery,
buildSharedDriveFileOrFolderByIdQuery
} from '@/queries'
/**
* Alert the user when is trying to move a shared folder/file inside another shared folder
*/
const MoveSharedFolderInsideAnotherModal = ({
entries,
folderId,
driveId,
onCancel,
onConfirm
}) => {
const { t } = useI18n()
const { byDocId } = useSharingContext()
const folderQuery = driveId
? buildSharedDriveFileOrFolderByIdQuery({ fileId: folderId, driveId })
: buildFileOrFolderByIdQuery(folderId)
const { fetchStatus, data } = useQuery(
folderQuery.definition,
folderQuery.options
)
if (fetchStatus === 'loaded') {
const sharedEntries = entries.filter(
({ _id }) => byDocId[_id] !== undefined
)
return (
{t('Move.sharedFolderInsideAnother.content_1')}
{t('Move.sharedFolderInsideAnother.content_2', {
source: getEntriesName(entries, t),
destination: data.name
})}
{sharedEntries.map(({ _id, name }) => (
{name}
))}
>
}
actions={
<>
>
}
/>
)
}
return
}
MoveSharedFolderInsideAnotherModal.propTypes = {
/** List of files or folder to move */
entries: PropTypes.array.isRequired,
/** Id of the destination folder */
folderId: PropTypes.string.isRequired,
/** Function called when the user cancels the move action */
onCancel: PropTypes.func.isRequired,
/** Function called when the user confirms the move action */
onConfirm: PropTypes.func.isRequired
}
export { MoveSharedFolderInsideAnotherModal }
================================================
FILE: src/modules/move/components/MoveModalSuccessAction.tsx
================================================
import React, { useState } from 'react'
import { NavigateFunction } from 'react-router-dom'
import { useClient } from 'cozy-client'
import Button from 'cozy-ui/transpiled/react/Buttons'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { OpenFolderButton } from '@/components/Button/OpenFolderButton'
import { File, FolderPickerEntry } from '@/components/FolderPicker/types'
import { cancelMove } from '@/modules/move/helpers'
import { useCancelable } from '@/modules/move/hooks/useCancelable'
interface MoveModalSuccessActionProps {
folder: File
entries: FolderPickerEntry[]
trashedFiles: File[]
canCancel?: boolean
refreshSharing: () => void
navigate: NavigateFunction
}
const MoveModalSuccessAction: React.FC = ({
folder,
entries,
trashedFiles,
canCancel = true,
refreshSharing,
navigate
}) => {
const { t } = useI18n()
const client = useClient()
const { registerCancelable } = useCancelable()
const [isCancelling, setCancelling] = useState(false)
const { showAlert } = useAlert()
const handleCancel = async (): Promise => {
setCancelling(true)
await cancelMove({
entries,
trashedFiles,
client,
registerCancelable,
showAlert,
t,
refreshSharing
})
}
return (
<>
{canCancel ? (
) : null}
>
)
}
export { MoveModalSuccessAction }
================================================
FILE: src/modules/move/helpers.js
================================================
import logger from '@/lib/logger'
import { CozyFile } from '@/models'
/**
* Cancel file movement function
* @param {object} client - The CozyClient instance
* @param {import('cozy-client/types').IOCozyFile[]} entries - List of files moved
* @param {import('cozy-client/types').IOCozyFile[]} trashedFiles - List of ids for files moved to the trash
* @param {Function} registerCancelable - Function to register the promise
* @param {Functione} refreshSharing - Function refresh sharing state
*/
export const cancelMove = async ({
client,
entries,
trashedFiles,
registerCancelable,
showAlert,
t,
refreshSharing
}) => {
try {
await Promise.all(
entries.map(entry =>
registerCancelable(CozyFile.move(entry._id, { folderId: entry.dir_id }))
)
)
const fileCollection = client.collection(CozyFile.doctype)
let restoreErrorsCount = 0
await Promise.all(
trashedFiles.map(id => {
try {
registerCancelable(fileCollection.restore(id))
} catch {
restoreErrorsCount++
}
})
)
if (restoreErrorsCount) {
showAlert({
message: t('Move.cancelledWithRestoreErrors', {
subject: entries.length === 1 ? entries[0].name : '',
smart_count: entries.length,
restoreErrorsCount
}),
severity: 'secondary'
})
} else {
showAlert({
message: t('Move.cancelled', {
subject: entries.length === 1 ? entries[0].name : '',
smart_count: entries.length
}),
severity: 'secondary'
})
}
} catch (e) {
logger.warn(e)
showAlert({
message: t('Move.cancelled_error', { smart_count: entries.length }),
severity: 'error'
})
} finally {
if (refreshSharing) refreshSharing()
}
}
/**
* Gets a name for the entry if there is only one, or a sentence with the number of elements if there are several
* @param {import('cozy-client/types').IOCozyFile[]} entries - List of files moved
* @param {Function} t - Translation function
* @returns {string} - Name for entries
*/
export const getEntriesName = (entries, t) => {
return entries.length !== 1
? t('Move.multipleEntries', {
smart_count: entries.length
})
: entries[0].name
}
/**
* @typedef {Object} SharedDoc
* @property {string[]} permissions - List of permissions
* @property {string[]} sharings - List of sharings
*/
/**
* Returns whether one of the entries that is shared not only by link
* @param {import('cozy-client/types').IOCozyFile[]} entries - List of files moved
* @param {Object} byDocId - Object with shared files by id from cozy-sharing
* @returns {boolean} - Whether one of the entries that is shared not only by link
*/
export const hasOneOfEntriesShared = (entries, byDocId) => {
const sharedEntries = entries.filter(({ _id }) => {
const doc = byDocId[_id]
if (doc === undefined) return false
const onlySharedByLink =
doc.permissions.length > 0 && doc.sharings.length === 0
if (onlySharedByLink) return false
return true
})
return sharedEntries.length > 0
}
================================================
FILE: src/modules/move/helpers.spec.js
================================================
import CozyClient from 'cozy-client'
import { CozyFile } from '@/models'
import { cancelMove, hasOneOfEntriesShared } from '@/modules/move/helpers'
jest.mock('cozy-doctypes')
jest.mock('cozy-stack-client')
CozyFile.doctype = 'io.cozy.files'
const getSpy = jest.fn().mockResolvedValue({
data: { id: 'fakeDoc', _type: 'io.cozy.files' }
})
const refreshSpy = jest.fn()
const restoreSpy = jest.fn()
const collectionSpy = jest.fn(() => ({
get: getSpy,
restore: restoreSpy
}))
const mockClient = new CozyClient({
stackClient: {
collection: collectionSpy,
on: jest.fn()
}
})
const t = x => x
const showAlert = jest.fn()
describe('cancelMove', () => {
const defaultEntries = [
{
_id: 'bill_201901',
dir_id: 'bills',
name: 'bill_201901.pdf'
},
{
_id: 'bill_201902',
dir_id: 'bills',
name: 'bill_201902.pdf'
},
// shared file:
{
_id: 'bill_201903',
dir_id: 'bills',
name: 'bill_201903.pdf'
}
]
const setup = async ({
entries = defaultEntries,
trashedFiles = []
} = {}) => {
return cancelMove({
client: mockClient,
entries: entries,
trashedFiles: trashedFiles,
showAlert,
t,
registerCancelable: promise => promise,
refreshSharing: refreshSpy
})
}
it('should move items back to their previous location', async () => {
await setup()
expect(CozyFile.move).toHaveBeenCalledWith('bill_201901', {
folderId: 'bills'
})
expect(CozyFile.move).toHaveBeenCalledWith('bill_201902', {
folderId: 'bills'
})
expect(restoreSpy).not.toHaveBeenCalled()
expect(refreshSpy).toHaveBeenCalled()
})
it('should restore files that have been trashed due to conflicts', async () => {
await setup({
entries: [],
trashedFiles: ['trashed-1', 'trashed-2']
})
expect(collectionSpy).toHaveBeenCalledWith('io.cozy.files', {})
expect(restoreSpy).toHaveBeenCalledWith('trashed-1')
expect(restoreSpy).toHaveBeenCalledWith('trashed-2')
expect(refreshSpy).toHaveBeenCalled()
})
})
describe('hasOneOfEntriesShared', () => {
it('should return false if entries are not shared', () => {
const entries = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]
const byDocId = {}
expect(hasOneOfEntriesShared(entries, byDocId)).toBe(false)
})
it('should return false if all entries are only shared by link', () => {
const entries = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]
const byDocId = {
1: { permissions: ['permission1'], sharings: [] },
2: { permissions: ['permission2'], sharings: [] }
}
expect(hasOneOfEntriesShared(entries, byDocId)).toBe(false)
})
it('should return true if at least one entry is shared', () => {
const entries = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]
const byDocId = {
2: { permissions: [], sharings: ['sharingId'] }
}
expect(hasOneOfEntriesShared(entries, byDocId)).toBe(true)
})
})
================================================
FILE: src/modules/move/hooks/useCancelable.jsx
================================================
import { useEffect, useRef, useCallback } from 'react'
import { cancelable } from 'cozy-client'
const useCancelable = () => {
const promisesRef = useRef([])
useEffect(() => {
// Cleanup function to cancel all promises
return () => {
promisesRef.current.forEach(p => p.cancel())
promisesRef.current = []
}
}, [])
const registerCancelable = useCallback(promise => {
const cancelableP = cancelable(promise)
promisesRef.current.push(cancelableP)
return cancelableP
}, [])
return {
registerCancelable
}
}
export { useCancelable }
================================================
FILE: src/modules/move/hooks/useMove.tsx
================================================
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { File, FolderPickerEntry } from '@/components/FolderPicker/types'
import { MoveModalSuccessAction } from '@/modules/move/components/MoveModalSuccessAction'
interface useMoveProps {
entries: FolderPickerEntry[]
}
interface showSuccessProps {
folder: File
trashedFiles: File[]
canCancel?: boolean
refreshSharing: () => void
}
interface useMoveReturn {
showSuccess: (props: showSuccessProps) => void
}
const useMove = ({ entries }: useMoveProps): useMoveReturn => {
const { t } = useI18n()
const navigate = useNavigate()
const { showAlert } = useAlert()
const showSuccess = ({
folder,
trashedFiles,
canCancel = true,
refreshSharing
}: showSuccessProps): void => {
const targetName = folder.name || t('breadcrumb.title_drive')
showAlert({
message: t('Move.success', {
smart_count: entries.length,
subject: entries.length === 1 ? entries[0].name : '',
target: targetName
}),
severity: 'success',
action: (
)
})
}
return { showSuccess }
}
export { useMove }
================================================
FILE: src/modules/navigation/AppRoute.jsx
================================================
import React from 'react'
import { Route, useParams, Outlet, Navigate } from 'react-router-dom'
import { BarRoutes } from 'cozy-bar'
import flag from 'cozy-flags'
import ExternalRedirect from './ExternalRedirect'
import Index from './Index'
import AIAssistantPaywallView from '../views/AI/AIAssistantPaywallView'
import { DriveFolderView } from '../views/Drive/DriveFolderView'
import FilesViewerDrive from '../views/Drive/FilesViewerDrive'
import OnlyOfficeView from '../views/OnlyOffice'
import OnlyOfficeCreateView from '../views/OnlyOffice/Create'
import OnlyOfficePaywallView from '../views/OnlyOffice/OnlyOfficePaywallView'
import RecentView from '../views/Recent'
import FilesViewerRecent from '../views/Recent/FilesViewerRecent'
import FilesViewerSharedDrive from '../views/SharedDrive/FilesViewerSharedDrive'
import SharingsView from '../views/Sharings'
import SharingsFilesViewer from '../views/Sharings/FilesViewerSharings'
import SharingsFolderView from '../views/Sharings/SharingsFolderView'
import FilesViewerTrash from '../views/Trash/FilesViewerTrash'
import TrashFolderView from '../views/Trash/TrashFolderView'
import FileHistory from '@/components/FileHistory'
import {
ROOT_DIR_ID,
TRASH_DIR_ID,
SHARED_DRIVES_DIR_ID,
SHARING_TAB_DRIVES
} from '@/constants/config'
import { SentryRoutes } from '@/lib/sentry'
import { UploaderComponent } from '@/modules//views/Upload/UploaderComponent'
import Layout from '@/modules/layout/Layout'
import { PublicNoteRedirect } from '@/modules/navigation/PublicNoteRedirect'
import FileOpenerExternal from '@/modules/viewer/FileOpenerExternal'
import { KonnectorRoutes } from '@/modules/views/Drive/KonnectorRoutes'
import { FavoritesView } from '@/modules/views/Favorites/FavoritesView'
import { FolderDuplicateView } from '@/modules/views/Folder/FolderDuplicateView'
import { DuplicateSharedDriveFilesView } from '@/modules/views/Modal/DuplicateSharedDriveFilesView'
import { MoveFilesView } from '@/modules/views/Modal/MoveFilesView'
import { MoveSharedDriveFilesView } from '@/modules/views/Modal/MoveSharedDriveFilesView'
import { QualifyFileView } from '@/modules/views/Modal/QualifyFileView'
import { ShareDisplayedFolderView } from '@/modules/views/Modal/ShareDisplayedFolderView'
import { ShareFileView } from '@/modules/views/Modal/ShareFileView'
import { NextcloudDeleteView } from '@/modules/views/Nextcloud/NextcloudDeleteView'
import { NextcloudDestroyView } from '@/modules/views/Nextcloud/NextcloudDestroyView'
import { NextcloudDuplicateView } from '@/modules/views/Nextcloud/NextcloudDuplicateView'
import { NextcloudFolderView } from '@/modules/views/Nextcloud/NextcloudFolderView'
import { NextcloudMoveView } from '@/modules/views/Nextcloud/NextcloudMoveView'
import { NextcloudTrashEmptyView } from '@/modules/views/Nextcloud/NextcloudTrashEmptyView'
import { NextcloudTrashView } from '@/modules/views/Nextcloud/NextcloudTrashView'
import SearchView from '@/modules/views/Search/SearchView'
import { SharedDriveFolderView } from '@/modules/views/SharedDrive/SharedDriveFolderView'
import { TrashDestroyView } from '@/modules/views/Trash/TrashDestroyView'
import { TrashEmptyView } from '@/modules/views/Trash/TrashEmptyView'
const FilesRedirect = () => {
const { folderId } = useParams()
return
}
const SharedDrivesRedirect = () => {
return
}
const OutletWrapper = ({ Component }) => (
<>
>
)
const AppRoute = () => (
} />
} />
} />
}>
} />
} />
} />
}
/>
}>
}
>
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
}
>
} />
{!flag('drive.hide-nextcloud-dev') ? (
<>
}
>
} />
} />
} />
}
>
} />
} />
>
) : null}
{flag('drive.shared-drive.enabled') ||
flag('drive.federated-shared-folder.enabled') ? (
<>
}
>
}
/>
} />
} />
} />
} />
} />
}
/>
>
) : null}
}>
}
>
} />
} />
} />
} />
} />
} />
} />
} />
} />
}
/>
}>
} />
} />
} />
} />
} />
}>
}
>
} />
} />
} />
} />
{/* This route must be a child of SharingsView so the modal opens on top of the sharing view */}
} />
} />
} />
{/* This route must be inside the /sharing path for the nav to have an activate state */}
}>
} />
{/* This route must be a child of SharingsFolderView so the modal opens on top of the folder view */}
} />
} />
} />
} />
} />
}>
} />
}>
} />
}
/>
}
/>
} />
} />
}>
} />
} />
} />
} />
} />
{BarRoutes.map(BarRoute => BarRoute)}
)
export default AppRoute
================================================
FILE: src/modules/navigation/ExternalNavItem.jsx
================================================
import PropTypes from 'prop-types'
import React, { useCallback } from 'react'
import { useClient, generateWebLink } from 'cozy-client'
import { isFlagshipApp } from 'cozy-device-helper'
import { useWebviewIntent } from 'cozy-intent'
import {
NavLink as UINavLink,
NavItem as UINavItem
} from 'cozy-ui/transpiled/react/Nav'
import { useI18n } from 'twake-i18n'
import { NavContent } from '@/modules/navigation/NavContent'
const ExternalNavItem = ({ slug, icon, label, path, clickState }) => {
const { t } = useI18n()
const client = useClient()
const webviewIntent = useWebviewIntent()
const href = generateWebLink({
slug,
cozyUrl: client.getStackClient().uri,
subDomainType: client.getInstanceOptions().subdomain,
...(path && { hash: path })
})
const handleClick = useCallback(
e => {
e.preventDefault()
if (clickState) {
clickState[1](undefined)
}
if (isFlagshipApp()) {
webviewIntent.call('openApp', href, { slug })
} else {
window.location.href = href
}
},
[href, slug, webviewIntent, clickState]
)
return (
)
}
ExternalNavItem.propTypes = {
slug: PropTypes.string.isRequired,
icon: PropTypes.element.isRequired,
label: PropTypes.string.isRequired,
path: PropTypes.string,
clickState: PropTypes.array
}
export { ExternalNavItem }
================================================
FILE: src/modules/navigation/ExternalRedirect.jsx
================================================
import React from 'react'
import { useParams } from 'react-router-dom'
import { useClient, useFetchShortcut } from 'cozy-client'
import Empty from 'cozy-ui/transpiled/react/Empty'
import GlobeIcon from 'cozy-ui/transpiled/react/Icons/Globe'
import { translate } from 'twake-i18n'
import EmptyIcon from '@/assets/icons/icon-folder-broken.svg'
import { DummyLayout } from '@/modules/layout/DummyLayout'
const ExternalRedirect = ({ t }) => {
const { fileId } = useParams()
const client = useClient()
const { shortcutInfos, fetchStatus } = useFetchShortcut(client, fileId)
if (shortcutInfos) {
// eslint-disable-next-line react-hooks/immutability
window.location.href = shortcutInfos.data.attributes.url
}
return (
{fetchStatus === 'failed' && (
)}
{fetchStatus !== 'failed' && (
)}
)
}
export default translate()(ExternalRedirect)
================================================
FILE: src/modules/navigation/FavoriteList.tsx
================================================
import React, { FC } from 'react'
import { useQuery } from 'cozy-client'
import { IOCozyFile } from 'cozy-client/types/types'
import { NavDesktopDropdown } from 'cozy-ui/transpiled/react/Nav'
import { useI18n } from 'twake-i18n'
import { FavoriteListItem } from '@/modules/navigation/FavoriteListItem'
import { buildFavoritesQuery } from '@/queries'
interface FavoriteListProps {
clickState: [string, (value: string | undefined) => void]
}
const FavoriteList: FC = ({ clickState }) => {
const { t } = useI18n()
const favoritesQuery = buildFavoritesQuery({
sortAttribute: 'name',
sortOrder: 'desc'
})
const favoritesResult = useQuery(
favoritesQuery.definition,
favoritesQuery.options
) as {
data?: IOCozyFile[] | null
}
if (favoritesResult.data && favoritesResult.data.length > 0) {
return (
{favoritesResult.data.map(file => (
))}
)
}
return null
}
export { FavoriteList }
================================================
FILE: src/modules/navigation/FavoriteListItem.tsx
================================================
import React, { FC } from 'react'
import {
splitFilename,
isDirectory,
isNote,
isOnlyOfficeFile
} from 'cozy-client/dist/models/file'
import type { IOCozyFile } from 'cozy-client/types/types'
import FileIcon from 'cozy-ui/transpiled/react/Icons/File'
import FileTypeServerIcon from 'cozy-ui/transpiled/react/Icons/FileTypeServer'
import FolderIcon from 'cozy-ui/transpiled/react/Icons/Folder'
import { NavIcon, NavLink, NavItem } from 'cozy-ui/transpiled/react/Nav'
import Typography from 'cozy-ui/transpiled/react/Typography'
import { FileLink } from './components/FileLink'
import { useFileLink } from '@/modules/navigation/hooks/useFileLink'
import { isNextcloudShortcut } from '@/modules/nextcloud/helpers'
interface FavoriteListItemProps {
file: IOCozyFile
clickState: [string, (value: string | undefined) => void]
}
const makeIcon = (file: IOCozyFile): string | React.ComponentType =>
isNextcloudShortcut(file)
? FileTypeServerIcon
: isDirectory(file)
? FolderIcon
: FileIcon
const FavoriteListItem: FC = ({
file,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
clickState: [lastClicked, setLastClicked]
}) => {
const { link } = useFileLink(file, {
forceFolderPath: isNote(file) || isOnlyOfficeFile(file) ? false : true
})
const { filename } = splitFilename(file)
const ItemIcon = makeIcon(file)
return (
setLastClicked(undefined)}
>
{filename}
)
}
export { FavoriteListItem }
================================================
FILE: src/modules/navigation/Index.jsx
================================================
import { useEffect, useContext } from 'react'
import { useNavigate } from 'react-router-dom'
import { useClient, Q, models } from 'cozy-client'
import { getSharingIdFromUrl } from './duck'
import { SHAREDWITHME_DIR_ID } from '@/constants/config'
import AcceptingSharingContext from '@/lib/AcceptingSharingContext'
/**
* Compute sharing object according to the sharing found in io.cozy.sharings and the sharing context
* @param {object} params - Params
* @param {object} params.client - The CozyClient instance
* @param {string} params.sharingId - Id of an io.cozy.sharings doc
* @param {object} params.sharingsValue - Sharing Context value
* @returns {object}
*/
const computeSharingsValue = async ({ client, sharingId, sharingsValue }) => {
const sharingRes = await client.query(
Q('io.cozy.sharings').getById(sharingId)
)
const computedSharingsValue = Object.assign(
{ [`${sharingRes.data.id}`]: sharingRes.data },
sharingsValue
)
return computedSharingsValue
}
/**
* Fetches io.cozy.sharings with the sharing Id
* stores the sharing in the context
* and route to the folder that contains the shared file
* @param {object} params - Params
* @param {object} params.client - The CozyClient instance
* @param {object} params.sharingsValue - Sharing Context value
* @param {function} params.setSharingsValue - Sharing Context setter
* @param {function} params.setFileValue - Sharing Context file setter
* @param {object} params.navigate - Lets you navigate programmatically
* @param {string} params.sharingId - Id of an io.cozy.sharings doc
*/
export const fetchSharing = async ({
client,
sharingsValue,
setSharingsValue,
setFileValue,
navigate,
sharingId
}) => {
if (!sharingId) {
return navigate('/folder', { replace: true })
}
try {
const referencedFilesRes = await client
.collection('io.cozy.files')
.findReferencedBy({ _type: 'io.cozy.sharings', _id: sharingId })
const referencedFiles = referencedFilesRes.included
const hasReferencedFile = referencedFiles.length >= 1
const referencedFile = hasReferencedFile ? referencedFiles[0] : null
const isSharingShortcut = hasReferencedFile
? models.file.isSharingShortcut(referencedFile)
: false
if (isSharingShortcut) {
setFileValue(referencedFile)
}
if (!hasReferencedFile || isSharingShortcut) {
const computedSharingsValue = await computeSharingsValue({
client,
sharingId,
sharingsValue
})
setSharingsValue(computedSharingsValue)
}
if (!hasReferencedFile) {
return navigate(`/folder/${SHAREDWITHME_DIR_ID}`, { replace: true })
}
return navigate(`/folder/${referencedFile.dir_id}`, { replace: true })
} catch (e) {
// eslint-disable-next-line
console.warn(
`fetchSharing error : ${e}. Redirect to /folder/${SHAREDWITHME_DIR_ID}`
)
return navigate(`/folder/${SHAREDWITHME_DIR_ID}`, { replace: true })
}
}
const Index = () => {
const client = useClient()
const navigate = useNavigate()
const { sharingsValue, setFileValue, setSharingsValue } = useContext(
AcceptingSharingContext
)
const sharingId = getSharingIdFromUrl(window.location)
useEffect(() => {
fetchSharing({
client,
sharingsValue,
setSharingsValue,
navigate,
sharingId,
setFileValue
})
}, [
client,
sharingId,
navigate,
setSharingsValue,
setFileValue,
sharingsValue
])
return null
}
export default Index
================================================
FILE: src/modules/navigation/Index.spec.js
================================================
import { createMockClient } from 'cozy-client'
import { fetchSharing } from './Index'
import { SHAREDWITHME_DIR_ID } from '@/constants/config'
const mockFileModels = require('cozy-client/dist/models/file')
jest.mock('cozy-keys-lib', () => ({
withVaultClient: jest.fn().mockReturnValue({}),
useVaultClient: jest.fn()
}))
const client = createMockClient({})
const navigate = jest.fn()
const setSharingsValue = jest.fn()
const setFileValue = jest.fn()
const sharingRes = { data: { id: '123' } }
const referencedFilesRes = { included: [{ id: 'fileId', dir_id: 'dirId' }] }
const setup = async ({ sharingId, withReferencedFiles, withShortcut } = {}) => {
client.query = jest
.fn()
.mockReturnValue(sharingId ? sharingRes : { data: [] })
mockFileModels.isSharingShortcut = () => (withShortcut ? true : false)
client.collection = jest.fn(() => ({
findReferencedBy: jest
.fn()
.mockReturnValue(
withReferencedFiles ? referencedFilesRes : { included: [] }
)
}))
await fetchSharing({
client,
navigate,
sharingsValue: {},
setSharingsValue: setSharingsValue,
setFileValue: setFileValue,
sharingId
})
}
/**
* Here's how it works: if there is a sharing id, we are in the sharing process.
* If there is a reference file, it means that a file in the current folder is linked to the current share.
* So we can check if it's a shortcut and then store it in the context to use it in the view.
* As for the redirection, it is done according to whether there is a reference file or not.
*/
describe('fetchSharing', () => {
it('should redirect to /folder and store nothing in context, if no sharing id', async () => {
await setup()
expect(setSharingsValue).not.toHaveBeenCalled()
expect(setFileValue).not.toHaveBeenCalled()
expect(navigate).toHaveBeenCalledWith('/folder', { replace: true })
})
it('should redirect to /shared-with-me-dir and store sharing in context, if sharing id but no referenced file', async () => {
await setup({
sharingId: '123'
})
expect(setSharingsValue).toHaveBeenCalled()
expect(setFileValue).not.toHaveBeenCalled()
expect(navigate).toHaveBeenCalledWith(`/folder/${SHAREDWITHME_DIR_ID}`, {
replace: true
})
})
it('should redirect to /folder/dirId and store nothing in context, if sharing id and referenced file but no shortcut', async () => {
await setup({
sharingId: '123',
withReferencedFiles: true
})
expect(setSharingsValue).not.toHaveBeenCalled()
expect(setFileValue).not.toHaveBeenCalled()
expect(navigate).toHaveBeenCalledWith('/folder/dirId', { replace: true })
})
it('should redirect to /folder/dirId and store sharing and file in context, if sharing id, referenced file and shortcut', async () => {
await setup({
sharingId: '123',
withReferencedFiles: true,
withShortcut: true
})
expect(setSharingsValue).toHaveBeenCalled()
expect(setFileValue).toHaveBeenCalled()
expect(navigate).toHaveBeenCalledWith('/folder/dirId', { replace: true })
})
})
================================================
FILE: src/modules/navigation/Nav.jsx
================================================
import React from 'react'
import flag from 'cozy-flags'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ClockIcon from 'cozy-ui/transpiled/react/Icons/ClockOutline'
import CloudIcon from 'cozy-ui/transpiled/react/Icons/Cloud2'
import StarIcon from 'cozy-ui/transpiled/react/Icons/Star'
import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'
import { NavDesktopDropdown } from 'cozy-ui/transpiled/react/Nav'
import UINav from 'cozy-ui/transpiled/react/Nav'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import NextcloudIcon from '@/assets/icons/icon-nextcloud.svg'
import { ExternalNavItem } from '@/modules/navigation/ExternalNavItem'
import { FavoriteList } from '@/modules/navigation/FavoriteList'
import { useNavContext } from '@/modules/navigation/NavContext'
import { NavItem } from '@/modules/navigation/NavItem'
import { SharingsNavItem } from '@/modules/navigation/SharingsNavItem'
import { ExternalDrives } from '@/modules/navigation/components/ExternalDrivesList'
export const Nav = () => {
const clickState = useNavContext()
const { isDesktop } = useBreakpoints()
const { t } = useI18n()
return (
}
label="drive"
rx={/\/(folder|nextcloud)(\/.*)?/}
clickState={clickState}
/>
{!isDesktop ? (
}
label="favorites"
rx={/\/favorites(\/.*)?/}
clickState={clickState}
/>
) : null}
}
label="recent"
rx={/\/recent(\/.*)?/}
clickState={clickState}
/>
}
label="trash"
rx={/\/trash(\/.*)?/}
clickState={clickState}
/>
{flag('settings.migration.enabled') && (
}
label="nextcloud"
path="/migration"
clickState={clickState}
/>
)}
{isDesktop ? : null}
{isDesktop ? (
) : null}
)
}
export default Nav
================================================
FILE: src/modules/navigation/NavContent.tsx
================================================
import React from 'react'
import Avatar from 'cozy-ui/transpiled/react/Avatar'
import Badge from 'cozy-ui/transpiled/react/Badge'
import { NavIcon, NavText } from 'cozy-ui/transpiled/react/Nav'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
interface NavContentProps {
icon?: string
badgeContent?: number
label?: string
}
const NavContent: React.FC = ({
icon,
badgeContent,
label
}) => {
const { isDesktop } = useBreakpoints()
if (badgeContent) {
if (isDesktop) {
return (
<>
{icon && }
{label}
{badgeContent > 99 ? '99+' : badgeContent}
>
)
} else {
return (
<>
{icon && (
)}
{label}
>
)
}
}
return (
<>
{icon && }
{label}
>
)
}
export { NavContent }
================================================
FILE: src/modules/navigation/NavContext.jsx
================================================
import React, { createContext, useState, useContext } from 'react'
export const NavContext = createContext()
export const NavProvider = ({ children }) => {
const clickState = useState(null)
return (
{children}
)
}
export const useNavContext = () => {
const context = useContext(NavContext)
if (context === undefined) {
throw new Error('useNavContext must be used within a NavProvider')
}
return context
}
================================================
FILE: src/modules/navigation/NavItem.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { NavItem as UINavItem } from 'cozy-ui/transpiled/react/Nav'
import { useI18n } from 'twake-i18n'
import { NavContent } from '@/modules/navigation/NavContent'
import { NavLink } from '@/modules/navigation/NavLink'
/**
* Renders a navigation item with optional badge content and support for shared links.
*
* @component
* @param {Object} props - The component props.
* @param {string} [props.to] - The path to navigate to when the item is clicked.
* @param {string|Object} [props.icon] - The icon to display next to the label.
* @param {string} [props.label] - The text label for the navigation item.
* @param {string} [props.forcedLabel] - The forced text label for the navigation item (optional).
* @param {RegExp} [props.rx] - A RegExp to modify the path dynamically (optional).
* @param {Object} [props.clickState] - State to be passed to the NavLink on click (optional).
* @param {number} [props.badgeContent] - Content of the badge to display (optional).
* @param {boolean} [props.secondary=false] - Whether to apply secondary styling to the nav item (optional).
* @returns {JSX.Element} The rendered navigation item component.
*/
const NavItem = ({
to,
icon,
label,
rx,
clickState,
badgeContent,
secondary,
forcedLabel
}) => {
const { t } = useI18n()
return (
)
}
NavItem.propTypes = {
to: PropTypes.string,
icon: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
label: PropTypes.string,
forcedLabel: PropTypes.string,
rx: PropTypes.shape(RegExp),
badgeContent: PropTypes.number
}
export { NavItem }
================================================
FILE: src/modules/navigation/NavLink.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
import { useLocation } from 'react-router-dom'
import { NavLink as UINavLink } from 'cozy-ui/transpiled/react/Nav'
import { navLinkMatch } from '@/modules/navigation/helpers'
/**
* Like react-router NavLink but sets the lastClicked state (passed in props)
* to have a faster change of active (not waiting for the route to completely
* change).
*/
const NavLink = ({
children,
to,
rx,
clickState: [lastClicked, setLastClicked]
}) => {
const location = useLocation()
const pathname = lastClicked ? lastClicked : location.pathname
const isActive = navLinkMatch(rx, to, pathname)
return (
{
if (!to) e.preventDefault()
setLastClicked(to)
}}
href={`#${to}`}
className={cx(
UINavLink.className,
isActive ? UINavLink.activeClassName : null
)}
>
{children}
)
}
NavLink.propTypes = {
children: PropTypes.node.isRequired,
to: PropTypes.string,
rx: PropTypes.shape(RegExp)
}
export { NavLink }
================================================
FILE: src/modules/navigation/PublicNoteRedirect.tsx
================================================
import React, { FC, useEffect, useState } from 'react'
import { useLocation, useParams } from 'react-router-dom'
import { useClient } from 'cozy-client'
import { fetchURL } from 'cozy-client/dist/models/note'
import Empty from 'cozy-ui/transpiled/react/Empty'
import Icon from 'cozy-ui/transpiled/react/Icon'
import SadCozyIcon from 'cozy-ui/transpiled/react/Icons/SadCozy'
import Spinner from 'cozy-ui/transpiled/react/Spinner'
import { useI18n } from 'twake-i18n'
import { joinPath } from '@/lib/path'
import { DummyLayout } from '@/modules/layout/DummyLayout'
const PublicNoteRedirect: FC = () => {
const { t } = useI18n()
const { fileId, driveId } = useParams()
const { search } = useLocation()
const client = useClient()
const [noteUrl, setNoteUrl] = useState(null)
const [fetchStatus, setFetchStatus] = useState<
'failed' | 'loading' | 'pending' | 'loaded'
>('pending')
useEffect(() => {
const fetchNoteUrl = async (fileId: string): Promise => {
setFetchStatus('loading')
try {
// Inside notes, we need to add / at the end of /public/ or /preview/ to avoid 409 error
const searchParams = new URLSearchParams(search)
const returnUrl = searchParams.get('returnUrl')
const pathname =
location.pathname === '/'
? '/public/'
: joinPath(location.pathname, '')
const url = await fetchURL(
client,
{
id: fileId
},
{
driveId,
pathname,
returnUrl
}
)
setNoteUrl(url)
setFetchStatus('loaded')
} catch (_error) {
setFetchStatus('failed')
}
}
if (fileId) {
void fetchNoteUrl(fileId)
}
}, [search, fileId, driveId, client])
if (noteUrl) {
// eslint-disable-next-line react-hooks/immutability
window.location.href = noteUrl
}
return (
{fetchStatus === 'failed' && (
}
title={t('PublicNoteRedirect.error.title')}
text={t('PublicNoteRedirect.error.subtitle')}
/>
)}
{fetchStatus !== 'failed' && }
)
}
export { PublicNoteRedirect }
================================================
FILE: src/modules/navigation/SharingsNavItem.jsx
================================================
import React from 'react'
import { useQuery } from 'cozy-client'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ShareIcon from 'cozy-ui/transpiled/react/Icons/ShareExternal'
import { NavItem } from '@/modules/navigation/NavItem'
import { buildNewSharingShortcutQuery } from '@/queries'
const SharingsNavItem = ({ clickState }) => {
const newSharingShortcutQuery = buildNewSharingShortcutQuery()
const newSharingShortcutResult = useQuery(
newSharingShortcutQuery.definition,
newSharingShortcutQuery.options
)
return (
}
label="sharings"
rx={/\/sharings(\/.*)?/}
clickState={clickState}
badgeContent={newSharingShortcutResult.data?.length}
/>
)
}
export { SharingsNavItem }
================================================
FILE: src/modules/navigation/components/ExternalDriveListItem.tsx
================================================
import React, { FC } from 'react'
import { splitFilename } from 'cozy-client/dist/models/file'
import type { IOCozyFile } from 'cozy-client/types/types'
import FileTypeServerIcon from 'cozy-ui/transpiled/react/Icons/FileTypeServer'
import { NavIcon, NavLink, NavItem } from 'cozy-ui/transpiled/react/Nav'
import Typography from 'cozy-ui/transpiled/react/Typography'
import { FileLink } from './FileLink'
import { useFileLink } from '@/modules/navigation/hooks/useFileLink'
interface ExternalDriveListItemProps {
file: IOCozyFile
setLastClicked: (value: string | undefined) => void
}
const ExternalDriveListItem: FC = ({
file,
setLastClicked
}) => {
const { link } = useFileLink(file, { forceFolderPath: false })
const { filename } = splitFilename(file)
return (
setLastClicked(undefined)}
>
{filename}
)
}
export { ExternalDriveListItem }
================================================
FILE: src/modules/navigation/components/ExternalDrivesList.tsx
================================================
import React, { FC } from 'react'
import { useQuery } from 'cozy-client'
import { IOCozyFile } from 'cozy-client/types/types'
import List from 'cozy-ui/transpiled/react/List'
import ListSubheader from 'cozy-ui/transpiled/react/ListSubheader'
import { useI18n } from 'twake-i18n'
import { ExternalDriveListItem } from './ExternalDriveListItem'
import { buildExternalDrivesQuery } from '@/queries'
interface ExternalDriveListProps {
className?: string
clickState: [string, (value: string | undefined) => void]
}
const ExternalDrives: FC = ({
className,
clickState
}) => {
const { t } = useI18n()
const externalDrivesQuery = buildExternalDrivesQuery({
sortAttribute: 'name',
sortOrder: 'desc'
})
const externalDrivesResult = useQuery(
externalDrivesQuery.definition,
externalDrivesQuery.options
) as {
data?: IOCozyFile[] | null
}
if (externalDrivesResult.data && externalDrivesResult.data.length > 0) {
return (
{t('Nav.item_external_drives')}
}
className={className}
>
{externalDrivesResult.data.map(file => (
))}
)
}
return null
}
export { ExternalDrives }
================================================
FILE: src/modules/navigation/components/FileLink.tsx
================================================
import React, { forwardRef } from 'react'
import { Link } from 'react-router-dom'
import type { LinkResult } from '@/modules/navigation/hooks/useFileLink'
interface FileLinkProps {
link: LinkResult
children: React.ReactNode
[key: string]: unknown
}
const FileLink = forwardRef(
function FileLinkComponent({ link, children, ...props }, ref) {
const openInNewTab = link.openInNewTab ? { target: '_blank' } : {}
if (link.app === 'drive') {
return (
{children}
)
}
return (
{children}
)
}
)
export { FileLink }
================================================
FILE: src/modules/navigation/duck/actions.jsx
================================================
import React from 'react'
import { isDirectory } from 'cozy-client/dist/models/file'
import { resetQuery as resetQueryAction } from 'cozy-client/dist/store'
import flag from 'cozy-flags'
import { QuotaPaywall } from 'cozy-ui-plus/dist/Paywall'
import {
ROOT_DIR_ID,
TRASH_DIR_ID,
FILES_FETCH_LIMIT,
MAX_PAYLOAD_SIZE_IN_GB,
MAX_UPLOAD_FILE_COUNT
} from '@/constants/config'
import { getEntriesTypeTranslated } from '@/lib/entries'
import logger from '@/lib/logger'
import { showModal } from '@/lib/react-cozy-helpers'
import { getFolderContent, getFolderContentQueries } from '@/modules/selectors'
import { addToUploadQueue, extractFilesEntries } from '@/modules/upload'
import UploadLimitDialog from '@/modules/upload/UploadLimitDialog'
export const SORT_FOLDER = 'SORT_FOLDER'
export const OPERATION_REDIRECTED = 'navigation/OPERATION_REDIRECTED'
const HTTP_CODE_CONFLICT = 409
export const operationRedirected = () => ({ type: OPERATION_REDIRECTED })
export const sortFolder = (folderId, sortAttribute, sortOrder = 'asc') => {
return {
type: SORT_FOLDER,
folderId,
sortAttribute,
sortOrder
}
}
/**
* Reset folder queries so the server re-sends proper paginated data.
* Works around cozy-client's sortAndLimitDocsIds capping realtime-added
* documents to `limit * fetchedPagesCount`, which hides files beyond
* the first page and leaves hasMore stale.
*/
const refetchFolderQueries = async (client, folderId) => {
try {
const storeState = client.store.getState()
const matchingQueries = getFolderContentQueries(storeState, folderId)
await Promise.all(
matchingQueries.map(async queryState => {
if (!queryState?.definition) return
// Clear stale pagination state then fetch every page
client.dispatch(resetQueryAction(queryState.id))
await client.queryAll(queryState.definition, { as: queryState.id })
})
)
} catch (error) {
logger.error('Failed to refetch folder queries after upload:', error)
}
}
/**
* Upload files to the given directory
* @param {Array} files - The list of File objects to upload
* @param {string} dirId - The id of the directory in which we upload the files
* @param {Object} sharingState - The sharing context (provided by SharingContext.Provider)
* @param {function} fileUploadedCallback - A callback called when a file is uploaded
* @param {Object} options - An object containing the following properties:
* - client - The cozy-client instance
* - showAlert - A function to show an alert
* - t - A translation function
* @param {string|undefined} driveId - The id of the drive in which we upload the files
* @param {function|undefined} addItems - Callback to add newly uploaded items to the context.
*/
export const uploadFiles =
(
files,
dirId,
sharingState,
fileUploadedCallback = () => null,
{ client, showAlert, t },
driveId,
addItems
) =>
async dispatch => {
let targetDirId = dirId
let navigateAfterUpload = false
if (dirId === null || dirId === undefined || dirId === TRASH_DIR_ID) {
targetDirId = ROOT_DIR_ID
navigateAfterUpload = true
}
const maxFileCount =
flag('drive.max-upload-file-count') ?? MAX_UPLOAD_FILE_COUNT
// Extract entries synchronously before browser clears dataTransfer
const entries = extractFilesEntries(files)
dispatch(
addToUploadQueue(
entries,
targetDirId,
sharingState,
fileUploadedCallback,
({
createdItems,
quotas,
conflicts,
networkErrors,
errors,
unreadableErrors,
updatedItems,
fileTooLargeErrors
}) => {
dispatch(
uploadQueueProcessed(
createdItems,
quotas,
conflicts,
networkErrors,
errors,
unreadableErrors,
updatedItems,
showAlert,
t,
fileTooLargeErrors,
navigateAfterUpload,
addItems
)
)
if (createdItems.length + updatedItems.length >= FILES_FETCH_LIMIT) {
refetchFolderQueries(client, targetDirId)
}
},
{
client,
maxFileCount,
onLimitExceeded: () =>
dispatch(
showModal( )
)
},
driveId,
addItems
)
)
}
const uploadQueueProcessed =
(
created,
quotas,
conflicts,
networkErrors,
errors,
unreadableErrors,
updated,
showAlert,
t,
fileTooLargeErrors,
navigateAfterUpload,
addItems
) =>
dispatch => {
const safeAddItems = typeof addItems === 'function' ? addItems : () => {}
const conflictCount = conflicts.length
const createdCount = created.length
const updatedCount = updated.length
const type = getEntriesTypeTranslated(t, [
...created,
...updated,
...conflicts
])
// Add new items to the NewContext
const successfulUploads = [...created, ...updated]
if (successfulUploads.length > 0) {
safeAddItems(successfulUploads)
}
// Add logging to debug upload completion
logger.debug('uploadQueueProcessed called with:', {
created: created.map(f => f.name),
updated: updated.map(f => f.name),
quotas: quotas.map(f => f.name),
conflicts: conflicts.map(f => f.name),
networkErrors: networkErrors.map(f => f.name),
errors: errors.map(f => ({
name: f.name,
status: f.status,
message: f.message
})),
unreadableErrors: unreadableErrors.map(f => f.name),
fileTooLargeErrors: fileTooLargeErrors.map(f => f.name),
navigateAfterUpload
})
if (quotas.length > 0) {
logger.warn(`Upload module triggers a quota alert: ${quotas}`)
dispatch(
showModal( )
)
} else if (networkErrors.length > 0) {
logger.warn(`Upload module triggers a network error: ${networkErrors}`)
showAlert({
message: t('upload.alert.network'),
severity: 'error',
duration: null,
noClickAway: true
})
} else if (unreadableErrors.length > 0) {
logger.warn(
`Upload module triggers an unreadable files error: ${unreadableErrors}`
)
showAlert({
message: t('upload.alert.unreadable_files'),
severity: 'error',
duration: null,
noClickAway: true
})
} else if (errors.length > 0) {
logger.error(`Upload module triggers an error: ${errors}`)
showAlert({
message: t('upload.alert.errors', { type }),
severity: 'error',
duration: null,
noClickAway: true
})
} else if (updatedCount > 0 && createdCount > 0 && conflictCount > 0) {
showAlert({
message: t('upload.alert.success_updated_conflicts', {
smart_count: createdCount,
updatedCount,
conflictCount,
type
}),
severity: 'success'
})
} else if (updatedCount > 0 && createdCount > 0) {
showAlert({
message: t('upload.alert.success_updated', {
smart_count: createdCount,
updatedCount,
type
}),
severity: 'success'
})
} else if (updatedCount > 0 && conflictCount > 0) {
showAlert({
message: t('upload.alert.updated_conflicts', {
smart_count: updatedCount,
conflictCount,
type
}),
severity: 'success'
})
} else if (conflictCount > 0) {
showAlert({
message: t('upload.alert.success_conflicts', {
smart_count: createdCount,
conflictNumber: conflictCount,
type
}),
severity: 'secondary'
})
} else if (updatedCount > 0 && createdCount === 0) {
showAlert({
message: t('upload.alert.updated', {
smart_count: updatedCount,
type
}),
severity: 'success'
})
} else if (fileTooLargeErrors.length > 0) {
showAlert({
message: t('upload.alert.fileTooLargeErrors', {
max_size_value: MAX_PAYLOAD_SIZE_IN_GB
}),
severity: 'error',
duration: null,
noClickAway: true
})
} else {
showAlert({
message: t('upload.alert.success', {
smart_count: createdCount,
type
}),
severity: 'success'
})
}
const hasSuccessfulUploads = created.length > 0 || updated.length > 0
if (navigateAfterUpload && hasSuccessfulUploads) {
logger.debug('Dispatching operationRedirected for upload.')
dispatch(operationRedirected())
} else {
logger.debug('Not dispatching operationRedirected for upload.', {
navigateAfterUpload,
hasSuccessfulUploads
})
}
}
/**
* Given a folderId, checks the current known state to return if
* a folder with the same name exist in the given folderId.
*
* The local state can be incomplete so this can return false
* negatives.
*/
const doesFolderExistByName = (state, parentFolderId, name) => {
const filesInCurrentView = getFolderContent(state, parentFolderId) || [] // TODO in the public view we don't use a query, so getFolderContent returns null. We could look inside the cozy-client store with a predicate to find folders with a matching dir_id.
const existingFolder = filesInCurrentView.find(f => {
return isDirectory(f) && f.name === name
})
return Boolean(existingFolder)
}
/**
* Creates a folder in the current view
*/
export const createFolder = (
client,
name,
currentFolderId,
{ showAlert, t } = {},
driveId,
addItems = () => {}
) => {
const safeAddItems = typeof addItems === 'function' ? addItems : () => {}
return async (dispatch, getState) => {
const state = getState()
let targetFolderId = currentFolderId
let navigateAfterCreate = false
if (
currentFolderId === null ||
currentFolderId === undefined ||
currentFolderId === TRASH_DIR_ID
) {
targetFolderId = ROOT_DIR_ID
navigateAfterCreate = true
}
const existingFolder = doesFolderExistByName(state, targetFolderId, name)
if (existingFolder) {
showAlert({
message: t('alert.folder_name', { folderName: name }),
severity: 'error'
})
throw new Error('alert.folder_name')
}
let createdFolder
try {
createdFolder = await client
.collection('io.cozy.files', { driveId })
.create({
name: name,
dirId: targetFolderId,
type: 'directory'
})
if (createdFolder) {
safeAddItems([createdFolder.data])
}
if (navigateAfterCreate && createdFolder) {
dispatch(operationRedirected())
}
} catch (err) {
if (err.response && err.response.status === HTTP_CODE_CONFLICT) {
showAlert({
message: t('alert.folder_name', { folderName: name }),
severity: 'error'
})
} else {
showAlert({ message: t('alert.folder_generic'), severity: 'error' })
}
throw err
}
}
}
================================================
FILE: src/modules/navigation/duck/actions.spec.jsx
================================================
import CozyClient from 'cozy-client'
import flag from 'cozy-flags'
import { createFolder, uploadFiles } from './actions'
import { generateFile } from 'test/generate'
import { setupFolderContent } from 'test/setup'
import { addToUploadQueue, extractFilesEntries } from '@/modules/upload'
jest.mock('cozy-flags', () => jest.fn(() => null))
jest.mock('@/modules/upload', () => ({
addToUploadQueue: jest.fn(() => () => {}),
extractFilesEntries: jest.fn()
}))
jest.mock('@/modules/upload/UploadLimitDialog', () => {
const React = require('react')
return function MockUploadLimitDialog(props) {
return React.createElement('div', {
'data-testid': 'upload-limit-dialog',
...props
})
}
})
const showAlert = jest.fn()
const t = x => x
beforeEach(() => {
const folders = Array(3)
.fill(null)
.map((x, i) => generateFile({ i, type: 'directory' }))
const files = Array(3)
.fill(null)
.map((x, i) => generateFile({ i }))
jest.spyOn(CozyClient.prototype, 'requestQuery').mockResolvedValue({
data: files.concat(folders)
})
})
afterEach(() => {
CozyClient.prototype.requestQuery.mockRestore()
})
describe('createFolder', () => {
beforeEach(() => {
jest.spyOn(CozyClient.prototype, 'create').mockImplementation(() => {})
jest.spyOn(CozyClient.prototype, 'collection').mockReturnValue({
create: jest.fn().mockResolvedValue({})
})
})
afterEach(() => {
CozyClient.prototype.create.mockRestore()
CozyClient.prototype.collection.mockRestore()
})
it('should not be possible to create a folder with a same name of an existing folder', async () => {
const folderId = 'folder123456'
const { client, store } = await setupFolderContent({
folderId
})
await expect(
store.dispatch(
createFolder(client, 'foobar2', folderId, { showAlert, t })
)
).rejects.toEqual(new Error('alert.folder_name'))
})
it('should be possible to create a folder', async () => {
const folderId = 'folder123456'
const { client, store } = await setupFolderContent({
folderId
})
await store.dispatch(
createFolder(client, 'foobar5', folderId, { showAlert, t })
)
expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {
driveId: undefined
})
const mockCollection = client.collection.mock.results[0].value
expect(mockCollection.create).toHaveBeenCalledWith({
dirId: 'folder123456',
name: 'foobar5',
type: 'directory'
})
})
})
describe('uploadFiles', () => {
const mockFiles = [new File([''], 'test.txt')]
const mockEntries = [{ file: mockFiles[0], isDirectory: false, entry: null }]
const deps = {
client: {},
showAlert: jest.fn(),
t: x => x
}
beforeEach(() => {
jest.clearAllMocks()
extractFilesEntries.mockReturnValue(mockEntries)
flag.mockReturnValue(null)
})
const getAddToUploadQueueOptions = () => addToUploadQueue.mock.calls[0][5]
it('passes the flag-driven limit and a modal-opening onLimitExceeded callback', async () => {
flag.mockReturnValue(100)
const dispatch = jest.fn()
await uploadFiles(mockFiles, 'dir-id', {}, () => null, deps)(dispatch)
const options = getAddToUploadQueueOptions()
expect(options).toMatchObject({ client: deps.client, maxFileCount: 100 })
expect(typeof options.onLimitExceeded).toBe('function')
options.onLimitExceeded()
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'SHOW_MODAL' })
)
})
it('falls back to the default limit when no flag is set', async () => {
flag.mockReturnValue(null)
const dispatch = jest.fn()
await uploadFiles(mockFiles, 'dir-id', {}, () => null, deps)(dispatch)
expect(getAddToUploadQueueOptions()).toMatchObject({ maxFileCount: 500 })
})
it('does not show the modal eagerly', async () => {
const dispatch = jest.fn()
await uploadFiles(mockFiles, 'dir-id', {}, () => null, deps)(dispatch)
expect(dispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'SHOW_MODAL' })
)
})
it('passes pre-extracted entries to addToUploadQueue', async () => {
const dispatch = jest.fn()
await uploadFiles(mockFiles, 'dir-id', {}, () => null, deps)(dispatch)
expect(extractFilesEntries).toHaveBeenCalledWith(mockFiles)
expect(addToUploadQueue).toHaveBeenCalledWith(
mockEntries,
'dir-id',
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({ client: deps.client }),
undefined,
undefined
)
})
})
================================================
FILE: src/modules/navigation/duck/async.js
================================================
export const extractFileAttributes = f => {
const id = f.id || f._id
return {
...f.attributes,
id,
_id: id,
_type: 'io.cozy.files',
links: f.links,
relationships: f.relationships
}
}
================================================
FILE: src/modules/navigation/duck/index.js
================================================
export { default, getSort } from './reducer'
export { sortFolder, uploadFiles, createFolder } from './actions'
export { getSharingIdFromUrl } from './utils'
================================================
FILE: src/modules/navigation/duck/reducer.js
================================================
import { combineReducers } from 'redux'
import { SORT_FOLDER, OPERATION_REDIRECTED } from './actions'
// Action type for resetting the flag
export const RESET_OPERATION_REDIRECTED =
'navigation/RESET_OPERATION_REDIRECTED'
const sort = (state = null, action) => {
switch (action.type) {
case SORT_FOLDER:
return {
attribute: action.sortAttribute,
order: action.sortOrder
}
default:
return state
}
}
// Reducer for the redirection flag
const operationRedirectedReducer = (state = false, action) => {
switch (action.type) {
case OPERATION_REDIRECTED:
return true
case RESET_OPERATION_REDIRECTED:
return false
default:
return state
}
}
export default combineReducers({
sort,
operationRedirected: operationRedirectedReducer // Add the reducer
})
/**
* Retrieves the sort value from the view object.
*
* @param {Object} state - The state object containing the view property.
* @returns {string} The sort value.
*/
// Selector needs to point to the correct state slice (`view`)
export const getSort = state => state.view.sort
// Selector for the state (`view`)
export const wasOperationRedirected = state => state.view.operationRedirected
================================================
FILE: src/modules/navigation/duck/utils.js
================================================
export const getSharingIdFromUrl = url => {
const urlSearchParams = new URLSearchParams(url.search)
return urlSearchParams.get('sharing')
}
================================================
FILE: src/modules/navigation/duck/utils.spec.js
================================================
import { getSharingIdFromUrl } from './utils'
describe('getSharingIdFromUrl', () => {
it('should return sharing id from url', () => {
const urlWithoutSharingId = new URL('https://test.mycozy.cloud/')
const urlWithSharingId = new URL('https://test.mycozy.cloud/?sharing=123')
expect(getSharingIdFromUrl(urlWithoutSharingId)).toBeNull()
expect(getSharingIdFromUrl(urlWithSharingId)).toBe('123')
})
})
================================================
FILE: src/modules/navigation/helpers.js
================================================
/**
* Returns true if `to` and `pathname` match
* Supports `rx` for regex matches.
*/
export const navLinkMatch = (rx, to, pathname) => {
return rx ? rx.test(pathname) : pathname.slice(1) === to
}
================================================
FILE: src/modules/navigation/hooks/helpers.spec.js
================================================
import { computeFileType, computeApp, computePath } from './helpers'
import { TRASH_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'
import { makeOnlyOfficeFileRoute } from '@/modules/views/OnlyOffice/helpers'
jest.mock('modules/views/OnlyOffice/helpers', () => ({
makeOnlyOfficeFileRoute: jest.fn()
}))
jest.mock('cozy-flags', () => jest.fn())
describe('computeFileType', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should return "trash" for the trash directory', () => {
const file = { _id: TRASH_DIR_ID }
expect(computeFileType(file)).toBe('trash')
})
it('should return "nextcloud-trash" for Nextcloud trash directory', () => {
const file = { _id: 'io.cozy.remote.nextcloud.files.trash-dir' }
expect(computeFileType(file)).toBe('nextcloud-trash')
})
it('should return "shared-drive" for files in shared drives directory', () => {
const file = {
dir_id: SHARED_DRIVES_DIR_ID,
_type: 'io.cozy.files',
type: 'file'
}
expect(computeFileType(file)).toBe('shared-drive')
})
it('should return "nextcloud-directory" for Nextcloud directories', () => {
const file = { _type: 'io.cozy.remote.nextcloud.files', type: 'directory' }
expect(computeFileType(file)).toBe('nextcloud-directory')
})
it('should return "nextcloud-file" for Nextcloud files', () => {
const file = { _type: 'io.cozy.remote.nextcloud.files', type: 'file' }
expect(computeFileType(file)).toBe('nextcloud-file')
})
it('should return "public-note-same-instance" for public notes on the same instance', () => {
const file = {
_type: 'io.cozy.files',
name: 'My journal.cozy-note',
type: 'file',
metadata: {
title: '',
version: '0'
},
cozyMetadata: {
createdOn: 'https://example.com/'
}
}
expect(
computeFileType(file, { isPublic: true, cozyUrl: 'https://example.com' })
).toBe('public-note-same-instance')
})
it('should return "note" for notes on the same instance', () => {
const file = {
_type: 'io.cozy.files',
name: 'My journal.cozy-note',
type: 'file',
metadata: {
title: '',
version: '0'
},
cozyMetadata: {
createdOn: 'https://example.com/'
}
}
expect(computeFileType(file, { cozyUrl: 'https://example.com/' })).toBe(
'note'
)
})
it('should return "public-note" for notes on an another instance', () => {
const file = {
_type: 'io.cozy.files',
name: 'My journal.cozy-note',
type: 'file',
metadata: {
title: '',
version: '0'
},
cozyMetadata: {
createdOn: 'https://example.com/'
}
}
expect(computeFileType(file, { cozyUrl: 'https://another.com/' })).toBe(
'public-note'
)
})
it('should return "public-note" for public notes', () => {
const file = {
_type: 'io.cozy.files',
name: 'My journal.cozy-note',
type: 'file',
metadata: {
title: '',
version: '0'
},
cozyMetadata: {
createdOn: 'https://example.com/'
}
}
expect(
computeFileType(file, { isPublic: true, cozyUrl: 'https://another.com' })
).toBe('public-note')
})
it('should return "onlyoffice" for files opened by OnlyOffice when Office is enabled', () => {
const file = {
_type: 'io.cozy.files',
class: 'text',
name: 'My document.docx',
type: 'file'
}
expect(computeFileType(file, { isOfficeEnabled: true })).toBe('onlyoffice')
})
it('should return "file" for files opened by OnlyOffice when Office is disabled', () => {
const file = {
_type: 'io.cozy.files',
class: 'text',
name: 'My document.docx',
type: 'file'
}
expect(computeFileType(file, { isOfficeEnabled: false })).toBe('file')
})
it('should return "file" for files that OnlyOffice can\'t open (.txt, .md)', () => {
const file = {
_type: 'io.cozy.files',
class: 'text',
name: 'My markdown.md',
type: 'file'
}
expect(computeFileType(file, { isOfficeEnabled: true })).toBe('file')
})
it('should return "nextcloud" for Nextcloud shortcuts', () => {
const file = {
_type: 'io.cozy.files',
class: 'shortcut',
cozyMetadata: {
createdByApp: 'nextcloud'
}
}
expect(computeFileType(file)).toBe('nextcloud')
})
it('should return "shortcut" for other shortcuts', () => {
const file = { _type: 'io.cozy.files', class: 'shortcut' }
expect(computeFileType(file)).toBe('shortcut')
})
it('should return "directory" for directories', () => {
const file = { _type: 'io.cozy.files', type: 'directory' }
expect(computeFileType(file)).toBe('directory')
})
it('should return "file" for other files', () => {
const file = { _type: 'io.cozy.files', type: 'file' }
expect(computeFileType(file)).toBe('file')
})
})
describe('computeApp', () => {
it('should return "nextcloud" for "nextcloud-file" type', () => {
expect(computeApp('nextcloud-file')).toBe('nextcloud')
})
it('should return "notes" for "note" type', () => {
expect(computeApp('note')).toBe('notes')
})
it('should return "drive" for any other types', () => {
expect(computeApp('unknown-type')).toBe('drive')
expect(computeApp('file')).toBe('drive')
})
})
describe('computePath', () => {
it('should return correct path for trash', () => {
expect(computePath({}, { type: 'trash', pathname: '/any/path' })).toBe(
'/trash'
)
})
it('should return correct path for nextcloud-trash', () => {
expect(
computePath({}, { type: 'nextcloud-trash', pathname: '/some/path' })
).toBe('/some/path/trash')
})
it('should return correct path for nextcloud', () => {
const file = { cozyMetadata: { sourceAccount: 'account1' } }
expect(computePath(file, { type: 'nextcloud', pathname: '/any' })).toBe(
'/nextcloud/account1'
)
})
it('should return correct path for nextcloud-directory', () => {
const file = { path: '/folder' }
expect(
computePath(file, { type: 'nextcloud-directory', pathname: '/some/path' })
).toBe('/some/path?path=/folder')
})
it('should return correct path for nextcloud-file', () => {
const file = { links: { self: '/file/link' } }
expect(
computePath(file, { type: 'nextcloud-file', pathname: '/any' })
).toBe('/file/link')
})
it('should return correct path for note', () => {
const file = { _id: 'note123' }
expect(computePath(file, { type: 'note', pathname: '/any' })).toBe(
'/n/note123'
)
})
it('should return correct path for public-note', () => {
const file = { _id: 'note123' }
expect(
computePath(file, { type: 'public-note', pathname: '/public' })
).toBe('/note/note123')
})
it('should return correct path for public-note with driveId in shared drive', () => {
const file = { _id: 'note123', driveId: 'drive456' }
expect(
computePath(file, { type: 'public-note', pathname: '/public' })
).toBe('/note/drive456/note123?returnUrl=')
})
it('should return correct path for public-note-same-instance', () => {
const file = { _id: 'note123' }
expect(
computePath(file, {
type: 'public-note-same-instance',
pathname: '/public'
})
).toBe('/?id=note123')
})
it('should return correct path for shortcut', () => {
const file = { _id: 'shortcut123' }
expect(computePath(file, { type: 'shortcut', pathname: '/any' })).toBe(
'/external/shortcut123'
)
})
it('should return correct path for directory at root', () => {
const file = { _id: 'dir123' }
expect(computePath(file, { type: 'directory', pathname: '/root' })).toBe(
'dir123'
)
})
it('should return correct path for nested directory', () => {
const file = { _id: 'dir123' }
expect(
computePath(file, { type: 'directory', pathname: '/root/nested' })
).toBe('../dir123')
})
it('should return correct path for onlyoffice', () => {
const file = { _id: 'file123' }
makeOnlyOfficeFileRoute.mockReturnValue('/onlyoffice/route')
expect(
computePath(file, {
type: 'onlyoffice',
pathname: '/some/path',
isPublic: true
})
).toBe('/onlyoffice/route')
expect(makeOnlyOfficeFileRoute).toHaveBeenCalledWith('file123', {
fromPathname: '/some/path',
fromPublicFolder: true
})
})
it('should return correct path for shared-drive', () => {
const file = { _id: 'file123', driveId: 'drive456' }
expect(computePath(file, { type: 'shared-drive', pathname: '/any' })).toBe(
'/shareddrive/drive456/file123'
)
})
it('should return correct for shared-drive in case user is owner', () => {
const file = { _id: 'file123' }
expect(computePath(file, { type: 'shared-drive', pathname: '/any' })).toBe(
'/folder/file123'
)
})
it('should return correct path for shared-drive-file', () => {
const file = {
_id: 'file123',
dir_id: 'dir456',
driveId: 'drive789',
_type: 'io.cozy.files'
}
expect(
computePath(file, { type: 'shared-drive-file', pathname: '/any' })
).toBe('/shareddrive/drive789/dir456/file/file123')
})
it('should throw error for shared-drive-file without driveId', () => {
const file = {
_id: 'file123',
dir_id: 'dir456',
_type: 'io.cozy.files'
}
expect(() =>
computePath(file, { type: 'shared-drive-file', pathname: '/any' })
).toThrow('Missing driveId or invalid file type in shared drive file')
})
it('should throw error for shared-drive-file without dir_id', () => {
const file = {
_id: 'file123',
driveId: 'drive789',
_type: 'io.cozy.files'
}
expect(() =>
computePath(file, { type: 'shared-drive-file', pathname: '/any' })
).toThrow('Missing dir_id in shared drive file')
})
it('should return correct path for default case', () => {
const file = { _id: 'file123' }
expect(computePath(file, { type: 'unknown', pathname: '/any' })).toBe(
'file/file123'
)
})
})
================================================
FILE: src/modules/navigation/hooks/helpers.ts
================================================
import CozyClient from 'cozy-client'
import {
isShortcut,
isNote,
isDocs,
shouldBeOpenedByOnlyOffice,
isDirectory
} from 'cozy-client/dist/models/file'
import { IOCozyFile } from 'cozy-client/types/types'
import type { File } from '@/components/FolderPicker/types'
import { TRASH_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'
import { joinPath } from '@/lib/path'
import {
isNextcloudShortcut,
isNextcloudFile
} from '@/modules/nextcloud/helpers'
import { makeSharedDriveNoteReturnUrl } from '@/modules/shareddrives/helpers'
import { makeOnlyOfficeFileRoute } from '@/modules/views/OnlyOffice/helpers'
interface ComputeFileTypeOptions {
isOfficeEnabled?: boolean
isPublic?: boolean
cozyUrl?: string
}
interface ComputePathOptions {
type: string
pathname: string
isPublic: boolean
client: CozyClient | null
}
export const computeFileType = (
file: File,
{
isOfficeEnabled = false,
isPublic = false,
cozyUrl = ''
}: ComputeFileTypeOptions = {}
): string => {
if (file._id === TRASH_DIR_ID) {
return 'trash'
} else if (file._id === 'io.cozy.remote.nextcloud.files.trash-dir') {
return 'nextcloud-trash'
} else if (
file.dir_id === SHARED_DRIVES_DIR_ID &&
!isNextcloudShortcut(file)
) {
return 'shared-drive'
} else if (file._type === 'io.cozy.remote.nextcloud.files') {
return isDirectory(file) ? 'nextcloud-directory' : 'nextcloud-file'
} else if (isNote(file)) {
// createdOn url ends with a trailing slash whereas cozyUrl does not joinPath fixes this
const isSameInstance =
joinPath(cozyUrl, '') === file.cozyMetadata?.createdOn
if (isPublic && isSameInstance) {
return 'public-note-same-instance'
} else if (isSameInstance) {
return 'note'
} else {
return 'public-note'
}
} else if (isDocs(file)) {
return 'docs'
} else if (shouldBeOpenedByOnlyOffice(file) && isOfficeEnabled) {
return 'onlyoffice'
} else if (isNextcloudShortcut(file)) {
return 'nextcloud'
} else if (isShortcut(file)) {
return 'shortcut'
} else if (isDirectory(file)) {
return 'directory'
} else if (file.driveId) {
return 'shared-drive-file'
} else {
return 'file'
}
}
export const computeApp = (type: string): string => {
switch (type) {
case 'nextcloud-file':
return 'nextcloud'
case 'note':
case 'public-note-same-instance':
return 'notes'
case 'docs':
return 'docs'
default:
return 'drive'
}
}
export const computePath = (
file: File,
{ type, pathname, isPublic, client }: ComputePathOptions
): string => {
const paths = pathname.split('/').slice(1)
const driveId = file.driveId as string | undefined
switch (type) {
case 'trash':
return '/trash'
case 'nextcloud-trash':
return `${pathname}/trash`
case 'nextcloud':
return `/nextcloud/${file.cozyMetadata?.sourceAccount ?? 'unknown'}`
case 'nextcloud-directory':
return `${pathname}?path=${file.path ?? '/'}`
case 'nextcloud-file':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
return file.links?.self ?? ''
case 'note':
return `/n/${file._id}`
case 'public-note-same-instance':
return `/?id=${file._id}`
case 'public-note':
if (driveId) {
const returnUrl = client
? makeSharedDriveNoteReturnUrl(client, file as IOCozyFile)
: ''
return `/note/${driveId}/${file._id}?returnUrl=${encodeURIComponent(
returnUrl
)}`
} else {
return `/note/${file._id}`
}
case 'docs':
return `/bridge/docs/${(file as IOCozyFile).metadata.externalId}`
case 'shortcut':
return `/external/${file._id}`
case 'directory':
// On mobile, if we are in /favorites tab, we do not want it to appears in computed path
// so we redirect to root route for folders
if (pathname.startsWith('/favorites')) {
return `/folder/${file._id}`
}
// paths with only one element correspond to the root of a page like /sharings
// when we add id we want to keep the path before to make /sharings/id
return paths.length === 1 ? file._id : `../${file._id}`
case 'onlyoffice':
return makeOnlyOfficeFileRoute(file._id, {
driveId,
fromPathname: pathname,
fromPublicFolder: isPublic
})
case 'shared-drive':
// Without driveId, we should use path `/folder/:folderId` because it's shared drive folder of owner
if (!driveId) {
return `/folder/${file._id}`
}
return `/shareddrive/${driveId}/${file._id}`
case 'shared-drive-file':
if (!driveId || isNextcloudFile(file)) {
throw new Error(
'Missing driveId or invalid file type in shared drive file'
)
}
if (!file.dir_id) {
throw new Error('Missing dir_id in shared drive file')
}
return `/shareddrive/${driveId}/${file.dir_id}/file/${file._id}`
default:
// On mobile, if we are in /favorites tab, we do not want it to appears in computed path
// so we redirect to root route for files
if (pathname.startsWith('/favorites')) {
return `/folder/${file.dir_id}/file/${file._id}`
}
return `file/${file._id}`
}
}
================================================
FILE: src/modules/navigation/hooks/useFileLink.tsx
================================================
import { useCallback } from 'react'
import { useLocation, useResolvedPath, useNavigate } from 'react-router-dom'
import type { Path } from 'react-router-dom'
import { useClient, generateWebLink } from 'cozy-client'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import type { File } from '@/components/FolderPicker/types'
import { joinPath } from '@/lib/path'
import {
computeFileType,
computeApp,
computePath
} from '@/modules/navigation/hooks/helpers'
import { usePublicContext } from '@/modules/public/PublicProvider'
import { getFolderPath } from '@/modules/routeUtils'
import { isOfficeEnabled as computeOfficeEnabled } from '@/modules/views/OnlyOffice/helpers'
export interface LinkResult {
app: string
href: string
to: Path
openInNewTab: boolean
isSharedDrive: boolean
}
interface UseFileLinkResult {
link: LinkResult
openLink: (evt: React.MouseEvent) => void
}
/**
* useFileLink computes the link to open a file.
*
* forceFolderPath is used to force `/folder` in the path
*
* To categories files requires different logic for the moment we can distinguishing 10 different cases. You can find the full list in the computeFileType function.
*
* Based on this category, we can compute the path to open the file. This path is relative so in case it will be used inside Drive we need to resolve it to use it inside generateWebLink. To work with relative path allows us to use the same logic for both cases (eg. recent, sharing pages)
*
* After we will make two types of links:
* - to: will be used to open the file inside Drive as it based on react-router-dom convention
* - href: which is regular href that can be used inside a link
*
* The first one is useful for link inside Drive and the second one for link outside of external application (eg. Notes, Nextcloud) or that will be opened in a new tab be default.
*
*/
const useFileLink = (
file: File,
{ forceFolderPath }: { forceFolderPath?: boolean } = {}
): UseFileLinkResult => {
const navigate = useNavigate()
const { pathname } = useLocation()
const client = useClient()
const { isDesktop } = useBreakpoints()
const isOfficeEnabled = computeOfficeEnabled(isDesktop)
const { isPublic } = usePublicContext()
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const cozyUrl = client?.getStackClient().uri as string
const type = computeFileType(file, {
isOfficeEnabled,
isPublic,
cozyUrl
})
const app = computeApp(type)
const path = computePath(file, {
type,
pathname,
isPublic,
client
})
const shouldBeOpenedInNewTab =
type === 'shortcut' || type === 'nextcloud-file'
const currentURL = new URL(window.location.href)
const currentPathname = currentURL.pathname
const currentSearchParams = currentURL.searchParams
// we use relative path because by default react-router-dom will use the structure of routes
// each level of the path don't have a route but we want to move relatively to the path
// to have more explanation : https://reactrouter.com/en/main/components/link#relative
let to = useResolvedPath(path, {
relative: forceFolderPath ? 'route' : 'path'
})
if (forceFolderPath && !shouldBeOpenedInNewTab) {
to = {
...to,
pathname:
(type === 'directory' ? '/folder' : getFolderPath(file.dir_id)) +
to.pathname
}
}
// we need to merge the searchParams of the current url and the new one created in computed path
// for example, to keep the sharecode in public context
const searchParams = new URLSearchParams({
...Object.fromEntries(currentSearchParams.entries()),
...Object.fromEntries(new URLSearchParams(to.search).entries())
})
// nextcloud-file is a special case because Nextcloud are not in cozy ecosystem
// so we open their link directly
const href =
type === 'nextcloud-file'
? path
: generateWebLink({
slug: app,
cozyUrl,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
subDomainType: client?.getInstanceOptions().subdomain,
// Inside notes, we need to add / at the end of /public/ or /preview/ to avoid 409 error
pathname:
type === 'public-note-same-instance'
? joinPath(currentPathname, '')
: currentPathname,
searchParams: searchParams as unknown as unknown[],
hash: to.pathname
})
const openLink = useCallback(
(evt: React.MouseEvent) => {
if (
evt.ctrlKey ||
evt.metaKey ||
evt.shiftKey ||
shouldBeOpenedInNewTab
) {
window.open(href, '_blank')
} else if (app === 'drive') {
navigate(to)
} else {
window.location.href = href
}
},
[app, href, navigate, to, shouldBeOpenedInNewTab]
)
return {
link: {
app,
href,
to,
openInNewTab: shouldBeOpenedInNewTab
},
openLink
}
}
export { useFileLink }
================================================
FILE: src/modules/navigation/hooks/useSharedDriveLink.tsx
================================================
import { useCallback } from 'react'
import type { Path } from 'react-router-dom'
import { useResolvedPath, useNavigate } from 'react-router-dom'
import { useClient, generateWebLink } from 'cozy-client'
import {
SharedDrive,
getFolderIdFromSharing
} from '@/modules/shareddrives/helpers'
export interface LinkResult {
app: string
href: string
to: Path
openInNewTab: boolean
}
interface UseFileLinkResult {
link: LinkResult
openLink: (evt: React.MouseEvent) => void
}
const useSharedDriveLink = (sharing: SharedDrive): UseFileLinkResult => {
const navigate = useNavigate()
const client = useClient()
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const cozyUrl = client?.getStackClient().uri as string
const app = 'drive'
const folderId = getFolderIdFromSharing(sharing)
if (!folderId) {
throw new Error('Missing folder id in shared drive')
}
/** Set shared drive path
* if user is owner of this shared drive, the path should be `folder/:folderId`
* otherwise the path should be `shareddrive/:driveId/:folderId`
*/
const path = sharing.owner
? `folder/${folderId}`
: `shareddrive/${sharing._id}/${folderId}`
const currentURL = new URL(window.location.href)
const currentPathname = currentURL.pathname
const currentSearchParams = currentURL.searchParams
const to = useResolvedPath(path, {
relative: 'route'
})
// we need to merge the searchParams of the current url and the new one created in computed path
// for example, to keep the sharecode in public context
const searchParams = new URLSearchParams({
...Object.fromEntries(currentSearchParams.entries())
})
// nextcloud-file is a special case because Nextcloud are not in cozy ecosystem
// so we open their link directly
const href = generateWebLink({
slug: app,
cozyUrl,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
subDomainType: client?.getInstanceOptions().subdomain,
// Inside notes, we need to add / at the end of /public/ or /preview/ to avoid 409 error
pathname: currentPathname,
searchParams: searchParams as unknown as unknown[],
hash: to.pathname
})
const openLink = useCallback(
(evt: React.MouseEvent) => {
if (evt.ctrlKey || evt.metaKey || evt.shiftKey) {
window.open(href, '_blank')
} else if (app === 'drive') {
navigate(to)
} else {
window.location.href = href
}
},
[app, href, navigate, to]
)
return {
link: {
app,
href,
to,
openInNewTab: false
},
openLink
}
}
export { useSharedDriveLink }
================================================
FILE: src/modules/nextcloud/components/NextcloudBanner.tsx
================================================
import cx from 'classnames'
import React from 'react'
import Alert from 'cozy-ui/transpiled/react/Alert'
import Icon from 'cozy-ui/transpiled/react/Icon'
import Typography from 'cozy-ui/transpiled/react/Typography'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import NextcloudIcon from '@/assets/icons/icon-nextcloud.svg'
const NextcloudBanner = (): JSX.Element => {
const { t } = useI18n()
const { isMobile } = useBreakpoints()
return (
}
severity="secondary"
square={isMobile}
>
{t('NextcloudBanner.title')}
)
}
export { NextcloudBanner }
================================================
FILE: src/modules/nextcloud/components/NextcloudBreadcrumb.jsx
================================================
import React from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { useI18n } from 'twake-i18n'
import { ROOT_DIR_ID } from '@/constants/config'
import { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'
import { useNextcloudInfos } from '@/modules/nextcloud/hooks/useNextcloudInfos'
const NextcloudBreadcrumb = ({ sourceAccount, path }) => {
const [searchParams, setSearchParams] = useSearchParams()
const { pathname } = useLocation()
const navigate = useNavigate()
const { t } = useI18n()
const { rootFolderName } = useNextcloudInfos({ sourceAccount })
const rootPaths = [
{
name: t('breadcrumb.title_drive'),
id: ROOT_DIR_ID
},
{
name: t('breadcrumb.title_shared_drives'),
id: 'io.cozy.files.shared-drives-dir'
},
{ name: rootFolderName, id: '/' }
]
const splitPath = path.split('/').filter(Boolean)
const pathList = splitPath.map((folder, index) => {
if (folder === 'trash') {
return {
name: t('NextcloudBreadcrumb.trash'),
id: '/trash/'
}
}
let name = folder
// In Nextcloud, the path to the folder inside the trash ends with a number prefixed by a dot (.d1721754243)
// as we don't want to display this number in the breadcrumb, we remove it
if (path.startsWith('/trash')) {
const lastDotPosition = name.lastIndexOf('.')
name = folder.substring(0, lastDotPosition)
}
return {
name,
id: '/' + splitPath.slice(0, index + 1).join('/')
}
})
const handleBreadcrumbClick = item => {
if (
item.id === 'io.cozy.files.shared-drives-dir' ||
item.id === ROOT_DIR_ID
) {
navigate(`/folder/${item.id}`)
} else if (pathname.endsWith('trash') && item.id === '/') {
navigate('..', { relative: 'path' })
} else {
searchParams.set('path', item.id)
setSearchParams(searchParams)
}
}
return (
)
}
export { NextcloudBreadcrumb }
================================================
FILE: src/modules/nextcloud/components/NextcloudDeleteConfirm.jsx
================================================
import React, { useState, useCallback } from 'react'
import { useClient } from 'cozy-client'
import { splitFilename } from 'cozy-client/dist/models/file'
import Buttons from 'cozy-ui/transpiled/react/Buttons'
import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ForbiddenIcon from 'cozy-ui/transpiled/react/Icons/Forbidden'
import RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'
import List from 'cozy-ui/transpiled/react/List'
import ListItem from 'cozy-ui/transpiled/react/ListItem'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { getEntriesTypeTranslated } from '@/lib/entries'
import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'
const NextcloudDeleteConfirm = ({ files, onClose }) => {
const { t } = useI18n()
const client = useClient()
const { showAlert } = useAlert()
const [isBusy, setBusy] = useState(false)
const onDelete = useCallback(async () => {
try {
setBusy(true)
await client
.collection('io.cozy.remote.nextcloud.files')
.destroyAll(files)
client.resetQuery(
computeNextcloudFolderQueryId({
sourceAccount: files[0].cozyMetadata.sourceAccount,
path: files[0].parentPath
})
)
client.resetQuery(
computeNextcloudFolderQueryId({
sourceAccount: files[0].cozyMetadata.sourceAccount,
path: '/trash/'
}) + '/trashed'
)
} catch (_e) {
showAlert({
message: t('NextcloudDeleteConfirm.error'),
severity: 'error'
})
} finally {
onClose()
setBusy(false)
}
}, [client, files, onClose, showAlert, t])
const entriesType = getEntriesTypeTranslated(t, files)
return (
}
actions={
<>
>
}
/>
)
}
export { NextcloudDeleteConfirm }
================================================
FILE: src/modules/nextcloud/components/NextcloudFolderBody.jsx
================================================
import React from 'react'
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom'
import { useClient } from 'cozy-client'
import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import { useI18n } from 'twake-i18n'
import { moveNextcloud } from './actions/moveNextcloud'
import { useClipboardContext } from '@/contexts/ClipboardProvider'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { hr } from '@/modules/actions'
import { duplicateTo } from '@/modules/actions/components/duplicateTo'
import { FolderBody } from '@/modules/folder/components/FolderBody'
import { deleteNextcloudFile } from '@/modules/nextcloud/components/actions/deleteNextcloudFile'
import { downloadNextcloudFile } from '@/modules/nextcloud/components/actions/downloadNextcloudFile'
import { openWithinNextcloud } from '@/modules/nextcloud/components/actions/openWithinNextcloud'
import { rename } from '@/modules/nextcloud/components/actions/rename'
import { shareNextcloudFile } from '@/modules/nextcloud/components/actions/shareNextcloudFile'
const NextcloudFolderBody = ({ path, queryResults }) => {
const [searchParams] = useSearchParams()
const client = useClient()
const { t } = useI18n()
const { pathname } = useLocation()
const navigate = useNavigate()
const { hasClipboardData } = useClipboardContext()
const allItems =
queryResults?.reduce((acc, result) => {
if (result.data) {
acc.push(...result.data)
}
return acc
}, []) || []
useKeyboardShortcuts({
onPaste: () => {},
canPaste: hasClipboardData,
client,
items: allItems,
sharingContext: null,
allowCopy: true,
isNextCloudFolder: true
})
const fileActions = makeActions(
[
shareNextcloudFile,
downloadNextcloudFile,
hr,
rename,
moveNextcloud,
duplicateTo,
openWithinNextcloud,
hr,
deleteNextcloudFile
],
{
t,
client,
pathname,
navigate,
search: searchParams.toString()
}
)
return (
)
}
export { NextcloudFolderBody }
================================================
FILE: src/modules/nextcloud/components/NextcloudToolbar.jsx
================================================
import cx from 'classnames'
import React, { useState, useRef } from 'react'
import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'
import {
makeActions,
divider
} from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import Buttons from 'cozy-ui/transpiled/react/Buttons'
import Icon from 'cozy-ui/transpiled/react/Icon'
import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'
import ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'
import { useI18n } from 'twake-i18n'
import { BarRightOnMobile } from '@/components/Bar'
import { MoreMenu } from '@/components/MoreMenu'
import { selectable } from '@/modules/actions/components/selectable'
import { addFolder } from '@/modules/nextcloud/components/actions/addFolder'
import { downloadNextcloudFolder } from '@/modules/nextcloud/components/actions/downloadNextcloudFolder'
import { openWithinNextcloud } from '@/modules/nextcloud/components/actions/openWithinNextcloud'
import { trash } from '@/modules/nextcloud/components/actions/trash'
import { upload } from '@/modules/nextcloud/components/actions/upload'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
const NextcloudToolbar = () => {
const { t } = useI18n()
const { showSelectionBar } = useSelectionContext()
/**
* TODO : Extract this logic to a component that can be reused for other toolbars
*/
const [isAddMenuOpened, setAddMenuOpened] = useState(false)
const addButtonRef = useRef(null)
const toggleAddMenu = () => setAddMenuOpened(!isAddMenuOpened)
const closeAddMenu = () => setAddMenuOpened(false)
const addActions = makeActions([addFolder, divider, upload], { t })
const moreActions = makeActions(
[selectable, openWithinNextcloud, downloadNextcloudFolder, divider, trash],
{
t,
showSelectionBar
}
)
return (
}
className="u-mr-half"
/>
}
aria-controls={isAddMenuOpened ? 'add-menu' : undefined}
aria-haspopup={true}
aria-expanded={isAddMenuOpened ? true : undefined}
className="u-mr-half"
/>
{isAddMenuOpened ? (
) : null}
)
}
export { NextcloudToolbar }
================================================
FILE: src/modules/nextcloud/components/NextcloudTrashFolderBody.tsx
================================================
import React, { FC } from 'react'
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom'
import { useClient } from 'cozy-client'
import { UseQueryReturnValue } from 'cozy-client/types/types'
import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { FolderBody } from '@/modules/folder/components/FolderBody'
import { restoreNextcloudFile } from '@/modules/nextcloud/components/actions/restoreNextcloudFile'
import { destroy } from '@/modules/trash/components/actions/destroy'
interface NextcloudTrashFolderBodyProps {
path: string
queryResults: UseQueryReturnValue[]
}
const NextcloudTrashFolderBody: FC = ({
path,
queryResults
}) => {
const [searchParams] = useSearchParams()
const client = useClient()
const { t } = useI18n()
const { showAlert } = useAlert()
const { pathname } = useLocation()
const navigate = useNavigate()
const fileActions = makeActions([restoreNextcloudFile, destroy], {
t,
client,
showAlert,
pathname,
navigate,
search: searchParams.toString()
})
return (
)
}
export { NextcloudTrashFolderBody }
================================================
FILE: src/modules/nextcloud/components/actions/addFolder.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const addFolder = ({ t }) => {
const label = t('toolbar.menu_new_folder')
const icon = IconFolder
return {
name: 'addFolder',
label,
icon,
action: () => {},
disabled: () => true,
Component: forwardRef(function AddFolder(props, ref) {
return (
)
})
}
}
export { addFolder }
================================================
FILE: src/modules/nextcloud/components/actions/deleteNextcloudFile.tsx
================================================
import React, { forwardRef } from 'react'
import { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'
interface DeleteNextcloudFileProps {
t: (key: string) => string
pathname: string
navigate: (path: string) => void
search: string
}
/**
* Deletes a Nextcloud file.
*
* @param t - The translation function.
* @param pathname - The current pathname.
* @param navigate - The navigation function.
* @param search - The current search string.
* @returns An actions menu item to delete a Nextcloud file
*/
export const deleteNextcloudFile = ({
t,
pathname,
navigate,
search
}: DeleteNextcloudFileProps): Action => {
const label = t('SelectionBar.trash')
const icon = TrashIcon
return {
name: 'deleteNextcloudFile',
label,
icon,
action: (files): void => {
navigateToModalWithMultipleFile({
files,
pathname,
navigate,
path: 'delete',
search
})
},
Component: forwardRef(function DeleteNextcloudFile(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/nextcloud/components/actions/downloadNextcloudFile.jsx
================================================
import React, { forwardRef } from 'react'
import { isFile } from 'cozy-client/dist/models/file'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
export const downloadNextcloudFile = ({ t, client }) => {
const label = t('SelectionBar.download')
const icon = DownloadIcon
return {
name: 'downloadNextcloudFile',
label,
icon,
displayCondition: docs => {
return docs.length === 1
},
action: docs => {
return client
.collection('io.cozy.remote.nextcloud.files')
.download(docs[0])
},
disabled: docs => docs.some(doc => !isFile(doc)),
Component: forwardRef(function DownloadNextcloudFile(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/nextcloud/components/actions/downloadNextcloudFolder.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const downloadNextcloudFolder = ({ t }) => {
const label = t('toolbar.menu_download_folder')
const icon = DownloadIcon
return {
name: 'downloadNextcloudFolder',
label,
icon,
action: () => {},
disabled: () => true,
Component: forwardRef(function DownloadNextcloudFolder(props, ref) {
return (
)
})
}
}
export { downloadNextcloudFolder }
================================================
FILE: src/modules/nextcloud/components/actions/duplicateNextcloudFile.jsx
================================================
import React, { forwardRef } from 'react'
import { isFile } from 'cozy-client/dist/models/file'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import MultiFilesIcon from 'cozy-ui/transpiled/react/Icons/MultiFiles'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const duplicateNextcloudFile = ({ t }) => {
const label = t('SelectionBar.duplicate')
const icon = MultiFilesIcon
return {
name: 'duplicateNextcloudFile',
label,
icon,
displayCondition: selection => {
return selection.length === 1 && isFile(selection[0])
},
action: () => {},
disabled: () => true,
Component: forwardRef(function Duplicate(props, ref) {
return (
)
})
}
}
export { duplicateNextcloudFile }
================================================
FILE: src/modules/nextcloud/components/actions/moveNextcloud.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import MovetoIcon from 'cozy-ui/transpiled/react/Icons/Moveto'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'
const moveNextcloud = ({ t, pathname, navigate, search }) => {
const label = t('SelectionBar.moveto')
const icon = MovetoIcon
return {
name: 'moveNextcloud',
label,
icon,
displayCondition: docs => docs.length > 0,
action: files => {
navigateToModalWithMultipleFile({
files,
pathname,
navigate,
path: 'move',
search
})
},
disabled: docs => docs.some(doc => doc.type === 'directory'),
Component: forwardRef(function MoveNextcloud(props, ref) {
return (
)
})
}
}
export { moveNextcloud }
================================================
FILE: src/modules/nextcloud/components/actions/openWithinNextcloud.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import LinkOutIcon from 'cozy-ui/transpiled/react/Icons/LinkOut'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
export const openWithinNextcloud = ({ t }) => {
const label = t('SelectionBar.openWithinNextcloud')
const icon = LinkOutIcon
return {
name: 'openWithinNextcloud',
label,
icon,
displayCondition: docs => docs.length === 1,
action: docs => {
window.open(docs[0].links.self, '_blank')
},
Component: forwardRef(function OpenWithinNextcloud(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/nextcloud/components/actions/rename.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import RenameIcon from 'cozy-ui/transpiled/react/Icons/Rename'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const rename = ({ t }) => {
const label = t('SelectionBar.rename')
const icon = RenameIcon
return {
name: 'rename',
label,
icon,
displayCondition: docs => docs.length === 1,
action: () => {},
disabled: () => true,
Component: forwardRef(function Rename(props, ref) {
return (
)
})
}
}
export { rename }
================================================
FILE: src/modules/nextcloud/components/actions/restoreNextcloudFile.tsx
================================================
import React, { forwardRef } from 'react'
import CozyClient from 'cozy-client/types/CozyClient'
import { NextcloudFile } from 'cozy-client/types/types'
import { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { getParentPath } from '@/lib/path'
import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'
interface NextcloudFilesCollection {
restore: (file: NextcloudFile) => Promise
}
interface RestoreNextcloudFileProps {
t: (key: string) => string
client: CozyClient
showAlert: import('cozy-ui/transpiled/react/providers/Alert').showAlertFunction
}
export const restoreNextcloudFile = ({
t,
client,
showAlert
}: RestoreNextcloudFileProps): Action => {
const label = t('RestoreNextcloudFile.label')
const icon = RestoreIcon
return {
name: 'restoreNextcloudFile',
label,
icon,
displayCondition: (files): boolean => files.length > 0,
action: async (files): Promise => {
const sourceAccount = files[0].cozyMetadata.sourceAccount
const parentPath = files[0].parentPath
try {
for (const file of files) {
const collection = client.collection(
'io.cozy.remote.nextcloud.files'
) as unknown as NextcloudFilesCollection
await collection.restore(file)
}
const restorePaths = files
.map(file =>
file.restore_path ? getParentPath(file.restore_path) : undefined
)
.filter(Boolean)
const uniqueRestorePaths = Array.from(new Set(restorePaths))
const resetResults = await Promise.all(
uniqueRestorePaths.map(restorePath => {
const queryId = computeNextcloudFolderQueryId({
sourceAccount,
path: restorePath
})
return client.resetQuery(queryId)
})
)
// If the query for the folder containing the restored files does not exist,
// we need to reset the query of the current folder to refresh the view.
// Since the current folder is the trash folder, its queryId ends with '/trashed'.
if (resetResults.some(query => query === null)) {
const queryId =
computeNextcloudFolderQueryId({
sourceAccount,
path: parentPath
}) + '/trashed'
await client.resetQuery(queryId)
}
showAlert({
message: t('RestoreNextcloudFile.success'),
severity: 'success'
})
} catch (_error) {
showAlert({
message: t('RestoreNextcloudFile.error'),
severity: 'error'
})
}
},
Component: forwardRef(function RestoreNextcloudFile(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/nextcloud/components/actions/shareNextcloudFile.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import LinkOutIcon from 'cozy-ui/transpiled/react/Icons/LinkOut'
import ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const shareNextcloudFile = ({ t }) => {
const label = t('toolbar.share')
const icon = ShareIcon
return {
name: 'share',
label,
icon,
displayCondition: docs => docs.length === 1,
action: docs => {
window.open(docs[0].links.self, '_blank')
},
Component: forwardRef(function Share(props, ref) {
return (
)
})
}
}
export { shareNextcloudFile }
================================================
FILE: src/modules/nextcloud/components/actions/trash.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const trash = ({ t }) => {
const label = t('SelectionBar.trash')
const icon = TrashIcon
return {
name: 'trash',
label,
icon,
action: () => {},
disabled: () => true,
Component: forwardRef(function DeleteNextcloudFile(props, ref) {
return (
)
})
}
}
export { trash }
================================================
FILE: src/modules/nextcloud/components/actions/upload.jsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import UploadIcon from 'cozy-ui/transpiled/react/Icons/Upload'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
const upload = ({ t }) => {
const label = t('toolbar.menu_upload')
const icon = UploadIcon
return {
name: 'upload',
label,
icon,
action: () => {},
disabled: () => true,
Component: forwardRef(function Upload(props, ref) {
return (
)
})
}
}
export { upload }
================================================
FILE: src/modules/nextcloud/helpers.ts
================================================
import { isShortcut } from 'cozy-client/dist/models/file'
import type { IOCozyFile, NextcloudFile } from 'cozy-client/types/types'
import flag from 'cozy-flags'
import type { File, FolderPickerEntry } from '@/components/FolderPicker/types'
export const computeNextcloudFolderQueryId = ({
sourceAccount,
path
}: {
sourceAccount: string
path: string
}): string => {
return `io.cozy.remote.nextcloud.files/sourceAccount/${sourceAccount}/path${path}`
}
/**
* Checks if the given file is a Nextcloud shortcut.
*
* @param file - The file object to check.
* @returns - Returns true if the file is a Nextcloud shortcut, false otherwise.
*/
export const isNextcloudShortcut = (file: IOCozyFile): boolean => {
return (
isShortcut(file) &&
file.cozyMetadata?.createdByApp === 'nextcloud' &&
!flag('drive.hide-nextcloud-dev')
)
}
export const isNextcloudFile = (
file: File | FolderPickerEntry
): file is NextcloudFile => {
return file._type === 'io.cozy.remote.nextcloud.files'
}
================================================
FILE: src/modules/nextcloud/hooks/useNextcloudCurrentFolder.tsx
================================================
import { useParams } from 'react-router-dom'
import { NextcloudFile, UseQueryReturnValue } from 'cozy-client/types/types'
import { computeNextcloudRootFolder } from '@/components/FolderPicker/helpers'
import { getParentPath } from '@/lib/path'
import { hasDataLoaded } from '@/lib/queries'
import { useNextcloudFolder } from '@/modules/nextcloud/hooks/useNextcloudFolder'
import { useNextcloudInfos } from '@/modules/nextcloud/hooks/useNextcloudInfos'
import { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'
/**
* Nextcloud don't have route to get parent folder
* so we need to fetch the content of his parent folder to get current folder data
*/
const useNextcloudCurrentFolder = (): NextcloudFile | undefined => {
const { sourceAccount } = useParams()
const path = useNextcloudPath()
const { instanceName } = useNextcloudInfos({ sourceAccount })
const { nextcloudResult } = useNextcloudFolder({
sourceAccount,
path: getParentPath(path)
}) as {
nextcloudResult: {
data?: NextcloudFile[] | null
}
}
if (path === '/' && sourceAccount) {
return computeNextcloudRootFolder({
sourceAccount,
instanceName
})
}
if (hasDataLoaded(nextcloudResult as UseQueryReturnValue)) {
return (nextcloudResult.data ?? []).find(file => file.path === path)
}
return undefined
}
export { useNextcloudCurrentFolder }
================================================
FILE: src/modules/nextcloud/hooks/useNextcloudEntries.tsx
================================================
import { useLocation, useParams } from 'react-router-dom'
import { NextcloudFile } from 'cozy-client/types/types'
import { hasDataLoaded } from '@/lib/queries'
import { useNextcloudFolder } from '@/modules/nextcloud/hooks/useNextcloudFolder'
import { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'
interface useNextcloudEntriesReturn {
isLoading: boolean
entries?: NextcloudFile[]
hasEntries: boolean
}
const useNextcloudEntries = ({
insideTrash = false
} = {}): useNextcloudEntriesReturn => {
const { state } = useLocation() as {
state?: { fileIds?: string[] }
}
const { sourceAccount } = useParams()
const path = useNextcloudPath({
insideTrash
})
const { nextcloudResult } = useNextcloudFolder({
sourceAccount,
path,
insideTrash
})
if (!state?.fileIds) {
return {
isLoading: false,
hasEntries: false
}
}
if (hasDataLoaded(nextcloudResult)) {
const entries = nextcloudResult.data.filter(({ _id }) =>
state.fileIds.includes(_id)
)
return {
isLoading: false,
hasEntries: entries.length > 0,
entries
}
}
return {
isLoading: true,
hasEntries: true
}
}
export { useNextcloudEntries }
================================================
FILE: src/modules/nextcloud/hooks/useNextcloudFolder.tsx
================================================
import { useQuery } from 'cozy-client'
import { NextcloudFile } from 'cozy-client/types/types'
import {
buildNextcloudFolderQuery,
buildNextcloudTrashFolderQuery,
QueryConfig
} from '@/queries'
interface NextcloudFolderProps {
sourceAccount?: string
path: string
insideTrash: boolean
}
interface NextcloudFolderReturn {
nextcloudQuery: QueryConfig
nextcloudResult: {
data?: NextcloudFile[] | null
}
}
const useNextcloudFolder = ({
sourceAccount,
path,
insideTrash = false
}: NextcloudFolderProps): NextcloudFolderReturn => {
const queryBuilder = insideTrash
? buildNextcloudTrashFolderQuery
: buildNextcloudFolderQuery
const nextcloudQuery = queryBuilder({
sourceAccount,
path
})
const nextcloudResult = useQuery(
nextcloudQuery.definition,
nextcloudQuery.options
) as NextcloudFolderReturn['nextcloudResult']
return {
nextcloudQuery,
nextcloudResult
}
}
export { useNextcloudFolder }
================================================
FILE: src/modules/nextcloud/hooks/useNextcloudInfos.jsx
================================================
import { hasQueryBeenLoaded, useQuery } from 'cozy-client'
import { buildNextcloudShortcutQuery } from '@/queries'
/**
* @typedef {Object} NextcloudInfos
* @property {boolean} isLoading - Whether the data is still loading
* @property {string} [instanceName] - The name of the Nextcloud instance
* @property {string} [instanceUrl] - The URL of the Nextcloud instance
* @property {string} [rootFolderName] - The name of the root folder
*/
/**
* Fetches the Nextcloud instance name and URL
*
* @param {Object} params
* @param {string} [params.sourceAccount] - The source account
* @returns {NextcloudInfos}
*/
const useNextcloudInfos = ({ sourceAccount }) => {
const nextcloudShortcutsQuery = buildNextcloudShortcutQuery({
sourceAccount
})
const nextcloudShortcutsResult = useQuery(
nextcloudShortcutsQuery.definition,
nextcloudShortcutsQuery.options
)
if (
hasQueryBeenLoaded(nextcloudShortcutsResult) &&
nextcloudShortcutsResult.data.length > 0 &&
nextcloudShortcutsResult.data[0].metadata
) {
const instanceName = nextcloudShortcutsResult.data[0].metadata.instanceName
return {
isLoading: false,
instanceName: nextcloudShortcutsResult.data[0].metadata.instanceName,
instanceUrl: nextcloudShortcutsResult.data[0].metadata.fileIdAttributes,
rootFolderName: `${instanceName} (Nextcloud)`
}
}
return {
isLoading: true
}
}
export { useNextcloudInfos }
================================================
FILE: src/modules/nextcloud/hooks/useNextcloudPath.jsx
================================================
import { useSearchParams } from 'react-router-dom'
const useNextcloudPath = ({ insideTrash = false } = {}) => {
const [searchParams] = useSearchParams()
const defaultPath = insideTrash ? '/trash/' : '/'
return searchParams.get('path') ?? defaultPath
}
export { useNextcloudPath }
================================================
FILE: src/modules/paste/index.js
================================================
import {
isFile,
copy,
move,
moveRelateToSharedDrive
} from 'cozy-client/dist/models/file'
import { resolveNameConflictsForCut } from './utils'
import { ROOT_DIR_ID, NEXTCLOUD_FILE_ID } from '@/constants/config'
import { DOCTYPE_FILES } from '@/lib/doctypes'
import logger from '@/lib/logger'
import { joinPath } from '@/lib/path'
import { hasOneOfEntriesShared } from '@/modules/move/helpers'
import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'
/**
* Executes move or copy operations for shared drive files/folders.
* Handles the specific API calls required for shared drive operations.
*
* @param {CozyClient} client - The cozy client instance
* @param {import('@/components/FolderPicker/types').File} entry - The file or folder to move/copy
* @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the entry
* @param {import('@/components/FolderPicker/types').File} destDirectory - The destination directory
* @param {string} operation - The operation type ('move' or 'copy')
* @returns {Promise} The result of the shared drive operation
*/
const executeSharedDriveMoveOrCopy = async (
client,
entry,
sourceDirectory,
destDirectory,
operation
) => {
return await moveRelateToSharedDrive(
client,
{
instance: entry.driveId
? sourceDirectory.attributes?.cozyMetadata?.createdOn
: '',
file_id: isFile(entry) ? entry._id : '',
dir_id: !isFile(entry) ? entry._id : '',
sharing_id: entry.driveId
},
{
instance: destDirectory.driveId
? destDirectory.cozyMetadata?.createdOn
: '',
sharing_id: destDirectory.driveId,
dir_id: destDirectory._id
},
operation === 'copy'
)
}
/**
* Executes a move operation for files or folders.
* Automatically detects if it's a shared drive operation and uses the appropriate API.
*
* @param {CozyClient} client - The cozy client instance
* @param {import('@/components/FolderPicker/types').File} entry - The file or folder to move
* @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the entry
* @param {import('@/components/FolderPicker/types').File} destDirectory - The destination directory
* @param {boolean} [force=false] - Whether to force the move operation
* @returns {Promise} The result of the move operation
*/
export const executeMove = async (
client,
entry,
sourceDirectory,
destDirectory,
force = false
) => {
const isSharedDriveOperation = entry.driveId || destDirectory.driveId
if (isSharedDriveOperation) {
return await executeSharedDriveMoveOrCopy(
client,
entry,
sourceDirectory,
destDirectory
)
}
return await move(client, entry, destDirectory, {
force
})
}
/**
* Handles paste operations (copy or cut) for multiple files/folders.
* Processes each file individually and handles validation, conflicts, and sharing permissions.
*
* @param {CozyClient} client - The cozy client instance
* @param {Array} files - Array of files/folders to paste
* @param {string | null} operation - The paste operation ('copy' or 'cut')
* @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the files
* @param {import('@/components/FolderPicker/types').File} targetFolder - The target folder for the paste operation
* @param {Object} [options={}] - Additional options
* @param {Function} [options.showAlert] - Function to show user alerts
* @param {Function} [options.t] - Translation function
* @param {unknown} [options.sharingContext] - Sharing context for validation
* @param {Function} [options.showMoveValidationModal] - Function to show move validation modals
* @param {boolean} [options.isPublic] - Whether the target folder is in public view
* @returns {Promise>} Array of operation results with success/failure status
*/
export const handlePasteOperation = async (
client,
files,
operation,
sourceDirectory,
targetFolder,
options = {}
) => {
const { showAlert, t, sharingContext, isPublic } = options
const results = []
// For cut operations, resolve name conflicts first
let processedFiles = files
if (operation === 'cut') {
processedFiles = await resolveNameConflictsForCut(
client,
files,
targetFolder,
isPublic
)
}
const isCopyOperation = operation === 'copy'
const isCutOperation = operation === 'cut'
let hasValidatedMove = false
for (const file of processedFiles) {
try {
if (isCopyOperation) {
if (!isFile(file)) continue
const result = await handleDuplicateWithValidation(
client,
file,
targetFolder,
{ showAlert, t }
)
results.push({ success: true, file: result, operation: 'copy' })
} else if (isCutOperation) {
const shouldValidateMove = !hasValidatedMove
if (shouldValidateMove) {
hasValidatedMove = true
}
const result = await handleMoveWithValidation(
client,
file,
sourceDirectory,
targetFolder,
{
sharingContext: shouldValidateMove ? sharingContext : null,
showMoveValidationModal: shouldValidateMove
? options.showMoveValidationModal
: null
}
)
results.push({ success: true, file: result, operation: 'move' })
}
} catch (error) {
results.push({ success: false, file, error, operation })
}
}
return results
}
/**
* Handles file duplication (copy operation) with validation and user feedback.
* Shows success alerts and handles Nextcloud query refreshing.
*
* @param {CozyClient} client - The cozy client instance
* @param {import('@/components/FolderPicker/types').File} file - The file to duplicate
* @param {import('@/components/FolderPicker/types').File} targetFolder - The target folder for duplication
* @param {Object} [options={}] - Additional options
* @param {Function} [options.showAlert] - Function to show user alerts
* @param {Function} [options.t] - Translation function
* @returns {Promise} The duplicated file object
*/
const handleDuplicateWithValidation = async (
client,
file,
targetFolder,
options = {}
) => {
const { showAlert, t } = options
const result = await copy(client, file, targetFolder)
const isCopyingInsideNextcloud = targetFolder._type === NEXTCLOUD_FILE_ID
if (isCopyingInsideNextcloud) {
refreshNextcloudQueries(client, targetFolder)
}
if (showAlert && t) {
showAlert({
message: t('DuplicateModal.success', {
smart_count: 1,
fileName: file.name,
destinationName:
targetFolder._id === ROOT_DIR_ID
? t('breadcrumb.title_drive')
: targetFolder.name
}),
severity: 'success'
})
}
return result
}
/**
* Creates a promisified move operation with user confirmation modal.
*
* @param {Function} showMoveValidationModal - Function to show validation modal
* @param {string} modalType - Type of modal to show
* @param {import('@/components/FolderPicker/types').File} file - File to move
* @param {import('@/components/FolderPicker/types').File} targetFolder - Target folder
* @param {CozyClient} client - Cozy client instance
* @param {import('@/components/FolderPicker/types').File} sourceDirectory - Source directory
* @returns {Promise} Promise that resolves with move result
*/
const createMoveWithConfirmation = (
showMoveValidationModal,
modalType,
file,
targetFolder,
client,
sourceDirectory
) => {
return new Promise((resolve, reject) => {
const executeConfirmedMove = async () => {
try {
const result = await executeMove(
client,
file,
sourceDirectory,
targetFolder,
true
)
resolve(result)
} catch (error) {
reject(error)
}
}
const cancelMove = () => reject(new Error('Move cancelled by user'))
showMoveValidationModal(
modalType,
file,
targetFolder,
executeConfirmedMove,
cancelMove
)
})
}
/**
* Determines the sharing context for a file and target folder.
*
* @param {import('@/components/FolderPicker/types').File} file - File to analyze
* @param {import('@/components/FolderPicker/types').File} targetFolder - Target folder
* @param {Object} sharingContext - Sharing context with helper functions
* @returns {Object} Sharing analysis result
*/
const analyzeSharingContext = (file, targetFolder, sharingContext) => {
const { getSharedParentPath, hasSharedParent, byDocId } = sharingContext
const sharedParentPath = file.path ? getSharedParentPath(file.path) : ''
const targetPath = joinPath(targetFolder.path, file.name)
const areMovedFilesShared = hasOneOfEntriesShared([file], byDocId)
const isOriginParentShared =
hasSharedParent(file.path || '') || !!file.driveId
const isTargetShared =
hasSharedParent(targetPath || '') ||
(!!targetFolder.driveId && targetFolder.driveId !== file.driveId)
const isInsideSameSharedFolder =
(sharedParentPath && targetPath.startsWith(sharedParentPath)) ||
(!!file.driveId &&
!!targetFolder.driveId &&
file.driveId === targetFolder.driveId)
return {
areMovedFilesShared,
isOriginParentShared,
isTargetShared,
isInsideSameSharedFolder
}
}
/**
* Handles sharing validation and shows appropriate modals if needed.
*
* @param {import('@/components/FolderPicker/types').File} file - File to move
* @param {import('@/components/FolderPicker/types').File} targetFolder - Target folder
* @param {Object} sharingContext - Sharing context
* @param {Function} showMoveValidationModal - Modal function
* @param {CozyClient} client - Cozy client
* @param {import('@/components/FolderPicker/types').File} sourceDirectory - Source directory
* @returns {Promise|null} Move result or null if no validation needed
*/
const handleSharingValidation = async (
file,
targetFolder,
sharingContext,
showMoveValidationModal,
client,
sourceDirectory
) => {
const { getSharedParentPath, hasSharedParent, byDocId } = sharingContext
const needsSharingValidation =
(getSharedParentPath && hasSharedParent && byDocId) ||
!!file.driveId ||
!!targetFolder.driveId
if (!needsSharingValidation) return null
try {
const sharingAnalysis = analyzeSharingContext(
file,
targetFolder,
sharingContext
)
const {
areMovedFilesShared,
isOriginParentShared,
isTargetShared,
isInsideSameSharedFolder
} = sharingAnalysis
if (isInsideSameSharedFolder) return null
if (isOriginParentShared && !isTargetShared) {
return createMoveWithConfirmation(
showMoveValidationModal,
'moveOutside',
file,
targetFolder,
client,
sourceDirectory
)
}
if (!areMovedFilesShared && isTargetShared) {
return createMoveWithConfirmation(
showMoveValidationModal,
'moveInside',
file,
targetFolder,
client,
sourceDirectory
)
}
if (areMovedFilesShared && isTargetShared) {
return createMoveWithConfirmation(
showMoveValidationModal,
'moveSharedInside',
file,
targetFolder,
client,
sourceDirectory
)
}
} catch (error) {
logger.error('Failed to validate sharing context:', error)
}
return null
}
/**
* Handles file renaming if needed.
*
* @param {CozyClient} client - Cozy client instance
* @param {import('@/components/FolderPicker/types').File} file - File to rename
*/
const handleFileRename = async (client, file) => {
if (!file.needsRename) return
await client.collection(DOCTYPE_FILES).update({
...file,
name: file.uniqueName,
_rev: file._rev || file.meta.rev
})
}
/**
* Handles Nextcloud query refresh after move operations.
*
* @param {CozyClient} client - Cozy client instance
* @param {import('@/components/FolderPicker/types').File} file - Moved file
* @param {import('@/components/FolderPicker/types').File} targetFolder - Target folder
*/
const handleNextcloudRefresh = (client, file, targetFolder) => {
const isMovingInsideNextcloud = targetFolder._type === NEXTCLOUD_FILE_ID
const isMovingOutsideNextcloud =
!isMovingInsideNextcloud && file._type === NEXTCLOUD_FILE_ID
if (isMovingInsideNextcloud || isMovingOutsideNextcloud) {
refreshNextcloudQueries(client, targetFolder, file, {
isMovingInsideNextcloud,
isMovingOutsideNextcloud
})
}
}
/**
* Handles file/folder move operations with comprehensive sharing validation.
* Checks for shared folder boundaries and shows appropriate validation modals.
* Handles name conflicts and Nextcloud integration.
*
* @param {CozyClient} client - The cozy client instance
* @param {import('@/components/FolderPicker/types').File} file - The file or folder to move
* @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the file
* @param {import('@/components/FolderPicker/types').File} targetFolder - The target folder for the move
* @param {Object} [options={}] - Additional options
* @param {Object} [options.sharingContext] - Sharing context for validation
* @param {Function} [options.showMoveValidationModal] - Function to show move validation modals
* @returns {Promise} The moved file/folder object
*/
const handleMoveWithValidation = async (
client,
file,
sourceDirectory,
targetFolder,
options = {}
) => {
const { sharingContext, showMoveValidationModal } = options
const canValidateSharing =
sharingContext &&
(file.path || file.driveId) &&
targetFolder.path &&
showMoveValidationModal
if (canValidateSharing) {
const validationResult = await handleSharingValidation(
file,
targetFolder,
sharingContext,
showMoveValidationModal,
client,
sourceDirectory
)
if (validationResult !== null) {
return validationResult
}
}
await handleFileRename(client, file)
const result = await executeMove(client, file, sourceDirectory, targetFolder)
handleNextcloudRefresh(client, file, targetFolder)
return result
}
/**
* Refreshes Nextcloud queries after move/copy operations.
*
* @param {CozyClient} client - The cozy client instance
* @param {import('@/components/FolderPicker/types').File} targetFolder - The target folder of the operation
* @param {import('@/components/FolderPicker/types').File | null} [sourceFile=null] - The source file (for move operations)
* @param {Object} [options={}] - Additional options
* @param {boolean} [options.isMovingInsideNextcloud=false] - Whether moving into Nextcloud
* @param {boolean} [options.isMovingOutsideNextcloud=false] - Whether moving out of Nextcloud
*/
const refreshNextcloudQueries = (
client,
targetFolder,
sourceFile = null,
options = {}
) => {
const { isMovingInsideNextcloud = false, isMovingOutsideNextcloud = false } =
options
if (isMovingInsideNextcloud || targetFolder._type === NEXTCLOUD_FILE_ID) {
const queryId = computeNextcloudFolderQueryId({
sourceAccount: targetFolder.cozyMetadata?.sourceAccount,
path: targetFolder.path
})
client?.resetQuery(queryId)
}
if (isMovingOutsideNextcloud && sourceFile) {
const queryId = computeNextcloudFolderQueryId({
sourceAccount: sourceFile.cozyMetadata?.sourceAccount,
path: sourceFile.path
})
client?.resetQuery(queryId)
}
}
================================================
FILE: src/modules/paste/index.spec.js
================================================
import { handlePasteOperation } from './index'
// Mock dependencies
jest.mock('cozy-client/dist/models/file', () => ({
isFile: jest.fn(),
copy: jest.fn(),
move: jest.fn()
}))
jest.mock('./utils', () => ({
resolveNameConflictsForCut: jest.fn()
}))
jest.mock('../move/helpers', () => ({
hasOneOfEntriesShared: jest.fn()
}))
jest.mock('../../lib/logger', () => ({
error: jest.fn(),
info: jest.fn()
}))
const { isFile, copy, move } = require('cozy-client/dist/models/file')
const { resolveNameConflictsForCut } = require('./utils')
const { hasOneOfEntriesShared } = require('../move/helpers')
describe('handlePasteOperation', () => {
let mockClient, mockFiles, mockTargetFolder, mockSourceDirectory, mockOptions
beforeEach(() => {
mockClient = {
save: jest.fn(),
query: jest.fn(),
collection: jest.fn(() => ({
updateFile: jest.fn(),
update: jest.fn().mockResolvedValue({ data: { _id: 'updated-file' } })
}))
}
mockFiles = [
{
_id: 'file1',
name: 'test1.txt',
type: 'file',
attributes: { name: 'test1.txt' }
},
{
_id: 'file2',
name: 'test2.txt',
type: 'file',
attributes: { name: 'test2.txt' }
}
]
mockTargetFolder = {
_id: 'target-folder',
name: 'Target Folder',
path: '/Target Folder'
}
mockSourceDirectory = {
_id: 'source-folder',
name: 'Source Folder',
path: '/Source Folder'
}
mockOptions = {
showAlert: jest.fn(),
t: jest.fn(key => key),
sharingContext: {}
}
// Default mocks
isFile.mockReturnValue(true)
copy.mockResolvedValue({ data: { _id: 'copied-file' } })
move.mockResolvedValue({ data: { _id: 'moved-file' } })
resolveNameConflictsForCut.mockResolvedValue(mockFiles)
hasOneOfEntriesShared.mockReturnValue(false)
jest.clearAllMocks()
})
describe('Copy Operations', () => {
it('should copy files successfully', async () => {
const result = await handlePasteOperation(
mockClient,
mockFiles,
'copy',
null, // sourceDirectory
mockTargetFolder,
mockOptions
)
expect(copy).toHaveBeenCalledTimes(2)
expect(copy).toHaveBeenCalledWith(
mockClient,
mockFiles[0],
mockTargetFolder
)
expect(copy).toHaveBeenCalledWith(
mockClient,
mockFiles[1],
mockTargetFolder
)
expect(result).toEqual([
{
success: true,
file: { data: { _id: 'copied-file' } },
operation: 'copy'
},
{
success: true,
file: { data: { _id: 'copied-file' } },
operation: 'copy'
}
])
})
it('should not resolve name conflicts for copy operations', async () => {
await handlePasteOperation(
mockClient,
mockFiles,
'copy',
null, // sourceDirectory
mockTargetFolder,
mockOptions
)
expect(resolveNameConflictsForCut).not.toHaveBeenCalled()
})
})
describe('Cut Operations', () => {
it('should move files successfully', async () => {
const result = await handlePasteOperation(
mockClient,
mockFiles,
'cut',
mockSourceDirectory,
mockTargetFolder,
mockOptions
)
expect(resolveNameConflictsForCut).toHaveBeenCalledWith(
mockClient,
mockFiles,
mockTargetFolder,
undefined
)
expect(move).toHaveBeenCalledTimes(2)
expect(move).toHaveBeenCalledWith(
mockClient,
mockFiles[0],
mockTargetFolder,
{ force: false }
)
expect(move).toHaveBeenCalledWith(
mockClient,
mockFiles[1],
mockTargetFolder,
{ force: false }
)
expect(result).toEqual([
{
success: true,
file: { data: { _id: 'moved-file' } },
operation: 'move'
},
{
success: true,
file: { data: { _id: 'moved-file' } },
operation: 'move'
}
])
})
it('should use resolved names for cut operations', async () => {
const resolvedFiles = [
{
...mockFiles[0],
needsRename: true,
uniqueName: 'test1 (1).txt',
attributes: { name: 'test1 (1).txt' },
_rev: 'rev1',
meta: { rev: 'rev1' }
},
{
...mockFiles[1],
needsRename: false,
uniqueName: 'test2.txt',
attributes: { name: 'test2.txt' }
}
]
resolveNameConflictsForCut.mockResolvedValue(resolvedFiles)
await handlePasteOperation(
mockClient,
mockFiles,
'cut',
mockSourceDirectory,
mockTargetFolder,
mockOptions
)
expect(move).toHaveBeenCalledTimes(2)
expect(move).toHaveBeenCalledWith(
mockClient,
resolvedFiles[0],
mockTargetFolder,
{ force: false }
)
expect(move).toHaveBeenCalledWith(
mockClient,
resolvedFiles[1],
mockTargetFolder,
{ force: false }
)
})
})
describe('Sharing Context', () => {
it('should handle shared files with sharing context', async () => {
hasOneOfEntriesShared.mockReturnValue(true)
const sharingContext = {
showMoveValidationModal: jest.fn(),
hideMoveValidationModal: jest.fn()
}
mockOptions.sharingContext = sharingContext
await handlePasteOperation(
mockClient,
mockFiles,
'cut',
mockSourceDirectory,
mockTargetFolder,
mockOptions
)
// Should still process files normally
expect(move).toHaveBeenCalledTimes(2)
})
it('should handle files without sharing context when shared', async () => {
hasOneOfEntriesShared.mockReturnValue(true)
// No sharing context provided
delete mockOptions.sharingContext
const result = await handlePasteOperation(
mockClient,
mockFiles,
'cut',
mockSourceDirectory,
mockTargetFolder,
mockOptions
)
// Should still process files
expect(result).toHaveLength(2)
expect(move).toHaveBeenCalledTimes(2)
})
})
describe('Nextcloud Integration', () => {
it('should handle Nextcloud files', async () => {
const nextcloudFiles = [
{
_id: 'nextcloud-file',
name: 'nextcloud.txt',
type: 'file',
attributes: { name: 'nextcloud.txt' },
cozyMetadata: { sourceAccount: 'nextcloud-account' }
}
]
await handlePasteOperation(
mockClient,
nextcloudFiles,
'copy',
null, // sourceDirectory
mockTargetFolder,
mockOptions
)
expect(copy).toHaveBeenCalledWith(
mockClient,
nextcloudFiles[0],
mockTargetFolder
)
})
})
describe('Edge Cases', () => {
it('should handle empty files array', async () => {
const result = await handlePasteOperation(
mockClient,
[],
'copy',
null, // sourceDirectory
mockTargetFolder,
mockOptions
)
expect(result).toEqual([])
expect(copy).not.toHaveBeenCalled()
expect(move).not.toHaveBeenCalled()
})
it('should handle null files array', async () => {
await expect(
handlePasteOperation(
mockClient,
null,
'copy',
null, // sourceDirectory
mockTargetFolder,
mockOptions
)
).rejects.toThrow('processedFiles is not iterable')
expect(copy).not.toHaveBeenCalled()
})
it('should handle invalid operation type', async () => {
const result = await handlePasteOperation(
mockClient,
mockFiles,
'invalid-operation',
null, // sourceDirectory
mockTargetFolder,
mockOptions
)
// Should default to no operation
expect(result).toEqual([])
expect(copy).not.toHaveBeenCalled()
expect(move).not.toHaveBeenCalled()
})
it('should handle missing target folder', async () => {
const result = await handlePasteOperation(
mockClient,
mockFiles,
'copy',
null, // sourceDirectory
null, // targetFolder
mockOptions
)
// Should return error results for each file
expect(result).toHaveLength(2)
expect(result[0].success).toBe(false)
expect(result[1].success).toBe(false)
expect(result[0].error).toBeInstanceOf(Error)
expect(result[1].error).toBeInstanceOf(Error)
expect(copy).toHaveBeenCalledTimes(2)
})
it('should handle missing options', async () => {
const result = await handlePasteOperation(
mockClient,
mockFiles,
'copy',
null, // sourceDirectory
mockTargetFolder
)
// Should still work without options
expect(result).toHaveLength(2)
expect(copy).toHaveBeenCalledTimes(2)
})
})
describe('Mixed File Types', () => {
it('should handle both files and folders', async () => {
const mixedFiles = [
{
_id: 'file1',
name: 'test.txt',
type: 'file',
attributes: { name: 'test.txt' }
},
{
_id: 'folder1',
name: 'Test Folder',
type: 'directory',
attributes: { name: 'Test Folder' }
}
]
isFile.mockImplementation(item => item.type === 'file')
const result = await handlePasteOperation(
mockClient,
mixedFiles,
'copy',
null, // sourceDirectory
mockTargetFolder,
mockOptions
)
expect(copy).toHaveBeenCalledTimes(1)
expect(result).toHaveLength(1)
expect(result.every(r => r.success)).toBe(true)
})
})
})
================================================
FILE: src/modules/paste/utils.js
================================================
import { Q } from 'cozy-client'
import { isFile } from 'cozy-client/dist/models/file'
/**
* Extracts the base name, extension, and existing suffix from a file/folder name.
* Handles numbered suffixes in parentheses and file extensions.
*
* @example
* parseName("file (2).txt", true) => { base: "file", extension: ".txt", suffix: 2 }
* parseName("folder (3)", false) => { base: "folder", extension: "", suffix: 3 }
* parseName("document.pdf", true) => { base: "document", extension: ".pdf", suffix: null }
*
* @param {string} name - The file or folder name to parse
* @param {boolean} isFileItem - Whether the item is a file (true) or folder (false)
* @returns {Object} Object containing base name, extension, and suffix
* @returns {string} returns.base - The base name without extension or suffix
* @returns {string} returns.extension - The file extension (empty for folders)
* @returns {number|null} returns.suffix - The numeric suffix if present, null otherwise
*/
const parseName = (name, isFileItem) => {
let base = name
let extension = ''
let suffix = null
if (isFileItem) {
const lastDotIndex = name.lastIndexOf('.')
if (lastDotIndex > 0) {
base = name.substring(0, lastDotIndex)
extension = name.substring(lastDotIndex)
}
}
const match = base.match(/^(.*)\s\((\d+)\)$/)
if (match) {
base = match[1]
suffix = parseInt(match[2], 10)
}
return { base, extension, suffix }
}
/**
* Generates a unique name not present in the given set of existing names.
* Appends numbered suffixes in parentheses until a unique name is found.
*
* @example
* generateUniqueNameWithSuffix("file.txt", new Set(["file.txt"]), true)
* // Returns: "file (1).txt"
*
* generateUniqueNameWithSuffix("folder (2)", new Set(["folder (2)", "folder (3)"]), false)
* // Returns: "folder (4)"
*
* @param {string} originalName - The original name to make unique
* @param {Set} existingNames - Set of names that already exist
* @param {boolean} isFileItem - Whether the item is a file (true) or folder (false)
* @returns {string} A unique name not present in existingNames
*/
export const generateUniqueNameWithSuffix = (
originalName,
existingNames,
isFileItem
) => {
if (!existingNames.has(originalName)) {
return originalName
}
const { base, extension, suffix } = parseName(originalName, isFileItem)
let counter = suffix ? suffix + 1 : 1
let newName
do {
newName = `${base} (${counter})${extension}`
counter++
} while (existingNames.has(newName))
return newName
}
/**
* Gets all existing items in a target folder to check for conflicts.
* Queries for all non-trashed files and folders in the specified directory.
*
* @param {CozyClient} client - The cozy client instance
* @param {import('cozy-client/types/types').IOCozyFile} targetFolder - The target folder object
* @param {string} targetFolder._id - The folder's unique identifier
* @returns {Promise>} Set of existing item names in the folder
* @throws {Error} When targetFolder is invalid or missing _id
*/
export const getExistingItems = async (
client,
targetFolder,
isPublic = false
) => {
if (!targetFolder || !targetFolder._id) {
throw new Error('Invalid targetFolder: missing _id')
}
if (isPublic) {
const { included } = await client
.collection('io.cozy.files')
.statById(targetFolder._id)
return new Set(included?.map(item => item.name))
}
const query = Q('io.cozy.files')
.where({
dir_id: targetFolder._id,
trashed: false
})
.indexFields(['dir_id', 'trashed'])
const result = await client.query(query)
const items = result.data || []
return new Set(items.map(item => item.name))
}
/**
* Resolves name conflicts by generating unique names for files/folders to be moved.
*
* @param {CozyClient} client - The cozy client instance
* @param {Array} files - Array of files/folders to be moved
* @param {import('cozy-client/types/types').IOCozyFile} targetFolder - The target folder object
* @param {string} targetFolder._id - The folder's unique identifier
* @returns {Promise>} Array of files with resolved names and conflict flags
* @returns {boolean} returns[].needsRename - Whether the file needs to be renamed
* @returns {string} returns[].uniqueName - The unique name for the file
* @throws {Error} When files is not an array
*
* @example
* const files = [{ name: "document.pdf", _id: "123" }]
* const resolved = await resolveNameConflictsForCut(client, files, targetFolder)
* // If "document.pdf" exists, returns:
* // [{ name: "document.pdf", uniqueName: "document (1).pdf", needsRename: true, ... }]
*/
export const resolveNameConflictsForCut = async (
client,
files,
targetFolder,
isPublic = false
) => {
if (!Array.isArray(files)) {
throw new Error('files must be an array')
}
const existingNames = await getExistingItems(client, targetFolder, isPublic)
const resolvedFiles = files.map(file => {
const isFileItem = isFile(file)
const originalName = file.name
const uniqueName = generateUniqueNameWithSuffix(
originalName,
existingNames,
isFileItem
)
// update the set so subsequent files don’t clash
existingNames.add(uniqueName)
return {
...file,
needsRename: originalName !== uniqueName,
uniqueName,
attributes: {
...file.attributes,
name: uniqueName
}
}
})
return resolvedFiles
}
================================================
FILE: src/modules/paste/utils.spec.js
================================================
import {
generateUniqueNameWithSuffix,
resolveNameConflictsForCut
} from './utils'
jest.mock('cozy-client/dist/models/file', () => ({
isFile: jest.fn()
}))
const { isFile } = require('cozy-client/dist/models/file')
describe('generateUniqueNameWithSuffix', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('File naming', () => {
beforeEach(() => {
isFile.mockReturnValue(true)
})
it('should return original name if no conflict', () => {
const existingNames = new Set(['other.txt'])
const result = generateUniqueNameWithSuffix(
'test.txt',
existingNames,
true
)
expect(result).toBe('test.txt')
})
it('should add suffix for conflicting file names', () => {
const existingNames = new Set(['test.txt'])
const result = generateUniqueNameWithSuffix(
'test.txt',
existingNames,
true
)
expect(result).toBe('test (1).txt')
})
it('should increment suffix for multiple conflicts', () => {
const existingNames = new Set([
'test.txt',
'test (1).txt',
'test (2).txt'
])
const result = generateUniqueNameWithSuffix(
'test.txt',
existingNames,
true
)
expect(result).toBe('test (3).txt')
})
it('should continue from existing suffix', () => {
const existingNames = new Set(['test (2).txt', 'test (3).txt'])
const result = generateUniqueNameWithSuffix(
'test (2).txt',
existingNames,
true
)
expect(result).toBe('test (4).txt')
})
})
describe('Folder naming', () => {
beforeEach(() => {
isFile.mockReturnValue(false)
})
it('should return original name if no conflict', () => {
const existingNames = new Set(['Other Folder'])
const result = generateUniqueNameWithSuffix(
'Test Folder',
existingNames,
false
)
expect(result).toBe('Test Folder')
})
it('should add suffix for conflicting folder names', () => {
const existingNames = new Set(['Test Folder'])
const result = generateUniqueNameWithSuffix(
'Test Folder',
existingNames,
false
)
expect(result).toBe('Test Folder (1)')
})
it('should increment suffix for multiple conflicts', () => {
const existingNames = new Set([
'Test Folder',
'Test Folder (1)',
'Test Folder (2)'
])
const result = generateUniqueNameWithSuffix(
'Test Folder',
existingNames,
false
)
expect(result).toBe('Test Folder (3)')
})
it('should continue from existing suffix for folders', () => {
const existingNames = new Set(['Test Folder (5)', 'Test Folder (6)'])
const result = generateUniqueNameWithSuffix(
'Test Folder (5)',
existingNames,
false
)
expect(result).toBe('Test Folder (7)')
})
})
})
describe('resolveNameConflictsForCut', () => {
let mockClient
beforeEach(() => {
const mockStatById = jest.fn()
mockClient = {
query: jest.fn(),
collection: jest.fn(() => ({
statById: mockStatById
}))
}
// Store reference to statById mock for easy access in tests
mockClient.mockStatById = mockStatById
isFile.mockImplementation(file => file.type === 'file')
jest.clearAllMocks()
})
it('should resolve conflicts for files', async () => {
const files = [
{
_id: 'file1',
name: 'test.txt',
type: 'file',
attributes: { name: 'test.txt' }
},
{
_id: 'file2',
name: 'document.pdf',
type: 'file',
attributes: { name: 'document.pdf' }
}
]
const existingItems = [{ name: 'test.txt' }, { name: 'other.txt' }]
mockClient.query.mockResolvedValue({ data: existingItems })
const targetFolder = { _id: 'target-folder' }
const result = await resolveNameConflictsForCut(
mockClient,
files,
targetFolder
)
expect(result).toHaveLength(2)
// First file should be renamed due to conflict
expect(result[0].needsRename).toBe(true)
expect(result[0].uniqueName).toBe('test (1).txt')
expect(result[0].attributes.name).toBe('test (1).txt')
// Second file should not be renamed (no conflict)
expect(result[1].needsRename).toBe(false)
expect(result[1].uniqueName).toBe('document.pdf')
expect(result[1].attributes.name).toBe('document.pdf')
})
it('should resolve conflicts for folders', async () => {
const folders = [
{
_id: 'folder1',
name: 'Documents',
type: 'directory',
attributes: { name: 'Documents' }
}
]
const existingItems = [{ name: 'Documents' }, { name: 'Pictures' }]
mockClient.query.mockResolvedValue({ data: existingItems })
const targetFolder = { _id: 'target-folder' }
const result = await resolveNameConflictsForCut(
mockClient,
folders,
targetFolder
)
expect(result).toHaveLength(1)
expect(result[0].needsRename).toBe(true)
expect(result[0].uniqueName).toBe('Documents (1)')
expect(result[0].attributes.name).toBe('Documents (1)')
})
describe('Public mode (isPublic=true)', () => {
it('should resolve conflicts for files in public mode', async () => {
const files = [
{
_id: 'file1',
name: 'test.txt',
type: 'file',
attributes: { name: 'test.txt' }
},
{
_id: 'file2',
name: 'document.pdf',
type: 'file',
attributes: { name: 'document.pdf' }
}
]
const existingItems = [{ name: 'test.txt' }, { name: 'other.txt' }]
mockClient.mockStatById.mockResolvedValue({
included: existingItems
})
const targetFolder = { _id: 'target-folder' }
const result = await resolveNameConflictsForCut(
mockClient,
files,
targetFolder,
true
)
expect(result).toHaveLength(2)
// First file should be renamed due to conflict
expect(result[0].needsRename).toBe(true)
expect(result[0].uniqueName).toBe('test (1).txt')
expect(result[0].attributes.name).toBe('test (1).txt')
// Second file should not be renamed (no conflict)
expect(result[1].needsRename).toBe(false)
expect(result[1].uniqueName).toBe('document.pdf')
expect(result[1].attributes.name).toBe('document.pdf')
// Should use collection.statById method for public mode
expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.files')
expect(mockClient.mockStatById).toHaveBeenCalledWith('target-folder')
expect(mockClient.query).not.toHaveBeenCalled()
})
it('should resolve conflicts for folders in public mode', async () => {
const folders = [
{
_id: 'folder1',
name: 'Documents',
type: 'directory',
attributes: { name: 'Documents' }
}
]
const existingItems = [{ name: 'Documents' }, { name: 'Pictures' }]
mockClient.mockStatById.mockResolvedValue({
included: existingItems
})
const targetFolder = { _id: 'target-folder' }
const result = await resolveNameConflictsForCut(
mockClient,
folders,
targetFolder,
true
)
expect(result).toHaveLength(1)
expect(result[0].needsRename).toBe(true)
expect(result[0].uniqueName).toBe('Documents (1)')
expect(result[0].attributes.name).toBe('Documents (1)')
// Should use collection.statById method for public mode
expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.files')
expect(mockClient.mockStatById).toHaveBeenCalledWith('target-folder')
expect(mockClient.query).not.toHaveBeenCalled()
})
})
})
================================================
FILE: src/modules/public/DownloadFilesButton.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { useClient } from 'cozy-client'
import Button from 'cozy-ui/transpiled/react/Buttons'
import Icon from 'cozy-ui/transpiled/react/Icon'
import DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { downloadFiles } from '@/modules/actions/utils'
export const DownloadFilesButton = ({
files,
variant = 'secondary',
...props
}) => {
const { t } = useI18n()
const client = useClient()
const { showAlert } = useAlert()
const handleClick = () => {
downloadFiles(client, files, { showAlert, t })
}
return (
}
onClick={handleClick}
variant={variant}
{...props}
/>
)
}
DownloadFilesButton.propTypes = {
files: PropTypes.array.isRequired,
variant: PropTypes.string
}
================================================
FILE: src/modules/public/LightFileViewer.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { useCallback } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { BarCenter } from 'cozy-bar'
import {
SharingBannerPlugin,
useSharingInfos,
OpenSharingLinkButton
} from 'cozy-sharing'
import MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis'
import Typography from 'cozy-ui/transpiled/react/Typography'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import Viewer, {
FooterActionButtons,
ForwardOrDownloadButton
} from 'cozy-viewer'
import styles from '@/modules/viewer/barviewer.styl'
import { FilesViewerLoading } from '@/components/FilesViewerLoading'
import PublicToolbar from '@/modules/public/PublicToolbar'
import {
isOfficeEnabled,
makeOnlyOfficeFileRoute
} from '@/modules/views/OnlyOffice/helpers'
const LightFileViewer = ({ files, isPublic }) => {
const sharingInfos = useSharingInfos()
const { isDesktop, isMobile } = useBreakpoints()
const { pathname } = useLocation()
const navigate = useNavigate()
const { loading, isSharingShortcutCreated, addSharingLink } = sharingInfos
const onlyOfficeOpener = useCallback(
file => {
const route = makeOnlyOfficeFileRoute(file.id, {
fromPathname: pathname
})
navigate(route)
},
[navigate, pathname]
)
const isCozySharing = window.location.pathname === '/preview'
const isShareNotAdded = !loading && !isSharingShortcutCreated
const isSharingBannerPluginDisplayed = isShareNotAdded || !isCozySharing
const isAddToMyCozyDisplayed = isShareNotAdded && isCozySharing
if (loading) return
return (
{isMobile && (
)}
{isSharingBannerPluginDisplayed &&
}
{isMobile && (
)}
{isAddToMyCozyDisplayed && (
)}
)
}
LightFileViewer.propTypes = {
files: PropTypes.array.isRequired,
isPublic: PropTypes.bool
}
export default LightFileViewer
================================================
FILE: src/modules/public/LightFileViewer.spec.jsx
================================================
import { render } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import I18n from 'twake-i18n'
import LightFileViewer from './LightFileViewer'
import AppLike from 'test/components/AppLike'
jest.mock('cozy-keys-lib', () => ({
...jest.requireActual('cozy-keys-lib'),
useVaultClient: jest.fn()
}))
jest.mock('cozy-intent', () => ({
WebviewIntentProvider: ({ children }) => children,
useWebviewIntent: () => ({ call: () => {} })
}))
jest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({
...jest.requireActual('cozy-ui/transpiled/react/providers/Breakpoints'),
__esModule: true,
default: jest.fn()
}))
// used inside cozy-viewer
jest.mock('cozy-client/dist/models/permission', () => ({
...jest.requireActual('cozy-client/dist/models/permission'),
isDocumentReadOnly: jest.fn().mockResolvedValue(false)
}))
const client = new createMockClient({})
const setup = ({ isDesktop = false, isMobile = false } = {}) => {
useBreakpoints.mockReturnValue({ isDesktop, isMobile })
const root = render(
''}>
)
return { root }
}
describe('LightFileViewer', () => {
describe('on Mobile and Tablet', () => {
it('should have the sharing banner and public toolbar but no viewer toolbar', () => {
jest.spyOn(console, 'error').mockImplementation() // TODO: to be removed with https://github.com/cozy/cozy-libs/pull/1457
jest.spyOn(console, 'warn').mockImplementation()
const { root } = setup({ isMobile: true })
const { queryByTestId, queryAllByRole } = root
expect(queryAllByRole('link')[0].getAttribute('href')).toBe(
'https://twake.app'
) // This is the sharing banner
expect(queryByTestId('public-toolbar')).toBeTruthy()
expect(queryByTestId('viewer-toolbar')).toBeFalsy()
})
})
describe('on Desktop', () => {
it('should have the sharing banner and viewer toolbar but no public toolbar', () => {
const { root } = setup({ isDesktop: true })
const { queryByTestId, queryAllByRole } = root
expect(queryAllByRole('link')[0].getAttribute('href')).toBe(
'https://twake.app'
) // This is the sharing banner
expect(queryByTestId('public-toolbar')).toBeFalsy()
expect(queryByTestId('viewer-toolbar')).toBeTruthy()
})
})
})
================================================
FILE: src/modules/public/PublicLayout.jsx
================================================
import React from 'react'
import { Outlet } from 'react-router-dom'
import { BarComponent } from 'cozy-bar'
import FlagSwitcher from 'cozy-flags/dist/FlagSwitcher'
import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite'
import { Layout } from 'cozy-ui/transpiled/react/Layout'
import Drive from '@/components/Icons/Drive'
import DriveText from '@/components/Icons/DriveText'
import { SelectionProvider } from '@/modules/selection/SelectionProvider'
import { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider'
import UploadQueue from '@/modules/upload/UploadQueue'
const PublicLayout = () => {
return (
)
}
export default PublicLayout
================================================
FILE: src/modules/public/PublicProvider.tsx
================================================
import React, { createContext, useContext, ReactNode } from 'react'
interface PublicContextType {
isPublic: boolean
}
const PublicContext = createContext({
isPublic: false
})
interface PublicProviderProps {
children: ReactNode
isPublic?: boolean
}
const PublicProvider: React.FC = ({
children,
isPublic = false
}) => {
const value = {
isPublic
}
return (
{children}
)
}
const usePublicContext = (): PublicContextType => {
const context = useContext(PublicContext)
if (context === undefined) {
throw new Error('usePublicContext must be used within a PublicProvider')
}
return context
}
export { PublicProvider, usePublicContext }
================================================
FILE: src/modules/public/PublicToolbar.jsx
================================================
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
import PublicToolbarByLink from './PublicToolbarByLink'
import PublicToolbarCozyToCozy from './PublicToolbarCozyToCozy'
const PublicToolbar = ({
hasWriteAccess,
refreshFolderContent,
files,
sharingInfos,
className
}) => {
const { loading, addSharingLink } = sharingInfos
if (loading) return null
return (
{!addSharingLink ? (
) : (
)}
)
}
PublicToolbar.propTypes = {
files: PropTypes.array.isRequired,
// hasWriteAccess is only required if we're in a sharing by link
hasWriteAccess: PropTypes.bool,
// refreshFolderContent is not required if we're displaying only one file or in a cozy to cozy sharing
refreshFolderContent: PropTypes.func,
sharingInfos: PropTypes.object,
className: PropTypes.string
}
export default PublicToolbar
================================================
FILE: src/modules/public/PublicToolbarByLink.jsx
================================================
import React from 'react'
import { useClient } from 'cozy-client'
import { useVaultClient } from 'cozy-keys-lib'
import { createCozySharingLink, useSharingInfos } from 'cozy-sharing'
import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { BarRightOnMobile } from '@/components/Bar'
import { useDisplayedFolder } from '@/hooks'
import { addItems, download, hr, select } from '@/modules/actions'
import AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'
import AddButton from '@/modules/drive/Toolbar/components/AddButton'
import ViewSwitcher from '@/modules/drive/Toolbar/components/ViewSwitcher'
import { DownloadFilesButton } from '@/modules/public/DownloadFilesButton'
import PublicToolbarMoreMenu from '@/modules/public/PublicToolbarMoreMenu'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import UploadButton from '@/modules/upload/UploadButton'
const PublicToolbarByLink = ({
files,
hasWriteAccess,
refreshFolderContent
}) => {
const { isMobile } = useBreakpoints()
const { displayedFolder } = useDisplayedFolder()
const { showSelectionBar, isSelectionBarVisible } = useSelectionContext()
const { t } = useI18n()
const { showAlert } = useAlert()
const client = useClient()
const vaultClient = useVaultClient()
const { createCozyLink } = useSharingInfos()
const isMoreMenuDisplayed = files.length > 1
const actions = makeActions(
[
isMobile && download,
files.length > 1 && select,
addItems,
isMobile && (files.length > 1 || hasWriteAccess) && hr,
isMobile && createCozySharingLink
],
{
t,
showAlert,
client,
vaultClient,
showSelectionBar,
createCozyLink,
hasWriteAccess
}
)
return (
{!isMobile && (
<>
{hasWriteAccess && (
<>
>
)}
{files.length > 0 && }
>
)}
{isMoreMenuDisplayed && (
)}
)
}
export default PublicToolbarByLink
================================================
FILE: src/modules/public/PublicToolbarCozyToCozy.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { useClient } from 'cozy-client'
import { useVaultClient } from 'cozy-keys-lib'
import {
addToCozySharingLink,
syncToCozySharingLink,
OpenSharingLinkButton
} from 'cozy-sharing'
import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { BarRightOnMobile } from '@/components/Bar'
import useCurrentFolderId from '@/hooks/useCurrentFolderId'
import { download, hr, select } from '@/modules/actions'
import ViewSwitcher from '@/modules/drive/Toolbar/components/ViewSwitcher'
import { DownloadFilesButton } from '@/modules/public/DownloadFilesButton'
import PublicToolbarMoreMenu from '@/modules/public/PublicToolbarMoreMenu'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
const PublicToolbarCozyToCozy = ({ sharingInfos, files }) => {
const {
loading,
addSharingLink,
syncSharingLink,
sharing,
isSharingShortcutCreated
} = sharingInfos
const { isMobile } = useBreakpoints()
const { t } = useI18n()
const { showAlert } = useAlert()
const client = useClient()
const { showSelectionBar } = useSelectionContext()
const vaultClient = useVaultClient()
const currentFolderId = useCurrentFolderId()
// Sharing can be a folder or a file
const itemId = currentFolderId ?? files[0]?._id
const isOnSharedFolder =
!loading && sharing?.rules?.some(rule => rule.values.includes(itemId))
const actions = makeActions(
[
isMobile && download,
files.length > 1 && select,
((isMobile && files.length > 0) || files.length > 1) && hr,
isOnSharedFolder && addToCozySharingLink,
isOnSharedFolder && syncToCozySharingLink
],
{
t,
showAlert,
client,
vaultClient,
showSelectionBar,
isSharingShortcutCreated,
addSharingLink,
syncSharingLink
}
)
return (
{!isMobile && (
<>
{files.length > 0 && }
{!isSharingShortcutCreated && isOnSharedFolder && (
)}
>
)}
)
}
PublicToolbarCozyToCozy.propTypes = {
files: PropTypes.array.isRequired,
sharingInfos: PropTypes.object.isRequired
}
export default PublicToolbarCozyToCozy
================================================
FILE: src/modules/public/PublicToolbarMoreMenu.jsx
================================================
import cx from 'classnames'
import React, { useState, useCallback, useRef } from 'react'
import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { MoreButton } from '@/components/Button'
const PublicToolbarMoreMenu = ({ files, actions }) => {
const moreButtonRef = useRef()
const { isMobile } = useBreakpoints()
const [menuIsVisible, setMenuVisible] = useState(false)
const openMenu = useCallback(() => setMenuVisible(true), [setMenuVisible])
const closeMenu = useCallback(() => setMenuVisible(false), [setMenuVisible])
const toggleMenu = useCallback(() => {
if (menuIsVisible) return closeMenu()
openMenu()
}, [closeMenu, openMenu, menuIsVisible])
if (actions.length === 0) return null
return (
<>
{menuIsVisible && (
)}
>
)
}
export default PublicToolbarMoreMenu
================================================
FILE: src/modules/public/helpers.js
================================================
export function isFilesIsFile(files) {
return files.length === 1 && files[0].type === 'file'
}
================================================
FILE: src/modules/routeUtils.js
================================================
export const getFolderPath = folderId => {
return `/folder/${folderId}`
}
export const getViewerPath = (folderId, fileId) => {
return `/folder/${folderId}/file/${fileId}`
}
export const getSharedDrivePath = (driveId, folderId) => {
return `/shareddrive/${driveId}/${folderId}`
}
export const getSharedDriveViewerPath = (driveId, folderId, fileId) => {
return `/shareddrive/${driveId}/${folderId}/file/${fileId}`
}
================================================
FILE: src/modules/search/components/BarSearchAutosuggest.jsx
================================================
import cx from 'classnames'
import React, { useState } from 'react'
import Autosuggest from 'react-autosuggest'
import { models, useClient } from 'cozy-client'
import { isFlagshipApp } from 'cozy-device-helper'
import { useWebviewIntent } from 'cozy-intent'
import List from 'cozy-ui/transpiled/react/List'
import styles from '@/modules/search/components/styles.styl'
import { SHARED_DRIVES_DIR_ID } from '@/constants/config'
import BarSearchInputGroup from '@/modules/search/components/BarSearchInputGroup'
import SuggestionItem from '@/modules/search/components/SuggestionItem'
import SuggestionListSkeleton from '@/modules/search/components/SuggestionListSkeleton'
import useSearch from '@/modules/search/hooks/useSearch'
const BarSearchAutosuggest = ({ t }) => {
const webviewIntent = useWebviewIntent()
const client = useClient()
const [input, setInput] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const { suggestions, hasSuggestions, isBusy, query, makeIndexes } =
useSearch(searchTerm)
const [focused, setFocused] = useState(false)
const theme = {
container: 'u-w-100',
suggestionsContainer:
styles['bar-search-autosuggest-suggestions-container'],
suggestionsContainerOpen:
styles['bar-search-autosuggest-suggestions-container--open'],
suggestionsList: styles['bar-search-autosuggest-suggestions-list']
}
const onSuggestionsFetchRequested = ({ value }) => {
setSearchTerm(value)
}
const onSuggestionsClearRequested = () => {
setSearchTerm('')
}
const cleanSearch = () => {
setInput('')
setSearchTerm('')
}
const onSuggestionSelected = async (event, { suggestion }) => {
// Open the shared drive in a new tab
if (suggestion.parentUrl?.includes(SHARED_DRIVES_DIR_ID)) {
window.open(`/#/external/${suggestion.id}`, '_blank')
return cleanSearch()
}
let url = `${window.location.origin}/#${suggestion.url}`
if (suggestion.openOn === 'notes') {
url = await models.note.fetchURL(client, {
id: suggestion.url.substr(3)
})
}
if (url) {
if (isFlagshipApp()) {
webviewIntent.call('openApp', url, { slug: suggestion.openOn })
} else {
window.location.assign(url)
}
} else {
// eslint-disable-next-line no-console
console.error(`openSuggestion (${suggestion.name}) could not be executed`)
}
cleanSearch()
}
// We want the user to find folders in which he can then navigate into, so we return the path here
const getSuggestionValue = suggestion => suggestion.subtitle
const renderSuggestion = suggestion => {
return (
)
}
const inputProps = {
placeholder: t('searchbar.placeholder'),
value: input,
onChange: (event, { newValue }) => {
setInput(newValue)
},
onFocus: () => {
makeIndexes()
setFocused(true)
},
onBlur: () => setFocused(false)
}
const renderInputComponent = inputProps => (
)
const renderSuggestionsContainer = ({ containerProps, children }) => {
return {children}
}
const hasNoSearchResult = searchTerm !== '' && focused && !hasSuggestions
return (
{hasNoSearchResult && !isBusy && (
{t('searchbar.empty', { query })}
)}
{hasNoSearchResult && isBusy && (
)}
)
}
export default BarSearchAutosuggest
================================================
FILE: src/modules/search/components/BarSearchInputGroup.jsx
================================================
import React from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconButton from 'cozy-ui/transpiled/react/IconButton'
import CrossCircleOutlineIcon from 'cozy-ui/transpiled/react/Icons/CrossCircleOutline'
import Magnifier from 'cozy-ui/transpiled/react/Icons/Magnifier'
import InputGroup from 'cozy-ui/transpiled/react/InputGroup'
import styles from '@/modules/search/components/styles.styl'
const BarSearchInputGroup = ({
children,
isMobile,
onClean,
isInputNotEmpty
}) => {
return (
) : null
}
append={
isInputNotEmpty ? (
) : null
}
>
{children}
)
}
export default BarSearchInputGroup
================================================
FILE: src/modules/search/components/SearchEmpty.jsx
================================================
import React from 'react'
import Grid from 'cozy-ui/transpiled/react/Grid'
import Icon from 'cozy-ui/transpiled/react/Icon'
import Typography from 'cozy-ui/transpiled/react/Typography'
import { useI18n } from 'twake-i18n'
import searchEmptyIllustration from '@/assets/icons/icon-search-empty.svg'
const SearchEmpty = ({ query }) => {
const { t } = useI18n()
return (
{t('search.empty.title', { query })}
{t('search.empty.subtitle', { query })}
)
}
export default SearchEmpty
================================================
FILE: src/modules/search/components/SuggestionItem.jsx
================================================
import React, { useCallback } from 'react'
import ListItem from 'cozy-ui/transpiled/react/ListItem'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { SHARED_DRIVES_DIR_ID } from '@/constants/config'
import FileIconMime from '@/modules/filelist/icons/FileIconMime'
import FileIconShortcut from '@/modules/filelist/icons/FileIconShortcut'
import SuggestionItemTextHighlighted from '@/modules/search/components/SuggestionItemTextHighlighted'
import SuggestionItemTextSecondary from '@/modules/search/components/SuggestionItemTextSecondary'
const SuggestionItem = ({
suggestion,
query,
onClick,
onParentOpened,
isMobile = false
}) => {
const openSuggestion = useCallback(() => {
if (typeof onClick == 'function') {
onClick(suggestion)
}
}, [onClick, suggestion])
const file = {
class: suggestion.class,
type: suggestion.type,
mime: suggestion.mime,
name: suggestion.title.replace(/\.url$/, ''), // Not using `splitFileName()` because we don't have access to the full file here.
parentUrl: suggestion.parentUrl
}
return (
{file.class === 'shortcut' ? (
) : (
)}
}
secondary={
file.parentUrl?.includes(SHARED_DRIVES_DIR_ID) ? null : (
)
}
/>
)
}
export default SuggestionItem
================================================
FILE: src/modules/search/components/SuggestionItemSkeleton.jsx
================================================
import React from 'react'
import ListItem from 'cozy-ui/transpiled/react/ListItem'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import Skeleton from 'cozy-ui/transpiled/react/Skeleton'
const SuggestionItemSkeleton = () => {
return (
}
secondary={
}
/>
)
}
export default SuggestionItemSkeleton
================================================
FILE: src/modules/search/components/SuggestionItemTextHighlighted.jsx
================================================
import React from 'react'
import { normalizeString } from '@/modules/search/components/helpers'
/**
* Add on part that equlas query into each result
*
* @param {Array} searchResult - list of results
* @param {string} query - search input
* @returns list of results with the query highlighted
*/
const highlightQueryTerms = (searchResult, query) => {
const normalizedQueryTerms = normalizeString(query)
const normalizedResultTerms = normalizeString(searchResult)
const matchedIntervals = []
const spacerLength = 1
let currentIndex = 0
normalizedResultTerms.forEach(resultTerm => {
normalizedQueryTerms.forEach(queryTerm => {
const index = resultTerm.indexOf(queryTerm)
if (index >= 0) {
matchedIntervals.push({
from: currentIndex + index,
to: currentIndex + index + queryTerm.length
})
}
})
currentIndex += resultTerm.length + spacerLength
})
// matchedIntervals can overlap, so we merge them.
// - sort the intervals by starting index
// - add the first interval to the stack
// - for every interval,
// - - add it to the stack if it doesn't overlap with the stack top
// - - or extend the stack top if the start overlaps and the new interval's top is bigger
const mergedIntervals = matchedIntervals
.sort((intervalA, intervalB) => intervalA.from > intervalB.from)
.reduce((computedIntervals, newInterval) => {
if (
computedIntervals.length === 0 ||
computedIntervals[computedIntervals.length - 1].to < newInterval.from
) {
computedIntervals.push(newInterval)
} else if (
computedIntervals[computedIntervals.length - 1].to < newInterval.to
) {
computedIntervals[computedIntervals.length - 1].to = newInterval.to
}
return computedIntervals
}, [])
// create an array containing the entire search result, with special characters, and the intervals surrounded y `` tags
const slicedOriginalResult =
mergedIntervals.length > 0
? [{searchResult.slice(0, mergedIntervals[0].from)} ]
: searchResult
for (let i = 0, l = mergedIntervals.length; i < l; ++i) {
slicedOriginalResult.push(
{searchResult.slice(mergedIntervals[i].from, mergedIntervals[i].to)}
)
if (i + 1 < l)
slicedOriginalResult.push(
{searchResult.slice(
mergedIntervals[i].to,
mergedIntervals[i + 1].from
)}
)
}
if (mergedIntervals.length > 0)
slicedOriginalResult.push(
{searchResult.slice(
mergedIntervals[mergedIntervals.length - 1].to,
searchResult.length
)}
)
return slicedOriginalResult
}
const SuggestionItemTextHighlighted = ({ text, query }) => {
const textHighlighted = highlightQueryTerms(text, query)
if (Array.isArray(textHighlighted)) {
return textHighlighted.map((item, idx) => ({
...item,
key: idx
}))
}
return textHighlighted
}
export default SuggestionItemTextHighlighted
================================================
FILE: src/modules/search/components/SuggestionItemTextSecondary.jsx
================================================
import React from 'react'
import { generateWebLink, useClient } from 'cozy-client'
import { isFlagshipApp } from 'cozy-device-helper'
import AppLinker, { generateUniversalLink } from 'cozy-ui-plus/dist/AppLinker'
import styles from '@/modules/search/components/styles.styl'
import SuggestionItemTextHighlighted from '@/modules/search/components/SuggestionItemTextHighlighted'
const SuggestionItemTextSecondary = ({
text,
query,
url,
onOpened,
isMobile
}) => {
const client = useClient()
if (isMobile) {
return
}
const app = {
slug: 'drive'
}
const { subdomain: subDomainType } = client.getInstanceOptions()
const generateLink = isFlagshipApp() ? generateUniversalLink : generateWebLink
const appWebRef =
app &&
generateLink({
slug: 'drive',
cozyUrl: client.getStackClient().uri,
subDomainType,
nativePath: url,
pathname: '/',
hash: url
})
return (
{({ onClick, href }) => (
{
e.stopPropagation()
if (typeof onOpened == 'function') {
onOpened(e)
}
if (typeof onClick == 'function') {
onClick(e)
}
}}
>
)}
)
}
export default SuggestionItemTextSecondary
================================================
FILE: src/modules/search/components/SuggestionListSkeleton.jsx
================================================
import React from 'react'
import List from 'cozy-ui/transpiled/react/List'
import SuggestionItemSkeleton from '@/modules/search/components/SuggestionItemSkeleton'
const SuggestionListSkeleton = ({ count }) => (
{Array(count || 4)
.fill(1)
.map((_, i) => (
))}
)
export default SuggestionListSkeleton
================================================
FILE: src/modules/search/components/helpers.js
================================================
import { models } from 'cozy-client'
import { ROOT_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'
import FuzzyPathSearch from '@/lib/FuzzyPathSearch.js'
import { makeOnlyOfficeFileRoute } from '@/modules/views/OnlyOffice/helpers'
export const TYPE_DIRECTORY = 'directory'
export const normalizeString = str =>
str
.toString()
.toLowerCase()
.replace(/\//g, ' ')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.split(' ')
/**
* Normalize file for Front usage in component inside
*
* To reduce API call, the fetching of Note URL has been delayed
* inside an onSelect function called only if provided to
* see https://github.com/cozy/cozy-drive/pull/2663#discussion_r938671963
*
* @param {CozyClient} client - cozy client instance
* @param {[IOCozyFile]} folders - all the folders returned by API
* @param {IOCozyFile} file - file to normalize
* @returns file with normalized field to be used in AutoSuggestion
*/
export const makeNormalizedFile = (client, folders, file) => {
const isDir = file.type === TYPE_DIRECTORY
const dirId = isDir ? file._id : file.dir_id
const urlToFolder = `/folder/${dirId}`
let path, url, parentUrl
let openOn = 'drive'
if (isDir) {
path = file.path
url = urlToFolder
parentUrl = urlToFolder
} else {
const parentDir = folders.find(folder => folder._id === file.dir_id)
path = parentDir && parentDir.path ? parentDir.path : ''
parentUrl = parentDir && parentDir._id ? `/folder/${parentDir._id}` : ''
if (models.file.isNote(file)) {
url = `/n/${file.id}`
openOn = 'notes'
} else if (models.file.shouldBeOpenedByOnlyOffice(file)) {
url = makeOnlyOfficeFileRoute(file.id, { fromPathname: urlToFolder })
} else {
url = `${urlToFolder}/file/${file._id}`
}
}
return {
id: file._id,
type: file.type,
name: file.name,
mime: file.mime,
class: file.class,
path,
url,
parentUrl,
openOn
}
}
/**
* Fetches all files without trashed and preloads FuzzyPathSearch
*
* Using _all_docs route
*
* Also, this method:
* - removing trashed data directly
* - removes orphan file
* - normalize file to match expectation
* - preloads FuzzyPathSearch
*
* @returns {Promise} nothing
*/
export const indexFiles = async client => {
const resp = await client
.getStackClient()
.fetchJSON(
'GET',
'/data/io.cozy.files/_all_docs?Fields=_id,trashed,dir_id,name,path,type,mime,class,metadata.title,metadata.version&DesignDocs=false&include_docs=true'
)
const files = resp.rows.map(row => ({ id: row.id, ...row.doc }))
const folders = files.filter(file => file.type === TYPE_DIRECTORY)
const notInTrash = file => !file.trashed && !/^\/\.cozy_trash/.test(file.path)
const notOrphans = file =>
folders.find(folder => folder._id === file.dir_id) !== undefined
const notRoot = file => file._id !== ROOT_DIR_ID
// Shared drives folder to be hidden in search.
// The files inside it though must appear. Thus only the file with the folder ID is filtered out.
const notSharedDrivesDir = file => file._id !== SHARED_DRIVES_DIR_ID
const normalizedFilesPrevious = files.filter(
file =>
notInTrash(file) &&
notOrphans(file) &&
notRoot(file) &&
notSharedDrivesDir(file)
)
const normalizedFiles = normalizedFilesPrevious.map(file =>
makeNormalizedFile(client, folders, file)
)
return new FuzzyPathSearch(normalizedFiles)
}
================================================
FILE: src/modules/search/components/helpers.spec.jsx
================================================
import { createMockClient, models } from 'cozy-client'
import { makeNormalizedFile, TYPE_DIRECTORY } from './helpers'
models.note.fetchURL = jest.fn(() => 'noteUrl')
const client = createMockClient({})
const noteFileProps = {
name: 'note.cozy-note',
metadata: {
content: '',
schema: '',
title: '',
version: ''
}
}
describe('makeNormalizedFile', () => {
it('should return correct values for a directory', () => {
const folders = []
const file = {
_id: 'fileId',
type: TYPE_DIRECTORY,
path: 'filePath',
name: 'fileName'
}
const normalizedFile = makeNormalizedFile(client, folders, file)
expect(normalizedFile).toEqual({
id: 'fileId',
name: 'fileName',
path: 'filePath',
url: '/folder/fileId',
parentUrl: '/folder/fileId',
openOn: 'drive',
mime: undefined,
type: 'directory'
})
})
it('should return correct values for a file', () => {
const folders = [{ _id: 'folderId', path: 'folderPath' }]
const file = {
_id: 'fileId',
dir_id: 'folderId',
type: 'file',
name: 'fileName'
}
const normalizedFile = makeNormalizedFile(client, folders, file)
expect(normalizedFile).toEqual({
id: 'fileId',
name: 'fileName',
path: 'folderPath',
url: '/folder/folderId/file/fileId',
parentUrl: '/folder/folderId',
openOn: 'drive',
mime: undefined,
type: 'file'
})
})
it('should return correct values for a note with on Select function - better for performance', () => {
const folders = [{ _id: 'folderId', path: 'folderPath' }]
const file = {
_id: 'fileId',
id: 'noteId',
dir_id: 'folderId',
type: 'file',
name: 'fileName',
...noteFileProps
}
const normalizedFile = makeNormalizedFile(client, folders, file)
expect(normalizedFile).toEqual({
id: 'fileId',
name: 'note.cozy-note',
path: 'folderPath',
url: '/n/noteId',
parentUrl: '/folder/folderId',
openOn: 'notes',
mime: undefined,
type: 'file'
})
})
it('should not return filled onSelect for a note without metadata', () => {
const folders = [{ _id: 'folderId', path: 'folderPath' }]
const file = {
_id: 'fileId',
id: 'noteId',
dir_id: 'folderId',
type: 'file',
name: 'note.cozy-note'
}
const normalizedFile = makeNormalizedFile(client, folders, file)
expect(normalizedFile).toEqual({
id: 'fileId',
name: 'note.cozy-note',
path: 'folderPath',
url: '/folder/folderId/file/fileId',
parentUrl: '/folder/folderId',
openOn: 'drive',
mime: undefined,
type: 'file'
})
})
})
================================================
FILE: src/modules/search/components/styles.styl
================================================
[role=banner]
.bar-search-autosuggest-suggestions-container
position absolute
top 100%
width 100%
max-height em(170px)
overflow auto
border-radius .5em
color var(--primaryTextColor)
background var(--paperBackgroundColor)
box-shadow var(--shadow7)
display none
box-sizing border-box
.bar-search-autosuggest-suggestions-container--open
display block
.bar-search-autosuggest-status-container
position absolute
display flex
align-items center
top 100%
left 0
right 0
min-height 48px
max-height em(170px)
overflow auto
border-radius .5em
background var(--paperBackgroundColor)
box-shadow var(--shadow7)
box-sizing border-box
&.--empty
padding .75em 1em
.bar-search-autosuggest-suggestions-list
margin 0
padding 0
list-style none
.bar-search-container
position relative
display flex
align-items center
flex-grow 1
margin-left 2em
margin-right 2em
padding-top .25em
padding-bottom .25em
&.mobile
margin-left 0
margin-right -.5em
.bar-search-input-group
border 0
max-height 40px
padding-left .5em
border-radius 1.25em
background-color var(--defaultBackgroundColor)
transition all .2s ease-out
overflow hidden
&:hover
background linear-gradient(0deg, var(--actionColorHover), var(--actionColorHover)), var(--defaultBackgroundColor)
.bar-search-input-group-append
padding-left .5em
color var(--secondaryTextColor)
input
padding-left .5em
background-color transparent
max-width 100%
height 100%
.suggestion-item-parent-link
color var(--secondaryTextColor)
text-decoration none
&:hover
text-decoration underline
================================================
FILE: src/modules/search/hooks/useSearch.jsx
================================================
import { useState, useEffect, useMemo } from 'react'
import { useClient } from 'cozy-client'
import useDebounce from '@/hooks/useDebounce'
import { indexFiles } from '@/modules/search/components/helpers'
const useSearch = (searchTerm, { limit = 10 } = {}) => {
const client = useClient()
const [allSuggestions, setAllSuggestions] = useState([])
const [suggestions, setSuggestions] = useState([])
const [fuzzy, setFuzzy] = useState(null)
const [isBusy, setBusy] = useState(true)
const [query, setQuery] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, {
delay: 500,
ignore: searchTerm === ''
})
const makeIndexes = async () => {
if (fuzzy == null) {
setFuzzy(await indexFiles(client))
}
}
useEffect(() => {
const fetchSuggestions = async value => {
setBusy(true)
let currentFuzzy = fuzzy
if (currentFuzzy == null) {
currentFuzzy = await indexFiles(client)
setFuzzy(currentFuzzy)
}
const suggestions = currentFuzzy.search(value).map(result => ({
id: result.id,
title: result.name,
subtitle: result.path,
url: result.url,
parentUrl: result.parentUrl,
openOn: result.openOn,
type: result.type,
mime: result.mime,
class: result.class
}))
setBusy(value === '') // To prevent empty state to appear at the first search
setQuery(value)
setAllSuggestions(suggestions)
setSuggestions(suggestions.slice(0, limit))
}
if (debouncedSearchTerm !== '') {
fetchSuggestions(debouncedSearchTerm)
} else {
// eslint-disable-next-line react-hooks/immutability
clearSuggestions()
}
}, [client, debouncedSearchTerm, fuzzy, limit])
const hasSuggestions = useMemo(() => suggestions.length > 0, [suggestions])
const hasMore = useMemo(
() => suggestions.length < allSuggestions.length,
[suggestions, allSuggestions]
)
const fetchMore = async () => {
setSuggestions(allSuggestions.slice(0, suggestions.length + limit))
}
const clearSuggestions = () => {
setBusy(true)
setQuery('')
setAllSuggestions([])
setSuggestions([])
}
return {
suggestions,
hasSuggestions,
hasMore,
isBusy,
query,
makeIndexes,
fetchMore
}
}
export default useSearch
================================================
FILE: src/modules/selection/RectangularSelection.jsx
================================================
import React, { useRef, useCallback, useMemo, useState, useEffect } from 'react'
import Selecto from 'react-selecto'
import styles from './RectangularSelection.styl'
import { useSelectionContext } from './SelectionProvider'
const INTERACTIVE_ELEMENTS_SELECTOR =
'button,a,input,select,textarea,label,[role="button"],[role="menuitem"],[role="option"]'
const SCROLL_STEP_IN_PIXELS = 10
/**
* Hit rate for the Selecto library.
* Controls how frequently the selection rectangle checks for elements to select.
* A value of 1 means it checks every pixel, ensuring precise selection.
*/
const HIT_RATE = 1
const buildSelectionFromItems = (fileIds, itemsMap) => {
const newSelection = {}
let lastSelectedId = null
for (const fileId of fileIds) {
const file = itemsMap.get(fileId)
if (file) {
newSelection[fileId] = file
lastSelectedId = fileId
}
}
return { newSelection, lastSelectedId }
}
const getVisibleFileIdsFromSelecto = selectoRef => {
const selectableElements = selectoRef.current?.getSelectableElements() || []
const visibleFileIds = new Set()
for (const el of selectableElements) {
const fileId = el.getAttribute('data-file-id')
if (fileId) {
visibleFileIds.add(fileId)
}
}
return visibleFileIds
}
const getSelectedFileIdsFromSelectoEvent = (e, getFileFromElement) => {
const selectedFileIds = new Set()
for (const el of e.selected) {
const file = getFileFromElement(el)
if (file) {
selectedFileIds.add(file._id)
}
}
return selectedFileIds
}
const accumulateSelectedItemsDuringDrag = (
selectedDuringDragRef,
selectedFileIds,
visibleFileIds,
preserveAll
) => {
const newAccumulated = new Set()
for (const fileId of selectedDuringDragRef.current) {
if (
preserveAll ||
!visibleFileIds.has(fileId) ||
selectedFileIds.has(fileId)
) {
newAccumulated.add(fileId)
}
}
for (const fileId of selectedFileIds) {
newAccumulated.add(fileId)
}
return newAccumulated
}
/**
* Component that enables rectangular selection of files in a grid view.
* Wraps children with a selection area that allows users to drag-select
* multiple files by drawing a selection rectangle.
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Child elements to render inside the selection container
* @param {Array} props.items - List of file items available for selection
* @param {React.RefObject} props.scrollContainerRef - Ref to the scrollable container for auto-scroll during selection (fallback)
* @param {HTMLElement|null} props.scrollElement - Direct HTMLElement for the scroll container (preferred over scrollContainerRef)
* @returns {React.ReactElement} The rectangular selection wrapper component
*/
const RectangularSelection = ({
children,
items,
scrollContainerRef,
scrollElement,
onSelectEnd
}) => {
const containerRef = useRef(null)
const selectoRef = useRef(null)
const [isContainerReady, setIsContainerReady] = useState(false)
const { setSelectedItems, selectedItems, setIsSelectAll } =
useSelectionContext()
const [resolvedScrollContainer, setResolvedScrollContainer] = useState(null)
const isDraggingRef = useRef(false)
const dragStartPosRef = useRef(null)
const wheelScrolledDuringDragRef = useRef(false)
const mutationObserverRef = useRef(null)
const selectedDuringDragRef = useRef(new Set())
useEffect(() => {
if (containerRef.current) {
setIsContainerReady(true)
}
return () => {
if (mutationObserverRef.current) {
mutationObserverRef.current.disconnect()
}
}
}, [])
useEffect(() => {
setResolvedScrollContainer(
scrollElement || scrollContainerRef?.current || null
)
}, [scrollElement, scrollContainerRef, isContainerReady])
/**
* Extracts file data from a DOM element using the data-file-id attribute.
* Uses a Map for O(1) lookups instead of O(n) array.find().
*
* @param {Element} el - DOM element with data-file-id attribute
* @returns {Object|undefined} The file object matching the element's ID, or undefined if not found
*/
const itemsMap = useMemo(() => {
const map = new Map()
for (const item of items) {
map.set(item._id, item)
}
return map
}, [items])
const getFileFromElement = useCallback(
el => {
const fileId = el.getAttribute('data-file-id')
if (!fileId) return undefined
return itemsMap.get(fileId)
},
[itemsMap]
)
/**
* Handles the selection event from react-selecto.
* Updates the selected items state based on elements inside the selection rectangle.
* Supports additive selection when Ctrl/Cmd key is held.
* Optimized: tracks count directly instead of Object.keys().length
*
* @param {Object} e - Selecto event object
* @param {Array} e.selected - Array of DOM elements inside the selection rectangle
* @param {Object} e.inputEvent - The original input event with modifier key state
*/
const handleSelect = useCallback(
e => {
const visibleFileIds = getVisibleFileIdsFromSelecto(selectoRef)
const selectedFileIds = getSelectedFileIdsFromSelectoEvent(
e,
getFileFromElement
)
// After a wheel scroll, items may still be in the DOM but outside
// the selection rectangle (content shifted, not rectangle shrunk).
// In that case, preserve all accumulated items to avoid losing them.
const newAccumulated = accumulateSelectedItemsDuringDrag(
selectedDuringDragRef,
selectedFileIds,
visibleFileIds,
wheelScrolledDuringDragRef.current
)
selectedDuringDragRef.current = newAccumulated
const { newSelection, lastSelectedId } = buildSelectionFromItems(
newAccumulated,
itemsMap
)
setSelectedItems(newSelection)
setIsSelectAll(Object.keys(newSelection).length === items.length)
if (lastSelectedId) {
onSelectEnd?.(lastSelectedId)
}
},
[
items.length,
itemsMap,
getFileFromElement,
setSelectedItems,
setIsSelectAll,
onSelectEnd
]
)
/**
* Determines whether a drag operation should initiate rectangular selection.
* Prevents selection when clicking on interactive elements or directly on files.
*
* @param {Object} e - Selecto drag condition event
* @param {Object} e.inputEvent - The original input event
* @param {Element} e.inputEvent.target - The target element being clicked
* @returns {boolean} True if drag selection should proceed, false otherwise
*/
const dragCondition = useCallback(e => {
const target = e.inputEvent?.target
if (!target) return true
const isInteractive = target.closest(INTERACTIVE_ELEMENTS_SELECTOR)
if (isInteractive) return false
const fileElement = target.closest('[data-file-id]')
return !fileElement
}, [])
/**
* Records the starting position of a drag operation.
* Used to distinguish between clicks and actual drag selections.
*
* @param {Object} e - Drag start event
* @param {number} e.clientX - X coordinate of the drag start
* @param {number} e.clientY - Y coordinate of the drag start
*/
const handleDragStart = useCallback(
e => {
dragStartPosRef.current = { x: e.clientX, y: e.clientY }
isDraggingRef.current = false
selectedDuringDragRef.current.clear()
// If Ctrl/Cmd is pressed, start with current selection
if (e.inputEvent?.ctrlKey || e.inputEvent?.metaKey) {
for (const item of Object.values(selectedItems)) {
selectedDuringDragRef.current.add(item._id)
}
}
},
[selectedItems]
)
/**
* Handles drag movement during the selection.
* Calculates distance from drag start and marks it as a real drag if moved more than 5 pixels.
*
* @param {Object} e - Drag event
* @param {number} e.clientX - X coordinate of the drag
* @param {number} e.clientY - Y coordinate of the drag
*/
const handleDrag = useCallback(e => {
const start = dragStartPosRef.current
if (!start) return
const dx = e.clientX - start.x
const dy = e.clientY - start.y
if (Math.hypot(dx, dy) > 5) {
isDraggingRef.current = true
}
}, [])
/**
* Handles the end of a drag operation.
* Cleans up the drag start position reference.
*/
const handleDragEnd = useCallback(() => {
dragStartPosRef.current = null
wheelScrolledDuringDragRef.current = false
selectedDuringDragRef.current.clear()
}, [])
/**
* Sets up a MutationObserver on the scroll container to detect when
* virtuoso adds or removes DOM elements (e.g. after scrolling).
* When mutations are detected during a drag, we force selecto to
* re-discover selectable targets so newly rendered elements can be selected.
*/
useEffect(() => {
if (!resolvedScrollContainer) return
if (mutationObserverRef.current) {
mutationObserverRef.current.disconnect()
}
const observer = new MutationObserver(() => {
if (isDraggingRef.current && selectoRef.current) {
selectoRef.current.findSelectableTargets()
}
})
observer.observe(resolvedScrollContainer, {
childList: true,
subtree: true
})
mutationObserverRef.current = observer
return () => observer.disconnect()
}, [resolvedScrollContainer])
/**
* Listens for mouse wheel scroll during a drag selection.
* Marks that a wheel scroll occurred so the accumulator preserves
* all previously selected items instead of dropping those that
* are still in the DOM but scrolled out of the selection rectangle.
*/
useEffect(() => {
if (!resolvedScrollContainer) return
const handleWheel = () => {
if (!isDraggingRef.current) return
wheelScrolledDuringDragRef.current = true
}
resolvedScrollContainer.addEventListener('wheel', handleWheel, {
passive: true
})
return () =>
resolvedScrollContainer.removeEventListener('wheel', handleWheel)
}, [resolvedScrollContainer])
/**
* Handles scroll events from react-selecto during drag selection.
* When the selection rectangle reaches the edge of the scrollable container,
* Selecto fires this event and we must manually scroll the container.
*
* New elements rendered by virtuoso after scrolling are detected by the
* MutationObserver which triggers selecto to re-check selectable targets.
*
* @param {Object} e - Selecto scroll event
* @param {number[]} e.direction - Scroll direction [x, y], each -1, 0, or 1
*/
const handleScroll = useCallback(
e => {
if (!resolvedScrollContainer) return
resolvedScrollContainer.scrollBy(
e.direction[0] * SCROLL_STEP_IN_PIXELS,
e.direction[1] * SCROLL_STEP_IN_PIXELS
)
},
[resolvedScrollContainer]
)
/**
* Handles clicks on the container to clear selection when clicking empty space.
* Skips if the click was part of a drag operation or if Ctrl/Cmd is pressed.
* Prevents clearing when clicking on files or interactive elements.
*
* @param {React.MouseEvent} e - Click event
*/
const handleContainerClick = useCallback(
e => {
// Early return if this click was part of a drag operation (rectangular selection)
if (isDraggingRef.current) {
e.stopPropagation()
e.preventDefault()
return
}
// Early return if Ctrl/Cmd is pressed (user wants to add to selection)
if (e.ctrlKey || e.metaKey) return
const target = e.target
// Early return if clicked on a file
if (target.closest('[data-file-id]')) return
// Early return if clicked on interactive element
if (target.closest(INTERACTIVE_ELEMENTS_SELECTOR)) return
// If clicked in empty space, clear selection
setSelectedItems({})
setIsSelectAll(false)
},
[setSelectedItems, setIsSelectAll]
)
return (
{children}
{isContainerReady && (
)}
)
}
export default RectangularSelection
================================================
FILE: src/modules/selection/RectangularSelection.styl
================================================
.rectangular-selection-container
position relative
.cozy-selecto-box
background-color var(--actionColorSelected)
border 2px solid var(--borderMainColor)
border-radius 2px
================================================
FILE: src/modules/selection/SelectionBar.tsx
================================================
import React, { useRef, useState } from 'react'
import ActionsBar from 'cozy-ui/transpiled/react/ActionsBar'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconButton from 'cozy-ui/transpiled/react/IconButton'
import ShieldCleanIcon from 'cozy-ui/transpiled/react/Icons/ShieldClean'
import List from 'cozy-ui/transpiled/react/List'
import ListItem from 'cozy-ui/transpiled/react/ListItem'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import Paper from 'cozy-ui/transpiled/react/Paper'
import Popover from 'cozy-ui/transpiled/react/Popover'
import { useI18n } from 'twake-i18n'
import {
filterActionsByPolicy,
hasAnyInfectedFile
} from '@/modules/actions/policies'
import type { DriveAction } from '@/modules/actions/types'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
type WrappedDriveAction = Record
const driveActionsToSelectionBarActions = (
driveActions: WrappedDriveAction[]
): WrappedDriveAction[] => {
return driveActions.filter(driveAction => {
const action = Object.values(driveAction)[0]
return (
action.displayInSelectionBar === undefined || action.displayInSelectionBar
)
})
}
const SelectionBar: React.FC<{
actions?: WrappedDriveAction[]
autoClose?: boolean
}> = ({ actions, autoClose = false }) => {
const { t } = useI18n()
const { isSelectionBarVisible, hideSelectionBar, selectedItems } =
useSelectionContext()
const [popoverOpen, setPopoverOpen] = useState(false)
const anchorRef = useRef(null)
const handlePopoverOpen = (): void => {
setPopoverOpen(true)
}
const handlePopoverClose = (): void => {
setPopoverOpen(false)
}
if (isSelectionBarVisible && actions) {
const selectedArray = Object.values(selectedItems)
let convertedActions = driveActionsToSelectionBarActions(actions)
convertedActions = filterActionsByPolicy(convertedActions, selectedArray)
const hasInfectedItem = hasAnyInfectedFile(selectedArray)
let color = 'default'
let iconComponent = null
if (hasInfectedItem) {
color = 'error'
iconComponent = (): JSX.Element => (
)
}
return (
)
}
return null
}
export default SelectionBar
================================================
FILE: src/modules/selection/SelectionProvider.d.ts
================================================
import { SelectionContextType, SelectionProviderProps } from './types'
declare const SelectionProvider: React.FC
declare const useSelectionContext: () => SelectionContextType
export { SelectionProvider, useSelectionContext }
export type { SelectionContextType, SelectionProviderProps }
================================================
FILE: src/modules/selection/SelectionProvider.jsx
================================================
import React, {
createContext,
useContext,
useMemo,
useState,
useEffect,
useCallback
} from 'react'
import { useLocation } from 'react-router-dom'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
/**
* @typedef TSelectionContext
* @property {Function} showSelectionBar Show the SelectionBar
* @property {Function} hideSelectionBar Hide the SelectionBar
* @property {boolean} isSelectionBarVisible Whether the SelectionBar is visible or not
* @property {Array} selectedItems List of selected items
* @property {Function} toggleSelectedItem Select an item if it is already selected, otherwise deselect it
* @property {Function} isItemSelected Find out if an item is selected by its id
* @property {boolean} isSelectAll Whether all the items are selected or not
* @property {Function} toggleSelectAllItems Toggle selects all items
* @property {Function} selectAll Select all items
* @property {Function} clearSelection Clear all the selected items
*/
/** @type {import('react').Context} */
const SelectionContext = createContext()
/**
* This provider allows you to manage item selection
*/
const SelectionProvider = ({ children }) => {
const location = useLocation()
const [selectedItems, setSelectedItems] = useState({})
const [isSelectionBarOpen, setSelectionBarOpen] = useState(false)
const [isSelectAll, setIsSelectAll] = useState(false)
const { highlightedItems, clearItems } = useNewItemHighlightContext()
const isItemSelected = id => {
return selectedItems[id] !== undefined
}
const toggleSelectedItem = item => {
if (highlightedItems?.length) {
clearItems()
}
if (isItemSelected(item._id)) {
const { [item._id]: _, ...stillSelected } = selectedItems
setSelectedItems(stillSelected)
} else {
setSelectedItems({ ...selectedItems, [item._id]: item })
}
}
const selectAll = items => {
const newSelectedItems = items.reduce((acc, item) => {
acc[item._id] = item
return acc
}, {})
setSelectedItems(newSelectedItems)
setIsSelectAll(true)
}
const clearSelection = useCallback(() => {
setIsSelectAll(false)
setSelectedItems({})
}, [])
const toggleSelectAllItems = items => {
if (isSelectAll) {
clearSelection()
} else {
selectAll(items)
}
}
const showSelectionBar = () => setSelectionBarOpen(true)
const hideSelectionBar = useCallback(() => {
clearSelection()
setSelectionBarOpen(false)
}, [clearSelection])
const isSelectionBarVisible = useMemo(() => {
return Object.keys(selectedItems).length !== 0 || isSelectionBarOpen
}, [isSelectionBarOpen, selectedItems])
useEffect(() => {
hideSelectionBar()
}, [location, hideSelectionBar])
return (
{children}
)
}
const useSelectionContext = () => useContext(SelectionContext)
export { SelectionProvider, useSelectionContext }
================================================
FILE: src/modules/selection/SelectionProvider.spec.jsx
================================================
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
import { Provider } from 'react-redux'
import {
MemoryRouter,
Routes,
Route,
Link,
useLocation
} from 'react-router-dom'
import { createStore } from 'redux'
import { generateFile } from 'test/generate'
import {
SelectionProvider,
useSelectionContext
} from '@/modules/selection/SelectionProvider'
jest.mock('modules/upload/NewItemHighlightProvider', () => ({
...jest.requireActual('modules/upload/NewItemHighlightProvider'),
useNewItemHighlightContext: () => ({
addItems: jest.fn()
})
}))
// Create a mock store for testing
const mockStore = createStore(() => ({
// Add any state that SelectionProvider needs
upload: {
queue: [],
newItems: []
}
}))
const SelectionConsumer = ({ items }) => {
const {
showSelectionBar,
hideSelectionBar,
isSelectionBarVisible,
toggleSelectedItem,
isItemSelected
} = useSelectionContext()
const { pathname } = useLocation()
return (
<>
{pathname === '/' && Change route}
{isSelectionBarVisible && (
Hide selection bar
)}
{items.map((item, index) => (
toggleSelectedItem(item)}
key={item.id}
data-testid={`item-${index + 1}`}
>
{`Item ${item.id} ${isItemSelected(item.id) ? 'selected' : ''}`}
))}
Show selection bar
>
)
}
describe('SelectionProvider', () => {
const item1 = generateFile({ i: 1 })
const item2 = generateFile({ i: 2 })
const item3 = generateFile({ i: 3 })
const items = [item1, item2, item3]
const setup = () => {
return render(
} />
}
/>
)
}
it('show and hide the selection bar', async () => {
setup()
expect(screen.queryByText('Hide selection bar')).toBeNull()
fireEvent.click(screen.getByText('Show selection bar'))
await waitFor(async () => {
const hideButton = await screen.findByText('Hide selection bar')
expect(hideButton).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Hide selection bar'))
expect(screen.queryByText('Hide selection bar')).toBeNull()
})
it('select and deselects item', () => {
setup()
// selecting one item
fireEvent.click(screen.getByText('Item file-foobar1'))
expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument()
expect(screen.getByText('Item file-foobar2')).toBeInTheDocument()
expect(screen.getByText('Hide selection bar')).toBeInTheDocument()
// selecting a second item
fireEvent.click(screen.getByText('Item file-foobar2'))
expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument()
expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument()
expect(screen.getByText('Hide selection bar')).toBeInTheDocument()
// deselecting the first item
fireEvent.click(screen.getByText('Item file-foobar1 selected'))
expect(screen.getByText('Item file-foobar1')).toBeInTheDocument()
expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument()
expect(screen.getByText('Hide selection bar')).toBeInTheDocument()
// deselecting the second item
fireEvent.click(screen.getByText('Item file-foobar2 selected'))
expect(screen.getByText('Item file-foobar1')).toBeInTheDocument()
expect(screen.getByText('Item file-foobar2')).toBeInTheDocument()
expect(screen.queryByText('Hide selection bar')).toBeNull()
})
it('should deselects items when location changed', async () => {
setup()
// show selection bar
fireEvent.click(screen.getByText('Show selection bar'))
const hideButton = await screen.findByText('Hide selection bar')
expect(hideButton).toBeInTheDocument()
// selecting all items
fireEvent.click(screen.getByText('Item file-foobar1'))
fireEvent.click(screen.getByText('Item file-foobar2'))
expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument()
expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument()
expect(screen.getByText('Hide selection bar')).toBeInTheDocument()
// change route
fireEvent.click(screen.getByText('Change route'))
// hide selection bar and selecting all items
await waitFor(async () => {
expect(await screen.findByText('Item file-foobar1')).toBeInTheDocument()
expect(await screen.findByText('Item file-foobar2')).toBeInTheDocument()
expect(screen.queryByText('Hide selection bar')).toBeNull()
})
})
})
================================================
FILE: src/modules/selection/types.ts
================================================
import { ReactNode } from 'react'
import { IOCozyFile } from 'cozy-client/types/types'
export type SelectedItems = Record
export interface SelectionContextType {
/** Show the SelectionBar */
showSelectionBar: () => void
/** Hide the SelectionBar */
hideSelectionBar: () => void
/** Clear all the selected items */
clearSelection: () => void
/** Whether the SelectionBar is visible or not */
isSelectionBarVisible: boolean
/** List of selected items as an array */
selectedItems: IOCozyFile[]
/** Select an item if it is not selected, otherwise deselect it */
toggleSelectedItem: (item: IOCozyFile) => void
/** Select all items */
selectAll: (items: IOCozyFile[]) => void
/** Find out if an item is selected by its id */
isItemSelected: (id: string) => boolean
/** Whether all the items are selected or not */
isSelectAll: boolean
/** Toggle selects all items */
toggleSelectAllItems: (items: IOCozyFile[]) => void
/** Set selected items directly (used internally) */
setSelectedItems: (
items: SelectedItems | ((prev: SelectedItems) => SelectedItems)
) => void
/** Set select all status */
setIsSelectAll: (isSelectAll: boolean) => void
}
export interface SelectionProviderProps {
children: ReactNode
}
================================================
FILE: src/modules/selectors.js
================================================
import maxBy from 'lodash/maxBy'
import { getDocumentFromState } from 'cozy-client/dist/store'
import { DOCTYPE_FILES } from '@/lib/doctypes'
import { getMirrorQueryId, parseFolderQueryId } from '@/lib/queries'
export const getFolderContentQueries = (rootState, folderId) => {
const queries = rootState.cozy.queries
const folderContentQueries = Object.entries(queries)
.filter(([queryId]) => {
const parsed = parseFolderQueryId(queryId)
if (!parsed) {
return false
}
const { folderId: queryFolderId } = parsed
if (queryFolderId !== folderId) {
return false
}
return true
})
.map(x => x[1])
return folderContentQueries
}
export const getLatestFolderQueryResults = (rootState, folderId) => {
const folderContentQueries = getFolderContentQueries(rootState, folderId)
if (folderContentQueries.length > 0) {
const mostRecentQueryResults =
maxBy(folderContentQueries, x => x.lastUpdate) || folderContentQueries[0]
const otherQueryId = getMirrorQueryId(mostRecentQueryResults.id)
const otherQueryResults = rootState.cozy.queries[otherQueryId]
return [mostRecentQueryResults, otherQueryResults]
}
return []
}
export const getFolderContent = (rootState, folderId) => {
const results = getLatestFolderQueryResults(rootState, folderId)
if (results.length > 0) {
const [mostRecentQueryResults, otherQueryResults] = results
const allContent = mostRecentQueryResults.data.concat(
otherQueryResults ? otherQueryResults.data : []
)
return allContent.map(fileId => {
return getDocumentFromState(rootState, DOCTYPE_FILES, fileId)
})
} else {
return null
}
}
================================================
FILE: src/modules/selectors.spec.js
================================================
import { getFolderContent } from './selectors'
import {
setupFolderContent,
setupStoreAndClient,
mockCozyClientRequestQuery
} from 'test/setup'
jest.mock('modules/navigation/AppRoute', () => ({ routes: [] }))
mockCozyClientRequestQuery('folderid123456')
describe('getFolderContent', () => {
it('should return an empty list if queries have not been loaded', () => {
const folderId = 'folderid123456'
const { store } = setupStoreAndClient()
const state = store.getState()
const files = getFolderContent(state, folderId)
expect(files).toEqual(null)
})
it('should return content from cozy client queries', async () => {
const folderId = 'folderid123456'
const { store } = await setupFolderContent({ folderId })
const state = store.getState()
const files = getFolderContent(state, folderId)
expect(files.length).toBe(13)
})
})
================================================
FILE: src/modules/services/components/Embeder.jsx
================================================
import React from 'react'
import { HashRouter, Routes, Route } from 'react-router-dom'
import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite'
import Spinner from 'cozy-ui/transpiled/react/Spinner'
import withBreakpoints from 'cozy-ui/transpiled/react/helpers/withBreakpoints'
import FileOpenerExternal from '@/modules/viewer/FileOpenerExternal'
import OnlyOfficeView from '@/modules/views/OnlyOffice'
import { isOfficeEnabled } from '@/modules/views/OnlyOffice/helpers'
class Embeder extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true
}
}
componentDidMount() {
this.fetchFileUrl()
}
async fetchFileUrl() {
const { service } = this.props
try {
const { id } = service.getData()
this.setState({ fileId: id, loading: false })
} catch (error) {
this.setState({ error, loading: false })
}
}
render() {
const {
service,
breakpoints: { isDesktop }
} = this.props
return (
{this.state.loading && (
)}
{this.state.error && (
{this.state.error.toString()}
)}
{this.state.fileId && (
}
/>
{isOfficeEnabled(isDesktop) && (
} />
)}
)}
)
}
}
export default withBreakpoints()(Embeder)
================================================
FILE: src/modules/services/components/IntentHandler.jsx
================================================
import React, { useEffect, useState } from 'react'
import { useClient } from 'cozy-client'
import Intents from 'cozy-interapp'
import logger from 'cozy-logger'
import Embeder from './Embeder'
const IntentHandler = ({ intentId }) => {
const client = useClient()
const [state, setState] = useState({
component: null,
service: null,
intent: null
})
const ServiceComponent = state.component
useEffect(() => {
const startService = async () => {
let component
let service
let intent
try {
const intents = new Intents({ client })
service = await intents.createService(intentId, window)
intent = service.getIntent()
if (
intent.attributes.action === 'OPEN' &&
intent.attributes.type === 'io.cozy.files'
) {
component = Embeder
}
setState({
component,
service,
intent
})
} catch (error) {
logger.error(error)
service.throw(error)
}
}
startService()
}, [client, intentId])
return ServiceComponent ? (
) : (
)
}
export default IntentHandler
================================================
FILE: src/modules/services/index.jsx
================================================
import IntentHandler from './components/IntentHandler'
export default IntentHandler
================================================
FILE: src/modules/services/services.styl
================================================
.fullscreen
position absolute
top 0
left 0
right 0
bottom 0
width 100%
height 100%
================================================
FILE: src/modules/shareddrives/components/SharedDriveBreadcrumb.jsx
================================================
import React, { useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from 'cozy-client'
import { useI18n } from 'twake-i18n'
import { SHARED_DRIVES_DIR_ID } from '@/constants/config.js'
import { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'
import { useBreadcrumbPath } from '@/modules/breadcrumb/hooks/useBreadcrumbPath.jsx'
import { buildSharedDriveIdQuery } from '@/queries'
const SharedDriveBreadcrumb = ({ driveId, folderId }) => {
const { t } = useI18n()
const navigate = useNavigate()
const sharedDriveQuery = buildSharedDriveIdQuery({ driveId })
const { data: sharedDrive } = useQuery(
sharedDriveQuery.definition,
sharedDriveQuery.options
)
const rootBreadcrumbPath = useMemo(
() => ({
id: sharedDrive?.rules?.[0]?.values?.[0],
name: sharedDrive?.description
}),
[sharedDrive]
)
const path = useBreadcrumbPath({
currentFolderId: folderId,
rootBreadcrumbPath,
driveId
})
const handleBreadcrumbClick = useCallback(
({ id }) => {
if (id === SHARED_DRIVES_DIR_ID) {
navigate(`/folder/${SHARED_DRIVES_DIR_ID}`)
return
}
navigate(`/shareddrive/${driveId}/${id}`)
},
[driveId, navigate]
)
return (
)
}
export { SharedDriveBreadcrumb }
================================================
FILE: src/modules/shareddrives/components/SharedDriveFolderBody.jsx
================================================
import React from 'react'
import { useDispatch } from 'react-redux'
import { useNavigate, useLocation, useParams } from 'react-router-dom'
import { useClient } from 'cozy-client'
import { useVaultClient } from 'cozy-keys-lib'
import { useSharingContext } from 'cozy-sharing'
import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { useModalContext } from '@/lib/ModalContext'
import {
download,
infos,
versions,
rename,
trash,
hr,
summariseByAI
} from '@/modules/actions'
import { duplicateTo } from '@/modules/actions/components/duplicateTo'
import { moveTo } from '@/modules/actions/components/moveTo'
import { FolderBody } from '@/modules/folder/components/FolderBody'
const SharedDriveFolderBody = ({
folderId,
queryResults,
refreshFolderContent
}) => {
const navigate = useNavigate()
const { pathname } = useLocation()
const client = useClient()
const vaultClient = useVaultClient()
const { driveId } = useParams()
const { t } = useI18n()
const { isOwner, byDocId, hasWriteAccess, refresh } = useSharingContext()
const { isMobile } = useBreakpoints()
const { showAlert } = useAlert()
const dispatch = useDispatch()
const { pushModal, popModal } = useModalContext()
const canWriteToCurrentFolder = hasWriteAccess(folderId, driveId)
const actionsOptions = {
client,
t,
vaultClient,
pathname,
isOwner,
isMobile,
driveId,
hasWriteAccess: canWriteToCurrentFolder,
byDocId,
dispatch,
canMove: canWriteToCurrentFolder,
canDuplicate: canWriteToCurrentFolder,
navigate,
showAlert,
pushModal,
popModal,
refresh
}
const actions = makeActions(
[
download,
hr,
summariseByAI,
hr,
rename,
moveTo,
duplicateTo,
infos,
hr,
versions,
hr,
trash
],
actionsOptions
)
return (
)
}
export { SharedDriveFolderBody }
================================================
FILE: src/modules/shareddrives/components/actions/leaveSharedDrive.js
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import LogoutIcon from 'cozy-ui/transpiled/react/Icons/Logout'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'
// Only for sharing tabs
export const leaveSharedDrive = ({ client, showAlert, t }) => {
const label = t('toolbar.menu_leave_shared_drive')
const icon = LogoutIcon
return {
name: 'leaveSharedDrive',
label: label,
icon,
displayCondition: docs => {
return docs.length === 1 && isFromSharedDriveRecipient(docs[0])
},
action: async docs => {
const sharedDriveId = docs[0].driveId
await client
.collection('io.cozy.sharings')
.revokeSelf({ _id: sharedDriveId })
showAlert({
message: t('Files.share.revokeSelf.success'),
severity: 'success'
})
},
Component: forwardRef(function deleteSharedDrive(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/shareddrives/components/actions/shareSharedDrive.js
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { navigateToModal } from '@/modules/actions/helpers'
import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'
// Only for sharing tabs
export const shareSharedDrive = ({ navigate, t }) => {
const label = t('Files.share.cta')
const icon = ShareIcon
return {
name: 'shareSharedDrive',
label: label,
icon,
displayCondition: docs => {
return docs.length === 1 && isFromSharedDriveRecipient(docs[0])
},
action: docs => {
const folderId = docs[0]._id
const driveId = docs[0].driveId
navigateToModal({
navigate,
pathname: `/shareddrive/${driveId}/${folderId}`,
files: docs,
path: 'share'
})
},
Component: forwardRef(function ShareSharedDrive(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/shareddrives/helpers.ts
================================================
import CozyClient, { generateWebLink } from 'cozy-client'
import { IOCozyFile } from 'cozy-client/types/types'
// Temporary type, need to be completed and then put in cozy-client
export interface SharedDrive {
_id: string
description: string
rules: Rule[]
owner?: boolean
}
export interface Rule {
title: string
values: string[]
}
/**
* Extract the sharing id from a file/folder relationships.referenced_by
* Returns undefined if not referenced by a sharing
*/
export const getSharingIdFromRelationships = (doc: {
relationships?: {
referenced_by?: { data?: { id: string; type: string }[] }
}
}): string | undefined =>
doc.relationships?.referenced_by?.data?.find(
ref => ref.type === 'io.cozy.sharings'
)?.id
export const getFolderIdFromSharing = (
sharing: SharedDrive
): string | undefined => {
try {
return sharing.rules[0].values[0]
} catch {
return undefined
}
}
export const isFromSharedDriveRecipient = (folder: IOCozyFile): boolean =>
folder && Boolean(folder.driveId)
export const makeSharedDriveNoteReturnUrl = (
client: CozyClient,
file: IOCozyFile
): string => {
return generateWebLink({
slug: 'drive',
searchParams: [],
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
cozyUrl: client.getStackClient().uri as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
subDomainType: client.getInstanceOptions().subdomain,
pathname: '',
hash: `/shareddrive/${file.driveId!}/${file.dir_id}`
})
}
================================================
FILE: src/modules/shareddrives/hooks/useQueryMultipleSharedDriveFolders.tsx
================================================
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useClient } from 'cozy-client'
import type { IOCozyFile } from 'cozy-client/types/types'
import { buildSharedDriveFolderQuery } from '@/queries'
interface UseQueryMultipleSharedDriveFoldersProps {
driveId: string
folderIds: string[]
}
interface SharedDriveResult {
data: IOCozyFile | null
}
interface SharedDriveFolderReturn {
sharedDriveResults: IOCozyFile[] | null
}
const useQueryMultipleSharedDriveFolders = ({
driveId,
folderIds
}: UseQueryMultipleSharedDriveFoldersProps): SharedDriveFolderReturn => {
const client = useClient()
const [sharedDriveResults, setSharedDriveResults] = useState<
SharedDriveFolderReturn['sharedDriveResults']
>([])
const sharedDriveQueries = useMemo(
() =>
folderIds.map(folderId =>
buildSharedDriveFolderQuery({
driveId,
folderId
})
),
[driveId, folderIds]
)
const fetchSharedDriveResults = useCallback(async () => {
const results = await Promise.all(
sharedDriveQueries.map(async query => {
return client?.query(
query.definition(),
query.options
) as Promise
})
)
setSharedDriveResults(
results.map(
(result: SharedDriveResult) => result.data
) as SharedDriveFolderReturn['sharedDriveResults']
)
}, [client, sharedDriveQueries])
useEffect(() => {
if (client) {
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchSharedDriveResults()
}
}, [client, fetchSharedDriveResults])
return {
sharedDriveResults
}
}
export { useQueryMultipleSharedDriveFolders }
================================================
FILE: src/modules/shareddrives/hooks/useSharedDriveFolder.spec.jsx
================================================
import { renderHook, act, waitFor } from '@testing-library/react'
import React from 'react'
import { createMockClient } from 'cozy-client'
import CozyRealtime from 'cozy-realtime'
import { useSharedDriveFolder } from './useSharedDriveFolder'
import AppLike from 'test/components/AppLike'
import logger from '@/lib/logger'
jest.mock('cozy-realtime', () => {
return jest.fn().mockImplementation(() => ({
subscribe: jest.fn(),
stop: jest.fn()
}))
})
jest.mock('lodash/debounce', () =>
jest.fn(fn => {
const immediate = (...args) => fn(...args)
immediate.cancel = jest.fn()
return immediate
})
)
jest.mock('@/lib/logger', () => ({
__esModule: true,
default: {
error: jest.fn()
}
}))
describe('useSharedDriveFolder', () => {
const mockDriveId = 'drive-id-1'
const mockFolderId = 'folder-id-1'
const mockData = [
{ _id: '1', name: 'file-1.txt', type: 'file' },
{ _id: '2', name: 'file-2.txt', type: 'file' }
]
const makeMockClient = statByIdFn => {
const mockClient = createMockClient({})
mockClient.getStackClient = () => ({
collection: () => ({
statById: statByIdFn
})
})
return mockClient
}
const setup = mockClient => {
const wrapper = ({ children }) => (
{children}
)
return renderHook(
() =>
useSharedDriveFolder({ driveId: mockDriveId, folderId: mockFolderId }),
{ wrapper }
)
}
afterEach(() => {
jest.clearAllMocks()
})
it('should fetch initial data', async () => {
const statByIdMock = jest.fn().mockResolvedValue({
included: mockData,
links: {}
})
const mockClient = makeMockClient(statByIdMock)
const { result } = setup(mockClient)
expect(result.current.sharedDriveResult.data).toBeUndefined()
await waitFor(() => {
expect(result.current.sharedDriveResult.included).toEqual(mockData)
})
expect(result.current.hasMore).toBe(false)
})
it('should indicate when there is more data to fetch', async () => {
const cursor = 'next-page-cursor'
const statByIdMock = jest.fn().mockResolvedValue({
included: mockData,
links: {
next: `/relative/link?page[cursor]=${cursor}&other=params`
}
})
const mockClient = makeMockClient(statByIdMock)
const { result } = setup(mockClient)
await waitFor(() => {
expect(result.current.hasMore).toBe(true)
})
})
it('should fetch more data when fetchMore is called', async () => {
const cursor = 'next-page-cursor'
const nextPageData = [
{ _id: '3', name: 'file-3.txt', type: 'file' },
{ _id: '4', name: 'file-4.txt', type: 'file' }
]
const statByIdMock = jest
.fn()
.mockResolvedValueOnce({
included: mockData,
links: { next: `/relative/link?page[cursor]=${cursor}` }
})
.mockResolvedValueOnce({
included: nextPageData,
links: {}
})
const mockClient = makeMockClient(statByIdMock)
const { result } = setup(mockClient)
await waitFor(() => expect(result.current.hasMore).toBe(true))
await act(() => result.current.fetchMore())
expect(statByIdMock).toHaveBeenLastCalledWith(mockFolderId, {
'page[cursor]': cursor,
'page[limit]': 100
})
await waitFor(() => {
expect(result.current.sharedDriveResult.included).toEqual([
...mockData,
...nextPageData
])
})
expect(result.current.hasMore).toBe(false)
})
it('should handle empty response', async () => {
const statByIdMock = jest.fn().mockResolvedValue({
included: [],
links: {}
})
const mockClient = makeMockClient(statByIdMock)
const { result } = setup(mockClient)
await waitFor(() => {
expect(result.current.sharedDriveResult.included).toEqual([])
})
expect(result.current.hasMore).toBe(false)
})
it('should handle errors during fetchMore and keep existing data', async () => {
const cursor = 'next-page-cursor'
const statByIdMock = jest
.fn()
.mockResolvedValueOnce({
included: mockData,
links: { next: `/relative/link?page[cursor]=${cursor}` }
})
.mockRejectedValueOnce(new Error('Network error'))
const mockClient = makeMockClient(statByIdMock)
const { result } = setup(mockClient)
await waitFor(() => expect(result.current.hasMore).toBe(true))
await act(() => result.current.fetchMore())
expect(result.current.sharedDriveResult.included).toEqual(mockData)
expect(result.current.hasMore).toBe(true)
expect(logger.error).toHaveBeenCalledWith(
'Error fetching more shared drive files:',
expect.any(Error)
)
})
it('should not fetch more if already fetching', async () => {
const cursor = 'next-page-cursor'
const statByIdMock = jest.fn().mockResolvedValue({
included: mockData,
links: { next: `/relative/link?page[cursor]=${cursor}` }
})
const mockClient = makeMockClient(statByIdMock)
const { result } = setup(mockClient)
await waitFor(() => expect(result.current.hasMore).toBe(true))
await act(async () => {
const fetchPromise = result.current.fetchMore()
await result.current.fetchMore()
await fetchPromise
})
expect(statByIdMock).toHaveBeenCalledTimes(2)
})
describe('realtime re-fetch', () => {
let triggerRealtimeEvent
beforeEach(() => {
CozyRealtime.mockImplementation(() => ({
subscribe: jest.fn((_event, _doctype, callback) => {
triggerRealtimeEvent = callback
}),
stop: jest.fn()
}))
})
it('should re-fetch only page 1 when realtime fires before any fetchMore', async () => {
const cursor = 'cursor-page-2'
const page1 = [{ _id: '1', name: 'file-1.txt', type: 'file' }]
const refreshedPage1 = [
{ _id: '1', name: 'file-1-renamed.txt', type: 'file' }
]
const statByIdMock = jest
.fn()
.mockResolvedValueOnce({
included: page1,
links: { next: `/link?page[cursor]=${cursor}` }
})
.mockResolvedValueOnce({
included: refreshedPage1,
links: { next: `/link?page[cursor]=${cursor}` }
})
const mockClient = makeMockClient(statByIdMock)
const { result } = setup(mockClient)
await waitFor(() =>
expect(result.current.sharedDriveResult.included).toEqual(page1)
)
expect(statByIdMock).toHaveBeenCalledTimes(1)
await act(async () => {
triggerRealtimeEvent()
})
await waitFor(() =>
expect(result.current.sharedDriveResult.included).toEqual(
refreshedPage1
)
)
// 1 initial + 1 re-fetch (page 1 only)
expect(statByIdMock).toHaveBeenCalledTimes(2)
})
it('should re-fetch all loaded pages when realtime fires after fetchMore', async () => {
const cursor1 = 'cursor-page-2'
const cursor2 = 'cursor-page-3'
const page1 = [{ _id: '1', name: 'file-1.txt', type: 'file' }]
const page2 = [{ _id: '2', name: 'file-2.txt', type: 'file' }]
const page3 = [{ _id: '3', name: 'file-3.txt', type: 'file' }]
const refreshedPage1 = [
{ _id: '1', name: 'file-1-renamed.txt', type: 'file' }
]
const refreshedPage2 = [{ _id: '2', name: 'file-2.txt', type: 'file' }]
const refreshedPage3 = [{ _id: '3', name: 'file-3.txt', type: 'file' }]
const statByIdMock = jest
.fn()
// Initial fetch: page 1
.mockResolvedValueOnce({
included: page1,
links: { next: `/link?page[cursor]=${cursor1}` }
})
// fetchMore: page 2
.mockResolvedValueOnce({
included: page2,
links: { next: `/link?page[cursor]=${cursor2}` }
})
// fetchMore: page 3
.mockResolvedValueOnce({ included: page3, links: {} })
// Realtime re-fetch: page 1
.mockResolvedValueOnce({
included: refreshedPage1,
links: { next: `/link?page[cursor]=${cursor1}` }
})
// Realtime re-fetch: page 2
.mockResolvedValueOnce({
included: refreshedPage2,
links: { next: `/link?page[cursor]=${cursor2}` }
})
// Realtime re-fetch: page 3
.mockResolvedValueOnce({ included: refreshedPage3, links: {} })
const mockClient = makeMockClient(statByIdMock)
const { result } = setup(mockClient)
await waitFor(() => expect(result.current.hasMore).toBe(true))
await act(() => result.current.fetchMore())
await act(() => result.current.fetchMore())
await waitFor(() =>
expect(result.current.sharedDriveResult.included).toEqual([
...page1,
...page2,
...page3
])
)
expect(statByIdMock).toHaveBeenCalledTimes(3)
await act(async () => {
triggerRealtimeEvent()
})
await waitFor(() =>
expect(result.current.sharedDriveResult.included).toEqual([
...refreshedPage1,
...refreshedPage2,
...refreshedPage3
])
)
// 3 initial + 3 re-fetch (all pages)
expect(statByIdMock).toHaveBeenCalledTimes(6)
})
})
})
================================================
FILE: src/modules/shareddrives/hooks/useSharedDriveFolder.tsx
================================================
import debounce from 'lodash/debounce'
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { useClient } from 'cozy-client'
import type { IOCozyFile } from 'cozy-client/types/types'
import CozyRealtime from 'cozy-realtime'
import logger from '@/lib/logger'
import {
paginatedStatById,
type PaginatedStatByIdResult
} from '@/modules/shareddrives/hooks/useSharedDriveFolderHelpers'
import { buildSharedDriveFolderQuery } from '@/queries'
import type { QueryConfig } from '@/queries'
interface SharedDriveFolderProps {
driveId: string
folderId: string
}
interface SharedDriveFolderReturn {
// FIXME: We should use useQuery hook here but it doesn't allow to get included data
// See https://github.com/cozy/cozy-client/issues/1620
sharedDriveQuery: QueryConfig
sharedDriveResult: {
data?: IOCozyFile[] | null
included?: IOCozyFile[] | null
}
fetchStatus: 'loading' | 'loaded' | 'failed'
hasMore: boolean
fetchMore: () => Promise
}
const useSharedDriveFolder = ({
driveId,
folderId
}: SharedDriveFolderProps): SharedDriveFolderReturn => {
const client = useClient()
const [sharedDriveResult, setSharedDriveResult] = useState<
SharedDriveFolderReturn['sharedDriveResult']
>({ data: undefined })
const [fetchStatus, setFetchStatus] =
useState('loading')
const [nextCursor, setNextCursor] = useState(null)
const nextCursorRef = useRef(null)
const isFetchingMore = useRef(false)
const fetchGeneration = useRef(0)
const loadedPagesCount = useRef(0)
const sharedDriveQuery = useMemo(
() =>
buildSharedDriveFolderQuery({
driveId,
folderId
}),
[driveId, folderId]
)
const statById = useMemo(
() => paginatedStatById(client, driveId),
[client, driveId]
)
useEffect(() => {
const fetchSharedDriveFolder = async (pagesToLoad = 1): Promise => {
fetchGeneration.current += 1
const currentGeneration = fetchGeneration.current
setSharedDriveResult({ data: undefined, included: undefined })
setFetchStatus('loading')
nextCursorRef.current = null
setNextCursor(null)
loadedPagesCount.current = 0
try {
let allIncluded: IOCozyFile[] = []
let cursor: string | null = null
for (let page = 0; page < pagesToLoad; page++) {
const result: PaginatedStatByIdResult = await statById(
folderId,
cursor
)
allIncluded = [...allIncluded, ...(result.included ?? [])]
cursor = result.nextCursor
if (!result.nextCursor) break
}
if (fetchGeneration.current === currentGeneration) {
setSharedDriveResult({ included: allIncluded })
setFetchStatus('loaded')
nextCursorRef.current = cursor
setNextCursor(cursor)
loadedPagesCount.current = pagesToLoad
}
} catch (error) {
logger.error('Error fetching shared drive folder:', error)
if (fetchGeneration.current === currentGeneration) {
setSharedDriveResult({ data: undefined, included: undefined })
setFetchStatus('failed')
nextCursorRef.current = null
setNextCursor(null)
}
}
}
if (client && driveId && folderId) {
void fetchSharedDriveFolder()
}
const debouncedFetch = debounce(() => {
void fetchSharedDriveFolder(Math.max(1, loadedPagesCount.current))
}, 500)
let realtime: CozyRealtime | undefined
if (client && driveId) {
realtime = new CozyRealtime({ client, sharedDriveId: driveId })
realtime.subscribe('updated', 'io.cozy.files', debouncedFetch)
realtime.subscribe('created', 'io.cozy.files', debouncedFetch)
realtime.subscribe('deleted', 'io.cozy.files', debouncedFetch)
}
return (): void => {
if (realtime) {
realtime.stop()
}
debouncedFetch.cancel()
}
}, [client, driveId, folderId, statById])
const fetchMore = useCallback(async (): Promise => {
if (isFetchingMore.current || !nextCursorRef.current || !client) return
isFetchingMore.current = true
const currentGeneration = fetchGeneration.current
try {
const { included, nextCursor: cursor } = await statById(
folderId,
nextCursorRef.current
)
if (fetchGeneration.current !== currentGeneration) return
setSharedDriveResult(prev => ({
...prev,
included: [...(prev.included ?? []), ...(included ?? [])]
}))
nextCursorRef.current = cursor
setNextCursor(cursor)
loadedPagesCount.current += 1
} catch (error) {
logger.error('Error fetching more shared drive files:', error)
} finally {
isFetchingMore.current = false
}
}, [client, folderId, statById])
const hasMore = !!nextCursor
return {
sharedDriveQuery,
sharedDriveResult,
fetchStatus,
hasMore,
fetchMore
}
}
export { useSharedDriveFolder }
================================================
FILE: src/modules/shareddrives/hooks/useSharedDriveFolderHelpers.ts
================================================
import CozyClient from 'cozy-client'
import type { IOCozyFile } from 'cozy-client/types/types'
const PAGE_LIMIT = 100
interface StatByIdLinks {
next?: string
}
interface StatByIdResult {
included: IOCozyFile[]
links?: StatByIdLinks
}
interface TypedFileCollection {
statById: (
id: string,
opts: Record
) => Promise
}
export interface PaginatedStatByIdResult {
included: IOCozyFile[]
nextCursor: string | null
}
export const paginatedStatById =
(client: CozyClient, driveId: string) =>
async (
folderId: string,
cursor: string | null = null
): Promise => {
const collection = client.collection('io.cozy.files', {
driveId
}) as unknown as TypedFileCollection
const { included = [], links } = await collection.statById(folderId, {
...(cursor ? { 'page[cursor]': cursor } : {}),
'page[limit]': PAGE_LIMIT
})
let nextCursor: string | null = null
if (links?.next) {
try {
const queryString = links.next.split('?')[1]
if (queryString) {
const params = new URLSearchParams(queryString)
nextCursor = params.get('page[cursor]')
}
} catch {
nextCursor = null
}
}
return { included, nextCursor }
}
================================================
FILE: src/modules/shareddrives/hooks/useSharedDrives.js
================================================
import { useState, useEffect } from 'react'
import { useClient, useQuery } from 'cozy-client'
import { DEFAULT_SORT } from '@/config/sort'
import { SHARED_DRIVES_DIR_ID } from '@/constants/config'
import { buildDriveQuery } from '@/queries'
export const useSharedDrives = () => {
const client = useClient()
const [isLoading, setIsLoading] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [sharedDrives, setSharedDrives] = useState([])
const folderQuery = buildDriveQuery({
currentFolderId: SHARED_DRIVES_DIR_ID,
type: 'directory',
sortAttribute: DEFAULT_SORT.attribute,
sortOrder: DEFAULT_SORT.order
})
const { lastUpdate } = useQuery(folderQuery.definition, folderQuery.options)
useEffect(() => {
let isCancelled = false
const fetchSharedDrives = async () => {
setIsLoading(true)
try {
const { data: sharedDrives } = await client
.collection('io.cozy.sharings')
.fetchSharedDrives()
if (!isCancelled) {
setSharedDrives(sharedDrives)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
setIsLoaded(true)
}
}
}
void fetchSharedDrives()
return () => {
isCancelled = true
}
}, [client, lastUpdate])
return { isLoading, isLoaded, sharedDrives }
}
================================================
FILE: src/modules/trash/components/DestroyConfirm.tsx
================================================
import React, { useState } from 'react'
import { splitFilename } from 'cozy-client/dist/models/file'
import Button from 'cozy-ui/transpiled/react/Buttons'
import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ForbiddenIcon from 'cozy-ui/transpiled/react/Icons/Forbidden'
import RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'
import List from 'cozy-ui/transpiled/react/List'
import ListItem from 'cozy-ui/transpiled/react/ListItem'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { File } from '@/components/FolderPicker/types'
import { getEntriesTypeTranslated } from '@/lib/entries'
interface DestroyConfirmProps {
files: File[]
onClose: () => void
onConfirm: () => Promise
}
const DestroyConfirm: React.FC = ({
files,
onClose,
onConfirm
}) => {
const { t } = useI18n()
const [isBusy, setBusy] = useState(false)
const { showAlert } = useAlert()
const entriesType = getEntriesTypeTranslated(t, files)
const handleDestroy = async (): Promise => {
// Prevent double executions
if (isBusy) return
setBusy(true)
try {
showAlert({
message: t('DestroyConfirm.processing', {
smart_count: files.length,
type: entriesType
}),
severity: 'info'
})
onClose()
await onConfirm()
showAlert({
message: t('DestroyConfirm.success', {
smart_count: files.length,
type: entriesType
}),
severity: 'success'
})
} catch {
showAlert({
message: t('DestroyConfirm.error'),
severity: 'error'
})
} finally {
setBusy(false)
}
}
const filename = files.length > 0 ? splitFilename(files[0]).filename : ''
return (
}
actions={
<>
>
}
/>
)
}
export default DestroyConfirm
================================================
FILE: src/modules/trash/components/EmptyTrashConfirm.tsx
================================================
import React, { useCallback, useState } from 'react'
import Button from 'cozy-ui/transpiled/react/Buttons'
import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ForbiddenIcon from 'cozy-ui/transpiled/react/Icons/Forbidden'
import RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'
import List from 'cozy-ui/transpiled/react/List'
import ListItem from 'cozy-ui/transpiled/react/ListItem'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
interface EmptyTrashConfirmProps {
onConfirm: () => Promise
onClose: () => void
}
const EmptyTrashConfirm: React.FC = ({
onConfirm,
onClose
}) => {
const { t } = useI18n()
const { showAlert } = useAlert()
const [isBusy, setBusy] = useState(false)
const handleConfirm = useCallback(async () => {
try {
showAlert({
message: t('EmptyTrashConfirm.processing'),
severity: 'info'
})
setBusy(true)
await onConfirm()
showAlert({
message: t('EmptyTrashConfirm.success'),
severity: 'success'
})
} catch {
showAlert({
message: t('EmptyTrashConfirm.error'),
severity: 'error'
})
} finally {
setBusy(false)
onClose()
}
}, [onConfirm, onClose, showAlert, t])
return (
}
actions={
<>
>
}
/>
)
}
export { EmptyTrashConfirm }
================================================
FILE: src/modules/trash/components/TrashBreadcrumb.tsx
================================================
import React, { useMemo, FC, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useI18n } from 'twake-i18n'
import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config.js'
import { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'
import { useBreadcrumbPath } from '@/modules/breadcrumb/hooks/useBreadcrumbPath.jsx'
interface TrashBreadcrumbProps {
currentFolderId: string
}
const TrashBreadcrumb: FC = ({ currentFolderId }) => {
const { t } = useI18n()
const navigate = useNavigate()
const rootBreadcrumbPath = useMemo(
() => ({
id: TRASH_DIR_ID,
name: t('breadcrumb.title_trash')
}),
[t]
)
const path = useBreadcrumbPath({
currentFolderId,
rootBreadcrumbPath
})
const trashPath = [
{
id: ROOT_DIR_ID,
name: t('breadcrumb.title_drive')
},
...path
]
const handleBreadcrumbClick = useCallback(
({ id }: { id: string }) => {
// We can navigate to the root folder inside the breadcrumb
if (id === ROOT_DIR_ID) {
navigate(`/folder/${ROOT_DIR_ID}`)
} else {
navigate(`/trash/${id}`)
}
},
[navigate]
)
return (
)
}
export { TrashBreadcrumb }
================================================
FILE: src/modules/trash/components/TrashToolbar.spec.jsx
================================================
import { render, fireEvent, act, screen } from '@testing-library/react'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { createMockClient } from 'cozy-client'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { TrashToolbar } from './TrashToolbar'
import AppLike from 'test/components/AppLike'
jest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({
...jest.requireActual('cozy-ui/transpiled/react/providers/Breakpoints'),
__esModule: true,
default: jest.fn(),
useBreakpoints: jest.fn()
}))
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn()
}))
describe('TrashToolbar', () => {
it('asks for confirmation before emptying the trash', async () => {
const mockClient = createMockClient({})
const navigateMock = jest.fn()
useBreakpoints.mockReturnValue({ isMobile: false })
useNavigate.mockReturnValue(navigateMock)
render(
)
const emptyTrashButton = screen.getByText('Empty trash')
act(() => {
fireEvent.click(emptyTrashButton)
})
expect(navigateMock).toHaveBeenCalledTimes(1)
expect(navigateMock).toHaveBeenCalledWith('empty')
})
})
================================================
FILE: src/modules/trash/components/TrashToolbar.tsx
================================================
import cx from 'classnames'
import React, { FC } from 'react'
import { useNavigate } from 'react-router-dom'
import { BarRight } from 'cozy-bar'
import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import Button from 'cozy-ui/transpiled/react/Buttons'
import Icon from 'cozy-ui/transpiled/react/Icon'
import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { MoreMenu } from '@/components/MoreMenu'
import { selectable } from '@/modules/actions/components/selectable'
import SearchButton from '@/modules/drive/Toolbar/components/SearchButton'
import ViewSwitcher from '@/modules/drive/Toolbar/components/ViewSwitcher'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import { emptyTrash } from '@/modules/trash/components/actions/emptyTrash'
const TrashToolbar: FC = () => {
const { t } = useI18n()
const { isMobile } = useBreakpoints()
const navigate = useNavigate()
const { showSelectionBar, isSelectionBarVisible } = useSelectionContext()
const handleEmptyTrash = (): void => {
navigate('empty')
}
const actions = makeActions([selectable, emptyTrash], {
t,
showSelectionBar,
navigate
})
if (isMobile) {
return (
)
}
return (
}
label={t('TrashToolbar.emptyTrash')}
/>
)
}
export { TrashToolbar }
================================================
FILE: src/modules/trash/components/actions/destroy.tsx
================================================
import React, { forwardRef } from 'react'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'
import type { ActionWithPolicy } from '@/modules/actions/types'
interface destroyProps {
t: (key: string, options?: Record) => string
navigate: (to: string) => void
pathname: string
search?: string
}
export const destroy = ({
t,
navigate,
pathname,
search
}: destroyProps): ActionWithPolicy => {
const label = t('SelectionBar.destroy')
const icon = TrashIcon
return {
name: 'destroy',
label,
icon,
allowTrashed: true,
action: (files): void => {
navigateToModalWithMultipleFile({
files,
pathname,
navigate,
path: 'destroy',
search
})
},
Component: forwardRef(function Destroy(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/trash/components/actions/emptyTrash.tsx
================================================
import React, { forwardRef } from 'react'
import { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
interface emptyTrashProps {
t: (key: string, options?: Record) => string
navigate: (to: string) => void
}
export const emptyTrash = ({ t, navigate }: emptyTrashProps): Action => {
const label = t('TrashToolbar.emptyTrash')
const icon = TrashIcon
return {
name: 'emptyTrash',
label,
icon,
action: (): void => {
navigate('empty')
},
Component: forwardRef(function EmptyTrash(props, ref) {
return (
)
})
}
}
================================================
FILE: src/modules/upload/Dropzone.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { useDropzone } from 'react-dropzone'
import { useDispatch } from 'react-redux'
import { useClient } from 'cozy-client'
import { useSharingContext } from 'cozy-sharing'
import { Content } from 'cozy-ui/transpiled/react/Layout'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/dropzone.styl'
import RightClickAddMenu from '@/components/RightClick/RightClickAddMenu'
import { uploadFiles } from '@/modules/navigation/duck'
import DropzoneTeaser from '@/modules/upload/DropzoneTeaser'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
const canDrop = evt => {
const items = evt.dataTransfer.items
for (let i = 0; i < items.length; i += 1) {
if (items[i].kind !== 'file') return false
}
return true
}
export const Dropzone = ({
displayedFolder,
disabled,
refreshFolderContent = null,
children
}) => {
const client = useClient()
const { t } = useI18n()
const { isMobile } = useBreakpoints()
const { showAlert } = useAlert()
const sharingState = useSharingContext()
const dispatch = useDispatch()
const { addItems } = useNewItemHighlightContext()
const fileUploadCallback = refreshFolderContent
? refreshFolderContent
: () => null
const onDrop = async (files, _, evt) => {
if (!canDrop(evt)) return
// react-dropzone v14 (default `getFilesFromEvent: fromEvent` from
// file-selector) walks dropped folders and gives us individual File
// objects with `.path` set to the relative path inside the dropped
// folder. addToUploadQueue uses those paths to flatten into per-file
// queue items at enqueue time.
dispatch(
uploadFiles(
files,
displayedFolder.id,
sharingState,
fileUploadCallback,
{ client, showAlert, t },
displayedFolder.driveId,
addItems
)
)
}
const { getRootProps, isDragActive } = useDropzone({
onDrop,
disabled,
noClick: true,
noKeyboard: true
})
return (
{isDragActive && }
{children}
)
}
export default Dropzone
================================================
FILE: src/modules/upload/DropzoneDnD.jsx
================================================
import cx from 'classnames'
import React from 'react'
import { useDrop } from 'react-dnd'
import { NativeTypes } from 'react-dnd-html5-backend'
import { useDispatch } from 'react-redux'
import { useClient } from 'cozy-client'
import { useSharingContext } from 'cozy-sharing'
import { Content } from 'cozy-ui/transpiled/react/Layout'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import styles from '@/styles/dropzone.styl'
import RightClickAddMenu from '@/components/RightClick/RightClickAddMenu'
import { uploadFiles } from '@/modules/navigation/duck'
import DropzoneTeaser from '@/modules/upload/DropzoneTeaser'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
// DnD helpers for folder upload
const canHandleFolders = evt => {
if (!evt.dataTransfer) return false
const dt = evt.dataTransfer
return dt.items && dt.items.length && dt.items[0].webkitGetAsEntry != null
}
const canDropHelper = evt => {
const items = evt.dataTransfer.items
for (let i = 0; i < items.length; i += 1) {
if (items[i].kind !== 'file') return false
}
return true
}
export const Dropzone = ({
displayedFolder,
disabled,
refreshFolderContent = null,
children
}) => {
const client = useClient()
const { t } = useI18n()
const { isMobile } = useBreakpoints()
const { showAlert } = useAlert()
const sharingState = useSharingContext()
const dispatch = useDispatch()
const { addItems } = useNewItemHighlightContext()
const fileUploadCallback = refreshFolderContent
? refreshFolderContent
: () => null
const [{ canDrop, isOver }, dropRef] = useDrop(
() => ({
accept: [NativeTypes.FILE],
canDrop: item => !disabled && canDropHelper(item),
drop(item) {
if (disabled) return
const filesToUpload = canHandleFolders(item)
? item.dataTransfer.items
: item.files
dispatch(
uploadFiles(
filesToUpload,
displayedFolder._id,
sharingState,
fileUploadCallback,
{ client, showAlert, t },
displayedFolder.driveId,
addItems
)
)
},
collect: monitor => {
return {
isOver: monitor.isOver(),
canDrop: monitor.canDrop()
}
}
}),
[displayedFolder]
)
const isActive = canDrop && isOver
return (
{isActive && }
{children}
)
}
const DropzoneWrapper = ({
displayedFolder,
disabled,
refreshFolderContent,
children
}) => {
const { isMobile } = useBreakpoints()
if (disabled) {
return {children}
}
return (
{children}
)
}
export default DropzoneWrapper
================================================
FILE: src/modules/upload/DropzoneTeaser.jsx
================================================
import React from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import UploadIcon from 'cozy-ui/transpiled/react/Icons/Upload'
import { translate } from 'twake-i18n'
import styles from '@/styles/dropzone.styl'
const DropzoneTeaser = translate()(({ t, currentFolder }) => (
{t('Files.dropzone.teaser')}
{(currentFolder && currentFolder.name) || 'Drive'}
))
export default DropzoneTeaser
================================================
FILE: src/modules/upload/NewItemHighlightProvider.jsx
================================================
import React, { createContext, useCallback, useContext, useState } from 'react'
const NewItemHighlightContext = createContext()
const NewItemHighlightProvider = ({ children }) => {
const [highlightedItems, setHighlightedItems] = useState([])
const [ids, setIds] = useState(new Set())
const addItems = newItems => {
if (!Array.isArray(newItems)) {
throw new Error('addItems expects an array')
}
const validItems = newItems.filter(item => item?._id)
if (validItems.length === 0) return
setHighlightedItems(validItems)
setIds(new Set(validItems.map(item => item._id)))
}
const clearItems = useCallback(() => {
setHighlightedItems([])
setIds(new Set())
}, [setHighlightedItems, setIds])
const isNew = item => {
return item?._id ? ids.has(item._id) : false
}
return (
{children}
)
}
const useNewItemHighlightContext = () => {
const ctx = useContext(NewItemHighlightContext)
if (!ctx)
throw new Error(
'useNewItemHighlightContext must be used within NewItemHighlightProvider'
)
return ctx
}
export { NewItemHighlightProvider, useNewItemHighlightContext }
================================================
FILE: src/modules/upload/UploadButton.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { useDispatch } from 'react-redux'
import { useClient } from 'cozy-client'
import withSharingState from 'cozy-sharing/dist/hoc/withSharingState'
import Button from 'cozy-ui/transpiled/react/Buttons'
import FileInput from 'cozy-ui/transpiled/react/FileInput'
import Icon from 'cozy-ui/transpiled/react/Icon'
import UploadIcon from 'cozy-ui/transpiled/react/Icons/Upload'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { uploadFiles } from '@/modules/navigation/duck'
import { usePublicContext } from '@/modules/public/PublicProvider'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
const UploadButton = ({
label,
disabled,
className,
displayedFolder,
sharingState,
componentsProps,
onUploaded
}) => {
const { showAlert } = useAlert()
const { addItems } = useNewItemHighlightContext()
const { t } = useI18n()
const dispatch = useDispatch()
const client = useClient()
const onUpload = files => {
dispatch(
uploadFiles(
files,
displayedFolder.id,
sharingState,
onUploaded,
{ client, showAlert, t },
displayedFolder.driveId,
addItems
)
)
}
const { isPublic } = usePublicContext()
return (
onUpload(files)}
data-testid="upload-btn"
value={[]} // always erase the value to be able to re-upload the same file
>
}
label={label}
/>
)
}
UploadButton.propTypes = {
label: PropTypes.string.isRequired,
disabled: PropTypes.bool,
className: PropTypes.string,
componentsProps: PropTypes.object,
onUploaded: PropTypes.func,
displayedFolder: PropTypes.object, // io.cozy.files
// in case of upload conflicts, shared files are not overridden
sharingState: PropTypes.object.isRequired
}
UploadButton.defaultProps = {
disabled: false
}
export default withSharingState(UploadButton)
================================================
FILE: src/modules/upload/UploadLimitDialog.jsx
================================================
import React from 'react'
import Button from 'cozy-ui/transpiled/react/Buttons'
import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Icon from 'cozy-ui/transpiled/react/Icon'
import DesktopDownloadIcon from 'cozy-ui/transpiled/react/Icons/DesktopDownload'
import Typography from 'cozy-ui/transpiled/react/Typography'
import { useI18n } from 'twake-i18n'
import { getDesktopAppDownloadLink } from '@/components/pushClient'
import { usePublicContext } from '@/modules/public/PublicProvider'
const UploadLimitDialog = ({ onClose, maxFileCount }) => {
const { t } = useI18n()
const { isPublic } = usePublicContext()
const handleDownloadDesktop = () => {
const link = getDesktopAppDownloadLink({ t })
window.open(link, '_blank', 'noopener,noreferrer')
onClose()
}
return (
{t(isPublic ? 'upload.limit.content_public' : 'upload.limit.content')}
}
actions={
isPublic ? (
) : (
<>
}
/>
>
)
}
/>
)
}
export default UploadLimitDialog
================================================
FILE: src/modules/upload/UploadQueue.jsx
================================================
import cx from 'classnames'
import React, { useCallback, useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CheckCircleIcon from 'cozy-ui/transpiled/react/Icons/CheckCircle'
import CrossCircleIcon from 'cozy-ui/transpiled/react/Icons/CrossCircle'
import SpinnerIcon from 'cozy-ui/transpiled/react/Icons/Spinner'
import WarningIcon from 'cozy-ui/transpiled/react/Icons/Warning'
import LinearProgress from 'cozy-ui/transpiled/react/LinearProgress'
import List from 'cozy-ui/transpiled/react/List'
import ListItem from 'cozy-ui/transpiled/react/ListItem'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
import Paper from 'cozy-ui/transpiled/react/Paper'
import Tooltip from 'cozy-ui/transpiled/react/Tooltip'
import Typography from 'cozy-ui/transpiled/react/Typography'
import Button from 'cozy-ui/transpiled/react/deprecated/Button'
import { useI18n } from 'twake-i18n'
import { getUploadQueue, purgeUploadQueue, status as uploadStatus } from '.'
import { DEFAULT_UPLOAD_PROGRESS_HIDE_DELAY } from '@/constants/config'
import getMimeTypeIcon from '@/lib/getMimeTypeIcon'
const {
LOADING,
PENDING,
RESOLVING,
CANCEL,
CREATED,
UPDATED,
ERROR_STATUSES,
DONE_STATUSES
} = uploadStatus
const IN_PROGRESS = new Set([PENDING, RESOLVING])
// For the determinate progress bar, weight each row by how far it's
// progressed: PENDING/RESOLVING contribute 0, LOADING contributes its
// loaded/total byte fraction, terminal statuses (success/error/cancel)
// contribute 1. Counts only matter as integers for the close-button
// gating; the bar uses the fractional total.
const summarise = queue => {
let done = 0
let success = 0
let progressTotal = 0
for (const item of queue) {
if (IN_PROGRESS.has(item.status)) continue
if (item.status === LOADING) {
progressTotal += item.progress?.total
? item.progress.loaded / item.progress.total
: 0
continue
}
done++
progressTotal += 1
if (item.status === CREATED || item.status === UPDATED) success++
}
return { done, success, progressTotal }
}
const popoverStyle = {
position: 'fixed',
bottom: '0.5rem',
right: '1.5rem',
width: '30rem',
maxWidth: '90%',
height: '13.125rem',
zIndex: 'var(--zIndex-popover, 1300)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: '0.5rem'
}
const headerStyle = {
minHeight: '2rem',
padding: '0.5rem 1rem',
margin: 0,
fontWeight: 'bold',
backgroundColor: 'var(--defaultBackgroundColor)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}
const contentStyle = {
overflow: 'auto',
flex: '1 1 auto',
// Without min-height: 0 the flex item refuses to shrink below its
// content size, so a long queue pushes header + progress bar
// out of the popover's clipped area.
minHeight: 0
}
const FileUploadProgress = ({ progress }) => {
if (!progress?.total) return null
return (
)
}
const UploadItem = ({ item, t }) => {
const { file, status, isDirectory, relativePath } = item
const displayName = relativePath || file?.name || ''
const isResolving = status === RESOLVING
const isLoading = status === LOADING
const isError = ERROR_STATUSES.includes(status)
const isDone = DONE_STATUSES.includes(status)
const isPending = status === PENDING
let statusIcon = null
if (isResolving || (isLoading && !item.progress)) {
// Use Icon directly rather than cozy-ui's : Spinner wraps the
// SVG in a div whose line-box baseline pushes the glyph ~2px above the
// sibling label, which looks misaligned in the row.
statusIcon = (
)
} else if (status === CANCEL) {
statusIcon =
} else if (isError) {
statusIcon =
} else if (isDone) {
statusIcon =
}
let label = null
if (isResolving) label = t('UploadQueue.item.preparing')
else if (isPending) label = t('UploadQueue.item.pending')
return (
{displayName}
) : (
{displayName}
)
}
secondary={
isLoading && item.progress ? (
) : null
}
/>
{statusIcon && {statusIcon} }
{label && (
{label}
)}
)
}
const UploadQueue = () => {
const { t } = useI18n()
const dispatch = useDispatch()
const queue = useSelector(getUploadQueue)
const {
done: doneCount,
success: successCount,
progressTotal
} = summarise(queue || [])
const purgeQueue = useCallback(() => dispatch(purgeUploadQueue()), [dispatch])
const queueLength = queue?.length ?? 0
// Everything in the queue has reached a terminal state (success or
// error). Drives the "done" header copy + the manual close button.
const allProcessed = queueLength > 0 && doneCount === queueLength
// Stricter: every item succeeded. Drives auto-purge so failures stay
// visible until the user dismisses them — auto-closing on partial
// failures would hide the failed rows behind the toast alert.
const allSucceeded = queueLength > 0 && successCount === queueLength
const isResolving = (queue || []).some(item => item.status === RESOLVING)
useEffect(() => {
if (allSucceeded) {
const timer = setTimeout(purgeQueue, DEFAULT_UPLOAD_PROGRESS_HIDE_DELAY)
return () => clearTimeout(timer)
}
}, [allSucceeded, purgeQueue])
if (queueLength === 0) return null
let headerText
if (isResolving) {
// Count the whole drop, not just the resolving placeholders, so a
// mixed drop (loose file + folder) reads as the user dropped it.
headerText = t('UploadQueue.header_preparing', {
smart_count: queueLength
})
} else if (allProcessed) {
headerText = t('UploadQueue.header_done', {
done: successCount,
total: queueLength
})
} else {
headerText = t('UploadQueue.header', { smart_count: queueLength })
}
return (
{headerText}
{allProcessed && !isResolving && (
)}
{queue.map(item => (
))}
)
}
export default UploadQueue
================================================
FILE: src/modules/upload/index.js
================================================
import { combineReducers } from 'redux'
import { getFullpath } from 'cozy-client/dist/models/file'
import { MAX_PAYLOAD_SIZE } from '@/constants/config'
import { DOCTYPE_FILES } from '@/lib/doctypes'
import logger from '@/lib/logger'
import { CozyFile } from '@/models'
const SLUG = 'upload'
export const ADD_TO_UPLOAD_QUEUE = 'ADD_TO_UPLOAD_QUEUE'
const UPLOAD_FILE = 'UPLOAD_FILE'
const UPLOAD_PROGRESS = 'UPLOAD_PROGRESS'
export const RECEIVE_UPLOAD_SUCCESS = 'RECEIVE_UPLOAD_SUCCESS'
export const RECEIVE_UPLOAD_ERROR = 'RECEIVE_UPLOAD_ERROR'
const PURGE_UPLOAD_QUEUE = 'PURGE_UPLOAD_QUEUE'
const RESOLVE_FOLDER_ITEMS = 'RESOLVE_FOLDER_ITEMS'
const CANCEL = 'cancel'
const PENDING = 'pending'
const LOADING = 'loading'
const CREATED = 'created'
const UPDATED = 'updated'
const FAILED = 'failed'
const CONFLICT = 'conflict'
const QUOTA = 'quota'
const NETWORK = 'network'
const UNREADABLE = 'unreadable'
// Placeholder status for a top-level folder drop while its tree is
// being walked and folders are being created server-side. Replaced by
// real PENDING items once flattenEntries completes.
const RESOLVING = 'resolving'
// Mirrors cozy-stack's ErrMaxFileSize message so client-side pre-flight
// rejections look identical to server-side ones and funnel through the
// same classifier branch.
const ERR_MAX_FILE_SIZE =
'The file is too big and exceeds the filesystem maximum file size'
const DONE_STATUSES = [CREATED, UPDATED]
const ERROR_STATUSES = [CONFLICT, NETWORK, QUOTA, FAILED, UNREADABLE]
const IN_PROGRESS_STATUSES = [PENDING, RESOLVING]
export const status = {
CANCEL,
PENDING,
LOADING,
CREATED,
UPDATED,
FAILED,
CONFLICT,
QUOTA,
NETWORK,
UNREADABLE,
RESOLVING,
DONE_STATUSES,
ERROR_STATUSES,
ERR_MAX_FILE_SIZE
}
const CONFLICT_ERROR = 409
const PAYLOAD_TOO_LARGE = 413
// `status` is preserved when set on the input (folder placeholder rows
// arrive with status: RESOLVING); real file items have no status and
// default to PENDING so processNextFile picks them up.
const itemInitialState = item => ({
...item,
status: item.status || PENDING,
progress: null
})
const getStatus = (state, action) => {
switch (action.type) {
case UPLOAD_FILE:
return LOADING
case RECEIVE_UPLOAD_SUCCESS:
return action.isUpdate ? UPDATED : CREATED
case RECEIVE_UPLOAD_ERROR:
return action.status
default:
return state
}
}
const getSpeed = (state, action) => {
const lastLoaded = state.loaded
const lastUpdated = state.lastUpdated
const now = action.date
const nowLoaded = action.loaded
return ((nowLoaded - lastLoaded) / (now - lastUpdated)) * 1000
}
let remainingTimes = []
let averageRemainingTime = undefined
let timeout = undefined
const getProgress = (state, action) => {
if (action.type == RECEIVE_UPLOAD_SUCCESS) {
return null
} else if (action.type === UPLOAD_PROGRESS) {
const speed = state ? getSpeed(state, action) : null
const loaded = action.loaded
const total = action.total
const instantRemainingTime =
speed && total && loaded ? (total - loaded) / speed : null
if (!averageRemainingTime) {
averageRemainingTime = instantRemainingTime
}
if (instantRemainingTime) {
remainingTimes.push(instantRemainingTime)
}
if (!timeout) {
timeout = setTimeout(() => {
averageRemainingTime =
remainingTimes.reduce((a, b) => a + b, 0) / remainingTimes.length
clearTimeout(timeout)
timeout = undefined
remainingTimes = []
}, 3000)
}
return {
loaded,
total,
lastUpdated: action.date,
speed,
remainingTime: averageRemainingTime
}
} else if (action.type === RECEIVE_UPLOAD_ERROR) {
return null
} else {
return state
}
}
const item = (state, action = { isUpdate: false }) => {
const resolvedUploadedItem =
action.uploadedItem !== undefined
? action.uploadedItem
: state?.uploadedItem
return {
...state,
status: getStatus(state.status, action),
progress: getProgress(state.progress, action),
...(resolvedUploadedItem !== undefined
? { uploadedItem: resolvedUploadedItem }
: {})
}
}
export const queue = (state = [], action) => {
switch (action.type) {
case ADD_TO_UPLOAD_QUEUE:
return [
...state.filter(i => i.status !== CREATED),
...action.files.map(f => itemInitialState(f))
]
case RESOLVE_FOLDER_ITEMS: {
const placeholderIds = new Set(action.placeholderIds)
const filtered = state.filter(i => !placeholderIds.has(i.fileId))
// If purgeUploadQueue ran while flattenEntries was in flight, the
// placeholders are gone — drop the resolved files too so a cancelled
// drop doesn't silently re-fill the queue and resume uploading.
if (filtered.length === state.length) return state
return [...filtered, ...action.files.map(f => itemInitialState(f))]
}
case PURGE_UPLOAD_QUEUE:
return []
case UPLOAD_FILE:
case RECEIVE_UPLOAD_SUCCESS:
case RECEIVE_UPLOAD_ERROR:
case UPLOAD_PROGRESS: {
// No matching row (e.g. the queue was purged before a stale
// dispatch landed): return the same reference so connected
// consumers don't re-render needlessly.
if (!state.some(i => i.fileId === action.fileId)) return state
return state.map(i => (i.fileId !== action.fileId ? i : item(i, action)))
}
default:
return state
}
}
export default combineReducers({
queue
})
export const uploadProgress = (fileId, event, date) => ({
type: UPLOAD_PROGRESS,
fileId,
loaded: event.loaded,
total: event.total,
date: date || Date.now()
})
/**
* Upload a single pending queue item: resolve its target directory
* (server-side folder id if the item came from a flattened folder drop,
* otherwise the caller-supplied `dirID`) and dispatch the upload
* lifecycle actions.
*
* Kept separate from {@link processNextFile} so the outer thunk only
* has the queue-draining loop and error funnel.
*
* @param {{file: File, fileId: string, folderId?: string}} pendingItem
* @param {object} client - cozy-client instance
* @param {string} dirID - Fallback directory when the item has no folderId
* @param {string} [driveId]
* @param {{dispatch: Function, safeCallback: Function}} io
*/
const uploadPendingItem = async (
pendingItem,
client,
dirID,
driveId,
{ dispatch, safeCallback }
) => {
const { file, fileId, folderId } = pendingItem
const targetDirId = folderId ?? dirID
try {
dispatch({ type: UPLOAD_FILE, fileId, file })
const onUploadProgress = event => dispatch(uploadProgress(fileId, event))
const { data: uploadedFile, isUpdate } = await uploadOrOverwriteFile(
client,
file,
targetDirId,
{ onUploadProgress },
driveId
)
safeCallback(uploadedFile)
dispatch({
type: RECEIVE_UPLOAD_SUCCESS,
fileId,
file,
isUpdate,
uploadedItem: uploadedFile
})
} catch (error) {
logger.error(
`Upload module catches an error when executing processNextFile(): ${error}`
)
dispatch({
type: RECEIVE_UPLOAD_ERROR,
fileId,
file,
status: classifyUploadError(error)
})
}
}
export const processNextFile =
(
fileUploadedCallback,
queueCompletedCallback,
dirID,
sharingState,
{ client },
driveId,
addItems
) =>
async (dispatch, getState) => {
const safeCallback =
typeof fileUploadedCallback === 'function'
? fileUploadedCallback
: () => {}
if (!client) {
throw new Error(
'Upload module needs a cozy-client instance to work. This instance should be made available by using the extraArgument function of redux-thunk'
)
}
const pendingItem = getUploadQueue(getState()).find(
i => i.status === PENDING
)
if (!pendingItem) {
return dispatch(onQueueEmpty(queueCompletedCallback))
}
await uploadPendingItem(pendingItem, client, dirID, driveId, {
dispatch,
safeCallback
})
dispatch(
processNextFile(
fileUploadedCallback,
queueCompletedCallback,
dirID,
sharingState,
{ client },
driveId,
addItems
)
)
}
const getFileFromEntry = entry =>
new Promise((resolve, reject) => entry.file(resolve, reject))
const readNextBatch = dirReader =>
new Promise((resolve, reject) => dirReader.readEntries(resolve, reject))
/**
* Read all entries from a directory upfront so file entries can be
* converted to File objects before uploads start. This prevents
* NotFoundError when the browser discards stale FileSystemEntry
* references during long sequential uploads of large directories.
*/
const readAllEntries = async dirReader => {
const entries = []
let batch
while ((batch = await readNextBatch(dirReader)).length > 0) {
entries.push(...batch)
}
return entries
}
const resolveFullpath = (client, dirID, name, driveId) =>
driveId
? getFullpath(client, dirID, name, driveId)
: CozyFile.getFullpath(dirID, name)
const getExistingDirectory = async (client, dirID, name, driveId) => {
const path = await resolveFullpath(client, dirID, name, driveId)
const statResp = await client
.collection(DOCTYPE_FILES, { driveId })
.statByPath(path)
if (statResp.data.type !== 'directory') {
throw new Error(`"${path}" already exists and is not a directory`)
}
return statResp.data
}
/**
* Map an upload failure to one of the queue item error statuses.
*
* Precedence matters: the Chrome pre-flight in `uploadFile` throws an
* `Error` whose message is a JSON blob carrying `status: 413` (no such
* property on the error itself), so the `ERR_MAX_FILE_SIZE` message
* match has to run before the `PAYLOAD_TOO_LARGE` status check. The
* plain disk-usage guard in the same function throws with
* `error.status = PAYLOAD_TOO_LARGE`, which the later branch catches.
*
* @param {Error} error
* @returns {string} One of the values exported in `status`.
*/
const classifyUploadError = error => {
if (error.name === 'NotFoundError') return UNREADABLE
if (error.message?.includes(ERR_MAX_FILE_SIZE)) return ERR_MAX_FILE_SIZE
if (error.status === CONFLICT_ERROR) return CONFLICT
if (error.status === PAYLOAD_TOO_LARGE) return QUOTA
if (/Failed to fetch$/.test(error.toString())) return NETWORK
return FAILED
}
/**
* Upload a file, or silently overwrite the existing version on 409.
*
* @param {object} client - cozy-client instance
* @param {File} file
* @param {string} dirID
* @param {{onUploadProgress?: Function}} options
* @param {string} [driveId]
* @returns {Promise<{data: object, isUpdate: boolean}>} The created
* (`isUpdate: false`) or overwritten (`isUpdate: true`) file document.
*/
const uploadOrOverwriteFile = async (client, file, dirID, options, driveId) => {
try {
const data = await uploadFile(client, file, dirID, options, driveId)
return { data, isUpdate: false }
} catch (err) {
if (err.status !== CONFLICT_ERROR) throw err
const path = await resolveFullpath(client, dirID, file.name, driveId)
const data = await overwriteFile(client, file, path, options, driveId)
return { data, isUpdate: true }
}
}
/**
* Create a folder, or return the existing one on 409.
*
* Used by the flatten helpers when walking a dropped folder tree: we
* reuse an existing same-name directory instead of failing.
* `getExistingDirectory` throws a plain `Error` (no `status`) if a
* non-directory sits at that name, which bubbles up as a normal upload
* failure rather than being silently overwritten.
*
* @param {object} client - cozy-client instance
* @param {string} name
* @param {string} dirID - Parent directory id
* @param {string} [driveId]
* @returns {Promise} The `io.cozy.files` document of the created
* or existing directory.
*/
const createFolderOrGetExisting = async (client, name, dirID, driveId) => {
try {
return await createFolder(client, name, dirID, driveId)
} catch (err) {
if (err.status !== CONFLICT_ERROR) throw err
return getExistingDirectory(client, dirID, name, driveId)
}
}
const createFolder = async (client, name, dirID, driveId) => {
const resp = await client
.collection(DOCTYPE_FILES, { driveId })
.createDirectory({ name, dirId: dirID })
return resp.data
}
const uploadFile = async (client, file, dirID, options = {}, driveId) => {
/** We have a bug with Chrome returning SPDY_ERROR_PROTOCOL.
* This is certainly caused by the couple HTTP2 / HAProxy / CozyStack
* when something cut the HTTP connexion before the Stack
*
* We can not intercept this error since Chrome only returns
* `Failed to fetch` as if we were offline. The only workaround for
* now, is to check if we'll have enough size on the Cozy before
* trying to upload the file to detect if we'll go out of quota
* before connexion being cut by something.
*
* We don't need to do that work on other browser (window.chrome
* should be available on new Edge, Chrome, Chromium, Brave, Opera...)
*/
if (window.chrome) {
const fileSize = parseInt(file.size, 10)
if (fileSize > MAX_PAYLOAD_SIZE) {
// Match cozy-stack error format
throw new Error(
JSON.stringify({
status: PAYLOAD_TOO_LARGE,
title: 'Request Entity Too Large',
detail: ERR_MAX_FILE_SIZE
})
)
}
const { data: diskUsage } = await client
.getStackClient()
.fetchJSON('GET', '/settings/disk-usage')
if (diskUsage.attributes.quota) {
const usedSpace = parseInt(diskUsage.attributes.used, 10)
const totalQuota = parseInt(diskUsage.attributes.quota, 10)
const availableSpace = totalQuota - usedSpace
if (fileSize > availableSpace) {
const error = new Error('Insufficient Disk Space')
error.status = PAYLOAD_TOO_LARGE
throw error
}
}
}
const { onUploadProgress } = options
const resp = await client
.collection(DOCTYPE_FILES, { driveId })
.createFile(file, { dirId: dirID, onUploadProgress })
return resp.data
}
/**
* @param {object} client - A CozyClient instance
* @param {File} file - The javascript File object to upload
* @param {string} path - The target file's path in the cozy
* @param {{onUploadProgress: Function}} [options]
* @param {string} [driveId] - Shared drive id
* @returns {Promise} The updated io.cozy.files document
*/
export const overwriteFile = async (
client,
file,
path,
options = {},
driveId = null
) => {
const statResp = await client
.collection(DOCTYPE_FILES, { driveId })
.statByPath(path)
const { id: fileId } = statResp.data
// updateFile destructures known param keys (fileId, name, …) and
// treats the rest as upload options (onUploadProgress, etc.) — so
// they must sit at the top level of the second argument, not nested
// under an `options` key.
const resp = await client
.collection(DOCTYPE_FILES, { driveId })
.updateFile(file, { fileId, ...options })
return resp.data
}
/**
* Build a flat queue item.
*
* `fileId` is the identity the reducer uses for progress/success/error
* updates, so it must be unique per item. The relative path (or bare
* filename for loose files) makes two `img.jpg`s in different folders
* distinct, but the same drop can be made twice in a row — the nonce
* scopes the id to one drop so two `photos/img.jpg` items from two
* drops don't collide and have a single dispatch flip both rows.
*
* @param {File} file
* @param {string} folderId - Server id of the folder the file goes into
* @param {string|null} relativePath - `"photos/2024/img.jpg"` when the
* file came from a dropped folder, `null` for loose files
* @param {string} nonce - Per-drop nonce; `''` keeps the legacy id
* shape for callers that don't care about cross-drop uniqueness.
* @returns {{fileId: string, file: File, relativePath: string|null, folderId: string}}
*/
const makeFlatItem = (file, folderId, relativePath = null, nonce = '') => {
const base = relativePath ?? file.name
return {
fileId: nonce ? `${nonce}_${base}` : base,
file,
relativePath,
folderId
}
}
/**
* Build a queue item representing an entry we couldn't read locally.
*
* The status is preset (via {@link classifyUploadError}) so the reducer
* keeps it instead of promoting the row to PENDING. A `NotFoundError`
* lands on UNREADABLE (firing the unreadable-files alert downstream);
* permission / generic I/O errors land on FAILED rather than being
* silently mislabelled. The synthetic `file` shim carries the entry's
* display name so the upload tray can render the row even though we
* never obtained a real `File` object. `isDirectory` is propagated so
* the queue UI renders the folder glyph for unreadable directories,
* making them visually distinct from unreadable files.
*
* @param {string} name - Local entry name (file or folder)
* @param {string|null} relativePath - Path of the failed entry relative
* to the drop root (e.g. `"a/b/c/d/e"`), or `null` for top-level
* loose entries
* @param {string} nonce - Per-drop nonce, same shape as in {@link makeFlatItem}
* @param {Error} error - The rejection from `readEntries` or `entry.file()`
* @param {boolean} [isDirectory=false] - `true` when the failed entry
* was a directory (its `readEntries` rejected); `false` for a file
* whose `entry.file()` rejected
* @returns {{fileId: string, file: {name: string, type: string},
* relativePath: string|null, folderId: null, status: string,
* isDirectory: boolean}}
*/
const makeFailedItem = (
name,
relativePath,
nonce,
error,
isDirectory = false
) => {
const base = relativePath ?? name
return {
fileId: nonce ? `${nonce}_${base}` : base,
file: { name, type: '' },
relativePath,
folderId: null,
isDirectory,
status: classifyUploadError(error)
}
}
/**
* @typedef {object} WalkedNode
* @property {string} name - Local entry name
* @property {true} [readFailed] - Set when `readEntries` rejected; the
* node has no children and `error` carries the rejection
* @property {Error} [error] - Present iff `readFailed` is true
* @property {File[]} [files] - Successfully extracted `File` objects
* @property {Array<{name: string, error: Error}>} [failedFiles] -
* Child file entries whose `entry.file()` rejected
* @property {WalkedNode[]} [subdirs] - Recursively-walked subdirectories
*/
/**
* Walk a `FileSystemDirectoryEntry` locally without touching the
* server. Read failures (long-path `NotFoundError` on Windows, vanished
* entries) are captured per-node instead of thrown, so the materialize
* step can finish creating the surrounding tree and surface failed
* reads as queue rows.
*
* Files within a directory are extracted via `Promise.all` (parallel),
* matching the original code's concurrency. Subdirs are recursed into
* sequentially to keep the parallelism bounded on wide trees.
*
* @param {FileSystemDirectoryEntry} dirEntry
* @returns {Promise}
*/
const walkDirectoryEntry = async dirEntry => {
let childEntries
try {
childEntries = await readAllEntries(dirEntry.createReader())
} catch (error) {
return { name: dirEntry.name, readFailed: true, error }
}
const fileEntries = childEntries.filter(c => c.isFile)
const subdirEntries = childEntries.filter(c => c.isDirectory)
const files = []
const failedFiles = []
const filePromises = fileEntries.map(c =>
getFileFromEntry(c).then(
f => files.push(f),
error => failedFiles.push({ name: c.name, error })
)
)
await Promise.all(filePromises)
const subdirs = []
for (const sub of subdirEntries) {
subdirs.push(await walkDirectoryEntry(sub))
}
return { name: dirEntry.name, files, failedFiles, subdirs }
}
/**
* @typedef {{fileId: string, file: File, relativePath: string|null, folderId: string}} ReadableFlatItem
* @typedef {{fileId: string, file: {name: string, type: string},
* relativePath: string|null, folderId: null, status: string,
* isDirectory: boolean}} FailedFlatItem
*/
/**
* Materialize a walked tree: create the server folder for every node
* we visited (including empty ones and ones whose `readEntries` failed
* locally), emit a flat queue item per readable file, and emit one
* error row per failed read (folder or file).
*
* Folders are created unconditionally so the resulting tree in Drive
* matches the shape that was dropped. Empty folders survive the round
* trip; folders whose contents couldn't be read appear empty AND carry
* a queue row pointing at the missing subtree so the user can drop the
* files back in by hand.
*
* @param {WalkedNode} node - A node returned by {@link walkDirectoryEntry}
* @param {string} parentDirId - Server id of the enclosing directory
* (i.e. the dir into which this node will be created)
* @param {string} pathPrefix - Relative path accumulated so far,
* without a trailing slash; `''` at the drop root
* @param {{client: object, driveId?: string, nonce: string}} ctx -
* Drop-invariant deps grouped to keep the recursion-changing args
* (`node`, `parentDirId`, `pathPrefix`) positional and short
* @returns {Promise>}
*/
const materializeNode = async (node, parentDirId, pathPrefix, ctx) => {
const newPrefix = pathPrefix ? `${pathPrefix}/${node.name}` : node.name
const newDir = await createFolderOrGetExisting(
ctx.client,
node.name,
parentDirId,
ctx.driveId
)
if (node.readFailed) {
return [makeFailedItem(node.name, newPrefix, ctx.nonce, node.error, true)]
}
const items = node.failedFiles.map(ff =>
makeFailedItem(
ff.name,
`${newPrefix}/${ff.name}`,
ctx.nonce,
ff.error,
false
)
)
for (const file of node.files) {
items.push(
makeFlatItem(file, newDir.id, `${newPrefix}/${file.name}`, ctx.nonce)
)
}
for (const sub of node.subdirs) {
const subItems = await materializeNode(sub, newDir.id, newPrefix, ctx)
items.push(...subItems)
}
return items
}
/**
* Build a memoised `ensureFolder(path)` function that creates (or
* reuses) nested folders under `rootDirId`, one server call per unique
* path segment.
*
* @param {string} rootDirId
* @param {object} client - cozy-client instance
* @param {string} [driveId]
* @returns {(folderPath: string) => Promise} Resolves to the
* server id of the folder at `folderPath` (relative to root).
*/
const makeFolderResolver = (rootDirId, client, driveId) => {
const cache = new Map([['', rootDirId]])
const ensure = async folderPath => {
if (cache.has(folderPath)) return cache.get(folderPath)
const lastSlash = folderPath.lastIndexOf('/')
const parentPath = lastSlash > 0 ? folderPath.slice(0, lastSlash) : ''
const name = lastSlash > 0 ? folderPath.slice(lastSlash + 1) : folderPath
const parentId = await ensure(parentPath)
const folder = await createFolderOrGetExisting(
client,
name,
parentId,
driveId
)
cache.set(folderPath, folder.id)
return folder.id
}
return ensure
}
/**
* Flatten a mixed list of dropped entries into per-file queue items.
*
* Three entry shapes are handled in a single pass:
* - `{isDirectory: true, entry}` — a `FileSystemEntry` from drag-and-drop;
* walked locally first via {@link walkDirectoryEntry}, then realized
* server-side via {@link materializeNode}. Read failures along the
* walk become per-entry UNREADABLE rows instead of throwing, so a
* long-path NotFoundError on Windows doesn't leave orphan folders
* server-side.
* - `{file}` whose `file.path` contains a `/` — a react-dropzone /
* file-selector File with a relative path; folders are created on the
* fly via the path-based resolver.
* - `{file}` with no folder structure — placed directly under `rootDirId`.
*
* Intermediate folders are created (or reused on 409) server-side
* before any file upload starts, so `processNextFile` only ever handles
* single-file items and there is exactly one place in the module that
* resolves folder conflicts.
*
* @param {Array<{file: File|null, isDirectory?: boolean, entry?: FileSystemEntry|null}>} entries
* @param {string} rootDirId - Directory id where the drop happened
* @param {object} client - cozy-client instance
* @param {string} [driveId]
* @returns {Promise>}
*/
export const flattenEntries = async (
entries,
rootDirId,
client,
driveId,
nonce = ''
) => {
const ensureFolder = makeFolderResolver(rootDirId, client, driveId)
const result = []
for (const entry of entries) {
if (entry.isDirectory && entry.entry) {
const tree = await walkDirectoryEntry(entry.entry)
const subItems = await materializeNode(tree, rootDirId, '', {
client,
driveId,
nonce
})
result.push(...subItems)
continue
}
const file = entry.file
if (!file) continue
const raw = file.path || ''
const cleanPath = raw.startsWith('/') ? raw.slice(1) : raw
if (!cleanPath.includes('/')) {
result.push(makeFlatItem(file, rootDirId, null, nonce))
} else {
const folderPath = cleanPath.slice(0, cleanPath.lastIndexOf('/'))
const folderId = await ensureFolder(folderPath)
result.push(makeFlatItem(file, folderId, cleanPath, nonce))
}
}
return result
}
export const removeFileToUploadQueue = file => async dispatch => {
dispatch({
type: RECEIVE_UPLOAD_SUCCESS,
fileId: file.name,
file,
isUpdate: true
})
}
/**
* An entry is "deferred" if it needs flattening — either a directory
* drag-drop or a react-dropzone file with a folder structure encoded
* in its `path`. Loose top-level files are NOT deferred and can be
* queued immediately as PENDING items.
*/
const isDeferredEntry = entry => {
if (entry.isDirectory && entry.entry) return true
const path = entry.file?.path
if (!path) return false
const cleanPath = path.startsWith('/') ? path.slice(1) : path
return cleanPath.includes('/')
}
/**
* Identify the top-level folder names a drop will create. Used to seed
* "resolving" placeholder rows so the upload tray appears immediately,
* before the (potentially slow) tree walk creates folders server-side.
*
* @param {Array<{file: File|null, isDirectory?: boolean, entry?: object|null}>} entries
* @returns {string[]} Unique top-level folder names
*/
const collectFolderRoots = entries => {
const roots = new Set()
for (const e of entries) {
if (e.isDirectory && e.entry) {
roots.add(e.entry.name)
continue
}
const path = e.file?.path
if (!path) continue
const cleanPath = path.startsWith('/') ? path.slice(1) : path
if (cleanPath.includes('/')) {
roots.add(cleanPath.split('/')[0])
}
}
return [...roots]
}
// Placeholder ids include a per-drop nonce so two `photos` folders
// dropped back-to-back can't collide on the same queue identity, and
// an index so two same-named folders inside one drop stay distinct.
const placeholderId = (name, index, nonce) =>
`__pending_${nonce}_${index}_${name}__`
const buildFolderPlaceholder = (name, index, nonce) => ({
fileId: placeholderId(name, index, nonce),
file: { name, type: '' },
relativePath: null,
folderId: null,
isDirectory: true,
status: RESOLVING
})
export const addToUploadQueue =
(
entries,
dirID,
sharingState,
fileUploadedCallback,
queueCompletedCallback,
{ client, maxFileCount, onLimitExceeded },
driveId,
addItems
) =>
async dispatch => {
const folderRoots = collectFolderRoots(entries)
const dropNonce = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`
const placeholders = folderRoots.map((name, i) =>
buildFolderPlaceholder(name, i, dropNonce)
)
const placeholderIds = placeholders.map(p => p.fileId)
const deferredEntries = entries.filter(isDeferredEntry)
const looseItems = entries
.filter(e => !isDeferredEntry(e) && e.file)
.map(e => makeFlatItem(e.file, dirID, null, dropNonce))
const allDropIds = [...placeholderIds, ...looseItems.map(i => i.fileId)]
const kickProcessing = () =>
dispatch(
processNextFile(
fileUploadedCallback,
queueCompletedCallback,
dirID,
sharingState,
{ client },
driveId,
addItems
)
)
const failDrop = errStatus => {
for (const fileId of allDropIds) {
dispatch({ type: RECEIVE_UPLOAD_ERROR, fileId, status: errStatus })
}
}
// Mark every row for this drop failed and kick so processNextFile
// hits an empty PENDING set and runs onQueueEmpty → the upstream
// queueCompletedCallback surfaces the right toast.
const failAndKick = error => {
failDrop(classifyUploadError(error))
kickProcessing()
}
const initialItems = [...placeholders, ...looseItems]
if (initialItems.length > 0) {
dispatch({ type: ADD_TO_UPLOAD_QUEUE, files: initialItems })
}
try {
if (
typeof maxFileCount === 'number' &&
(await exceedsFileLimit(entries, maxFileCount))
) {
// Modal is the user-facing feedback; the dropped rows stay in
// the tray as FAILED so the user sees what was rejected. We
// run the limit check before kicking processing, so loose
// files don't quietly get uploaded behind the modal. No
// kickProcessing here keeps queueCompletedCallback silent and
// avoids a redundant toast over the modal.
failDrop(FAILED)
if (typeof onLimitExceeded === 'function') onLimitExceeded()
return
}
} catch (error) {
failAndKick(error)
return
}
if (looseItems.length > 0) kickProcessing()
if (deferredEntries.length === 0) return
try {
const flatItems = await flattenEntries(
deferredEntries,
dirID,
client,
driveId,
dropNonce
)
dispatch({ type: RESOLVE_FOLDER_ITEMS, placeholderIds, files: flatItems })
kickProcessing()
} catch (error) {
failAndKick(error)
}
}
export const purgeUploadQueue = () => ({ type: PURGE_UPLOAD_QUEUE })
export const onQueueEmpty = callback => (dispatch, getState) => {
const safeCallback = typeof callback === 'function' ? callback : () => {}
const queue = getUploadQueue(getState())
// While folder placeholders are still being resolved, the queue isn't
// really empty; suppress the completion callback so the per-drop alert
// doesn't fire mid-flatten. The chain ends silently here; the
// addToUploadQueue thunk re-kicks processNextFile after the matching
// RESOLVE_FOLDER_ITEMS dispatch.
if (queue.some(i => i.status === RESOLVING)) return
const quotas = getQuotaErrors(queue)
const conflicts = getConflicts(queue)
const created = getCreated(queue)
const updated = getUpdated(queue)
const networkErrors = getNetworkErrors(queue)
const errors = getErrors(queue)
const unreadableErrors = getUnreadableErrors(queue)
const fileTooLargeErrors = getfileTooLargeErrors(queue)
const createdItems = created
.map(item => item.uploadedItem)
.filter(item => item && item._id)
const updatedItems = updated
.map(item => item.uploadedItem)
.filter(item => item && item._id)
return safeCallback({
createdItems,
quotas,
conflicts,
networkErrors,
errors,
unreadableErrors,
updatedItems,
fileTooLargeErrors
})
}
// selectors
const filterByStatus = (queue, status) => queue.filter(f => f.status === status)
const getConflicts = queue => filterByStatus(queue, CONFLICT)
const getErrors = queue => filterByStatus(queue, FAILED)
const getQuotaErrors = queue => filterByStatus(queue, QUOTA)
const getNetworkErrors = queue => filterByStatus(queue, NETWORK)
const getUnreadableErrors = queue => filterByStatus(queue, UNREADABLE)
const getCreated = queue => filterByStatus(queue, CREATED)
const getUpdated = queue => filterByStatus(queue, UPDATED)
const getfileTooLargeErrors = queue => filterByStatus(queue, ERR_MAX_FILE_SIZE)
export const getUploadQueue = state => state[SLUG].queue
export const getProcessed = state =>
getUploadQueue(state).filter(f => !IN_PROGRESS_STATUSES.includes(f.status))
export const getSuccessful = state => {
const queue = getUploadQueue(state)
return queue.filter(f => [CREATED, UPDATED].includes(f.status))
}
export const selectors = {
getConflicts,
getErrors,
getQuotaErrors,
getNetworkErrors,
getUnreadableErrors,
getCreated,
getUpdated,
getProcessed,
getSuccessful
}
// DOM helpers
export const extractFilesEntries = items => {
let results = []
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (item.webkitGetAsEntry != null && item.webkitGetAsEntry()) {
const entry = item.webkitGetAsEntry()
results.push({
file: item.getAsFile(),
isDirectory: entry.isDirectory === true,
entry
})
} else {
results.push({ file: item, isDirectory: false, entry: null })
}
}
if (results.length === 0) {
logger.warn('Upload module files entries extraction: no file entry')
}
return results
}
/**
* Recursively count all files inside a directory entry.
*
* @param {FileSystemDirectoryEntry} directoryEntry - A directory obtained from the drag-and-drop FileSystem API
* @returns {Promise} Total number of files (excluding sub-directories themselves)
*/
const countDirectoryFiles = async directoryEntry => {
const reader = directoryEntry.createReader()
const childEntries = await readAllEntries(reader)
let count = 0
for (const entry of childEntries) {
if (entry.isFile) {
count += 1
} else if (entry.isDirectory) {
count += await countDirectoryFiles(entry)
}
}
return count
}
/**
* Check whether the total number of files in the given entries exceeds
* the provided limit. Directories are counted in parallel for speed.
* Flat files are checked first to avoid directory traversal when possible.
*
* @param {Array<{file: File, isDirectory: boolean, entry: FileSystemEntry|null}>} entries - Extracted entries from {@link extractFilesEntries}
* @param {number} limit - Maximum number of files allowed
* @returns {Promise} `true` if the file count exceeds the limit
*/
export const exceedsFileLimit = async (entries, limit) => {
const fileCount = entries.filter(e => !e.isDirectory || !e.entry).length
const directories = entries.filter(e => e.isDirectory && e.entry)
if (fileCount > limit) return true
const dirCounts = await Promise.all(
directories.map(e => countDirectoryFiles(e.entry))
)
let count = fileCount
for (const dirCount of dirCounts) {
count += dirCount
if (count > limit) return true
}
return false
}
================================================
FILE: src/modules/upload/index.spec.js
================================================
import {
processNextFile,
selectors,
queue,
overwriteFile,
uploadProgress,
extractFilesEntries,
exceedsFileLimit,
flattenEntries,
addToUploadQueue,
onQueueEmpty
} from './index'
import logger from '@/lib/logger'
import { CozyFile } from '@/models'
jest.mock('cozy-doctypes')
const createFileSpy = jest.fn().mockName('createFile')
const createDirectorySpy = jest.fn().mockName('createDirectory')
const statByPathSpy = jest.fn().mockName('statByPath')
const updateFileSpy = jest.fn().mockName('updateFile')
const fakeClient = {
collection: () => ({
createFile: createFileSpy,
createDirectory: createDirectorySpy,
statByPath: statByPathSpy,
updateFile: updateFileSpy
}),
query: jest.fn()
}
CozyFile.getFullpath.mockResolvedValue('/my-dir/mydoc.odt')
describe('processNextFile function', () => {
const fileUploadedCallbackSpy = jest.fn()
const queueCompletedCallbackSpy = jest.fn()
const dirId = 'my-dir'
const dispatchSpy = jest.fn(x => x)
const file = new File(['foo'], 'my-doc.odt')
const sharingState = {
sharedPaths: []
}
fakeClient.query.mockResolvedValueOnce(null)
it('should handle an empty queue', async () => {
const getState = () => ({
upload: {
queue: []
}
})
const asyncProcess = processNextFile(
fileUploadedCallbackSpy,
queueCompletedCallbackSpy,
dirId,
sharingState,
{ client: fakeClient }
)
const result = await asyncProcess(dispatchSpy, getState, {
client: fakeClient
})
result(dispatchSpy, getState)
expect(queueCompletedCallbackSpy).toHaveBeenCalledWith({
createdItems: [],
quotas: [],
conflicts: [],
networkErrors: [],
errors: [],
unreadableErrors: [],
updatedItems: [],
fileTooLargeErrors: []
})
})
it('should process files in the queue', async () => {
const getState = () => ({
upload: {
queue: [
{
status: 'pending',
file,
entry: '',
isDirectory: false
}
]
}
})
createFileSpy.mockResolvedValue({
data: {
file
}
})
const asyncProcess = processNextFile(
fileUploadedCallbackSpy,
queueCompletedCallbackSpy,
dirId,
sharingState,
{ client: fakeClient }
)
await asyncProcess(dispatchSpy, getState)
expect(dispatchSpy).toHaveBeenCalledWith({
type: 'UPLOAD_FILE',
file
})
expect(createFileSpy).toHaveBeenCalledWith(file, {
dirId: 'my-dir',
onUploadProgress: expect.any(Function)
})
})
it('should process a file in conflict', async () => {
const getState = () => ({
upload: {
queue: [
{
status: 'pending',
file,
entry: '',
isDirectory: false
}
]
}
})
createFileSpy.mockRejectedValue({
status: 409,
title: 'Conflict',
detail: 'file already exists',
source: {}
})
statByPathSpy.mockResolvedValue({
data: {
dir_id: 'my-dir',
id: 'b552a167-1aa4'
}
})
updateFileSpy.mockResolvedValue({ data: file })
const asyncProcess = processNextFile(
fileUploadedCallbackSpy,
queueCompletedCallbackSpy,
dirId,
sharingState,
{ client: fakeClient }
)
await asyncProcess(dispatchSpy, getState)
expect(dispatchSpy).toHaveBeenNthCalledWith(1, {
type: 'UPLOAD_FILE',
file
})
expect(createFileSpy).toHaveBeenCalledWith(file, {
dirId: 'my-dir',
onUploadProgress: expect.any(Function)
})
expect(updateFileSpy).toHaveBeenCalledWith(file, {
fileId: 'b552a167-1aa4',
onUploadProgress: expect.any(Function)
})
expect(fileUploadedCallbackSpy).toHaveBeenCalledWith(file)
expect(dispatchSpy).toHaveBeenNthCalledWith(2, {
type: 'RECEIVE_UPLOAD_SUCCESS',
file,
isUpdate: true,
uploadedItem: file
})
})
it('should handle an error during overwrite', async () => {
logger.error = jest.fn()
const getState = () => ({
upload: {
queue: [
{
status: 'pending',
file,
entry: '',
isDirectory: false
}
]
}
})
createFileSpy.mockRejectedValue({
status: 409,
title: 'Conflict',
detail: 'file already exists',
source: {}
})
statByPathSpy.mockResolvedValue({
data: {
id: 'b552a167-1aa4'
}
})
updateFileSpy.mockRejectedValue({ status: 413 })
const asyncProcess = processNextFile(
fileUploadedCallbackSpy,
queueCompletedCallbackSpy,
dirId,
sharingState,
{ client: fakeClient }
)
await asyncProcess(dispatchSpy, getState, { client: fakeClient })
expect(fileUploadedCallbackSpy).not.toHaveBeenCalled()
expect(dispatchSpy).toHaveBeenNthCalledWith(2, {
file,
status: 'quota',
type: 'RECEIVE_UPLOAD_ERROR'
})
})
it('should handle an error during upload', async () => {
logger.warn = jest.fn()
const getState = () => ({
upload: {
queue: [
{
status: 'pending',
file,
entry: '',
isDirectory: false
}
]
}
})
createFileSpy.mockRejectedValue({
status: 413,
title: 'QUOTA',
detail: 'QUOTA',
source: {}
})
const asyncProcess = processNextFile(
fileUploadedCallbackSpy,
queueCompletedCallbackSpy,
dirId,
sharingState,
{ client: fakeClient }
)
await asyncProcess(dispatchSpy, getState, { client: fakeClient })
expect(fileUploadedCallbackSpy).not.toHaveBeenCalled()
expect(dispatchSpy).toHaveBeenNthCalledWith(2, {
file,
status: 'quota',
type: 'RECEIVE_UPLOAD_ERROR'
})
})
it('should classify NotFoundError (browser FileSystem API) as unreadable', async () => {
logger.warn = jest.fn()
const getState = () => ({
upload: {
queue: [
{
status: 'pending',
file,
entry: '',
isDirectory: false
}
]
}
})
const notFoundError = new Error(
'A requested file or directory could not be found at the time an operation was processed.'
)
notFoundError.name = 'NotFoundError'
createFileSpy.mockRejectedValue(notFoundError)
const asyncProcess = processNextFile(
fileUploadedCallbackSpy,
queueCompletedCallbackSpy,
dirId,
sharingState,
{ client: fakeClient }
)
await asyncProcess(dispatchSpy, getState, { client: fakeClient })
expect(fileUploadedCallbackSpy).not.toHaveBeenCalled()
expect(dispatchSpy).toHaveBeenNthCalledWith(2, {
file,
status: 'unreadable',
type: 'RECEIVE_UPLOAD_ERROR'
})
})
})
describe('selectors', () => {
const queue = [
{ status: 'created' },
{ status: 'updated' },
{ status: 'conflict' },
{ status: 'failed' },
{ status: 'quota' },
{ status: 'network' },
{ status: 'pending' }
]
describe('getCreated selector', () => {
it('should return all uploaded items', () => {
const result = selectors.getCreated(queue)
expect(result).toEqual([
{
status: 'created'
}
])
})
})
describe('getUpdated selector', () => {
it('should return all updated items', () => {
const result = selectors.getUpdated(queue)
expect(result).toEqual([
{
status: 'updated'
}
])
})
})
describe('getSuccessful selector', () => {
it('should return all successful items', () => {
const queue = [
{ id: '1', status: 'created' },
{ id: '2', status: 'quota' },
{ id: '3', status: 'conflict' },
{ id: '4', status: 'updated' },
{ id: '5', status: 'failed' },
{ id: '6', status: 'updated' }
]
const state = {
upload: {
queue
}
}
const result = selectors.getSuccessful(state)
expect(result).toEqual([
{ id: '1', status: 'created' },
{ id: '4', status: 'updated' },
{ id: '6', status: 'updated' }
])
})
})
})
describe('queue reducer', () => {
const buildItem = name => ({
fileId: name,
status: 'pending',
file: { name },
progress: null
})
const state = [
buildItem('doc1.odt'),
buildItem('doc2.odt'),
buildItem('doc3.odt')
]
it('should be empty (initial state)', () => {
const result = queue(undefined, {})
expect(result).toEqual([])
})
it('should handle PURGE_UPLOAD_QUEUE action type', () => {
const action = {
type: 'PURGE_UPLOAD_QUEUE'
}
const state = [{ status: 'created', id: '1' }]
const result = queue(state, action)
expect(result).toEqual([])
})
it('drops RESOLVE_FOLDER_ITEMS files when no placeholder remains in state', () => {
const stateIn = [buildItem('unrelated.odt')]
const result = queue(stateIn, {
type: 'RESOLVE_FOLDER_ITEMS',
placeholderIds: ['__pending_abc_0_photos__'],
files: [
{ fileId: 'photos/a.jpg', file: { name: 'a.jpg' }, folderId: 'dir-1' }
]
})
// Same-reference return so connected components don't re-render.
expect(result).toBe(stateIn)
})
it('replaces matched placeholders with files on RESOLVE_FOLDER_ITEMS', () => {
const placeholder = {
fileId: '__pending_abc_0_photos__',
status: 'resolving',
file: { name: 'photos' },
progress: null
}
const result = queue([placeholder], {
type: 'RESOLVE_FOLDER_ITEMS',
placeholderIds: ['__pending_abc_0_photos__'],
files: [
{ fileId: 'photos/a.jpg', file: { name: 'a.jpg' }, folderId: 'dir-1' }
]
})
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
fileId: 'photos/a.jpg',
status: 'pending'
})
})
it('should handle UPLOAD_FILE action type', () => {
const action = {
type: 'UPLOAD_FILE',
fileId: 'doc1.odt'
}
const result = queue(state, action)
expect(result[0]).toMatchObject({ fileId: 'doc1.odt', status: 'loading' })
expect(result[1]).toMatchObject({ fileId: 'doc2.odt', status: 'pending' })
expect(result[2]).toMatchObject({ fileId: 'doc3.odt', status: 'pending' })
})
it('should handle RECEIVE_UPLOAD_SUCCESS action type', () => {
const action = {
type: 'RECEIVE_UPLOAD_SUCCESS',
fileId: 'doc3.odt'
}
const result = queue(state, action)
expect(result[2]).toMatchObject({ fileId: 'doc3.odt', status: 'created' })
})
it('should handle RECEIVE_UPLOAD_SUCCESS action type (update)', () => {
const action = {
type: 'RECEIVE_UPLOAD_SUCCESS',
fileId: 'doc3.odt',
isUpdate: true
}
const result = queue(state, action)
expect(result[2]).toMatchObject({ fileId: 'doc3.odt', status: 'updated' })
})
it('should handle RECEIVE_UPLOAD_ERROR action type', () => {
const action = {
type: 'RECEIVE_UPLOAD_ERROR',
fileId: 'doc2.odt',
status: 'conflict'
}
const result = queue(state, action)
expect(result[1]).toMatchObject({ fileId: 'doc2.odt', status: 'conflict' })
})
describe('progress action', () => {
const fileId = 'doc1.odt'
const date1 = 1000
const date2 = 2000
const event1 = { loaded: 100, total: 400 }
const event2 = { loaded: 200, total: 400 }
it('should handle UPLOAD_PROGRESS', () => {
const result = queue(state, uploadProgress(fileId, event1, date1))
expect(result[0].progress).toEqual({
lastUpdated: date1,
remainingTime: null,
speed: null,
loaded: event1.loaded,
total: event1.total
})
expect(result[1].progress).toBe(null)
})
it('should compute speed and remaining time', () => {
const result = queue(state, uploadProgress(fileId, event1, date1))
expect(result[0].progress.remainingTime).toBe(null)
const result2 = queue(result, uploadProgress(fileId, event2, date2))
expect(result2[0].progress).toEqual({
lastUpdated: expect.any(Number),
loaded: 200,
remainingTime: 2,
speed: 100,
total: 400
})
})
it('should handle upload error', () => {
const result = queue(state, uploadProgress(fileId, event1, date1))
const result2 = queue(result, uploadProgress(fileId, event2, date2))
const result3 = queue(result2, {
type: 'RECEIVE_UPLOAD_ERROR',
fileId
})
expect(result3[0].progress).toEqual(null)
})
})
})
// Helpers to mock browser FileSystem API objects
const createMockFileEntry = (name, content = '') => ({
isFile: true,
isDirectory: false,
name,
file: resolve => resolve(new File([content], name))
})
// A file entry whose file() rejects, simulating Windows long-path
// NotFoundError surfacing during File extraction.
const createUnreadableFileEntry = name => ({
isFile: true,
isDirectory: false,
name,
file: (_resolve, reject) => {
const err = new Error('vanished')
err.name = 'NotFoundError'
reject(err)
}
})
const createMockDirEntry = (name, children) => ({
isFile: false,
isDirectory: true,
name,
createReader: () => {
let read = false
return {
readEntries: resolve => {
if (!read) {
read = true
resolve(children)
} else {
resolve([])
}
}
}
}
})
// A directory entry whose readEntries rejects, simulating Windows
// long-path NotFoundError when enumerating a deep folder.
const createUnreadableDirEntry = name => ({
isFile: false,
isDirectory: true,
name,
createReader: () => ({
readEntries: (_resolve, reject) => {
const err = new Error('path too long')
err.name = 'NotFoundError'
reject(err)
}
})
})
const createBrokenDirEntry = name => ({
isFile: false,
isDirectory: true,
name,
createReader: () => ({
readEntries: (_resolve, reject) => reject(new Error('permission denied'))
})
})
describe('extractFilesEntries', () => {
it('should extract plain File objects', () => {
const files = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')]
const result = extractFilesEntries(files)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
file: files[0],
isDirectory: false,
entry: null
})
})
it('should extract DataTransferItem with file entry', () => {
const file = new File(['a'], 'a.txt')
const fileEntry = { isFile: true, isDirectory: false }
const items = [
{
webkitGetAsEntry: () => fileEntry,
getAsFile: () => file
}
]
const result = extractFilesEntries(items)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
file,
isDirectory: false,
entry: fileEntry
})
})
it('should extract DataTransferItem with directory entry', () => {
const dirEntry = { isFile: false, isDirectory: true }
const items = [
{
webkitGetAsEntry: () => dirEntry,
getAsFile: () => null
}
]
const result = extractFilesEntries(items)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
file: null,
isDirectory: true,
entry: dirEntry
})
})
it('should handle empty items', () => {
const result = extractFilesEntries([])
expect(result).toHaveLength(0)
})
})
describe('exceedsFileLimit', () => {
it('should return false when flat files are under the limit', async () => {
const entries = [
{ file: new File(['a'], 'a.txt'), isDirectory: false, entry: null },
{ file: new File(['b'], 'b.txt'), isDirectory: false, entry: null },
{ file: new File(['c'], 'c.txt'), isDirectory: false, entry: null }
]
expect(await exceedsFileLimit(entries, 500)).toBe(false)
})
it('should return false when total including directories is under the limit', async () => {
const dirEntry = createMockDirEntry('photos', [
createMockFileEntry('img1.jpg'),
createMockFileEntry('img2.jpg')
])
const entries = [
{ file: null, isDirectory: true, entry: dirEntry },
{ file: new File(['a'], 'doc.txt'), isDirectory: false, entry: null }
]
expect(await exceedsFileLimit(entries, 500)).toBe(false)
})
it('should count files in nested directories', async () => {
const subDir = createMockDirEntry('sub', [createMockFileEntry('deep.txt')])
const topDir = createMockDirEntry('top', [
createMockFileEntry('shallow.txt'),
subDir
])
const entries = [{ file: null, isDirectory: true, entry: topDir }]
expect(await exceedsFileLimit(entries, 1)).toBe(true)
expect(await exceedsFileLimit(entries, 2)).toBe(false)
})
it('should return false for empty directories', async () => {
const emptyDir = createMockDirEntry('empty', [])
const entries = [
{ file: null, isDirectory: true, entry: emptyDir },
{ file: new File(['a'], 'a.txt'), isDirectory: false, entry: null }
]
expect(await exceedsFileLimit(entries, 500)).toBe(false)
})
it('should return false for empty entries', async () => {
expect(await exceedsFileLimit([], 500)).toBe(false)
})
it('should return true when flat files alone exceed the limit', async () => {
const entries = Array.from({ length: 600 }, (_, i) => ({
file: new File([''], `file${i}.txt`),
isDirectory: false,
entry: null
}))
expect(await exceedsFileLimit(entries, 500)).toBe(true)
})
it('should return false when files across multiple directories are under the limit', async () => {
const dir1 = createMockDirEntry(
'dir1',
Array.from({ length: 10 }, (_, i) => createMockFileEntry(`a${i}.txt`))
)
const dir2 = createMockDirEntry(
'dir2',
Array.from({ length: 15 }, (_, i) => createMockFileEntry(`b${i}.txt`))
)
const entries = [
{ file: null, isDirectory: true, entry: dir1 },
{ file: null, isDirectory: true, entry: dir2 },
{ file: new File([''], 'root.txt'), isDirectory: false, entry: null }
]
expect(await exceedsFileLimit(entries, 500)).toBe(false)
})
it('should return true when cumulative count across directories exceeds the limit', async () => {
const dir1 = createMockDirEntry(
'dir1',
Array.from({ length: 300 }, (_, i) => createMockFileEntry(`a${i}.txt`))
)
const dir2 = createMockDirEntry(
'dir2',
Array.from({ length: 300 }, (_, i) => createMockFileEntry(`b${i}.txt`))
)
const entries = [
{ file: null, isDirectory: true, entry: dir1 },
{ file: null, isDirectory: true, entry: dir2 }
]
expect(await exceedsFileLimit(entries, 500)).toBe(true)
})
})
describe('overwriteFile function', () => {
beforeEach(() => {
statByPathSpy.mockReset()
updateFileSpy.mockReset()
})
it('should update the io.cozy.files', async () => {
updateFileSpy.mockResolvedValue({
data: {
id: 'b7cb22be72d2',
type: 'io.cozy.files',
attributes: {
type: 'file',
name: 'mydoc.odt'
}
}
})
statByPathSpy.mockResolvedValue({
data: {
id: 'b7cb22be72d2',
dir_id: '972bc693-f015'
}
})
const file = new File([''], 'mydoc.odt')
const onUploadProgress = jest.fn()
const result = await overwriteFile(fakeClient, file, '/parent/mydoc.odt', {
onUploadProgress
})
expect(updateFileSpy).toHaveBeenCalledWith(file, {
fileId: 'b7cb22be72d2',
onUploadProgress
})
expect(result).toEqual({
id: 'b7cb22be72d2',
type: 'io.cozy.files',
attributes: {
type: 'file',
name: 'mydoc.odt'
}
})
})
})
describe('flattenEntries', () => {
beforeEach(() => {
createDirectorySpy.mockReset()
createFileSpy.mockReset()
statByPathSpy.mockReset()
updateFileSpy.mockReset()
CozyFile.getFullpath.mockReset()
createDirectorySpy.mockImplementation(async ({ name }) => ({
data: { id: `dir-${name}`, name, type: 'directory' }
}))
})
it('should flatten a dropped directory entry into per-file items with relative paths', async () => {
const directoryEntry = createMockDirEntry('photos', [
createMockFileEntry('img1.jpg'),
createMockFileEntry('img2.jpg')
])
const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]
const result = await flattenEntries(entries, 'root', fakeClient, null)
expect(createDirectorySpy).toHaveBeenCalledWith({
name: 'photos',
dirId: 'root'
})
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
fileId: 'photos/img1.jpg',
relativePath: 'photos/img1.jpg',
folderId: 'dir-photos'
})
})
it('should reuse an existing folder when createDirectory returns 409', async () => {
createDirectorySpy.mockReset()
createDirectorySpy.mockRejectedValueOnce({ status: 409 })
CozyFile.getFullpath.mockResolvedValueOnce('/root/photos')
statByPathSpy.mockResolvedValueOnce({
data: { type: 'directory', id: 'existing-photos' }
})
const directoryEntry = createMockDirEntry('photos', [
createMockFileEntry('img.jpg')
])
const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]
const result = await flattenEntries(entries, 'root', fakeClient, null)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
fileId: 'photos/img.jpg',
relativePath: 'photos/img.jpg',
folderId: 'existing-photos'
})
})
it('should recurse into nested directories and carry the relative path', async () => {
const innerDir = createMockDirEntry('2024', [
createMockFileEntry('ski.jpg')
])
const directoryEntry = createMockDirEntry('photos', [innerDir])
const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]
const result = await flattenEntries(entries, 'root', fakeClient, null)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
fileId: 'photos/2024/ski.jpg',
relativePath: 'photos/2024/ski.jpg',
folderId: 'dir-2024'
})
})
it('should place loose files under the root directory without a relative path', async () => {
const plainFile = new File(['a'], 'note.txt')
const entries = [{ file: plainFile, isDirectory: false, entry: null }]
const result = await flattenEntries(entries, 'root', fakeClient, null)
expect(createDirectorySpy).not.toHaveBeenCalled()
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
fileId: 'note.txt',
relativePath: null,
folderId: 'root'
})
})
it('still creates the ancestor folders when the deepest one is unreadable', async () => {
// We can't enumerate `e`, but every ancestor (and `e` itself)
// should still be created server-side so the user can drop the
// missing files into the right place by hand. A single UNREADABLE
// row flags the folder whose contents we couldn't read.
const e = createUnreadableDirEntry('e')
const d = createMockDirEntry('d', [e])
const c = createMockDirEntry('c', [d])
const b = createMockDirEntry('b', [c])
const a = createMockDirEntry('a', [b])
const entries = [{ file: null, isDirectory: true, entry: a }]
const result = await flattenEntries(entries, 'root', fakeClient, null)
const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)
expect(createdNames).toEqual(['a', 'b', 'c', 'd', 'e'])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
relativePath: 'a/b/c/d/e',
folderId: null,
status: 'unreadable',
isDirectory: true
})
})
it('creates empty folders even when they contain no files', async () => {
// Empty subfolders are part of the dropped structure; they should
// land in Drive verbatim so the tree matches what was dropped.
const empty = createMockDirEntry('empty', [])
const top = createMockDirEntry('top', [empty])
const entries = [{ file: null, isDirectory: true, entry: top }]
const result = await flattenEntries(entries, 'root', fakeClient, null)
const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)
expect(createdNames).toEqual(['top', 'empty'])
expect(result).toEqual([])
})
it('classifies non-NotFoundError read failures as failed, not unreadable', async () => {
const broken = createBrokenDirEntry('broken')
const top = createMockDirEntry('top', [broken])
const entries = [{ file: null, isDirectory: true, entry: top }]
const result = await flattenEntries(entries, 'root', fakeClient, null)
const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)
expect(createdNames).toEqual(['top', 'broken'])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
relativePath: 'top/broken',
status: 'failed'
})
})
it('uploads readable siblings and surfaces one row per unreadable subtree', async () => {
// top/
// ok.txt <- readable, should upload
// broken/ <- readEntries fails, one unreadable row
// nested/
// deep.txt <- readable, should upload
// ghost.bin <- entry.file() fails, one unreadable row
const broken = createUnreadableDirEntry('broken')
const nested = createMockDirEntry('nested', [
createMockFileEntry('deep.txt'),
createUnreadableFileEntry('ghost.bin')
])
const top = createMockDirEntry('top', [
createMockFileEntry('ok.txt'),
broken,
nested
])
const entries = [{ file: null, isDirectory: true, entry: top }]
const result = await flattenEntries(entries, 'root', fakeClient, null)
const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)
expect(createdNames).toEqual(['top', 'broken', 'nested'])
const readable = result.filter(r => r.status !== 'unreadable')
const unreadable = result.filter(r => r.status === 'unreadable')
expect(readable).toHaveLength(2)
expect(readable.map(r => r.relativePath).sort()).toEqual([
'top/nested/deep.txt',
'top/ok.txt'
])
expect(unreadable).toHaveLength(2)
expect(unreadable.map(r => r.relativePath).sort()).toEqual([
'top/broken',
'top/nested/ghost.bin'
])
const brokenRow = unreadable.find(r => r.relativePath === 'top/broken')
const ghostRow = unreadable.find(
r => r.relativePath === 'top/nested/ghost.bin'
)
expect(brokenRow.isDirectory).toBe(true)
expect(ghostRow.isDirectory).toBe(false)
})
it('should route react-dropzone File.path entries through the folder cache', async () => {
const nested = new File(['a'], 'a.txt')
nested.path = '/album/2024/a.txt'
const loose = new File(['b'], 'b.txt')
loose.path = '/b.txt'
const entries = [
{ file: nested, isDirectory: false, entry: null },
{ file: loose, isDirectory: false, entry: null }
]
const result = await flattenEntries(entries, 'root', fakeClient, null)
expect(createDirectorySpy).toHaveBeenCalledTimes(2)
expect(createDirectorySpy).toHaveBeenNthCalledWith(1, {
name: 'album',
dirId: 'root'
})
expect(createDirectorySpy).toHaveBeenNthCalledWith(2, {
name: '2024',
dirId: 'dir-album'
})
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
fileId: 'album/2024/a.txt',
relativePath: 'album/2024/a.txt',
folderId: 'dir-2024'
})
expect(result[1]).toMatchObject({
fileId: 'b.txt',
relativePath: null,
folderId: 'root'
})
})
})
describe('addToUploadQueue placeholder flow', () => {
beforeEach(() => {
createDirectorySpy.mockReset()
createFileSpy.mockReset()
statByPathSpy.mockReset()
updateFileSpy.mockReset()
CozyFile.getFullpath.mockReset()
createDirectorySpy.mockImplementation(async ({ name }) => ({
data: { id: `dir-${name}`, name, type: 'directory' }
}))
})
const runThunk = async (
thunk,
getState = () => ({ upload: { queue: [] } })
) => {
const dispatched = []
const pending = []
const dispatch = jest.fn(action => {
dispatched.push(action)
if (typeof action !== 'function') return undefined
const result = action(dispatch, getState)
if (result && typeof result.then === 'function') pending.push(result)
return result
})
await thunk(dispatch, getState)
// Awaited thunks may dispatch further thunks, so drain in a loop
// until no new promises are queued.
while (pending.length) await Promise.all(pending.splice(0))
return dispatched.filter(a => typeof a !== 'function')
}
it('emits a placeholder for each top-level folder and replaces it after flatten', async () => {
const directoryEntry = createMockDirEntry('photos', [
createMockFileEntry('img1.jpg'),
createMockFileEntry('img2.jpg')
])
const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]
const actions = await runThunk(
addToUploadQueue(
entries,
'root',
{},
() => null,
() => null,
{ client: fakeClient },
null,
() => null
)
)
const adds = actions.filter(a => a.type === 'ADD_TO_UPLOAD_QUEUE')
const resolves = actions.filter(a => a.type === 'RESOLVE_FOLDER_ITEMS')
expect(adds).toHaveLength(1)
expect(adds[0].files).toEqual([
expect.objectContaining({
fileId: expect.stringMatching(/^__pending_.+_0_photos__$/),
status: 'resolving'
})
])
expect(resolves).toHaveLength(1)
expect(resolves[0].placeholderIds).toEqual([
expect.stringMatching(/^__pending_.+_0_photos__$/)
])
expect(resolves[0].files).toHaveLength(2)
expect(resolves[0].files[0]).toMatchObject({
fileId: expect.stringMatching(/^.+_photos\/img1\.jpg$/),
relativePath: 'photos/img1.jpg'
})
})
it('skips placeholders for plain file drops', async () => {
const plainFile = new File(['a'], 'note.txt')
const entries = [{ file: plainFile, isDirectory: false, entry: null }]
const actions = await runThunk(
addToUploadQueue(
entries,
'root',
{},
() => null,
() => null,
{ client: fakeClient },
null,
() => null
)
)
const types = actions.map(a => a.type)
expect(types).toContain('ADD_TO_UPLOAD_QUEUE')
expect(types).not.toContain('RESOLVE_FOLDER_ITEMS')
})
it('replaces the placeholder with one unreadable row when an inner folder cannot be read', async () => {
// Asserts the placeholder is resolved (not stuck on RESOLVING) by
// an UNREADABLE row — otherwise the queue would silently swallow
// the drop and the alert pipeline never fires.
const e = createUnreadableDirEntry('e')
const d = createMockDirEntry('d', [e])
const c = createMockDirEntry('c', [d])
const b = createMockDirEntry('b', [c])
const a = createMockDirEntry('a', [b])
const entries = [{ file: null, isDirectory: true, entry: a }]
const actions = await runThunk(
addToUploadQueue(
entries,
'root',
{},
() => null,
() => null,
{ client: fakeClient },
null,
() => null
)
)
const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)
expect(createdNames).toEqual(['a', 'b', 'c', 'd', 'e'])
const resolves = actions.filter(a => a.type === 'RESOLVE_FOLDER_ITEMS')
expect(resolves).toHaveLength(1)
expect(resolves[0].placeholderIds).toEqual([
expect.stringMatching(/^__pending_.+_0_a__$/)
])
expect(resolves[0].files).toHaveLength(1)
expect(resolves[0].files[0]).toMatchObject({
relativePath: 'a/b/c/d/e',
status: 'unreadable'
})
})
it('marks placeholders as failed if flatten throws', async () => {
const directoryEntry = createMockDirEntry('photos', [
createMockFileEntry('img.jpg')
])
createDirectorySpy.mockReset()
createDirectorySpy.mockRejectedValue(new Error('server down'))
const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]
const actions = await runThunk(
addToUploadQueue(
entries,
'root',
{},
() => null,
() => null,
{ client: fakeClient },
null,
() => null
)
)
const errors = actions.filter(a => a.type === 'RECEIVE_UPLOAD_ERROR')
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({
fileId: expect.stringMatching(/^__pending_.+_0_photos__$/),
status: 'failed'
})
expect(actions.some(a => a.type === 'RESOLVE_FOLDER_ITEMS')).toBe(false)
})
it('marks placeholders as unreadable if flatten throws NotFoundError', async () => {
const directoryEntry = createMockDirEntry('photos', [
createMockFileEntry('img.jpg')
])
createDirectorySpy.mockReset()
const notFound = new Error('vanished')
notFound.name = 'NotFoundError'
createDirectorySpy.mockRejectedValue(notFound)
const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]
const actions = await runThunk(
addToUploadQueue(
entries,
'root',
{},
() => null,
() => null,
{ client: fakeClient },
null,
() => null
)
)
const errors = actions.filter(a => a.type === 'RECEIVE_UPLOAD_ERROR')
expect(errors[0]).toMatchObject({
fileId: expect.stringMatching(/^__pending_.+_0_photos__$/),
status: 'unreadable'
})
})
it('fails placeholders and invokes onLimitExceeded when limit hit', async () => {
const directoryEntry = createMockDirEntry('photos', [
createMockFileEntry('a.jpg'),
createMockFileEntry('b.jpg'),
createMockFileEntry('c.jpg')
])
const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]
const onLimitExceeded = jest.fn()
const actions = await runThunk(
addToUploadQueue(
entries,
'root',
{},
() => null,
() => null,
{ client: fakeClient, maxFileCount: 2, onLimitExceeded },
null,
() => null
)
)
expect(onLimitExceeded).toHaveBeenCalledTimes(1)
const errors = actions.filter(a => a.type === 'RECEIVE_UPLOAD_ERROR')
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({
fileId: expect.stringMatching(/^__pending_.+_0_photos__$/),
status: 'failed'
})
// No flatten happened: no folders should have been created
expect(createDirectorySpy).not.toHaveBeenCalled()
expect(actions.some(a => a.type === 'RESOLVE_FOLDER_ITEMS')).toBe(false)
})
it('does not invoke onLimitExceeded when count is under the limit', async () => {
const directoryEntry = createMockDirEntry('photos', [
createMockFileEntry('a.jpg')
])
const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]
const onLimitExceeded = jest.fn()
const actions = await runThunk(
addToUploadQueue(
entries,
'root',
{},
() => null,
() => null,
{ client: fakeClient, maxFileCount: 100, onLimitExceeded },
null,
() => null
)
)
expect(onLimitExceeded).not.toHaveBeenCalled()
// Make sure the under-limit path actually proceeded with flatten
// and didn't silently no-op.
expect(createDirectorySpy).toHaveBeenCalled()
expect(actions.some(a => a.type === 'RESOLVE_FOLDER_ITEMS')).toBe(true)
})
})
describe('onQueueEmpty', () => {
it('does not fire the callback while resolving placeholders are present', () => {
const callback = jest.fn()
const dispatch = jest.fn()
const getState = () => ({
upload: {
queue: [{ fileId: '__pending_0_photos__', status: 'resolving' }]
}
})
onQueueEmpty(callback)(dispatch, getState)
expect(callback).not.toHaveBeenCalled()
})
it('fires the callback when no resolving placeholders remain', () => {
const callback = jest.fn()
const dispatch = jest.fn()
const getState = () => ({
upload: {
queue: [
{ fileId: 'a.txt', status: 'created', uploadedItem: { _id: 'a' } }
]
}
})
onQueueEmpty(callback)(dispatch, getState)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
createdItems: [{ _id: 'a' }]
})
)
})
})
================================================
FILE: src/modules/viewer/CallToAction.jsx
================================================
import localforage from 'localforage'
import React, { Component } from 'react'
import { withClient } from 'cozy-client'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CrossIcon from 'cozy-ui/transpiled/react/Icons/Cross'
import styles from './styles.styl'
import {
getDesktopAppDownloadLink,
isClientAlreadyInstalled,
NOVIEWER_DESKTOP_CTA
} from '@/components/pushClient'
import Config from '@/config/config.json'
class CallToAction extends Component {
state = {
mustShow: false
}
async componentDidMount() {
if (Config.promoteDesktop.isActivated !== true) return
const seen = (await localforage.getItem(NOVIEWER_DESKTOP_CTA)) || false
if (!seen) {
try {
const mustSee = !(await isClientAlreadyInstalled(this.props.client))
if (mustSee) {
this.setState({ mustShow: true })
}
} catch (_e) {
this.setState({ mustShow: false })
}
}
}
markAsSeen = () => {
localforage.setItem(NOVIEWER_DESKTOP_CTA, true)
this.setState({ mustShow: false })
}
render() {
if (!this.state.mustShow || Config.promoteDesktop.isActivated !== true)
return null
const { t } = this.props
const link = getDesktopAppDownloadLink({ t })
return (
{t('Viewer.noviewer.cta.saveTime')}
)
}
}
export default withClient(CallToAction)
================================================
FILE: src/modules/viewer/CallToAction.spec.jsx
================================================
import { render, waitFor } from '@testing-library/react'
import localforage from 'localforage'
import React from 'react'
import CallToAction from './CallToAction'
import { NOVIEWER_DESKTOP_CTA } from '@/components/pushClient'
jest.mock('localforage')
jest.mock('config/config.json', () => ({
promoteDesktop: { isActivated: true }
}))
jest.mock('components/pushClient', () => ({
getDesktopAppDownloadLink: jest.fn().mockReturnValue('https://twake.app'),
isClientAlreadyInstalled: jest.fn().mockResolvedValueOnce(false),
isLinux: jest.fn(),
NOVIEWER_DESKTOP_CTA: 'noviewer_desktop_cta'
}))
describe('CallToAction', () => {
it('should get item noviewer desktop from localforage', async () => {
// Given
localforage.getItem = jest.fn().mockResolvedValueOnce(false)
// When
await waitFor(async () => {
render( )
})
// Then
expect(localforage.getItem).toHaveBeenCalledWith(NOVIEWER_DESKTOP_CTA)
})
it('should use rel="noreferrer" (which implies rel="noopener", because it is a security risk', async () => {
// Given
localforage.getItem = jest.fn().mockResolvedValueOnce(false)
// When
let container
await waitFor(async () => {
const result = render( )
container = result.container
})
// Then
expect(container.querySelector('a[target="_blank"]')).toHaveAttribute(
'rel',
'noreferrer'
)
})
})
================================================
FILE: src/modules/viewer/Fallback.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import CallToAction from './CallToAction'
import NoViewerButton from './NoViewerButton'
const Fallback = ({ file, t }) => {
return (
<>
>
)
}
Fallback.propTypes = {
file: PropTypes.object.isRequired,
t: PropTypes.func.isRequired // t is a prop passed by the parent and must not be received from the translate() HOC — otherwise the translation context becomes the one of the viewer instad of the app. See https://github.com/cozy/cozy-ui/issues/914#issuecomment-487959521
}
export default Fallback
================================================
FILE: src/modules/viewer/FileOpenerExternal.jsx
================================================
/**
* This component was previously named FileOpener
* It has been renamed since it is used in :
* - an intent handler (aka service)
* - via cozydrive://
*/
import React, { useCallback, useEffect, useState } from 'react'
import { RemoveScroll } from 'react-remove-scroll'
import { useNavigate, useParams } from 'react-router-dom'
import { useClient } from 'cozy-client'
import Spinner from 'cozy-ui/transpiled/react/Spinner'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import Viewer, {
FooterActionButtons,
ForwardOrDownloadButton,
ToolbarButtons,
SharingButton
} from 'cozy-viewer'
import { translate, useI18n } from 'twake-i18n'
import { ensureFileHasPath } from '@/components/FilesRealTimeQueries'
import Fallback from '@/modules/viewer/Fallback'
import {
isOfficeEnabled,
makeOnlyOfficeFileRoute
} from '@/modules/views/OnlyOffice/helpers'
import { buildFileOrFolderByIdQuery } from '@/queries'
const FileNotFoundError = translate()(({ t }) => (
{t('FileOpenerExternal.fileNotFoundError')}
))
const FileOpener = props => {
const navigate = useNavigate()
const { isDesktop } = useBreakpoints()
const { t } = useI18n()
const { fileId } = useParams()
const { showAlert } = useAlert()
const client = useClient()
const [state, setState] = useState({
loading: true,
file: null
})
const { service } = props
const { file, loading, fileNotFound } = state
const loadFileInfo = useCallback(
async id => {
try {
setState({ fileNotFound: false, loading: true })
const query = buildFileOrFolderByIdQuery(id)
const result = await client.query(query.definition(), query.options)
const file = await ensureFileHasPath(result.data, client)
setState({ file, loading: false })
} catch (_e) {
setState({ fileNotFound: true, loading: false })
showAlert({
message: t('alert.could_not_open_file')
})
}
},
[client, showAlert, t]
)
useEffect(() => {
const requestedFileId = fileId ?? props.fileId
if (requestedFileId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadFileInfo(requestedFileId)
}
}, [fileId, props.fileId, loadFileInfo])
return (
{loading && }
{fileNotFound && }
{!loading && !fileNotFound && (
{}}
onCloseRequest={service ? () => service.terminate() : null}
renderFallbackExtraContent={file => }
componentsProps={{
OnlyOfficeViewer: {
isEnabled: isOfficeEnabled(isDesktop),
opener: file => navigate(makeOnlyOfficeFileRoute(file.id))
}
}}
>
)}
)
}
export default FileOpener
================================================
FILE: src/modules/viewer/FilesViewer.jsx
================================================
import React, { useCallback, useEffect, useState, useMemo } from 'react'
import { RemoveScroll } from 'react-remove-scroll'
import { useNavigate, useParams } from 'react-router-dom'
import { Q, useClient } from 'cozy-client'
import flag from 'cozy-flags'
import Button from 'cozy-ui/transpiled/react/Buttons'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import Viewer, {
FooterActionButtons,
ForwardOrDownloadButton,
ToolbarButtons,
SharingButton
} from 'cozy-viewer'
import { useI18n } from 'twake-i18n'
import { ensureFileHasPath } from '@/components/FilesRealTimeQueries'
import { FilesViewerLoading } from '@/components/FilesViewerLoading'
import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'
import { useCurrentFileId } from '@/hooks'
import { useMoreMenuActions } from '@/hooks/useMoreMenuActions'
import logger from '@/lib/logger'
import { navigateToModal } from '@/modules/actions/helpers'
import Fallback from '@/modules/viewer/Fallback'
import MoreMenu from '@/modules/viewer/MoreMenu'
import {
isOfficeEnabled,
makeOnlyOfficeFileRoute
} from '@/modules/views/OnlyOffice/helpers'
/**
* Shows a set of files through cozy-ui's Viewer
*
* - Re-uses the cozy-client's Query for the current directory files
* with the same sort order.
* - If the file to show is not present in the query results, will call
* fetchMore() on the query
*/
const FilesViewer = ({ filesQuery, files, onClose, onChange, viewerProps }) => {
const [currentFile, setCurrentFile] = useState(null)
const [fetchingMore, setFetchingMore] = useState(false)
const { isDesktop } = useBreakpoints()
const fileId = useCurrentFileId()
const client = useClient()
const { t } = useI18n()
const navigate = useNavigate()
const { driveId } = useParams()
const handleOnClose = useCallback(() => {
if (onClose) {
onClose()
}
}, [onClose])
const handleOnChange = useCallback(
nextFile => {
if (onChange) {
onChange(nextFile.id)
}
},
[onChange]
)
const currentIndex = useMemo(() => {
return files.findIndex(f => f.id === fileId)
}, [files, fileId])
const hasCurrentIndex = useMemo(() => currentIndex != -1, [currentIndex])
const viewerFiles = useMemo(
() => (hasCurrentIndex ? files : [currentFile]),
[hasCurrentIndex, files, currentFile]
)
useEffect(() => {
let isMounted = true
// If we can't find the file in the loaded files, that's probably because the user
// is trying to open a direct link to a file that wasn't in the first 50 files of
// the containing folder (it comes from a fetchMore...) ; we load the file attributes
// directly as a contingency measure
const fetchFileIfNecessary = async () => {
if (hasCurrentIndex) return
if (currentFile && isMounted) {
setCurrentFile(null)
}
try {
const { data } = await client.query(
Q('io.cozy.files').getById(fileId).sharingById(driveId)
)
const fileWithPath = await ensureFileHasPath(data, client)
isMounted && setCurrentFile(fileWithPath)
} catch (_e) {
logger.warn("can't find the file")
handleOnClose()
}
}
fetchFileIfNecessary()
return () => {
isMounted = false
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
let isMounted = true
// If we get close of the last file fetched, but we know there are more in the folder
// (it shouldn't happen in /recent), we fetch more files
const fetchMoreIfNecessary = async () => {
if (fetchingMore) {
return
}
setFetchingMore(true)
try {
const currentIndex = files.findIndex(f => f.id === fileId)
if (
(filesQuery.data.length - currentIndex <= 5 || currentIndex === -1) &&
filesQuery.hasMore &&
isMounted
) {
await filesQuery.fetchMore()
}
} finally {
setFetchingMore(false)
}
}
fetchMoreIfNecessary()
return () => {
isMounted = false
}
}, [fetchingMore, filesQuery, files, fileId])
const viewerIndex = useMemo(
() => (hasCurrentIndex ? currentIndex : 0),
[hasCurrentIndex, currentIndex]
)
const actions = useMoreMenuActions(currentFile ?? {})
// If we can't find the file, we fallback to the (potentially loading)
// direct stat made by the viewer
if (currentIndex === -1 && !currentFile) {
return
}
const redirectToPaywall = () => {
navigate('v/ai/paywall', { replace: true })
}
return (
}
componentsProps={{
OnlyOfficeViewer: {
isEnabled: isOfficeEnabled(isDesktop),
opener: file => navigate(makeOnlyOfficeFileRoute(file.id))
},
toolbarProps: {
showFilePath: true,
onPaywallRedirect: redirectToPaywall
},
...(viewerProps || {})
}}
>
{flag('drive.new-file-viewer-ui.enabled') && (
}
onClick={() =>
navigateToModal({
navigate,
pathname: '',
files,
path: 'share'
})
}
/>
)}
)
}
export default React.memo(FilesViewer)
================================================
FILE: src/modules/viewer/FilesViewer.spec.jsx
================================================
import { render, screen } from '@testing-library/react'
import React from 'react'
import CozyClient, { useQuery } from 'cozy-client'
import FilesViewer from './FilesViewer'
import AppLike from 'test/components/AppLike'
import { generateFile } from 'test/generate'
import { useCurrentFileId } from '@/hooks'
jest.mock('cozy-client/dist/hooks/useQuery', () => jest.fn())
jest.mock('cozy-keys-lib', () => ({
useVaultClient: jest.fn()
}))
jest.mock('lib/logger', () => ({
error: jest.fn()
}))
jest.mock('hooks')
jest.mock('@/components/FilesRealTimeQueries', () => ({
...jest.requireActual('@/components/FilesRealTimeQueries'),
ensureFileHasPath: jest.fn().mockImplementation(file => Promise.resolve(file))
}))
jest.mock('cozy-viewer', () => ({
...jest.requireActual('cozy-viewer'),
__esModule: true,
default: () => Viewer
}))
const sleep = duration => new Promise(resolve => setTimeout(resolve, duration))
describe('FilesViewer', () => {
beforeEach(() => {
jest.clearAllMocks()
})
const setup = ({
fileId = 'file-foobar0',
nbFiles = 3,
totalCount,
client = new CozyClient({}),
useQueryResultAttributes
} = {}) => {
const filesFixture = Array(nbFiles)
.fill(null)
.map((x, i) => generateFile({ i, type: 'file' }))
const mockedUseQueryReturnedValues = {
data: filesFixture,
count: totalCount || filesFixture.length,
fetchMore: jest.fn().mockImplementation(() => {
throw new Error('Fetch more should not be called')
}),
...useQueryResultAttributes
}
useQuery.mockReturnValue(mockedUseQueryReturnedValues)
useCurrentFileId.mockReturnValue(fileId)
return render(
)
}
it('should render a Viewer', async () => {
setup()
const viewer = await screen.findByText('Viewer')
expect(viewer).toBeInTheDocument()
})
it('should fetch the file if necessary', async () => {
const client = new CozyClient({})
client.query = jest.fn().mockResolvedValue({
data: generateFile({ i: '51' })
})
setup({
client,
nbFiles: 50,
totalCount: 100,
fileId: 'file-foobar51'
})
const viewer = await screen.findByText('Viewer')
expect(viewer).toBeInTheDocument()
expect(client.query).toHaveBeenCalledWith(
expect.objectContaining({
id: 'file-foobar51',
doctype: 'io.cozy.files'
})
)
})
it('should call ensureFileHasPath when fetching file', async () => {
const client = new CozyClient({})
const fileData = generateFile({ i: '51' })
const fileWithPath = { ...fileData, path: '/test/path' }
client.query = jest.fn().mockResolvedValue({
data: fileData
})
const { ensureFileHasPath } = require('@/components/FilesRealTimeQueries')
ensureFileHasPath.mockResolvedValue(fileWithPath)
setup({
client,
nbFiles: 50,
totalCount: 100,
fileId: 'file-foobar51'
})
const viewer = await screen.findByText('Viewer')
expect(viewer).toBeInTheDocument()
expect(ensureFileHasPath).toHaveBeenCalledWith(fileData, client)
expect(client.query).toHaveBeenCalledWith(
expect.objectContaining({
id: 'file-foobar51',
doctype: 'io.cozy.files'
})
)
})
it('should fetch more files if necessary', async () => {
const client = new CozyClient({})
client.query = jest.fn().mockResolvedValue({
data: generateFile({ i: '51' })
})
const fetchMore = jest.fn().mockImplementation(async () => {
await sleep(50)
})
const hasMore = jest.fn().mockReturnValue(true)
setup({
client,
nbFiles: 50,
totalCount: 100,
fileId: 'file-foobar48',
useQueryResultAttributes: {
fetchMore,
hasMore
}
})
const viewer = await screen.findByText('Viewer')
expect(viewer).toBeInTheDocument()
expect(fetchMore).toHaveBeenCalledTimes(1)
})
})
================================================
FILE: src/modules/viewer/MoreMenu.jsx
================================================
import cx from 'classnames'
import React, { useState, useRef } from 'react'
import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'
import Icon from 'cozy-ui/transpiled/react/Icon'
import IconButton from 'cozy-ui/transpiled/react/IconButton'
import DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useMoreMenuActions } from '@/hooks/useMoreMenuActions'
const MoreMenu = ({ file }) => {
const [showMenu, setShowMenu] = useState(false)
const { isDesktop } = useBreakpoints()
const anchorRef = useRef()
const actions = useMoreMenuActions(file)
if (file.trashed) return null
return (
<>
setShowMenu(v => !v)}
>
{showMenu && (
setShowMenu(false)}
/>
)}
>
)
}
export default MoreMenu
================================================
FILE: src/modules/viewer/NoViewerButton.jsx
================================================
import React from 'react'
import { useClient } from 'cozy-client'
import Buttons from 'cozy-ui/transpiled/react/Buttons'
import { downloadFile } from './helpers'
const NoViewerButton = ({ file, t }) => {
const client = useClient()
return (
downloadFile(client, file)}
label={t('Viewer.noviewer.download')}
/>
)
}
export default NoViewerButton
================================================
FILE: src/modules/viewer/barviewer.styl
================================================
@require 'settings/z-index.styl'
@require '../../styles/coz-bar-size.styl'
.viewer-wrapper-with-bar
display flex
flex-direction column
position absolute
width 100%
height 'calc(100% - %s)' % $coz-bar-size
z-index ($bar-index - 2)
================================================
FILE: src/modules/viewer/helpers.js
================================================
export const downloadFile = async (client, file) => {
return client
.collection('io.cozy.files', { driveId: file.driveId })
.download(file)
}
================================================
FILE: src/modules/viewer/styles.styl
================================================
@require 'settings/breakpoints.styl'
.pho-viewer-noviewer-cta
position relative
border-radius .5rem
background-color rgba(255, 255, 255, .05)
padding 1rem
width 80%
max-width 36rem
margin-top 2rem
+medium-screen()
display none
.pho-viewer-noviewer-cta-cross
position absolute
top 1rem
right 1rem
cursor pointer
h3
margin 0 0 1.1rem
h3:before
content ''
position relative
top .2rem
width 1.5rem
height 1.5rem
margin-right .75rem
display inline-block
background embedurl('./icons/icon-magic-trick.svg') center center / cover no-repeat
ul
padding 0
li
list-style-type none
margin-bottom 1rem
&:last-child
margin-bottom 0
&:before
content ''
position relative
top .3rem
border-radius 1rem
width 1.5rem
height 1.5rem
margin-right .75rem
display inline-block
background var(--primaryColor) embedurl('./icons/icon-check.svg') center center no-repeat
a, a:hover, a:focus, a:visited
color var(--white)
================================================
FILE: src/modules/views/AI/AIAssistantPaywallView.tsx
================================================
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { AiAssistantPaywall } from 'cozy-ui-plus/dist/Paywall'
const AIAssistantPaywallView = (): JSX.Element => {
const navigate = useNavigate()
const onClose = (): void => {
navigate('..')
}
return
}
export default AIAssistantPaywallView
================================================
FILE: src/modules/views/Drive/DriveFolderView.jsx
================================================
import React, { useContext, useEffect, useMemo } from 'react'
import { useDispatch } from 'react-redux'
import { useNavigate, Outlet, useLocation, useParams } from 'react-router-dom'
import { useQuery, useClient } from 'cozy-client'
import flag from 'cozy-flags'
import { useVaultClient } from 'cozy-keys-lib'
import {
useSharingContext,
useNativeFileSharing,
shareNative
} from 'cozy-sharing'
import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import HarvestBanner from './HarvestBanner'
import useHead from '@/components/useHead'
import { DEFAULT_SORT } from '@/config/sort'
import { ROOT_DIR_ID } from '@/constants/config'
import { useClipboardContext } from '@/contexts/ClipboardProvider'
import { useCurrentFolderId, useDisplayedFolder, useFolderSort } from '@/hooks'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { FabContext } from '@/lib/FabProvider'
import { useModalContext } from '@/lib/ModalContext'
import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'
import {
share,
download,
trash,
rename,
infos,
versions,
hr,
selectAllItems,
summariseByAI
} from '@/modules/actions'
import { addToFavorites } from '@/modules/actions/components/addToFavorites'
import { duplicateTo } from '@/modules/actions/components/duplicateTo'
import { moveTo } from '@/modules/actions/components/moveTo'
import { personalizeFolder } from '@/modules/actions/components/personalizeFolder'
import { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites'
import { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'
import { useExtraColumns } from '@/modules/certifications/useExtraColumns'
import AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'
import FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'
import Toolbar from '@/modules/drive/Toolbar'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import Dropzone from '@/modules/upload/Dropzone'
import DropzoneDnD from '@/modules/upload/DropzoneDnD'
import { useTrashRedirect } from '@/modules/views/Drive/useTrashRedirect'
import FolderView from '@/modules/views/Folder/FolderView'
import FolderViewBody from '@/modules/views/Folder/FolderViewBody'
import FolderViewBreadcrumb from '@/modules/views/Folder/FolderViewBreadcrumb'
import FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'
import FolderViewBodyVz from '@/modules/views/Folder/virtualized/FolderViewBody'
import { useResumeUploadFromFlagship } from '@/modules/views/Upload/useResumeFromFlagship'
import {
buildDriveQuery,
buildFileWithSpecificMetadataAttributeQuery
} from '@/queries'
// Those extra columns names must match a metadata attribute name, e.g. carbonCopy or electronicSafe
const desktopExtraColumnsNames = []
const mobileExtraColumnsNames = []
const DriveFolderView = () => {
const navigate = useNavigate()
const { pathname } = useLocation()
const params = useParams()
const currentFolderId = useCurrentFolderId()
useHead()
const { isSelectionBarVisible, toggleSelectAllItems, isSelectAll } =
useSelectionContext()
const { isMobile, isDesktop } = useBreakpoints()
const { t, lang } = useI18n()
const { isFabDisplayed, setIsFabDisplayed } = useContext(FabContext)
const { isBigThumbnail, toggleThumbnailSize } = useThumbnailSizeContext()
const sharingContext = useSharingContext()
const { allLoaded, hasWriteAccess, refresh, isOwner, byDocId } =
sharingContext
const { isNativeFileSharingAvailable, shareFilesNative } =
useNativeFileSharing()
const client = useClient()
const vaultClient = useVaultClient()
const { pushModal, popModal } = useModalContext()
const dispatch = useDispatch()
const extraColumnsNames = makeExtraColumnsNamesFromMedia({
isMobile,
desktopExtraColumnsNames,
mobileExtraColumnsNames
})
const { showAlert } = useAlert()
const { hasClipboardData } = useClipboardContext()
const extraColumns = useExtraColumns({
columnsNames: extraColumnsNames,
queryBuilder: buildFileWithSpecificMetadataAttributeQuery,
currentFolderId
})
const { displayedFolder: _displayedFolder, isNotFound } = useDisplayedFolder()
const displayedFolder = useMemo(() => _displayedFolder, [_displayedFolder])
useTrashRedirect(displayedFolder)
const [sortOrder, setSortOrder, isSettingsLoaded] =
useFolderSort(currentFolderId)
// Sort by size does not work for directory, so in case sorting by size we will change to default sorting
const folderQuery = buildDriveQuery({
currentFolderId,
type: 'directory',
sortAttribute:
sortOrder.attribute !== 'size'
? sortOrder.attribute
: DEFAULT_SORT.attribute,
sortOrder:
sortOrder.attribute !== 'size' ? sortOrder.order : DEFAULT_SORT.order
})
const fileQuery = buildDriveQuery({
currentFolderId,
type: 'file',
sortAttribute: sortOrder.attribute,
sortOrder: sortOrder.order
})
const foldersResult = useQuery(folderQuery.definition, folderQuery.options)
const filesResult = useQuery(fileQuery.definition, fileQuery.options)
let allResults = [foldersResult, filesResult]
const isInError = allResults.some(result => result.fetchStatus === 'failed')
const isLoading = allResults.some(
result => result.fetchStatus === 'loading' && !result.lastUpdate
)
const isPending = allResults.some(result => result.fetchStatus === 'pending')
const canWriteToCurrentFolder = hasWriteAccess(currentFolderId)
useKeyboardShortcuts({
canPaste: hasClipboardData && canWriteToCurrentFolder,
client,
items: [...(foldersResult.data || []), ...(filesResult.data || [])],
sharingContext,
pushModal,
popModal,
refresh
})
const actionsOptions = {
client,
t,
lang,
vaultClient,
pushModal,
popModal,
refresh,
dispatch,
navigate,
pathname,
hasWriteAccess: canWriteToCurrentFolder,
canMove: true,
isPublic: false,
allLoaded,
showAlert,
isOwner,
byDocId,
isMobile,
isNativeFileSharingAvailable,
shareFilesNative,
selectAll: () =>
toggleSelectAllItems(allResults.map(query => query.data).flat()),
isSelectAll,
displayedFolder
}
const actions = makeActions(
[
selectAllItems,
share,
shareNative,
download,
hr,
summariseByAI,
hr,
rename,
moveTo,
duplicateTo,
addToFavorites,
removeFromFavorites,
personalizeFolder,
infos,
hr,
versions,
hr,
trash
],
actionsOptions
)
const rootBreadcrumbPath = useMemo(
() => ({
id: ROOT_DIR_ID,
name: t('breadcrumb.title_drive')
}),
[t]
)
useResumeUploadFromFlagship()
useEffect(() => {
if (canWriteToCurrentFolder) {
setIsFabDisplayed(!isDesktop)
return () => {
// to not have this set to false on other views after using this view
setIsFabDisplayed(false)
}
}
}, [setIsFabDisplayed, isDesktop, canWriteToCurrentFolder])
const DropzoneComp =
flag('drive.virtualization.enabled') && !isMobile ? DropzoneDnD : Dropzone
return (
{currentFolderId && (
)}
{flag('drive.show.harvest-banner') && (
)}
{flag('drive.virtualization.enabled') && !isMobile ? (
) : (
)}
{isFabDisplayed && (
)}
)
}
export { DriveFolderView }
================================================
FILE: src/modules/views/Drive/DriveFolderView.spec.jsx
================================================
import { act, render } from '@testing-library/react'
import React from 'react'
import AppLike from 'test/components/AppLike'
import { setupStoreAndClient } from 'test/setup'
import AppRoute from '@/modules/navigation/AppRoute'
jest.mock('cozy-harvest-lib', () => ({
LaunchTriggerCard: jest.fn()
}))
jest.mock('modules/views/Drive/useTrashRedirect', () => ({
useTrashRedirect: jest.fn()
}))
jest.mock('../../upload/Dropzone', () => ({ children }) => (
{children}
))
jest.mock(
'../Folder/FolderViewBreadcrumb',
() =>
({ rootBreadcrumbPath, currentFolderId }) => (
)
)
jest.mock('hooks', () => ({
useCurrentFolderId: jest.fn().mockReturnValue('1234'),
useDisplayedFolder: jest.fn().mockReturnValue({ id: '5678' }),
useFolderSort: jest.fn(() => [{ attribute: 'name', order: 'asc' }, jest.fn()])
}))
jest.mock('modules/shareddrives/hooks/useSharedDrives', () => ({
useSharedDrives: jest.fn().mockReturnValue([])
}))
jest.mock('cozy-keys-lib', () => ({
useVaultClient: jest.fn()
}))
jest.mock('components/useHead', () => jest.fn())
jest.mock('@/modules/shareddrives/hooks/useSharedDriveFolder', () => ({
useSharedDriveFolder: jest.fn().mockReturnValue({
sharedDriveQuery: {},
sharedDriveResult: { data: null }
})
}))
describe('Drive View', () => {
const setup = () => {
const { store, client } = setupStoreAndClient()
client.plugins.realtime = {
subscribe: jest.fn(),
unsubscribe: jest.fn()
}
client.query = jest.fn().mockReturnValue({ data: [] })
client.fetchQueryAndGetFromState = jest.fn().mockReturnValue({ data: [] })
const rendered = render(
)
return { ...rendered, client }
}
it('should use FolderViewBreadcrumb with correct rootBreadcrumbPath', async () => {
let render
await act(async () => {
render = await setup()
})
const { getByTestId } = render
expect(getByTestId('FolderViewBreadcrumb')).toBeTruthy()
expect(
getByTestId('FolderViewBreadcrumb').hasAttribute('data-path')
).toEqual(true)
expect(
getByTestId('FolderViewBreadcrumb').getAttribute('data-folder-id')
).toEqual('1234')
})
})
================================================
FILE: src/modules/views/Drive/FilesViewerDrive.jsx
================================================
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from 'cozy-client'
import { FilesViewerLoading } from '@/components/FilesViewerLoading'
import { useCurrentFolderId, useFolderSort } from '@/hooks'
import { getFolderPath, getViewerPath } from '@/modules/routeUtils'
import FilesViewer from '@/modules/viewer/FilesViewer'
import { buildDriveQuery } from '@/queries'
const FilesViewerDrive = () => {
const navigate = useNavigate()
const [sortOrder] = useFolderSort()
const folderId = useCurrentFolderId()
const buildedFilesQuery = buildDriveQuery({
currentFolderId: folderId,
type: 'file',
sortAttribute: sortOrder.attribute,
sortOrder: sortOrder.order
})
const filesQuery = useQuery(
buildedFilesQuery.definition,
buildedFilesQuery.options
)
const viewableFiles = filesQuery.data
if (viewableFiles) {
return (
navigate(getFolderPath(folderId))}
onChange={fileId => navigate(`${getViewerPath(folderId, fileId)}`)}
/>
)
}
return
}
export default FilesViewerDrive
================================================
FILE: src/modules/views/Drive/HarvestBanner.jsx
================================================
import PropTypes from 'prop-types'
import React from 'react'
import { useQuery, isQueryLoading, Q } from 'cozy-client'
import { LaunchTriggerCard } from 'cozy-harvest-lib'
import Divider from 'cozy-ui/transpiled/react/Divider'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import useDocument from '@/components/useDocument'
import { getKonnectorSlugFromFile } from '@/lib/konnectors'
import {
buildTriggersQueryByAccountId,
buildFileOrFolderByIdQuery
} from '@/queries'
const HarvestBanner = ({ folderId }) => {
const folder = useDocument('io.cozy.files', folderId)
const { isMobile } = useBreakpoints()
let konnectorSlug = undefined
let accountId = undefined
const fileId = folder?.relationships?.contents?.data?.[0]?.id
const fileQuery = buildFileOrFolderByIdQuery(fileId)
const file = useQuery(fileQuery.definition, {
...fileQuery.options,
enabled: Boolean(fileId)
})
if (file.data) {
konnectorSlug = getKonnectorSlugFromFile(file.data)
accountId = file.data.cozyMetadata?.sourceAccount
}
const queryTriggers = buildTriggersQueryByAccountId(accountId)
const { data: triggers, ...triggersQueryLeft } = useQuery(
queryTriggers.definition,
queryTriggers.options
)
const isTriggersLoading = isQueryLoading(triggersQueryLeft)
const konnector = useQuery(
Q('io.cozy.konnectors').getById(`io.cozy.konnectors/${konnectorSlug}`),
{
as: `io.cozy.konnectors/${konnectorSlug}`,
enabled: Boolean(konnectorSlug),
singleDocData: true
}
)
if (!konnector.data || konnector.data.length === 0 || isTriggersLoading) {
return null
}
return (
)
}
HarvestBanner.propTypes = {
folderId: PropTypes.string.isRequired
}
export default HarvestBanner
================================================
FILE: src/modules/views/Drive/KonnectorRoutes.jsx
================================================
import React from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery } from 'cozy-client'
import { HarvestRoutes } from 'cozy-harvest-lib'
import datacardOptions from 'cozy-harvest-lib/dist/datacards/datacardOptions'
import {
buildTriggersQueryByKonnectorSlug,
buildKonnectorsQueryById
} from '@/queries'
const KonnectorRoutes = () => {
const { konnectorSlug } = useParams()
const navigate = useNavigate()
const queryTriggers = buildTriggersQueryByKonnectorSlug(konnectorSlug)
const { data: triggers } = useQuery(
queryTriggers.definition,
queryTriggers.options
)
const trigger = triggers?.[0]
const queryKonnector = buildKonnectorsQueryById({
id: `io.cozy.konnectors/${konnectorSlug}`,
enabled: Boolean(trigger)
})
const { data: konnectors } = useQuery(
queryKonnector.definition,
queryKonnector.options
)
const konnector = konnectors?.[0]
const konnectorWithTriggers = konnector
? { ...konnector, triggers: { data: triggers } }
: undefined
const onDismiss = () => navigate('..')
return (
)
}
export { KonnectorRoutes }
================================================
FILE: src/modules/views/Drive/SharedDrivesFolderView.tsx
================================================
import React, { FC, useMemo } from 'react'
import { Outlet } from 'react-router-dom'
import { useQuery } from 'cozy-client'
import { Content } from 'cozy-ui/transpiled/react/Layout'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { ROOT_DIR_ID } from '@/constants/config'
import { useFolderSort } from '@/hooks'
import useDisplayedFolder from '@/hooks/useDisplayedFolder'
import { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'
import {
useExtraColumns,
ExtraColumn
} from '@/modules/certifications/useExtraColumns'
import { FolderBody } from '@/modules/folder/components/FolderBody'
import FolderView from '@/modules/views/Folder/FolderView'
import FolderViewBreadcrumb from '@/modules/views/Folder/FolderViewBreadcrumb'
import FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'
import {
buildDriveQuery,
buildFileWithSpecificMetadataAttributeQuery
} from '@/queries'
const desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']
const mobileExtraColumnsNames: string[] = []
const SharedDrivesFolderView: FC = () => {
const { isMobile } = useBreakpoints()
const { t } = useI18n()
const { isNotFound } = useDisplayedFolder()
const extraColumnsNames = makeExtraColumnsNamesFromMedia({
isMobile,
desktopExtraColumnsNames,
mobileExtraColumnsNames
})
const extraColumns = useExtraColumns({
columnsNames: extraColumnsNames,
queryBuilder: buildFileWithSpecificMetadataAttributeQuery,
currentFolderId: 'io.cozy.files.shared-drives-dir'
}) as ExtraColumn[]
const [sortOrder] = useFolderSort('io.cozy.files.shared-drives-dir')
const folderQuery = buildDriveQuery({
currentFolderId: 'io.cozy.files.shared-drives-dir',
type: 'directory',
sortAttribute: sortOrder.attribute,
sortOrder: sortOrder.order
})
const fileQuery = buildDriveQuery({
currentFolderId: 'io.cozy.files.shared-drives-dir',
type: 'file',
sortAttribute: sortOrder.attribute,
sortOrder: sortOrder.order
})
const foldersResult = useQuery(folderQuery.definition, folderQuery.options)
const filesResult = useQuery(fileQuery.definition, fileQuery.options)
const queryResults = [foldersResult, filesResult]
const rootBreadcrumbPath = useMemo(
() => ({
id: ROOT_DIR_ID,
name: t('breadcrumb.title_drive')
}),
[t]
)
return (
)
}
export { SharedDrivesFolderView }
================================================
FILE: src/modules/views/Drive/useTrashRedirect.jsx
================================================
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { TRASH_DIR_PATH } from '@/constants/config'
export const useTrashRedirect = displayedFolder => {
const navigate = useNavigate()
useEffect(() => {
if (displayedFolder && displayedFolder.path.startsWith(TRASH_DIR_PATH)) {
navigate('/trash/' + displayedFolder.id)
}
}, [navigate, displayedFolder])
}
================================================
FILE: src/modules/views/Favorites/FavoritesView.tsx
================================================
import React, { FC } from 'react'
import { useDispatch } from 'react-redux'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useClient, useQuery } from 'cozy-client'
import { IOCozyFile } from 'cozy-client/types/types'
import {
useSharingContext,
useNativeFileSharing,
shareNative
} from 'cozy-sharing'
import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
import { Content } from 'cozy-ui/transpiled/react/Layout'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { useFolderSort } from '@/hooks'
import { useModalContext } from '@/lib/ModalContext'
import {
download,
rename,
infos,
versions,
share,
hr,
trash,
summariseByAI
} from '@/modules/actions'
import { addToFavorites } from '@/modules/actions/components/addToFavorites'
import { moveTo } from '@/modules/actions/components/moveTo'
import { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites'
import { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'
import { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'
import {
useExtraColumns,
ExtraColumn
} from '@/modules/certifications/useExtraColumns'
import AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'
import FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'
import Toolbar from '@/modules/drive/Toolbar'
import { FolderBody } from '@/modules/folder/components/FolderBody'
import { isNextcloudShortcut } from '@/modules/nextcloud/helpers'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import FolderView from '@/modules/views/Folder/FolderView'
import FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'
import {
buildFavoritesQuery,
buildFileWithSpecificMetadataAttributeQuery
} from '@/queries'
const desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']
const mobileExtraColumnsNames: string[] = []
const FavoritesView: FC = () => {
const navigate = useNavigate()
const { pathname } = useLocation()
const { isMobile } = useBreakpoints()
const { t, lang } = useI18n()
const client = useClient()
const { isSelectionBarVisible } = useSelectionContext()
const { pushModal, popModal } = useModalContext()
const { allLoaded, refresh } = useSharingContext()
const { isNativeFileSharingAvailable, shareFilesNative } =
useNativeFileSharing()
const dispatch = useDispatch()
const { showAlert } = useAlert()
const [sortOrder] = useFolderSort('favorites')
const extraColumnsNames = makeExtraColumnsNamesFromMedia({
isMobile,
desktopExtraColumnsNames,
mobileExtraColumnsNames
})
const extraColumns = useExtraColumns({
columnsNames: extraColumnsNames,
queryBuilder: buildFileWithSpecificMetadataAttributeQuery,
currentFolderId: 'io.cozy.files.shared-drives-dir'
}) as ExtraColumn[]
const favoritesQuery = buildFavoritesQuery({
sortAttribute: sortOrder.attribute,
sortOrder: sortOrder.order
})
const favoritesResult = useQuery(
favoritesQuery.definition,
favoritesQuery.options
) as {
data?: IOCozyFile[] | null
}
const handleInteractWith = (file: IOCozyFile): boolean =>
!isNextcloudShortcut(file)
const actionsOptions = {
client,
t,
lang,
pushModal,
popModal,
refresh,
dispatch,
navigate,
pathname,
hasWriteAccess: true,
canMove: true,
isPublic: false,
allLoaded,
showAlert,
isMobile,
isNativeFileSharingAvailable,
shareFilesNative
}
const actions = makeActions(
[
share,
shareNative,
download,
hr,
summariseByAI,
hr,
rename,
moveTo,
addToFavorites,
removeFromFavorites,
infos,
hr,
versions,
hr,
trash
],
actionsOptions
)
return (
{isMobile && (
{
// Empty function needed because this props is required
}}
>
)}
)
}
export { FavoritesView }
================================================
FILE: src/modules/views/Folder/ColoredFolder.jsx
================================================
import React, { useRef } from 'react'
import { shadeColor } from './helpers'
let gradientIdCounter = 0
function ColoredFolder({ color = '#1D7AFF', ...props }) {
const gradientIdRef = useRef(null)
if (gradientIdRef.current === null) {
gradientIdRef.current = `file-type-colored-folder-gradient-${++gradientIdCounter}`
}
const gradientId = gradientIdRef.current
const base = color
const dark = shadeColor(base, { to: 'black', factor: 0.1 })
const lightStrong = shadeColor(base, { to: 'white', factor: 0.45 })
const light = shadeColor(base, { to: 'white', factor: 0.35 })
const mid = shadeColor(base, { to: 'white', factor: 0.15 })
return (
)
}
export default ColoredFolder
================================================
FILE: src/modules/views/Folder/CustomizedIcon.jsx
================================================
import React from 'react'
import Icon from 'cozy-ui/transpiled/react/Icon'
import ColoredFolder from './ColoredFolder'
import { getIcon } from '@/components/IconPicker/IconIndex'
import IconStack from '@/components/IconStack'
export const CustomizedIcon = ({
selectedColor = '#46a2ff',
selectedIcon,
selectedIconColor,
size
}) => {
return (
}
foregroundIcon={
}
/>
)
}
================================================
FILE: src/modules/views/Folder/FolderCustomizer.jsx
================================================
import React, { useState, useRef } from 'react'
import { useClient, useQuery } from 'cozy-client'
import Backdrop from 'cozy-ui/transpiled/react/Backdrop'
import Buttons from 'cozy-ui/transpiled/react/Buttons'
import { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
import Grid from 'cozy-ui/transpiled/react/Grid'
import { Spinner } from 'cozy-ui/transpiled/react/Spinner'
import Tab from 'cozy-ui/transpiled/react/Tab'
import Tabs from 'cozy-ui/transpiled/react/Tabs'
import Typography from 'cozy-ui/transpiled/react/Typography'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import { CustomizedIcon } from './CustomizedIcon'
import styles from '@/styles/folder-customizer.styl'
import { ColorPicker } from '@/components/ColorPicker/ColorPicker'
import { COLORS } from '@/components/ColorPicker/constants'
import { IconPicker } from '@/components/IconPicker/index.jsx'
import { addRecentIcon } from '@/hooks'
import logger from '@/lib/logger'
import {
buildFileOrFolderByIdQuery,
buildSharedDriveFileOrFolderByIdQuery
} from '@/queries'
export const FolderCustomizerModal = ({ folderId, driveId, onClose }) => {
const folderQuery = driveId
? buildSharedDriveFileOrFolderByIdQuery({ fileId: folderId, driveId })
: buildFileOrFolderByIdQuery(folderId)
const result = useQuery(folderQuery.definition, folderQuery.options)
const { fetchStatus, data: folder } = result
return fetchStatus !== 'loaded' ? (
) : (
)
}
FolderCustomizerModal.displayName = 'FolderCustomizerModal'
const DumbFolderCustomizer = ({ folder, driveId, onClose }) => {
const { t } = useI18n()
const tabItems = ['colors', 'icons']
const [selectedColor, setSelectedColor] = useState(
folder.metadata?.decorations?.color || COLORS[8]
)
const [selectedIcon, setSelectedIcon] = useState(
folder.metadata?.decorations?.icon || null
)
const [selectedIconColor, setSelectedIconColor] = useState(
folder.metadata?.decorations?.icon_color
)
const { showAlert } = useAlert()
const client = useClient()
const handleColorSelect = color => {
setSelectedColor(color)
}
const handleIconSelect = iconName => {
setSelectedIcon(iconName)
}
const handleIconColorSelect = color => {
setSelectedIconColor(color)
}
const handleApply = async () => {
try {
// Prepare decorations object
const decorations = {
...folder.metadata?.decorations,
color: selectedColor
}
// Only add icon and icon_color if an actual icon is selected (not "none")
if (selectedIcon && selectedIcon !== 'none') {
decorations.icon = selectedIcon
if (selectedIconColor) {
decorations.icon_color = selectedIconColor
}
} else {
delete decorations.icon
delete decorations.icon_color
}
if (driveId) {
await client.collection('io.cozy.files', { driveId }).update({
...folder,
metadata: {
...folder.metadata,
decorations
}
})
} else {
await client.save({
...folder,
metadata: {
...folder.metadata,
decorations
}
})
}
if (selectedIcon && selectedIcon !== 'none') {
addRecentIcon(selectedIcon)
}
} catch (error) {
logger.error(`Error while updating folder decoration`, error)
showAlert({
message: t('FolderCustomizer.error'),
severity: 'error'
})
} finally {
onClose()
}
}
const [selectedTab, setSelectedTab] = useState(0)
const iconContainerRef = useRef(null)
const handleTabChange = (_, newValue) => {
setSelectedTab(newValue)
}
return (
{tabItems.map(tabItem => (
))}
{tabItems[selectedTab] === 'colors' && (
{t('FolderCustomizer.description')}
)}
{tabItems[selectedTab] === 'icons' && (
)}
}
actions={
<>
>
}
/>
)
}
================================================
FILE: src/modules/views/Folder/FolderDuplicateView.tsx
================================================
import React, { FC } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
import { hasQueryBeenLoaded, useQuery } from 'cozy-client'
import { IOCozyFile } from 'cozy-client/types/types'
import flag from 'cozy-flags'
import { LoaderModal } from '@/components/LoaderModal'
import useDisplayedFolder from '@/hooks/useDisplayedFolder'
import { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal'
import { buildParentsByIdsQuery } from '@/queries'
const FolderDuplicateView: FC = () => {
const navigate = useNavigate()
const { state } = useLocation() as {
state: { fileIds?: string[] }
}
const { displayedFolder } = useDisplayedFolder()
const hasFileIds = state.fileIds != undefined
const fileQuery = buildParentsByIdsQuery(state.fileIds ?? [])
const fileResult = useQuery(fileQuery.definition, {
...fileQuery.options,
enabled: hasFileIds
}) as {
data?: IOCozyFile[] | null
}
if (!hasFileIds) {
return
}
if (hasQueryBeenLoaded(fileResult) && fileResult.data && displayedFolder) {
const onClose = (): void => {
navigate('..', { replace: true })
}
return (
)
}
return
}
export { FolderDuplicateView }
================================================
FILE: src/modules/views/Folder/FolderView.jsx
================================================
import React from 'react'
import { RealTimeQueries } from 'cozy-client'
import { NotFound } from '@/components/Error/NotFound'
import FilesRealTimeQueries from '@/components/FilesRealTimeQueries'
import { ModalStack } from '@/lib/ModalContext'
import { ModalManager } from '@/lib/react-cozy-helpers'
import Main from '@/modules/layout/Main'
/**
* Renders the FolderView component.
*
* @component
* @param {Object} props - The component props.
* @param {ReactNode} props.children - The child components to render.
* @param {boolean} props.isNotFound - Indicates if the folder is not found.
* @returns {ReactNode} The rendered FolderView component.
*/
const FolderView = ({ children, isNotFound }) => (
{isNotFound ? : children}
)
export default React.memo(FolderView)
================================================
FILE: src/modules/views/Folder/FolderViewBody.jsx
================================================
import cx from 'classnames'
import React, { useContext, useState, useEffect, useRef } from 'react'
import { useSelector } from 'react-redux'
import { isSharingShortcut } from 'cozy-client/dist/models/file'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useFileSorting } from './hooks/useFileSorting'
import { useSyncingFakeFile } from './useSyncingFakeFile'
import styles from '@/styles/folder-view.styl'
import { EmptyWrapper } from '@/components/Error/Empty'
import Oops from '@/components/Error/Oops'
import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'
import { useShiftSelection } from '@/hooks/useShiftSelection'
import AcceptingSharingContext from '@/lib/AcceptingSharingContext'
import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import AddFolder from '@/modules/filelist/AddFolder'
import { FileWithSelection as File } from '@/modules/filelist/File'
import { FileList } from '@/modules/filelist/FileList'
import FileListBody from '@/modules/filelist/FileListBody'
import { FileListHeader } from '@/modules/filelist/FileListHeader'
import FileListRowsPlaceholder from '@/modules/filelist/FileListRowsPlaceholder'
import LoadMore from '@/modules/filelist/LoadMoreV2'
import { isTypingNewFolderName } from '@/modules/filelist/duck'
import SelectionBar from '@/modules/selection/SelectionBar'
import { isReferencedByShareInSharingContext } from '@/modules/views/Folder/syncHelpers'
const FileListBodyWrapper = ({ viewType, children }) => {
return (
{children}
)
}
// TODO: extraColumns is then passed to 'FileListHeader', 'AddFolder',
// and 'File' (this one from a 'syncingFakeFile' and a normal file).
// It is easy to forget to update one of these components to pass 'extraColumns'.
// It would be ideal to centralize it somewhere.
const FolderViewBody = ({
currentFolderId,
displayedFolder,
queryResults,
actions,
canSort,
canUpload = true,
withFilePath = false,
refreshFolderContent = null,
extraColumns,
orderProps,
driveId
}) => {
const { isDesktop } = useBreakpoints()
const { viewType, switchView } = useViewSwitcherContext()
const folderViewRef = useRef()
const IsAddingFolder = useSelector(isTypingNewFolderName)
const { sortOrder, isSettingsLoaded, sortedFiles, changeSortOrder } =
useFileSorting(currentFolderId, queryResults, orderProps)
const { setLastInteractedItem, onShiftClick } = useShiftSelection(
{ items: sortedFiles, viewType },
folderViewRef
)
/**
* Since we are not able to restore the scroll correctly,
* and force the scroll to top every time we change the
* current folder. This is to avoid this kind of weird
* behavior:
* - If I go to a sub-folder, if this subfolder has a lot
* of data and I scrolled down until the bottom. If I go
* back, then my folder will also be scrolled down.
*
* This is an ugly hack, yeah.
* */
useEffect(() => {
if (isDesktop) {
const scrollable = document.querySelectorAll(
'[data-testid=fil-content-body]'
)[0]
if (scrollable) {
scrollable.scroll({ top: 0 })
}
} else {
window.scroll({ top: 0 })
}
}, [currentFolderId, isDesktop])
const { isBigThumbnail } = useThumbnailSizeContext()
const { sharingsValue } = useContext(AcceptingSharingContext)
const isInError = queryResults.some(query => query.fetchStatus === 'failed')
const hasDataToShow =
!isInError &&
queryResults.some(query => query.data && query.data.length > 0)
const isLoading =
!hasDataToShow &&
queryResults.some(
query => query.fetchStatus === 'loading' && !query.lastUpdate
) &&
!isSettingsLoaded
const isEmpty = !isInError && !isLoading && !hasDataToShow
const showEmpty = displayedFolder !== null && !IsAddingFolder && isEmpty
const isSharingContextEmpty = Object.keys(sharingsValue).length <= 0
const { syncingFakeFile } = useSyncingFakeFile({ isEmpty, queryResults })
const onToggleSelect = (fileId, e) => {
setLastInteractedItem(fileId)
onShiftClick(fileId, e)
}
/**
* When we mount the component when we already have data in cache,
* the mount is time consuming since we'll render at least 100 lines
* of File.
*
* React seems to batch together the fact that :
* - we change a route
* - we want to render 100 files
* resulting in a non smooth transition between views (Drive / Recent / ...)
*
* In order to bypass this batch, we use a state to first display a much
* more simpler component and then the files
*/
const [needsToWait, setNeedsToWait] = useState(true)
useEffect(() => {
let timeout = null
if (!isLoading) {
timeout = setTimeout(() => {
setNeedsToWait(false)
}, 50)
}
return () => clearTimeout(timeout)
}, [isLoading])
return (
<>
{hasDataToShow && (
)}
{!hasDataToShow && !needsToWait && (
)}
{isInError && }
{(needsToWait || isLoading) && }
{/* TODO FolderViewBody should not have the responsability to chose
which empty component to display. It should be done by the "view" itself.
But adding a new prop like
)}
{hasDataToShow && !needsToWait && (
<>
{syncingFakeFile && (
)}
{sortedFiles.map(file => {
return (
{
onToggleSelect(file?._id, e)
}}
/>
)
})}
{queryResults.some(query => query.hasMore) && (
{
queryResults.forEach(query => {
if (query.hasMore && query.fetchMore) {
query.fetchMore()
}
})
}}
/>
)}
>
)}
>
)
}
export default FolderViewBody
================================================
FILE: src/modules/views/Folder/FolderViewBreadcrumb.jsx
================================================
import PropTypes from 'prop-types'
import React, { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'
import { useBreadcrumbPath } from '@/modules/breadcrumb/hooks/useBreadcrumbPath.jsx'
const FolderViewBreadcrumb = ({
currentFolderId,
rootBreadcrumbPath,
sharedDocumentIds
}) => {
const navigate = useNavigate()
const path = useBreadcrumbPath({
currentFolderId,
rootBreadcrumbPath,
sharedDocumentIds
})
const onBreadcrumbClick = useCallback(
({ id }) => {
navigate(id ? `../${id}` : '..', {
relative: 'path'
})
},
[navigate]
)
return path && path.length > 0 ? (
) : null
}
FolderViewBreadcrumb.propTypes = {
currentFolderId: PropTypes.string.isRequired,
rootBreadcrumbPath: PropTypes.exact({
id: PropTypes.string,
name: PropTypes.string
}).isRequired,
sharedDocumentIds: PropTypes.array
}
export default FolderViewBreadcrumb
================================================
FILE: src/modules/views/Folder/FolderViewBreadcrumb.spec.jsx
================================================
import { render } from '@testing-library/react'
import React from 'react'
import FolderViewBreadcrumb from './FolderViewBreadcrumb'
import {
dummyBreadcrumbPathWithRootLarge,
dummyRootBreadcrumbPath
} from 'test/dummies/dummyBreadcrumbPath'
import { useBreadcrumbPath } from '@/modules/breadcrumb/hooks/useBreadcrumbPath'
jest.mock('modules/breadcrumb/hooks/useBreadcrumbPath')
jest.mock('modules/breadcrumb/components/MobileAwareBreadcrumb', () => ({
MobileAwareBreadcrumb: ({ path, opening }) => (
)
}))
jest.mock('react-router-dom', () => ({
useNavigate: jest.fn()
}))
describe('FolderViewBreadcrumb', () => {
const rootBreadcrumbPath = dummyRootBreadcrumbPath()
it('should use breadcrumb path', () => {
// Given
const currentFolderId = '1234'
const sharedDocumentIds = [currentFolderId, '5678']
// When
render(
)
// Then
expect(useBreadcrumbPath).toHaveBeenCalledWith({
currentFolderId,
rootBreadcrumbPath,
sharedDocumentIds
})
})
it('should set correct path in template', () => {
// Given
useBreadcrumbPath.mockReturnValue(dummyBreadcrumbPathWithRootLarge())
// When
const { getByTestId } = render(
)
// Then
expect(getByTestId('MobileAwareBreadcrumb')).toBeTruthy()
expect(
getByTestId('MobileAwareBreadcrumb').hasAttribute('data-path')
).toEqual(true)
expect(
getByTestId('MobileAwareBreadcrumb').getAttribute('data-opening')
).toEqual('false')
})
it('should be null when path empty', () => {
// Given
useBreadcrumbPath.mockReturnValue([])
// When
const { container } = render(
)
// Then
expect(container).toMatchInlineSnapshot(`
`)
})
it('should be null when path undefined', () => {
// Given
useBreadcrumbPath.mockReturnValue()
// When
const { container } = render(
)
// Then
expect(container).toMatchInlineSnapshot(`
`)
})
})
================================================
FILE: src/modules/views/Folder/FolderViewHeader.jsx
================================================
import React from 'react'
import Topbar from '@/modules/layout/Topbar'
const FolderViewHeader = ({ children }) => {
return {children}
}
export default FolderViewHeader
================================================
FILE: src/modules/views/Folder/OldFolderViewBreadcrumb.jsx
================================================
import React, { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useClient } from 'cozy-client'
import logger from '@/lib/logger'
import { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'
const FolderViewBreadcrumb = ({
displayedFolder,
sharedDocumentId,
getBreadcrumbPath
}) => {
const navigate = useNavigate()
const client = useClient()
const [path, setPath] = useState(null)
useEffect(() => {
let isMounted = true
// eslint-disable-next-line react-hooks/set-state-in-effect
setPath(null)
if (!displayedFolder || !sharedDocumentId) return
const asyncGetPaths = async () => {
try {
const paths = await getBreadcrumbPath({
client,
displayedFolder,
sharedDocumentId
})
if (isMounted) {
setPath(paths)
}
} catch (error) {
logger.error(`Error while fetching breadcrumb path: ${error}`)
if (isMounted) {
setPath(null)
}
}
}
asyncGetPaths()
return () => {
isMounted = false
}
}, [displayedFolder, sharedDocumentId, client, getBreadcrumbPath])
const onBreadcrumbClick = useCallback(
({ id }) => {
navigate(id ? `../${id}` : '..', {
relative: 'path'
})
},
[navigate]
)
return path ? (
) : null
}
export default FolderViewBreadcrumb
================================================
FILE: src/modules/views/Folder/PublicFolderDuplicateView.tsx
================================================
import React, { FC } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
import usePublicFileByIdsQuery from '../Public/usePublicFileByIdsQuery'
import { LoaderModal } from '@/components/LoaderModal'
import useDisplayedFolder from '@/hooks/useDisplayedFolder'
import { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal'
const PublicFolderDuplicateView: FC = () => {
const navigate = useNavigate()
const { state } = useLocation() as {
state: { fileIds?: string[] }
}
const { displayedFolder } = useDisplayedFolder()
const hasFileIds = state.fileIds != undefined
const { files, fetchStatus } = usePublicFileByIdsQuery(
state.fileIds ?? ([] as string[])
)
if (!hasFileIds) {
return
}
if (fetchStatus === 'loaded' && files.length && displayedFolder) {
const onClose = (): void => {
navigate('..', { replace: true })
}
return (
)
}
return
}
export { PublicFolderDuplicateView }
================================================
FILE: src/modules/views/Folder/helpers.js
================================================
import { SHARED_DRIVES_DIR_ID } from '@/constants/config'
import { getDriveI18n } from '@/locales'
/**
* Converts a hex color string to an RGB object.
*
* @param {string} hex - The hex color string (e.g., "#ff8800" or "ff8800").
* @returns {{r: number, g: number, b: number}|null} An object with r, g, b values (0-255), or null if input is invalid.
*/
export const hexToRgb = hex => {
if (!hex) return null
const normalized = hex.replace(/^#/, '')
const long = /^(\w{2})(\w{2})(\w{2})$/i
const match = normalized.match(long)
if (!match) return null
const [, r, g, b] = match
return {
r: parseInt(r, 16),
g: parseInt(g, 16),
b: parseInt(b, 16)
}
}
/**
* Converts an RGB object to a hex color string.
*
* @param {{r: number, g: number, b: number}} param - The RGB object.
* @returns {string} The hex color string (e.g., "#ff8800" or "ff8800").
*/
export const rgbToHex = ({ r, g, b }) => {
const toHex = n => n.toString(16).padStart(2, '0')
return `#${toHex(Math.max(0, Math.min(255, r)))}${toHex(
Math.max(0, Math.min(255, g))
)}${toHex(Math.max(0, Math.min(255, b)))}`
}
/**
* Mixes two RGB objects with a given factor.
*
* @param {{r: number, g: number, b: number}} colorRgb - The color RGB object.
* @param {{r: number, g: number, b: number}} mixRgb - The mix RGB object.
* @param {number} factor - The factor (0-1).
* @returns {{r: number, g: number, b: number}} The mixed RGB object.
*/
export const mixWith = (colorRgb, mixRgb, factor) => {
// Linear blend: result = color * (1 - f) + mix * f
const f = Math.max(0, Math.min(1, factor))
return {
r: Math.round(colorRgb.r * (1 - f) + mixRgb.r * f),
g: Math.round(colorRgb.g * (1 - f) + mixRgb.g * f),
b: Math.round(colorRgb.b * (1 - f) + mixRgb.b * f)
}
}
/**
* Generates a shade of a given hex color string.
*
* @param {string} baseHex - The base hex color string.
* @param {{to?: 'white' | 'black', factor?: number}} options - The options.
* @returns {string} The shaded hex color string.
*/
export const shadeColor = (baseHex, { to = 'white', factor = 0.2 } = {}) => {
const base = hexToRgb(baseHex)
if (!base) return baseHex
const target =
to === 'black' ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 }
const mixed = mixWith(base, target, factor)
return rgbToHex(mixed)
}
export const makeColumns = isBigThumbnail => {
const { t } = getDriveI18n()
return [
{
id: 'name',
maxWidth: 0,
disablePadding: !isBigThumbnail,
label: t('table.head_name')
},
{
id: 'updated_at',
disablePadding: false,
width: 160,
label: t('table.head_update'),
textAlign: 'right'
},
{
id: 'size',
disablePadding: false,
width: 80,
label: t('table.head_size'),
textAlign: 'right'
},
{
id: 'share',
disablePadding: false,
width: 125,
label: t('table.head_status'),
textAlign: 'right',
sortable: false
},
{
id: 'menu',
disablePadding: false,
width: 60,
label: '',
textAlign: 'center',
sortable: false
}
]
}
/**
* Sort files by type to put directory and trash before files
* @param {import('cozy-client/types').IOCozyFile[]} file
* @returns {import('cozy-client/types').IOCozyFile[]}
*/
export const secondarySort = file => {
const { tempFolder, folders, files, trashFolder } = file.reduce(
(acc, el) => {
if (el?.type === 'tempDirectory') {
acc.tempFolder.push(el)
} else if (el?.type === 'directory') {
if (el?.name === '.cozy_trash') {
acc.trashFolder.push(el)
} else if (el?._id === SHARED_DRIVES_DIR_ID) {
acc.folders.unshift(el)
} else {
acc.folders.push(el)
}
} else if (el?.type === 'file') {
acc.files.push(el)
}
return acc
},
{ tempFolder: [], folders: [], files: [], trashFolder: [] }
)
return [...tempFolder, ...folders, ...trashFolder, ...files]
}
================================================
FILE: src/modules/views/Folder/helpers.spec.js
================================================
import { hexToRgb, rgbToHex, mixWith, shadeColor } from './helpers'
// Mock locales to make labels deterministic in tests
jest.mock('@/locales', () => ({
getDriveI18n: () => ({ t: k => k })
}))
describe('color helpers', () => {
test('hexToRgb returns null on falsy/invalid inputs', () => {
expect(hexToRgb('')).toBeNull()
expect(hexToRgb(null)).toBeNull()
expect(hexToRgb(undefined)).toBeNull()
expect(hexToRgb('zzz')).toBeNull()
expect(hexToRgb('#1234')).toBeNull()
})
test('hexToRgb parses 6-digit hex with or without #', () => {
expect(hexToRgb('#ff8800')).toEqual({ r: 255, g: 136, b: 0 })
expect(hexToRgb('ff8800')).toEqual({ r: 255, g: 136, b: 0 })
expect(hexToRgb('#000000')).toEqual({ r: 0, g: 0, b: 0 })
expect(hexToRgb('#ffffff')).toEqual({ r: 255, g: 255, b: 255 })
})
test('rgbToHex converts and clamps values into #rrggbb', () => {
expect(rgbToHex({ r: 255, g: 136, b: 0 })).toBe('#ff8800')
expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe('#000000')
expect(rgbToHex({ r: 255, g: 255, b: 255 })).toBe('#ffffff')
// Clamp below 0 and above 255
expect(rgbToHex({ r: -20, g: 500, b: 10 })).toBe('#00ff0a')
})
test('mixWith blends two colors linearly by factor', () => {
const red = { r: 255, g: 0, b: 0 }
const blue = { r: 0, g: 0, b: 255 }
expect(mixWith(red, blue, 0)).toEqual({ r: 255, g: 0, b: 0 })
expect(mixWith(red, blue, 1)).toEqual({ r: 0, g: 0, b: 255 })
expect(mixWith(red, blue, 0.5)).toEqual({ r: 128, g: 0, b: 128 })
// Factor is clamped to [0, 1]
expect(mixWith(red, blue, -1)).toEqual({ r: 255, g: 0, b: 0 })
expect(mixWith(red, blue, 2)).toEqual({ r: 0, g: 0, b: 255 })
})
test('shadeColor mixes towards white or black', () => {
expect(shadeColor('#000000', { to: 'white', factor: 0.5 })).toBe('#808080')
expect(shadeColor('#ffffff', { to: 'black', factor: 0.5 })).toBe('#808080')
expect(shadeColor('#ff0000', { to: 'white', factor: 0.5 })).toBe('#ff8080')
expect(shadeColor('#00ff00', { to: 'black', factor: 0.5 })).toBe('#008000')
// Fallback to input if base is invalid
expect(shadeColor('zzz', { to: 'white', factor: 0.5 })).toBe('zzz')
})
})
================================================
FILE: src/modules/views/Folder/hooks/useFileSorting.js
================================================
import { useMemo, useCallback } from 'react'
import {
stableSort,
getComparator
} from 'cozy-ui/transpiled/react/Table/Virtualized/helpers'
import { secondarySort } from '../helpers'
import { useFolderSort } from '@/hooks'
/**
* Custom hook for handling file sorting logic
* @param {string} currentFolderId - The current folder ID
* @param {Array} queryResults - Query results containing files
* @param {Object} orderProps - External order properties (optional)
* @returns {Object} Sorting state and functions
*/
export const useFileSorting = (currentFolderId, queryResults, orderProps) => {
// Get internal sorting state from existing hook
const [internalSortOrder, internalSetSortOrder, internalIsSettingsLoaded] =
useFolderSort(currentFolderId)
// Merge internal and external sort properties
const sortOrder = orderProps?.sortOrder ?? internalSortOrder
const setSortOrder = orderProps?.setOrder ?? internalSetSortOrder
const isSettingsLoaded =
orderProps?.isSettingsLoaded ?? internalIsSettingsLoaded
// Extract all files from query results
const allFiles = useMemo(() => {
const files = []
queryResults.forEach(query => {
if (query.data && query.data.length > 0) {
files.push(...query.data)
}
})
return files
}, [queryResults])
// Sort files based on current sort order
const sortedFiles = useMemo(() => {
const { order, attribute: orderBy } = sortOrder
if (!order || !orderBy) {
return secondarySort(allFiles)
}
const sortedData = stableSort(allFiles, getComparator(order, orderBy))
return secondarySort(sortedData)
}, [allFiles, sortOrder])
// Create sort change handler
const changeSortOrder = useCallback(
(_, attribute, order) => setSortOrder({ attribute, order }),
[setSortOrder]
)
return {
sortOrder,
setSortOrder,
isSettingsLoaded,
allFiles,
sortedFiles,
changeSortOrder
}
}
================================================
FILE: src/modules/views/Folder/hooks/useFileSorting.spec.js
================================================
import { renderHook } from '@testing-library/react'
import { useFileSorting } from './useFileSorting'
// Mock des dépendances
jest.mock('@/hooks', () => ({
useFolderSort: jest.fn(() => [
{ order: 'asc', attribute: 'name' },
jest.fn(),
true
])
}))
jest.mock('cozy-ui/transpiled/react/Table/Virtualized/helpers', () => ({
stableSort: jest.fn((data, comparator) => [...data].sort(comparator)),
getComparator: jest.fn((order, orderBy) => (a, b) => {
if (order === 'asc') {
return a[orderBy]?.localeCompare(b[orderBy])
}
return b[orderBy]?.localeCompare(a[orderBy])
})
}))
describe('useFileSorting', () => {
const mockQueryResults = [
{
data: [
{
_id: '1',
name: 'file-b.txt',
type: 'file',
updated_at: '2023-01-01T10:00:00Z'
},
{
_id: '2',
name: 'file-a.txt',
type: 'file',
updated_at: '2023-01-01T11:00:00Z'
},
{
_id: '3',
name: 'folder-c',
type: 'directory',
updated_at: '2023-01-01T12:00:00Z'
}
]
}
]
const mockOrderProps = {
sortOrder: { order: 'desc', attribute: 'updated_at' },
setOrder: jest.fn(),
isSettingsLoaded: true
}
afterEach(() => {
jest.clearAllMocks()
})
it('should extract all files from query results', () => {
const { result } = renderHook(() =>
useFileSorting('folder-1', mockQueryResults, {})
)
expect(result.current.allFiles).toHaveLength(3)
expect(result.current.allFiles[0]._id).toBe('1')
})
it('should use internal sort order when no orderProps provided', () => {
const { result } = renderHook(() =>
useFileSorting('folder-1', mockQueryResults, {})
)
expect(result.current.sortOrder).toEqual({
order: 'asc',
attribute: 'name'
})
})
it('should use external sort order when orderProps provided', () => {
const { result } = renderHook(() =>
useFileSorting('folder-1', mockQueryResults, mockOrderProps)
)
expect(result.current.sortOrder).toEqual({
order: 'desc',
attribute: 'updated_at'
})
})
it('should apply secondary sort (directories before files)', () => {
const { result } = renderHook(() =>
useFileSorting(
'folder-1',
[
{
data: [
{ _id: '1', name: 'file.txt', type: 'file' },
{ _id: '2', name: 'folder', type: 'directory' }
]
}
],
{}
)
)
// Secondary sort should put directories before files
expect(result.current.sortedFiles[0].type).toBe('directory')
expect(result.current.sortedFiles[1].type).toBe('file')
})
it('should apply both primary and secondary sort', () => {
const { result } = renderHook(() =>
useFileSorting('folder-1', mockQueryResults, mockOrderProps)
)
// Should be sorted by updated_at in descending order (most recent first), then by type
expect(result.current.sortedFiles).toHaveLength(3)
// Verify the actual sort ordering
const sortedFiles = result.current.sortedFiles
// Expected order: updated_at descending (folder-c:12:00, file-a.txt:11:00, file-b.txt:10:00)
// Secondary sort: directories before files (folder-c should come before files)
expect(sortedFiles[0].name).toBe('folder-c')
expect(sortedFiles[0].type).toBe('directory')
expect(sortedFiles[1].name).toBe('file-a.txt')
expect(sortedFiles[1].type).toBe('file')
expect(sortedFiles[2].name).toBe('file-b.txt')
expect(sortedFiles[2].type).toBe('file')
// Alternative verification: map filenames and types
const fileInfo = sortedFiles.map(file => ({
name: file.name,
type: file.type
}))
expect(fileInfo).toEqual([
{ name: 'folder-c', type: 'directory' },
{ name: 'file-a.txt', type: 'file' },
{ name: 'file-b.txt', type: 'file' }
])
})
it('should create changeSortOrder callback', () => {
const { result } = renderHook(() =>
useFileSorting('folder-1', mockQueryResults, mockOrderProps)
)
result.current.changeSortOrder(null, 'size', 'asc')
expect(mockOrderProps.setOrder).toHaveBeenCalledWith({
attribute: 'size',
order: 'asc'
})
})
it('should handle empty query results', () => {
const { result } = renderHook(() => useFileSorting('folder-1', [], {}))
expect(result.current.allFiles).toHaveLength(0)
expect(result.current.sortedFiles).toHaveLength(0)
})
it('should handle query results with empty data arrays', () => {
const { result } = renderHook(() =>
useFileSorting(
'folder-1',
[{ data: [] }, { data: null }, { data: undefined }],
{}
)
)
expect(result.current.allFiles).toHaveLength(0)
})
})
================================================
FILE: src/modules/views/Folder/syncHelpers.js
================================================
import get from 'lodash/get'
/**
* Whether there is a file referenced by a share id
* @param {array} queryResults - List of folders and files
* @param {string} sharingId - Id of an io.cozy.sharings doc
* @returns {bool} true|false
*/
export const isThereFileReferencedBySharingId = (queryResults, sharingId) => {
return queryResults.some(query => {
return query.data.some(file => {
const fileReferences =
file.referenced_by && file.referenced_by.length >= 1
if (fileReferences) {
return file.referenced_by.some(reference => {
if (reference.type === 'io.cozy.sharings') {
return reference.id === sharingId
}
return false
})
}
return false
})
})
}
/**
* Remove a share from sharing context
* @param {object} params - Params
* @param {object} params.sharingsValue - Sharing Context value
* @param {function} params.setSharingsValue - Sharing Context setter
* @param {string} params.sharingId - Id of an io.cozy.sharings doc
*/
export const removeSharingFromContext = ({
sharingsValue,
setSharingsValue,
sharingId
}) => {
delete sharingsValue[sharingId]
setSharingsValue(sharingsValue)
}
/**
* Create a syncing fake file, necessary in the case of sharing without shortcut
* This fake file shows a spinner the time it takes to recover the real file
* @param {object} params - Params
* @param {string} params.sharingId - Id of an io.cozy.sharings doc
* @param {object} params.sharingsValue - Sharing Context value
* @param {object} params.fileValue - Sharing Context file value
* @returns {object} Syncing fake file
*/
export const createSyncingFakeFile = ({ sharingValue }) => {
if (!sharingValue) {
return null
}
return {
name: sharingValue.attributes.description,
id: sharingValue.id,
type: 'directory'
}
}
/**
* Returns syncingFakeFile if it is still needed, otherwise remove the share in context
* @param {object} params - Params
* @param {array} params.queryResults - List of folders and files
* @param {string} params.sharingId - Id of an io.cozy.sharings doc
* @param {object} params.sharingsValue - Sharing Context value
* @param {function} params.setSharingsValue - Sharing Context setter
* @param {object} params.syncingFakeFile - Syncing fake file
* @returns {object} Syncing fake file or null
*/
export const checkSyncingFakeFileObsolescence = ({
queryResults,
sharingId,
sharingsValue,
setSharingsValue,
syncingFakeFile
}) => {
const isThereRealtimeFileReferencedBySharing =
isThereFileReferencedBySharingId(queryResults, sharingId)
if (!isThereRealtimeFileReferencedBySharing) {
return syncingFakeFile
}
removeSharingFromContext({ sharingsValue, setSharingsValue, sharingId })
return null
}
/**
* Create syncing fake file or check if it still longer needed
* @param {object} params - Params
* @param {boolean} params.isEmpty - Whether the query to fetch files returns nothing or error
* @param {boolean} params.isSharingContextEmpty - Whether the sharing context is empty
* @param {array} params.queryResults - List of folders and files
* @param {string} params.sharingId - Id of an io.cozy.sharings doc
* @param {object} params.sharingsValue - Sharing Context value
* @param {function} params.setSharingsValue - Sharing Context setter
* @param {object} params.fileValue - Sharing Context file value
* @returns {object} Syncing fake file
*/
export const computeSyncingFakeFile = ({
isEmpty,
isSharingContextEmpty,
queryResults,
sharingId,
sharingsValue,
setSharingsValue,
fileValue
}) => {
if (isEmpty || isSharingContextEmpty) {
return null
}
const sharingValue = sharingsValue[sharingId]
if (fileValue || !sharingValue) {
return null
}
const syncingFakeFile = createSyncingFakeFile({
sharingValue
})
const updatedSyncingFakeFile = checkSyncingFakeFileObsolescence({
syncingFakeFile,
queryResults,
sharingId,
sharingsValue,
setSharingsValue,
fileValue
})
return updatedSyncingFakeFile
}
/**
* Whether the file is referenced by a share in the sharing context
* @param {object} file - An io.cozy.files doc
* @param {object} sharingsValue - Sharing Context value
* @returns {bool}
*/
export const isReferencedByShareInSharingContext = (file, sharingsValue) => {
const fileReferences = get(file, 'relationships.referenced_by.data')
if (!fileReferences) return false
const fileSharingId = fileReferences.find(
reference => reference.type === 'io.cozy.sharings'
).id
return get(sharingsValue, fileSharingId, false) !== false
}
================================================
FILE: src/modules/views/Folder/syncHelpers.spec.js
================================================
import {
isThereFileReferencedBySharingId,
createSyncingFakeFile,
computeSyncingFakeFile,
checkSyncingFakeFileObsolescence,
isReferencedByShareInSharingContext
} from './syncHelpers'
const queryResults = [
{
id: 'directory',
data: [
{
referenced_by: [
{
type: 'io.cozy.sharings',
id: 'directory-sharing-id'
},
{
type: 'io.cozy.otherType',
id: 'other-type-id'
}
]
}
]
},
{
id: 'file',
data: [
{
referenced_by: []
},
{
referenced_by: [
{
type: 'io.cozy.sharings',
id: 'file-with-sharing-id'
}
]
}
]
}
]
let sharingsValue
const setupComputeSyncingFakeFile = ({
isEmpty = false,
isSharingContextEmpty = false,
queryResults,
sharingId = '',
sharingsValue,
setSharingsValue = jest.fn(),
fileValue = undefined
} = {}) => {
const syncingFakeFile = computeSyncingFakeFile({
isEmpty,
isSharingContextEmpty,
queryResults,
sharingId,
sharingsValue,
setSharingsValue,
fileValue
})
return { syncingFakeFile }
}
describe('syncHelpers', () => {
beforeEach(() => {
sharingsValue = {
id1: {
id: 'id1',
attributes: {
description: 'fileName.ext'
}
},
'file-with-sharing-id': {
id: 'file-with-sharing-id',
attributes: {
description: 'folderName'
}
}
}
})
describe('isThereFileReferencedBySharingId', () => {
it('should return true if a directory is referenced by the sharing id', () => {
expect(
isThereFileReferencedBySharingId(queryResults, 'directory-sharing-id')
).toBeTruthy()
})
it('should return true if a file is referenced by the sharing id', () => {
expect(
isThereFileReferencedBySharingId(queryResults, 'file-with-sharing-id')
).toBeTruthy()
})
it('should return false if no directory/file is referenced by the sharing id', () => {
expect(
isThereFileReferencedBySharingId(queryResults, 'no-sharing-id')
).toBeFalsy()
})
it('should return false if the reference id is not for io.cozy.sharings', () => {
expect(
isThereFileReferencedBySharingId(queryResults, 'other-type-id')
).toBeFalsy()
})
})
describe('createSyncingFakeFile', () => {
it('should return null if no sharing value', () => {
expect(createSyncingFakeFile({})).toBeNull()
})
it('should return fake file well formated according to the sharing value', () => {
expect(
createSyncingFakeFile({
sharingValue: sharingsValue.id1
})
).toMatchObject({
name: 'fileName.ext',
id: 'id1',
type: 'directory'
})
})
})
describe('checkSyncingFakeFileObsolescence', () => {
it('should return syncingFakeFile if there is no file with the same id', () => {
const syncingFakeFile = {
id: 'fakeFileId'
}
expect(
checkSyncingFakeFileObsolescence({
queryResults,
sharingId: 'id1',
sharingsValue,
setSharingsValue: jest.fn(),
syncingFakeFile
})
).toMatchObject(syncingFakeFile)
})
it('should return null if there is a file with the same id', () => {
expect(
checkSyncingFakeFileObsolescence({
queryResults,
sharingId: 'file-with-sharing-id',
sharingsValue,
setSharingsValue: jest.fn()
})
).toBeNull()
})
})
describe('computeSyncingFakeFile', () => {
it('should return null if no content', () => {
const { syncingFakeFile } = setupComputeSyncingFakeFile({ isEmpty: true })
expect(syncingFakeFile).toBeNull()
})
it('should return null if no sharing context', () => {
const { syncingFakeFile } = setupComputeSyncingFakeFile({
isSharingContextEmpty: true
})
expect(syncingFakeFile).toBeNull()
})
it('should return null if no syncingFakeFile created (for example because no sharing context found)', () => {
const { syncingFakeFile } = setupComputeSyncingFakeFile({
sharingId: 'no-id',
sharingsValue
})
expect(syncingFakeFile).toBeNull()
})
it('should return null if syncingFakeFile is no longer needed', () => {
const { syncingFakeFile } = setupComputeSyncingFakeFile({
sharingId: 'file-with-sharing-id',
sharingsValue,
queryResults
})
expect(syncingFakeFile).toBeNull()
})
it('should return syncingFakeFile if still needed', () => {
const { syncingFakeFile } = setupComputeSyncingFakeFile({
sharingId: 'id1',
sharingsValue,
queryResults
})
expect(syncingFakeFile).toMatchObject({
name: 'fileName.ext',
id: 'id1',
type: 'directory'
})
})
})
describe('isReferencedByShareInSharingContext', () => {
it('should return true or false if the file is referenced or not by a share in sharing context', () => {
const referencedFile = {
id: 'fileId',
relationships: {
referenced_by: {
data: [{ id: 'file-with-sharing-id', type: 'io.cozy.sharings' }]
}
}
}
const notReferencedFile = {
id: 'fileId',
relationships: {
referenced_by: {
data: [{ id: 'file-without-sharing-id', type: 'io.cozy.sharings' }]
}
}
}
const FileWithNoReference = {
id: 'fileId',
relationships: {
referenced_by: undefined
}
}
expect(
isReferencedByShareInSharingContext(referencedFile, sharingsValue)
).toBeTruthy()
expect(
isReferencedByShareInSharingContext(notReferencedFile, sharingsValue)
).toBeFalsy()
expect(
isReferencedByShareInSharingContext(FileWithNoReference, sharingsValue)
).toBeFalsy()
})
})
})
================================================
FILE: src/modules/views/Folder/useSyncingFakeFile.js
================================================
import { useContext, useMemo } from 'react'
import { computeSyncingFakeFile } from './syncHelpers'
import AcceptingSharingContext from '@/lib/AcceptingSharingContext'
import { getSharingIdFromUrl } from '@/modules/navigation/duck'
export const useSyncingFakeFile = ({ isEmpty, queryResults }) => {
const { sharingsValue, setSharingsValue, fileValue } = useContext(
AcceptingSharingContext
)
const isSharingContextEmpty = useMemo(
() => Object.keys(sharingsValue).length <= 0,
[sharingsValue]
)
const sharingId = getSharingIdFromUrl(window.location)
const syncingFakeFile = computeSyncingFakeFile({
isEmpty,
isSharingContextEmpty,
queryResults,
sharingId,
sharingsValue,
setSharingsValue,
fileValue
})
return { syncingFakeFile }
}
================================================
FILE: src/modules/views/Folder/virtualized/AddFolderWrapper.jsx
================================================
import React from 'react'
import { useVaultClient } from 'cozy-keys-lib'
import Table from 'cozy-ui/transpiled/react/Table'
import TableBody from 'cozy-ui/transpiled/react/TableBody'
import TableCell from 'cozy-ui/transpiled/react/TableCell'
import TableHead from 'cozy-ui/transpiled/react/TableHead'
import TableRow from 'cozy-ui/transpiled/react/TableRow'
import styles from '@/styles/folder-view.styl'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import AddFolder from '@/modules/filelist/AddFolder'
const AddFolderWrapper = ({
columns,
currentFolderId,
refreshFolderContent,
driveId
}) => {
const vaultClient = useVaultClient()
const { viewType } = useViewSwitcherContext()
if (viewType === 'grid') {
return (
)
}
return (
{columns.map((column, idx) => (
{column.label}
))}
—
—
—
)
}
export default AddFolderWrapper
================================================
FILE: src/modules/views/Folder/virtualized/FolderViewBody.jsx
================================================
import React, { useState, useEffect, useMemo } from 'react'
import { useSelector } from 'react-redux'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import FolderViewBodyContent from './FolderViewBodyContent'
import { makeColumns } from '../helpers'
import { EmptyWrapper } from '@/components/Error/Empty'
import Oops from '@/components/Error/Oops'
import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'
import FileListRowsPlaceholder from '@/modules/filelist/FileListRowsPlaceholder'
import { isTypingNewFolderName } from '@/modules/filelist/duck'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
import AddFolderWrapper from '@/modules/views/Folder/virtualized/AddFolderWrapper'
const FolderViewBody = ({
currentFolderId,
displayedFolder,
queryResults,
actions,
canUpload = true,
canDrag,
withFilePath = false,
refreshFolderContent = null,
driveId,
orderProps = {
sortOrder: {},
setOrder: () => {},
isSettingsLoaded: true
}
}) => {
const { isDesktop } = useBreakpoints()
const IsAddingFolder = useSelector(isTypingNewFolderName)
const { isBigThumbnail } = useThumbnailSizeContext()
const { clearItems } = useNewItemHighlightContext()
const { sortOrder, setOrder, isSettingsLoaded } = orderProps
const isInError = queryResults.some(query => query.fetchStatus === 'failed')
const hasDataToShow =
!isInError &&
queryResults.some(query => query.data && query.data.length > 0)
const isLoading =
!hasDataToShow &&
queryResults.some(
query => query.fetchStatus === 'loading' && !query.lastUpdate
)
const isEmpty = !isInError && !isLoading && !hasDataToShow
const columns = useMemo(() => makeColumns(isBigThumbnail), [isBigThumbnail])
/**
* Since we are not able to restore the scroll correctly,
* and force the scroll to top every time we change the
* current folder. This is to avoid this kind of weird
* behavior:
* - If I go to a sub-folder, if this subfolder has a lot
* of data and I scrolled down until the bottom. If I go
* back, then my folder will also be scrolled down.
*
* This is an ugly hack, yeah.
* */
useEffect(() => {
if (isDesktop) {
const scrollable = document.querySelectorAll(
'[data-testid=fil-content-body]'
)[0]
if (scrollable) {
scrollable.scroll({ top: 0 })
}
} else {
window.scroll({ top: 0 })
}
clearItems()
}, [currentFolderId, isDesktop, clearItems])
/**
* When we mount the component when we already have data in cache,
* the mount is time consuming since we'll render at least 100 lines
* of File.
*
* React seems to batch together the fact that :
* - we change a route
* - we want to render 100 files
* resulting in a non smooth transition between views (Drive / Recent / ...)
*
* In order to bypass this batch, we use a state to first display a much
* more simpler component and then the files
*/
const [needsToWait, setNeedsToWait] = useState(true)
useEffect(() => {
let timeout = null
if (!isLoading) {
timeout = setTimeout(() => {
setNeedsToWait(false)
}, 50)
}
return () => clearTimeout(timeout)
}, [isLoading])
if (needsToWait || isLoading || !isSettingsLoaded) {
return
}
if (isInError) {
return
}
/* TODO FolderViewBody should not have the responsability to chose
which empty component to display. It should be done by the "view" itself.
But adding a new prop like
)
}
return (
)
}
return (
)
}
export default FolderViewBody
================================================
FILE: src/modules/views/Folder/virtualized/FolderViewBodyContent.jsx
================================================
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import { useClient } from 'cozy-client'
import flag from 'cozy-flags'
import { useSharingContext } from 'cozy-sharing'
import {
stableSort,
getComparator
} from 'cozy-ui/transpiled/react/Table/Virtualized/helpers'
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
import { useI18n } from 'twake-i18n'
import Grid from './Grid'
import { secondarySort } from '../helpers'
import { useSyncingFakeFile } from '../useSyncingFakeFile'
import { SHARED_DRIVES_DIR_ID } from '@/constants/config'
import { useShiftSelection } from '@/hooks/useShiftSelection'
import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'
import { isTypingNewFolderName } from '@/modules/filelist/duck'
import { useCancelable } from '@/modules/move/hooks/useCancelable'
import RectangularSelection from '@/modules/selection/RectangularSelection'
import SelectionBar from '@/modules/selection/SelectionBar'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import Table from '@/modules/views/Folder/virtualized/Table'
import { makeRows, onDrop } from '@/modules/views/Folder/virtualized/helpers'
const FolderViewBodyContent = ({
currentFolderId,
displayedFolder,
actions,
columns,
queryResults,
isEmpty,
canDrag,
withFilePath,
driveId,
orderProps = {
sortOrder: {},
setOrder: () => {}
},
refreshFolderContent
}) => {
const folderViewRef = useRef()
// Stores the actual scroll container HTMLElement from virtuoso's scrollerRef callback.
// This is needed because virtuosoRef.current only exposes the handle API (scrollTo, scrollBy, etc.),
// not the DOM element required for RectangularSelection's auto-scroll functionality.
const [scrollElement, setScrollElement] = useState(null)
// Ref to store the virtuoso imperative handle (scrollTo, scrollToIndex, etc.).
// Note: This is a plain ref and does not trigger re-renders on assignment.
// Consumers should access virtuosoRef.current when needed (e.g., in effects
// triggered by other state changes like highlightedItems).
const virtuosoRef = useRef(null)
const client = useClient()
const { selectAll, selectedItems, setSelectedItems, setIsSelectAll } =
useSelectionContext()
const { sharedPaths } = useSharingContext()
const { registerCancelable } = useCancelable()
const { showAlert } = useAlert()
const { viewType } = useViewSwitcherContext()
const { t } = useI18n()
const IsAddingFolder = useSelector(isTypingNewFolderName)
const { sortOrder } = orderProps
const { order, attribute: orderBy } = sortOrder
const fetchMore = queryResults.find(query => query.hasMore)?.fetchMore
const isSelectedItem = file => {
if (file._id === SHARED_DRIVES_DIR_ID) {
return false
}
return selectedItems.some(item => item._id === file._id)
}
const { syncingFakeFile } = useSyncingFakeFile({ isEmpty, queryResults })
const rows = useMemo(
() => makeRows({ queryResults, IsAddingFolder, syncingFakeFile }),
[queryResults, IsAddingFolder, syncingFakeFile]
)
const sortedRows = useMemo(() => {
const sortedData = stableSort(rows, getComparator(order, orderBy))
return secondarySort(sortedData)
}, [rows, order, orderBy])
const { setLastInteractedItem, onShiftClick } = useShiftSelection(
{
items: sortedRows,
viewType
},
folderViewRef
)
const onInteractWithFile = (itemId, event) => {
setLastInteractedItem(itemId)
onShiftClick(itemId, event)
}
const isDynamicSelectionEnabled = flag('drive.dynamic-selection.enabled')
const handleContainerClick = useCallback(
e => {
const target = e.target
const isOnFile = target.closest('[data-file-id]')
if (isOnFile) return
setSelectedItems({})
setIsSelectAll(false)
},
[setSelectedItems, setIsSelectAll]
)
const dragProps = useMemo(
() => ({
enabled: canDrag,
dragId: 'drag-drive',
onDrop: onDrop({
client,
showAlert,
selectAll,
registerCancelable,
sharedPaths,
t,
refreshFolderContent,
displayedFolder
})
}),
[
canDrag,
client,
showAlert,
selectAll,
registerCancelable,
sharedPaths,
t,
refreshFolderContent,
displayedFolder
]
)
const tableComponent = (
)
const gridComponent = (
)
const viewContent = viewType === 'list' ? tableComponent : gridComponent
return (
{isDynamicSelectionEnabled ? (
{viewContent}
) : (
{viewContent}
)}
)
}
export default FolderViewBodyContent
================================================
FILE: src/modules/views/Folder/virtualized/Grid.jsx
================================================
import cx from 'classnames'
import React, {
forwardRef,
useCallback,
useMemo,
useRef,
useState
} from 'react'
import flag from 'cozy-flags'
import { useVaultClient } from 'cozy-keys-lib'
import VirtualizedGridList from 'cozy-ui/transpiled/react/GridList/Virtualized'
import virtuosoComponents from 'cozy-ui/transpiled/react/GridList/Virtualized/Dnd/virtuosoComponents'
import CustomDragLayer from 'cozy-ui/transpiled/react/utils/Dnd/CustomDrag/CustomDragLayer'
import GridWrapper from './GridWrapper'
import styles from '@/styles/filelist.styl'
import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'
import AddFolder from '@/modules/filelist/AddFolder'
import { GridFileWithSelection as GridFile } from '@/modules/filelist/virtualized/GridFile'
import useScrollToHighlightedItem from '@/modules/views/Folder/virtualized/useScrollToHighlightedItem'
const GridItem = forwardRef(({ item, context, ...props }, ref) => {
const { componentProps } = context
const DefaultItem = virtuosoComponents.Item
return (
)
})
GridItem.displayName = 'GridItem'
const GridItemMemo = React.memo(GridItem)
const mergedComponents = {
...virtuosoComponents,
List: GridWrapper,
Item: GridItemMemo
}
const Grid = forwardRef(
(
{
items,
actions,
withFilePath = false,
refreshFolderContent,
isSharingContextEmpty,
isSharingShortcut = null,
isReferencedByShareInSharingContext,
sharingsValue,
fetchMore,
dragProps,
currentFolderId,
driveId,
onInteractWithFile,
selectedItems,
isSelectedItem,
virtuosoRef: parentVirtuosoRef,
scrollerRef
},
ref
) => {
const vaultClient = useVaultClient()
const internalVirtuosoRef = useRef(null)
const virtuosoRef = parentVirtuosoRef || internalVirtuosoRef
const [itemsInDropProcess, setItemsInDropProcess] = useState([])
const componentProps = useMemo(
() => ({
Item: {
className: cx(styles['fil-content-grid-item'])
}
}),
[]
)
const itemRenderer = useCallback(
(file, { isOver }) => (
<>
{file.type != 'tempDirectory' ? (
) : (
)}
>
),
[
actions,
withFilePath,
refreshFolderContent,
isSharingContextEmpty,
isSharingShortcut,
isReferencedByShareInSharingContext,
sharingsValue,
onInteractWithFile,
vaultClient,
currentFolderId,
driveId
]
)
const gridContext = useMemo(
() => ({
actions,
selectedItems,
isSelectedItem,
dragProps,
itemRenderer,
itemsInDropProcess,
componentProps,
setItemsInDropProcess,
items
}),
[
actions,
selectedItems,
isSelectedItem,
dragProps,
itemRenderer,
itemsInDropProcess,
componentProps,
items
]
)
useScrollToHighlightedItem(virtuosoRef, items)
return (
{dragProps?.dragId && }
)
}
)
Grid.displayName = 'Grid'
export default React.memo(Grid)
================================================
FILE: src/modules/views/Folder/virtualized/GridWrapper.jsx
================================================
import cx from 'classnames'
import React, { forwardRef } from 'react'
import styles from '@/styles/folder-view.styl'
const GridWrapper = forwardRef(({ style, children }, ref) => (
{children}
))
GridWrapper.displayName = 'GridWrapper'
export default GridWrapper
================================================
FILE: src/modules/views/Folder/virtualized/Table.jsx
================================================
import cx from 'classnames'
import React, {
forwardRef,
useCallback,
useMemo,
useRef,
useState
} from 'react'
import { useSelector } from 'react-redux'
import flag from 'cozy-flags'
import VirtualizedTable from 'cozy-ui/transpiled/react/Table/Virtualized'
import TableRowDnD from 'cozy-ui/transpiled/react/Table/Virtualized/Dnd/TableRow'
import virtuosoComponentsDnd from 'cozy-ui/transpiled/react/Table/Virtualized/Dnd/virtuosoComponents'
import CustomDragLayer from 'cozy-ui/transpiled/react/utils/Dnd/CustomDrag/CustomDragLayer'
import { secondarySort } from '../helpers'
import styles from '@/styles/filelist.styl'
import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'
import { useClipboardContext } from '@/contexts/ClipboardProvider'
import { isRenaming as isRenamingSelector } from '@/modules/drive/rename'
import Cell from '@/modules/filelist/virtualized/cells/Cell'
import { useSelectionContext } from '@/modules/selection/SelectionProvider'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
import useScrollToHighlightedItem from '@/modules/views/Folder/virtualized/useScrollToHighlightedItem'
const TableRow = forwardRef(({ item, context, children, ...props }, ref) => {
const { isItemCut } = useClipboardContext()
const isCut = isItemCut(item._id)
const { actions } = context
return (
{children}
)
})
TableRow.displayName = 'TableRow'
const TableRowMemo = React.memo(TableRow)
const components = {
...virtuosoComponentsDnd,
TableRow: TableRowMemo
}
const Table = forwardRef(
(
{
rows,
columns,
dragProps,
selectAll,
fetchMore,
isSelectedItem,
selectedItems,
currentFolderId,
withFilePath,
actions,
driveId,
orderProps = {
sortOrder: {},
setOrder: () => {}
},
onInteractWithFile,
refreshFolderContent,
virtuosoRef: parentVirtuosoRef,
scrollerRef
},
ref
) => {
const { toggleSelectedItem, setSelectedItems } = useSelectionContext()
const { isNew } = useNewItemHighlightContext()
const isRenamingActive = useSelector(isRenamingSelector)
const internalVirtuosoRef = useRef(null)
const virtuosoRef = parentVirtuosoRef || internalVirtuosoRef
const [itemsInDropProcess, setItemsInDropProcess] = useState([])
const { sortOrder, setOrder } = orderProps
const selectedItemsCount = Object.keys(selectedItems || {}).length
const handleRowSelect = useCallback(
(row, event) => {
if (isRenamingActive) return
event?.stopPropagation?.()
if (
flag('drive.dynamic-selection.enabled') &&
selectedItemsCount > 0 &&
!event?.shiftKey
) {
setSelectedItems({ [row._id]: row })
} else {
toggleSelectedItem(row)
}
onInteractWithFile?.(row?._id, event)
},
[
toggleSelectedItem,
onInteractWithFile,
selectedItemsCount,
setSelectedItems,
isRenamingActive
]
)
const handleSort = ({ order, orderBy }) => {
setOrder({
order,
attribute: orderBy
})
}
const tableContext = useMemo(
() => ({
actions,
selectedItems,
isSelectedItem,
dragProps,
itemsInDropProcess,
setItemsInDropProcess
}),
[actions, selectedItems, isSelectedItem, dragProps, itemsInDropProcess]
)
// Memoize componentsProps to avoid recreating the Cell component on every render
// This follows Virtuoso's recommendation to not define custom components inline
const componentsProps = useMemo(
() => ({
rowContent: {
onClick: handleRowSelect,
children: (
|
)
}
}),
[
handleRowSelect,
currentFolderId,
withFilePath,
actions,
onInteractWithFile,
refreshFolderContent,
driveId
]
)
useScrollToHighlightedItem(virtuosoRef, rows)
return (
{dragProps?.dragId && }
)
}
)
Table.displayName = 'Table'
export default React.memo(Table)
================================================
FILE: src/modules/views/Folder/virtualized/helpers.js
================================================
import logger from '@/lib/logger'
import { executeMove } from '@/modules/paste'
export const makeRows = ({ queryResults, IsAddingFolder, syncingFakeFile }) => {
const rows = queryResults.flatMap(el => el.data)
if (IsAddingFolder) {
rows.push({
type: 'tempDirectory'
})
}
if (syncingFakeFile) {
rows.push(syncingFakeFile)
}
return rows
}
export const onDrop =
({
client,
showAlert,
selectAll,
registerCancelable,
sharedPaths,
t,
refreshFolderContent,
displayedFolder
}) =>
async (draggedItems, itemHovered, selectedItems) => {
if (
itemHovered.type !== 'directory' ||
draggedItems.some(({ _id }) => _id === itemHovered._id)
) {
return null
}
if (selectedItems.length > 0) {
selectAll([])
}
try {
await Promise.all(
draggedItems.map(async draggedItem => {
const force =
Array.isArray(sharedPaths) &&
!sharedPaths.includes(itemHovered.path)
await registerCancelable(
executeMove(
client,
draggedItem,
displayedFolder,
itemHovered,
force
)
)
})
)
showAlert({
severity: 'success',
message: t('Move.success', {
subject: draggedItems[0].name,
target: itemHovered.name,
smart_count: draggedItems.length
})
})
if (refreshFolderContent) {
refreshFolderContent()
}
} catch (error) {
logger.warn(`Error while dragging files:`, error)
showAlert({
severity: 'error',
message: t('Move.error', {
smart_count: draggedItems.length
})
})
}
}
================================================
FILE: src/modules/views/Folder/virtualized/useScrollToHighlightedItem.jsx
================================================
import { useEffect, useRef } from 'react'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
/**
* Scrolls the virtualized list to the latest highlighted item present in the
* current dataset. This ensures that newly created files/folders become
* visible even when inserted outside the current viewport.
*
* @param {React.MutableRefObject} virtuosoRef - Ref to the Virtuoso component instance
* @param {Array} items - Current array of items rendered by the list
*/
const useScrollToHighlightedItem = (virtuosoRef, items) => {
const { highlightedItems } = useNewItemHighlightContext()
const lastScrolledIdRef = useRef(null)
useEffect(() => {
if (!highlightedItems?.length) {
lastScrolledIdRef.current = null
return
}
if (!Array.isArray(items) || items.length === 0) {
return
}
const indexById = new Map()
for (const [index, current] of items.entries()) {
if (current?._id) {
indexById.set(current._id, index)
}
}
const targetItem = highlightedItems[highlightedItems.length - 1]
if (
!targetItem?._id ||
!indexById.has(targetItem._id) ||
targetItem._id === lastScrolledIdRef.current
)
return
const targetIndex = indexById.get(targetItem._id)
const virtuosoHandle = virtuosoRef?.current
if (targetIndex === -1 || !virtuosoHandle) {
return
}
virtuosoHandle.scrollToIndex({
index: targetIndex,
align: 'center',
behavior: 'smooth'
})
lastScrolledIdRef.current = targetItem._id
}, [highlightedItems, items, virtuosoRef])
}
export default useScrollToHighlightedItem
================================================
FILE: src/modules/views/Folder/virtualized/useScrollToHighlightedItem.spec.jsx
================================================
import { renderHook, waitFor } from '@testing-library/react'
import flag from 'cozy-flags'
import useScrollToHighlightedItem from './useScrollToHighlightedItem'
import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'
jest.mock('cozy-flags', () => jest.fn())
jest.mock('@/modules/upload/NewItemHighlightProvider', () => ({
useNewItemHighlightContext: jest.fn()
}))
describe('useScrollToHighlightedItem', () => {
let highlightedItemsValue
let virtuosoRef
beforeEach(() => {
highlightedItemsValue = []
virtuosoRef = {
current: {
scrollToIndex: jest.fn()
}
}
flag.mockReturnValue(true)
useNewItemHighlightContext.mockImplementation(() => ({
highlightedItems: highlightedItemsValue
}))
})
afterEach(() => {
jest.clearAllMocks()
})
const setHighlightedItems = items => {
highlightedItemsValue = items
useNewItemHighlightContext.mockImplementation(() => ({
highlightedItems: highlightedItemsValue
}))
}
it('scrolls to the highlighted item present in the dataset', async () => {
setHighlightedItems([{ _id: 'match' }])
const items = [{ _id: 'foo' }, { _id: 'match' }, { _id: 'bar' }]
renderHook(() => useScrollToHighlightedItem(virtuosoRef, items))
await waitFor(() =>
expect(virtuosoRef.current.scrollToIndex).toHaveBeenCalledWith({
index: 1,
align: 'center',
behavior: 'smooth'
})
)
})
})
================================================
FILE: src/modules/views/Modal/DuplicateSharedDriveFilesView.jsx
================================================
import React from 'react'
import { useNavigate, useLocation, useParams } from 'react-router-dom'
import { LoaderModal } from '@/components/LoaderModal'
import useDisplayedFolder from '@/hooks/useDisplayedFolder'
import { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal'
import { useQueryMultipleSharedDriveFolders } from '@/modules/shareddrives/hooks/useQueryMultipleSharedDriveFolders'
const DuplicateSharedDriveFilesView = () => {
const navigate = useNavigate()
const { state } = useLocation()
const { driveId } = useParams()
const { displayedFolder } = useDisplayedFolder()
const { sharedDriveResults } = useQueryMultipleSharedDriveFolders({
folderIds: state?.fileIds ?? [],
driveId
})
if (sharedDriveResults && displayedFolder) {
const onClose = () => {
navigate('..', { replace: true })
}
const entries = sharedDriveResults.map(file => ({
...file,
path: `${displayedFolder.path}/${file.name}`
}))
return (
)
}
return
}
export { DuplicateSharedDriveFilesView }
================================================
FILE: src/modules/views/Modal/MoveFilesView.jsx
================================================
import React from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
import { hasQueryBeenLoaded, useQuery } from 'cozy-client'
import { LoaderModal } from '@/components/LoaderModal'
import useDisplayedFolder from '@/hooks/useDisplayedFolder'
import MoveModal from '@/modules/move/MoveModal'
import { useSharedDrives } from '@/modules/shareddrives/hooks/useSharedDrives'
import { buildParentsByIdsQuery } from '@/queries'
const MoveFilesView = ({ isOpenInViewer }) => {
const navigate = useNavigate()
const { state } = useLocation()
const { displayedFolder } = useDisplayedFolder()
const { sharedDrives } = useSharedDrives()
const hasFileIds = state?.fileIds != undefined
const fileQuery = buildParentsByIdsQuery(hasFileIds ? state.fileIds : [])
const fileResult = useQuery(fileQuery.definition, {
...fileQuery.options,
enabled: hasFileIds
})
if (!hasFileIds) {
return
}
if (hasQueryBeenLoaded(fileResult) && fileResult.data && displayedFolder) {
const onClose = () => {
navigate('..', { replace: true })
}
const onMovingSuccess = () => {
/**
* In file viewer, after moving success the file will not exist in the current folder,
* we should navigate to current folder view instead.
* */
navigate(isOpenInViewer ? '../..' : '..', { replace: true })
}
const showNextcloudFolder = !fileResult.data.some(
file => file.type === 'directory'
)
return (
0}
/>
)
}
return
}
export { MoveFilesView }
================================================
FILE: src/modules/views/Modal/MovePublicFilesView.tsx
================================================
import React from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
import usePublicFileByIdsQuery from '../Public/usePublicFileByIdsQuery'
import { LoaderModal } from '@/components/LoaderModal'
import useDisplayedFolder from '@/hooks/useDisplayedFolder'
import MoveModal from '@/modules/move/MoveModal'
interface LocationState {
fileIds?: string[]
}
const MovePublicFilesView = (): React.ReactElement => {
const navigate = useNavigate()
const { state } = useLocation() as { state: LocationState | null }
const { displayedFolder } = useDisplayedFolder()
const hasFileIds = state?.fileIds !== undefined
const { files, fetchStatus } = usePublicFileByIdsQuery(
state?.fileIds ?? ([] as string[])
)
if (!hasFileIds) {
return
}
if (fetchStatus === 'loaded' && files.length && displayedFolder) {
const onClose = (): void => {
navigate('..', { replace: true })
}
const onMovingSuccess = (): void => {
navigate('..', { replace: true, state: { refresh: true } })
}
return (
)
}
return
}
export { MovePublicFilesView }
================================================
FILE: src/modules/views/Modal/MoveSharedDriveFilesView.jsx
================================================
import React from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { LoaderModal } from '@/components/LoaderModal'
import useDisplayedFolder from '@/hooks/useDisplayedFolder'
import MoveModal from '@/modules/move/MoveModal'
import { useQueryMultipleSharedDriveFolders } from '@/modules/shareddrives/hooks/useQueryMultipleSharedDriveFolders'
const MoveSharedDriveFilesView = () => {
const navigate = useNavigate()
const { state } = useLocation()
const { displayedFolder } = useDisplayedFolder()
const { sharedDriveResults } = useQueryMultipleSharedDriveFolders({
folderIds: state.fileIds,
driveId: displayedFolder?.driveId
})
if (sharedDriveResults && displayedFolder) {
const onClose = () => {
navigate('..', { replace: true })
}
const showNextcloudFolder = !sharedDriveResults.some(
file => file.type === 'directory'
)
const entries = sharedDriveResults.map(file => ({
...file,
path: `${displayedFolder.path}/${file.name}`
}))
return (
)
}
return
}
export { MoveSharedDriveFilesView }
================================================
FILE: src/modules/views/Modal/QualifyFileView.jsx
================================================
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { isQueryLoading, useClient, useQuery } from 'cozy-client'
import { getQualification } from 'cozy-client/dist/models/document'
import { themesList } from 'cozy-client/dist/models/document/documentTypeData'
import { isQualificationNote } from 'cozy-client/dist/models/document/documentTypeDataHelpers'
import { getBoundT } from 'cozy-client/dist/models/document/locales'
import Icon from 'cozy-ui/transpiled/react/Icon'
import FileDuotoneIcon from 'cozy-ui/transpiled/react/Icons/FileDuotone'
import FileTypeNoteIcon from 'cozy-ui/transpiled/react/Icons/FileTypeNote'
import NestedSelectResponsive from 'cozy-ui/transpiled/react/NestedSelect/NestedSelectResponsive'
import { useI18n } from 'twake-i18n'
import IconStack from '@/components/IconStack'
import { LoaderModal } from '@/components/LoaderModal'
import { buildFileOrFolderByIdQuery } from '@/queries'
const OptionIconStack = ({ icon }) => {
return (
}
{...(icon && {
foregroundIcon:
})}
/>
)
}
const makeOptions = ({ t, scannerT, focusedId }) => {
const themesWithNone = [
{
id: 'none',
items: [],
label: t('Scan.none')
},
themesList
]
return {
focusedId,
children: themesWithNone.map(theme => {
return {
id: theme.label,
title:
theme.id === 'none'
? t('Scan.none')
: scannerT(`Scan.themes.${theme.label}`),
icon: ,
children: theme.items.map(item => {
return {
id: item.label,
item,
title: scannerT(`Scan.items.${item.label}`),
icon: isQualificationNote(item) ? (
) : (
)
}
})
}
})
}
}
export const QualifyFileView = () => {
const navigate = useNavigate()
const { fileId } = useParams()
const { t, lang } = useI18n()
const client = useClient()
const scannerT = getBoundT(lang || 'en')
const fileQuery = buildFileOrFolderByIdQuery(fileId)
const { data: file, ...fileQueryResult } = useQuery(
fileQuery.definition,
fileQuery.options
)
const qualificationLabel = getQualification(file)?.label
const defaultOptions = makeOptions({
t,
scannerT,
focusedId: qualificationLabel
})
const onClose = () => {
navigate('..', { replace: true })
}
const handleClick = async ({ title, item }) => {
const fileCollection = client.collection('io.cozy.files')
const removeQualification = qualificationLabel && title === t('Scan.none')
if (!qualificationLabel && removeQualification) {
return onClose()
}
/*
In the case where we remove the qualification it's necessary to define the attribute to `null` and not `undefined`, with `undefined` the stack does not return the attribute and today the Redux store is not updated for a missing attribute.
As a result, the UI is not updated and continues to display the qualification on the document, even though it has been deleted in CouchDB.
*/
await fileCollection.updateMetadataAttribute(file._id, {
qualification: removeQualification ? null : item
})
onClose()
}
const isSelected = ({ title, item }) => {
return qualificationLabel
? qualificationLabel === item?.label
: title === t('Scan.none')
}
if (isQueryLoading(fileQueryResult)) {
return
}
return (
)
}
================================================
FILE: src/modules/views/Modal/ShareDisplayedFolderView.jsx
================================================
import React from 'react'
import { useNavigate } from 'react-router-dom'
import flag from 'cozy-flags'
import { ShareModal } from 'cozy-sharing'
import { SHARING_TAB_DRIVES } from '@/constants/config'
import { useDisplayedFolder } from '@/hooks'
const ShareDisplayedFolderView = () => {
const { displayedFolder } = useDisplayedFolder()
const navigate = useNavigate()
if (displayedFolder) {
const onClose = () => {
navigate('..', { replace: true })
}
const onRevokeSuccess = () => {
if (displayedFolder.driveId) {
navigate(`/sharings?tab=${SHARING_TAB_DRIVES}`)
}
}
return (
)
}
return null
}
export { ShareDisplayedFolderView }
================================================
FILE: src/modules/views/Modal/ShareFileView.jsx
================================================
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { hasQueryBeenLoaded, useQuery } from 'cozy-client'
import flag from 'cozy-flags'
import { ShareModal } from 'cozy-sharing'
import { LoaderModal } from '@/components/LoaderModal'
import {
buildFileOrFolderByIdQuery,
buildSharedDriveFileOrFolderByIdQuery
} from '@/queries'
const ShareFileView = () => {
const navigate = useNavigate()
const { fileId, driveId } = useParams()
const fileQuery = driveId
? buildSharedDriveFileOrFolderByIdQuery({ fileId, driveId })
: buildFileOrFolderByIdQuery(fileId)
const fileResult = useQuery(fileQuery.definition, fileQuery.options)
const handleExit = () => {
navigate('..', { replace: true })
}
if (hasQueryBeenLoaded(fileResult) && fileResult.data) {
return (
)
}
// After successfully removing self from a shared file, the file is not found anymore but the query is considered loaded
// We check if the data is null, meaning the sharing has been removed
if (hasQueryBeenLoaded(fileResult) && !fileResult.data) {
handleExit()
}
// Accessing the URL of a file that doesn't exist anymore (or never existed)
// e.g. /folder/io.cozy.files.shared-with-me-dir/file/someidresolvingto404/share
if (fileResult.fetchStatus === 'failed') {
handleExit()
}
return
}
export { ShareFileView }
================================================
FILE: src/modules/views/Nextcloud/NextcloudDeleteView.jsx
================================================
import React from 'react'
import {
Navigate,
useLocation,
useNavigate,
useSearchParams
} from 'react-router-dom'
import { LoaderModal } from '@/components/LoaderModal'
import { getParentPath } from '@/lib/path'
import { NextcloudDeleteConfirm } from '@/modules/nextcloud/components/NextcloudDeleteConfirm'
import { useNextcloudEntries } from '@/modules/nextcloud/hooks/useNextcloudEntries'
const NextcloudDeleteView = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { pathname } = useLocation()
const { entries, hasEntries, isLoading } = useNextcloudEntries()
const newPath = getParentPath(pathname) + `?${searchParams.toString()}`
const handleClose = () => {
navigate(newPath, { replace: true })
}
if (!hasEntries) {
return
}
if (entries && !isLoading) {
return
}
return
}
export { NextcloudDeleteView }
================================================
FILE: src/modules/views/Nextcloud/NextcloudDestroyView.tsx
================================================
import React, { FC, useCallback } from 'react'
import {
Navigate,
useLocation,
useParams,
useNavigate,
useSearchParams
} from 'react-router-dom'
import { useClient } from 'cozy-client'
import { NextcloudFile } from 'cozy-client/types/types'
interface NextcloudFilesCollection {
deletePermanently: (file: NextcloudFile) => Promise
}
import { LoaderModal } from '@/components/LoaderModal'
import { getParentPath } from '@/lib/path'
import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'
import { useNextcloudEntries } from '@/modules/nextcloud/hooks/useNextcloudEntries'
import { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'
import DestroyConfirm from '@/modules/trash/components/DestroyConfirm'
const NextcloudDestroyView: FC = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { sourceAccount } = useParams()
const client = useClient()
const path = useNextcloudPath({
insideTrash: true
})
const { pathname } = useLocation()
const { hasEntries, entries, isLoading } = useNextcloudEntries({
insideTrash: true
})
const newPath = `${getParentPath(pathname) ?? ''}?${searchParams.toString()}`
const handleClose = (): void => {
navigate(newPath, { replace: true })
}
const handleConfirm = useCallback(async (): Promise => {
if (entries) {
for (const file of entries) {
const collection = client?.collection(
'io.cozy.remote.nextcloud.files'
) as unknown as NextcloudFilesCollection
await collection.deletePermanently(file)
}
const queryId =
computeNextcloudFolderQueryId({
sourceAccount,
path
}) + '/trashed'
void client?.resetQuery(queryId)
}
}, [client, entries, path, sourceAccount])
if (!hasEntries) {
return
}
if (entries && !isLoading) {
return (
)
}
return
}
export { NextcloudDestroyView }
================================================
FILE: src/modules/views/Nextcloud/NextcloudDuplicateView.tsx
================================================
import React, { FC } from 'react'
import {
Navigate,
useLocation,
useNavigate,
useSearchParams
} from 'react-router-dom'
import { LoaderModal } from '@/components/LoaderModal'
import { getParentPath } from '@/lib/path'
import { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal'
import { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder'
import { useNextcloudEntries } from '@/modules/nextcloud/hooks/useNextcloudEntries'
const NextcloudDuplicateView: FC = () => {
const { pathname } = useLocation()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const currentFolder = useNextcloudCurrentFolder()
const { entries, hasEntries, isLoading } = useNextcloudEntries()
const newPath =
(getParentPath(pathname) ?? '') + `?${searchParams.toString()}`
if (!hasEntries && !isLoading) {
return
}
if (entries && currentFolder) {
const onClose = (): void => {
navigate(newPath, { replace: true })
}
return (
)
}
return
}
export { NextcloudDuplicateView }
================================================
FILE: src/modules/views/Nextcloud/NextcloudFolderView.jsx
================================================
import React from 'react'
import { Outlet, useParams } from 'react-router-dom'
import { Content } from 'cozy-ui/transpiled/react/Layout'
import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'
import { NextcloudBanner } from '@/modules/nextcloud/components/NextcloudBanner'
import { NextcloudBreadcrumb } from '@/modules/nextcloud/components/NextcloudBreadcrumb'
import { NextcloudFolderBody } from '@/modules/nextcloud/components/NextcloudFolderBody'
import { NextcloudToolbar } from '@/modules/nextcloud/components/NextcloudToolbar'
import { useNextcloudFolder } from '@/modules/nextcloud/hooks/useNextcloudFolder'
import { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'
import FolderView from '@/modules/views/Folder/FolderView'
import FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'
const NextcloudFolderView = () => {
const { isMobile } = useBreakpoints()
const { sourceAccount } = useParams()
const path = useNextcloudPath()
const { t } = useI18n()
const { nextcloudResult } = useNextcloudFolder({
sourceAccount,
path
})
var queryResults = [nextcloudResult]
if (path === '/') {
queryResults = [
nextcloudResult,
{
id: 'io.cozy.remote.nextcloud.files.trash-dir',
fetchStatus: nextcloudResult.fetchStatus,
data:
nextcloudResult.fetchStatus === 'loaded'
? [
{
_id: 'io.cozy.remote.nextcloud.files.trash-dir',
type: 'directory',
name: t('NextcloudBreadcrumb.trash')
}
]
: []
}
]
}
return (
)
}
export { NextcloudFolderView }
================================================
FILE: src/modules/views/Nextcloud/NextcloudMoveView.jsx
================================================
import React from 'react'
import {
Navigate,
useLocation,
useNavigate,
useSearchParams
} from 'react-router-dom'
import { LoaderModal } from '@/components/LoaderModal'
import { getParentPath } from '@/lib/path'
import MoveModal from '@/modules/move/MoveModal'
import { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder'
import { useNextcloudEntries } from '@/modules/nextcloud/hooks/useNextcloudEntries'
const NextcloudMoveView = () => {
const { pathname } = useLocation()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const currentFolder = useNextcloudCurrentFolder()
const { entries, hasEntries, isLoading } = useNextcloudEntries()
const newPath = getParentPath(pathname) + `?${searchParams.toString()}`
const onClose = () => {
navigate(newPath, { replace: true })
}
if (!hasEntries) {
return
}
if (entries && !isLoading && currentFolder) {
return (
)
}
return
}
export { NextcloudMoveView }
================================================
FILE: src/modules/views/Nextcloud/NextcloudTrashEmptyView.tsx
================================================
import React, { FC } from 'react'
import {
useNavigate,
useParams,
useLocation,
useSearchParams
} from 'react-router-dom'
import { useClient } from 'cozy-client'
interface NextcloudFilesCollection {
emptyTrash: (sourceAccount: string | undefined) => Promise
}
import { getParentPath } from '@/lib/path'
import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'
import { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'
import { EmptyTrashConfirm } from '@/modules/trash/components/EmptyTrashConfirm'
const NextcloudTrashEmptyView: FC = () => {
const { sourceAccount } = useParams()
const [searchParams] = useSearchParams()
const { pathname } = useLocation()
const path = useNextcloudPath({
insideTrash: true
})
const navigate = useNavigate()
const client = useClient()
const handleClose = (): void => {
navigate(`${getParentPath(pathname) ?? ''}?${searchParams.toString()}`, {
replace: true
})
}
const handleConfirm = async (): Promise => {
const collection = client?.collection(
'io.cozy.remote.nextcloud.files'
) as unknown as NextcloudFilesCollection
await collection.emptyTrash(sourceAccount)
const queryId =
computeNextcloudFolderQueryId({
sourceAccount,
path
}) + '/trashed'
await client?.resetQuery(queryId)
}
return
}
export { NextcloudTrashEmptyView }
================================================
FILE: src/modules/views/Nextcloud/NextcloudTrashView.tsx
================================================
import React, { FC } from 'react'
import { Outlet, useParams } from 'react-router-dom'
import { NextcloudBanner } from '@/modules/nextcloud/components/NextcloudBanner'
import { NextcloudBreadcrumb } from '@/modules/nextcloud/components/NextcloudBreadcrumb'
import { NextcloudTrashFolderBody } from '@/modules/nextcloud/components/NextcloudTrashFolderBody'
import { useNextcloudFolder } from '@/modules/nextcloud/hooks/useNextcloudFolder'
import { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'
import { TrashToolbar } from '@/modules/trash/components/TrashToolbar'
import FolderView from '@/modules/views/Folder/FolderView'
import FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'
const NextcloudTrashView: FC = () => {
const { sourceAccount } = useParams()
const path = useNextcloudPath({
insideTrash: true
})
const { nextcloudResult } = useNextcloudFolder({
sourceAccount,
path,
insideTrash: true
})
return (