Skip to content

Commit c06cd09

Browse files
Merge pull request #1713 from CleverCloud/cellar-explorer-upload-objects
feat(cellar-explorer): add directory creation and objects upload
2 parents 9bb11e8 + c99f755 commit c06cd09

13 files changed

Lines changed: 452 additions & 11 deletions

sandbox/cc-cellar-explorer/cc-cellar-explorer-sandbox.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class CcCellarExplorerSandbox extends LitElement {
2525
constructor() {
2626
super();
2727

28-
this._addonId = 'addon_ad9e692d-3d28-47b9-82db-76840db955a7';
28+
this._addonId = 'addon_9262abfc-3394-479e-8f15-491c97b906d3';
2929

3030
/** @type {Ref<CcInputText>} */
3131
this._addonIdInputRef = createRef();

src/components/cc-cellar-explorer/cc-cellar-explorer.client.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,36 @@ export class CellarExplorerClient {
154154
.then(sendToApi({ apiConfig: this._apiConfig }))
155155
.catch(catchError);
156156
}
157+
158+
/**
159+
* @param {string} bucketName
160+
* @param {string} objectName
161+
* @param {File} file
162+
*/
163+
async uploadObject(bucketName, objectName, file) {
164+
const result = await Promise.resolve({
165+
method: 'post',
166+
url: `/v4/cellar/organisations/${this._ownerId}/cellar/${this._addonId}/buckets/${encodeURIComponent(bucketName)}/objects/${encodeURIComponent(objectName)}/presigned-url`,
167+
})
168+
.then(sendToApi({ apiConfig: this._apiConfig }))
169+
.catch(catchError);
170+
171+
const presignedUrl = result.url;
172+
173+
const response = await fetch(presignedUrl, {
174+
method: 'post',
175+
headers: { 'Content-Type': file.type ?? 'application/octet-stream' },
176+
body: file,
177+
});
178+
179+
if (!response.ok) {
180+
const body = await response.json().catch(() => /** @type {null} */ (null));
181+
if (body?.code != null) {
182+
throw new CellarExplorerError(body.code, body.error, body.context);
183+
}
184+
throw new Error(`Upload failed with status ${response.status}`);
185+
}
186+
}
157187
}
158188

159189
/**

src/components/cc-cellar-explorer/cc-cellar-explorer.client.types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ export interface CellarFile {
2828
name: string;
2929
updatedAt: string;
3030
contentLength: number;
31+
volatile?: boolean;
3132
}
3233

3334
export interface CellarDirectory {
3435
type: 'directory';
3536
key: string;
3637
name: string;
38+
volatile?: boolean;
3739
}
3840

3941
export interface CellarFileDetails extends CellarFile {

src/components/cc-cellar-object-list/cc-cellar-object-list.ctrl.js

Lines changed: 172 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { Abortable } from '../../lib/abortable.js';
22
import { i18n } from '../../lib/i18n/i18n.js';
33
import { notifyError, notifySuccess } from '../../lib/notifications.js';
4-
import { isCellarExplorerErrorWithCode } from '../cc-cellar-explorer/cc-cellar-explorer.client.js';
4+
import { isCellarExplorerErrorWithCode, pathToString } from '../cc-cellar-explorer/cc-cellar-explorer.client.js';
55
import '../cc-smart-container/cc-smart-container.js';
66
import { CcCellarNavigateToHomeEvent } from './cc-cellar-object-list.events.js';
77
import './cc-cellar-object-list.js';
88

99
/**
1010
* @import { CcCellarObjectList } from './cc-cellar-object-list.js'
11-
* @import { CellarObjectListState, CellarObjectListStateLoaded, CellarObjectState, CellarFileState, CellarFileDetailsState } from './cc-cellar-object-list.types.js'
11+
* @import { CellarObjectListState, CellarObjectListStateLoaded, CellarObjectState, CellarFileState, CellarFileDetailsState, CellarDirectoryCreateFormState } from './cc-cellar-object-list.types.js'
1212
* @import { CellarExplorerClient } from '../cc-cellar-explorer/cc-cellar-explorer.client.js'
13+
* @import { CellarDirectory } from '../cc-cellar-explorer/cc-cellar-explorer.client.types.js'
1314
* @import { UpdateCallback } from '../common.types.js'
1415
* @import { OnEventCallback } from '../../lib/smart/smart-component.types.js'
1516
*/
1617

18+
const DIRECTORY_INVALID_CHARS = /[/\\{}^%`\]">[~<#|\u0080-\u00FF]/;
19+
1720
export class ObjectListController {
1821
/** @type {CellarExplorerClient} */
1922
#cellarClient;
@@ -86,6 +89,18 @@ export class ObjectListController {
8689
onEvent('cc-cellar-object-delete', (objectKey) => {
8790
this.deleteObject(objectKey);
8891
});
92+
93+
onEvent('cc-cellar-object-create-directory', (directoryName) => {
94+
this.createDirectory(directoryName);
95+
});
96+
97+
onEvent('cc-cellar-object-download', (objectKey) => {
98+
this.download(objectKey);
99+
});
100+
101+
onEvent('cc-cellar-object-upload', (file) => {
102+
this.uploadObject(file);
103+
});
89104
}
90105

91106
abort() {
@@ -212,6 +227,32 @@ export class ObjectListController {
212227
}
213228
}
214229

230+
/**
231+
* @param {string} objectKey
232+
* @returns {Promise<void>}
233+
*/
234+
async download(objectKey) {
235+
this.#updateDetails({ state: 'downloading' });
236+
try {
237+
const signedUrl = await this.#cellarClient.getObjectSignedUrl(this.#bucketName, objectKey);
238+
239+
const element = document.createElement('a');
240+
element.setAttribute('href', signedUrl.url);
241+
element.setAttribute('target', '_blank');
242+
document.body.appendChild(element);
243+
element.click();
244+
document.body.removeChild(element);
245+
} catch (error) {
246+
this.#handleErrorOnObject({
247+
error,
248+
objectKey,
249+
orElse: () => notifyError(i18n('cc-cellar-object-list.error.object-download-failed', { objectKey })),
250+
});
251+
} finally {
252+
this.#updateDetails({ state: 'idle' });
253+
}
254+
}
255+
215256
async #fetchObjects() {
216257
try {
217258
const response = await this.#abortable.run(() =>
@@ -220,7 +261,9 @@ export class ObjectListController {
220261
filter: this.#filter,
221262
}),
222263
);
223-
this.#objects = [...response.directories, ...response.content].map((object) => ({ state: 'idle', ...object }));
264+
this.#objects = [...response.directories, ...response.content].map((object) =>
265+
object.type === 'file' ? /** @type {CellarFileState} */ ({ ...object, state: 'idle' }) : object,
266+
);
224267
this.#nextCursor = response.cursor;
225268
this.#updateState({
226269
type: 'loaded',
@@ -310,4 +353,130 @@ export class ObjectListController {
310353
this.#signedUrlInterval = null;
311354
}
312355
}
356+
357+
/**
358+
* @param {string} directoryName
359+
*/
360+
async createDirectory(directoryName) {
361+
this.#updateCreateForm({ type: 'creating', directoryName, error: null });
362+
363+
if (DIRECTORY_INVALID_CHARS.test(directoryName)) {
364+
this.#updateCreateForm({ type: 'idle', directoryName, error: 'directory-name-invalid' });
365+
return;
366+
}
367+
368+
const alreadyExists = this.#objects.some((obj) => obj.type === 'directory' && obj.name === directoryName);
369+
370+
if (alreadyExists) {
371+
this.#updateCreateForm({ type: 'idle', directoryName, error: 'directory-already-exists' });
372+
return;
373+
}
374+
375+
/** @type {CellarDirectory} */
376+
const newDir = {
377+
type: 'directory',
378+
key: [...this.#path, directoryName].join('/'),
379+
name: directoryName,
380+
volatile: true,
381+
};
382+
383+
this.#objects = [...this.#objects, newDir].sort((a, b) => {
384+
if (a.volatile && !b.volatile) {
385+
return -1;
386+
}
387+
if (!a.volatile && b.volatile) {
388+
return 1;
389+
}
390+
if (a.type === 'directory' && b.type === 'file') {
391+
return -1;
392+
}
393+
if (a.type === 'file' && b.type === 'directory') {
394+
return 1;
395+
}
396+
return a.name.localeCompare(b.name);
397+
});
398+
399+
this.#updateState(
400+
/** @param {CellarObjectListStateLoaded} state */ (state) => {
401+
state.objects = this.#objects;
402+
state.createDirectoryForm = null;
403+
},
404+
);
405+
notifySuccess(i18n('cc-cellar-object-list.success.directory-created', { directoryName }));
406+
}
407+
408+
/**
409+
* @param {File} file
410+
* @returns {Promise<void>}
411+
*/
412+
async uploadObject(file) {
413+
const pathAtUploadStart = this.#path;
414+
const objectKey = pathToString(this.#path) + file.name;
415+
this.#updateUploadState({ type: 'uploading' });
416+
417+
try {
418+
await this.#cellarClient.uploadObject(this.#bucketName, objectKey, file);
419+
notifySuccess(i18n('cc-cellar-object-list.success.object-uploaded', { objectKey }));
420+
421+
if (this.#path === pathAtUploadStart) {
422+
/** @type {CellarFileState} */
423+
const newFile = {
424+
type: 'file',
425+
key: objectKey,
426+
name: file.name,
427+
updatedAt: new Date().toISOString(),
428+
contentLength: file.size,
429+
volatile: true,
430+
state: 'idle',
431+
};
432+
this.#objects = [newFile, ...this.#objects.filter((obj) => obj.key !== objectKey)];
433+
this.#updateState(
434+
/** @param {CellarObjectListStateLoaded} state */ (state) => {
435+
state.objects = this.#objects;
436+
state.uploadState = { type: 'idle' };
437+
},
438+
);
439+
}
440+
} catch (error) {
441+
if (this.#path === pathAtUploadStart) {
442+
this.#handleErrorOnObject({
443+
error,
444+
objectKey,
445+
orElse: () => notifyError(i18n('cc-cellar-object-list.error.object-upload-failed', { objectKey })),
446+
});
447+
} else {
448+
notifyError(i18n('cc-cellar-object-list.error.object-upload-failed', { objectKey }));
449+
}
450+
} finally {
451+
if (this.#path === pathAtUploadStart) {
452+
this.#updateUploadState({ type: 'idle' });
453+
}
454+
}
455+
}
456+
457+
/**
458+
* @param {import('./cc-cellar-object-list.types.js').CellarObjectUploadState} newState
459+
*/
460+
#updateUploadState(newState) {
461+
this.#updateState(
462+
/** @param {CellarObjectListStateLoaded} state */ (state) => {
463+
state.uploadState = newState;
464+
},
465+
);
466+
}
467+
468+
/**
469+
* @param {Partial<CellarDirectoryCreateFormState>|null} newState
470+
*/
471+
#updateCreateForm(newState) {
472+
this.#updateState(
473+
/** @param {CellarObjectListStateLoaded} state */ (state) => {
474+
if (newState == null) {
475+
state.createDirectoryForm = null;
476+
} else {
477+
state.createDirectoryForm = { ...state.createDirectoryForm, ...newState };
478+
}
479+
},
480+
);
481+
}
313482
}

src/components/cc-cellar-object-list/cc-cellar-object-list.events.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,48 @@ export class CcCellarObjectDeleteEvent extends CcEvent {
122122
super(CcCellarObjectDeleteEvent.TYPE, details);
123123
}
124124
}
125+
126+
/**
127+
* Dispatched when a Cellar object download is requested.
128+
* @extends {CcEvent<string>}
129+
*/
130+
export class CcCellarObjectDownloadEvent extends CcEvent {
131+
static TYPE = 'cc-cellar-object-download';
132+
133+
/**
134+
* @param {string} details
135+
*/
136+
constructor(details) {
137+
super(CcCellarObjectDownloadEvent.TYPE, details);
138+
}
139+
}
140+
141+
/**
142+
* Dispatched when a new directory is created.
143+
* @extends {CcEvent<string>}
144+
*/
145+
export class CcCellarObjectCreateDirectoryEvent extends CcEvent {
146+
static TYPE = 'cc-cellar-object-create-directory';
147+
148+
/**
149+
* @param {string} details
150+
*/
151+
constructor(details) {
152+
super(CcCellarObjectCreateDirectoryEvent.TYPE, details);
153+
}
154+
}
155+
156+
/**
157+
* Dispatched when a file upload is requested.
158+
* @extends {CcEvent<File>}
159+
*/
160+
export class CcCellarObjectUploadEvent extends CcEvent {
161+
static TYPE = 'cc-cellar-object-upload';
162+
163+
/**
164+
* @param {File} file
165+
*/
166+
constructor(file) {
167+
super(CcCellarObjectUploadEvent.TYPE, file);
168+
}
169+
}

0 commit comments

Comments
 (0)