diff --git a/.gitignore b/.gitignore index 79222102..d4644475 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ build # misc .DS_Store +yarn.lock diff --git a/features/assign_a_topic_to_every_selected_item.feature b/features/assign_a_topic_to_every_selected_item.feature new file mode 100644 index 00000000..38fb92d4 --- /dev/null +++ b/features/assign_a_topic_to_every_selected_item.feature @@ -0,0 +1,30 @@ +#language: fr + +Fonctionnalité: Ajouter une rubrique à tous les items séléctionnés + +Contexte: + Soit le point de vue "Histoire de l'art" rattaché au portfolio "vitraux" + Soit le point de vue "Histoire des religions" rattaché au portfolio "vitraux" + + Soit le corpus "Vitraux - Bénel" rattaché au portfolio "vitraux" + Soit le corpus "Vitraux - Recensement" rattaché au portfolio "vitraux" + Soit le corpus "Vitraux - Dr. Krieger" rattaché au portfolio "vitraux" + + Soit la rubrique "XIXe s." rattachée au point de vue "Histoire de l'art" + Soit la rubrique "Technique du verre" rattachée au point de vue "Histoire de l'art" + + Soit l'item "DSN 000" rattaché à la rubrique "XIXe s." + Soit l'item "DSN 001" rattaché à la rubrique "XIXe s." + Soit l'item "DSN 002" rattaché à la rubrique "XIXe s." + Soit l'item "DSN 004" rattaché à la rubrique "XIXe s." + Soit l'item "DSN 005" rattaché à la rubrique "XIXe s." + +Scénario: + Soit "vitraux" le portfolio ouvert + Et l'utilisateur est connecté + Et "Datation" une des rubriques développées + Et la rubrique "XIXe s." sélectionnée + Et le mode sélection activé + Quand on attribue la rubrique "Technique du verre" aux items "DSN 000", "DSN 001", "DSN 002", "DSN 004", et "DSN 005" + Alors les items "DSN 000", "DSN 001", "DSN 002", "DSN 004" et "DSN 005" ont la rubrique "Technique du verre" + diff --git a/features/step_definitions/portfolio.rb b/features/step_definitions/portfolio.rb index ea92eea7..6de14dbd 100644 --- a/features/step_definitions/portfolio.rb +++ b/features/step_definitions/portfolio.rb @@ -60,7 +60,7 @@ def getUUID(itemName) end Soit("{string} le portfolio ouvert") do |portfolio| - visit "/" + visit "/" + portfolio end Soit("{string} une des rubriques développées") do |topic| @@ -92,6 +92,22 @@ def getUUID(itemName) visit "/" end +Soit("la rubrique {string} sélectionnée") do |topic| + find_link(topic).click +end + +Soit("l'utilisateur est connecté") do + find_link(href: '#login').click + fill_in("nom d'utilisateur", with: "alice") + fill_in("mot de passe", with: "whiterabbit") + click_on('Se connecter') +end + +# For scenatio with multiple selection +Soit("le mode sélection activé") do + click_on('Attribuer Topic') +end + # Events Quand("un visiteur ouvre la page d'accueil du site") do @@ -110,6 +126,39 @@ def getUUID(itemName) click_on item end +Quand("on ajoute un attribut de recherche {string} avec pour valeur {string}") do |attribut, valeur| +fill_in('Attribut1', with: attribut) +fill_in('Valeur1', with: valeur) +click_button('Rechercher') +end + +Quand("on ajoute un attribut de recherche {string} avec pour valeur {string} et un attribut de recherche {string} avec pour valeur {string}") do |attribut1, attribut2, valeur1, valeur2| +fill_in('Attribut1', with: attribut1) +fill_in('Valeur1', with: valeur1) +find_button(class: ['btn', 'btn-light', 'creationButton']).click +fill_in('Attribut2', with: attribut2) +fill_in('Valeur2', with: valeur2) +click_button('Rechercher') +end + +Quand("on attribue la rubrique {string} aux items {string}, {string}, {string}, {string}, et {string}") do |topic, item1, item2, item3, item4, item5| + + click_on(item1) + click_on(item2) + click_on(item3) + click_on(item4) + click_on(item5) + find('.TopicGroupAddInput').send_keys [topic, :down, :enter] + find('.TopicGroupAddButton').click + click_button('Confirmer') + click_button('Fermer') + click_button('Annuler') +end + +Quand("on met {string} dans la barre de recherche") do |value| + fill_in('Rechercher...', with: value).native.send_keys(:return) +end + # Outcomes Alors("le titre affiché est {string}") do |portfolio| @@ -140,3 +189,22 @@ def getUUID(itemName) expect(page).not_to have_content item end + +Alors("les dessins {string} et {string} sont parmi les dessins affichés") do |valeur1, valeur2| +expect(page).to have_content(valeur1) +expect(page).to have_content(valeur2) +end + + +Alors("les items {string}, {string}, {string}, {string} et {string} ont la rubrique {string}") do |item1, item2, item3, item4, item5, topic| + click_on(topic) + expect(page).to have_content item1 + expect(page).to have_content item2 + expect(page).to have_content item3 + expect(page).to have_content item4 + expect(page).to have_content item5 +end + +Alors("il doit y avoir au moins {int} items dans la rubrique {string}") do |int, string| + pending # Write code here that turns the phrase above into concrete actions +end diff --git a/src/components/Corpora/Corpora.jsx b/src/components/Corpora/Corpora.jsx index ef7f91c3..95cd1c8a 100644 --- a/src/components/Corpora/Corpora.jsx +++ b/src/components/Corpora/Corpora.jsx @@ -1,6 +1,12 @@ import React, { Component } from 'react'; import { Link } from 'react-router-dom'; +import Autosuggest from 'react-autosuggest'; import getConfig from '../../config/config.js'; +import Hypertopic from 'hypertopic'; +import conf from '../../config/config.json'; + + +let hypertopic = new Hypertopic(conf.services); // Get the configured list display mode let listView = getConfig('listView', { @@ -10,18 +16,104 @@ let listView = getConfig('listView', { }); class Corpora extends Component { + + constructor(props) { + super(props); + this.state = { + selectMode: false, + selectedItems: {}, + suggestions: [], + topics: [], + topicInputValue: '', + topicSelected: null, + confirm: false, + addInProgress: false, + addCompleted: false + }; + } render() { let items = this._getItems(); let count = this.props.items.length; let total = this.props.from; + + // For the Autosuggest + const inputProps = { + placeholder: "Ajouter une rubrique...", + value: this.state.topicInputValue, + onChange: this.topicInputOnChange.bind(this), + type: "text", + className: "form-control TopicGroupAddInput" + } + const suggestTheme = { + container: 'autosuggest', + input: 'form-control', + suggestionsContainer: 'dropdown open', + suggestionsList: `dropdown-menu ${this.state.suggestions.length ? 'show' : ''}`, + suggestion: 'dropdown-item', + suggestionHighlighted: 'active' + }; + + let numberOfSelectedItems = Object.values(this.state.selectedItems).reduce((total, currentValue) => { return currentValue ? total + 1 : total }, 0); + + let subjectH2; + if (this.state.selectMode) { + subjectH2 = ( +

