diff --git a/src/components/cc-breadcrumbs/cc-breadcrumbs.events.js b/src/components/cc-breadcrumbs/cc-breadcrumbs.events.js new file mode 100644 index 000000000..2a257b2dd --- /dev/null +++ b/src/components/cc-breadcrumbs/cc-breadcrumbs.events.js @@ -0,0 +1,16 @@ +import { CcEvent } from '../../lib/events.js'; + +/** + * Dispatched when a breadcrumb item is clicked. + * @extends {CcEvent<{path: Array}>} + */ +export class CcBreadcrumbClickEvent extends CcEvent { + static TYPE = 'cc-breadcrumb-click'; + + /** + * @param {{path: Array}} details + */ + constructor(details) { + super(CcBreadcrumbClickEvent.TYPE, details); + } +} diff --git a/src/components/cc-breadcrumbs/cc-breadcrumbs.js b/src/components/cc-breadcrumbs/cc-breadcrumbs.js new file mode 100644 index 000000000..aba8d681e --- /dev/null +++ b/src/components/cc-breadcrumbs/cc-breadcrumbs.js @@ -0,0 +1,127 @@ +import { css, html, LitElement } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import '../cc-link/cc-link.js'; +import { CcBreadcrumbClickEvent } from './cc-breadcrumbs.events.js'; + +/** + * @import { CcBreadcrumbsItem } from './cc-breadcrumbs.types.js' + */ + +/** + * A breadcrumb navigation component that displays a hierarchical path of items. + * + * Each breadcrumb item is clickable except the last item. + * Clicking a breadcrumb item dispatches a `CcBreadcrumbClickEvent` with the path to that item. + * + * WARNING: When a breadcrumb item's `label` is an empty string, you MUST set the `iconA11yName` value to provide accessible text for the icon. + * Otherwise, the link will have no discernible text, which is a serious accessibility issue (WCAG: Links must have discernible text). + * + * @cssdisplay block + */ +export class CcBreadcrumbs extends LitElement { + static get properties() { + return { + disabled: { type: Boolean }, + items: { type: Array }, + }; + } + + static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }; + + constructor() { + super(); + + /** @type {Array} Set the items. */ + this.items = []; + } + + /** + * @param {Event} event + * @param {Array} path + */ + _onItemClick(event, path) { + event.preventDefault(); + event.stopPropagation(); + this.dispatchEvent(new CcBreadcrumbClickEvent({ path })); + } + + render() { + /** @type {Array} */ + let current = []; + const parts = this.items ?? []; + + return html`
    + ${parts.map((item, index) => { + let path = [...current, item.value]; + current = path; + const isLast = index === parts.length - 1; + const label = item.label ?? item.value; + + const itemView = + item.icon != null + ? html`${label}` + : label; + + return html`
  • + ${isLast ? itemView : ''} + ${!isLast + ? html` this._onItemClick(event, path)} + >${itemView}` + : ''} +
  • `; + })} +
`; + } + + static get styles() { + return [ + // language=CSS + css` + :host { + display: block; + + --gap: 0.5em; + } + + ul { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: var(--gap); + list-style: none; + margin: 0; + padding: 0; + } + + li { + align-items: center; + display: flex; + gap: var(--gap); + } + + .item-wrapper { + align-items: center; + display: flex; + gap: 0.2em; + min-width: 0; + } + + cc-icon { + flex-shrink: 0; + } + + li + li::before { + color: var(--cc-color-text-disabled, #ccc); + content: '/'; + font-size: 1.2em; + font-weight: bold; + } + `, + ]; + } +} + +window.customElements.define('cc-breadcrumbs', CcBreadcrumbs); diff --git a/src/components/cc-breadcrumbs/cc-breadcrumbs.stories.js b/src/components/cc-breadcrumbs/cc-breadcrumbs.stories.js new file mode 100644 index 000000000..9940da30e --- /dev/null +++ b/src/components/cc-breadcrumbs/cc-breadcrumbs.stories.js @@ -0,0 +1,100 @@ +import { html, render } from 'lit'; +import { iconRemixHome_3Line as iconHouse } from '../../assets/cc-remix.icons.js'; +import { makeStory } from '../../stories/lib/make-story.js'; +import './cc-breadcrumbs.js'; + +export default { + tags: ['autodocs'], + title: '🧬 Atoms/', + component: 'cc-breadcrumbs', +}; + +/** + * @import { CcBreadcrumbs } from './cc-breadcrumbs.js' + */ + +const conf = { + component: 'cc-breadcrumbs', +}; + +export const defaultStory = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + items: [{ value: 'first' }, { value: 'second' }, { value: 'third' }], + }, + ], +}); + +export const withIconStory = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + items: [{ value: 'home', icon: iconHouse }, { value: 'first' }, { value: 'second' }, { value: 'third' }], + }, + ], +}); + +export const withNoLabelStory = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + items: [ + { value: 'home', label: '', icon: iconHouse, iconA11yName: 'Home' }, + { value: 'first' }, + { value: 'second' }, + { value: 'third' }, + ], + }, + ], +}); + +export const withSmallContainerStory = makeStory(conf, { + dom: /** @param {HTMLElement} container */ (container) => { + let width = '25'; + + /** + * @param {Event & { target: { value: string }}} e + */ + function _onChange(e) { + width = e.target.value; + refresh(); + } + + const items = [{ value: 'home', icon: iconHouse }, { value: 'first' }, { value: 'second' }, { value: 'third' }]; + + function refresh() { + render( + html` +
+
+ + +
+ +
+ `, + container, + ); + } + + refresh(); + }, + // language=CSS + css: ` + cc-breadcrumbs { + border: 2px solid #ccc; + } + .main { + display: flex; + flex-direction: column; + gap: 1em; + } + .form { + display: flex; + flex-direction: column; + gap: 0.25em; + align-items: start; + } + `, +}); diff --git a/src/components/cc-breadcrumbs/cc-breadcrumbs.types.d.ts b/src/components/cc-breadcrumbs/cc-breadcrumbs.types.d.ts new file mode 100644 index 000000000..1460daccc --- /dev/null +++ b/src/components/cc-breadcrumbs/cc-breadcrumbs.types.d.ts @@ -0,0 +1,12 @@ +import { IconModel } from '../common.types.js'; + +export interface CcBreadcrumbsItem { + value: string; + label?: string; + icon?: IconModel; + /** + * WARNING: When `label` is an empty string, you MUST set this value to provide accessible text for the icon. + * Otherwise, the link will have no discernible text, which is a serious accessibility issue (WCAG: Links must have discernible text). + */ + iconA11yName?: string; +} diff --git a/src/components/cc-cellar-bucket-list/cc-cellar-bucket-list.ctrl.js b/src/components/cc-cellar-bucket-list/cc-cellar-bucket-list.ctrl.js index e1e3b47b5..d09d0c6d5 100644 --- a/src/components/cc-cellar-bucket-list/cc-cellar-bucket-list.ctrl.js +++ b/src/components/cc-cellar-bucket-list/cc-cellar-bucket-list.ctrl.js @@ -1,3 +1,4 @@ +import { Abortable } from '../../lib/abortable.js'; import { notifyError, notifySuccess } from '../../lib/notifications.js'; import { isStringEmpty, sortByProps } from '../../lib/utils.js'; import { i18n } from '../../translations/translation.js'; @@ -28,6 +29,8 @@ export class BucketsListController { #sort; /** @type {string} */ #filter; + /** @type {Abortable} */ + #abortable; /** * @param {CellarExplorerClient} cellarClient @@ -41,6 +44,7 @@ export class BucketsListController { this.#buckets = []; this.#sort = { column: 'name', direction: 'asc' }; this.#filter = ''; + this.#abortable = new Abortable(); } /** @@ -74,10 +78,14 @@ export class BucketsListController { }); } + abort() { + this.#abortable.abort(); + } + async initialFetch() { this.#updateState({ type: 'loading' }); try { - const response = await this.#cellarClient.listBuckets(); + const response = await this.#abortable.run(() => this.#cellarClient.listBuckets()); this.#buckets = response.buckets.map((bucket) => ({ state: 'idle', ...bucket })); this.#updateState({ type: 'loaded', @@ -190,7 +198,7 @@ export class BucketsListController { async showBucketDetails(bucketName) { this.#updateBucketState(bucketName, { state: 'fetching' }); try { - const bucketDetails = await this.#cellarClient.getBucket(bucketName); + const bucketDetails = await this.#abortable.run(() => this.#cellarClient.getBucket(bucketName)); this.#updateState( /** @param {CellarBucketListStateLoaded} list */ (list) => { list.details = { state: 'idle', ...bucketDetails }; diff --git a/src/components/cc-cellar-bucket-list/cc-cellar-bucket-list.js b/src/components/cc-cellar-bucket-list/cc-cellar-bucket-list.js index 8e698fdf1..176d8126d 100644 --- a/src/components/cc-cellar-bucket-list/cc-cellar-bucket-list.js +++ b/src/components/cc-cellar-bucket-list/cc-cellar-bucket-list.js @@ -14,6 +14,7 @@ import { accessibilityStyles } from '../../styles/accessibility.js'; import { i18n } from '../../translations/translation.js'; import '../cc-badge/cc-badge.js'; import '../cc-button/cc-button.js'; +import { CcCellarNavigateToBucketEvent } from '../cc-cellar-object-list/cc-cellar-object-list.events.js'; import '../cc-clipboard/cc-clipboard.js'; import '../cc-dialog-confirm-actions/cc-dialog-confirm-actions.js'; import '../cc-dialog/cc-dialog.js'; @@ -98,7 +99,7 @@ export class CcCellarBucketList extends LitElement { this.updateComplete.then(() => { if (this.state.type === 'loaded') { const index = this.state.buckets.findIndex((b) => b.name === bucketName); - this._gridRef?.value.scrollToIndex(index); + this._gridRef.value?.scrollToIndex(index); } }); } @@ -155,6 +156,13 @@ export class CcCellarBucketList extends LitElement { //#region Event handlers + /** + * @param {string} bucketName + */ + _onBucketClick(bucketName) { + this.dispatchEvent(new CcCellarNavigateToBucketEvent(bucketName)); + } + /** * @param {string} bucketName */ @@ -339,10 +347,11 @@ export class CcCellarBucketList extends LitElement { { header: i18n('cc-cellar-bucket-list.grid.column.name'), cellAt: (bucketState) => ({ - type: 'text', + type: 'link', value: bucketState.name, icon: iconBucket, enableCopyToClipboard: true, + onClick: () => this._onBucketClick(bucketState.name), }), width: 'minmax(max-content, 1fr)', sort: getSort('name'), @@ -598,7 +607,7 @@ export class CcCellarBucketList extends LitElement { .details-wrapper { display: flex; flex-direction: column; - width: 25em; + max-width: 25em; } .details-wrapper .details-icon-wrapper { diff --git a/src/components/cc-cellar-explorer/cc-cellar-explorer.client.js b/src/components/cc-cellar-explorer/cc-cellar-explorer.client.js index 5fdb89533..14ff15035 100644 --- a/src/components/cc-cellar-explorer/cc-cellar-explorer.client.js +++ b/src/components/cc-cellar-explorer/cc-cellar-explorer.client.js @@ -4,7 +4,7 @@ import { withOptions } from '@clevercloud/client/esm/with-options.js'; import { CcApiErrorEvent } from '../../lib/send-to-api.events.js'; /** - * @import { CellarEndpoint, CellarBucket, CellarBucketDetails, CellarBucketsListResponse } from './cc-cellar-explorer.client.types.js' + * @import { CellarEndpoint, CellarBucket, CellarBucketDetails, CellarBucketsListResponse, CellarObjectsListResponse, CellarFileDetails } from './cc-cellar-explorer.client.types.js' * @import { ApiConfig } from '../../lib/send-to-api.js' */ @@ -12,19 +12,23 @@ export class CellarExplorerClient { /** * @param {ApiConfig} url * @param {CellarEndpoint} cellarEndpoint - * @param {AbortSignal} signal */ - constructor(url, cellarEndpoint, signal) { + constructor(url, cellarEndpoint) { this._url = url; this._cellarEndpoint = cellarEndpoint; - this._signal = signal; + this._abortController = new AbortController(); + } + + close() { + this._abortController.abort(); } /** + * @param {AbortSignal} [signal] * @returns {Promise} */ - listBuckets() { - return this.#send(`/cellar/bucket/_list`, { count: 1000 }, true); + listBuckets(signal) { + return this.#send(`/cellar/bucket/_list`, { count: 1000 }, signal ?? this._abortController.signal); } /** @@ -34,15 +38,16 @@ export class CellarExplorerClient { * @returns {Promise} */ createBucket(payload) { - return this.#send(`/cellar/bucket/_create`, payload, false); + return this.#send(`/cellar/bucket/_create`, payload); } /** * @param {string} bucketName + * @param {AbortSignal} [signal] * @returns {Promise} */ - getBucket(bucketName) { - return this.#send(`/cellar/bucket/_get`, { name: bucketName }, true); + getBucket(bucketName, signal) { + return this.#send(`/cellar/bucket/_get`, { name: bucketName }, signal ?? this._abortController.signal); } /** @@ -50,17 +55,67 @@ export class CellarExplorerClient { * @returns {Promise} */ deleteBucket(bucketName) { - return this.#send(`/cellar/bucket/_delete`, { name: bucketName }, false); + return this.#send(`/cellar/bucket/_delete`, { name: bucketName }); + } + + /** + * @param {string} bucketName + * @param {Array} path + * @param {{cursor: string, filter: string}} options + * @param {AbortSignal} [signal] + * @returns {Promise} + */ + listObjects(bucketName, path, options, signal) { + const prefix = pathToString(path) + (options.filter ?? ''); + return this.#send( + `/cellar/object/_list`, + { bucketName, prefix, cursor: options.cursor, count: 50 }, + signal ?? this._abortController.signal, + ); + } + + /** + * @param {string} bucketName + * @param {string} objectKey + * @param {AbortSignal} [signal] + * @returns {Promise} + */ + getObject(bucketName, objectKey, signal) { + return this.#send(`/cellar/object/_get`, { bucketName, objectKey }, signal ?? this._abortController.signal); + } + + /** + * @param {string} bucketName + * @param {string} objectKey + * @param {number} [expiresIn] + * @param {AbortSignal} [signal] + * @returns {Promise<{url: string}>} + */ + getObjectSignedUrl(bucketName, objectKey, expiresIn, signal) { + return this.#send( + `/cellar/object/_signed-url`, + { bucketName, objectKey, expiresIn }, + signal ?? this._abortController.signal, + ); + } + + /** + * @param {string} bucketName + * @param {string} objectKey + * @returns {Promise} + */ + deleteObject(bucketName, objectKey) { + return this.#send(`/cellar/object/_delete`, { bucketName, objectKey }); } /** * @param {string} path * @param {object} body - * @param {boolean} withSignal + * @param {AbortSignal} [signal] * @returns {Promise} * @template T */ - #send(path, body, withSignal) { + #send(path, body, signal) { return /** @type {Promise} */ ( Promise.resolve({ method: 'post', @@ -73,7 +128,7 @@ export class CellarExplorerClient { }) // @ts-expect-error FIXME: will become irrelevant when we switch to the new client .then(prefixUrl(this._url)) - .then(withOptions({ signal: withSignal ? this._signal : undefined })) + .then(withOptions({ signal })) .then(request) .catch((error) => { window.dispatchEvent(new CcApiErrorEvent(error)); diff --git a/src/components/cc-cellar-explorer/cc-cellar-explorer.client.types.d.ts b/src/components/cc-cellar-explorer/cc-cellar-explorer.client.types.d.ts index 572394a56..25fc8465b 100644 --- a/src/components/cc-cellar-explorer/cc-cellar-explorer.client.types.d.ts +++ b/src/components/cc-cellar-explorer/cc-cellar-explorer.client.types.d.ts @@ -22,3 +22,40 @@ export interface CellarBucket { export interface CellarBucketDetails extends CellarBucket {} export type CellarBucketVersioning = 'disabled' | 'enabled' | 'suspended'; + +export interface CellarObjectsListResponse { + cursor?: string; + content: Array; +} + +export interface CellarFile { + type: 'file'; + key: string; + name: string; + updatedAt: string; + contentLength: number; +} + +export interface CellarDirectory { + type: 'directory'; + key: string; + name: string; +} + +export interface CellarFileDetails extends CellarFile { + contentType: string; + tags: Array<{ key: string; value: string }>; + acl: Array; + metadata: Record; +} + +export interface CellarAcl { + grantee: Array; + permission: 'FULL_CONTROL' | 'READ' | 'READ_ACP' | 'WRITE' | 'WRITE_ACP'; +} + +export interface CellarGrantee { + id: string; + name: string; + type: 'AmazonCustomerByEmail' | 'CanonicalUser' | 'Group'; +} diff --git a/src/components/cc-cellar-explorer/cc-cellar-explorer.js b/src/components/cc-cellar-explorer/cc-cellar-explorer.js index 3843b39b3..c6cb0f10c 100644 --- a/src/components/cc-cellar-explorer/cc-cellar-explorer.js +++ b/src/components/cc-cellar-explorer/cc-cellar-explorer.js @@ -2,6 +2,7 @@ import { css, html, LitElement } from 'lit'; import { createRef, ref } from 'lit/directives/ref.js'; import { i18n } from '../../translations/translation.js'; import '../cc-cellar-bucket-list/cc-cellar-bucket-list.js'; +import '../cc-cellar-object-list/cc-cellar-object-list.js'; import '../cc-loader/cc-loader.js'; import '../cc-notice/cc-notice.js'; @@ -58,7 +59,10 @@ export class CcCellarExplorer extends LitElement { >`; } - return html``; + return html``; } static get styles() { diff --git a/src/components/cc-cellar-explorer/cc-cellar-explorer.smart.js b/src/components/cc-cellar-explorer/cc-cellar-explorer.smart.js index 634db750f..8dc521146 100644 --- a/src/components/cc-cellar-explorer/cc-cellar-explorer.smart.js +++ b/src/components/cc-cellar-explorer/cc-cellar-explorer.smart.js @@ -2,6 +2,7 @@ import { getAllEnvVars } from '@clevercloud/client/esm/api/v2/addon.js'; import { sendToApi } from '../../lib/send-to-api.js'; import { defineSmartComponent } from '../../lib/smart/define-smart-component.js'; import { BucketsListController } from '../cc-cellar-bucket-list/cc-cellar-bucket-list.ctrl.js'; +import { ObjectListController } from '../cc-cellar-object-list/cc-cellar-object-list.ctrl.js'; import { CellarExplorerClient } from './cc-cellar-explorer.client.js'; import './cc-cellar-explorer.js'; @@ -10,7 +11,9 @@ import './cc-cellar-explorer.js'; * @import { CellarExplorerStateLoaded } from './cc-cellar-explorer.types.js' * @import { CellarEndpoint } from './cc-cellar-explorer.client.types.js' * @import { CcCellarBucketList } from '../cc-cellar-bucket-list/cc-cellar-bucket-list.js' + * @import { CcCellarObjectList } from '../cc-cellar-object-list/cc-cellar-object-list.js' * @import { CellarBucketListState } from '../cc-cellar-bucket-list/cc-cellar-bucket-list.types.js' + * @import { CellarObjectListState } from '../cc-cellar-object-list/cc-cellar-object-list.types.js' * @import { EnvVar } from '../common.types.js' * @import { UpdateCallback } from '../common.types.js' * @import { ApiConfig } from '../../lib/send-to-api.js' @@ -39,10 +42,10 @@ defineSmartComponent({ .then((cellarEndpoint) => { updateComponent('state', { type: 'loaded', level: { type: 'buckets', state: { type: 'loading' } } }); - const cellarClient = new CellarExplorerClient(cellarProxyUrl, cellarEndpoint, signal); + const cellarClient = new CellarExplorerClient(cellarProxyUrl, cellarEndpoint); /** @type {() => CcCellarBucketList} */ - const getComponent = () => component.shadowRoot.querySelector('cc-cellar-bucket-list-beta'); + const getBucketListComponent = () => component.shadowRoot.querySelector('cc-cellar-bucket-list-beta'); /** @type {UpdateCallback} */ const updateBucketComponent = (newState) => { updateComponent( @@ -62,12 +65,63 @@ defineSmartComponent({ ); }; - const bucketsListController = new BucketsListController(cellarClient, getComponent, updateBucketComponent); + const bucketsListController = new BucketsListController( + cellarClient, + getBucketListComponent, + updateBucketComponent, + ); bucketsListController.init(onEvent); + /** @type {UpdateCallback} */ + const updateObjectListComponent = (newState) => { + updateComponent( + 'state', + /** @param {CellarExplorerStateLoaded} state*/ (state) => { + if (state.level.type === 'objects') { + if (typeof newState === 'function') { + const result = newState(/** @type {any} */ (state.level.state)); + if (result != null && typeof result === 'object') { + state.level.state = result; + } + } else { + state.level.state = newState; + } + } + }, + ); + }; + + /** @type {() => CcCellarObjectList} */ + const getObjectListComponent = () => component.shadowRoot.querySelector('cc-cellar-object-list-beta'); + const objectListController = new ObjectListController( + cellarClient, + getObjectListComponent, + updateObjectListComponent, + ); + objectListController.init(onEvent); + onEvent('cc-cellar-bucket-created', (bucketName) => { component.scrollToBucket(bucketName); }); + + onEvent('cc-cellar-navigate-to-home', () => { + updateComponent('state', { type: 'loaded', level: { type: 'buckets', state: { type: 'loading' } } }); + bucketsListController.initialFetch(); + }); + + onEvent('cc-cellar-navigate-to-bucket', (bucketName) => { + updateComponent('state', { + type: 'loaded', + level: { type: 'objects', state: { type: 'loading', bucketName, path: [] } }, + }); + objectListController.changeBucket(bucketName); + }); + + signal.onabort = () => { + cellarClient.close(); + bucketsListController.abort(); + objectListController.abort(); + }; }) .catch((error) => { console.log(error); diff --git a/src/components/cc-cellar-explorer/cc-cellar-explorer.types.d.ts b/src/components/cc-cellar-explorer/cc-cellar-explorer.types.d.ts index 3cf809f20..c4c9988b6 100644 --- a/src/components/cc-cellar-explorer/cc-cellar-explorer.types.d.ts +++ b/src/components/cc-cellar-explorer/cc-cellar-explorer.types.d.ts @@ -1,4 +1,5 @@ import { CellarBucketListState } from '../cc-cellar-bucket-list/cc-cellar-bucket-list.types.js'; +import { CellarObjectListState } from '../cc-cellar-object-list/cc-cellar-object-list.types.js'; export type CellarExplorerState = CellarExplorerStateLoading | CellarExplorerStateError | CellarExplorerStateLoaded; @@ -15,9 +16,14 @@ export interface CellarExplorerStateLoaded { level: CellarExplorerLevel; } -export type CellarExplorerLevel = CellarExplorerLevelBuckets; +export type CellarExplorerLevel = CellarExplorerLevelBuckets | CellarExplorerLevelObjects; export interface CellarExplorerLevelBuckets { type: 'buckets'; state: CellarBucketListState; } + +export interface CellarExplorerLevelObjects { + type: 'objects'; + state: CellarObjectListState; +} diff --git a/src/components/cc-cellar-object-list/cc-cellar-object-list.ctrl.js b/src/components/cc-cellar-object-list/cc-cellar-object-list.ctrl.js new file mode 100644 index 000000000..59926e109 --- /dev/null +++ b/src/components/cc-cellar-object-list/cc-cellar-object-list.ctrl.js @@ -0,0 +1,319 @@ +import { Abortable } from '../../lib/abortable.js'; +import { i18n } from '../../lib/i18n/i18n.js'; +import { notifyError, notifySuccess } from '../../lib/notifications.js'; +import { isCellarExplorerErrorWithCode } from '../cc-cellar-explorer/cc-cellar-explorer.client.js'; +import '../cc-smart-container/cc-smart-container.js'; +import { CcCellarNavigateToHomeEvent } from './cc-cellar-object-list.events.js'; +import './cc-cellar-object-list.js'; + +/** + * @import { CcCellarObjectList } from './cc-cellar-object-list.js' + * @import { CellarObjectListState, CellarObjectListStateLoaded, CellarObjectState, CellarFileState, CellarFileDetailsState } from './cc-cellar-object-list.types.js' + * @import { CellarExplorerClient } from '../cc-cellar-explorer/cc-cellar-explorer.client.js' + * @import { UpdateCallback } from '../common.types.js' + * @import { OnEventCallback } from '../../lib/smart/smart-component.types.js' + */ + +export class ObjectListController { + /** @type {CellarExplorerClient} */ + #cellarClient; + /** @type {() => CcCellarObjectList} */ + #getComponent; + /** @type {UpdateCallback} */ + #updateState; + /** @type {string} */ + #bucketName; + /** @type {Array} */ + #path; + /** @type {Array} */ + #objects; + /** @type {string} */ + #filter; + /** @type {string|null} */ + #nextCursor; + /** @type {string|null} */ + #currentCursor; + /** @type {Array} */ + #previousPages = []; + /** @type {Abortable} */ + #abortable; + + /** + * @param {CellarExplorerClient} cellarClient + * @param {() => CcCellarObjectList} getComponent + * @param {UpdateCallback} updateState + */ + constructor(cellarClient, getComponent, updateState) { + this.#cellarClient = cellarClient; + this.#getComponent = getComponent; + this.#updateState = updateState; + this.#objects = []; + this.#filter = ''; + this.#path = []; + this.#abortable = new Abortable(); + } + + /** + * @param {OnEventCallback} onEvent + */ + init(onEvent) { + onEvent('cc-cellar-object-filter', (filter) => { + this.filter(filter); + }); + + onEvent('cc-cellar-navigate-to-path', (path) => { + this.navigateTo(path); + }); + + onEvent('cc-cellar-navigate-to-previous-page', () => { + this.previousPage(); + }); + + onEvent('cc-cellar-navigate-to-next-page', () => { + this.nextPage(); + }); + + onEvent('cc-cellar-object-show', (objectKey) => { + this.showDetails(objectKey); + }); + + onEvent('cc-cellar-object-hide', () => { + this.hideDetails(); + }); + + onEvent('cc-cellar-object-delete', (objectKey) => { + this.deleteObject(objectKey); + }); + + onEvent('cc-cellar-object-download', (objectKey) => { + this.download(objectKey); + }); + } + + abort() { + this.#abortable.abort(); + } + + /** + * @param {string} bucketName + * @returns {Promise} + */ + async changeBucket(bucketName) { + this.#bucketName = bucketName; + this.#path = []; + this.#nextCursor = null; + this.#currentCursor = null; + this.#previousPages = []; + + this.#updateState({ type: 'loading', bucketName: this.#bucketName, path: this.#path }); + await this.#fetchObjects(); + } + + /** + * @param {string} filter + */ + async filter(filter) { + this.#filter = filter; + this.#nextCursor = null; + this.#currentCursor = null; + this.#previousPages = []; + this.#updateState({ type: 'filtering', bucketName: this.#bucketName, path: this.#path, filter: this.#filter }); + + await this.#fetchObjects(); + } + + /** + * @param {Array} path + */ + async navigateTo(path) { + this.#path = path; + + this.#updateState({ type: 'loading', bucketName: this.#bucketName, path: this.#path }); + await this.#fetchObjects(); + } + + async previousPage() { + if (this.#previousPages.length === 0) { + return; + } + this.#currentCursor = this.#previousPages.pop(); + + this.#updateState({ type: 'loading', bucketName: this.#bucketName, path: this.#path }); + await this.#fetchObjects(); + } + + async nextPage() { + if (this.#nextCursor == null) { + return; + } + + this.#previousPages.push(this.#currentCursor); + this.#currentCursor = this.#nextCursor; + + this.#updateState({ type: 'loading', bucketName: this.#bucketName, path: this.#path }); + await this.#fetchObjects(); + } + + /** + * @param {string} objectKey + */ + async showDetails(objectKey) { + this.#updateFileState(objectKey, { state: 'fetching' }); + try { + const bucketDetails = await this.#cellarClient.getObject(this.#bucketName, objectKey); + this.#updateState( + /** @param {CellarObjectListStateLoaded} state */ (state) => { + state.details = { state: 'idle', ...bucketDetails }; + }, + ); + } catch (error) { + this.#handleErrorOnObject(error, objectKey, () => + notifyError(i18n('cc-cellar-object-list.error.object-fetch-failed', { objectKey })), + ); + } finally { + this.#updateFileState(objectKey, { state: 'idle' }); + } + } + + hideDetails() { + this.#updateDetails(null); + } + + /** + * @param {string} objectKey + * @returns {Promise} + */ + async deleteObject(objectKey) { + this.#updateDetails({ state: 'deleting' }); + + try { + await this.#cellarClient.deleteObject(this.#bucketName, objectKey); + notifySuccess(i18n('cc-cellar-object-list.success.object-deleted', { objectKey })); + this.#removeObject(objectKey); + } catch (error) { + this.#handleErrorOnObject( + error, + objectKey, + () => notifyError(i18n('cc-cellar-object-list.error.object-deletion-failed', { objectKey })), + true, + ); + } finally { + // in any case, we need to hide the details to make the toast visible + this.hideDetails(); + } + } + + /** + * @param {string} objectKey + * @returns {Promise} + */ + async download(objectKey) { + this.#updateDetails({ state: 'downloading' }); + try { + const signedUrl = await this.#cellarClient.getObjectSignedUrl(this.#bucketName, objectKey); + + const element = document.createElement('a'); + element.setAttribute('href', signedUrl.url); + element.setAttribute('target', '_blank'); + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + } catch (error) { + this.#handleErrorOnObject(error, objectKey, () => + notifyError(i18n('cc-cellar-object-list.error.object-download-failed', { objectKey })), + ); + } finally { + this.#updateDetails({ state: 'idle' }); + } + } + + async #fetchObjects() { + try { + const response = await this.#abortable.run(() => + this.#cellarClient.listObjects(this.#bucketName, this.#path, { + cursor: this.#currentCursor, + filter: this.#filter, + }), + ); + this.#objects = response.content.map((object) => ({ state: 'idle', ...object })); + this.#nextCursor = response.cursor; + this.#updateState({ + type: 'loaded', + bucketName: this.#bucketName, + path: this.#path, + filter: this.#filter, + objects: this.#objects, + hasNext: this.#nextCursor != null, + hasPrevious: this.#currentCursor != null, + }); + } catch (error) { + console.log(error); + this.#updateState({ type: 'error', bucketName: this.#bucketName, path: this.#path }); + } + } + + /** + * @param {string} objectKey + */ + #removeObject(objectKey) { + this.#objects = this.#objects.filter((object) => object.key !== objectKey); + this.#updateState( + /** @param {CellarObjectListStateLoaded} state */ (state) => { + state.objects = this.#objects; + }, + ); + } + + /** + * @param {string} key + * @param {Partial} newState + */ + #updateFileState(key, newState) { + this.#objects = this.#objects.map((object) => + object.key === key && object.type === 'file' ? { ...object, ...newState } : object, + ); + this.#updateState( + /** @param {CellarObjectListStateLoaded} state */ (state) => { + state.objects = this.#objects; + }, + ); + } + + /** + * @param {Partial} newState + */ + #updateDetails(newState) { + this.#updateState( + /** @param {CellarObjectListStateLoaded} state */ (state) => { + if (newState == null) { + state.details = null; + } else { + state.details = { ...state.details, ...newState }; + } + }, + ); + } + + /** + * @param {unknown} error + * @param {string} objectKey + * @param {function} orElse + * @param {boolean} [deleteMode] + */ + #handleErrorOnObject(error, objectKey, orElse, deleteMode = false) { + if (isCellarExplorerErrorWithCode(error, 'clever.file-explorer-proxy.cellar.bucket-not-found')) { + notifyError(i18n('cc-cellar-object-list.error.bucket-not-found', { bucketName: this.#bucketName })); + this.#getComponent().dispatchEvent(new CcCellarNavigateToHomeEvent()); + } else if (isCellarExplorerErrorWithCode(error, 'clever.file-explorer-proxy.cellar.object-not-found')) { + if (deleteMode) { + notifySuccess(i18n('cc-cellar-object-list.success.object-already-deleted', { objectKey })); + } else { + notifyError(i18n('cc-cellar-object-list.error.object-not-found', { objectKey })); + } + this.#removeObject(objectKey); + } else { + console.log(error); + orElse(); + } + } +} diff --git a/src/components/cc-cellar-object-list/cc-cellar-object-list.events.js b/src/components/cc-cellar-object-list/cc-cellar-object-list.events.js new file mode 100644 index 000000000..a8db1d281 --- /dev/null +++ b/src/components/cc-cellar-object-list/cc-cellar-object-list.events.js @@ -0,0 +1,139 @@ +import { CcEvent } from '../../lib/events.js'; + +/** + * Dispatched when a Cellar objects filtering is requested. + * @extends {CcEvent} + */ +export class CcCellarObjectFilterEvent extends CcEvent { + static TYPE = 'cc-cellar-object-filter'; + + /** + * @param {string} details + */ + constructor(details) { + super(CcCellarObjectFilterEvent.TYPE, details); + } +} + +/** + * Dispatched when a Cellar navigation to bucket is requested. + * @extends {CcEvent} + */ +export class CcCellarNavigateToHomeEvent extends CcEvent { + static TYPE = 'cc-cellar-navigate-to-home'; + + constructor() { + super(CcCellarNavigateToHomeEvent.TYPE); + } +} + +/** + * Dispatched when a Cellar navigation to bucket is requested. + * @extends {CcEvent} + */ +export class CcCellarNavigateToBucketEvent extends CcEvent { + static TYPE = 'cc-cellar-navigate-to-bucket'; + + /** + * @param {string} details + */ + constructor(details) { + super(CcCellarNavigateToBucketEvent.TYPE, details); + } +} + +/** + * Dispatched when a Cellar navigation to path is requested. + * @extends {CcEvent>} + */ +export class CcCellarNavigateToPathEvent extends CcEvent { + static TYPE = 'cc-cellar-navigate-to-path'; + + /** + * @param {Array} details + */ + constructor(details) { + super(CcCellarNavigateToPathEvent.TYPE, details); + } +} + +/** + * Dispatched when a Cellar navigation to previous page is requested. + * @extends {CcEvent} + */ +export class CcCellarNavigateToPreviousPageEvent extends CcEvent { + static TYPE = 'cc-cellar-navigate-to-previous-page'; + + constructor() { + super(CcCellarNavigateToPreviousPageEvent.TYPE); + } +} + +/** + * Dispatched when a Cellar navigation to next page is requested. + * @extends {CcEvent} + */ +export class CcCellarNavigateToNextPageEvent extends CcEvent { + static TYPE = 'cc-cellar-navigate-to-next-page'; + + constructor() { + super(CcCellarNavigateToNextPageEvent.TYPE); + } +} + +/** + * Dispatched when a Cellar object show detail is requested. + * @extends {CcEvent} + */ +export class CcCellarObjectShowEvent extends CcEvent { + static TYPE = 'cc-cellar-object-show'; + + /** + * @param {string} details + */ + constructor(details) { + super(CcCellarObjectShowEvent.TYPE, details); + } +} + +/** + * Dispatched when a Cellar object hide detail is requested. + * @extends {CcEvent} + */ +export class CcCellarObjectHideEvent extends CcEvent { + static TYPE = 'cc-cellar-object-hide'; + + constructor() { + super(CcCellarObjectHideEvent.TYPE); + } +} + +/** + * Dispatched when a Cellar object delete is requested. + * @extends {CcEvent} + */ +export class CcCellarObjectDeleteEvent extends CcEvent { + static TYPE = 'cc-cellar-object-delete'; + + /** + * @param {string} details + */ + constructor(details) { + super(CcCellarObjectDeleteEvent.TYPE, details); + } +} + +/** + * Dispatched when a Cellar object download is requested. + * @extends {CcEvent} + */ +export class CcCellarObjectDownloadEvent extends CcEvent { + static TYPE = 'cc-cellar-object-download'; + + /** + * @param {string} details + */ + constructor(details) { + super(CcCellarObjectDownloadEvent.TYPE, details); + } +} diff --git a/src/components/cc-cellar-object-list/cc-cellar-object-list.js b/src/components/cc-cellar-object-list/cc-cellar-object-list.js new file mode 100644 index 000000000..bdfcee7d4 --- /dev/null +++ b/src/components/cc-cellar-object-list/cc-cellar-object-list.js @@ -0,0 +1,617 @@ +import { css, html, LitElement } from 'lit'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { + iconRemixArchiveLine as iconBucket, + iconRemixDeleteBin_6Line as iconDelete, + iconRemixFolder_4Line as iconDirectory, + iconRemixDownloadLine as iconDownload, + iconRemixFile_2Line as iconFile, + iconRemixFileZipLine as iconFileArchive, + iconRemixFileMusicLine as iconFileAudio, + iconRemixFileImageLine as iconFileImage, + iconRemixFilePdfLine as iconFilePdf, + iconRemixFileTextLine as iconFileText, + iconRemixFileVideoLine as iconFileVideo, + iconRemixSearchLine as iconFilter, + iconRemixHome_3Line as iconHouse, + iconRemixMoreFill as iconMore, + iconRemixArrowRightSFill as iconNext, + iconRemixArrowLeftSFill as iconPrevious, +} from '../../assets/cc-remix.icons.js'; +import { formSubmit } from '../../lib/form/form-submit-directive.js'; +import { random, randomString } from '../../lib/utils.js'; +import { accessibilityStyles } from '../../styles/accessibility.js'; +import { i18n } from '../../translations/translation.js'; +import '../cc-breadcrumbs/cc-breadcrumbs.js'; +import '../cc-button/cc-button.js'; +import '../cc-clipboard/cc-clipboard.js'; +import '../cc-drawer/cc-drawer.js'; +import '../cc-grid/cc-grid.js'; +import '../cc-icon/cc-icon.js'; +import '../cc-input-text/cc-input-text.js'; +import '../cc-notice/cc-notice.js'; +import { + CcCellarNavigateToBucketEvent, + CcCellarNavigateToHomeEvent, + CcCellarNavigateToNextPageEvent, + CcCellarNavigateToPathEvent, + CcCellarNavigateToPreviousPageEvent, + CcCellarObjectDeleteEvent, + CcCellarObjectDownloadEvent, + CcCellarObjectFilterEvent, + CcCellarObjectHideEvent, + CcCellarObjectShowEvent, +} from './cc-cellar-object-list.events.js'; + +/** + * @import { CellarObjectListState, CellarObjectListStateLoading, CellarObjectListStateLoaded, CellarObjectListStateFiltering, CellarObjectState } from './cc-cellar-object-list.types.js' + * @import { CcBreadcrumbClickEvent } from '../cc-breadcrumbs/cc-breadcrumbs.events.js' + * @import { CcBreadcrumbs } from '../cc-breadcrumbs/cc-breadcrumbs.js' + * @import { CcGrid } from '../cc-grid/cc-grid.js' + * @import { CcGridColumnDefinition } from '../cc-grid/cc-grid.types.js' + * @import { TemplateResult } from 'lit' + * @import { Ref } from 'lit/directives/ref.js' + */ + +/** @type {Array>} */ +const SKELETON_OBJECTS = [...Array(5)].map(() => ({ + type: 'file', + state: 'idle', + name: randomString(random(10, 15)), + updatedAt: new Date().toISOString(), + contentLength: random(150_000, 2_000_000), +})); + +const ARCHIVE_CONTENT_TYPES = [ + 'application/zip', + 'application/x-zip-compressed', + 'application/x-tar', + 'application/gzip', + 'application/x-gzip', + 'application/x-freearc', + 'application/x-bzip', + 'application/x-bzip2', + 'application/x-rar-compressed', + 'application/vnd.rar', + 'application/x-7z-compressed', + 'application/java-archive', + 'application/vnd.ms-cab-compressed', + 'application/x-xz', + 'application/x-lzma', + 'application/zstd', + 'application/x-cpio', + 'application/x-compress', + 'application/x-lz4', + 'application/x-lzip', + 'application/x-arj', + 'application/x-lzh-compressed', + 'application/x-ace-compressed', + 'application/x-stuffit', +]; + +/** + * A component that allows to navigate through a Cellar addon. + * + * @cssdisplay block + */ +export class CcCellarObjectList extends LitElement { + static get properties() { + return { + state: { type: Object }, + }; + } + + constructor() { + super(); + + /** @type {CellarObjectListState} Sets state. */ + this.state = { type: 'loading', bucketName: '', path: [] }; + + /** @type {Ref} */ + this._breadcrumbsRef = createRef(); + + /** @type {Ref} */ + this._gridRef = createRef(); + } + + /** + * @param {string} contentType + */ + _getFileIcon(contentType) { + if (contentType === 'text/plain') { + return iconFileText; + } + if (contentType === 'application/pdf') { + return iconFilePdf; + } + if (contentType.startsWith('image/')) { + return iconFileImage; + } + if (contentType.startsWith('audio/')) { + return iconFileAudio; + } + if (contentType.startsWith('video/')) { + return iconFileVideo; + } + if (ARCHIVE_CONTENT_TYPES.includes(contentType)) { + return iconFileArchive; + } + return iconFile; + } + + /** + * @param {{filter: string}} formData + */ + _onFilterFormSubmit({ filter }) { + this.dispatchEvent(new CcCellarObjectFilterEvent(filter)); + } + + /** + * @param {CcBreadcrumbClickEvent} event + */ + _onPathItemClick(event) { + const path = event.detail.path; + if (path.length === 1) { + this.dispatchEvent(new CcCellarNavigateToHomeEvent()); + } + if (path.length === 2) { + this.dispatchEvent(new CcCellarNavigateToBucketEvent(this.state.bucketName)); + } + this.dispatchEvent(new CcCellarNavigateToPathEvent(path.slice(2))); + } + + /** + * @param {string} objectName + */ + _onDisplayObjectDetailsRequested(objectName) { + this.dispatchEvent(new CcCellarObjectShowEvent(objectName)); + } + + _onCloseObjectDetails() { + this.dispatchEvent(new CcCellarObjectHideEvent()); + } + + _onDrawerFocusLost() { + if (this.state.type === 'loaded') { + if (this.state.objects.length > 0) { + this._gridRef.value?.focus(); + } else { + this._breadcrumbsRef.value?.focus(); + } + } + } + + /** + * @param {string} objectKey + */ + _onDeleteObject(objectKey) { + this.dispatchEvent(new CcCellarObjectDeleteEvent(objectKey)); + } + + /** + * @param {string} objectKey + */ + _onDownloadObject(objectKey) { + this.dispatchEvent(new CcCellarObjectDownloadEvent(objectKey)); + } + + render() { + if (this.state.type === 'error') { + return html``; + } + + return html`
+ ${this._renderHeading(this.state)} ${this._renderPath(this.state)} ${this._renderList(this.state)} + ${this._renderPagination(this.state)} ${this.state.type === 'loaded' ? this._renderFileDetails(this.state) : ''} +
`; + } + + /** + * @param {CellarObjectListStateLoading|CellarObjectListStateLoaded|CellarObjectListStateFiltering} state + * @returns {TemplateResult} + */ + _renderHeading(state) { + const filter = state.type === 'loaded' || state.type === 'filtering' ? state.filter : ''; + + return html` +
+
+ ${i18n('cc-cellar-object-list.heading.title')} +
+
+
+ + + ${i18n('cc-cellar-object-list.heading.filter.button')} + +
+
+
+ `; + } + + /** + * @param {CellarObjectListStateLoading|CellarObjectListStateLoaded|CellarObjectListStateFiltering} state + * @returns {TemplateResult} + */ + _renderPath(state) { + const items = [ + { value: '/home/', label: '', icon: iconHouse, iconA11yName: i18n('cc-cellar-object-list.back-to-bucket-list') }, + { value: '/bucket/', label: state.bucketName, icon: iconBucket }, + ...state.path.map((path) => ({ + value: path, + icon: iconDirectory, + })), + ]; + + const copyValue = state.path.length > 0 ? state.path.join('/') : state.bucketName; + + return html` +
+ + +
+ `; + } + + /** + * @param {CellarObjectListStateLoading|CellarObjectListStateLoaded|CellarObjectListStateFiltering} state + * @returns {TemplateResult} + */ + _renderList(state) { + /** @type {Array>} */ + const columns = [ + { + header: i18n('cc-cellar-object-list.grid.column.name'), + cellAt: (object) => { + if (object.type === 'directory') { + return { + type: 'link', + value: object.name, + icon: iconDirectory, + enableCopyToClipboard: true, + onClick: () => { + this.dispatchEvent(new CcCellarNavigateToPathEvent([...state.path, object.name])); + }, + }; + } + return { + type: 'text', + value: object.name, + icon: iconFile, + enableCopyToClipboard: true, + }; + }, + width: 'minmax(max-content, 1fr)', + }, + { + header: i18n('cc-cellar-object-list.grid.column.last-update'), + cellAt: (object) => { + if (object.type === 'directory') { + return null; + } + return { + type: 'text', + value: i18n('cc-cellar-object-list.date', { date: object.updatedAt }), + }; + }, + width: 'max-content', + volatile: true, + }, + { + header: i18n('cc-cellar-object-list.grid.column.size'), + cellAt: (object) => { + if (object.type === 'directory') { + return null; + } + return { + type: 'text', + value: i18n('cc-cellar-object-list.size', { size: object.contentLength }), + }; + }, + width: 'max-content', + align: 'end', + volatile: true, + }, + { + header: '', + cellAt: (object) => { + if (object.type === 'directory') { + return null; + } + return { + type: 'button', + value: i18n('cc-cellar-object-list.grid.show-details.a11y-name', { objectName: object.key }), + icon: iconMore, + waiting: object.state === 'fetching', + onClick: () => this._onDisplayObjectDetailsRequested(object.key), + }; + }, + width: 'max-content', + }, + ]; + + const items = state.type === 'loading' || state.type === 'filtering' ? SKELETON_OBJECTS : state.objects; + const isFiltered = state.type === 'loaded' ? state.filter : ''; + + if (items.length === 0) { + return html`
+ ${isFiltered + ? i18n('cc-cellar-object-list.empty.no-filtered-items') + : i18n('cc-cellar-object-list.empty.no-items')} +
`; + } + + const busy = state.type === 'loading' || state.type === 'filtering'; + return html` + + `; + } + + /** + * @param {CellarObjectListStateLoading|CellarObjectListStateLoaded|CellarObjectListStateFiltering} state + * @returns {TemplateResult} + */ + _renderPagination(state) { + const hasPrevious = state.type === 'loaded' && state.hasPrevious; + const hasNext = state.type === 'loaded' && state.hasNext; + + return html``; + } + + /** + * @param {CellarObjectListStateLoaded} state + * @returns {TemplateResult} + */ + _renderFileDetails(state) { + const object = state.details; + + return html` + ${object != null + ? html`
+
+ +
${object.name}
+
+ +
${i18n('cc-cellar-object-list.details.actions.title')}
+
+ this._onDownloadObject(object.key)} + .waiting=${object.state === 'downloading'} + .disabled=${object.state !== 'idle' && object.state !== 'downloading'} + >${i18n('cc-cellar-object-list.details.actions.download.button')} + this._onDeleteObject(object.key)} + .waiting=${object.state === 'deleting'} + .disabled=${object.state !== 'idle' && object.state !== 'deleting'} + >${i18n('cc-cellar-object-list.details.actions.delete.button')} +
+ +
${i18n('cc-cellar-object-list.details.overview.title')}
+ +
+
${i18n('cc-cellar-object-list.details.overview.location')}
+
${object.key}
+ +
${i18n('cc-cellar-object-list.details.overview.content-type')}
+
${object.contentType}
+ +
${i18n('cc-cellar-object-list.details.overview.size')}
+
${i18n('cc-cellar-object-list.details.overview.size-in-bytes', { size: object.contentLength })}
+ +
${i18n('cc-cellar-object-list.details.overview.updated-at')}
+
${i18n('cc-cellar-object-list.details.overview.date', { date: object.updatedAt })}
+
+
` + : ''} +
`; + } + + static get styles() { + return [ + accessibilityStyles, + // language=CSS + css` + :host { + display: block; + } + + .wrapper { + background-color: var(--cc-color-bg-default, #fff); + border: 1px solid var(--cc-color-border-neutral, #aaa); + border-radius: var(--cc-border-radius-default, 0.25em); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1em; + height: 100%; + padding: 1em; + } + + .list-heading { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 1em; + margin-bottom: 1em; + } + + .list-heading--left { + align-items: center; + display: flex; + gap: 0.2em; + } + + .list-heading--title { + color: var(--cc-color-text-primary-strongest, #000); + font-size: 1.2em; + font-weight: bold; + } + + .list-heading--center { + align-items: center; + display: flex; + flex: 1 1 auto; + gap: 0.5em; + justify-content: end; + } + + .list-heading--center form { + align-items: center; + display: inline-flex; + flex: 1; + gap: 0.5em; + max-width: 28em; + } + + .list-heading--center cc-input-text { + flex: 1; + } + + .path-wrapper { + align-items: center; + display: flex; + gap: 1em; + } + + .empty-list { + align-items: center; + display: flex; + justify-content: center; + padding: 1em; + } + + .list { + border: 1px solid var(--cc-color-border-neutral-weak, #aaa); + border-radius: var(--cc-border-radius-default, 0.25em); + flex: 1; + min-height: 0; + } + + .pagination { + align-items: center; + display: flex; + gap: 1em; + } + + .details-wrapper { + display: flex; + flex-direction: column; + max-width: 25em; + } + + .details-wrapper .details-icon-wrapper { + align-content: center; + align-items: center; + border: 1px solid var(--cc-color-border-neutral, #aaa); + border-radius: var(--cc-border-radius-default, 0.25em); + color: var(--cc-color-text-primary-strongest, #000); + display: flex; + flex-direction: column; + gap: 1em; + padding: 1em; + } + + .details-wrapper .details-icon-wrapper cc-icon { + height: 5em; + width: 5em; + } + + .details-wrapper .details-icon-wrapper .details-name { + font-weight: bold; + white-space: wrap; + word-wrap: anywhere; + } + + .details-wrapper .details-sub-title { + align-items: center; + border-bottom: 1px solid var(--cc-color-border-primary-weak, #aaa); + display: flex; + font-weight: bold; + margin-bottom: 1em; + margin-top: 2em; + padding-bottom: 0.5em; + } + + .details-wrapper .details-actions { + display: flex; + flex-direction: column; + gap: 0.5em; + } + + .details-wrapper .details-overview-list { + display: flex; + flex-direction: column; + margin: 0; + } + + .details-wrapper .details-overview-list dt { + font-size: 0.94em; + font-weight: bold; + margin-bottom: 0.25em; + } + + .details-wrapper .details-overview-list dd { + align-items: center; + display: flex; + font-size: 0.94em; + gap: 0.5em; + margin: 0 0 1em; + } + `, + ]; + } +} + +// eslint-disable-next-line wc/tag-name-matches-class +window.customElements.define('cc-cellar-object-list-beta', CcCellarObjectList); diff --git a/src/components/cc-cellar-object-list/cc-cellar-object-list.stories.js b/src/components/cc-cellar-object-list/cc-cellar-object-list.stories.js new file mode 100644 index 000000000..f5c3ef67b --- /dev/null +++ b/src/components/cc-cellar-object-list/cc-cellar-object-list.stories.js @@ -0,0 +1,345 @@ +import { fileDetailsState, fileState, objects } from '../../stories/fixtures/cellar.js'; +import { makeStory } from '../../stories/lib/make-story.js'; +import './cc-cellar-object-list.js'; + +export default { + tags: ['autodocs'], + title: '🚧 Beta/🛠 Cellar Explorer/', + component: 'cc-cellar-object-list-beta', +}; + +/** + * @import { CcCellarObjectList } from './cc-cellar-object-list.js' + */ + +const conf = { + component: 'cc-cellar-object-list-beta', + // language=CSS + css: `cc-cellar-object-list-beta { + height: 500px; + }`, +}; + +export const defaultStory = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: objects(2, 30), + hasPrevious: false, + hasNext: false, + }, + }, + ], +}); + +export const loading = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loading', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + }, + }, + ], +}); + +export const error = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'error', + bucketName: 'bucket-1', + }, + }, + ], +}); + +export const bucketRootDir = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: [], + objects: objects(2, 30), + hasPrevious: false, + hasNext: false, + }, + }, + ], +}); + +export const fewObjects = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: objects(0, 3), + hasPrevious: false, + hasNext: false, + }, + }, + ], +}); + +export const empty = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [], + hasPrevious: false, + hasNext: false, + }, + }, + ], +}); + +export const filtering = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'filtering', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + filter: 'object-1', + }, + }, + ], +}); + +export const filtered = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: objects(2, [ + 'object-1', + 'object-10', + 'object-11', + 'object-12', + 'object-13', + 'object-14', + 'object-15', + 'object-16', + 'object-17', + 'object-18', + 'object-19', + ]), + filter: 'object-1', + hasPrevious: false, + hasNext: false, + }, + }, + ], +}); + +export const emptyFiltered = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [], + filter: '???', + hasPrevious: false, + hasNext: false, + }, + }, + ], +}); + +export const fetchingObject = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: objects(2, 30).map((o, i) => (i === 0 ? { ...o, state: 'fetching' } : o)), + hasPrevious: false, + hasNext: false, + }, + }, + ], +}); + +export const detailsOctetFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('dat', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: fileDetailsState('dat', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + }, + }, + ], +}); + +export const detailsTextFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('txt', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: fileDetailsState('txt', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + }, + }, + ], +}); + +export const detailsImageFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('jpg', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: fileDetailsState('jpg', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + }, + }, + ], +}); + +export const detailsArchiveFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('zip', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: fileDetailsState('zip', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + }, + }, + ], +}); + +export const detailsPdfFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('pdf', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: fileDetailsState('pdf', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + }, + }, + ], +}); + +export const detailsAudioFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('mp3', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: fileDetailsState('mp3', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + }, + }, + ], +}); + +export const detailsVideoFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('mp4', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: fileDetailsState('mp4', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + }, + }, + ], +}); + +export const deletingFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('dat', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: { + ...fileDetailsState('dat', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + state: 'deleting', + }, + }, + }, + ], +}); + +export const downloadingFile = makeStory(conf, { + /** @type {Partial[]} */ + items: [ + { + state: { + type: 'loaded', + bucketName: 'bucket-1', + path: ['dir-1', 'dir-1-1', 'dir-1-1-1'], + objects: [fileState('dat', ['dir-1', 'dir-1-1', 'dir-1-1-1'])], + hasPrevious: false, + hasNext: false, + details: { + ...fileDetailsState('dat', ['dir-1', 'dir-1-1', 'dir-1-1-1']), + state: 'downloading', + }, + }, + }, + ], +}); diff --git a/src/components/cc-cellar-object-list/cc-cellar-object-list.types.d.ts b/src/components/cc-cellar-object-list/cc-cellar-object-list.types.d.ts new file mode 100644 index 000000000..a49938725 --- /dev/null +++ b/src/components/cc-cellar-object-list/cc-cellar-object-list.types.d.ts @@ -0,0 +1,50 @@ +import { + CellarDirectory, + CellarFile, + CellarFileDetails, +} from '../cc-cellar-explorer/cc-cellar-explorer.client.types.js'; + +export type CellarObjectListState = + | CellarObjectListStateLoading + | CellarObjectListStateError + | CellarObjectListStateLoaded + | CellarObjectListStateFiltering; + +export interface CellarObjectListStateLoading { + type: 'loading'; + bucketName: string; + path: Array; +} + +export interface CellarObjectListStateError { + type: 'error'; + bucketName: string; +} + +export interface CellarObjectListStateLoaded { + type: 'loaded'; + bucketName: string; + path: Array; + filter?: string; + objects: Array; + details?: CellarFileDetailsState; + hasPrevious: boolean; + hasNext: boolean; +} + +export interface CellarObjectListStateFiltering { + type: 'filtering'; + bucketName: string; + path: Array; + filter?: string; +} + +export type CellarObjectState = CellarFileState | CellarDirectory; + +export interface CellarFileState extends CellarFile { + state: 'idle' | 'fetching'; +} + +export interface CellarFileDetailsState extends CellarFileDetails { + state: 'idle' | 'deleting' | 'downloading'; +} diff --git a/src/components/cc-grid/cc-grid.js b/src/components/cc-grid/cc-grid.js index 4e4d4aa88..075f91987 100644 --- a/src/components/cc-grid/cc-grid.js +++ b/src/components/cc-grid/cc-grid.js @@ -558,16 +558,26 @@ export class CcGrid extends LitElement { if (cell.type === 'link') { return { focusable: !skeleton, - template: html` this._onCellClick(rowIndex, columnIndex, cell.onClick)} - >${cell.value}`, + template: html`
+ ${cell.icon != null ? html`` : ''} + this._onCellClick(rowIndex, columnIndex, cell.onClick)} + >${cell.value} + ${cell.enableCopyToClipboard + ? html`` + : ''} +
`, }; } diff --git a/src/components/cc-grid/cc-grid.stories.js b/src/components/cc-grid/cc-grid.stories.js index 05b3d0929..4a3eab0c2 100644 --- a/src/components/cc-grid/cc-grid.stories.js +++ b/src/components/cc-grid/cc-grid.stories.js @@ -36,7 +36,7 @@ const conf = { * @returns {CcGridCell} */ const simpleCellAt = (_, row, col) => { - if (col === 1) { + if (col === 0) { return { type: 'link', value: `item ${row + 1} col ${col + 1}`, @@ -44,11 +44,19 @@ const simpleCellAt = (_, row, col) => { onClick: () => {}, }; } + if (col === 1) { + return { + type: 'link', + value: `item ${row + 1} col ${col + 1}`, + onClick: () => {}, + enableCopyToClipboard: true, + }; + } return { type: 'text', value: `item ${row + 1} col ${col + 1}`, icon: col === 0 ? iconHeart : null, - enableCopyToClipboard: col === 0, + enableCopyToClipboard: col === 3, }; }; @@ -68,6 +76,7 @@ export const defaultStory = makeStory(conf, { { header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)' }, { header: 'Column2', cellAt: simpleCellAt, width: 'max-content' }, { header: 'Column3', cellAt: simpleCellAt, width: 'max-content' }, + { header: 'Column4', cellAt: simpleCellAt, width: 'max-content' }, { header: '', cellAt: buttonCellAt }, ], items: items(), @@ -84,6 +93,7 @@ export const withSmallNumberOfItems = makeStory(conf, { { header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)' }, { header: 'Column2', cellAt: simpleCellAt, width: 'max-content' }, { header: 'Column3', cellAt: simpleCellAt, width: 'max-content' }, + { header: 'Column4', cellAt: simpleCellAt, width: 'max-content' }, { header: '', cellAt: buttonCellAt }, ], items: items(5), @@ -100,6 +110,7 @@ export const withFullySkeleton = makeStory(conf, { { header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)' }, { header: 'Column2', cellAt: simpleCellAt, width: 'max-content' }, { header: 'Column3', cellAt: simpleCellAt, width: 'max-content' }, + { header: 'Column4', cellAt: simpleCellAt, width: 'max-content' }, { header: '', cellAt: buttonCellAt }, ], items: items(), @@ -129,6 +140,11 @@ export const withPartiallySkeleton = makeStory(conf, { cellAt: (o, row, col) => ({ ...simpleCellAt(o, row, col), skeleton: row > 2 }), width: 'max-content', }, + { + header: 'Column4', + cellAt: (o, row, col) => ({ ...simpleCellAt(o, row, col), skeleton: row > 2 }), + width: 'max-content', + }, { header: '', cellAt: (_, row) => ({ ...buttonCellAt(), skeleton: row > 2 }) }, ], items: items(5), @@ -145,6 +161,7 @@ export const withSortableColumns = makeStory(conf, { { header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)', sort: 'none' }, { header: 'Column2', cellAt: simpleCellAt, width: 'max-content', sort: 'none' }, { header: 'Column3', cellAt: simpleCellAt, width: 'max-content' }, + { header: 'Column4', cellAt: simpleCellAt, width: 'max-content' }, { header: '', cellAt: buttonCellAt }, ], items: items(), @@ -161,6 +178,7 @@ export const withSortedColumns = makeStory(conf, { { header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)', sort: 'asc' }, { header: 'Column2', cellAt: simpleCellAt, width: 'max-content', sort: 'desc' }, { header: 'Column3', cellAt: simpleCellAt, width: 'max-content', sort: 'none' }, + { header: 'Column4', cellAt: simpleCellAt, width: 'max-content', sort: 'none' }, { header: '', cellAt: buttonCellAt }, ], items: items(), @@ -177,6 +195,7 @@ export const withDisabled = makeStory(conf, { { header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)', sort: 'asc' }, { header: 'Column2', cellAt: simpleCellAt, width: 'max-content', sort: 'desc' }, { header: 'Column3', cellAt: simpleCellAt, width: 'max-content', sort: 'none' }, + { header: 'Column4', cellAt: simpleCellAt, width: 'max-content', sort: 'none' }, { header: '', cellAt: buttonCellAt }, ], items: items(), diff --git a/src/components/cc-grid/cc-grid.types.d.ts b/src/components/cc-grid/cc-grid.types.d.ts index d730fc5a6..067839c0e 100644 --- a/src/components/cc-grid/cc-grid.types.d.ts +++ b/src/components/cc-grid/cc-grid.types.d.ts @@ -27,6 +27,7 @@ interface CcGridCellLink { icon?: IconModel; onClick: () => void; skeleton?: boolean; + enableCopyToClipboard?: boolean; } interface CcGridCellButton { diff --git a/src/components/cc-kv-explorer/kv-details-ctrl.js b/src/components/cc-kv-explorer/kv-details-ctrl.js index de1e6d5c0..ca9bbf5b5 100644 --- a/src/components/cc-kv-explorer/kv-details-ctrl.js +++ b/src/components/cc-kv-explorer/kv-details-ctrl.js @@ -1,8 +1,8 @@ +import { Abortable } from '../../lib/abortable.js'; import { KvKeyEditorHashCtrl } from './kv-key-editor-hash-ctrl.js'; import { KvKeyEditorListCtrl } from './kv-key-editor-list-ctrl.js'; import { KvKeyEditorSetCtrl } from './kv-key-editor-set-ctrl.js'; import { KvKeyEditorStringCtrl } from './kv-key-editor-string-ctrl.js'; -import { Abortable } from './kv-utils.js'; /** * @import { CcKvExplorer } from './cc-kv-explorer.js' diff --git a/src/components/cc-kv-explorer/kv-key-editor-hash-ctrl.js b/src/components/cc-kv-explorer/kv-key-editor-hash-ctrl.js index 540dc436e..43b92230c 100644 --- a/src/components/cc-kv-explorer/kv-key-editor-hash-ctrl.js +++ b/src/components/cc-kv-explorer/kv-key-editor-hash-ctrl.js @@ -8,8 +8,8 @@ import { matchKvPattern } from './kv-utils.js'; * @import { CcKvExplorerDetailState, CcKvExplorerDetailStateEditHash, CcKvKeyValueHash } from './cc-kv-explorer.types.js' * @import { KvClient } from './kv-client.js' * @import { CcKvHashElementState, CcKvHashExplorerState, CcKvHashExplorerStateLoading, CcKvHashExplorerAddFormState } from '../cc-kv-hash-explorer/cc-kv-hash-explorer.types.js' - * @import { Abortable } from './kv-utils.js' * @import { ObjectOrFunction } from '../common.types.js' + * @import { Abortable } from '../../lib/abortable.js' */ /** diff --git a/src/components/cc-kv-explorer/kv-key-editor-list-ctrl.js b/src/components/cc-kv-explorer/kv-key-editor-list-ctrl.js index 9ea97763f..c625a8e79 100644 --- a/src/components/cc-kv-explorer/kv-key-editor-list-ctrl.js +++ b/src/components/cc-kv-explorer/kv-key-editor-list-ctrl.js @@ -6,8 +6,8 @@ import { KvScanner } from './kv-scanner.js'; * @import { CcKvExplorerDetailState, CcKvExplorerDetailStateEditList, CcKvKeyValueList } from './cc-kv-explorer.types.js' * @import { KvClient } from './kv-client.js' * @import { CcKvListElementState, CcKvListExplorerState, CcKvListExplorerStateLoading, CcKvListExplorerAddFormState } from '../cc-kv-list-explorer/cc-kv-list-explorer.types.js' - * @import { Abortable } from './kv-utils.js' * @import { ObjectOrFunction } from '../common.types.js' + * @import { Abortable } from '../../lib/abortable.js' */ /** diff --git a/src/components/cc-kv-explorer/kv-key-editor-set-ctrl.js b/src/components/cc-kv-explorer/kv-key-editor-set-ctrl.js index d693dc81e..07db3c407 100644 --- a/src/components/cc-kv-explorer/kv-key-editor-set-ctrl.js +++ b/src/components/cc-kv-explorer/kv-key-editor-set-ctrl.js @@ -8,8 +8,8 @@ import { matchKvPattern } from './kv-utils.js'; * @import { CcKvExplorerDetailState, CcKvExplorerDetailStateEditSet, CcKvKeyValueSet } from './cc-kv-explorer.types.js' * @import { KvClient } from './kv-client.js' * @import { CcKvSetElementState, CcKvSetExplorerState, CcKvSetExplorerStateLoading, CcKvSetExplorerAddFormState } from '../cc-kv-set-explorer/cc-kv-set-explorer.types.js' - * @import { Abortable } from './kv-utils.js' * @import { ObjectOrFunction } from '../common.types.js' + * @import { Abortable } from '../../lib/abortable.js' */ /** diff --git a/src/components/cc-kv-explorer/kv-key-editor-string-ctrl.js b/src/components/cc-kv-explorer/kv-key-editor-string-ctrl.js index cd493e2d8..22befd5a3 100644 --- a/src/components/cc-kv-explorer/kv-key-editor-string-ctrl.js +++ b/src/components/cc-kv-explorer/kv-key-editor-string-ctrl.js @@ -4,8 +4,8 @@ import { KvKeyEditorCtrl } from './kv-key-editor-ctrl.js'; * @import { CcKvExplorer } from './cc-kv-explorer.js' * @import { CcKvExplorerDetailState, CcKvExplorerDetailStateEditString, CcKvKeyValueString } from './cc-kv-explorer.types.js' * @import { KvClient } from './kv-client.js' - * @import { Abortable } from './kv-utils.js' * @import { ObjectOrFunction } from '../common.types.js' + * @import { Abortable } from '../../lib/abortable.js' */ /** diff --git a/src/components/cc-kv-explorer/kv-keys-ctrl.js b/src/components/cc-kv-explorer/kv-keys-ctrl.js index 1389770a3..0c817e70f 100644 --- a/src/components/cc-kv-explorer/kv-keys-ctrl.js +++ b/src/components/cc-kv-explorer/kv-keys-ctrl.js @@ -1,6 +1,7 @@ +import { Abortable } from '../../lib/abortable.js'; import { isStringEmpty } from '../../lib/utils.js'; import { KvScanner } from './kv-scanner.js'; -import { Abortable, matchKvPattern } from './kv-utils.js'; +import { matchKvPattern } from './kv-utils.js'; /** * @import { CcKvExplorer } from './cc-kv-explorer.js' diff --git a/src/components/cc-kv-explorer/kv-scanner.js b/src/components/cc-kv-explorer/kv-scanner.js index 9c389f6e3..c299f94e7 100644 --- a/src/components/cc-kv-explorer/kv-scanner.js +++ b/src/components/cc-kv-explorer/kv-scanner.js @@ -1,5 +1,5 @@ /** - * @import { Abortable } from './kv-utils.js' + * @import { Abortable } from '../../lib/abortable.js'; */ /** diff --git a/src/components/cc-kv-explorer/kv-utils.js b/src/components/cc-kv-explorer/kv-utils.js index aa4ee3a94..1dd3b7bff 100644 --- a/src/components/cc-kv-explorer/kv-utils.js +++ b/src/components/cc-kv-explorer/kv-utils.js @@ -50,34 +50,3 @@ function convertKvMatchToRegex(match) { return new RegExp(`^${reg}$`); } - -export class Abortable { - constructor() { - /** @type {AbortController} */ - this.abortCtrl = null; - } - - abort() { - this.abortCtrl?.abort(); - } - - /** - * @param {(...args: Array) => Promise} func - * @returns {Promise} - * @template T - */ - run(func) { - this.abort(); - this.abortCtrl = new AbortController(); - - return new Promise((resolve, reject) => { - func(this.abortCtrl.signal) - .then(resolve) - .catch((e) => { - if (!(e instanceof DOMException && e.name === 'AbortError')) { - reject(e); - } - }); - }); - } -} diff --git a/src/lib/abortable.js b/src/lib/abortable.js new file mode 100644 index 000000000..c8c26b92e --- /dev/null +++ b/src/lib/abortable.js @@ -0,0 +1,30 @@ +export class Abortable { + constructor() { + /** @type {AbortController} */ + this.abortCtrl = null; + } + + abort() { + this.abortCtrl?.abort(); + } + + /** + * @param {(...args: Array) => Promise} func + * @returns {Promise} + * @template T + */ + run(func) { + this.abort(); + this.abortCtrl = new AbortController(); + + return new Promise((resolve, reject) => { + func(this.abortCtrl.signal) + .then(resolve) + .catch((e) => { + if (!(e instanceof DOMException && e.name === 'AbortError')) { + reject(e); + } + }); + }); + } +} diff --git a/src/lib/events-map.types.d.ts b/src/lib/events-map.types.d.ts index 522b8f92d..dff64293f 100644 --- a/src/lib/events-map.types.d.ts +++ b/src/lib/events-map.types.d.ts @@ -14,6 +14,7 @@ import { CcAddonRebuildEvent, CcAddonRestartEvent } from '../components/cc-addon import { CcAddonVersionChangeEvent } from '../components/cc-addon-info/cc-addon-info.events.js'; import { CcAddonOptionFormSubmitEvent } from '../components/cc-addon-option-form/cc-addon-option-form.events.js'; import { CcAddonOptionChangeEvent } from '../components/cc-addon-option/cc-addon-option.events.js'; +import { CcBreadcrumbClickEvent } from '../components/cc-breadcrumbs/cc-breadcrumbs.events.js'; import { CcCellarBucketCreatedEvent, CcCellarBucketCreateEvent, @@ -23,6 +24,18 @@ import { CcCellarBucketShowEvent, CcCellarBucketSortEvent, } from '../components/cc-cellar-bucket-list/cc-cellar-bucket-list.events.js'; +import { + CcCellarNavigateToBucketEvent, + CcCellarNavigateToHomeEvent, + CcCellarNavigateToNextPageEvent, + CcCellarNavigateToPathEvent, + CcCellarNavigateToPreviousPageEvent, + CcCellarObjectDeleteEvent, + CcCellarObjectDownloadEvent, + CcCellarObjectFilterEvent, + CcCellarObjectHideEvent, + CcCellarObjectShowEvent, +} from '../components/cc-cellar-object-list/cc-cellar-object-list.events.js'; import { CcDomainAddEvent, CcDomainDeleteEvent, @@ -197,6 +210,7 @@ declare global { 'cc-application-restart': CcApplicationRestartEvent; 'cc-application-start': CcApplicationStartEvent; 'cc-application-stop': CcApplicationStopEvent; + 'cc-breadcrumb-click': CcBreadcrumbClickEvent; 'cc-cellar-bucket-create': CcCellarBucketCreateEvent; 'cc-cellar-bucket-created': CcCellarBucketCreatedEvent; 'cc-cellar-bucket-delete': CcCellarBucketDeleteEvent; @@ -204,6 +218,16 @@ declare global { 'cc-cellar-bucket-hide': CcCellarBucketHideEvent; 'cc-cellar-bucket-show': CcCellarBucketShowEvent; 'cc-cellar-bucket-sort': CcCellarBucketSortEvent; + 'cc-cellar-navigate-to-bucket': CcCellarNavigateToBucketEvent; + 'cc-cellar-navigate-to-home': CcCellarNavigateToHomeEvent; + 'cc-cellar-navigate-to-next-page': CcCellarNavigateToNextPageEvent; + 'cc-cellar-navigate-to-path': CcCellarNavigateToPathEvent; + 'cc-cellar-navigate-to-previous-page': CcCellarNavigateToPreviousPageEvent; + 'cc-cellar-object-delete': CcCellarObjectDeleteEvent; + 'cc-cellar-object-download': CcCellarObjectDownloadEvent; + 'cc-cellar-object-filter': CcCellarObjectFilterEvent; + 'cc-cellar-object-hide': CcCellarObjectHideEvent; + 'cc-cellar-object-show': CcCellarObjectShowEvent; 'cc-click': CcClickEvent; 'cc-close': CcCloseEvent; 'cc-close-request': CcCloseRequestEvent; diff --git a/src/stories/fixtures/cellar.js b/src/stories/fixtures/cellar.js index 597d1de38..e20c05be1 100644 --- a/src/stories/fixtures/cellar.js +++ b/src/stories/fixtures/cellar.js @@ -2,7 +2,8 @@ import { random } from '../../lib/utils.js'; /** * @import { CellarBucketState, CellarBucketDetailsState } from '../../components/cc-cellar-bucket-list/cc-cellar-bucket-list.types.js' - * @import { CellarBucket } from '../../components/cc-cellar-explorer/cc-cellar-explorer.client.types.js' + * @import { CellarObjectState, CellarFileState, CellarFileDetailsState } from '../../components/cc-cellar-object-list/cc-cellar-object-list.types.js' + * @import { CellarBucket, CellarFileDetails } from '../../components/cc-cellar-explorer/cc-cellar-explorer.client.types.js' */ @@ -73,3 +74,115 @@ export function buckets(count) { return count.map(toBucket); } + +/** + * @type {Record} + */ +const CONTENT_TYPES_BY_EXTENSION = { + 'txt': 'text/plain', + 'jpg': 'image/jpeg', + 'zip': 'application/zip', + 'pdf': 'application/pdf', + 'mp3': 'audio/mpeg', + 'mp4': 'video/mp4', +} + +/** + * @param {string} extension + * @param {Array} [path] + * @returns {CellarFileDetails} + **/ +export function fileDetails(extension, path) { + const name = `file-1.${extension}`; + return { + type: 'file', + name: name, + key: path == null || path.length === 0 ? name : path.join('/') + '/' + name, + updatedAt: new Date().toISOString(), + contentLength: random(150_000, 2_000_000), + contentType: CONTENT_TYPES_BY_EXTENSION[extension] ?? 'application/octet-stream', + tags: [{key: 'tag1', value: 'value1'}, {key: 'tag2', value: 'value2'}], + acl: [ + { grantee: [{id: 'user', name: 'user', type: 'CanonicalUser'}], permission: 'FULL_CONTROL' }, + ], + metadata: {metadata1: 'value1', metadata2: 'value2'}, + } +} + +/** + * @param {string} extension + * @param {Array} [path] + * @returns {CellarFileState} + **/ +export function fileState(extension, path ) { + return { + state: 'idle', + ...fileDetails(extension, path), + } +} + +/** + * @param {string} extension + * @param {Array} [path] + * @returns {CellarFileDetailsState} + **/ +export function fileDetailsState(extension, path ) { + return { + state: 'idle', + ...fileDetails(extension, path), + } +} + +/** + * @param {number|Array} directories + * @param {number|Array} files + * @returns {Array} + */ +export function objects(directories, files) { + return [...generateDirectories(directories), ...generateFiles(files) ]; +} + +/** + * @param {number|Array} count + * @returns {Array} + */ +function generateFiles(count) { + /** + * @param {string} name + * @returns {CellarObjectState} + */ + const toFile = (name) => ({ + type: 'file', + state: 'idle', + name, + key: name, + updatedAt: new Date().toISOString(), + contentLength: random(150_000, 2_000_000), + }); + if (typeof count === 'number') { + return [...Array(count)].map((_, i) => toFile(`file-${i + 1}.txt`)); + } + + return count.map(toFile); +} + +/** + * @param {number|Array} count + * @returns {Array} + */ +function generateDirectories(count) { + /** + * @param {string} name + * @returns {CellarObjectState} + */ + const toDir = (name) => ({ + type: 'directory', + name, + key: name, + }); + if (typeof count === 'number') { + return [...Array(count)].map((_, i) => toDir(`dir-${i + 1}`)); + } + + return count.map(toDir); +} \ No newline at end of file diff --git a/src/translations/translations.en.js b/src/translations/translations.en.js index 3174c22ef..0a9950388 100644 --- a/src/translations/translations.en.js +++ b/src/translations/translations.en.js @@ -492,6 +492,56 @@ export const translations = { //#region cc-cellar-explorer 'cc-cellar-explorer.error': `Error while loading component`, //#endregion + //#region cc-cellar-object-list + 'cc-cellar-object-list.back-to-bucket-list': `Go back to list of buckets`, + 'cc-cellar-object-list.date': /** @param {{date: string}} _ */ ({ date }) => formatDateOnly(date), + 'cc-cellar-object-list.details.actions.delete.button': `Delete object`, + 'cc-cellar-object-list.details.actions.download.button': `Download object`, + 'cc-cellar-object-list.details.actions.title': `Actions`, + 'cc-cellar-object-list.details.heading': `Object details`, + 'cc-cellar-object-list.details.overview.content-type': `Content type`, + 'cc-cellar-object-list.details.overview.date': /** @param {{date: string}} _ */ ({ date }) => formatDate(date), + 'cc-cellar-object-list.details.overview.location': `Location`, + 'cc-cellar-object-list.details.overview.size': `Size`, + 'cc-cellar-object-list.details.overview.size-in-bytes': /** @param {{size: number}} _ */ ({ size }) => { + const formatted = formatBytes(size); + const exact = `${formatNumber(lang, size)}${BYTES_SI_SEPARATOR}${BYTES_SYMBOL}`; + return formatted === exact + ? formatted + : `${formatted} (${formatNumber(lang, size)}${BYTES_SI_SEPARATOR}byte${size <= 1 ? '' : 's'})`; + }, + 'cc-cellar-object-list.details.overview.title': `Object overview`, + 'cc-cellar-object-list.details.overview.updated-at': `Last update`, + 'cc-cellar-object-list.empty.no-filtered-items': `No objects matching the filter were found`, + 'cc-cellar-object-list.empty.no-items': `There are no objects`, + 'cc-cellar-object-list.error': `Something went wrong while loading the list of objects`, + 'cc-cellar-object-list.error.bucket-not-found': /** @param {{bucketName: string}} _ */ ({ bucketName }) => + `Bucket ${bucketName} does not exist`, + 'cc-cellar-object-list.error.object-deletion-failed': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `Failed to delete object ${objectKey}`, + 'cc-cellar-object-list.error.object-download-failed': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `Failed to download object ${objectKey}`, + 'cc-cellar-object-list.error.object-fetch-failed': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `Failed to get object ${objectKey}`, + 'cc-cellar-object-list.error.object-not-found': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `Object ${objectKey} does not exist`, + 'cc-cellar-object-list.grid.a11y-name': `List of objects`, + 'cc-cellar-object-list.grid.column.last-update': `Last update`, + 'cc-cellar-object-list.grid.column.name': `Name`, + 'cc-cellar-object-list.grid.column.size': `Size`, + 'cc-cellar-object-list.grid.show-details.a11y-name': /** @param {{objectName: string}} _ */ ({ objectName }) => + `Show details for object ${objectName}`, + 'cc-cellar-object-list.heading.filter.button': `Filter`, + 'cc-cellar-object-list.heading.filter.label': `Filter`, + 'cc-cellar-object-list.heading.title': `List of objects`, + 'cc-cellar-object-list.page.next': `Next page`, + 'cc-cellar-object-list.page.previous': `Previous page`, + 'cc-cellar-object-list.size': /** @param {{size: number}} _ */ ({ size }) => formatBytes(size), + 'cc-cellar-object-list.success.object-already-deleted': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `Object ${objectKey} was already deleted`, + 'cc-cellar-object-list.success.object-deleted': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `Object ${objectKey} deleted successfully`, + //#endregion //#region cc-clipboard 'cc-clipboard.copied': `The text has been copied`, 'cc-clipboard.copy': /** @param {{text: string}} _ */ ({ text }) => diff --git a/src/translations/translations.fr.js b/src/translations/translations.fr.js index cbc56d905..8c645030a 100644 --- a/src/translations/translations.fr.js +++ b/src/translations/translations.fr.js @@ -503,6 +503,56 @@ export const translations = { //#region cc-cellar-explorer 'cc-cellar-explorer.error': `Une erreur est survenue pendant le chargement`, //#endregion + //#region cc-cellar-object-list + 'cc-cellar-object-list.back-to-bucket-list': `Retour à la list des buckets`, + 'cc-cellar-object-list.date': /** @param {{date: string}} _ */ ({ date }) => formatDateOnly(date), + 'cc-cellar-object-list.details.actions.delete.button': `Supprimer l'objet`, + 'cc-cellar-object-list.details.actions.download.button': `Télécharger l'objet`, + 'cc-cellar-object-list.details.actions.title': `Actions`, + 'cc-cellar-object-list.details.heading': `Détails de l'objet`, + 'cc-cellar-object-list.details.overview.content-type': `Content-Type`, + 'cc-cellar-object-list.details.overview.date': /** @param {{date: string}} _ */ ({ date }) => formatDate(date), + 'cc-cellar-object-list.details.overview.location': `Emplacement`, + 'cc-cellar-object-list.details.overview.size': `Taille`, + 'cc-cellar-object-list.details.overview.size-in-bytes': /** @param {{size: number}} _ */ ({ size }) => { + const formatted = formatBytes(size); + const exact = `${formatNumber(lang, size)}${BYTES_SI_SEPARATOR}${BYTES_SYMBOL}`; + return formatted === exact + ? formatted + : `${formatted} (${formatNumber(lang, size)}${BYTES_SI_SEPARATOR}octet${size <= 1 ? '' : 's'})`; + }, + 'cc-cellar-object-list.details.overview.title': `Aperçu de l'objet`, + 'cc-cellar-object-list.details.overview.updated-at': `Dernière modification`, + 'cc-cellar-object-list.empty.no-filtered-items': `Aucun objet ne correspond au filtre`, + 'cc-cellar-object-list.empty.no-items': `Il n'y a aucun objet`, + 'cc-cellar-object-list.error': `Une erreur est survenue pendant le chargement de la liste des objets`, + 'cc-cellar-object-list.error.bucket-not-found': /** @param {{bucketName: string}} _ */ ({ bucketName }) => + `Le bucket ${bucketName} n'existe pas`, + 'cc-cellar-object-list.error.object-deletion-failed': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `La suppression de l'objet ${objectKey} a échoué`, + 'cc-cellar-object-list.error.object-download-failed': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `Impossible de télécharger l'objet ${objectKey}`, + 'cc-cellar-object-list.error.object-fetch-failed': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `Impossible de récupérer l'objet ${objectKey}`, + 'cc-cellar-object-list.error.object-not-found': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `L'objet ${objectKey} n'existe pas`, + 'cc-cellar-object-list.grid.a11y-name': `Liste des objets`, + 'cc-cellar-object-list.grid.column.last-update': `Dernière modification`, + 'cc-cellar-object-list.grid.column.name': `Nom`, + 'cc-cellar-object-list.grid.column.size': `Taille`, + 'cc-cellar-object-list.grid.show-details.a11y-name': /** @param {{objectName: string}} _ */ ({ objectName }) => + `Afficher les détails de l'objet ${objectName}`, + 'cc-cellar-object-list.heading.filter.button': `Filtrer`, + 'cc-cellar-object-list.heading.filter.label': `Filtre`, + 'cc-cellar-object-list.heading.title': `Liste des objets`, + 'cc-cellar-object-list.page.next': `Page suivante`, + 'cc-cellar-object-list.page.previous': `Page précédente`, + 'cc-cellar-object-list.size': /** @param {{size: number}} _ */ ({ size }) => formatBytes(size), + 'cc-cellar-object-list.success.object-already-deleted': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `L'objet ${objectKey} était déjà supprimé`, + 'cc-cellar-object-list.success.object-deleted': /** @param {{objectKey: string}} _ */ ({ objectKey }) => + `L'objet ${objectKey} a été supprimé avec succès`, + //#endregion //#region cc-clipboard 'cc-clipboard.copied': `Le texte a été copié`, 'cc-clipboard.copy': /** @param {{text: string}} _ */ ({ text }) =>