Skip to content

Commit d36c233

Browse files
committed
✨(front) Allow to pick files from drive if set up.
Use the drive-sdk package to allow user to pick a file from its drive workspace if one drive instance has been set up through envvars. https://github.com/suitenumerique/drive
1 parent d978405 commit d36c233

File tree

17 files changed

+1013
-85
lines changed

17 files changed

+1013
-85
lines changed

src/frontend/package-lock.json

Lines changed: 44 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@blocknote/core": "0.33.0",
2424
"@blocknote/mantine": "0.33.0",
2525
"@blocknote/react": "0.33.0",
26+
"@gouvfr-lasuite/drive-sdk": "0.0.1",
2627
"@gouvfr-lasuite/ui-kit": "0.8.2",
2728
"@hookform/resolvers": "5.1.1",
2829
"@openfun/cunningham-react": "3.2.0",
@@ -33,8 +34,8 @@
3334
"@viselect/react": "3.9.0",
3435
"clsx": "2.1.1",
3536
"date-fns": "4.1.0",
36-
"downshift": "9.0.10",
3737
"dompurify": "3.2.6",
38+
"downshift": "9.0.10",
3839
"i18next": "25.3.0",
3940
"next": "15.3.4",
4041
"posthog-js": "1.257.0",

src/frontend/src/features/forms/components/message-form/_index.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,9 @@
135135
flex-direction: column;
136136
box-sizing: border-box;
137137
position: relative;
138-
}
138+
}
139+
140+
.drive-attachment-picker:disabled {
141+
cursor: wait;
142+
pointer-events: auto;
143+
}

src/frontend/src/features/forms/components/message-form/attachment-uploader.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import { useDropzone } from 'react-dropzone';
1010
import { AttachmentHelper } from '@/features/utils/attachment-helper';
1111
import { useDebounceCallback } from '@/hooks/use-debounce-callback';
1212
import { DropZone } from './dropzone';
13+
import { DriveAttachmentPicker, DriveFile } from './drive-attachment-picker';
14+
import { Icon } from '@gouvfr-lasuite/ui-kit';
1315

1416
interface AttachmentUploaderProps {
15-
initialAttachments?: readonly Attachment[];
17+
initialAttachments?: (DriveFile | Attachment)[];
1618
onChange: () => void;
1719
}
1820

