From 0bf8aef3f6afdaeea1859aaa21fc897331d9187d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 8 May 2024 19:33:19 +0800 Subject: [PATCH] Created UI for pod managament and browsing and identity management, expanded on UI --- .eslintrc.js | 6 +- .stylelintignore | 4 + .stylelintrc.js | 5 + addon/components/explorer-header.hbs | 18 + addon/components/explorer-header.js | 25 ++ addon/components/modals/backup-pod.hbs | 3 + addon/components/modals/create-pod.hbs | 5 + addon/components/modals/resync-pod.hbs | 3 + .../table/cell/pod-content-actions.hbs | 32 ++ .../table/cell/pod-content-actions.js | 73 ++++ .../table/cell/pod-content-name.hbs | 8 + .../components/table/cell/pod-content-name.js | 16 + addon/controllers/account.js | 130 ++++++ addon/controllers/application.js | 29 +- addon/controllers/home.js | 23 ++ addon/controllers/pods/explorer.js | 149 +++++++ addon/controllers/pods/explorer/content.js | 12 + addon/controllers/pods/index.js | 137 +++++++ addon/controllers/pods/index/pod.js | 12 + addon/routes.js | 13 +- addon/routes/account.js | 3 + addon/routes/home.js | 3 + addon/routes/pods/explorer.js | 44 ++ addon/routes/pods/explorer/content.js | 10 + addon/routes/pods/index.js | 21 + addon/routes/pods/index/pod.js | 3 + addon/services/explorer-state.js | 101 +++++ addon/styles/solid-engine.css | 46 +++ addon/templates/account.hbs | 42 ++ addon/templates/application.hbs | 29 +- addon/templates/home.hbs | 11 + addon/templates/pods/explorer.hbs | 20 + addon/templates/pods/explorer/content.hbs | 19 + addon/templates/pods/index.hbs | 19 + addon/templates/pods/index/pod.hbs | 11 + app/components/explorer-header.js | 1 + app/components/modals/backup-pod.js | 1 + app/components/modals/create-pod.js | 1 + app/components/modals/resync-pod.js | 1 + .../table/cell/pod-content-actions.js | 1 + app/components/table/cell/pod-content-name.js | 1 + app/controllers/account.js | 1 + app/controllers/home.js | 1 + app/controllers/pods/explorer.js | 1 + app/controllers/pods/explorer/content.js | 1 + app/controllers/pods/index.js | 1 + app/controllers/pods/index/pod.js | 1 + app/routes/account.js | 1 + app/routes/home.js | 1 + app/routes/pods/explorer.js | 1 + app/routes/pods/explorer/content.js | 1 + app/routes/pods/index.js | 1 + app/routes/pods/index/pod.js | 1 + app/services/explorer-state.js | 1 + app/templates/account.js | 1 + app/templates/home.js | 1 + app/templates/pods/explorer.js | 1 + app/templates/pods/explorer/content.js | 1 + app/templates/pods/index.js | 1 + app/templates/pods/index/pod.js | 1 + composer.json | 12 +- extension.json | 2 +- package.json | 6 +- pnpm-lock.yaml | 385 ++++++++++++++---- server/data/pods.json | 328 +++++++++++++++ server/src/Client/OpenIDConnectClient.php | 4 +- server/src/Client/SolidClient.php | 2 +- .../src/Http/Controllers/SolidController.php | 61 ++- server/src/LegacyClient/OIDCClient.php | 2 +- server/src/LegacyClient/SolidClient.php | 3 +- server/src/Support/Utils.php | 38 ++ server/src/routes.php | 1 + .../components/explorer-header-test.js | 26 ++ .../components/modals/backup-pod-test.js | 26 ++ .../components/modals/create-pod-test.js | 26 ++ .../components/modals/resync-pod-test.js | 26 ++ .../table/cell/pod-content-actions-test.js | 26 ++ .../table/cell/pod-content-name-test.js | 26 ++ tests/unit/controllers/account-test.js | 12 + tests/unit/controllers/home-test.js | 12 + tests/unit/controllers/pods/explorer-test.js | 12 + .../controllers/pods/explorer/content-test.js | 12 + tests/unit/controllers/pods/index-test.js | 12 + tests/unit/controllers/pods/index/pod-test.js | 12 + tests/unit/routes/account-test.js | 11 + tests/unit/routes/home-test.js | 11 + tests/unit/routes/pods/explorer-test.js | 11 + .../unit/routes/pods/explorer/content-test.js | 11 + tests/unit/routes/pods/index-test.js | 11 + tests/unit/routes/pods/index/pod-test.js | 11 + tests/unit/services/explorer-state-test.js | 12 + 91 files changed, 2142 insertions(+), 118 deletions(-) create mode 100644 addon/components/explorer-header.hbs create mode 100644 addon/components/explorer-header.js create mode 100644 addon/components/modals/backup-pod.hbs create mode 100644 addon/components/modals/create-pod.hbs create mode 100644 addon/components/modals/resync-pod.hbs create mode 100644 addon/components/table/cell/pod-content-actions.hbs create mode 100644 addon/components/table/cell/pod-content-actions.js create mode 100644 addon/components/table/cell/pod-content-name.hbs create mode 100644 addon/components/table/cell/pod-content-name.js create mode 100644 addon/controllers/account.js create mode 100644 addon/controllers/home.js create mode 100644 addon/controllers/pods/explorer.js create mode 100644 addon/controllers/pods/explorer/content.js create mode 100644 addon/controllers/pods/index.js create mode 100644 addon/controllers/pods/index/pod.js create mode 100644 addon/routes/account.js create mode 100644 addon/routes/home.js create mode 100644 addon/routes/pods/explorer.js create mode 100644 addon/routes/pods/explorer/content.js create mode 100644 addon/routes/pods/index.js create mode 100644 addon/routes/pods/index/pod.js create mode 100644 addon/services/explorer-state.js create mode 100644 addon/templates/account.hbs create mode 100644 addon/templates/home.hbs create mode 100644 addon/templates/pods/explorer.hbs create mode 100644 addon/templates/pods/explorer/content.hbs create mode 100644 addon/templates/pods/index.hbs create mode 100644 addon/templates/pods/index/pod.hbs create mode 100644 app/components/explorer-header.js create mode 100644 app/components/modals/backup-pod.js create mode 100644 app/components/modals/create-pod.js create mode 100644 app/components/modals/resync-pod.js create mode 100644 app/components/table/cell/pod-content-actions.js create mode 100644 app/components/table/cell/pod-content-name.js create mode 100644 app/controllers/account.js create mode 100644 app/controllers/home.js create mode 100644 app/controllers/pods/explorer.js create mode 100644 app/controllers/pods/explorer/content.js create mode 100644 app/controllers/pods/index.js create mode 100644 app/controllers/pods/index/pod.js create mode 100644 app/routes/account.js create mode 100644 app/routes/home.js create mode 100644 app/routes/pods/explorer.js create mode 100644 app/routes/pods/explorer/content.js create mode 100644 app/routes/pods/index.js create mode 100644 app/routes/pods/index/pod.js create mode 100644 app/services/explorer-state.js create mode 100644 app/templates/account.js create mode 100644 app/templates/home.js create mode 100644 app/templates/pods/explorer.js create mode 100644 app/templates/pods/explorer/content.js create mode 100644 app/templates/pods/index.js create mode 100644 app/templates/pods/index/pod.js create mode 100644 server/data/pods.json create mode 100644 tests/integration/components/explorer-header-test.js create mode 100644 tests/integration/components/modals/backup-pod-test.js create mode 100644 tests/integration/components/modals/create-pod-test.js create mode 100644 tests/integration/components/modals/resync-pod-test.js create mode 100644 tests/integration/components/table/cell/pod-content-actions-test.js create mode 100644 tests/integration/components/table/cell/pod-content-name-test.js create mode 100644 tests/unit/controllers/account-test.js create mode 100644 tests/unit/controllers/home-test.js create mode 100644 tests/unit/controllers/pods/explorer-test.js create mode 100644 tests/unit/controllers/pods/explorer/content-test.js create mode 100644 tests/unit/controllers/pods/index-test.js create mode 100644 tests/unit/controllers/pods/index/pod-test.js create mode 100644 tests/unit/routes/account-test.js create mode 100644 tests/unit/routes/home-test.js create mode 100644 tests/unit/routes/pods/explorer-test.js create mode 100644 tests/unit/routes/pods/explorer/content-test.js create mode 100644 tests/unit/routes/pods/index-test.js create mode 100644 tests/unit/routes/pods/index/pod-test.js create mode 100644 tests/unit/services/explorer-state-test.js diff --git a/.eslintrc.js b/.eslintrc.js index d483054..6ce7d20 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,6 +16,10 @@ module.exports = { env: { browser: true, }, + globals: { + socketClusterClient: 'readonly', + L: 'readonly', + }, rules: { 'ember/no-array-prototype-extensions': 'off', 'ember/no-computed-properties-in-native-classes': 'off', @@ -27,7 +31,7 @@ module.exports = { 'n/no-unpublished-require': [ 'error', { - allowModules: ['resolve', 'broccoli-funnel'], + allowModules: ['resolve'], }, ], }, diff --git a/.stylelintignore b/.stylelintignore index a0cf71c..29348e2 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -6,3 +6,7 @@ # addons /.node_modules.ember-try/ + +# server +/server/ +/server_vendor/ diff --git a/.stylelintrc.js b/.stylelintrc.js index 62d4554..f1804fe 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -2,4 +2,9 @@ module.exports = { extends: ['stylelint-config-standard', 'stylelint-prettier/recommended'], + rules: { + 'selector-class-pattern': null, + 'no-descending-specificity': null, + 'color-function-notation': null, + }, }; diff --git a/addon/components/explorer-header.hbs b/addon/components/explorer-header.hbs new file mode 100644 index 0000000..a7cc128 --- /dev/null +++ b/addon/components/explorer-header.hbs @@ -0,0 +1,18 @@ +
+
+
+ +
+ {{#each this.state as |content|}} + {{content.name}} + {{/each}} +
+
+
+
+ {{#if @onSearch}} + + {{/if}} + {{yield}} +
+
\ No newline at end of file diff --git a/addon/components/explorer-header.js b/addon/components/explorer-header.js new file mode 100644 index 0000000..2c26ee9 --- /dev/null +++ b/addon/components/explorer-header.js @@ -0,0 +1,25 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class ExplorerHeaderComponent extends Component { + @service explorerState; + @tracked state = []; + + constructor(owner, { pod }) { + super(...arguments); + this.state = this.explorerState.get(pod); + this.explorerState.on('change', (id, state) => { + if (id === pod) { + this.state = state; + } + }); + } + + @action onStateClicked(content) { + if (typeof this.args.onStateClicked === 'function') { + this.args.onStateClicked(content); + } + } +} diff --git a/addon/components/modals/backup-pod.hbs b/addon/components/modals/backup-pod.hbs new file mode 100644 index 0000000..464bfc7 --- /dev/null +++ b/addon/components/modals/backup-pod.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/addon/components/modals/create-pod.hbs b/addon/components/modals/create-pod.hbs new file mode 100644 index 0000000..e57cead --- /dev/null +++ b/addon/components/modals/create-pod.hbs @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/addon/components/modals/resync-pod.hbs b/addon/components/modals/resync-pod.hbs new file mode 100644 index 0000000..464bfc7 --- /dev/null +++ b/addon/components/modals/resync-pod.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/addon/components/table/cell/pod-content-actions.hbs b/addon/components/table/cell/pod-content-actions.hbs new file mode 100644 index 0000000..ab01dce --- /dev/null +++ b/addon/components/table/cell/pod-content-actions.hbs @@ -0,0 +1,32 @@ +
+ + + +
\ No newline at end of file diff --git a/addon/components/table/cell/pod-content-actions.js b/addon/components/table/cell/pod-content-actions.js new file mode 100644 index 0000000..3c69eae --- /dev/null +++ b/addon/components/table/cell/pod-content-actions.js @@ -0,0 +1,73 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action, computed } from '@ember/object'; +import { isArray } from '@ember/array'; + +export default class TableCellPodContentActionsComponent extends Component { + constructor(owner, { column, row }) { + super(...arguments); + if (isArray(column.actions)) { + this.actions = column.actions; + } + + if (typeof column.actions === 'function') { + this.actions = column.actions(row); + } + } + + @tracked actions = []; + @tracked defaultButtonText = 'Actions'; + + @computed('args.column.ddButtonText', 'defaultButtonText') get buttonText() { + const { ddButtonText } = this.args.column; + + if (ddButtonText === undefined) { + return this.defaultButtonText; + } + + if (ddButtonText === false) { + return null; + } + + return ddButtonText; + } + + @action setupComponent(dropdownWrapperNode) { + const tableCellNode = this.getOwnerTableCell(dropdownWrapperNode); + tableCellNode.style.overflow = 'visible'; + } + + @action getOwnerTableCell(dropdownWrapperNode) { + while (dropdownWrapperNode) { + dropdownWrapperNode = dropdownWrapperNode.parentNode; + + if (dropdownWrapperNode.tagName.toLowerCase() === 'td') { + return dropdownWrapperNode; + } + } + + return undefined; + } + + @action onDropdownItemClick(columnAction, row, dd) { + if (typeof dd?.actions?.close === 'function') { + dd.actions.close(); + } + + if (typeof columnAction?.fn === 'function') { + columnAction.fn(row); + } + } + + @action calculatePosition(trigger) { + let { width } = trigger.getBoundingClientRect(); + + let style = { + marginTop: '0px', + right: width + 3, + top: 0, + }; + + return { style }; + } +} diff --git a/addon/components/table/cell/pod-content-name.hbs b/addon/components/table/cell/pod-content-name.hbs new file mode 100644 index 0000000..bad8160 --- /dev/null +++ b/addon/components/table/cell/pod-content-name.hbs @@ -0,0 +1,8 @@ + + {{#if (has-block)}} + {{yield}} + {{else}} + + {{or @value @column.anchorText "-"}} + {{/if}} + \ No newline at end of file diff --git a/addon/components/table/cell/pod-content-name.js b/addon/components/table/cell/pod-content-name.js new file mode 100644 index 0000000..5ebc866 --- /dev/null +++ b/addon/components/table/cell/pod-content-name.js @@ -0,0 +1,16 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class TableCellPodContentNameComponent extends Component { + @action onClick() { + const { column, row } = this.args; + + if (typeof column?.action === 'function') { + column.action(row); + } + + if (typeof column?.onClick === 'function') { + column.onClick(row, ...arguments); + } + } +} diff --git a/addon/controllers/account.js b/addon/controllers/account.js new file mode 100644 index 0000000..7bfbc31 --- /dev/null +++ b/addon/controllers/account.js @@ -0,0 +1,130 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import { task } from 'ember-concurrency'; + +export default class AccountController extends Controller { + /** + * Inject the `currentUser` service. + * + * @memberof ConsoleAccountIndexController + */ + @service currentUser; + + /** + * Inject the `fetch` service. + * + * @memberof ConsoleAccountIndexController + */ + @service fetch; + + /** + * Inject the `notifications` service. + * + * @memberof ConsoleAccountIndexController + */ + @service notifications; + + /** + * Inject the `modalsManager` service. + * + * @memberof ConsoleAccountIndexController + */ + @service modalsManager; + + /** + * Alias to the currentUser service user record. + * + * @memberof ConsoleAccountIndexController + */ + @alias('currentUser.user') user; + + /** + * Handle upload of new photo + * + * @param {UploadFile} file + * @memberof ConsoleAccountIndexController + */ + @action uploadNewPhoto(file) { + return this.fetch.uploadFile.perform( + file, + { + path: `uploads/${this.user.company_uuid}/users/${this.user.slug}`, + subject_uuid: this.user.id, + subject_type: 'user', + type: 'user_avatar', + }, + (uploadedFile) => { + this.user.setProperties({ + avatar_uuid: uploadedFile.id, + avatar_url: uploadedFile.url, + }); + + return this.user.save(); + } + ); + } + + /** + * Starts the task to change password + * + * @param {Event} event + * @memberof ConsoleAccountIndexController + */ + @task *saveProfile(event) { + // If from event fired + if (event instanceof Event) { + event.preventDefault(); + } + + let canUpdateProfile = true; + // If email has been changed prompt for password validation + if (this.changedUserAttribute('email')) { + canUpdateProfile = yield this.validatePassword.perform(); + } + + if (canUpdateProfile === true) { + try { + const user = yield this.user.save(); + this.notifications.success('Profile changes saved.'); + this.currentUser.set('user', user); + } catch (error) { + this.notifications.serverError(error); + } + } else { + this.user.rollbackAttributes(); + } + } + + /** + * Task to validate current password + * + * @return {boolean} + * @memberof ConsoleAccountIndexController + */ + @task *validatePassword() { + let isPasswordValid = false; + + yield this.modalsManager.show('modals/validate-password', { + body: 'You must validate your password to update the account email address.', + onValidated: (isValid) => { + isPasswordValid = isValid; + }, + }); + + return isPasswordValid; + } + + /** + * Checks if any user attribute has been changed + * + * @param {string} attributeKey + * @return {boolean} + * @memberof ConsoleAccountIndexController + */ + changedUserAttribute(attributeKey) { + const changedAttributes = this.user.changedAttributes(); + return changedAttributes[attributeKey] !== undefined; + } +} diff --git a/addon/controllers/application.js b/addon/controllers/application.js index a87cc82..8a0d04c 100644 --- a/addon/controllers/application.js +++ b/addon/controllers/application.js @@ -1,27 +1,30 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; -import { task } from 'ember-concurrency'; +import { tracked } from '@glimmer/tracking'; +import { task, timeout } from 'ember-concurrency'; export default class ApplicationController extends Controller { - @service universe; @service fetch; + @service appCache; + @tracked pods = []; constructor() { super(...arguments); - this.universe.on('sidebarContext.available', (sidebarContext) => { - sidebarContext.hideNow(); - }); + this.getPods.perform(); } - @task *authenticate() { - const { authenticationUrl, identifier } = yield this.fetch.get('request-authentication', {}, { namespace: 'solid/int/v1' }); - if (authenticationUrl) { - window.location.href = `${authenticationUrl}/${identifier}`; + @task *getPods() { + yield timeout(600); + + if (this.appCache.has('solid:pods')) { + this.pods = this.appCache.get('solid:pods', []); + return; } - } - @task *getAccountIndex() { - const response = yield this.fetch.get('account', {}, { namespace: 'solid/int/v1' }); - console.log('[response]', response); + try { + this.pods = yield this.fetch.get('pods', {}, { namespace: 'solid/int/v1' }); + } catch (error) { + // silence + } } } diff --git a/addon/controllers/home.js b/addon/controllers/home.js new file mode 100644 index 0000000..033a1bd --- /dev/null +++ b/addon/controllers/home.js @@ -0,0 +1,23 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +export default class HomeController extends Controller { + @service fetch; + @service notifications; + + @task *authenticate() { + try { + const { authenticationUrl, identifier } = yield this.fetch.get('request-authentication', {}, { namespace: 'solid/int/v1' }); + if (authenticationUrl) { + window.location.href = `${authenticationUrl}/${identifier}`; + } + } catch (error) { + this.notifications.serverError(error); + } + } + + @task *getAccountIndex() { + yield this.fetch.get('account', {}, { namespace: 'solid/int/v1' }); + } +} diff --git a/addon/controllers/pods/explorer.js b/addon/controllers/pods/explorer.js new file mode 100644 index 0000000..6d08210 --- /dev/null +++ b/addon/controllers/pods/explorer.js @@ -0,0 +1,149 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task, timeout } from 'ember-concurrency'; + +export default class PodsExplorerController extends Controller { + @service hostRouter; + @service fetch; + @service notifications; + @service explorerState; + @service modalsManager; + @service crud; + @tracked cursor = ''; + @tracked pod = ''; + @tracked query = ''; + queryParams = ['cursor', 'pod', 'query']; + columns = [ + { + label: 'Name', + valuePath: 'name', + width: '75%', + cellComponent: 'table/cell/pod-content-name', + onClick: this.viewContents, + }, + { + label: 'Type', + valuePath: 'type', + cellClassNames: 'capitalize', + width: '5%', + }, + { + label: 'Size', + valuePath: 'size', + width: '5%', + }, + { + label: 'Created At', + valuePath: 'created_at', + width: '15%', + }, + { + label: '', + cellComponent: 'table/cell/pod-content-actions', + ddButtonText: false, + ddButtonIcon: 'ellipsis-h', + ddButtonIconPrefix: 'fas', + ddMenuLabel: 'Actions', + cellClassNames: 'overflow-visible', + wrapperClass: 'flex items-center justify-end mx-2', + width: '10%', + actions: (content) => { + return [ + { + label: content.type === 'folder' ? 'Browse Folder' : 'View Contents', + fn: this.viewContents, + }, + { + separator: true, + }, + { + label: 'Delete', + fn: this.deleteSomething, + }, + ]; + }, + sortable: false, + filterable: false, + resizable: false, + searchable: false, + }, + ]; + + @action reload() { + this.hostRouter.refresh(); + } + + @action back() { + if (typeof this.cursor === 'string' && this.cursor.length && this.cursor !== this.model.id) { + const current = this.reverseCursor(); + return this.hostRouter.transitionTo('console.solid-protocol.pods.explorer', current, { queryParams: { cursor: this.cursor, pod: this.pod } }); + } + + this.hostRouter.transitionTo('console.solid-protocol.pods.index'); + } + + @action viewContents(content) { + if (content.type === 'folder') { + return this.hostRouter.transitionTo('console.solid-protocol.pods.explorer', content, { queryParams: { cursor: this.trackCursor(content), pod: this.pod } }); + } + + if (content.type === 'file') { + return this.hostRouter.transitionTo('console.solid-protocol.pods.explorer.content', content); + } + + return this.hostRouter.transitionTo('console.solid-protocol.pods.explorer', this.pod, { queryParams: { cursor: this.trackCursor(content), pod: this.pod } }); + } + + @action deleteSomething() { + this.modalsManager.confirm({ + title: 'Are you sure you want to delete this content?', + body: 'Deleting this Content will remove this content from this pod. This is irreversible!', + acceptButtonText: 'Delete Forever', + confirm: () => {}, + }); + } + + @action deleteSelected() { + const selected = this.table.selectedRows; + + this.crud.bulkDelete(selected, { + modelNamePath: 'name', + acceptButtonText: 'Delete All', + onSuccess: () => { + return this.hostRouter.refresh(); + }, + }); + } + + trackCursor(content) { + if (typeof this.cursor === 'string' && this.cursor.includes(content.id)) { + const segments = this.cursor.split(':'); + const currentIndex = segments.findIndex((segment) => segment === content.id); + + if (currentIndex > -1) { + const retainedSegments = segments.slice(0, currentIndex + 1); + this.cursor = retainedSegments.join(':'); + return this.cursor; + } + } + + this.cursor = this.cursor ? `${this.cursor}:${content.id}` : content.id; + return this.cursor; + } + + reverseCursor() { + const segments = this.cursor.split(':'); + segments.pop(); + const current = segments[segments.length - 1]; + this.cursor = segments.join(':'); + return current; + } + + @task({ restartable: true }) *search(event) { + yield timeout(300); + const query = typeof event.target.value === 'string' ? event.target.value : ''; + this.hostRouter.transitionTo('console.solid-protocol.pods.explorer', this.model.id, { queryParams: { cursor: this.cursor, query } }); + } +} diff --git a/addon/controllers/pods/explorer/content.js b/addon/controllers/pods/explorer/content.js new file mode 100644 index 0000000..b6a540e --- /dev/null +++ b/addon/controllers/pods/explorer/content.js @@ -0,0 +1,12 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; + +export default class PodsExplorerContentController extends Controller { + @action setOverlayContext(overlayContextApi) { + this.overlayContextApi = overlayContextApi; + } + + @action onPressClose() { + window.history.back(); + } +} diff --git a/addon/controllers/pods/index.js b/addon/controllers/pods/index.js new file mode 100644 index 0000000..a0a63e7 --- /dev/null +++ b/addon/controllers/pods/index.js @@ -0,0 +1,137 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task, timeout } from 'ember-concurrency'; + +export default class PodsIndexController extends Controller { + @service hostRouter; + @service notifications; + @service filters; + @service modalsManager; + @service crud; + @tracked query = ''; + + columns = [ + { + label: 'Pod', + valuePath: 'name', + width: '80%', + cellComponent: 'table/cell/anchor', + onClick: this.explorePod, + }, + { + label: 'Size', + valuePath: 'size', + width: '5%', + }, + { + label: 'Created At', + valuePath: 'created_at', + width: '15%', + }, + { + label: '', + cellComponent: 'table/cell/dropdown', + ddButtonText: false, + ddButtonIcon: 'ellipsis-h', + ddButtonIconPrefix: 'fas', + ddMenuLabel: 'Pod Actions', + cellClassNames: 'overflow-visible', + wrapperClass: 'flex items-center justify-end mx-2', + width: '10%', + actions: [ + { + label: 'Browse', + fn: this.openPod, + }, + { + label: 'Backup', + fn: this.backupPod, + }, + { + label: 'Re-sync', + fn: this.resyncPod, + }, + { + separator: true, + }, + { + label: 'Delete', + fn: this.deletePod, + }, + ], + sortable: false, + filterable: false, + resizable: false, + searchable: false, + }, + ]; + + @action reload() { + this.hostRouter.refresh(); + } + + @action openPod(pod) { + this.hostRouter.transitionTo('console.solid-protocol.pods.index.pod', pod); + } + + @action explorePod(pod) { + this.hostRouter.transitionTo('console.solid-protocol.pods.explorer', pod, { queryParams: { cursor: pod.id, pod: pod.id } }); + } + + @action createPod() { + this.modalsManager.show('modals/create-pod', { + title: 'Create a new Pod', + acceptButtonText: 'Create Pod', + pod: { + name: null, + }, + confirm: () => {}, + }); + } + + @action backupPod() { + this.modalsManager.confirm({ + title: 'Are you sure you want to create a backup?', + body: 'Running a backup will create a duplicate Pod with the same contents.', + acceptButtonText: 'Start Backup', + confirm: () => {}, + }); + } + + @action resyncPod() { + this.modalsManager.confirm({ + title: 'Are you sure you want to re-sync?', + body: 'Running a re-sync will update all data from Fleetbase to this pod, overwriting the current contents with the latest.', + acceptButtonText: 'Start Sync', + confirm: () => {}, + }); + } + + @action deletePod() { + this.modalsManager.confirm({ + title: 'Are you sure you want to delete this Pod?', + body: "Deleting this Pod will destroy this pod and all it's contents. This is irreversible!", + acceptButtonText: 'Delete Forever', + confirm: () => {}, + }); + } + + @action deleteSelectedPods() { + const selected = this.table.selectedRows; + + this.crud.bulkDelete(selected, { + modelNamePath: 'name', + acceptButtonText: 'Delete All', + onSuccess: () => { + return this.hostRouter.refresh(); + }, + }); + } + + @task({ restartable: true }) *search(event) { + yield timeout(300); + this.query = typeof event.target.value === 'string' ? event.target.value : ''; + } +} diff --git a/addon/controllers/pods/index/pod.js b/addon/controllers/pods/index/pod.js new file mode 100644 index 0000000..57ee654 --- /dev/null +++ b/addon/controllers/pods/index/pod.js @@ -0,0 +1,12 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; + +export default class PodsIndexPodController extends Controller { + @action setOverlayContext(overlayContextApi) { + this.overlayContextApi = overlayContextApi; + } + + @action onPressClose() { + window.history.back(); + } +} diff --git a/addon/routes.js b/addon/routes.js index fbe9f1b..edcdee1 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -1,3 +1,14 @@ import buildRoutes from 'ember-engines/routes'; -export default buildRoutes(function () {}); +export default buildRoutes(function () { + this.route('home', { path: '/' }); + this.route('account'); + this.route('pods', function () { + this.route('explorer', { path: '/explorer/:id' }, function () { + this.route('content', { path: '/~/:slug' }); + }); + this.route('index', { path: '/' }, function () { + this.route('pod', { path: '/pod/:slug' }); + }); + }); +}); diff --git a/addon/routes/account.js b/addon/routes/account.js new file mode 100644 index 0000000..17e9a38 --- /dev/null +++ b/addon/routes/account.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class AccountRoute extends Route {} diff --git a/addon/routes/home.js b/addon/routes/home.js new file mode 100644 index 0000000..f9ee296 --- /dev/null +++ b/addon/routes/home.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class HomeRoute extends Route {} diff --git a/addon/routes/pods/explorer.js b/addon/routes/pods/explorer.js new file mode 100644 index 0000000..cded656 --- /dev/null +++ b/addon/routes/pods/explorer.js @@ -0,0 +1,44 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class PodsExplorerRoute extends Route { + @service fetch; + @service explorerState; + + queryParmas = { + query: { + refreshModel: true, + }, + pod: { + refreshModel: false, + }, + cursor: { + refreshModel: false, + }, + }; + + @action willTransition(transition) { + const pod = transition.to.queryParams.pod; + const cursor = transition.to.queryParams.cursor; + if (pod && cursor) { + this.explorerState.trackWithCursor(pod, cursor); + } + } + + beforeModel(transition) { + const pod = transition.to.queryParams.pod; + const cursor = transition.to.queryParams.cursor; + if (pod && cursor) { + this.explorerState.trackWithCursor(pod, cursor); + } + } + + model({ id, query }) { + return this.fetch.get('pods', { id, query }, { namespace: 'solid/int/v1' }); + } + + afterModel(model, transition) { + this.explorerState.track(transition.to.queryParams.pod, model); + } +} diff --git a/addon/routes/pods/explorer/content.js b/addon/routes/pods/explorer/content.js new file mode 100644 index 0000000..88b97fb --- /dev/null +++ b/addon/routes/pods/explorer/content.js @@ -0,0 +1,10 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class PodsExplorerContentRoute extends Route { + @service fetch; + + model({ slug }) { + return this.fetch.get('pods', { slug }, { namespace: 'solid/int/v1' }); + } +} diff --git a/addon/routes/pods/index.js b/addon/routes/pods/index.js new file mode 100644 index 0000000..96345c5 --- /dev/null +++ b/addon/routes/pods/index.js @@ -0,0 +1,21 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class PodsIndexRoute extends Route { + @service fetch; + @service appCache; + + queryParams = { + query: { + refreshModel: true, + }, + }; + + model(params) { + return this.fetch.get('pods', params, { namespace: 'solid/int/v1' }); + } + + afterModel(model) { + this.appCache.set('solid:pods', model); + } +} diff --git a/addon/routes/pods/index/pod.js b/addon/routes/pods/index/pod.js new file mode 100644 index 0000000..a4339fc --- /dev/null +++ b/addon/routes/pods/index/pod.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class PodsIndexPodRoute extends Route {} diff --git a/addon/services/explorer-state.js b/addon/services/explorer-state.js new file mode 100644 index 0000000..dc34089 --- /dev/null +++ b/addon/services/explorer-state.js @@ -0,0 +1,101 @@ +import Service from '@ember/service'; +import Evented from '@ember/object/evented'; +import { inject as service } from '@ember/service'; +import { isArray } from '@ember/array'; + +export default class ExplorerStateService extends Service.extend(Evented) { + @service appCache; + + trackWithCursor(id, cursor) { + const segments = typeof cursor === 'string' ? cursor.split(':') : []; + const state = this.get(id); + this.update( + id, + state.filter((content) => { + return segments.includes(content.id); + }) + ); + this.clean(id); + return this; + } + + track(id, content) { + if (id === content.id) { + this.initialize(id, content); + } else { + this.push(id, content); + } + + this.clean(id); + return this; + } + + initialize(id, content) { + const state = this.get(id); + if (isArray(state) && state.length === 0) { + state.pushObject(content); + this.update(id, state); + } + + return this; + } + + push(id, content) { + const state = this.get(id); + if (isArray(state) && this.doesntHave(id, content)) { + state.pushObject(content); + this.update(id, state); + } + + return this; + } + + pop(id) { + const state = this.get(id); + if (isArray(state)) { + state.pop(); + this.update(id, state); + } + + return this; + } + + has(id, content) { + const state = this.get(id); + return state.findIndex((_) => _.id === content.id) >= 0; + } + + doesntHave(id, content) { + return !this.has(id, content); + } + + get(id) { + return this.appCache.get(`${id}:explorer:state`, []); + } + + update(id, state = []) { + this.appCache.set(`${id}:explorer:state`, state); + this.trigger('change', id, state); + return this; + } + + clean(id) { + const state = this.get(id); + if (isArray(state)) { + const seenIds = new Set(); + this.update( + id, + state.filter((_) => { + if (seenIds.has(_.id)) { + return false; + } + + seenIds.add(_.id); + return true; + }) + ); + } + + return this; + } +} diff --git a/addon/styles/solid-engine.css b/addon/styles/solid-engine.css index 9681a09..34b3003 100644 --- a/addon/styles/solid-engine.css +++ b/addon/styles/solid-engine.css @@ -27,3 +27,49 @@ body[data-theme='dark'] .solid-fleetbase-home-container a:not([class*='text-']), .solid-fleetbase-home-container a:hover { opacity: 0.5; } + +.pod-explorer-breadcrumb-container { + display: flex; + flex-direction: row; + align-items: center; +} + +.pod-explorer-breadcrumb-container > .pod-explorer-breadcrumb:first-child { + border-top-left-radius: 0.75rem; + border-bottom-left-radius: 0.75rem; +} + +.pod-explorer-breadcrumb-container > .pod-explorer-breadcrumb:last-child { + border-top-right-radius: 0.75rem; + border-bottom-right-radius: 0.75rem; +} + +.pod-explorer-breadcrumb { + font-size: 0.85rem; + border: 1px #111827 solid; + padding: 0 0.75rem; + line-height: 1.75rem; + background-color: #1f2937; + color: #fff; +} + +.pod-explorer-breadcrumb:hover { + opacity: 0.75; +} + +.pod-explorer-breadcrumb.active-breadcrumb { + background-color: #2563eb; + color: #eff6ff; +} + +body[data-theme='light'] .pod-explorer-breadcrumb { + border: 1px #e5e7eb solid; + background-color: #e5e7eb; + color: #1f2937; +} + +body[data-theme='light'] .pod-explorer-breadcrumb.active-breadcrumb { + background-color: #3b82f6; + color: #eff6ff; + border-color: #2563eb; +} diff --git a/addon/templates/account.hbs b/addon/templates/account.hbs new file mode 100644 index 0000000..a614e2c --- /dev/null +++ b/addon/templates/account.hbs @@ -0,0 +1,42 @@ + + + + + + \ No newline at end of file diff --git a/addon/templates/application.hbs b/addon/templates/application.hbs index 6b8d727..ccfcc25 100644 --- a/addon/templates/application.hbs +++ b/addon/templates/application.hbs @@ -1,15 +1,20 @@ - - + + Home + Pods + {{!-- + All + {{#each this.pods as |pod|}} + +
+ +
+
{{pod.name}}
+
+ {{/each}} +
--}} + Account +
- -

Welcome to Solid for Fleetbase

-

Getting Started

-
-

- Sign up for an account to get started with your own Pod and WebID. Once you are logged in you can begin to manage your pods and sync data directly from Fleetbase to your Pods. -

-
- {{!--