From 434c7c7b77bea81907833be6d341265ad5106266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Tamargo?= Date: Wed, 7 Jul 2021 23:34:51 +0300 Subject: [PATCH] MBS-11778: Convert edit header to React --- lib/MusicBrainz/Server/Edit.pm | 28 +-- root/components/common-macros.tt | 11 +- root/edit/components/EditHeader.js | 227 +++++++++++++++++++ root/edit/components/VoteTally.js | 43 ++++ root/edit/edit_header.tt | 90 -------- root/edit/index.tt | 13 +- root/edit/list.tt | 10 +- root/server/components.js | 2 +- root/static/scripts/common/linkedEntities.js | 4 + root/types/edit.js | 2 + root/utility/edit.js | 15 ++ 11 files changed, 314 insertions(+), 131 deletions(-) create mode 100644 root/edit/components/EditHeader.js create mode 100644 root/edit/components/VoteTally.js delete mode 100644 root/edit/edit_header.tt diff --git a/lib/MusicBrainz/Server/Edit.pm b/lib/MusicBrainz/Server/Edit.pm index 7c19e782b27..c372f1e16d4 100644 --- a/lib/MusicBrainz/Server/Edit.pm +++ b/lib/MusicBrainz/Server/Edit.pm @@ -7,7 +7,7 @@ use MusicBrainz::Server::Data::Utils qw( boolean_to_json datetime_to_iso8601 ); use MusicBrainz::Server::Edit::Exceptions; use MusicBrainz::Server::Edit::Utils qw( edit_status_name ); use MusicBrainz::Server::Entity::Types; -use MusicBrainz::Server::Entity::Util::JSON qw( to_json_array ); +use MusicBrainz::Server::Entity::Util::JSON qw( add_linked_entity to_json_array ); use MusicBrainz::Server::Constants qw( :edit_status :expire_action @@ -139,19 +139,6 @@ sub no_votes { scalar shift->_grep_votes(sub { $_->vote == $VOTE_NO && !$_->superseded }); } -sub votes_for_editor -{ - my ($self, $editor_id) = @_; - $self->_grep_votes(sub { $_->editor_id == $editor_id }); -} - -sub latest_vote_for_editor -{ - my ($self, $editor_id) = @_; - my @votes = $self->votes_for_editor($editor_id) or return; - return $votes[-1]; -} - sub is_open { return shift->status == $STATUS_OPEN; @@ -233,15 +220,6 @@ sub editor_may_cancel { && $self->editor_id == $editor->id; } -sub was_approved -{ - my $self = shift; - - return 0 if $self->is_open; - - return scalar $self->_grep_votes(sub { $_->vote == $VOTE_APPROVE }) -} - sub approval_requires_comment { my ($self, $editor) = @_; @@ -319,9 +297,12 @@ sub initialize sub TO_JSON { my ($self) = @_; + add_linked_entity('editor', $self->editor_id, $self->editor); + my $can_preview = $self->does('MusicBrainz::Server::Edit::Role::Preview'); my $conditions = $self->edit_conditions; return { + auto_edit => boolean_to_json($self->auto_edit), close_time => datetime_to_iso8601($self->close_time), conditions => { duration => $conditions->{duration} + 0, @@ -333,6 +314,7 @@ sub TO_JSON { display_data => $self->display_data, data => $self->data, edit_kind => $self->edit_kind, + edit_name => $self->edit_name, edit_notes => to_json_array($self->edit_notes), edit_type => $self->edit_type + 0, editor_id => $self->editor_id + 0, diff --git a/root/components/common-macros.tt b/root/components/common-macros.tt index 5c6ffcb0277..9482cea8b62 100644 --- a/root/components/common-macros.tt +++ b/root/components/common-macros.tt @@ -494,11 +494,6 @@ END -%] INCLUDE _link_other_entity content=text action=action type='user' default_content=editor.name avatar=image_url image_size=size; END -%] -[%~ MACRO editor_type_info(editor) BLOCK; -%] - [% IF editor.is_limited; '(' _ l('beginner') _ ')'; END %] - [% IF editor.is_bot; '(' _ l('bot') _ ')'; END %] -[%- END -%] - [%~ MACRO link_edit(edit, action, text) BLOCK; # Converted to React at root/static/scripts/common/components/EditLink.js INCLUDE _link_other_entity content=text action=action type='edit' default_content=edit.id; END -%] @@ -611,7 +606,7 @@ END -%] CASE 2; l('High'); END -%] -[%~ MACRO edit_status_class(edit) BLOCK ~%] +[%~ MACRO edit_status_class(edit) BLOCK # Converted to React as getEditStatusClass at root/utility/edit.js ~%] [%~ IF edit.status == 1 ~%] [%- " open" -%] [%~ ELSIF edit.status == 2 ~%] @@ -625,7 +620,7 @@ END -%] [%~ END ~%] [%~ END ~%] -[%~ MACRO vote_tally(edit) BLOCK -%] +[%~ MACRO vote_tally(edit) BLOCK # Converted to React at root/edit/components/VoteTally.js -%] [%- IF edit.auto_edit; '' _ l('automatically applied') _ ''; ELSE; l('{yes} yes : {no} no', { yes => '' _ edit.yes_votes _ '', @@ -633,7 +628,7 @@ END -%] END -%] [%- END -%] -[%~ MACRO css_class_name(name) BLOCK; +[%~ MACRO css_class_name(name) BLOCK; # React equivalent is kebabCase at root/static/scripts/common/utility/strings.js name | lower | replace('[^a-z]+', '-') | remove('^-|-$'); END -%] diff --git a/root/edit/components/EditHeader.js b/root/edit/components/EditHeader.js new file mode 100644 index 00000000000..da135784c97 --- /dev/null +++ b/root/edit/components/EditHeader.js @@ -0,0 +1,227 @@ +/* + * @flow strict-local + * Copyright (C) 2021 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; + +import {EDIT_VOTE_APPROVE} from '../../constants'; +import RequestLogin from '../../components/RequestLogin'; +import VotingPeriod from '../../components/VotingPeriod'; +import linkedEntities from '../../static/scripts/common/linkedEntities'; +import EditLink from '../../static/scripts/common/components/EditLink'; +import EditorLink from '../../static/scripts/common/components/EditorLink'; +import bracketed from '../../static/scripts/common/utility/bracketed'; +import {isBot} from '../../static/scripts/common/utility/privileges'; +import {kebabCase} from '../../static/scripts/common/utility/strings'; +import getVoteName from '../../static/scripts/edit/utility/getVoteName'; +import { + editorMayApprove, + editorMayCancel, + getEditStatusClass, + getLatestVoteForEditor, +} from '../../utility/edit'; +import formatUserDate from '../../utility/formatUserDate'; +import {returnToCurrentPage} from '../../utility/returnUri'; + +import VoteTally from './VoteTally'; + +type Props = { + +$c: CatalystContextT, + +edit: {...EditT, +id: number}, + +isSummary?: boolean, + +voter?: UnsanitizedEditorT, +}; + +const EditorTypeInfo = ({editor}: {editor: EditorT}) => ( + editor.is_limited ? ( + + {bracketed( + + {l('beginner')} + , + )} + + ) : isBot(editor) ? ( + + {bracketed( + + {l('bot')} + , + )} + + ) : null +); + +const EditHeader = ({ + $c, + edit, + isSummary = false, + voter, +}: Props): React.Element<'div'> => { + const user = $c.user; + const mayApprove = editorMayApprove(edit, user); + const mayCancel = editorMayCancel(edit, user); + const editTitle = texp.l( + 'Edit #{id} - {name}', + {id: edit.id, name: l(edit.edit_name)}, + ); + const editEditor = linkedEntities.editor[edit.editor_id]; + const isEditEditor = user ? user.id === edit.editor_id : false; + const isVoter = user && voter && user.id === voter.id; + const latestVoteForEditor = user + ? getLatestVoteForEditor(edit, user) + : null; + const latestVoteForEditorName = latestVoteForEditor + ? getVoteName(latestVoteForEditor.vote) + : null; + const latestVoteForVoter = voter + ? getLatestVoteForEditor(edit, voter) + : null; + const latestVoteForVoterName = latestVoteForVoter + ? getVoteName(latestVoteForVoter.vote) + : null; + const editWasApproved = !edit.is_open && edit.votes.some( + (vote) => vote.vote === EDIT_VOTE_APPROVE, + ); + const showVoteTally = latestVoteForEditor || isEditEditor || !edit.is_open; + + return ( +
+ {isSummary ? ( + <> +
+ + + + + + + + +
+ {voter && isVoter === false ? ( +
+ {l('Their vote: ')} + {nonEmpty(latestVoteForVoterName) + ? lp(latestVoteForVoterName, 'vote') + : null} +
+ ) : user ? ( +
+ {l('My vote: ')} + {nonEmpty(latestVoteForEditorName) ? ( + lp(latestVoteForEditorName, 'vote') + ) : isEditEditor ? ( + l('N/A') + ) : l('None')} +
+ ) : null} +
+ {showVoteTally ? ( +
+ {user ? null : ( + <> + {addColon(l('Vote tally'))} + {' '} + + )} + +
+ ) : null} +
+ {edit.is_open ? ( + <> + {addColon(l('Voting'))} + {' '} + + + ) : editWasApproved ? ( + <> + {addColon(l('Approved'))} + {' '} + {formatUserDate($c, edit.close_time)} + + ) : ( + <> + {addColon(l('Closed'))} + {' '} + {formatUserDate($c, edit.close_time)} + + )} +
+
+

+ +

+ + ) : ( + <> + {user && (mayApprove || mayCancel) ? ( +
+ {mayApprove ? ( + + {l('Approve edit')} + + ) : null} + {mayCancel ? ( + + {l('Cancel edit')} + + ) : null} +
+ ) : null} +

{editTitle}

+ + )} + +

+ {'~'} + {user ? ( + <> + {exp.l( + 'Edit by {editor}', + {editor: }, + )} + + + ) : ( + <> + {l('Editor hidden')} + {' '} + {/* Show editor type since knowing it's, say, a bot is useful */} + + {' '} + {bracketed( + , + )} + + )} +

+
+ ); +}; + +export default EditHeader; diff --git a/root/edit/components/VoteTally.js b/root/edit/components/VoteTally.js new file mode 100644 index 00000000000..d3197651c7b --- /dev/null +++ b/root/edit/components/VoteTally.js @@ -0,0 +1,43 @@ +/* + * @flow strict + * Copyright (C) 2021 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import { + EDIT_VOTE_YES, + EDIT_VOTE_NO, +} from '../../constants'; + +function countVotes(votes, voteValue): number { + return votes.reduce( + (count, vote) => { + return count + ((vote.vote === voteValue && !vote.superseded) ? 1 : 0); + }, + 0, + ); +} + +const VoteTally = ({edit}: {edit: EditT}): Expand2ReactOutput => { + if (edit.auto_edit) { + return {l('automatically applied')}; + } + + const yesVotes = countVotes(edit.votes, EDIT_VOTE_YES); + const noVotes = countVotes(edit.votes, EDIT_VOTE_NO); + + return ( + exp.l( + '{yes} yes : {no} no', + { + no: {noVotes}, + yes: {yesVotes}, + }, + ) + ); +}; + +export default VoteTally; diff --git a/root/edit/edit_header.tt b/root/edit/edit_header.tt deleted file mode 100644 index d6edb17d095..00000000000 --- a/root/edit/edit_header.tt +++ /dev/null @@ -1,90 +0,0 @@ -
- [%~ IF summary ~%] -
- - - - - - - - -
- [%~ IF voter && voter.id != c.user.id ~%] -
- [%- l('Their vote: ') -%] - [%- lp(edit.latest_vote_for_editor(voter.id).vote_name, 'vote') ~%] -
- [%~ ELSIF c.user.id ~%] - [%~ IF edit.latest_vote_for_editor(c.user.id) ~%] -
- [%- l('My vote: ') -%] - [%- lp(edit.latest_vote_for_editor(c.user.id).vote_name, 'vote') ~%] -
- [%~ ELSIF c.user.id == edit.editor_id ~%] -
- [%- l('My vote: ') -%] - [%- l('N/A') ~%] -
- [%~ ELSIF !edit.latest_vote_for_editor(c.user.id) ~%] -
- [%- l('My vote: ') -%] - [%- l('None') ~%] -
- [%~ END ~%] - [%~ END ~%] -
- [%~ IF c.user.id && edit.latest_vote_for_editor(c.user.id) || c.user.id == edit.editor_id || !edit.is_open ~%] -
- [%~ IF !c.user_exists ~%] - [%- add_colon(l('Vote tally')) _ ' ' -%] - [%~ END ~%] - [%- vote_tally(edit) -%] -
- [%~ END ~%] -
- [%~ IF edit.status == 1 ~%] - [%~ add_colon(l('Voting')) _ ' ' ~%] - [%~ React.embed(c, 'components/VotingPeriod', {closingDate => edit.expires_time _ 'Z'}) ~%] - [%~ ELSIF !edit.is_open && edit.was_approved ~%] - [%~ l('Approved: {date}', { date => html_escape(UserDate.format(edit.close_time)) }) ~%] - [%~ ELSIF !edit.is_open ~%] - [%~ l('Closed: {date}', { date => html_escape(UserDate.format(edit.close_time)) }) ~%] - [%~ END ~%] -
-
-

- [%~ link_edit(edit, show, l('Edit #{id} - {name}', { id => edit.id, name => edit.l_edit_name})) ~%] -

- [%~ ELSE ~%] - [%~ IF c.user_exists && (edit.editor_may_approve(c.user) || edit.editor_may_cancel(c.user)) ~%] -
- [%~ IF edit.editor_may_approve(c.user) ~%] - [% l('Approve edit') %] - [%~ END =%] - - [%= IF edit.editor_may_cancel(c.user) ~%] - [% l('Cancel edit') %] - [%~ END ~%] -
- [% END %] -

- [%- l('Edit #{id} - {name}', { id => edit.id, name => html_escape(edit.l_edit_name) } ) -%] -

- [%~ END ~%] - -

- ~ - [%~ IF c.user_exists ~%] - [% l('Edit by {editor}', - { editor => link_entity(edit.editor) }) %] - [%- editor_type_info(edit.editor) -%] - [%~ ELSE ~%] - [% # We show editor type since knowing if it's, say, a bot edit is useful %] - [% l('Editor hidden') ~%] - [%- editor_type_info(edit.editor) ~%] - [%- bracketedWithSpace(request_login(l('log in to see who'))) -%] - [%~ END ~%] -

-
diff --git a/root/edit/index.tt b/root/edit/index.tt index cfdd8e4089b..9fe1601911b 100644 --- a/root/edit/index.tt +++ b/root/edit/index.tt @@ -1,8 +1,12 @@ [%- WRAPPER 'layout.tt' title=l('Edit #{id}', { id => edit.id }) -%]
- [% INCLUDE 'edit/edit_header.tt' %] [%- edit_json_obj = React.to_json_object(edit) -%] + [%- edit_json_obj_no_data = {} -%] + [%- edit_json_obj_no_data.import(edit_json_obj) -%] + [%- edit_json_obj_no_data.delete('display_data') -%] + + [%~ React.embed(c, "edit/components/EditHeader.js", {edit => edit_json_obj_no_data}) ~%]

[% l('Changes') %]

[% IF edit.data.defined %] @@ -11,7 +15,6 @@ [% ELSE %] [%-INCLUDE "edit/details/${edit.edit_template}.tt" %] [% END %] - [%- edit_json_obj.delete('display_data') -%] [% ELSE %]

[% l('An error occurred while loading this edit.') %]

[% link_edit(edit, 'data', l('Raw edit data may be available.')) %]

@@ -32,7 +35,7 @@ [% l('My vote:') %] - [% React.embed(c, 'edit/components/Vote', {edit => edit_json_obj}) %] + [% React.embed(c, 'edit/components/Vote', {edit => edit_json_obj_no_data}) %] [% END %] @@ -75,7 +78,7 @@

[% l('Edit notes') %]

[%~ IF c.user_exists ~%] - [%- React.embed(c, 'edit/components/EditNotes', {edit => edit_json_obj, index => 0, isOnEditPage => 1}) -%] + [%- React.embed(c, 'edit/components/EditNotes', {edit => edit_json_obj_no_data, index => 0, isOnEditPage => 1}) -%] [%- IF edit.editor_may_vote(c.user) -%] [%- form_submit(l('Submit vote and note')) -%] [%- ELSIF edit.editor_may_add_note(c.user) -%] @@ -92,6 +95,6 @@
[%- IF !full_width -%] - [%- React.embed(c, 'edit/components/EditSidebar', {edit => edit_json_obj}) -%] + [%- React.embed(c, 'edit/components/EditSidebar', {edit => edit_json_obj_no_data}) -%] [%- END -%] [%- END -%] diff --git a/root/edit/list.tt b/root/edit/list.tt index 0cf36d90bd7..03a076f030c 100644 --- a/root/edit/list.tt +++ b/root/edit/list.tt @@ -46,16 +46,18 @@
[%~ FOREACH edit=edits ~%]
- [%~ INCLUDE 'edit/edit_header.tt' summary=1 -%] - - - [%- edit_json_obj = React.to_json_object(edit); rendered_react_template = '' -%] + [%- IF edit.data.defined AND edit.edit_template_react; rendered_react_template = React.embed(c, "edit/details/${edit.edit_template_react}.js", {edit => edit_json_obj}); END; edit_json_obj.delete('display_data') -%] + + [%~ React.embed(c, "edit/components/EditHeader.js", {edit => edit_json_obj, isSummary => 1, voter = React.to_json_object(voter)}) ~%] + + +
[%- React.embed(c, 'edit/components/EditSummary', {edit => edit_json_obj, index => loop.index}) -%]
diff --git a/root/server/components.js b/root/server/components.js index ab314c9148c..50c7e401672 100644 --- a/root/server/components.js +++ b/root/server/components.js @@ -317,11 +317,11 @@ module.exports = { 'components/Relationships': require('../components/Relationships'), 'components/RelationshipsTable': require('../components/RelationshipsTable'), 'components/UserAccountTabs': require('../components/UserAccountTabs'), - 'components/VotingPeriod': require('../components/VotingPeriod'), 'edit/CannotApproveEdit': require('../edit/CannotApproveEdit'), 'edit/CannotCancelEdit': require('../edit/CannotCancelEdit'), 'edit/CannotVote': require('../edit/CannotVote'), 'edit/NoteIsRequired': require('../edit/NoteIsRequired'), + 'edit/components/EditHeader': require('../edit/components/EditHeader'), 'edit/components/EditNote': require('../edit/components/EditNote'), 'edit/components/EditNotes': require('../edit/components/EditNotes'), 'edit/components/EditSidebar': require('../edit/components/EditSidebar'), diff --git a/root/static/scripts/common/linkedEntities.js b/root/static/scripts/common/linkedEntities.js index 8274d71fa41..ee18321d988 100644 --- a/root/static/scripts/common/linkedEntities.js +++ b/root/static/scripts/common/linkedEntities.js @@ -23,6 +23,9 @@ export type LinkedEntitiesT = { artist_type: { [artistId: number]: ArtistTypeT, }, + editor: { + [editorId: number]: EditorT, + }, event: { [eventId: number]: EventT, }, @@ -101,6 +104,7 @@ const EMPTY_OBJECT = Object.freeze({}); const linkedEntities/*: LinkedEntitiesT */ = Object.create(Object.seal({ artist_type: EMPTY_OBJECT, + editor: EMPTY_OBJECT, language: EMPTY_OBJECT, link_attribute_type: EMPTY_OBJECT, link_type: EMPTY_OBJECT, diff --git a/root/types/edit.js b/root/types/edit.js index 6f415649940..7f314d4d11c 100644 --- a/root/types/edit.js +++ b/root/types/edit.js @@ -29,6 +29,7 @@ declare type EditStatusT = // MusicBrainz::Server::Edit::TO_JSON declare type EditT = { + +auto_edit: boolean, +close_time: string, +conditions: { +auto_edit: boolean, @@ -39,6 +40,7 @@ declare type EditT = { +created_time: string, +data: {+[dataProp: string]: any, ...}, +edit_kind: 'add' | 'edit' | 'remove' | 'merge' | 'other', + +edit_name: string, +edit_notes: $ReadOnlyArray, +edit_type: number, +editor_id: number, diff --git a/root/utility/edit.js b/root/utility/edit.js index b2bb91b78e9..b1054b367c9 100644 --- a/root/utility/edit.js +++ b/root/utility/edit.js @@ -91,6 +91,21 @@ export function getEditStatusDescription(edit: EditT): string { } } +export function getEditStatusClass(edit: EditT): string { + switch (edit.status) { + case EDIT_STATUS_OPEN: + return 'open'; + case EDIT_STATUS_APPLIED: + return 'applied'; + case EDIT_STATUS_FAILEDVOTE: + return 'failed'; + case EDIT_STATUS_DELETED: + return 'cancelled'; + default: + return 'edit-error'; + } +} + export function getVotesForEditor( edit: EditT, editor: UnsanitizedEditorT,