@@ -25,7 +27,7 @@ export const AttachmentUploader = ({
2527
const form = useFormContext();
2628
const { t, i18n } = useTranslation();
2729
const { selectedMailbox } = useMailboxContext();
28-
const [attachments, setAttachments] = useState<Attachment[]>(initialAttachments.map((a) => ({ ...a, state: 'idle' })));
30+
const [attachments, setAttachments] = useState<(DriveFile | Attachment)[]>(initialAttachments.map((a) => ({ ...a, state: 'idle' })));
2931
const [uploadingQueue, setUploadingQueue] = useState<File[]>([]);
3032
const [failedQueue, setFailedQueue] = useState<File[]>([]);
3133
const { mutateAsync: uploadBlob } = useBlobUploadCreate();
@@ -46,14 +48,18 @@ export const AttachmentUploader = ({
4648
}
4749
const removeToUploadingQueue = (attachments: File[]) => setUploadingQueue(uploadingQueue => removeToQueue(uploadingQueue, attachments));
4850
const removeToFailedQueue = (attachments: File[]) => setFailedQueue(failedQueue => removeToQueue(failedQueue, attachments));
49-
const appendToAttachments = (newAttachments: Attachment[]) => {
51+
const appendToAttachments = (newAttachments: (DriveFile | Attachment)[]) => {
5052
// Append attachments to the end of the list and sort by descending created_at
5153
setAttachments(
5254
attachments => [...attachments, ...newAttachments].sort((a, b) => Number(new Date(b.created_at)) - Number(new Date(a.created_at)))
5355
);
5456
}
55-
const removeToAttachments = (entries: Attachment[]) => {
56-
setAttachments(attachments => attachments.filter((a) => !entries.some(e => e.blobId === a.blobId)));
57+
58+
const removeToAttachments = (entries: (DriveFile | Attachment)[]) => {
59+
setAttachments(attachments => attachments.filter((a) => !entries.some(e => {
60+
if ('blobId' in a && 'blobId' in e) return e.blobId === a.blobId;
61+
return e.id === a.id;
62+
})));
5763
}
5864

5965
/**
@@ -94,18 +100,24 @@ export const AttachmentUploader = ({
94100
if (!hasClickInBucketList) {
95101
getRootProps().onClick?.(event);
96102
}
103+
}
97104

105+
const handleDriveAttachmentPick = (attachments: DriveFile[]) => {
106+
appendToAttachments(attachments);
98107
}
99108

100109
/**
101-
* Update the form value when the attachments change
102-
* Trigger the onChange callback to update the form each 1s
110+
* Update the form value when the attachments change.
103111
*/
104112
useEffect(() => {
105-
form.setValue('attachments', attachments.map((attachment) => ({
113+
// Only keep local attachments
114+
const localAttachments = attachments.filter(attachment => 'blobId' in attachment);
115+
const driveAttachments = attachments.filter(attachment => 'url' in attachment);
116+
form.setValue('attachments', localAttachments.map((attachment) => ({
106117
blobId: attachment.blobId,
107118
name: attachment.name
108119
})), { shouldDirty: true });
120+
form.setValue('driveAttachments', driveAttachments, { shouldDirty: true });
109121
if (form.formState.dirtyFields.attachments) {
110122
debouncedOnChange();
111123
}
@@ -122,11 +134,12 @@ export const AttachmentUploader = ({
122134
<div className="attachment-uploader__input">
123135
<Button
124136
color="tertiary"
125-
icon={<span className="material-icons">attach_file</span>}
137+
icon={<Icon name="attach_file" />}
126138
type="button"
127139
>
128140
{t("message_form.attachments_uploader.input_label")}
129141
</Button>
142+
<DriveAttachmentPicker onPick={handleDriveAttachmentPick} />
130143
<p className="attachment-uploader__input__helper-text">
131144
{t("message_form.attachments_uploader.or_drag_and_drop")}
132145
</p>
@@ -154,7 +167,11 @@ export const AttachmentUploader = ({
154167
<AttachmentItem key={`uploading-${entry.name}-${entry.size}-${entry.lastModified}`} attachment={entry} isLoading />
155168
))}
156169
{attachments.map((entry) => (
157-
<AttachmentItem key={entry.blobId} attachment={entry} onDelete={() => removeToAttachments([entry])} />
170+
<AttachmentItem
171+
key={'blobId' in entry ? entry.blobId : entry.id}
172+
attachment={entry}
173+
onDelete={() => removeToAttachments([entry])}
174+
/>
158175
))}
159176
</div>
160177
</div>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useCallback, useState } from "react"
2+
import { Button, Tooltip } from "@openfun/cunningham-react"
3+
import { openPicker, type Item, type PickerResult } from "@gouvfr-lasuite/drive-sdk";
4+
import { useTranslation } from "react-i18next";
5+
import { Spinner } from "@gouvfr-lasuite/ui-kit";
6+
import { useConfig } from "@/features/providers/config";
7+
import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature";
8+
import { DriveIcon } from "./drive-icon";
9+
import { Attachment } from "@/features/api/gen/models/attachment";
10+
11+
export type DriveFile = { url: string } & Omit<Attachment, 'sha256' | 'blobId'>;
12+
13+
type DriveAttachmentPickerProps = {
14+
onPick: (attachments: DriveFile[]) => void;
15+
}
16+
17+
/**
18+
* DriveAttachmentPicker is a component that allows the user to pick files
19+
* from a Drive instance if one is configured otherwise it will return null.
20+
*
21+
* Drive Config is retrieved from the backend. Take a look at the `DRIVE_CONFIG`
22+
* in the `settings.py` file in the backend.
23+
*
24+
* https://github.com/suitenumerique/drive
25+
*/
26+
export const DriveAttachmentPicker = ({ onPick }: DriveAttachmentPickerProps) => {
27+
const { t } = useTranslation();
28+
const [isLoading, setIsLoading] = useState(false);
29+
const config = useConfig();
30+
const isDriveDisabled = !useFeatureFlag(FEATURE_KEYS.DRIVE);
31+
const serializeToDriveFile = (item: Item): DriveFile => ({
32+
id: item.id,
33+
name: item.title,
34+
url: item.url,
35+
type: item.type,
36+
size: item.size,
37+
created_at: new Date().toISOString(),
38+
});
39+
40+
const pick = useCallback(async () => {
41+
if (isDriveDisabled) return;
42+
setIsLoading(true);
43+
let result: PickerResult | null = null;
44+
45+
try {
46+
result = await openPicker({
47+
url: config.DRIVE!.sdk_url,
48+
apiUrl: config.DRIVE!.api_url,
49+
});
50+
} catch (error) {
51+
console.error(error);
52+
} finally {
53+
setIsLoading(false);
54+
}
55+
56+
if (result?.type === "picked" && result.items) {
57+
onPick(result.items.map(serializeToDriveFile));
58+
}
59+
}, [isDriveDisabled]);
60+
61+
if (isDriveDisabled) return null;
62+
63+
return (
64+
<Tooltip content={t('tooltips.add_attachment_from_drive')}>
65+
<Button
66+
color="tertiary"
67+
icon={isLoading ? <Spinner size="sm" /> : <DriveIcon />}
68+
type="button"
69+
disabled={isLoading}
70+
aria-busy={isLoading}
71+
onClick={pick}
72+
className="drive-attachment-picker"
73+
/>
74+
</Tooltip>
75+
)
76+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { SVGProps } from "react"
2+
3+
export const DriveIcon = ({ size = 'small', ...props }: { size?: 'small' | 'medium' | 'large' } & SVGProps<SVGSVGElement>) => {
4+
const sizeMap = {
5+
small: 21,
6+
medium: 32,
7+
large: 48,
8+
}
9+
return (
10+
<svg width={sizeMap[size]} height={sizeMap[size]} viewBox="0 0 64 65" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
11+
<path d="M55.3531 19.9525H16.8394C12.465 19.9525 8.06606 23.3947 7.01421 27.6408L4 39.8087V16.1976C4 13.6559 4.58282 11.7415 5.74845 10.4546C6.92902 9.15152 8.56539 8.5 10.6575 8.5H17.0013C17.7783 8.5 18.4508 8.5563 19.0187 8.66891C19.5866 8.78152 20.1096 8.97457 20.5878 9.24805C21.066 9.50544 21.5666 9.8674 22.0897 10.3339L23.3674 11.4681C23.995 11.9989 24.5853 12.377 25.1383 12.6022C25.6912 12.8274 26.3711 12.94 27.1781 12.94H48.025C50.4309 12.94 52.2541 13.6076 53.4945 14.9428C54.607 16.1262 55.2265 17.7961 55.3531 19.9525Z" fill="currentColor"/>
12+
<path d="M11.3531 54.5C8.93219 54.5 7.27319 53.8071 6.37613 52.4213C5.47493 51.0522 5.3552 49.032 6.01696 46.3606L10.6542 27.6409C11.2228 25.3457 13.6005 23.4851 15.9651 23.4851H58.7796C61.1442 23.4851 62.6002 25.3457 62.0316 27.6409L57.3944 46.3606C56.7326 49.032 55.6344 51.0522 54.0997 52.4213C52.5609 53.8071 50.723 54.5 48.586 54.5H11.3531Z" fill="currentColor"/>
13+
</svg>
14+
)
15+
}

0 commit comments

Comments
 (0)