+ Mode sélection +
+ +
+ +
+
+
+ +
+

+ ); + } else { + subjectH2 = ( +

+ {this.props.ids.join(' + ')} +
+ {count} / {total} +
+
+

+ ); + } + + let confirm = ""; + if (this.state.confirm && this.state.topicSelected !== null) { + let confirmButton = (); + confirm = ( +
+
+

Ajout du topic "{this.state.topicSelected.topicName}" à la sélection

+
{this.state.addInProgress ? "Ajout en cours..." : (this.state.addCompleted ? "Ajout des topics completé!" : `${numberOfSelectedItems} items sélectionnés`)}
+
+ {this.state.addCompleted ? "" : confirmButton} + +
+
+
+ ); + } + return(
-
-

- {this.props.ids.join(' + ')} - {count} / {total} -

+ {confirm} +
+ {subjectH2}
{items}
@@ -32,21 +124,168 @@ class Corpora extends Component { _getItems() { return this.props.items.map(item => - ); } + + _toggleItemState(item) { + let temp = this.state.selectedItems; + temp[item.id] = !temp[item.id]; + this.setState({ selectedItems: temp }); + } + + _setSelectMode(activate) { + if (activate) { + this.setState({selectMode: true, topics: this._getTopics()}); + } else { + this.setState({selectMode: false}); + } + } + + _getTopics() { + let r = []; + for (let viewpoint of this.props.viewpoints) { + for (let t in viewpoint) { + let topic = viewpoint[t]; + if (typeof topic === "object" && Object.hasOwnProperty.call(topic, "name")) { + let parentTopicNames = ""; + let broaderTopic = topic.broader; + while (broaderTopic) { + parentTopicNames = broaderTopic[0].name + " > " + parentTopicNames; + broaderTopic = viewpoint[broaderTopic[0].id].broader; + } + parentTopicNames = viewpoint.name + " > " + parentTopicNames; + parentTopicNames = parentTopicNames.substring(0, parentTopicNames.length - 3); + r.push({ + topicName: topic.name[0], + parentTopicNames, + id: t, + viewpointId : viewpoint.id + }); + } + } + } + return r; + } + + _assignTopic() { + this.setState({ addInProgress: true }); + let selectedTopic = this.state.topicSelected; + let items = getSelectedItemsArray(this.state.selectedItems); + items.forEach(itemId => { + hypertopic.get({_id: itemId}).then(data => { + data.topics=data.topics || {}; + data.topics[selectedTopic.id] = { viewpoint: selectedTopic.viewpointId }; + return data; + }) + .then(hypertopic.post) + .then(() => { + this.setState({ addInProgress: false, addCompleted: true }); + }) + .catch(error => { + console.log(`error : ${error}`); + this.setState({ addInProgress: false }); + }); + }); + } + /*-------------* + | Autosuggest | + *-------------*/ + + getSuggestions(value) { + const inputValue = value.trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + const inputLength = inputValue.length; + const regex = new RegExp(`(^|\\b)${inputValue}`, 'i'); + if (inputLength !== 0) { + let count = 0; + return this.state.topics.filter(v => { + // Normalise the value: remove the accents + let noAccents = v.topicName.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + return regex.test(noAccents) && count++ < 10; + }); + } + return []; + } + + // Fetches current suggestions and stores them in the state + onSuggestionsFetchRequested({value}) { + this.setState({ + suggestions: this.getSuggestions(value) + }); + }; + + // Clears the suggestion list + onSuggestionsClearRequested() { + this.setState({ + suggestions: [] + }); + }; + + // A suggestion has been selected from the list of suggestions + onSuggestionSelected(event, { suggestion }) { + this.setState({ topicSelected: suggestion }); + } + + // Renders a suggestion item in the suggestions list + renderSuggestion(suggestion, {query}) { + query = query.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + const regex = new RegExp(`(^|\\b)${query}`, 'gi'); + /*let topicNameParts = suggestion.topicName.split(regex).map((t, i) => { + if (t.toLowerCase() === query.toLowerCase()) { + return {t}; + } + return {t}; + });*/ + const noAccents = suggestion.topicName.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + const regexIndexOf = function(startpos) { + var indexOf = noAccents.substring(startpos || 0).search(regex); + return (indexOf >= 0) ? (indexOf + (startpos || 0)) : indexOf; + } + let topicNameParts = []; + let currentIndex = 0; + let foundIndex = 0; + let key = 0; + while ((foundIndex = regexIndexOf(currentIndex)) !== -1) { + const nonEmphasized = suggestion.topicName.substring(currentIndex, foundIndex); + if (nonEmphasized.length > 0) { topicNameParts.push({nonEmphasized}); } + const emphasized = suggestion.topicName.substring(foundIndex, foundIndex + query.length); + topicNameParts.push({emphasized}); + currentIndex = foundIndex + query.length; + } + const nonEmphasized = suggestion.topicName.substring(currentIndex); + if (nonEmphasized.length > 0) { topicNameParts.push({nonEmphasized}); } + return ( + +
{suggestion.parentTopicNames}
+ {topicNameParts} +
+ ); + }; + + // Simply returns the name of the given suggestion + getSuggestionValue(suggestion) { + return suggestion.topicName; + } + + // The change event for the topic suggest + topicInputOnChange(event, { newValue }) { + this.setState({ + topicInputValue: newValue, + canValidateTopic: false + }); + } } function Item(props) { switch (listView.mode) { case 'article': - return Article(props.item); + return Article(props); case 'picture': - return Picture(props.item); + return Picture(props); default: - return Picture(props.item); + return Picture(props); } } @@ -57,13 +296,13 @@ function getString(obj) { return String(obj); } -function Article(item) { +function Article(props) { let propList = (listView.props || []).map(key => { - return
  • {key} : {getString(item[key])}
  • ; + return
  • {key} : {getString(props.item[key])}
  • ; }); - let uri = `/item/${item.corpus}/${item.id}`; - let name = getString(item[listView.name]); + let uri = `/item/${props.item.corpus}/${props.item.id}`; + let name = getString(props.item[listView.name]); return (
    {name}
    @@ -72,13 +311,15 @@ function Article(item) { ); } -function Picture(item) { - let uri = `/item/${item.corpus}/${item.id}`; - let img = getString(item[listView.image]); - let name = getString(item[listView.name]); +function Picture(props) { + let uri = `/item/${props.item.corpus}/${props.item.id}`; + let img = getString(props.item[listView.image]); + let name = getString(props.item[listView.name]); + let selected = props.corpora.state.selectedItems[props.item.id]; return ( -
    - +
    props.corpora._toggleItemState(props.item)}> + + {if (props.corpora.state.selectMode) e.preventDefault();}}> {name}/
    {name}
    @@ -86,4 +327,14 @@ function Picture(item) { ); } +function getSelectedItemsArray(items){ + let array = []; + for(let itemId in items){ + if (items[itemId]){ + array.push(itemId); + } + } + return array; +} + export default Corpora; diff --git a/src/components/Portfolio/Portfolio.jsx b/src/components/Portfolio/Portfolio.jsx index 5bca632d..f3df185c 100644 --- a/src/components/Portfolio/Portfolio.jsx +++ b/src/components/Portfolio/Portfolio.jsx @@ -219,7 +219,7 @@ class Portfolio extends Component { _getCorpora() { let ids = this.state.corpora.map(c => c.id); return ( - + ); } } diff --git a/src/styles/App.css b/src/styles/App.css index 22c9cce1..6728f62c 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -63,7 +63,58 @@ h1 a, h1 a:hover { } .Subject h2 { + display: flex; + flex-direction: row; + align-items: center; background-color: dimgrey; + flex-wrap: nowrap; +} + +.Subject h2 > * { + flex-shrink: 1; +} + +.Subject h2 > :first-child { + flex-grow: 1; +} + +.Subject h2 .input-group { + width: auto; +} + +.TopicGroupAddConfirm { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + z-index: 3; + top: 0px; + left: 0px; + width: 100vw; + height: 100vh; + background-color: hsla(0,0%,0%,0.5); +} +.TopicGroupAddConfirm > div { + background-color: white; + padding: 20px 32px; + max-width: 50vw; + max-height: 50vh; + border-radius: 8px; +} + +.TopicGroupAddInput { + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; +} + +.TopicGroupAddButton { + border-color: hsla(0,0%,0%,0.3) !important; +} + +.parentTopicNames { + font-variant: small-caps; + color: dimgrey; + font-size: 0.75em; } .Description h2 { @@ -134,6 +185,25 @@ h1 a, h1 a:hover { .Item { margin: 5px; color: black; + position: relative; +} + +.Subject.selectMode .Item.selected { + outline: 3px solid #8b0000; +} +.Item .oi-check { + display: none; + position: absolute; + top: 4px; + right: 4px; + color: #8b0000; + border: 2px solid #8b0000; + background-color: hsla(0,100%,100%,0.8); + border-radius: 100px; + padding: 2px; +} +.Subject.selectMode .Item.selected .oi-check { + display: block; } .Attributes { @@ -243,6 +313,10 @@ h1 a, h1 a:hover { font-weight: normal; } +.SuggestionItem b { + color: red; +} + .react-autosuggest__input { width: 17vw; } @@ -375,3 +449,46 @@ ul.Outliner, .btn:not(.btn-xs) .oi { vertical-align: middle; } + +.TopicSelectorWindowDark { + position: fixed; + z-index: 1; + top: 0px; + left: 0px; + width: 100vw; + height: 100vh; + background-color: hsla(0,0%,0%,0.5); +} + +.TopicSelectorWindow { + position: fixed; + z-index: 1; + top: 50%; + left: 50%; + transform: translate(-50%,-50%); + min-width: 400px; + width: 50%; + min-height: 300px; + height: 50%; + background-color: white; + border: 1px solid #3d0000; +} + +.TopicSelectorWindow h2 { + background-color: #8b0000; + font-size: 1.5rem; + font-weight: 700; + padding-left: 20px; + display: flex; + flex-direction: row; + align-items: center; +} + +.TopicSelectorWindow h2 > :first-child { + flex-grow: 1; +} + +.TopicSelectorWindow h2 > .oi-x { + font-size: 1rem; + cursor: pointer; +}