diff --git a/app/guid-file/route.ts b/app/guid-file/route.ts index 84eb6e7222..a25f7e95b3 100644 --- a/app/guid-file/route.ts +++ b/app/guid-file/route.ts @@ -58,6 +58,7 @@ export default class GuidFile extends Route { }; this.set('headTags', this.metaTags.getHeadTags(metaTagsData)); this.headTagsService.collectHeadTags(); + await taskFor(model.target.get('getEnabledAddons')).perform(); blocker.done(); } diff --git a/app/guid-file/template.hbs b/app/guid-file/template.hbs index dddac29fd6..f41e71b18c 100644 --- a/app/guid-file/template.hbs +++ b/app/guid-file/template.hbs @@ -21,7 +21,12 @@ ({{t 'general.version'}}: {{this.viewedVersion}}) {{/if}} - + <:body> diff --git a/app/guid-node/files/provider/route.ts b/app/guid-node/files/provider/route.ts index d3718b2715..bb28666ec1 100644 --- a/app/guid-node/files/provider/route.ts +++ b/app/guid-node/files/provider/route.ts @@ -19,6 +19,7 @@ export default class GuidNodeFilesProviderRoute extends Route.extend({}) { @waitFor async fileProviderTask(guidRouteModel: GuidRouteModel, fileProviderId: string) { const node = await guidRouteModel.taskInstance; + await taskFor(node.getEnabledAddons).perform(); try { const fileProviders = await node.queryHasMany( 'files', diff --git a/app/models/node.ts b/app/models/node.ts index f6aa0ec9f4..84498d416d 100644 --- a/app/models/node.ts +++ b/app/models/node.ts @@ -4,10 +4,15 @@ import { computed } from '@ember/object'; import { alias, bool, equal, not } from '@ember/object/computed'; import { inject as service } from '@ember/service'; import { htmlSafe } from '@ember/template'; +import { tracked } from '@glimmer/tracking'; import { buildValidations, validator } from 'ember-cp-validations'; import Intl from 'ember-intl/services/intl'; import CedarMetadataRecordModel from 'ember-osf-web/models/cedar-metadata-record'; +import config from 'ember-osf-web/config/environment'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; + import getRelatedHref from 'ember-osf-web/utils/get-related-href'; import AbstractNodeModel from 'ember-osf-web/models/abstract-node'; @@ -26,6 +31,13 @@ import RegistrationModel from './registration'; import SubjectModel from './subject'; import WikiModel from './wiki'; +const { + OSF: { + apiUrl, + apiNamespace, + }, +} = config; + const Validations = buildValidations({ title: [ validator('presence', true), @@ -109,6 +121,10 @@ export default class NodeModel extends AbstractNodeModel.extend(Validations, Col @attr('boolean') currentUserCanComment!: boolean; @attr('boolean') wikiEnabled!: boolean; + // FE-only property to check enabled addons. + // null until getEnabledAddons has been called + @tracked addonsEnabled?: string[]; + @hasMany('contributor', { inverse: 'node' }) contributors!: AsyncHasMany & ContributorModel[]; @@ -315,6 +331,26 @@ export default class NodeModel extends AbstractNodeModel.extend(Validations, Col this.set('nodeLicense', props); } + + @task + @waitFor + async getEnabledAddons() { + const endpoint = `${apiUrl}/${apiNamespace}/nodes/${this.id}/addons/`; + const response = await this.currentUser.authenticatedAJAX({ + url: endpoint, + type: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + xhrFields: { withCredentials: true }, + }); + if (response.data) { + const addonList = response.data + .filter((addon: any) => addon.attributes.node_has_auth) + .map((addon: any) => addon.id); + this.set('addonsEnabled', addonList); + } + } } declare module 'ember-data/types/registries/model' { diff --git a/app/packages/files/file.ts b/app/packages/files/file.ts index a525443245..7a60c8c56d 100644 --- a/app/packages/files/file.ts +++ b/app/packages/files/file.ts @@ -154,6 +154,14 @@ export default abstract class File { ); } + get isBoaFile() { + return this.fileModel.name.endsWith('.boa'); + } + + get providerIsOsfstorage() { + return this.fileModel.provider === 'osfstorage'; + } + async createFolder(newFolderName: string) { if (this.fileModel.isFolder) { await this.fileModel.createFolder(newFolderName); diff --git a/lib/osf-components/addon/components/file-actions-menu/component.ts b/lib/osf-components/addon/components/file-actions-menu/component.ts index bc425f44d1..355382a20f 100644 --- a/lib/osf-components/addon/components/file-actions-menu/component.ts +++ b/lib/osf-components/addon/components/file-actions-menu/component.ts @@ -5,11 +5,13 @@ import { inject as service } from '@ember/service'; import Media from 'ember-responsive'; import File from 'ember-osf-web/packages/files/file'; import StorageManager from 'osf-components/components/storage-provider-manager/storage-manager/component'; +import NodeModel from 'ember-osf-web/models/node'; interface Args { item: File; onDelete: () => void; manager?: StorageManager; // No manager for file-detail page + addonsEnabled? : string[]; } export default class FileActionsMenu extends Component { @@ -19,6 +21,7 @@ export default class FileActionsMenu extends Component { @tracked moveModalOpen = false; @tracked useCopyModal = false; @tracked renameModalOpen = false; + @tracked isSubmitToBoaModalOpen = false; @action closeDeleteModal() { @@ -34,4 +37,34 @@ export default class FileActionsMenu extends Component { openRenameModal() { this.renameModalOpen = true; } + + @action + closeSubmitToBoaModal() { + this.isSubmitToBoaModalOpen = false; + } + + @action + openSubmitToBoaModal() { + this.isSubmitToBoaModalOpen = true; + } + + get isBoaEnabled() { + return this.args.addonsEnabled?.includes('boa'); + } + + get showSubmitToBoa() { + const { item, manager } = this.args; + if (item.providerIsOsfstorage && item.isBoaFile && this.isBoaEnabled) { + let userCanUploadToHere; + if (manager) { + userCanUploadToHere = manager.currentFolder.userCanUploadToHere; + } else { + const storage = (item.fileModel.target as unknown as NodeModel).get('storage'); + const writableTarget = item.currentUserPermission === 'write' && !item.targetIsRegistration; + userCanUploadToHere = writableTarget && !storage.get('isOverStorageCap'); + } + return userCanUploadToHere; + } + return false; + } } diff --git a/lib/osf-components/addon/components/file-actions-menu/submit-to-boa-modal/component.ts b/lib/osf-components/addon/components/file-actions-menu/submit-to-boa-modal/component.ts new file mode 100644 index 0000000000..96915b0dfa --- /dev/null +++ b/lib/osf-components/addon/components/file-actions-menu/submit-to-boa-modal/component.ts @@ -0,0 +1,102 @@ +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import Component from '@glimmer/component'; +import { task } from 'ember-concurrency'; +import IntlService from 'ember-intl/services/intl'; +import File from 'ember-osf-web/packages/files/file'; +import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; +import config from 'ember-osf-web/config/environment'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +interface Args { + file: File; + isOpen: boolean; + closeModal: () => {}; +} + +export default class SubmitToBoaModal extends Component { + @service toast!: Toastr; + @service intl!: IntlService; + @tracked selectedDataset?: string; + + datasets = [ + '2022 Jan/Java', + '2022 Feb/Python', + '2021 Method Chains', + '2021 Aug/Python', + '2021 Aug/Kotlin (small)', + '2021 Aug/Kotlin', + '2021 Jan/ML-Verse', + '2020 August/Python-DS', + '2019 October/GitHub (small)', + '2019 October/GitHub (medium)', + '2019 October/GitHub', + '2015 September/GitHub', + '2013 September/SF (small)', + '2013 September/SF (medium)', + '2013 September/SF', + '2013 May/SF', + '2013 February/SF', + '2012 July/SF', + ]; + + @action + onDatasetChange(newDataset: string) { + this.selectedDataset = newDataset; + } + + @task + @waitFor + async confirmSubmitToBoa() { + try { + const file = this.args.file; + const fileModel = file.fileModel; + const parentFolder = await fileModel.get('parentFolder'); + const grandparentFolder = await parentFolder.get('parentFolder'); + const endpoint = config.OSF.url + 'api/v1/project/' + fileModel.target.get('id') + '/boa/submit-job/'; + const uploadLink = new URL(parentFolder.get('links').upload as string); + uploadLink.searchParams.set('kind', 'file'); + const payload = { + data: { + nodeId: fileModel.target.get('id'), + name: file.name, + materialized: fileModel.materializedPath, + sizeInt: fileModel.size, + links: { + download: file.links.download, + upload: file.links.upload, + }, + }, + parent: { + links: { + upload: uploadLink.toString(), + }, + isAddonRoot: !grandparentFolder, + }, + dataset: this.selectedDataset, + }; + await this.args.file.currentUser.authenticatedAJAX({ + url: endpoint, + type: 'POST', + data: JSON.stringify(payload), + xhrFields: { withCredentials: true }, + headers: { + 'Content-Type': 'application/json', + }, + }); + + this.args.closeModal(); + } catch (e) { + captureException(e); + const errorMessageKey = this.intl.t('osf-components.file-browser.submit_to_boa_fail', + { fileName: this.args.file.name, htmlSafe: true }) as string; + this.toast.error(getApiErrorMessage(e), errorMessageKey); + return; + } + + this.toast.success( + this.intl.t('osf-components.file-browser.submit_to_boa_success', { fileName: this.args.file.name }), + ); + } +} diff --git a/lib/osf-components/addon/components/file-actions-menu/submit-to-boa-modal/styles.scss b/lib/osf-components/addon/components/file-actions-menu/submit-to-boa-modal/styles.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/osf-components/addon/components/file-actions-menu/submit-to-boa-modal/template.hbs b/lib/osf-components/addon/components/file-actions-menu/submit-to-boa-modal/template.hbs new file mode 100644 index 0000000000..7b490cead7 --- /dev/null +++ b/lib/osf-components/addon/components/file-actions-menu/submit-to-boa-modal/template.hbs @@ -0,0 +1,34 @@ + + + {{t 'osf-components.file-browser.submit_to_boa'}} + + + +

{{t 'osf-components.file-browser.boa_dataset_spiel'}}

+ + {{dataset}} + +

{{t 'osf-components.file-browser.confirm_submit_to_boa' fileName=@file.name}}

+ +
+ + + + +
\ No newline at end of file diff --git a/lib/osf-components/addon/components/file-actions-menu/template.hbs b/lib/osf-components/addon/components/file-actions-menu/template.hbs index 5ddbdf2af0..b71c881588 100644 --- a/lib/osf-components/addon/components/file-actions-menu/template.hbs +++ b/lib/osf-components/addon/components/file-actions-menu/template.hbs @@ -110,9 +110,31 @@ {{/if}} {{/if}} + {{#if this.showSubmitToBoa}} + + {{/if}} +{{#if this.showSubmitToBoa}} + +{{/if}} {{#if @manager}} {{#unless @manager.selectedFiles}} - + {{/unless}} diff --git a/lib/osf-components/app/components/file-actions-menu/submit-to-boa-modal/component.js b/lib/osf-components/app/components/file-actions-menu/submit-to-boa-modal/component.js new file mode 100644 index 0000000000..c6f967d721 --- /dev/null +++ b/lib/osf-components/app/components/file-actions-menu/submit-to-boa-modal/component.js @@ -0,0 +1 @@ +export { default } from 'osf-components/components/file-actions-menu/submit-to-boa-modal/component'; diff --git a/lib/osf-components/app/components/file-actions-menu/submit-to-boa-modal/template.js b/lib/osf-components/app/components/file-actions-menu/submit-to-boa-modal/template.js new file mode 100644 index 0000000000..c743a5fd8e --- /dev/null +++ b/lib/osf-components/app/components/file-actions-menu/submit-to-boa-modal/template.js @@ -0,0 +1 @@ +export { default } from 'osf-components/components/file-actions-menu/submit-to-boa-modal/template'; diff --git a/mirage/config.ts b/mirage/config.ts index cde688242c..a1dbe60d09 100644 --- a/mirage/config.ts +++ b/mirage/config.ts @@ -3,6 +3,7 @@ import config from 'ember-osf-web/config/environment'; import { createReviewAction } from 'ember-osf-web/mirage/views/review-action'; import { createResource, updateResource } from 'ember-osf-web/mirage/views/resource'; +import { addonsList } from './views/addons'; import { getCitation } from './views/citation'; import { createCollectionSubmission, getCollectionSubmissions } from './views/collection-submission'; import { createSubmissionAction } from './views/collection-submission-action'; @@ -49,7 +50,7 @@ import { updatePassword } from './views/user-password'; import * as userSettings from './views/user-setting'; import * as wb from './views/wb'; -const { OSF: { apiUrl, shareBaseUrl } } = config; +const { OSF: { apiUrl, shareBaseUrl, url: osfUrl } } = config; export default function(this: Server) { this.passthrough(); // pass through all requests on currrent domain @@ -67,6 +68,10 @@ export default function(this: Server) { this.get('/index-value-search', valueSearch); // this.get('/index-card/:id', Detail); + this.urlPrefix = osfUrl; + this.namespace = '/api/v1/'; + this.post('project/:id/boa/submit-job/', () => ({})); // submissions to BoA + this.urlPrefix = apiUrl; this.namespace = '/v2'; @@ -111,6 +116,7 @@ export default function(this: Server) { onCreate: createBibliographicContributor, }); + this.get('/nodes/:parentID/addons', addonsList); this.get('/nodes/:parentID/files', nodeFileProviderList); // Node file providers list this.get('/nodes/:parentID/files/:fileProviderId', nodeFilesListForProvider); // Node files list for file provider this.get('/nodes/:parentID/files/:fileProviderId/:folderId', folderFilesList); // Node folder detail view diff --git a/mirage/factories/node.ts b/mirage/factories/node.ts index 07b79742e0..f1051734eb 100644 --- a/mirage/factories/node.ts +++ b/mirage/factories/node.ts @@ -13,6 +13,7 @@ export interface MirageNode extends Node { regionId: string | number; lastLogged: Date | string; _anonymized: boolean; + boaEnabled: boolean; } export interface NodeTraits { @@ -72,6 +73,7 @@ export default Factory.extend({ public: true, tags: faker.lorem.words(5).split(' '), _anonymized: false, + boaEnabled: false, withContributors: trait({ afterCreate(node, server) { diff --git a/mirage/scenarios/dashboard.ts b/mirage/scenarios/dashboard.ts index cfd51f3f8e..f477cabbb9 100644 --- a/mirage/scenarios/dashboard.ts +++ b/mirage/scenarios/dashboard.ts @@ -1,6 +1,7 @@ import { ModelInstance, Server } from 'ember-cli-mirage'; import config from 'ember-osf-web/config/environment'; +import FileProviderModel from 'ember-osf-web/models/file-provider'; import { Permission } from 'ember-osf-web/models/osf-model'; import User from 'ember-osf-web/models/user'; @@ -48,8 +49,19 @@ export function dashboardScenario(server: Server, currentUser: ModelInstance) => provider.name === 'osfstorage', + )[0] as ModelInstance; + server.create('file', { + id: 'snake', + name: 'snake.boa', + checkout: currentUser.id, + target: filesNode, + parentFolder: filesNodeOsfStorage.rootFolder, + }); server.create('contributor', { node: filesNode, users: currentUser, diff --git a/mirage/views/addons.ts b/mirage/views/addons.ts new file mode 100644 index 0000000000..0d9ab54222 --- /dev/null +++ b/mirage/views/addons.ts @@ -0,0 +1,43 @@ +import { HandlerContext, ModelInstance, Request, Response, Schema } from 'ember-cli-mirage'; +import FileProviderModel from 'ember-osf-web/models/file-provider'; + +// This is the handler for the unofficial node/addons endpoint +// It is only being used by the file-action-menu component to determine if a node has the BoA addon enabled +// It is not being used by the official OSF API +export function addonsList(this: HandlerContext, schema: Schema, request: Request) { + const { parentID } = request.params; + const node = schema.nodes.find(parentID); + if (!node) { + return; + } + const addons = node.files.models.map((addon: ModelInstance) => { + const data = this.serialize(addon).data; + data.id = addon.name; + data.type = 'node-addons'; + data.attributes = { + node_has_auth: true, + configured: false, + external_account_id: '1234', + folder_id: null, + folder_path: null, + }; + return data; + }); + if (node.boaEnabled) { + addons.push({ + id: 'boa', + type: 'node-addons', + attributes: { + node_has_auth: true, + configured: false, + external_account_id: '1234', + folder_id: null, + folder_path: null, + }, + }); + } + + return new Response(200, {}, { + data: addons, + }); +} diff --git a/translations/en-us.yml b/translations/en-us.yml index aa9092cde0..d7bccba181 100644 --- a/translations/en-us.yml +++ b/translations/en-us.yml @@ -117,6 +117,7 @@ file_actions_menu: error_message: 'Could not copy to clipboard' rename: 'Rename' rename_aria: 'Open rename link' + submit_to_boa: 'Submit to Boa' node_categories: analysis: Analysis communication: Communication @@ -2234,6 +2235,13 @@ osf-components: delete_success: '{itemCount, plural, one {# item} other {# items}} deleted successfully' deleting: 'Deleting {itemCount, plural, one {# item} other {# items}}' retry: 'retry delete' + submit_to_boa: 'Submit file to Boa?' + boa_dataset_select_placeholder: 'Select a dataset' + confirm_submit_to_boa: 'Are you sure you want to submit "{fileName}" to Boa?' + confirm_submit_to_boa_yes: 'Submit' + submit_to_boa_fail: 'Unable to submit "{fileName}" to Boa. Please try again later. Contact support@osf.io if the problem persists' + submit_to_boa_success: 'Successfully submitted "{fileName}" to Boa. You will be notified by email when the job is done.' + boa_dataset_spiel: 'Please select a dataset to run the query against:' help_modal: heading: 'Using the OSF Files Browser' providers: 'See All Files in a Provider'