From 2a6446efad785cf59b6ab7b290cfbc4cca1aec1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Tue, 7 Apr 2026 15:31:58 +0200 Subject: [PATCH 1/5] fix: Missing CSS in intents targets --- src/targets/intents/index.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/targets/intents/index.jsx b/src/targets/intents/index.jsx index c5048ddac5..aa2c391c4c 100644 --- a/src/targets/intents/index.jsx +++ b/src/targets/intents/index.jsx @@ -2,6 +2,7 @@ import 'cozy-ui/transpiled/react/stylesheet.css' import 'cozy-ui/dist/cozy-ui.utils.min.css' +import 'cozy-ui-plus/dist/stylesheet.css' import 'cozy-viewer/dist/stylesheet.css' import 'cozy-sharing/dist/stylesheet.css' From 8821d126108e4f1511b1ccb69e1fbdbd6d61e5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Tue, 7 Apr 2026 15:33:49 +0200 Subject: [PATCH 2/5] feat: Add PICK intent --- manifest.webapp | 5 ++++ .../services/components/IntentHandler.jsx | 6 +++++ src/modules/services/components/Picker.jsx | 24 +++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 src/modules/services/components/Picker.jsx diff --git a/manifest.webapp b/manifest.webapp index 7603648d10..99a8166892 100644 --- a/manifest.webapp +++ b/manifest.webapp @@ -78,6 +78,11 @@ "action": "OPEN", "type": ["io.cozy.suggestions"], "href": "/intents" + }, + { + "action": "PICK", + "type": ["io.cozy.files"], + "href": "/intents" } ], "entrypoints": [ diff --git a/src/modules/services/components/IntentHandler.jsx b/src/modules/services/components/IntentHandler.jsx index 40d4897934..b7c7c3037d 100644 --- a/src/modules/services/components/IntentHandler.jsx +++ b/src/modules/services/components/IntentHandler.jsx @@ -5,6 +5,7 @@ import Intents from 'cozy-interapp' import logger from 'cozy-logger' import Embeder from './Embeder' +import Picker from './Picker' const IntentHandler = ({ intentId }) => { const client = useClient() @@ -32,6 +33,11 @@ const IntentHandler = ({ intentId }) => { intent.attributes.type === 'io.cozy.files' ) { component = Embeder + } else if ( + intent.attributes.action === 'PICK' && + intent.attributes.type === 'io.cozy.files' + ) { + component = Picker } setState({ diff --git a/src/modules/services/components/Picker.jsx b/src/modules/services/components/Picker.jsx new file mode 100644 index 0000000000..2f0aaeb1ac --- /dev/null +++ b/src/modules/services/components/Picker.jsx @@ -0,0 +1,24 @@ +import React from 'react' + +import { useClient } from 'cozy-client' +import { makeSharingLink } from 'cozy-client/dist/models/sharing' +import FilePicker from 'cozy-ui-plus/dist/FilePicker' + +const Picker = ({ service }) => { + const client = useClient() + + const handleClick = async fileId => { + // TODO: check multiple sharing link issues + // const [shareLinkTTL, shareLinkPermanent] = await Promise.all([ + // makeSharingLink(client, [fileId], { ttl: '5m' }), + // makeSharingLink(client, [fileId]) + // ]) + + const sharingLink = await makeSharingLink(client, [fileId]) + service.terminate({ id: fileId, sharingLink }) + } + + return +} + +export default Picker From 2b26beef9a7fa9eed0e5d1a6eaa0348a7d27879a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Wed, 8 Apr 2026 20:19:41 +0200 Subject: [PATCH 3/5] feat: Use local FilePicker instead of cozy-ui-plus --- .../components/FilePicker/FilePickerBody.jsx | 111 ++++++++++++ .../FilePicker/FilePickerBodyItem.jsx | 126 ++++++++++++++ .../FilePicker/FilePickerBodyItem.spec.jsx | 164 ++++++++++++++++++ .../FilePicker/FilePickerBreadcrumb.jsx | 55 ++++++ .../FilePicker/FilePickerFooter.jsx | 42 +++++ .../FilePicker/FilePickerFooter.spec.jsx | 41 +++++ .../FilePicker/FilePickerHeader.jsx | 89 ++++++++++ .../services/components/FilePicker/helpers.js | 46 +++++ .../components/FilePicker/helpers.spec.js | 84 +++++++++ .../services/components/FilePicker/index.jsx | 94 ++++++++++ .../components/FilePicker/locales/en.json | 8 + .../components/FilePicker/locales/fr.json | 8 + .../components/FilePicker/locales/ru.json | 8 + .../components/FilePicker/locales/vi.json | 8 + .../services/components/FilePicker/queries.js | 37 ++++ .../components/FilePicker/styles.styl | 18 ++ src/modules/services/components/Picker.jsx | 3 +- 17 files changed, 941 insertions(+), 1 deletion(-) create mode 100644 src/modules/services/components/FilePicker/FilePickerBody.jsx create mode 100644 src/modules/services/components/FilePicker/FilePickerBodyItem.jsx create mode 100644 src/modules/services/components/FilePicker/FilePickerBodyItem.spec.jsx create mode 100644 src/modules/services/components/FilePicker/FilePickerBreadcrumb.jsx create mode 100644 src/modules/services/components/FilePicker/FilePickerFooter.jsx create mode 100644 src/modules/services/components/FilePicker/FilePickerFooter.spec.jsx create mode 100644 src/modules/services/components/FilePicker/FilePickerHeader.jsx create mode 100644 src/modules/services/components/FilePicker/helpers.js create mode 100644 src/modules/services/components/FilePicker/helpers.spec.js create mode 100644 src/modules/services/components/FilePicker/index.jsx create mode 100644 src/modules/services/components/FilePicker/locales/en.json create mode 100644 src/modules/services/components/FilePicker/locales/fr.json create mode 100644 src/modules/services/components/FilePicker/locales/ru.json create mode 100644 src/modules/services/components/FilePicker/locales/vi.json create mode 100644 src/modules/services/components/FilePicker/queries.js create mode 100644 src/modules/services/components/FilePicker/styles.styl diff --git a/src/modules/services/components/FilePicker/FilePickerBody.jsx b/src/modules/services/components/FilePicker/FilePickerBody.jsx new file mode 100644 index 0000000000..3e571653fa --- /dev/null +++ b/src/modules/services/components/FilePicker/FilePickerBody.jsx @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types' +import React, { useCallback } from 'react' + +import { models, useQuery } from 'cozy-client' +import List from 'cozy-ui/transpiled/react/List' +import LoadMore from 'cozy-ui/transpiled/react/LoadMore' + +import FilePickerBodyItem from './FilePickerBodyItem' +import { isValidFile } from './helpers' +import { buildContentFolderQuery } from './queries' + +const { + file: { isDirectory } +} = models + +const FilePickerBody = ({ + navigateTo, + folderId, + onSelectItemId, + itemsIdsSelected, + itemTypesAccepted, + multiple +}) => { + const contentFolderQuery = buildContentFolderQuery(folderId) + const { + data: contentFolder, + hasMore, + fetchMore + } = useQuery(contentFolderQuery.definition, contentFolderQuery.options) + + const onCheck = useCallback( + itemId => { + const isChecked = itemsIdsSelected.some( + fileIdSelected => fileIdSelected === itemId + ) + if (isChecked) { + onSelectItemId( + itemsIdsSelected.filter(fileIdSelected => fileIdSelected !== itemId) + ) + } else onSelectItemId(prev => [...prev, itemId]) + }, + [itemsIdsSelected, onSelectItemId] + ) + + // When click on checkbox/radio area... + const handleChoiceClick = useCallback( + item => () => { + if (multiple) onCheck(item._id) + else onSelectItemId(item._id) + }, + [multiple, onCheck, onSelectItemId] + ) + + // ...when click anywhere on the rest of the line + const handleListItemClick = useCallback( + item => () => { + if (isDirectory(item)) { + navigateTo(contentFolder.find(it => it._id === item._id)) + } + + if (isValidFile(item, itemTypesAccepted)) { + if (multiple) onCheck(item._id) + else onSelectItemId(item._id) + } + }, + [ + contentFolder, + itemTypesAccepted, + multiple, + navigateTo, + onCheck, + onSelectItemId + ] + ) + + return ( + + {contentFolder && + contentFolder.map((item, idx) => { + const hasDivider = contentFolder + ? idx !== contentFolder.length - 1 + : false + + return ( + + ) + })} + {hasMore && } + + ) +} + +FilePickerBody.propTypes = { + onSelectItemId: PropTypes.func.isRequired, + itemsIdsSelected: PropTypes.arrayOf(PropTypes.string).isRequired, + folderId: PropTypes.string.isRequired, + navigateTo: PropTypes.func.isRequired, + itemTypesAccepted: PropTypes.arrayOf(PropTypes.string).isRequired +} + +export default FilePickerBody diff --git a/src/modules/services/components/FilePicker/FilePickerBodyItem.jsx b/src/modules/services/components/FilePicker/FilePickerBodyItem.jsx new file mode 100644 index 0000000000..d21992299d --- /dev/null +++ b/src/modules/services/components/FilePicker/FilePickerBodyItem.jsx @@ -0,0 +1,126 @@ +import cx from 'classnames' +import { filesize } from 'filesize' +import PropTypes from 'prop-types' +import React from 'react' + +import { models } from 'cozy-client' +import Checkbox from 'cozy-ui/transpiled/react/Checkbox' +import Divider from 'cozy-ui/transpiled/react/Divider' +import Icon from 'cozy-ui/transpiled/react/Icon' +import FileTypeFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder' +import FileTypeText from 'cozy-ui/transpiled/react/Icons/FileTypeText' +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 Radio from 'cozy-ui/transpiled/react/Radios' +import { makeStyles } from 'cozy-ui/transpiled/react/styles' +import { useI18n } from 'twake-i18n' + +import { isValidFile, isValidFolder } from './helpers' +import styles from './styles.styl' + +const { + file: { isDirectory, isFile } +} = models + +const useStyles = makeStyles(() => ({ + verticalDivider: { + height: '2rem', + display: 'flex', + alignSelf: 'auto', + alignItems: 'center', + marginLeft: '0.5rem' + } +})) + +const FilePickerBodyItem = ({ + item, + itemTypesAccepted, + multiple, + handleChoiceClick, + handleListItemClick, + itemsIdsSelected, + hasDivider +}) => { + const classes = useStyles() + const { f } = useI18n() + const hasChoice = + isValidFile(item, itemTypesAccepted) || + isValidFolder(item, itemTypesAccepted) + + const Input = multiple ? Checkbox : Radio + + const listItemSecondaryContent = isFile(item) + ? `${f(item.updated_at, 'dd LLL yyyy')} - ${filesize(item.size, { + base: 10 + })}` + : null + + return ( + <> + +
+ + + + +
+ {isDirectory(item) && hasChoice && ( + + )} +
+ { + // handled by onClick on the container + }} + checked={itemsIdsSelected.includes(item._id)} + value={item._id} + className={cx('u-p-0', { + 'u-o-100': hasChoice, + 'u-o-0': !hasChoice + })} + disabled={!hasChoice} + /> +
+
+ {hasDivider && } + + ) +} + +FilePickerBodyItem.propTypes = { + item: PropTypes.object.isRequired, + itemTypesAccepted: PropTypes.arrayOf(PropTypes.string).isRequired, + multiple: PropTypes.bool, + handleChoiceClick: PropTypes.func.isRequired, + handleListItemClick: PropTypes.func.isRequired, + itemsIdsSelected: PropTypes.arrayOf(PropTypes.string).isRequired, + hasDivider: PropTypes.bool.isRequired +} + +export default FilePickerBodyItem diff --git a/src/modules/services/components/FilePicker/FilePickerBodyItem.spec.jsx b/src/modules/services/components/FilePicker/FilePickerBodyItem.spec.jsx new file mode 100644 index 0000000000..d0e82d698e --- /dev/null +++ b/src/modules/services/components/FilePicker/FilePickerBodyItem.spec.jsx @@ -0,0 +1,164 @@ +import { render, fireEvent } from '@testing-library/react' +import { filesize } from 'filesize' +import React from 'react' + +import FilePickerBodyItem from './FilePickerBodyItem' +import DemoProvider from './docs/DemoProvider' +import { isValidFile, isValidFolder } from './helpers' + +const mockFile01 = { + _id: '001', + type: 'file', + name: 'Filename.pdf', + mime: 'application/pdf', + updated_at: '2021-01-01T12:00:00.000000+01:00' +} +const mockFolder01 = { + _id: '002', + type: 'directory', + name: 'Foldername', + updated_at: '2021-01-01T12:00:00.000000+01:00' +} + +jest.mock('filesize', () => jest.fn()) +jest.mock('./helpers', () => ({ + ...jest.requireActual('./helpers'), + isValidFile: jest.fn(), + isValidFolder: jest.fn() +})) + +describe('FilePickerBodyItem components:', () => { + const mockHandleChoiceClick = jest.fn() + const mockHandleListItemClick = jest.fn() + filesize.mockReturnValue('111Ko') + + const setup = ({ + item = mockFile01, + multiple = false, + validFile = false, + validFolder = false + } = {}) => { + isValidFile.mockReturnValue(validFile) + isValidFolder.mockReturnValue(validFolder) + + return render( + + + + ) + } + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should be rendered correctly', () => { + const { container } = setup() + + expect(container).toBeDefined() + }) + + it('should display filename', () => { + const { getByText } = setup() + + expect(getByText('Filename.pdf')) + }) + + it('should display foldername', () => { + const { getByText } = setup({ item: mockFolder01 }) + + expect(getByText('Foldername')) + }) + + it("should item's line is not disabled when has not valid type & is File", () => { + const { getByTestId } = setup({ + item: mockFile01, + validFile: false, + validFolder: false + }) + const listItem = getByTestId('list-item') + + expect(listItem).toHaveAttribute('aria-disabled', 'true') + }) + + it("should item's line is not disabled when has not valid type & is Folder", () => { + const { getByTestId } = setup({ + item: mockFolder01, + validFile: false, + validFolder: false + }) + const listItem = getByTestId('list-item') + + expect(listItem).toHaveAttribute('aria-disabled', 'false') + }) + + describe('Functions called', () => { + it('should call "handleChoiceClick" function when click on checkbox/radio area', () => { + const { getByTestId } = setup({ validFile: true }) + fireEvent.click(getByTestId('choice-onclick')) + + expect(mockHandleChoiceClick).toHaveBeenCalled() + }) + + it('should NOT call "handleChoiceClick" function when click on checkbox/radio area, if is Folder & not accepted', () => { + const { getByTestId } = setup({ + item: mockFolder01, + validFolder: false + }) + fireEvent.click(getByTestId('choice-onclick')) + + expect(mockHandleChoiceClick).not.toHaveBeenCalled() + }) + it('should NOT call "handleChoiceClick" function when click on checkbox/radio area, if is File & not accepted', () => { + const { getByTestId } = setup({ validFile: false }) + fireEvent.click(getByTestId('choice-onclick')) + + expect(mockHandleChoiceClick).not.toHaveBeenCalled() + }) + + it('should call "handleListItemClick" function when click on ListItem node', () => { + const { getByTestId } = setup() + fireEvent.click(getByTestId('listitem-onclick')) + + expect(mockHandleListItemClick).toHaveBeenCalled() + }) + }) + + describe('Attribute "multiple"', () => { + it('should radio button exists if "multiple" atribute is False', () => { + const { getByTestId } = setup() + const radioBtn = getByTestId('radio-btn') + expect(radioBtn).not.toBeNull() + }) + + it('should checkbox button exists if "multiple" atribute is True', () => { + const { getByTestId } = setup({ multiple: true }) + const checkboxBtn = getByTestId('checkbox-btn') + expect(checkboxBtn).not.toBeNull() + }) + }) + + describe('Radio/Checkbox button', () => { + it('should disable and not display the Radio button if it is a File and is not accepted', () => { + const { getByTestId } = setup({ validFile: false }) + const radioBtn = getByTestId('radio-btn') + + expect(radioBtn.getAttribute('disabled')).toBe(null) + }) + + it('should disable and not display the Radio button if it is a Folder and is not accepted', () => { + const { getByTestId } = setup({ item: mockFolder01 }) + const radioBtn = getByTestId('radio-btn') + + expect(radioBtn.getAttribute('disabled')).toBe(null) + }) + }) +}) diff --git a/src/modules/services/components/FilePicker/FilePickerBreadcrumb.jsx b/src/modules/services/components/FilePicker/FilePickerBreadcrumb.jsx new file mode 100644 index 0000000000..2f219d0d10 --- /dev/null +++ b/src/modules/services/components/FilePicker/FilePickerBreadcrumb.jsx @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types' +import React, { Fragment, useCallback, memo } from 'react' + +import Icon from 'cozy-ui/transpiled/react/Icon' +import RightIcon from 'cozy-ui/transpiled/react/Icons/Right' +import Typography from 'cozy-ui/transpiled/react/Typography' +import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' + +import styles from './styles.styl' + +const FilePickerBreadcrumb = ({ path, onBreadcrumbClick }) => { + const { isMobile } = useBreakpoints() + const hasPath = path && path.length > 0 + + const navigateTo = useCallback( + folder => () => onBreadcrumbClick(folder), + [onBreadcrumbClick] + ) + + return ( + + {hasPath + ? isMobile + ? path[path.length - 1].name + : path.map((folder, idx) => { + if (idx < path.length - 1) { + return ( + + + {folder.name} + + + + ) + } else { + return {folder.name} + } + }) + : null} + + ) +} + +FilePickerBreadcrumb.propTypes = { + path: PropTypes.array, + onBreadcrumbClick: PropTypes.func +} + +export default memo(FilePickerBreadcrumb) diff --git a/src/modules/services/components/FilePicker/FilePickerFooter.jsx b/src/modules/services/components/FilePicker/FilePickerFooter.jsx new file mode 100644 index 0000000000..83b591c15e --- /dev/null +++ b/src/modules/services/components/FilePicker/FilePickerFooter.jsx @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types' +import React, { memo } from 'react' + +import Button from 'cozy-ui/transpiled/react/Buttons' +import { createUseI18n } from 'twake-i18n' + +import en from './locales/en.json' +import fr from './locales/fr.json' +import ru from './locales/ru.json' +import vi from './locales/vi.json' + +const locales = { en, fr, ru, vi } +const useI18n = createUseI18n(locales) + +const FilePickerFooter = ({ onConfirm, onClose, disabledConfirm }) => { + const { t } = useI18n() + + return ( + <> +