diff --git a/data/test-data.js b/data/test-data.js index 6529b4a215..8cda8e4660 100644 --- a/data/test-data.js +++ b/data/test-data.js @@ -475,7 +475,7 @@ export function createSprinter() { ); } -export function sprinterHelper(numRevisions) { +export async function sprinterHelper(numRevisions) { const promiseList = []; for (let i = 0; i < numRevisions; i++) { promiseList.push( @@ -483,7 +483,8 @@ export function sprinterHelper(numRevisions) { .save(null, {method: 'insert'}) ); } - return Promise.all(promiseList); + const result = await Promise.all(promiseList); + return result; } export function createFunRunner() { diff --git a/package.json b/package.json index 60c4f79c8c..008fe8bd52 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@elastic/elasticsearch": "^5.6.22", "@fortawesome/fontawesome-svg-core": "^1.2.30", "@fortawesome/free-brands-svg-icons": "^6.1.1", - "@fortawesome/free-solid-svg-icons": "^6.1.1", + "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.1.11", "array-move": "^3.0.1", "bookbrainz-data": "4.0.0", @@ -85,7 +85,7 @@ "serve-favicon": "^2.4.3", "serve-static": "^1.14.1", "superagent": "^8.0.8", - "swagger-jsdoc": "^6.2.5", + "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^4.6.0", "validator": "^13.7.0" }, @@ -136,7 +136,7 @@ "nodemon": "^2.0.2", "redux-mock-store": "^1.5.4", "resolve-url-loader": "^5.0.0", - "rewire": "^5.0.0", + "rewire": "^7.0.0", "sass": "^1.59.2", "sass-loader": "^13.2.0", "sinon": "^14.0.0", diff --git a/sql/migrations/2023-05-15-work-types/up.sql b/sql/migrations/2023-05-15-work-types/up.sql new file mode 100644 index 0000000000..65e8cbc63a --- /dev/null +++ b/sql/migrations/2023-05-15-work-types/up.sql @@ -0,0 +1,180 @@ +-- Add new columns for hierarchy, description and deprecation + +BEGIN TRANSACTION; + +ALTER TABLE bookbrainz.work_type ADD COLUMN description TEXT; +ALTER TABLE bookbrainz.work_type ADD COLUMN parent_id INT; +ALTER TABLE bookbrainz.work_type ADD COLUMN child_order INT NOT NULL DEFAULT 0; +ALTER TABLE bookbrainz.work_type ADD COLUMN deprecated BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE bookbrainz.work_type ADD FOREIGN KEY (parent_id) REFERENCES bookbrainz.work_type (id); + +COMMIT; + + +-- Add new types and modify existing ones — see https://tickets.metabrainz.org/browse/BB-740 + +-- Top-level types first +BEGIN TRANSACTION; + + -- Non-fiction, id 8 + UPDATE bookbrainz.work_type + SET description='Prose work that is not fiction.', + parent_id=NULL, + child_order=1 + WHERE id=8; + + -- Poem, id 4 + UPDATE bookbrainz.work_type + SET description='A work of poetry; a non-prosaic composition that uses stylistic and rhythmic qualities of language to evoke meanings in addition to, or in place of, ostensible meaning. Poetry is very variable and particularly difficult to define; generally any work described as poetry should be considered of the poem work type.', + parent_id=NULL, + child_order=2 + WHERE id=4; + + INSERT INTO bookbrainz.work_type ("id","label","description","parent_id","child_order") + VALUES + (13,'Fiction','Literary work portraying individuals or events that are imaginary, though it may be based on a true story or situation.',NULL,0); + +COMMIT; + +-- Then second level types +BEGIN TRANSACTION; + + -- Play, id 5 + UPDATE bookbrainz.work_type + SET description='Work consisting mostly of dialogue and intended to be performed by actors.', + parent_id=13, + child_order=2 + WHERE id=5; + + -- Novel, id 1 + UPDATE bookbrainz.work_type + SET description='Prose narrative of considerable length and a certain complexity.', + parent_id=13, + child_order=1 + WHERE id=1; + + -- Epic, id 3 + UPDATE bookbrainz.work_type + SET description='Long narrative poem in which a heroic protagonist engages in an action of great mythic or historical significance.', + parent_id=4, + child_order=0 + WHERE id=3; + + -- Article, id 6 + UPDATE bookbrainz.work_type + SET label='Periodical article', + description='Article typically published in periodical publications, such as newspapers and magazines.', + parent_id=8, + child_order=6 + WHERE id=6; + + INSERT INTO bookbrainz.work_type ("id","label","description","parent_id","child_order") + VALUES + (14,'Short-form fiction','Prose narrative of limited complexity and length, too short to be considered a novel.',13,0), + (15,'Comics/manga/sequential art','Sequence of panels of images, usually including textual devices such as speech balloons, captions, and onomatopoeia to indicate dialogue, narration, and sound effects.',13,2), + (16,'Picture book story','A story, generally for young children, with many pictures and a simple narrative. Books consisting of such stories are called picture books.',13,3), + + (17,'Introductory text','Text that precedes the main work and offers some sort of introduction to it.',8,0), + (18,'Conclusion','Text placed after the main work and offering a conclusion to the book.',8,1), + (19,'Letter','Written message addressed to a person or organization. This work type should be used for real (not fictional) letters only. (Epistolary novels are novels and should not be split into individual letters.)',8,2), + (20,'Essay','Piece of writing in which the author develops their own argument on some subject.',8,3), + (21,'Speech','Address delivered to an audience (the written work being the text meant to read or the transcript of such as address).',8,4), + (22,'Scientific literature','Scholarly work containing firsthand reports of research, often reviewed by experts (primary literature), or synthesizing and condensing what is known on specific topics (secondary literature).',8,5), + (23,'Biographical literature','Work describing a real person’s life.',8,7), + (24,'Reference work','Informative work intended for consultation rather than consecutive reading.',8,8), + (25,'Legal instrument','A formal written legal document.',8,9), + (26,'Recipe','A set of instructions for making a dish of prepared food. Recipes are generally preceded by the list of necessary ingredients.',8,10), + + (27,'Sonnet','A 14-line poem with a variable rhyme scheme originating in Italy, and originally consisting of two quatrains and two tercets. Traditionally, a volta occurs between the eighth and ninth lines (or before the final couplet in the Shakespearean sonnet).',4,1), + (28,'Ballad','Short narrative poem in rhythmic verse suitable for singing, often in quatrains and rhyming the second and fourth lines.',4,2), + (29,'Haiku','Originally, a traditional short Japanese poetic form with a 5-7-5 phonetic units pattern, now adapted in different ways in other languages.',4,3), + (30,'Villanelle','Poetic composition consisting of nineteen lines: five tercets followed by a quatrain, with the first and third lines of the first stanza repeating alternately in the following stanzas, forming refrains, and as the final two lines of the final quatrain.',4,4); + +COMMIT; + + + +-- Finally, third level types +BEGIN TRANSACTION; + + -- Short Story + UPDATE bookbrainz.work_type + SET description='Prose narrative that is shorter than a novel or novella and that usually deals with only a few characters.', + parent_id=14, + child_order=0 + WHERE id=2; + -- Novella + UPDATE bookbrainz.work_type + SET description='Prose narrative whose length is shorter and less complex than most novels, but longer and more complex than most short stories.', + parent_id=14, + child_order=1 + WHERE id=12; + + -- Scientific Paper + UPDATE bookbrainz.work_type + SET description='Aliases: research paper, research article. Original full-length manuscript the results of scholarly research in a scientific discipline.', + parent_id=22, + child_order=0 + WHERE id=7; + + -- Introduction + UPDATE bookbrainz.work_type + SET description='Preliminary explanation preceding the main work.', + parent_id=17, + child_order=0 + WHERE id=11; + + INSERT INTO bookbrainz.work_type ("id","label","description","parent_id","child_order") + VALUES + (31,'Stage play','Work in prose or verse consisting mostly of dialogue and intended to be performed by actors on a stage.',5,0), + (32,'Screenplay','Text that provides the basis for a film production. Besides the dialogue spoken by the characters, screenplays usually also include a shot-by-shot outline of the film’s action.',5,1), + (33,'Comic strip','A series of comics panels designed in a narrative or chronological order.',15,0), + (34,'Yonkoma','Alias: 4-koma. Comic strip consisting of four panels of the same size arranged vertically.',15,1), + (35,'Comics story','Multiple-page work consisting of comics panels, usually in chronological order, that tells a story. Comics stories are typically published in comic books, which can contain multiple stories.',15,2), + (36,'Graphic novel','Long-form, generally book-length, comics story.',15,3), + (37,'Foreword','Preliminary text, generally written someone other than the author, introducing the work or the author.',17,0), + (38,'Preface','Introductory text, generally written by the author of the main work.',17,1), + (39,'Afterword','Text placed after the main work providing enriching comment, such as how the book came into being or the work’s historical or cultural context.',18,0), + (40,'Postface','Brief article or explanatory information placed at the end of a book.',18,1), + (41,'Epistle','Letter, generally didactic and elegant in style, often addressed to a group of people.',19,0), + (42,'Sermon','A religious discourse delivered by a preacher, generally based on a text of scripture and as part of a worship service.',21,0), + (43,'Opinion piece','Alias: op-ed. Article expressing the author’s opinion about a subject.',6,0), + (44,'Editorial','Article, often unsigned, expressing the opinion of the editors or publishers.',6,1), + (45,'News article','Article relating current or recent news.',6,2), + (46,'Review','Critical evaluation of an artistic work, performance, or product.',6,3), + (47,'Interview','The reproduction of a series of questions posed by a member of the press and the answers given by the person being interviewed.',6,4), + (48,'Biography','Work describing a real person’s life in detail.',23,0), + (49,'Autobiography','Biography written by the subject themselves.',23,1), + (50,'Memoir','Autobiographical work distinguished from autobiography by its narrow focus, generally retelling only a specific part of a person’s life.',23,2), + (51,'Diary','A record of events in one’s life, consisting of daily autobiographical entries. Although there are exceptions, diaries are generally written as personal records with no intention of publication, but notable diaries are sometimes published.',23,3), + (52,'Dictionary','Lists lexemes and their meanings in the same or in a different language. A dictionary may also provide additional information about the lexemes, such as their pronunciation, grammatical forms and functions, etymologies, and variant spellings.',24,0), + (53,'Encyclopedia','Aliases: encyclopædia, encyclopaedia. Work providing extensive information on all branches of knowledge arranged into articles or entries.',24,1), + (54,'Thesaurus','Work that arranges works according to their meaning, or simply lists their synonyms.',24,2), + (55,'Petrarchan sonnet','Alias: Italian sonnet. Original form of the sonnet with two quatrains and two tercets, which can be joined in an octave and a sextet.',27,0), + (56,'Shakespearean sonnet','Alias: English sonnet. English variant, with three quatrains followed by a final couplet. The three quatrains can be joined into a stanza.',27,1), + (57,'Blank verse','Poetic composition that does not rhyme but follows a regular meter. In English, this is almost always iambic pentameter.',28,0), + (58,'Limerick','Poetic composition consisting of five lines of chiefly anapestic verse, the third and fourth lines of two metrical feet and in the others of three feet, rhyming aabba. Limericks are often humorous, nonsensical, and sometimes lewd.',28,1), + (59,'Japanese haiku','Traditional Japanese poetic form consisting of three phrases composed of seventeen on (phonetic units) in a 5-7-5 pattern which include a kireji (“cutting word”), and a kigo (seasonal reference).',29,0), + (60,'Non-Japanese haiku','Adaptation of the haiku form into other languages, sometimes the 5-7-5 on pattern is interpreted as three lines of five, seven and five syllables, although this is not required. Short, concise and impressionistic wording is generally seen as an essential feature, and other haiku characteristics may be ignored or adapted in different ways.',29,1); + +COMMIT; + + +-- Deprecate existing types (Anthology and Serial) +BEGIN TRANSACTION; + + UPDATE bookbrainz.work_type + SET deprecated=true, description = 'deprecated' + WHERE id IN (9,10); + +COMMIT; + +-- finally, set not null constraint and check in the description column like we do for other text columns +-- We do not do this at the beginning because 'description' is a new column and all values will be NULL until we set them +BEGIN TRANSACTION; + ALTER TABLE bookbrainz.work_type + ALTER COLUMN description SET NOT NULL; + ALTER TABLE bookbrainz.work_type + ADD CONSTRAINT work_type_description_check CHECK (((description <> ''::text))); +COMMIT; \ No newline at end of file diff --git a/sql/schemas/bookbrainz.sql b/sql/schemas/bookbrainz.sql index e0fdbe4755..8eb9c2802f 100644 --- a/sql/schemas/bookbrainz.sql +++ b/sql/schemas/bookbrainz.sql @@ -407,8 +407,13 @@ ALTER TABLE bookbrainz.publisher_revision ADD FOREIGN KEY (data_id) REFERENCES b CREATE TABLE bookbrainz.work_type ( id SERIAL PRIMARY KEY, - label TEXT NOT NULL UNIQUE CHECK (label <> '') + label TEXT NOT NULL UNIQUE CHECK (label <> ''), + description TEXT NOT NULL CHECK (description <> ''), + parent_id INT, + child_order INT NOT NULL DEFAULT 0, + deprecated BOOLEAN NOT NULL DEFAULT FALSE ); +ALTER TABLE bookbrainz.work_type ADD FOREIGN KEY (parent_id) REFERENCES bookbrainz.work_type (id); CREATE TABLE bookbrainz.work_data ( id SERIAL PRIMARY KEY, diff --git a/src/client/components/forms/registration-details.js b/src/client/components/forms/registration-details.js index 9a512d310f..43b34da18e 100644 --- a/src/client/components/forms/registration-details.js +++ b/src/client/components/forms/registration-details.js @@ -46,10 +46,10 @@ class RegistrationForm extends React.Component { handleSubmit(event) { event.preventDefault(); - const gender = this.gender.select.getValue()[0].id; + const genderId = this.gender?.select?.getValue()?.[0]?.id; const data = { displayName: this.displayName.value, - gender: gender ? parseInt(gender, 10) : null + gender: genderId ? parseInt(genderId, 10) : null }; this.setState({ diff --git a/src/client/components/input/drag-and-drop-image.js b/src/client/components/input/drag-and-drop-image.js deleted file mode 100644 index 9cac1b6bdb..0000000000 --- a/src/client/components/input/drag-and-drop-image.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2016 Max Prettyjohns - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, write to the Free Software Foundation, Inc., - * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; - - -/** - * This class is derived from the React Component base class and renders - * an image which supports drag and drop functionality. - */ -class DragAndDropImage extends React.Component { - /** - * Binds the class methods to their respective data. - * @constructor - */ - constructor() { - super(); - this.handleDragStart = this.handleDragStart.bind(this); - } - - /** - * Transfers the data of the achievement badge component properties to the - * DragAndDrop event, which in turn transfers the data on handleDrop to that - * of the achievement badge which will be showcased on editor's - * public profile. - * @param {object} ev - Passed in the function to be initialized with data - * onDragStart. - */ - handleDragStart(ev) { - const data = { - id: this.props.achievementId, - name: this.props.achievementName, - src: this.props.src - }; - ev.dataTransfer.setData('text', JSON.stringify(data)); - } - - /** - * Renders an image of a particular achievement badge, which can be dragged - * to set the user's publicly showcased achievements - * @returns {ReactElement} - The rendered image element. - */ - render() { - return ( - - ); - } -} - -DragAndDropImage.displayName = 'DragAndDropImage'; -DragAndDropImage.propTypes = { - achievementId: PropTypes.number.isRequired, - achievementName: PropTypes.string.isRequired, - height: PropTypes.string.isRequired, - src: PropTypes.string.isRequired -}; - -export default DragAndDropImage; diff --git a/src/client/components/input/drag-and-drop-image.tsx b/src/client/components/input/drag-and-drop-image.tsx new file mode 100644 index 0000000000..d4a4a57fe4 --- /dev/null +++ b/src/client/components/input/drag-and-drop-image.tsx @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2016 Max Prettyjohns + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import PropTypes from 'prop-types'; +import React from 'react'; + +const {useCallback} = React; + +/** + * Props for DragAndDropImage component + * @typedef {Object} Props + * @property {number} achievementId - ID of the achievement + * @property {string} achievementName - Name of the achievement + * @property {string} height - Height of the image + * @property {string} src - Image source URL + */ + +type Props = { + achievementId: number; + achievementName: string; + height: string; + src: string; +}; + +/** + * The `DragAndDropImage` component renders an image of a particular achievement badge, which can be dragged to set the user's publicly showcased achievements. + * + * @param {Props} props - Props for the component + * + * @returns {JSX.Element} - The rendered image element. + */ + +function DragAndDropImage({achievementId, achievementName, height, src}: Props): JSX.Element { + /** + * Transfers the data of the achievement badge component properties to the DragEvent, which in turn transfers the data on handleDrop to that of the achievement badge which will be showcased on editor's public profile. + * @param {React.DragEvent} ev - The drag event object. + */ + const handleDragStart = useCallback((ev: React.DragEvent) => { + const data = { + id: achievementId, + name: achievementName, + src + }; + ev.dataTransfer.setData('text', JSON.stringify(data)); + }, [achievementId, achievementName, src]); + + return ( + + ); +} + +DragAndDropImage.displayName = 'DragAndDropImage'; +DragAndDropImage.propTypes = { + achievementId: PropTypes.number.isRequired, + achievementName: PropTypes.string.isRequired, + height: PropTypes.string.isRequired, + src: PropTypes.string.isRequired +}; + +export default DragAndDropImage; diff --git a/src/client/components/pages/entities/author.js b/src/client/components/pages/entities/author.js index d6fa6f5c94..6e801b318e 100644 --- a/src/client/components/pages/entities/author.js +++ b/src/client/components/pages/entities/author.js @@ -22,6 +22,7 @@ import React, {createRef, useCallback} from 'react'; import AverageRating from './average-ratings'; import CBReviewModal from './cbReviewModal'; +import EditionTable from './edition-table'; import EntityAnnotation from './annotation'; import EntityFooter from './footer'; import EntityImage from './image'; @@ -130,6 +131,12 @@ function AuthorDisplayPage({entity, identifierTypes, user, wikipediaExtract}) { }, [reviewsRef]); const urlPrefix = getEntityUrl(entity); + const editions = []; + if (entity?.authorCredits) { + entity.authorCredits.forEach((authorCredit) => { + editions.push(...authorCredit.editions); + }); + } return (
@@ -156,6 +163,7 @@ function AuthorDisplayPage({entity, identifierTypes, user, wikipediaExtract}) { + This entity does not appear in any public collection. -
Click the "Add to collection"  - button below to add it to an existing collection or create a new one. +
+ Click the "Add to collection" button below to add it to an existing collection or create a new one.

} diff --git a/src/client/components/pages/entities/series-table.js b/src/client/components/pages/entities/series-table.js index b55bbe32c6..16245222c7 100644 --- a/src/client/components/pages/entities/series-table.js +++ b/src/client/components/pages/entities/series-table.js @@ -79,7 +79,7 @@ function SeriesTable({series, showAddedAtColumn, showCheckboxes, selectedEntitie - + { diff --git a/src/client/components/pages/parts/collections-table.js b/src/client/components/pages/parts/collections-table.js index fef6d0f7e9..635dd175de 100644 --- a/src/client/components/pages/parts/collections-table.js +++ b/src/client/components/pages/parts/collections-table.js @@ -128,26 +128,26 @@ class CollectionsTable extends React.Component { > - - - - + + + + { showPrivacy ? - : null + : null } { showIfOwnerOrCollaborator ? - : null + : null } { showOwner ? - : null + : null } { showLastModified ? - : null + : null } diff --git a/src/client/components/pages/parts/revisions-table.js b/src/client/components/pages/parts/revisions-table.js index 7557046430..1b13f90a5d 100644 --- a/src/client/components/pages/parts/revisions-table.js +++ b/src/client/components/pages/parts/revisions-table.js @@ -45,20 +45,20 @@ function RevisionsTable(props) { > - + { showEntities ? - : null + : null } { showRevisionEditor ? - : null + : null } { showRevisionNote ? - : null + : null } - + diff --git a/src/client/components/pages/parts/search-results.js b/src/client/components/pages/parts/search-results.js index e4a37a157b..a35a12ee0c 100644 --- a/src/client/components/pages/parts/search-results.js +++ b/src/client/components/pages/parts/search-results.js @@ -232,9 +232,9 @@ class SearchResults extends React.Component { !this.props.condensed && - - - + + + } diff --git a/src/client/components/pages/search.tsx b/src/client/components/pages/search.tsx index 36dcc6148e..ca3a5eddd0 100644 --- a/src/client/components/pages/search.tsx +++ b/src/client/components/pages/search.tsx @@ -82,10 +82,13 @@ class SearchPage extends React.Component { }; this.paginationUrl = './search/search'; + this.pagerElementRef = React.createRef(); } paginationUrl: string; + pagerElementRef: React.RefObject; + /** * Gets user text query from the browser's URL search parameters and * sets it in the state to be passed down to SearchField and Pager components @@ -94,7 +97,13 @@ class SearchPage extends React.Component { * @param {string} type - Entity type selected from dropdown */ handleSearch = (query: string, type: string) => { - this.setState({query, type}); + if (query === this.state.query && type === this.state.type && this.pagerElementRef.current) { + // if no change in query or type, re-run the search + this.pagerElementRef.current.triggerSearch(); + } + else { + this.setState({query, type}); + } }; /** @@ -154,6 +163,7 @@ class SearchPage extends React.Component { nextEnabled={this.props.nextEnabled} paginationUrl={this.paginationUrl} querySearchParams={querySearchParams} + ref={this.pagerElementRef} results={results} searchParamsChangeCallback={this.searchParamsChangeCallback} searchResultsCallback={this.searchResultsCallback} diff --git a/src/client/components/pages/statistics.js b/src/client/components/pages/statistics.js index ed2dad85be..6e2857d43b 100644 --- a/src/client/components/pages/statistics.js +++ b/src/client/components/pages/statistics.js @@ -48,10 +48,10 @@ function TopEditorsTable(props) { > - - - - + + + + @@ -101,10 +101,10 @@ function EntityCountTable(props) { > - - - - + + + + diff --git a/src/client/entity-editor/author-credit-editor/actions.ts b/src/client/entity-editor/author-credit-editor/actions.ts index d4b86f8974..52e5eabf30 100644 --- a/src/client/entity-editor/author-credit-editor/actions.ts +++ b/src/client/entity-editor/author-credit-editor/actions.ts @@ -27,7 +27,9 @@ export const SHOW_AUTHOR_CREDIT_EDITOR = 'SHOW_AUTHOR_CREDIT_EDITOR'; export const HIDE_AUTHOR_CREDIT_EDITOR = 'HIDE_AUTHOR_CREDIT_EDITOR'; export const REMOVE_EMPTY_CREDIT_ROWS = 'REMOVE_EMPTY_CREDIT_ROWS'; export const UPDATE_AUTHOR_CREDIT = 'UPDATE_AUTHOR_CREDIT'; - +export const CLEAR_AUTHOR_CREDIT = 'CLEAR_AUTHOR_CREDIT'; +export const RESET_AUTHOR_CREDIT = 'RESET_AUTHOR_CREDIT'; +export const TOGGLE_AUTHOR_CREDIT = 'TOGGLE_AUTHOR_CREDIT'; export type Action = { type: string, @@ -193,3 +195,32 @@ export function updateAuthorCredit(authorCredit: AuthorCredit): Action { type: UPDATE_AUTHOR_CREDIT }; } + +/** + * @returns {Action} The resulting CLEAR_AUTHOR_CREDIT action. + */ +export function clearAuthorCredit(): Action { + return { + type: CLEAR_AUTHOR_CREDIT + }; +} + +/** + * @returns {Action} The resulting RESET_AUTHOR_CREDIT action. + */ +export function resetAuthorCredit(): Action { + return { + type: RESET_AUTHOR_CREDIT + }; +} + +/** + * Produces an action indicating that the AC checkbox should be toggled. + * + * @returns {Action} The resulting TOGGLE_AUTHOR_CREDIT action. + */ +export function toggleAuthorCredit(): Action { + return { + type: TOGGLE_AUTHOR_CREDIT + }; +} diff --git a/src/client/entity-editor/author-credit-editor/author-credit-section.tsx b/src/client/entity-editor/author-credit-editor/author-credit-section.tsx index b2dc176cca..b1a6fcca73 100644 --- a/src/client/entity-editor/author-credit-editor/author-credit-section.tsx +++ b/src/client/entity-editor/author-credit-editor/author-credit-section.tsx @@ -16,18 +16,21 @@ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ -import { - Action, +import {Action, AuthorCreditRow, addAuthorCreditRow, + clearAuthorCredit, hideAuthorCreditEditor, removeEmptyCreditRows, + resetAuthorCredit, showAuthorCreditEditor, - updateCreditAuthorValue -} from './actions'; -import {Button, Col, Form, InputGroup, OverlayTrigger, Row, Tooltip} from 'react-bootstrap'; + toggleAuthorCredit + , updateCreditAuthorValue} from './actions'; +import {Button, Col, Form, FormLabel, InputGroup, OverlayTrigger, Row, Tooltip} from 'react-bootstrap'; + import {get as _get, map as _map, values as _values, camelCase} from 'lodash'; -import {faPencilAlt, faQuestionCircle} from '@fortawesome/free-solid-svg-icons'; + +import {faInfoCircle, faPencilAlt, faQuestionCircle} from '@fortawesome/free-solid-svg-icons'; import AuthorCreditEditor from './author-credit-editor'; import type {Dispatch} from 'redux'; // eslint-disable-line import/named @@ -53,12 +56,14 @@ type OwnProps = { type StateProps = { authorCreditEditor: Record, + authorCreditEnable: boolean, showEditor: boolean, isEditable:boolean, }; type DispatchProps = { onAuthorChange: (Author) => unknown, + toggleAuthorCreditEnable: (newValue:boolean) => unknown, onClearHandler:(arg) => unknown, onEditAuthorCredit: (rowCount: number) => unknown, onEditorClose: () => unknown, @@ -68,7 +73,8 @@ type Props = OwnProps & StateProps & DispatchProps; function AuthorCreditSection({ authorCreditEditor: immutableAuthorCreditEditor, onEditAuthorCredit, onEditorClose, - showEditor, onAuthorChange, isEditable, onClearHandler, isUnifiedForm, isLeftAlign, ...rest + showEditor, onAuthorChange, isEditable, authorCreditEnable, toggleAuthorCreditEnable, + onClearHandler, isUnifiedForm, isLeftAlign, ...rest }: Props) { const authorCreditEditor = convertMapToObject(immutableAuthorCreditEditor); let editor; @@ -85,17 +91,17 @@ function AuthorCreditSection({ const authorCreditPreview = _map(authorCreditEditor, (credit) => `${credit.name}${credit.joinPhrase}`).join(''); const authorCreditRows = _values(authorCreditEditor); - const isValid = validateAuthorCreditSection(authorCreditRows); + const isValid = validateAuthorCreditSection(authorCreditRows, authorCreditEnable); const editButton = ( // eslint-disable-next-line react/jsx-no-bind - ); const label = ( - + Author Credit ); @@ -108,10 +114,33 @@ function AuthorCreditSection({ ); const optionValue = authorCreditPreview.length && {label: authorCreditPreview, value: authorCreditPreview}; const tooltip = ( - + Name(s) of the Author(s) as they appear on the book cover ); + const checkboxLabel = ( + <> + + This Edition doesn't have an Author + Select this checkbox if this Edition doesn't have an Author or + if you don't know the Author(s) + } + > + + + + + + ); + const onCheckChangeHandler = React.useCallback(() => { + toggleAuthorCreditEnable(!authorCreditEnable); + }, [authorCreditEnable]); let resCol:any = {md: {offset: 3, span: 6}}; if (isUnifiedForm || isLeftAlign) { resCol = {lg: {offset: 0, span: 6}}; @@ -155,8 +184,16 @@ function AuthorCreditSection({ /> {editButton} - + @@ -174,15 +211,22 @@ AuthorCreditSection.defaultProps = { isUnifiedForm: false }; function mapStateToProps(rootState, {type}): StateProps { - const firstRowKey = rootState.get('authorCreditEditor').keySeq().first(); - const authorCreditRow = rootState.getIn(['authorCreditEditor', firstRowKey]); - const isEditable = !(rootState.get('authorCreditEditor').size > 1) && - authorCreditRow.get('name') === authorCreditRow.getIn(['author', 'text'], ''); const entitySection = `${camelCase(type)}Section`; + const authorCreditEnable = rootState.getIn([entitySection, 'authorCreditEnable']) ?? true; + const authorCreditState = rootState.get('authorCreditEditor'); + const showEditor = rootState.getIn([entitySection, 'authorCreditEditorVisible']); + + const authorCreditRow = authorCreditState.first(); + const isEditable = Boolean(authorCreditEnable) && + authorCreditState.size <= 1 && + Boolean(authorCreditRow) && + authorCreditRow.get('name') === authorCreditRow.getIn(['author', 'text'], ''); + return { authorCreditEditor: rootState.get('authorCreditEditor'), + authorCreditEnable, isEditable, - showEditor: rootState.getIn([entitySection, 'authorCreditEditorVisible']) + showEditor }; } @@ -202,6 +246,15 @@ function mapDispatchToProps(dispatch: Dispatch): DispatchProps { onEditorClose: () => { dispatch(removeEmptyCreditRows()); dispatch(hideAuthorCreditEditor()); + }, + toggleAuthorCreditEnable: (newValue) => { + if (newValue) { + dispatch(resetAuthorCredit()); + } + else { + dispatch(clearAuthorCredit()); + } + dispatch(toggleAuthorCredit()); } }; } diff --git a/src/client/entity-editor/author-credit-editor/reducer.ts b/src/client/entity-editor/author-credit-editor/reducer.ts index 0e0b200c55..16785758c2 100644 --- a/src/client/entity-editor/author-credit-editor/reducer.ts +++ b/src/client/entity-editor/author-credit-editor/reducer.ts @@ -18,8 +18,10 @@ import { ADD_AUTHOR_CREDIT_ROW, + CLEAR_AUTHOR_CREDIT, REMOVE_AUTHOR_CREDIT_ROW, REMOVE_EMPTY_CREDIT_ROWS, + RESET_AUTHOR_CREDIT, UPDATE_CREDIT_AUTHOR_VALUE, UPDATE_CREDIT_DISPLAY_VALUE, UPDATE_CREDIT_JOIN_PHRASE_VALUE @@ -148,6 +150,10 @@ function reducer( return deleteAuthorCreditRow(state, payload); case REMOVE_EMPTY_CREDIT_ROWS: return deleteEmptyRows(state); + case CLEAR_AUTHOR_CREDIT: + return Immutable.OrderedMap({}); + case RESET_AUTHOR_CREDIT: + return Immutable.OrderedMap(initialState); // no default } return state; diff --git a/src/client/entity-editor/button-bar/actions.ts b/src/client/entity-editor/button-bar/actions.ts index d4ad49a452..fef8e7ed5c 100644 --- a/src/client/entity-editor/button-bar/actions.ts +++ b/src/client/entity-editor/button-bar/actions.ts @@ -53,3 +53,4 @@ export function showIdentifierEditor(): Action { type: SHOW_IDENTIFIER_EDITOR }; } + diff --git a/src/client/entity-editor/edition-group-section/reducer.ts b/src/client/entity-editor/edition-group-section/reducer.ts index 55e6604b0c..64c3184351 100644 --- a/src/client/entity-editor/edition-group-section/reducer.ts +++ b/src/client/entity-editor/edition-group-section/reducer.ts @@ -21,13 +21,14 @@ import * as Immutable from 'immutable'; import { Action, UPDATE_TYPE } from './actions'; -import {HIDE_AUTHOR_CREDIT_EDITOR, SHOW_AUTHOR_CREDIT_EDITOR} from '../author-credit-editor/actions'; +import {HIDE_AUTHOR_CREDIT_EDITOR, SHOW_AUTHOR_CREDIT_EDITOR, TOGGLE_AUTHOR_CREDIT} from '../author-credit-editor/actions'; type State = Immutable.Map; function reducer( state: State = Immutable.Map({ + authorCreditEnable: true, type: null }), action: Action @@ -40,7 +41,8 @@ function reducer( return state.set('authorCreditEditorVisible', true); case HIDE_AUTHOR_CREDIT_EDITOR: return state.set('authorCreditEditorVisible', false); - + case TOGGLE_AUTHOR_CREDIT: + return state.set('authorCreditEnable', !state.get('authorCreditEnable')); // no default } return state; diff --git a/src/client/entity-editor/edition-section/actions.ts b/src/client/entity-editor/edition-section/actions.ts index d330eb5373..4705a08093 100644 --- a/src/client/entity-editor/edition-section/actions.ts +++ b/src/client/entity-editor/edition-section/actions.ts @@ -54,6 +54,7 @@ export const UPDATE_DEPTH = 'UPDATE_DEPTH'; export const TOGGLE_SHOW_EDITION_GROUP = 'TOGGLE_SHOW_EDITION_GROUP'; export const UPDATE_WARN_IF_EDITION_GROUP_EXISTS = 'UPDATE_WARN_IF_EDITION_GROUP_EXISTS'; + /** * Produces an action indicating that the edition status for the edition being * edited should be updated with the provided value. diff --git a/src/client/entity-editor/edition-section/reducer.ts b/src/client/entity-editor/edition-section/reducer.ts index 94237d62cd..49878790d0 100644 --- a/src/client/entity-editor/edition-section/reducer.ts +++ b/src/client/entity-editor/edition-section/reducer.ts @@ -39,7 +39,7 @@ import { UPDATE_WEIGHT, UPDATE_WIDTH } from './actions'; -import {HIDE_AUTHOR_CREDIT_EDITOR, SHOW_AUTHOR_CREDIT_EDITOR} from '../author-credit-editor/actions'; +import {HIDE_AUTHOR_CREDIT_EDITOR, SHOW_AUTHOR_CREDIT_EDITOR, TOGGLE_AUTHOR_CREDIT} from '../author-credit-editor/actions'; type State = Immutable.Map; @@ -47,6 +47,7 @@ type State = Immutable.Map; function reducer( state: State = Immutable.Map({ authorCreditEditorVisible: false, + authorCreditEnable: true, format: null, languages: Immutable.List([]), matchingNameEditionGroups: [], @@ -98,6 +99,8 @@ function reducer( return state.set('matchingNameEditionGroups', []); } return state.set('matchingNameEditionGroups', payload); + case TOGGLE_AUTHOR_CREDIT: + return state.set('authorCreditEnable', !state.get('authorCreditEnable')); // no default } return state; diff --git a/src/client/entity-editor/validators/common.ts b/src/client/entity-editor/validators/common.ts index b16cd84c46..2e29499dc9 100644 --- a/src/client/entity-editor/validators/common.ts +++ b/src/client/entity-editor/validators/common.ts @@ -191,9 +191,9 @@ export function validateAuthorCreditRow(row: any): boolean { } export const validateAuthorCreditSection = _.partialRight( - // Requires at least one Author Credit row + // Requires at least one Author Credit row or zero in case of optional validateMultiple, _.partialRight.placeholder, - validateAuthorCreditRow, null, false + validateAuthorCreditRow, null, _.partialRight.placeholder ); // In the merge editor we use the authorCredit directly instead of the authorCreditEditor state export function validateAuthorCreditSectionMerge(authorCredit:AuthorCredit) :boolean { diff --git a/src/client/entity-editor/validators/edition-group.ts b/src/client/entity-editor/validators/edition-group.ts index 4b54983642..2132fa5a9e 100644 --- a/src/client/entity-editor/validators/edition-group.ts +++ b/src/client/entity-editor/validators/edition-group.ts @@ -17,6 +17,7 @@ */ +import {_IdentifierType, isIterable} from '../../../types'; import {get, validatePositiveInteger} from './base'; import { validateAliases, @@ -28,7 +29,6 @@ import { } from './common'; import _ from 'lodash'; -import type {_IdentifierType} from '../../../types'; export function validateEditionGroupSectionType(value: any): boolean { @@ -44,11 +44,17 @@ export function validateForm( isMerge?:boolean ): boolean { let validAuthorCredit; + const authorCreditEnable = isIterable(formData) ? formData.getIn(['editionGroupSection', 'authorCreditEnable'], true) : + get(formData, 'editionGroupSection.authorCreditEnable', true); if (isMerge) { validAuthorCredit = validateAuthorCreditSectionMerge(get(formData, 'authorCredit', {})); } + else if (!authorCreditEnable) { + validAuthorCredit = isIterable(formData) ? formData.get('authorCreditEditor')?.size === 0 : + _.size(get(formData, 'authorCreditEditor', {})) === 0; + } else { - validAuthorCredit = validateAuthorCreditSection(get(formData, 'authorCreditEditor', {})); + validAuthorCredit = validateAuthorCreditSection(get(formData, 'authorCreditEditor', {}), authorCreditEnable); } const conditions = [ validateAliases(get(formData, 'aliasEditor', {})), diff --git a/src/client/entity-editor/validators/edition.ts b/src/client/entity-editor/validators/edition.ts index 7e88e4ecd6..91e45af41d 100644 --- a/src/client/entity-editor/validators/edition.ts +++ b/src/client/entity-editor/validators/edition.ts @@ -17,6 +17,7 @@ */ +import {_IdentifierType, isIterable} from '../../../types'; import {get, validateDate, validatePositiveInteger, validateUUID} from './base'; import { validateAliases, @@ -29,7 +30,6 @@ import { import {Iterable} from 'immutable'; import _ from 'lodash'; -import type {_IdentifierType} from '../../../types'; import {convertMapToObject} from '../../helpers/utils'; @@ -134,11 +134,17 @@ export function validateForm( isMerge?:boolean ): boolean { let validAuthorCredit; + const authorCreditEnable = isIterable(formData) ? formData.getIn(['editionSection', 'authorCreditEnable'], true) : + get(formData, 'editionSection.authorCreditEnable', true); if (isMerge) { validAuthorCredit = validateAuthorCreditSectionMerge(get(formData, 'authorCredit', {})); } + else if (!authorCreditEnable) { + validAuthorCredit = isIterable(formData) ? formData.get('authorCreditEditor')?.size === 0 : + _.size(get(formData, 'authorCreditEditor', {})) === 0; + } else { - validAuthorCredit = validateAuthorCreditSection(get(formData, 'authorCreditEditor', {})); + validAuthorCredit = validateAuthorCreditSection(get(formData, 'authorCreditEditor', {}), authorCreditEnable); } const conditions = [ validateAliases(get(formData, 'aliasEditor', {})), diff --git a/src/client/entity-editor/work-section/work-section.tsx b/src/client/entity-editor/work-section/work-section.tsx index b57570de5c..666b1b2a20 100644 --- a/src/client/entity-editor/work-section/work-section.tsx +++ b/src/client/entity-editor/work-section/work-section.tsx @@ -26,13 +26,13 @@ import { } from './actions'; import {Col, Form, OverlayTrigger, Row, Tooltip} from 'react-bootstrap'; import type {List, Map} from 'immutable'; +import Select, {OptionProps, components} from 'react-select'; +import {faArrowTurnUp, faQuestionCircle} from '@fortawesome/free-solid-svg-icons'; import type {Dispatch} from 'redux'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import LanguageField from '../common/language-field'; -import Select from 'react-select'; import {connect} from 'react-redux'; -import {faQuestionCircle} from '@fortawesome/free-solid-svg-icons'; import makeImmutable from '../common/make-immutable'; @@ -40,7 +40,12 @@ const ImmutableLanguageField = makeImmutable(LanguageField); type WorkType = { label: string, - id: number + id: number, + description: string, + parentId: number, + childOrder: number, + deprecated: boolean, + depth?: number, // added for display }; type LanguageOption = { @@ -67,11 +72,58 @@ type StateProps = { type DispatchProps = { onLanguagesChange: (arg: Array) => unknown, - onTypeChange: (arg: {value: number} | null) => unknown + onTypeChange: (arg: WorkType | null) => unknown }; type Props = OwnProps & StateProps & DispatchProps; +function sortWorkTypes( + workTypes: Array, + parentId: number | null = null, + depth = 0 +) { + const sortedArray = []; + + // Filter the array to get all the items with the specified parentId + const children = workTypes.filter((item) => item.parentId === parentId); + + // Sort the children based on the childOrder property + children.sort((a, b) => a.childOrder - b.childOrder); + + // Apply the current depth so we can indent them in the select + children.forEach(type => type.depth = depth); + + // Recursively sort and append each child's descendants + for (const child of children) { + sortedArray.push(child); + sortedArray.push(...sortWorkTypes(workTypes, child.id, depth + 1)); + } + + return sortedArray; +} + +function workTypeSelectMenuOption(props: OptionProps) { + const {data, label} = props; + const {depth, id, description} = data; + let indentationClass; + if (depth > 0) { + indentationClass = `margin-left-d${10 * depth}`; + } + return ( + + {description}} + placement="bottom" + > +
+ {depth > 0 &&
}{label} +
+ + + ); +} + /** * Container component. The WorkSection component contains input fields * specific to the work entity. The intention is that this component is @@ -99,14 +151,12 @@ function WorkSection({ label: language.name, value: language.id })); + const validWorkTypes = workTypes.filter(type => !type.deprecated); + const workTypesForDisplay = sortWorkTypes(validWorkTypes); - const workTypesForDisplay = workTypes.map((type) => ({ - label: type.label, - value: type.id - })); - const typeOption = workTypesForDisplay.filter((el) => el.value === typeValue); + const selectedTypeOption:WorkType = workTypesForDisplay.find((el) => el.id === typeValue); const tooltip = ( - + Literary form or structure of the work ); @@ -137,11 +187,20 @@ function WorkSection({
NameName Series Type Ordering Type
NameDescriptionEntity TypeEntitiesNameDescriptionEntity TypeEntitiesPrivacyPrivacyCollaborator/OwnerCollaborator/OwnerOwnerOwnerLast ModifiedLast Modified
Revision IDRevision IDModified entitiesModified entitiesUserUserNoteNoteDateDate
TypeNameAliasesTypeNameAliases
#Editor's NameTotal RevisionsRegistration Date#Editor's NameTotal RevisionsRegistration Date
#Entity TypeTotalAdded in Last 30 days#Entity TypeTotalAdded in Last 30 days