From 52b21d5ddf78e8499d54a71b82e32ef9a894552c Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Thu, 5 Jun 2025 14:54:58 -0400 Subject: [PATCH 1/6] [ENG-8146] Add manual DOI and GUID fields to the preprint and registration submission workflow (#2566) --- app/models/preprint.ts | 2 + app/models/registration.ts | 2 + .../submit/title-and-abstract/component.ts | 21 +++++++++- .../submit/title-and-abstract/template.hbs | 36 +++++++++++++++++ config/environment.js | 1 + .../manager/component.ts | 40 +++++++++++++++++++ .../manager/template.hbs | 2 + .../finalize-registration-modal/template.hbs | 38 ++++++++++++++++++ lib/osf-components/package.json | 3 +- translations/en-us.yml | 2 + 10 files changed, 145 insertions(+), 2 deletions(-) diff --git a/app/models/preprint.ts b/app/models/preprint.ts index 942c04ac82f..7695225d50e 100644 --- a/app/models/preprint.ts +++ b/app/models/preprint.ts @@ -82,6 +82,8 @@ export default class PreprintModel extends AbstractNodeModel { @attr('string') preregLinkInfo!: PreprintPreregLinkInfoEnum; @attr('number') version!: number; @attr('boolean') isLatestVersion!: boolean; + @attr('string') manualDoi!: string; + @attr('string') manualGuid!: string; @belongsTo('node', { inverse: 'preprints' }) node!: AsyncBelongsTo & NodeModel; diff --git a/app/models/registration.ts b/app/models/registration.ts index 5ec13834004..1262ee16963 100644 --- a/app/models/registration.ts +++ b/app/models/registration.ts @@ -115,6 +115,8 @@ export default class RegistrationModel extends NodeModel.extend(Validations) { @attr('boolean') hasAnalyticCode!: boolean; @attr('boolean') hasPapers!: boolean; @attr('boolean') hasSupplements!: boolean; + @attr('string') manualDoi!: string; + @attr('string') manualGuid!: string; // Write-only attributes @attr('array') includedNodeIds?: string[]; diff --git a/app/preprints/-components/submit/title-and-abstract/component.ts b/app/preprints/-components/submit/title-and-abstract/component.ts index 07726a12158..bd7b65ecabf 100644 --- a/app/preprints/-components/submit/title-and-abstract/component.ts +++ b/app/preprints/-components/submit/title-and-abstract/component.ts @@ -2,10 +2,11 @@ import Component from '@glimmer/component'; import PreprintStateMachine from 'ember-osf-web/preprints/-components/submit/preprint-state-machine/component'; import { action } from '@ember/object'; import { ValidationObject } from 'ember-changeset-validations'; -import { validatePresence, validateLength } from 'ember-changeset-validations/validators'; +import { validatePresence, validateLength, validateFormat } from 'ember-changeset-validations/validators'; import buildChangeset from 'ember-osf-web/utils/build-changeset'; import { inject as service } from '@ember/service'; import Intl from 'ember-intl/services/intl'; +import { DOIRegex } from 'ember-osf-web/utils/doi'; /** * The TitleAndAbstract Args @@ -17,6 +18,8 @@ interface TitleAndAbstractArgs { interface TitleAndAbstractForm { title: string; description: string; + manualDoi: string; + manualGuid: string; } /** @@ -45,6 +48,22 @@ export default class TitleAndAbstract extends Component{ }, }), ], + manualDoi: validateFormat({ + allowBlank: true, + allowNone: true, + ignoreBlank: true, + regex: DOIRegex, + type: 'invalid_doi', + }), + manualGuid: validateLength({ + allowBlank: true, + min:5, + type: 'greaterThanOrEqualTo', + translationArgs: { + description: this.intl.t('preprints.submit.step-title.guid'), + gte: '5 characters', + }, + }), }; titleAndAbstractFormChangeset = buildChangeset(this.args.manager.preprint, this.titleAndAbstractFormValidation); diff --git a/app/preprints/-components/submit/title-and-abstract/template.hbs b/app/preprints/-components/submit/title-and-abstract/template.hbs index a53255e1aa2..e4a7a4188d1 100644 --- a/app/preprints/-components/submit/title-and-abstract/template.hbs +++ b/app/preprints/-components/submit/title-and-abstract/template.hbs @@ -48,6 +48,42 @@ @onKeyUp={{this.validate}} /> {{/let}} + {{#if (feature-flag 'manual_doi_and_guid')}} + {{#let (unique-id 'manualDoi') as |manualDoiField|}} + + + {{/let}} + {{#let (unique-id 'manualGuid') as |manualGuidField|}} + + + {{/let}} + {{/if}} \ No newline at end of file diff --git a/config/environment.js b/config/environment.js index 61bc5d0ec3b..a69017abdd1 100644 --- a/config/environment.js +++ b/config/environment.js @@ -316,6 +316,7 @@ module.exports = function(environment) { }, storageI18n: 'storage_i18n', gravyWaffle: 'gravy_waffle', + manualDoiAndGuid: 'manual_doi_and_guid', enableInactiveSchemas: 'enable_inactive_schemas', verifyEmailModals: 'ember_verify_email_modals', ABTesting: { diff --git a/lib/osf-components/addon/components/registries/finalize-registration-modal/manager/component.ts b/lib/osf-components/addon/components/registries/finalize-registration-modal/manager/component.ts index db0835f6045..2ca16819ca6 100644 --- a/lib/osf-components/addon/components/registries/finalize-registration-modal/manager/component.ts +++ b/lib/osf-components/addon/components/registries/finalize-registration-modal/manager/component.ts @@ -14,6 +14,10 @@ import RegistrationModel from 'ember-osf-web/models/registration'; import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; import DraftRegistrationManager from 'registries/drafts/draft/draft-registration-manager'; +import buildChangeset from 'ember-osf-web/utils/build-changeset'; +import { ValidationObject } from 'ember-changeset-validations'; +import { validateFormat, validateLength } from 'ember-changeset-validations/validators'; +import { DOIRegex } from 'ember-osf-web/utils/doi'; import template from './template'; export interface FinalizeRegistrationModalManager { @@ -25,6 +29,11 @@ export interface FinalizeRegistrationModalManager { draftManager: DraftRegistrationManager; } +interface ManualDoiAndGuidForm { + manualDoi: string; + manualGuid: string; +} + @layout(template) @tagName('') export default class FinalizeRegistrationModalManagerComponent extends Component @@ -32,9 +41,32 @@ export default class FinalizeRegistrationModalManagerComponent extends Component @service intl!: Intl; @service toast!: Toast; + // validationFunction() { + // debugger; + // } + manualDoiAndGuidFormChangesetValidation: ValidationObject = { + manualDoi: validateFormat({ + allowBlank: true, + allowNone: true, + ignoreBlank: true, + regex: DOIRegex, + type: 'invalid_doi', + }), + // manualDoi: this.validationFunction, + manualGuid: validateLength({ + allowBlank: true, + min:5, + type: 'greaterThanOrEqualTo', + translationArgs: { + description: this.intl.t('preprints.submit.step-title.guid'), + gte: '5 characters', + }, + }), + }; // Required arguments registration!: RegistrationModel; draftManager!: DraftRegistrationManager; + guidAndDoiFormChangeset!: any; // Optional arguments onSubmitRegistration?: (registrationId: string) => void; @@ -67,6 +99,14 @@ export default class FinalizeRegistrationModalManagerComponent extends Component didReceiveAttrs() { assert('finalize-registration-modal::manager must have a registration', Boolean(this.registration)); + this.guidAndDoiFormChangeset = buildChangeset(this.registration, this.manualDoiAndGuidFormChangesetValidation); + } + + @action + validateManualDoiAndGuid() { + // debugger; + this.guidAndDoiFormChangeset.validate(); + this.guidAndDoiFormChangeset.execute(); } @action diff --git a/lib/osf-components/addon/components/registries/finalize-registration-modal/manager/template.hbs b/lib/osf-components/addon/components/registries/finalize-registration-modal/manager/template.hbs index e6359a0caa2..fc945e23a5c 100644 --- a/lib/osf-components/addon/components/registries/finalize-registration-modal/manager/template.hbs +++ b/lib/osf-components/addon/components/registries/finalize-registration-modal/manager/template.hbs @@ -5,4 +5,6 @@ hasEmbargoEndDate=this.hasEmbargoEndDate submittingRegistration=this.submittingRegistration draftManager=this.draftManager + guidAndDoiFormChangeset=this.guidAndDoiFormChangeset + validateManualDoiAndGuid=this.validateManualDoiAndGuid )}} diff --git a/lib/osf-components/addon/components/registries/finalize-registration-modal/template.hbs b/lib/osf-components/addon/components/registries/finalize-registration-modal/template.hbs index 281eb00cc85..cd53e157514 100644 --- a/lib/osf-components/addon/components/registries/finalize-registration-modal/template.hbs +++ b/lib/osf-components/addon/components/registries/finalize-registration-modal/template.hbs @@ -43,6 +43,44 @@ /> {{/if}} + {{#if (feature-flag 'manual_doi_and_guid')}} + + {{#let (unique-id 'manualDoi') as |manualDoiField|}} + + + {{/let}} + {{#let (unique-id 'manualGuid') as |manualGuidField|}} + + + {{/let}} + + {{/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..bce64513967 --- /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: 100%; + + &.file-picker { + width: 0; + } + + .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/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' From 14c69fb605d56a13787c1fc51bc790f8f7d9761c Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Fri, 13 Jun 2025 13:14:56 -0400 Subject: [PATCH 5/6] Bump version no. Add CHANGELOG --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) 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/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", From 192199edae0d4a5cdab39b67c719d9c4fa6ef2f1 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 13 Jun 2025 09:59:29 -0500 Subject: [PATCH 6/6] Fixed the logic --- .../addon/components/google-file-picker-widget/styles.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index bce64513967..a1e4c242a9c 100644 --- a/lib/osf-components/addon/components/google-file-picker-widget/styles.scss +++ b/lib/osf-components/addon/components/google-file-picker-widget/styles.scss @@ -1,10 +1,10 @@ // stylelint-disable max-nesting-depth, selector-max-compound-selectors, selector-no-qualifying-type .google-file-picker-container { - width: 100%; + width: 0; &.file-picker { - width: 0; + width: 100%; } .instruction-container {