diff --git a/api/v1/reviewerSuggestions/formRequests/AddReviewerSuggestion.php b/api/v1/reviewerSuggestions/formRequests/AddReviewerSuggestion.php deleted file mode 100644 index f319734225d..00000000000 --- a/api/v1/reviewerSuggestions/formRequests/AddReviewerSuggestion.php +++ /dev/null @@ -1,65 +0,0 @@ - [ - 'required', - 'string', - 'max:255', - ], - 'givenName' => [ - 'required', - 'string', - 'max:255', - ], - 'email' => [ - 'required', - 'email', - ], - ]; - } - - /** - * Get the error messages for the defined validation rules. - * - * @return array - */ - public function messages(): array - { - return [ - 'familyName.required' => 'family name is required', - ]; - } - - /** - * Get custom attributes for validator errors. - * - * @return array - */ - public function attributes(): array - { - return [ - 'familyName' => __('user.familyName'), - 'givenName' => __('user.givenName'), - 'email' => __('user.email'), - ]; - } -} diff --git a/api/v1/reviewerSuggestions/ReviewerSuggestionController.php b/api/v1/reviewers/suggestions/ReviewerSuggestionController.php similarity index 75% rename from api/v1/reviewerSuggestions/ReviewerSuggestionController.php rename to api/v1/reviewers/suggestions/ReviewerSuggestionController.php index d9f6573d23d..63b9b94f45b 100644 --- a/api/v1/reviewerSuggestions/ReviewerSuggestionController.php +++ b/api/v1/reviewers/suggestions/ReviewerSuggestionController.php @@ -13,28 +13,25 @@ * */ -namespace PKP\API\v1\reviewerSuggestions; +namespace PKP\API\v1\reviewers\suggestions; use APP\core\Application; -use PKP\API\v1\reviewerSuggestions\formRequests\AddReviewerSuggestion; +use PKP\API\v1\reviewers\suggestions\resources\ReviewerSuggestionResource; -use PKP\security\authorization\internal\SubmissionIncompletePolicy; use APP\facades\Repo; +use PKP\API\v1\reviewers\suggestions\formRequests\AddReviewerSuggestion; +use PKP\security\authorization\internal\SubmissionIncompletePolicy; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; -use PKP\core\PKPApplication; use PKP\core\PKPBaseController; use PKP\core\PKPRequest; -use PKP\db\DAORegistry; -use PKP\log\EmailLogEntry; -use PKP\log\SubmissionEmailLogEventType; use PKP\security\authorization\ContextAccessPolicy; use PKP\security\authorization\SubmissionAccessPolicy; use PKP\security\authorization\UserRolesRequiredPolicy; use PKP\security\Role; -use PKP\submissionFile\SubmissionFile; +use PKP\submission\reviewer\suggestion\ReviewerSuggestion; class ReviewerSuggestionController extends PKPBaseController { @@ -43,7 +40,7 @@ class ReviewerSuggestionController extends PKPBaseController */ public function getHandlerPath(): string { - return 'reviewerSuggestions'; + return 'submissions/{submissionId}/reviewers/suggestions'; } /** @@ -74,11 +71,11 @@ public function getGroupRoutes(): void ->name('reviewer.suggestions.get') ->whereNumber('suggestionId'); - Route::get('submission/{submissionId}', $this->getMany(...)) + Route::get('', $this->getMany(...)) ->name('reviewer.suggestions.getMany') ->whereNumber('submissionId'); - Route::post('submission/{submissionId}', $this->add(...)) + Route::post('', $this->add(...)) ->name('reviewer.suggestions.add'); Route::put('{suggestionId}', $this->edit(...)) @@ -114,30 +111,40 @@ public function authorize(PKPRequest $request, array &$args, array $roleAssignme public function get(Request $illuminateRequest): JsonResponse { - $request = $this->getRequest(); - $context = $request->getContext(); - $contextId = $context->getId(); + $suggestion = ReviewerSuggestion::find($illuminateRequest->route('suggestionId')); + + if (!$suggestion) { + return response()->json([ + 'error' => __('api.404.resourceNotFound'), + ], Response::HTTP_NOT_FOUND); + } - return response()->json([], Response::HTTP_OK); + return response()->json( + (new ReviewerSuggestionResource($suggestion))->toArray($illuminateRequest), + Response::HTTP_OK + ); } public function getMany(Request $illuminateRequest): JsonResponse { - $request = $this->getRequest(); - $context = $request->getContext(); - $contextId = $context->getId(); + $suggestions = ReviewerSuggestion::withSubmissionId($illuminateRequest->route('sibmissionId'))->get(); - return response()->json([], Response::HTTP_OK); + return response()->json([ + 'items' => ReviewerSuggestionResource::collection($suggestions), + 'itemMax' => $suggestions->count(), + ], Response::HTTP_OK); } public function add(AddReviewerSuggestion $illuminateRequest): JsonResponse { $validateds = $illuminateRequest->validated(); - $request = $this->getRequest(); - $context = $request->getContext(); - $contextId = $context->getId(); + + $suggestion = ReviewerSuggestion::create($validateds); - return response()->json([], Response::HTTP_OK); + return response()->json( + (new ReviewerSuggestionResource($suggestion))->toArray($illuminateRequest), + Response::HTTP_OK + ); } public function edit(Request $illuminateRequest): JsonResponse diff --git a/api/v1/reviewers/suggestions/formRequests/AddReviewerSuggestion.php b/api/v1/reviewers/suggestions/formRequests/AddReviewerSuggestion.php new file mode 100644 index 00000000000..c0a9424205d --- /dev/null +++ b/api/v1/reviewers/suggestions/formRequests/AddReviewerSuggestion.php @@ -0,0 +1,107 @@ +map( + fn($value) => is_array($value) ? array_filter($value) : $value + )->toArray(); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'submissionId' => [ + 'required', + 'integer', + Rule::exists('submissions', 'submission_id'), + ], + 'suggestingUserId' => [ + 'sometimes', + 'nullable', + 'integer', + Rule::exists('users', 'user_id'), + ], + 'familyName' => [ + 'required', + 'array', + ], + 'givenName' => [ + 'required', + 'array', + ], + 'email' => [ + 'required', + 'email', + ], + 'affiliation' => [ + 'required', + 'array', + ], + 'suggestionReason' => [ + 'required', + 'array', + ], + ]; + } + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public function messages(): array + { + return [ + 'familyName.required' => 'family name is required', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'familyName' => __('user.familyName'), + 'givenName' => __('user.givenName'), + 'email' => __('user.email'), + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'suggestingUserId' => Application::get()->getRequest()?->getUser()?->getId(), + 'submissionId' => $this->route('submissionId'), + ]); + } +} diff --git a/api/v1/reviewers/suggestions/resources/ReviewerSuggestionResource.php b/api/v1/reviewers/suggestions/resources/ReviewerSuggestionResource.php new file mode 100644 index 00000000000..f777734bc08 --- /dev/null +++ b/api/v1/reviewers/suggestions/resources/ReviewerSuggestionResource.php @@ -0,0 +1,28 @@ + $this->id, + 'submissionId' => $this->submissionId, + 'suggestingUserId' => $this->suggestingUserId, + 'familyName' => $this->familyName, + 'givenName' => $this->givenName, + 'fullName' => $this->fullname, + 'email' => $this->email, + 'orcidId' => $this->orcidId, + 'affiliation' => $this->affiliation, + 'suggestionReason' => $this->suggestionReason, + ]; + } +} \ No newline at end of file diff --git a/classes/components/forms/publication/ReviewerSuggestionsForm.php b/classes/components/forms/publication/ReviewerSuggestionsForm.php new file mode 100644 index 00000000000..9541cf409b6 --- /dev/null +++ b/classes/components/forms/publication/ReviewerSuggestionsForm.php @@ -0,0 +1,82 @@ +action = $action; + $this->locales = $locales; + $this->submission = $submission; + $this->context = $context; + + $this + ->addField(new FieldText('givenName', [ + 'label' => __('user.givenName'), + 'isMultilingual' => true, + 'isRequired' => true, + ])) + ->addField(new FieldText('familyName', [ + 'label' => __('user.familyName'), + 'isMultilingual' => true, + 'isRequired' => true, + ])) + ->addField(new FieldText('email', [ + 'label' => __('user.email'), + 'isRequired' => true, + ])) + ->addField(new FieldText('affiliation', [ + 'label' => __('user.affiliation'), + 'isRequired' => true, + 'isMultilingual' => true, + ])) + ->addField(new FieldRichTextarea('suggestionReason', [ + 'label' => __('reviewerSuggestion.suggestionReason'), + 'description' => __('reviewerSuggestion.suggestionReason.description'), + 'isRequired' => true, + 'isMultilingual' => true, + ])); + + if (OrcidManager::isEnabled()) { + $this->addField(new FieldOrcid('orcid', [ + 'label' => __('user.orcid'), + 'tooltip' => __('orcid.about.orcidExplanation'), + ]), [FIELD_POSITION_AFTER, 'email']); + } + } +} diff --git a/classes/components/forms/submission/SubmissionGuidanceSettings.php b/classes/components/forms/submission/SubmissionGuidanceSettings.php index 471357060c7..ae0ca835710 100644 --- a/classes/components/forms/submission/SubmissionGuidanceSettings.php +++ b/classes/components/forms/submission/SubmissionGuidanceSettings.php @@ -41,14 +41,15 @@ public function __construct(string $action, array $locales, Context $context) 'submissions' ); - $this->addField(new FieldRichTextarea('authorGuidelines', [ - 'label' => __('manager.setup.authorGuidelines'), - 'description' => __('manager.setup.authorGuidelines.description', ['url' => $submissionUrl]), - 'isMultilingual' => true, - 'value' => $context->getData('authorGuidelines'), - 'toolbar' => 'bold italic superscript subscript | link | blockquote bullist numlist', - 'plugins' => 'paste,link,lists', - ])) + $this + ->addField(new FieldRichTextarea('authorGuidelines', [ + 'label' => __('manager.setup.authorGuidelines'), + 'description' => __('manager.setup.authorGuidelines.description', ['url' => $submissionUrl]), + 'isMultilingual' => true, + 'value' => $context->getData('authorGuidelines'), + 'toolbar' => 'bold italic superscript subscript | link | blockquote bullist numlist', + 'plugins' => 'paste,link,lists', + ])) ->addField(new FieldRichTextarea('beginSubmissionHelp', [ 'label' => __('submission.wizard.beforeStart'), 'description' => __('manager.setup.workflow.beginSubmissionHelp.description'), @@ -113,5 +114,28 @@ public function __construct(string $action, array $locales, Context $context) 'toolbar' => 'bold italic superscript subscript | link | blockquote bullist numlist', 'plugins' => 'paste,link,lists', ])); + + $this->addReviewSuggestionGuidanceDetail($this->context); + } + + /** + * Add review suggestion guidance text + */ + protected function addReviewSuggestionGuidanceDetail(Context $context): void + { + $schema = app()->get('schema'); /** @var \PKP\services\PKPSchemaService $schema */ + + if (!collect($schema->get("context")->properties)->has("reviewerSuggestionEnabled")) { + return; + } + + $this->addField(new FieldRichTextarea('reviewerSuggestionsHelp', [ + 'label' => __('submission.forReviewerSuggestion'), + 'description' => __('manager.setup.workflow.reviewerSuggestionsHelp.description'), + 'isMultilingual' => true, + 'value' => $context->getData('reviewerSuggestionsHelp'), + 'toolbar' => 'bold italic superscript subscript | link | blockquote bullist numlist', + 'plugins' => 'paste,link,lists', + ])); } } diff --git a/classes/components/listPanels/ReviewerSuggestionsListPanel.php b/classes/components/listPanels/ReviewerSuggestionsListPanel.php new file mode 100644 index 00000000000..f1f9270b603 --- /dev/null +++ b/classes/components/listPanels/ReviewerSuggestionsListPanel.php @@ -0,0 +1,131 @@ +submission = $submission; + $this->context = $context; + $this->locales = $locales; + $this->items = $items; + $this->canEditPublication = $canEditPublication; + } + + /** + * @copydoc ListPanel::getConfig() + */ + public function getConfig() + { + $config = parent::getConfig(); + + // Remove some props not used in this list panel + unset($config['description']); + unset($config['expanded']); + unset($config['headingLevel']); + + $config = array_merge( + $config, + [ + 'canEditPublication' => $this->canEditPublication, + 'publicationApiUrlFormat' => $this->getPublicationUrlFormat(), + 'reviewerSuggestionsApiUrl' => $this->getReviewerSuggestionsApiUrl(), + 'form' => $this->getLocalizedForm(), + 'items' => $this->items, + ] + ); + + return $config; + } + + protected function getReviewerSuggestionsApiUrl(): string + { + return Application::get()->getRequest()->getDispatcher()->url( + Application::get()->getRequest(), + Application::ROUTE_API, + $this->context->getPath(), + "submissions/{$this->submission->getId()}/reviewers/suggestions" + ); + } + + /** + * Get an example of the url to a publication's API endpoint, + * with a placeholder instead of the publication id, eg: + * + * http://example.org/api/v1/submissions/1/publications/__publicationId__ + */ + protected function getPublicationUrlFormat(): string + { + return Application::get()->getRequest()->getDispatcher()->url( + Application::get()->getRequest(), + Application::ROUTE_API, + $this->context->getPath(), + 'submissions/' . $this->submission->getId() . '/publications/__publicationId__' + ); + } + + /** + * Get the form data localized to the submission's locale + */ + protected function getLocalizedForm(): array + { + $apiUrl = $this->getReviewerSuggestionsApiUrl(); + + $submissionLocale = $this->submission->getData('locale'); + $data = $this->getForm($apiUrl)->getConfig(); + + $data['primaryLocale'] = $submissionLocale; + $data['visibleLocales'] = [$submissionLocale]; + $data['supportedFormLocales'] = collect($this->locales) + ->sortBy([fn (array $a, array $b) => $b['key'] === $submissionLocale ? 1 : -1]) + ->values() + ->toArray(); + + return $data; + } + + /** + * Get the reviewer suggestions form + */ + protected function getForm(string $url): ReviewerSuggestionsForm + { + return new ReviewerSuggestionsForm( + $url, + $this->locales, + $this->submission, + $this->context + ); + } +} diff --git a/classes/core/PKPBaseController.php b/classes/core/PKPBaseController.php index d1a2c64c9d9..4b0b35d431a 100644 --- a/classes/core/PKPBaseController.php +++ b/classes/core/PKPBaseController.php @@ -388,6 +388,11 @@ public function getParameter(string $parameterName, mixed $default = null): mixe if (isset($queryParams[$parameterName])) { return $queryParams[$parameterName]; } + + $inputs = $illuminateRequest->input(); + if (isset($inputs[$parameterName])) { + return $inputs[$parameterName]; + } } return $default; diff --git a/classes/core/traits/ModelWithSettings.php b/classes/core/traits/ModelWithSettings.php index d077cf73bb3..3d0a0d2dbf5 100644 --- a/classes/core/traits/ModelWithSettings.php +++ b/classes/core/traits/ModelWithSettings.php @@ -46,7 +46,7 @@ abstract public function getTable(); /** * Get settings table name */ - abstract public function getSettingsTable(); + abstract public function getSettingsTable(): string; /** * The name of the schema for the Model if exists, null otherwise diff --git a/classes/migration/install/ReviewerSuggestionsMigration.php b/classes/migration/install/ReviewerSuggestionsMigration.php index d22c60d6d69..02785cc160a 100644 --- a/classes/migration/install/ReviewerSuggestionsMigration.php +++ b/classes/migration/install/ReviewerSuggestionsMigration.php @@ -29,15 +29,15 @@ public function up(): void $table->bigInteger('reviewer_suggestion_id')->autoIncrement(); $table - ->bigInteger('user_id') + ->bigInteger('suggesting_user_id') ->nullable() ->comment('The user/author who has made the suggestion'); $table - ->foreign('user_id') + ->foreign('suggesting_user_id') ->references('user_id') ->on('users') ->onDelete('set null'); - $table->index(['user_id'], 'reviewer_suggestions_user_id'); + $table->index(['suggesting_user_id'], 'reviewer_suggestions_suggesting_user_id'); $table->bigInteger('submission_id')->comment('Submission at which the suggestion was made'); $table->foreign('submission_id')->references('submission_id')->on('submissions')->onDelete('cascade'); @@ -54,7 +54,7 @@ public function up(): void $table ->bigInteger('stage_id') ->nullable() - ->comment('The stage at whihc suggestion approved'); + ->comment('The stage at which suggestion approved'); $table ->bigInteger('approver_id') diff --git a/classes/migration/upgrade/v3_5_0/I4787_ReviewSuggestions.php b/classes/migration/upgrade/v3_5_0/I4787_ReviewSuggestions.php new file mode 100644 index 00000000000..c47e1664334 --- /dev/null +++ b/classes/migration/upgrade/v3_5_0/I4787_ReviewSuggestions.php @@ -0,0 +1,50 @@ +_installer, $this->_attributes))->up(); + parent::up(); + } + + /** + * Reverse the migration + */ + public function down(): void + { + (new ReviewerSuggestionsMigration($this->_installer, $this->_attributes))->down(); + parent::down(); + } + + /** + * @return Collection [settingName => localeKey] + */ + protected function getNewSettings(): Collection + { + return collect([ + 'reviewerSuggestionsHelp' => 'default.submission.step.reviewerSuggestions', + ]); + } +} diff --git a/classes/publication/Repository.php b/classes/publication/Repository.php index c829375ab29..ae1acbfa015 100644 --- a/classes/publication/Repository.php +++ b/classes/publication/Repository.php @@ -365,7 +365,7 @@ public function version(Publication $publication): int $citationDao->importCitations($newPublication->getId(), $newPublication->getData('citationsRaw')); } - $genreDao = DAORegistry::getDAO('GenreDAO'); + $genreDao = DAORegistry::getDAO('GenreDAO'); /** @var \PKP\submission\GenreDAO $genreDao */ $genres = $genreDao->getEnabledByContextId($context->getId()); $jatsFile = Repo::jats() diff --git a/classes/submission/maps/Schema.php b/classes/submission/maps/Schema.php index 0924f1915f1..a51ca00ce55 100644 --- a/classes/submission/maps/Schema.php +++ b/classes/submission/maps/Schema.php @@ -14,6 +14,8 @@ namespace PKP\submission\maps; use APP\core\Application; +use PKP\API\v1\reviewers\suggestions\resources\ReviewerSuggestionResource; + use APP\facades\Repo; use APP\submission\Submission; use Illuminate\Support\Collection; @@ -28,6 +30,7 @@ use PKP\stageAssignment\StageAssignment; use PKP\submission\Genre; use PKP\submission\reviewAssignment\ReviewAssignment; +use PKP\submission\reviewer\suggestion\ReviewerSuggestion; use PKP\submission\reviewRound\ReviewRound; use PKP\submission\reviewRound\ReviewRoundDAO; use PKP\submissionFile\SubmissionFile; @@ -54,6 +57,9 @@ class Schema extends \PKP\core\maps\Schema /** @var Enumerable Stage assignments associated with submissions. */ public Enumerable $stageAssignments; + /** @var Enumerable Reviewer Suggestions associated with submissions. */ + public Enumerable $reviewerSuggestions; + /** * Get extra property names used in the submissions list * @@ -115,12 +121,14 @@ public function map( array $genres, ?Enumerable $reviewAssignments = null, ?Enumerable $stageAssignments = null, - bool|Collection $anonymizeReviews = false + bool|Collection $anonymizeReviews = false, + ?Enumerable $reviewerSuggestions = null ): array { $this->userGroups = $userGroups; $this->genres = $genres; $this->reviewAssignments = $reviewAssignments ?? Repo::reviewAssignment()->getCollector()->filterBySubmissionIds([$item->getId()])->getMany()->remember(); $this->stageAssignments = $stageAssignments ?? $this->getStageAssignmentsBySubmissions(collect([$item]), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + $this->reviewerSuggestions = $reviewerSuggestions ?? ReviewerSuggestion::withSubmissionIds($item->getId())->get(); return $this->mapByProperties($this->getProps(), $item, $anonymizeReviews); } @@ -143,11 +151,13 @@ public function summarize( ?Enumerable $reviewAssignments = null, ?Enumerable $stageAssignments = null, bool|Collection $anonymizeReviews = false, + ?Enumerable $reviewerSuggestions = null ): array { $this->userGroups = $userGroups; $this->genres = $genres; $this->reviewAssignments = $reviewAssignments ?? Repo::reviewAssignment()->getCollector()->filterBySubmissionIds([$item->getId()])->getMany()->remember(); $this->stageAssignments = $stageAssignments ?? $this->getStageAssignmentsBySubmissions(collect([$item]), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + $this->reviewerSuggestions = $reviewerSuggestions ?? ReviewerSuggestion::withSubmissionIds($item->getId())->get(); return $this->mapByProperties($this->getSummaryProps(), $item, $anonymizeReviews); } @@ -161,7 +171,12 @@ public function summarize( * @param Genre[] $genres The file genres in this context * @param bool|Collection $anonymizeReviews List of review assignment IDs to anonymize */ - public function mapMany(Enumerable $collection, LazyCollection $userGroups, array $genres, bool|Collection $anonymizeReviews = false): Enumerable + public function mapMany( + Enumerable $collection, + LazyCollection $userGroups, + array $genres, + bool|Collection $anonymizeReviews = false + ): Enumerable { $this->collection = $collection; $this->userGroups = $userGroups; @@ -173,6 +188,8 @@ public function mapMany(Enumerable $collection, LazyCollection $userGroups, arra $associatedStageAssignments = $this->stageAssignments->groupBy(fn (StageAssignment $stageAssignment, int $key) => $stageAssignment->submissionId); + // TODO : Build up associated reviewer suggestions + return $collection->map( fn ($item) => $this->map( @@ -195,7 +212,12 @@ public function mapMany(Enumerable $collection, LazyCollection $userGroups, arra * @param Genre[] $genres The file genres in this context * @param bool|Collection $anonymizeReviews List of review assignment IDs to anonymize */ - public function summarizeMany(Enumerable $collection, LazyCollection $userGroups, array $genres, bool|Collection $anonymizeReviews = false): Enumerable + public function summarizeMany( + Enumerable $collection, + LazyCollection $userGroups, + array $genres, + bool|Collection $anonymizeReviews = false + ): Enumerable { $this->collection = $collection; $this->userGroups = $userGroups; @@ -212,6 +234,8 @@ public function summarizeMany(Enumerable $collection, LazyCollection $userGroups $stageAssignment->submissionId ); + // TODO : Build up associated reviewer suggestions + return $collection->map( fn ($item) => $this->summarize( @@ -246,6 +270,8 @@ public function mapToSubmissionsList( $this->genres = $genres; $this->reviewAssignments = $reviewAssignments ?? Repo::reviewAssignment()->getCollector()->filterBySubmissionIds([$item->getId()])->getMany()->remember(); $this->stageAssignments = $stageAssignments ?? $this->getStageAssignmentsBySubmissions(collect([$item]), [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SUB_EDITOR]); + $this->reviewerSuggestions = $reviewerSuggestions ?? ReviewerSuggestion::withSubmissionIds($item->getId())->get(); + return $this->mapByProperties($this->getSubmissionsListProps(), $item, $anonymizeReviews); } @@ -279,6 +305,8 @@ public function mapManyToSubmissionsList( $stageAssignment->submissionId ); + // TODO : Build up associated reviewer suggestions + return $collection->map( fn ($item) => $this->mapToSubmissionsList( @@ -406,6 +434,9 @@ protected function mapByProperties(array $props, Submission $submission, bool|Co case 'urlWorkflow': $output[$prop] = Repo::submission()->getWorkflowUrlByUserRoles($submission); break; + case 'reviewerSuggestions': + $output[$prop] = $this->getPropertyReviewerSuggestions($this->reviewerSuggestions); + break; default: $output[$prop] = $submission->getData($prop); break; @@ -415,6 +446,19 @@ protected function mapByProperties(array $props, Submission $submission, bool|Co return $output; } + /** + * Get details about the reviewer suggestions for a submission + */ + protected function getPropertyReviewerSuggestions(Enumerable $reviewerSuggestions): array + { + // TODO : why index start from 1 instead of 0 + // this cause the transformation to Object instead of Array in JS side + return array_values( + ReviewerSuggestionResource::collection($reviewerSuggestions) + ->toArray(app()->get("request")) + ); + } + /** * Get details about the review assignments for a submission */ diff --git a/classes/submission/reviewer/suggestion/ReviewerSuggestion.php b/classes/submission/reviewer/suggestion/ReviewerSuggestion.php index 090dbecd087..b265e8f5299 100644 --- a/classes/submission/reviewer/suggestion/ReviewerSuggestion.php +++ b/classes/submission/reviewer/suggestion/ReviewerSuggestion.php @@ -14,36 +14,67 @@ namespace PKP\submission\reviewer\suggestion; -use Illuminate\Database\Eloquent\Model; use APP\facades\Repo; use Carbon\Carbon; -use Eloquence\Behaviours\HasCamelCasing; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; - +use PKP\core\traits\ModelWithSettings; class ReviewerSuggestion extends Model { - use HasCamelCasing; + use ModelWithSettings; protected $table = 'reviewer_suggestions'; protected $primaryKey = 'reviewer_suggestion_id'; - protected $fillable = [ - - ]; + protected $guarded = []; protected function casts(): array { return [ - 'userId' => 'int', - 'submissionId' => 'int', - 'email' => 'string', - 'orcidId' => 'string', - 'approvedAt' => 'datetime', - 'stageId' => 'int', - 'approverId' => 'int', - 'reviewerId' => 'int', + 'suggestingUserId' => 'int', + 'submissionId' => 'int', + 'email' => 'string', + 'orcidId' => 'string', + 'approvedAt' => 'datetime', + 'stageId' => 'int', + 'approverId' => 'int', + 'reviewerId' => 'int', + ]; + } + + public function getSettingsTable(): string + { + return 'reviewer_suggestion_settings'; + } + + public static function getSchemaName(): ?string + { + return null; + } + + // TODO Add instution details as setting + public function getSettings(): array + { + return [ + 'familyName', + 'givenName', + 'affiliation', + 'suggestionReason', + ]; + } + + // TODO should the instution details be a multigingual prop + public function getMultilingualProps(): array + { + return [ + 'fullName', + 'familyName', + 'givenName', + 'affiliation', + 'suggestionReason', ]; } @@ -52,6 +83,21 @@ public function hasApproved(): ?Carbon return $this->approvedAt; } + /** + * Get the full name + */ + protected function fullName(): Attribute + { + return Attribute::make( + get: function (mixed $value, array $attributes) { + $familyName = $this->familyName; + return collect($this->givenName) + ->map(fn ($givenName, $locale) => $givenName. ' ' . $familyName[$locale]) + ->toArray(); + } + ); + } + /** * Accessor and Mutator for primary key => id */ @@ -60,74 +106,61 @@ protected function id(): Attribute return Attribute::make( get: fn($value, $attributes) => $attributes[$this->primaryKey] ?? null, set: fn($value) => [$this->primaryKey => $value], - ); + )->shouldCache(); } - /** - * Accessor for submission. - * Should replace with relationship once Submission is converted to an Eloquent Model. - */ protected function submission(): Attribute { return Attribute::make( get: fn () => Repo::submission()->get($this->submissionId, true), - ); + )->shouldCache(); } - /** - * Accessor for user. - * Should replace with relationship once User is converted to an Eloquent Model. - */ - protected function user(): Attribute + protected function suggestingUser(): Attribute { return Attribute::make( - get: fn () => Repo::user()->get($this->userId, true), - ); + get: fn () => $this->suggestingUserId + ? Repo::user()->get($this->suggestingUserId, true) + : null + )->shouldCache(); } - /** - * Accessor for user. - * Should replace with relationship once User is converted to an Eloquent Model. - */ - protected function approver(): ?Attribute + protected function approver(): Attribute { return Attribute::make( get: fn () => $this->approverId ? Repo::user()->get($this->approverId, true) : null, - ); + )->shouldCache(); } - /** - * Accessor for user. - * Should replace with relationship once User is converted to an Eloquent Model. - */ - protected function reviewer(): ?Attribute + protected function reviewer(): Attribute { return Attribute::make( get: fn () => $this->reviewerId ? Repo::user()->get($this->reviewerId, true) : null, - ); + )->shouldCache(); } public function scopeWithContextId(Builder $query, int $contextId): Builder { - return $query->whereIn("submission_id", fn (Builder $query) => $query - ->select("submission_id") - ->from("submissions") - ->where("context_id", $contextId) - ); + return $query + ->whereIn('submission_id', fn (Builder $query) => $query + ->select('submission_id') + ->from('submissions') + ->where('context_id', $contextId) + ); } - public function scopeWithSubmissionId(Builder $query, int $submissionId): Builder + public function scopeWithSubmissionIds(Builder $query, int|array $submissionId): Builder { - return $query->where("submission_id", $submissionId); + return $query->whereIn('submission_id', Arr::wrap($submissionId)); } - public function scopeWithUserId(Builder $query, int $userId): Builder + public function scopeWithSuggestingUserIds(Builder $query, int|array $userId): Builder { - return $query->where("user_id", $userId); + return $query->whereIn('suggesting_user_id', Arr::wrap($userId)); } - public function scopeWithStageId(Builder $query, int $stageId): Builder + public function scopeWithStageIds(Builder $query, int|array $stageId): Builder { - return $query->where("stage_id", $stageId); + return $query->whereIn('stage_id', Arr::wrap($stageId)); } } diff --git a/locale/en/default.po b/locale/en/default.po index 55b6e68c2ba..54c6194ac83 100644 --- a/locale/en/default.po +++ b/locale/en/default.po @@ -170,6 +170,15 @@ msgstr "" "address. You can add information about this contributor in a message to the " "editor at a later step in the submission process.

" +msgid "default.submission.step.reviewerSuggestions" +msgstr "" +"

When submitting your article, you have the option to suggest 2/3 potentials " +"reviewers. This can help streampline the review process and provide valueable " +"input for the editorial team. Please choose reviewers who are expert in your " +"field and have not conflict of interest with your work. This feature amins to " +"enhance the review process and and support a more efficient experience for both " +"authors and journals.

" + msgid "default.submission.step.details" msgstr "" "

Please provide the following details to help us manage your submission in " diff --git a/locale/en/grid.po b/locale/en/grid.po index f50a11b79f8..a7adad15b85 100644 --- a/locale/en/grid.po +++ b/locale/en/grid.po @@ -409,6 +409,17 @@ msgstr "" "Are you sure you want to remove {$name} as a contributor? This action can " "not be undone." +msgid "grid.action.addReviewerSuggestion" +msgstr "Add Reviewer Suggestion" + +msgid "grid.action.deleteReviewerSuggestion" +msgstr "Delete Reviewer Suggestion" + +msgid "grid.action.deleteReviewerSuggestion.confirmationMessage" +msgstr "" +"Are you sure you want to remove this suggestion? This action can " +"not be undone." + msgid "grid.action.newVersion" msgstr "Create a new version" diff --git a/locale/en/manager.po b/locale/en/manager.po index 54e1e87cfb6..cb2da64be98 100644 --- a/locale/en/manager.po +++ b/locale/en/manager.po @@ -1327,6 +1327,9 @@ msgstr "The following is shown to authors during the upload files step. Provide msgid "manager.setup.workflow.contributorsHelp.description" msgstr "The following is shown to authors during the contributors step. Provide a brief explanation of what information the author should provide about themselves, co-authors, and any other contributors." +msgid "manager.setup.workflow.reviewerSuggestionsHelp.description" +msgstr "The following is shown to authors during the reviewer suggestions step. Provide a brief explanation of what information the author should provide about themselves, co-authors, and any other contributors." + msgid "manager.setup.workflow.detailsHelp.description" msgstr "The following is shown to authors during the details step, when they are asked to provide the title, abstract, and other key information about their submission." diff --git a/locale/en/submission.po b/locale/en/submission.po index b9c2c65e84b..d856fe5c40f 100644 --- a/locale/en/submission.po +++ b/locale/en/submission.po @@ -663,6 +663,9 @@ msgstr "First published" msgid "submission.forTheEditors" msgstr "For the Editors" +msgid "submission.forReviewerSuggestion" +msgstr "For Reviewer Suggestion" + msgid "submission.galley" msgstr "Galley" @@ -1245,6 +1248,9 @@ msgstr "Upload a File Ready for Publication" msgid "submission.upload.noAvailableReviewFiles" msgstr "There are no files for you to revise at this time." +msgid "submission.reviewerSuggestions" +msgstr "Reviewer Suggestions" + msgid "editor.submission.roundStatus.revisionsRequested" msgstr "Revisions have been requested." @@ -2177,6 +2183,9 @@ msgstr "Make a Submission: {$step}" msgid "submission.wizard.noContributors" msgstr "No contributors have been added for this submission." +msgid "submission.wizard.noReviewerSuggestions" +msgstr "No reviewers have been suggested for this submission." + msgid "submission.wizard.missingContributorLanguage" msgstr "The given name is missing in {$language} for one or more of the contributors." @@ -2452,5 +2461,11 @@ msgstr "Competing Interests" msgid "author.competingInterests.description" msgstr "Please disclose any competing interests this author may have with the research subject." +msgid "reviewerSuggestion.suggestionReason" +msgstr "Reasons for suggesting reviewer" + +msgid "reviewerSuggestion.suggestionReason.description" +msgstr "Please share why you are recommending this reviewer and metion is there are any potential conflict of interest." + msgid "submission.localeNotSupported" msgstr "The language of the submission ({$language}) is not one of the supported submission languages. You can still edit the submission details but new submissions are not currently accepted with this language." diff --git a/pages/submission/PKPSubmissionHandler.php b/pages/submission/PKPSubmissionHandler.php index 3bc2b037e64..ec1f6945e71 100644 --- a/pages/submission/PKPSubmissionHandler.php +++ b/pages/submission/PKPSubmissionHandler.php @@ -34,6 +34,7 @@ use PKP\components\forms\submission\ForTheEditors; use PKP\components\forms\submission\PKPSubmissionFileForm; use PKP\components\listPanels\ContributorsListPanel; +use PKP\components\listPanels\ReviewerSuggestionsListPanel; use PKP\context\Context; use PKP\db\DAORegistry; use PKP\security\authorization\SubmissionAccessPolicy; @@ -48,6 +49,7 @@ abstract class PKPSubmissionHandler extends Handler { public const SECTION_TYPE_CONFIRM = 'confirm'; public const SECTION_TYPE_CONTRIBUTORS = 'contributors'; + public const SECTION_TYPE_REVIEWER_SUGGESTIONS = 'reviewerSuggestions'; public const SECTION_TYPE_FILES = 'files'; public const SECTION_TYPE_FORM = 'form'; public const SECTION_TYPE_TEMPLATE = 'template'; @@ -216,16 +218,21 @@ protected function showWizard(array $args, Request $request, Submission $submiss $reconfigureSubmissionForm = $this->getReconfigureForm($context, $submission, $publication, $sections, $categories); $steps = $this->getSteps($request, $submission, $publication, $formLocales, $sections, $categories); + $components = [ + $submissionFilesListPanel['id'] => $submissionFilesListPanel, + $contributorsListPanel->id => $contributorsListPanel->getConfig(), + $reconfigureSubmissionForm->id => $reconfigureSubmissionForm->getConfig(), + ]; - $templateMgr = TemplateManager::getManager($request); + if ($context->getData('reviewerSuggestionEnabled')) { + $reviewerSuggestionsListPanel = $this->getReviewerSuggestionsListPanel($request, $submission, $publication, $formLocales); + $components[$reviewerSuggestionsListPanel->id] = $reviewerSuggestionsListPanel->getConfig(); + } + $templateMgr = TemplateManager::getManager($request); $templateMgr->setState([ 'categories' => Repo::category()->getBreadcrumbs($categories), - 'components' => [ - $submissionFilesListPanel['id'] => $submissionFilesListPanel, - $contributorsListPanel->id => $contributorsListPanel->getConfig(), - $reconfigureSubmissionForm->id => $reconfigureSubmissionForm->getConfig(), - ], + 'components' => $components, 'i18nConfirmSubmit' => $this->getConfirmSubmitMessage($submission, $context), 'i18nDiscardChanges' => __('common.discardChanges'), 'i18nDisconnected' => __('common.disconnected'), @@ -312,6 +319,11 @@ protected function getSteps(Request $request, Submission $submission, Publicatio $steps[] = $this->getFilesStep($request, $submission, $publication, $locales, $publicationApiUrl); $steps[] = $this->getContributorsStep($request, $submission, $publication, $locales, $publicationApiUrl); $steps[] = $this->getEditorsStep($request, $submission, $publication, $locales, $publicationApiUrl, $categories); + + if ($request->getContext()->getData('reviewerSuggestionEnabled')) { + $steps[] = $this->getReviewerSuggestionsStep($request); + } + $steps[] = $this->getConfirmStep($request, $submission, $publication, $locales, $publicationApiUrl); return $steps; @@ -471,7 +483,12 @@ protected function getSubmissionFilesListPanel(Request $request, Submission $sub /** * Get an instance of the ContributorsListPanel component */ - protected function getContributorsListPanel(Request $request, Submission $submission, Publication $publication, array $locales): ContributorsListPanel + protected function getContributorsListPanel( + Request $request, + Submission $submission, + Publication $publication, + array $locales + ): ContributorsListPanel { return new ContributorsListPanel( 'contributors', @@ -484,6 +501,27 @@ protected function getContributorsListPanel(Request $request, Submission $submis ); } + /** + * Get an instance of the ContributorsListPanel component + */ + protected function getReviewerSuggestionsListPanel( + Request $request, + Submission $submission, + Publication $publication, + array $locales + ): ReviewerSuggestionsListPanel + { + return new ReviewerSuggestionsListPanel( + 'reviewerSuggestions', + __('submission.reviewerSuggestions'), + $submission, + $request->getContext(), + $locales, + [], // Populated by publication state + true + ); + } + /** * Get the user groups that a user can submit in */ @@ -534,7 +572,13 @@ protected function getFilesStep(Request $request, Submission $submission, Public /** * Get the state for the contributors step */ - protected function getContributorsStep(Request $request, Submission $submission, Publication $publication, array $locales, string $publicationApiUrl): array + protected function getContributorsStep( + Request $request, + Submission $submission, + Publication $publication, + array $locales, + string $publicationApiUrl + ): array { return [ 'id' => 'contributors', @@ -552,10 +596,39 @@ protected function getContributorsStep(Request $request, Submission $submission, ]; } + /** + * Get the state for the reviewer suggestion step + */ + protected function getReviewerSuggestionsStep(Request $request): array + { + return [ + 'id' => 'reviewerSuggestions', + 'name' => __('submission.reviewerSuggestions'), + 'reviewName' => __('submission.reviewerSuggestions'), + 'sections' => [ + [ + 'id' => 'reviewerSuggestions', + 'name' => __('submission.reviewerSuggestions'), + 'type' => self::SECTION_TYPE_REVIEWER_SUGGESTIONS, + 'description' => $request->getContext()->getLocalizedData('reviewerSuggestionsHelp'), + ], + ], + 'reviewTemplate' => '/submission/review-reviewer-suggestions.tpl', + ]; + } + /** * Get the state for the details step */ - protected function getDetailsStep(Request $request, Submission $submission, Publication $publication, array $locales, string $publicationApiUrl, array $sections, string $controlledVocabUrl): array + protected function getDetailsStep( + Request $request, + Submission $submission, + Publication $publication, + array $locales, + string $publicationApiUrl, + array $sections, + string $controlledVocabUrl + ): array { $titleAbstractForm = $this->getDetailsForm( $publicationApiUrl, @@ -608,7 +681,14 @@ protected function getDetailsStep(Request $request, Submission $submission, Publ * If no metadata is enabled during submission, the metadata * form is not shown. */ - protected function getEditorsStep(Request $request, Submission $submission, Publication $publication, array $locales, string $publicationApiUrl, LazyCollection $categories): array + protected function getEditorsStep( + Request $request, + Submission $submission, + Publication $publication, + array $locales, + string $publicationApiUrl, + LazyCollection $categories + ): array { $metadataForm = $this->getForTheEditorsForm( $publicationApiUrl, @@ -672,7 +752,13 @@ protected function getEditorsStep(Request $request, Submission $submission, Publ /** * Get the state for the Confirm step */ - protected function getConfirmStep(Request $request, Submission $submission, Publication $publication, array $locales, string $publicationApiUrl): array + protected function getConfirmStep( + Request $request, + Submission $submission, + Publication $publication, + array $locales, + string $publicationApiUrl + ): array { $sections = [ [ diff --git a/templates/submission/review-reviewer-suggestions.tpl b/templates/submission/review-reviewer-suggestions.tpl new file mode 100644 index 00000000000..618a1179874 --- /dev/null +++ b/templates/submission/review-reviewer-suggestions.tpl @@ -0,0 +1,69 @@ +{** + * templates/submission/review-contributors.tpl + * + * Copyright (c) 2014-2022 Simon Fraser University + * Copyright (c) 2003-2022 John Willinsky + * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. + * + * The template in the submission wizard when reviewing the contributors step. + *} + +

+
+

+ {$step.reviewName|escape} +

+ + {translate key="common.edit"} + +
+
+ + + {translate key="submission.wizard.noReviewerSuggestions"} + +
    +
  • + + + {{ error }} + +
  • +
  • + + {{ localize(reviewerSuggestion.fullName) }} + {{ reviewerSuggestion.email }} + + + {{ localize(reviewerSuggestion.affiliation) }} + +
  • + {call_hook name="Template::SubmissionWizard::Section::Review::ReviewerSuggestions" submission=$submission step=$step.id} +
+
+
\ No newline at end of file diff --git a/templates/submission/wizard.tpl b/templates/submission/wizard.tpl index b1f62365197..7f41f1ab026 100644 --- a/templates/submission/wizard.tpl +++ b/templates/submission/wizard.tpl @@ -88,6 +88,14 @@ @updated:contributors="setContributors" @updated:publication="setPublication" > +