diff --git a/CHANGELOG.md b/CHANGELOG.md index 652291afdce..b8ca3a95b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [25.12.0] - 2025-06-13 +### Added +- Google File Picker workflow +- Misc. improvements + ## [25.11.0] - 2025-06-11 ### Added - Manual GUID and DOI assignment during Preprint and Registration Creation diff --git a/app/config/environment.d.ts b/app/config/environment.d.ts index d29e42e6b8d..863cd9add0e 100644 --- a/app/config/environment.d.ts +++ b/app/config/environment.d.ts @@ -141,6 +141,12 @@ declare const config: { doiUrlPrefix: string; dataciteTrackerRepoId: string; dataCiteTrackerUrl: string; + googleFilePicker: { + GOOGLE_FILE_PICKER_SCOPES: string; + GOOGLE_FILE_PICKER_CLIENT_ID: string; + GOOGLE_FILE_PICKER_API_KEY: string; + GOOGLE_FILE_PICKER_APP_ID: number; + } }; social: { twitter: { diff --git a/app/guid-node/files/provider/template.hbs b/app/guid-node/files/provider/template.hbs index 8f1e23912c7..0deaedcd1de 100644 --- a/app/guid-node/files/provider/template.hbs +++ b/app/guid-node/files/provider/template.hbs @@ -7,6 +7,7 @@ >
{ // To be implemented in child classes return; diff --git a/app/models/authorized-storage-account.ts b/app/models/authorized-storage-account.ts index 45ea1d06685..f23a509b2d7 100644 --- a/app/models/authorized-storage-account.ts +++ b/app/models/authorized-storage-account.ts @@ -1,13 +1,16 @@ -import { AsyncBelongsTo, belongsTo } from '@ember-data/model'; +import { AsyncBelongsTo, attr, belongsTo } from '@ember-data/model'; import { waitFor } from '@ember/test-waiters'; import { task } from 'ember-concurrency'; import { ConnectedStorageOperationNames, OperationKwargs } from 'ember-osf-web/models/addon-operation-invocation'; +import ExternalStorageServiceModel from 'ember-osf-web/models/external-storage-service'; -import ExternalStorageServiceModel from './external-storage-service'; import AuthorizedAccountModel from './authorized-account'; import UserReferenceModel from './user-reference'; export default class AuthorizedStorageAccountModel extends AuthorizedAccountModel { + @attr('fixstring') serializeOauthToken!: string; + @attr('fixstring') oauthToken!: string; + @belongsTo('user-reference', { inverse: 'authorizedStorageAccounts' }) readonly accountOwner!: AsyncBelongsTo & UserReferenceModel; diff --git a/app/models/index-card-search.ts b/app/models/index-card-search.ts index fc5250db51c..5421da16dcd 100644 --- a/app/models/index-card-search.ts +++ b/app/models/index-card-search.ts @@ -10,7 +10,7 @@ export interface SearchFilter { filterType?: string; } -export const ShareMoreThanTenThousand = 'https://share.osf.io/vocab/2023/trove/ten-thousands-and-more'; +export const ShareMoreThanTenThousand = 'trove:ten-thousands-and-more'; export default class IndexCardSearchModel extends Model { @attr('string') cardSearchText!: string; diff --git a/app/models/search-result.ts b/app/models/search-result.ts index f4c7dae9032..f7279cb0a6e 100644 --- a/app/models/search-result.ts +++ b/app/models/search-result.ts @@ -314,7 +314,7 @@ export default class SearchResultModel extends Model { } get isWithdrawn() { - return this.resourceMetadata.dateWithdrawn || this.resourceMetadata['https://osf.io/vocab/2022/withdrawal']; + return this.resourceMetadata.dateWithdrawn || this.resourceMetadata['osf:withdrawal']; } get configuredAddonNames() { diff --git a/app/packages/addons-service/provider.ts b/app/packages/addons-service/provider.ts index 602aa683fbe..edb62595a39 100644 --- a/app/packages/addons-service/provider.ts +++ b/app/packages/addons-service/provider.ts @@ -237,6 +237,7 @@ export default class Provider { accountOwner: this.userReference, }); await newAccount.save(); + newAccount.initiateOauth = null; return newAccount; } diff --git a/config/environment.js b/config/environment.js index a69017abdd1..b35ce2b8859 100644 --- a/config/environment.js +++ b/config/environment.js @@ -102,6 +102,13 @@ const { SHARE_SEARCH_URL: shareSearchUrl = 'http://localhost:8003/api/v2/search/creativeworks/_search', SOURCEMAPS_ENABLED: sourcemapsEnabled = true, SHOW_DEV_BANNER = false, + + GOOGLE_FILE_PICKER_SCOPES, + /* eslint-disable-next-line max-len */ + GOOGLE_FILE_PICKER_CLIENT_ID, + GOOGLE_FILE_PICKER_API_KEY, + GOOGLE_FILE_PICKER_APP_ID, + } = { ...process.env, ...localConfig }; module.exports = function(environment) { @@ -224,6 +231,12 @@ module.exports = function(environment) { doiUrlPrefix: 'https://doi.org/', dataciteTrackerRepoId, dataCiteTrackerUrl, + googleFilePicker: { + GOOGLE_FILE_PICKER_SCOPES, + GOOGLE_FILE_PICKER_CLIENT_ID, + GOOGLE_FILE_PICKER_API_KEY, + GOOGLE_FILE_PICKER_APP_ID, + }, }, social: { twitter: { diff --git a/lib/osf-components/addon/components/activity-log/component.ts b/lib/osf-components/addon/components/activity-log/component.ts index 1f6e9318c03..dc3b9614a77 100644 --- a/lib/osf-components/addon/components/activity-log/component.ts +++ b/lib/osf-components/addon/components/activity-log/component.ts @@ -4,7 +4,7 @@ export default class ActivityLogComponent extends Component { public loadEmbeds = { embed: [ - 'group', 'linked_node', 'linked_registration', 'original_node', + 'linked_node', 'linked_registration', 'original_node', 'template_node', 'user', ], }; diff --git a/lib/osf-components/addon/components/addons-service/configured-addon-edit/component.ts b/lib/osf-components/addon/components/addons-service/configured-addon-edit/component.ts index 6b55e01e763..28bec3d385e 100644 --- a/lib/osf-components/addon/components/addons-service/configured-addon-edit/component.ts +++ b/lib/osf-components/addon/components/addons-service/configured-addon-edit/component.ts @@ -1,7 +1,10 @@ import { action } from '@ember/object'; +import { waitFor } from '@ember/test-waiters'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { TaskInstance } from 'ember-concurrency'; +import { task, TaskInstance } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; + import { Item, ItemType } from 'ember-osf-web/models/addon-operation-invocation'; import AuthorizedAccountModel from 'ember-osf-web/models/authorized-account'; @@ -12,6 +15,7 @@ import ConfiguredAddonModel from 'ember-osf-web/models/configured-addon'; import ConfiguredCitationAddonModel from 'ember-osf-web/models/configured-citation-addon'; import ConfiguredComputingAddonModel from 'ember-osf-web/models/configured-computing-addon'; import ConfiguredStorageAddonModel from 'ember-osf-web/models/configured-storage-addon'; +import ExternalStorageServiceModel from 'ember-osf-web/models/external-storage-service'; interface Args { @@ -25,6 +29,8 @@ export default class ConfiguredAddonEdit extends Component { @tracked selectedFolder = this.args.configuredAddon?.rootFolder; @tracked selectedFolderDisplayName = this.args.configuredAddon?.rootFolderName; @tracked currentItems: Item[] = []; + @tracked isWBGoogleDrive = false; + @tracked accountId!: string; originalName = this.displayName; originalRootFolder = this.selectedFolder; @@ -34,6 +40,7 @@ export default class ConfiguredAddonEdit extends Component { super(owner, args); if (this.args.configuredAddon) { if (this.args.configuredAddon instanceof ConfiguredStorageAddonModel) { + taskFor(this.loadExternalStorageService).perform(); this.defaultKwargs['itemType'] = ItemType.Folder; } if (this.args.configuredAddon instanceof ConfiguredCitationAddonModel) { @@ -42,6 +49,7 @@ export default class ConfiguredAddonEdit extends Component { } if (this.args.authorizedAccount) { if (this.args.authorizedAccount instanceof AuthorizedStorageAccountModel) { + taskFor(this.loadExternalStorageService).perform(); this.defaultKwargs['itemType'] = ItemType.Folder; } if (this.args.authorizedAccount instanceof AuthorizedCitationAccountModel) { @@ -50,6 +58,28 @@ export default class ConfiguredAddonEdit extends Component { } } + /** + * This is called only to authorize because the current implementation will throw an + * error because the "root folder" is not yet set. + */ + @task + @waitFor + async loadExternalStorageService() { + let external!: ExternalStorageServiceModel; + if (this.args.configuredAddon && this.args.configuredAddon instanceof ConfiguredStorageAddonModel) { + const baseAccount = await this.args.configuredAddon.baseAccount; + this.accountId = baseAccount?.id; + external = await this.args.configuredAddon.externalStorageService; + } + if (this.args.authorizedAccount && this.args.authorizedAccount instanceof AuthorizedStorageAccountModel) { + external = await this.args.authorizedAccount.externalStorageService; + + this.accountId = this.args.authorizedAccount.id; + } + + this.isWBGoogleDrive = external?.wbKey === 'googledrive'; + } + get requiresRootFolder() { return !( this.args.authorizedAccount instanceof AuthorizedComputingAccountModel @@ -58,6 +88,14 @@ export default class ConfiguredAddonEdit extends Component { ); } + get isGoogleDrive(): boolean { + return this.isWBGoogleDrive; + } + + get displayFileManager(): boolean { + return this.requiresRootFolder && !this.isGoogleDrive; + } + get invalidDisplayName() { return !this.displayName || this.displayName?.trim().length === 0; } diff --git a/lib/osf-components/addon/components/addons-service/configured-addon-edit/styles.scss b/lib/osf-components/addon/components/addons-service/configured-addon-edit/styles.scss index 3dc6c476d5d..f362d65404e 100644 --- a/lib/osf-components/addon/components/addons-service/configured-addon-edit/styles.scss +++ b/lib/osf-components/addon/components/addons-service/configured-addon-edit/styles.scss @@ -62,3 +62,8 @@ .item-name { white-space: normal; } + +.picker-style { + white-space: pre-wrap; + +} diff --git a/lib/osf-components/addon/components/addons-service/configured-addon-edit/template.hbs b/lib/osf-components/addon/components/addons-service/configured-addon-edit/template.hbs index 4d40bc76e44..44737b94b70 100644 --- a/lib/osf-components/addon/components/addons-service/configured-addon-edit/template.hbs +++ b/lib/osf-components/addon/components/addons-service/configured-addon-edit/template.hbs @@ -21,7 +21,7 @@ {{/if}}
- {{#if this.requiresRootFolder }} + {{#if this.displayFileManager}}
{{t 'addons.configure.selected-folder'}} @@ -150,6 +150,13 @@ + {{else}} + {{/if}}
+ {{#if this.isGoogleDrive}} + + {{/if}} + {{#if this.isGoogleDrive}} + + {{/if}} {{/let}} \ No newline at end of file diff --git a/lib/osf-components/addon/components/file-browser/template.hbs b/lib/osf-components/addon/components/file-browser/template.hbs index 924bf455b8e..7e76424f154 100644 --- a/lib/osf-components/addon/components/file-browser/template.hbs +++ b/lib/osf-components/addon/components/file-browser/template.hbs @@ -160,7 +160,11 @@ @isRegistration={{@manager.targetNode.isRegistration}} /> {{#if (and @manager.currentFolder.userCanUploadToHere @enableUpload)}} - + {{/if}} {{/if}}
diff --git a/lib/osf-components/addon/components/google-file-picker-widget/component.ts b/lib/osf-components/addon/components/google-file-picker-widget/component.ts new file mode 100644 index 00000000000..8f104d89dcb --- /dev/null +++ b/lib/osf-components/addon/components/google-file-picker-widget/component.ts @@ -0,0 +1,215 @@ +import Store from '@ember-data/store'; +import { action } from '@ember/object'; +import { waitFor } from '@ember/test-waiters'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import config from 'ember-osf-web/config/environment'; +import { Item } from 'ember-osf-web/models/addon-operation-invocation'; +import StorageManager from 'osf-components/components/storage-provider-manager/storage-manager/component'; +import { inject as service } from '@ember/service'; +import Intl from 'ember-intl/services/intl'; + +const { + GOOGLE_FILE_PICKER_SCOPES, + GOOGLE_FILE_PICKER_API_KEY, + GOOGLE_FILE_PICKER_APP_ID, +} = config.OSF.googleFilePicker; + +// +// 📚 Interface for Expected Arguments +// +interface Args { + /** + * selectFolder + * + * @description + * A callback function passed into the component + * that accepts a partial Item object and handles it (e.g., selects a file). + */ + selectFolder?: (a: Partial) => void; + onRegisterChild?: (a: GoogleFilePickerWidget) => void; + selectedFolderName?: string; + isFolderPicker: boolean; + rootFolderId: string; + manager: StorageManager; + accountId: string; +} + +// +// 📚 Extend Global Window Type +// +// Declares that `window` can optionally have a GoogleFilePickerWidget instance. +// This allows safe typing when accessing it elsewhere. +// +declare global { + interface Window { + GoogleFilePickerWidget?: GoogleFilePickerWidget; + gapi?: any; + google?: any; + } +} + +// +// GoogleFilePickerWidget Component +// +// @description +// An Ember Glimmer component that exposes itself to the global `window` +// so that external JavaScript (like Google Picker API callbacks) +// can interact with it directly. +// +export default class GoogleFilePickerWidget extends Component { + @service intl!: Intl; + @service store!: Store; + @tracked folderName!: string | undefined; + @tracked isFolderPicker = false; + @tracked openGoogleFilePicker = false; + @tracked visible = false; + @tracked isGFPDisabled = true; + pickerInited = false; + selectFolder: any = undefined; + accessToken!: string; + scopes = GOOGLE_FILE_PICKER_SCOPES; + apiKey = GOOGLE_FILE_PICKER_API_KEY; + appId = GOOGLE_FILE_PICKER_APP_ID; + mimeTypes = ''; + parentId = ''; + isMultipleSelect: boolean; + title!: string; + + /** + * Constructor + * + * @description + * Initializes the GoogleFilePickerWidget component and exposes its key methods to the global `window` object + * for integration with external JavaScript (e.g., Google Picker API). + * + * - Sets `window.GoogleFilePickerWidget` to the current component instance (`this`), + * allowing external scripts to call methods like `filePickerCallback()`. + * - Captures the closure action `selectFolder` from `this.args` and assigns it directly to `window.selectFolder`, + * preserving the correct closure reference even outside of Ember's internal context. + * + * @param owner - The owner/context passed by Ember at component instantiation. + * @param args - The arguments passed to the component, including closure actions like `selectFolder`. + */ + constructor(owner: unknown, args: Args) { + super(owner, args); + + window.GoogleFilePickerWidget = this; + this.selectFolder = this.args.selectFolder; + this.mimeTypes = this.args.isFolderPicker ? 'application/vnd.google-apps.folder' : ''; + this.parentId = this.args.isFolderPicker ? '': this.args.rootFolderId; + this.title = this.args.isFolderPicker ? + this.intl.t('addons.configure.google-file-picker.root-folder-title') : + this.intl.t('addons.configure.google-file-picker.file-folder-title'); + this.isMultipleSelect = !this.args.isFolderPicker; + this.isFolderPicker = this.args.isFolderPicker; + + + this.folderName = this.args.selectedFolderName; + + taskFor(this.loadOauthToken).perform(); + } + + @task + @waitFor + private async loadOauthToken(): Promise{ + if (this.args.accountId) { + const authorizedStorageAccount = await this.store. + findRecord('authorized-storage-account', this.args.accountId); + authorizedStorageAccount.serializeOauthToken = true; + const token = await authorizedStorageAccount.save(); + this.accessToken = token.oauthToken; + this.isGFPDisabled = this.accessToken ? false : true; + } + } + + /** + * filePickerCallback + * + * @description + * Action triggered when a file is selected via an external picker. + * Logs the file data and notifies the parent system by calling `selectFolder`. + * + * @param file - The file object selected (format determined by external API) + */ + @action + filePickerCallback(data: any) { + if (this.selectFolder !== undefined) { + this.folderName = data.name; + this.selectFolder({ + itemName: data.name, + itemId: data.id, + }); + } else { + this.args.manager.reload(); + } + } + + @action + registerComponent() { + if (this.args.onRegisterChild) { + this.args.onRegisterChild(this); // Pass the child's instance to the parent + } + } + + willDestroy() { + super.willDestroy(); + this.pickerInited = false; + } + + + /** + * Callback after api.js is loaded. + */ + gapiLoaded() { + window.gapi.load('client:picker', this.initializePicker.bind(this)); + } + + /** + * Callback after the API client is loaded. Loads the + * discovery doc to initialize the API. + */ + async initializePicker() { + this.pickerInited = true; + if (this.isFolderPicker) { + this.visible = true; + } + } + + /** + * Create and render a Picker object for searching images. + */ + @action + createPicker() { + const googlePickerView = new window.google.picker.DocsView(window.google.picker.ViewId.DOCS); + googlePickerView.setSelectFolderEnabled(true); + googlePickerView.setMimeTypes(this.mimeTypes); + googlePickerView.setIncludeFolders(true); + googlePickerView.setParent(this.parentId); + + const picker = new window.google.picker.PickerBuilder() + .enableFeature(this.isMultipleSelect ? window.google.picker.Feature.MULTISELECT_ENABLED : '') + .setDeveloperKey(this.apiKey) + .setAppId(this.appId) + .addView(googlePickerView) + .setTitle(this.title) + .setOAuthToken(this.accessToken) + .setCallback(this.pickerCallback.bind(this)) + .build(); + picker.setVisible(true); + } + + /** + * Displays the file details of the user's selection. + * @param {object} data - Containers the user selection from the picker + */ + async pickerCallback(data: any) { + if (data.action === window.google.picker.Action.PICKED) { + this.filePickerCallback(data.docs[0]); + } + } +} + + diff --git a/lib/osf-components/addon/components/google-file-picker-widget/styles.scss b/lib/osf-components/addon/components/google-file-picker-widget/styles.scss new file mode 100644 index 00000000000..a1e4c242a9c --- /dev/null +++ b/lib/osf-components/addon/components/google-file-picker-widget/styles.scss @@ -0,0 +1,25 @@ +// stylelint-disable max-nesting-depth, selector-max-compound-selectors, selector-no-qualifying-type + +.google-file-picker-container { + width: 0; + + &.file-picker { + width: 100%; + } + + .instruction-container { + width: 100%; + } + + .action-container { + width: 100%; + + .authorize-button { + visibility: hidden; + + &.visible { + visibility: visible; + } + } + } +} diff --git a/lib/osf-components/addon/components/google-file-picker-widget/template.hbs b/lib/osf-components/addon/components/google-file-picker-widget/template.hbs new file mode 100644 index 00000000000..f00ea98b7ee --- /dev/null +++ b/lib/osf-components/addon/components/google-file-picker-widget/template.hbs @@ -0,0 +1,27 @@ +
+ {{#if this.isFolderPicker}} +
+ {{#if this.folderName}} +

+ + {{~t 'addons.configure.google-file-picker.selected-folder'~}}: + + + {{this.folderName}} + +

+ {{/if}} +
+
+ +
+ {{/if}} + +
\ No newline at end of file diff --git a/lib/osf-components/addon/components/search-page/component.ts b/lib/osf-components/addon/components/search-page/component.ts index c2a6803ae37..7217d249585 100644 --- a/lib/osf-components/addon/components/search-page/component.ts +++ b/lib/osf-components/addon/components/search-page/component.ts @@ -83,7 +83,7 @@ export default class SearchPage extends Component { @tracked relatedProperties?: RelatedPropertyPathModel[] = []; @tracked booleanFilters?: RelatedPropertyPathModel[] = []; @tracked page?: string = ''; - @tracked totalResultCount?: string | number; + @tracked totalResultCount?: number | {'@id': string}; @tracked firstPageCursor?: string | null; @tracked prevPageCursor?: string | null; @tracked nextPageCursor?: string | null; @@ -263,7 +263,7 @@ export default class SearchPage extends Component { this.nextPageCursor = searchResult.nextPageCursor; this.prevPageCursor = searchResult.prevPageCursor; this.searchResults = searchResult.searchResultPage.toArray(); - this.totalResultCount = searchResult.totalResultCount === ShareMoreThanTenThousand ? '10,000+' : + this.totalResultCount = searchResult.totalResultCount?.['@id'] === ShareMoreThanTenThousand ? '10,000+' : searchResult.totalResultCount; } catch (e) { this.toast.error(e); diff --git a/lib/osf-components/app/components/google-file-picker-widget/component.js b/lib/osf-components/app/components/google-file-picker-widget/component.js new file mode 100644 index 00000000000..ad0f5c2048e --- /dev/null +++ b/lib/osf-components/app/components/google-file-picker-widget/component.js @@ -0,0 +1 @@ +export { default } from 'osf-components/components/google-file-picker-widget/component'; diff --git a/lib/osf-components/app/components/google-file-picker-widget/template.js b/lib/osf-components/app/components/google-file-picker-widget/template.js new file mode 100644 index 00000000000..b1eb6c3fcdf --- /dev/null +++ b/lib/osf-components/app/components/google-file-picker-widget/template.js @@ -0,0 +1 @@ +export { default } from 'osf-components/components/google-file-picker-widget/template'; diff --git a/package.json b/package.json index a420d92cc08..b7b3331d81e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ember-osf-web", - "version": "25.11.0", + "version": "25.12.0", "private": true, "description": "Ember front-end for the Open Science Framework", "homepage": "https://github.com/CenterForOpenScience/ember-osf-web#readme", diff --git a/translations/en-us.yml b/translations/en-us.yml index 7754655da74..08fbfec9c73 100644 --- a/translations/en-us.yml +++ b/translations/en-us.yml @@ -358,6 +358,11 @@ addons: verify: 'Connect the following account:' authorizing: 'Connecting…' configure: + google-file-picker: + select-root-folder: 'Select Root Folder' + selected-folder: 'Selected Folder' + root-folder-title: 'Select a root folder' + file-folder-title: 'Select a file or folder to add' heading: 'Configure {providerName}' display-name: 'Display name' selected-folder: 'Selected folder:' @@ -3040,6 +3045,7 @@ osf-components: error_ends_with_dot: 'File name cannot end with period' error_forbidden_chars: 'Please remove special characters from the folder name.' add_button_aria: 'Add files or folders here' + add-from-drive: 'Add from Drive' upload_file: 'Upload file' uploading_file: 'Uploading {fileCount, plural, one {# file} other {# files}}' upload_failed: '{fileCount, plural, one {# file} other {# files}} failed, click to try again'