diff --git a/NEWS.md b/NEWS.md index 149ef038a7..f6d1e72c4a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,9 @@ # selfoss news ## 2.20 – unreleased +### New features +- Tags are now autocompleted when editing a new source. ([#1311](https://github.com/fossar/selfoss/pull/1311), [#669](https://github.com/fossar/selfoss/issues/669)) + ### Bug fixes - Configuration parser was changed to *raw* method, which relaxes the requirement to quote option values containing special characters in `config.ini`. ([#1371](https://github.com/fossar/selfoss/issues/1371)) diff --git a/assets/js/templates/App.jsx b/assets/js/templates/App.jsx index d65333e0d2..fe7ef596c8 100644 --- a/assets/js/templates/App.jsx +++ b/assets/js/templates/App.jsx @@ -265,7 +265,9 @@ function PureApp({ )} - + diff --git a/assets/js/templates/Source.jsx b/assets/js/templates/Source.jsx index 32c0b60363..c6a899545a 100644 --- a/assets/js/templates/Source.jsx +++ b/assets/js/templates/Source.jsx @@ -1,7 +1,8 @@ import React from 'react'; -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { Button as MenuButton, Wrapper as MenuWrapper, Menu, MenuItem } from 'react-aria-menubutton'; import { useHistory, useLocation } from 'react-router-dom'; +import ReactTags from 'react-tag-autocomplete'; import { fadeOut } from '@siteparts/show-hide-effects'; import { makeEntriesLinkLocation } from '../helpers/uri'; import PropTypes from 'prop-types'; @@ -67,10 +68,7 @@ function handleSave({ // Make tags into a list. const tagsList = tags - ? tags - .split(',') - .map((tag) => tag.trim()) - .filter((tag) => tag !== '') + ? tags.map((tag) => tag.name) : []; const values = { @@ -192,15 +190,24 @@ function handleDelete({ } // start editing -function handleEdit({ event, source, setEditedSource }) { +function handleEdit({ event, source, tagInfo, setEditedSource }) { event.preventDefault(); const { id, title, tags, filter, spout, params } = source; + const newTags = + tags + ? tags.map(unescape).map((name) => ({ + id: tagInfo[name]?.id, + name, + color: tagInfo[name]?.color, + })) + : []; + setEditedSource({ id, title: title ? unescape(title) : '', - tags: tags ? tags.map(unescape).join(',') : '', + tags: newTags, filter, spout, params @@ -267,6 +274,64 @@ function daysAgo(date) { return Math.floor((today - old) / MS_PER_DAY); } + +function ColorBox({ color }) { + return ( + + ); +} + +ColorBox.propTypes = { + color: nullable(PropTypes.string).isRequired, +}; + +function mkTag(tagInfo) { + function Tag({ classNames, removeButtonText, onDelete, tag }) { + return ( + + + {' '} + {tag.name} + + ); + } + + Tag.propTypes = { + classNames: PropTypes.object.isRequired, + removeButtonText: PropTypes.string.isRequired, + onDelete: PropTypes.func.isRequired, + tag: PropTypes.object.isRequired, + }; + + return Tag; +} + + +const reactTagsClassNames = { + root: 'react-tags', + rootFocused: 'is-focused', + selected: 'react-tags-selected', + selectedTag: 'react-tags-selected-tag', + selectedTagName: 'react-tags-selected-tag-name', + search: 'react-tags-search', + searchWrapper: 'react-tags-search-wrapper', + searchInput: 'react-tags-search-input', + suggestions: 'react-tags-suggestions', + suggestionActive: 'is-active', + suggestionDisabled: 'is-disabled', + suggestionPrefix: 'react-tags-suggestion-prefix' +}; + function SourceEditForm({ source, sourceElem, @@ -274,6 +339,7 @@ function SourceEditForm({ setSources, spouts, setSpouts, + tagInfo, setEditedSource, sourceActionLoading, setSourceActionLoading, @@ -305,8 +371,39 @@ function SourceEditForm({ [updateEditedSource] ); - const tagsOnChange = React.useCallback( - (event) => updateEditedSource({ tags: event.target.value }), + const tagsOnAddition = React.useCallback( + (input) => { + // We need to handle pasting as well. + const tagsToAdd = + typeof input.id !== 'undefined' + ? [input] + : input.name + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag !== '') + .map((tag) => ({ name: tag, id: undefined })); + updateEditedSource(({ tags }) => { + const usedTagNames = tags.map(({ name }) => name); + const freshTagsToAdd = tagsToAdd.filter((tag) => !usedTagNames.includes(tag.name)); + if (freshTagsToAdd.length === 0) { + // All tags already included, no change. + return {}; + } + + return { tags: [...tags, ...freshTagsToAdd] }; + }); + }, + [updateEditedSource] + ); + + const tagsOnDelete = React.useCallback( + (index) => { + updateEditedSource(({ tags }) => { + let newTags = tags.slice(0); + newTags.splice(index, 1); + return { tags: newTags}; + }); + }, [updateEditedSource] ); @@ -366,6 +463,15 @@ function SourceEditForm({ [source, sourceElem, setSources, setEditedSource, dirty, setDirty] ); + const tagSuggestions = useMemo( + () => Object.entries(tagInfo).map(([name, { id, color }]) => ({ + id, + name, + prefix: + })), + [tagInfo] + ); + const _ = React.useContext(LocalizationContext); const sourceParamsContent = ( @@ -403,6 +509,10 @@ function SourceEditForm({ ); + const reactTags = useRef(); + + const tagComponent = useMemo(() => mkTag(tagInfo), [tagInfo]); + return ( @@ -431,18 +541,25 @@ function SourceEditForm({ {_('source_tags')} - - - {' '} - {_('source_comma')} - {sourceErrors['tags'] ? ( {sourceErrors['tags']} ) : null} @@ -548,6 +665,7 @@ SourceEditForm.propTypes = { setSources: PropTypes.func.isRequired, spouts: PropTypes.object.isRequired, setSpouts: PropTypes.func.isRequired, + tagInfo: PropTypes.object.isRequired, setEditedSource: PropTypes.func.isRequired, sourceActionLoading: PropTypes.bool.isRequired, setSourceActionLoading: PropTypes.func.isRequired, @@ -562,7 +680,15 @@ SourceEditForm.propTypes = { setDirty: PropTypes.func.isRequired, }; -export default function Source({ source, setSources, spouts, setSpouts, dirty, setDirtySources }) { +export default function Source({ + source, + setSources, + spouts, + setSpouts, + tagInfo, + dirty, + setDirtySources, +}) { const isNew = !source.title; let classes = { source: true, @@ -591,8 +717,8 @@ export default function Source({ source, setSources, spouts, setSpouts, dirty, s }, [justSavedTimeout]); const editOnClick = React.useCallback( - (event) => handleEdit({ event, source, setEditedSource }), - [source] + (event) => handleEdit({ event, source, tagInfo, setEditedSource }), + [source, tagInfo] ); const setDirty = React.useCallback( @@ -723,6 +849,7 @@ export default function Source({ source, setSources, spouts, setSpouts, dirty, s setSources, spouts, setSpouts, + tagInfo, setEditedSource, sourceActionLoading, setSourceActionLoading, @@ -750,6 +877,7 @@ Source.propTypes = { setSources: PropTypes.func.isRequired, spouts: PropTypes.object.isRequired, setSpouts: PropTypes.func.isRequired, + tagInfo: PropTypes.object.isRequired, dirty: PropTypes.bool.isRequired, setDirtySources: PropTypes.func.isRequired, }; diff --git a/assets/js/templates/SourcesPage.jsx b/assets/js/templates/SourcesPage.jsx index 2642627266..9a8fe0085b 100644 --- a/assets/js/templates/SourcesPage.jsx +++ b/assets/js/templates/SourcesPage.jsx @@ -1,3 +1,4 @@ +import PropTypes from 'prop-types'; import React from 'react'; import { useMemo } from 'react'; import { Prompt } from 'react-router'; @@ -90,9 +91,28 @@ function loadSources({ abortController, location, setSpouts, setSources, setLoad }); } -export default function SourcesPage() { + +export default function SourcesPage({ tags }) { const [spouts, setSpouts] = React.useState([]); const [sources, setSources] = React.useState([]); + const tagInfo = useMemo( + () => { + let maxTagId = 1; + let info = {}; + + tags.forEach(({ tag, color }) => { + if (typeof info[tag] === 'undefined') { + info[tag] = { + id: maxTagId++, + color, + }; + } + }); + + return info; + }, + [tags] + ); const [loadingState, setLoadingState] = React.useState(LoadingState.INITIAL); @@ -105,6 +125,11 @@ export default function SourcesPage() { React.useEffect(() => { const abortController = new AbortController(); + if (selfoss.app.state.tags.length === 0) { + // Ensure tags are loaded. + selfoss.reloadTags(); + } + loadSources({ abortController, location, setSpouts, setSources, setLoadingState }) .then(() => { if (isAdding) { @@ -183,7 +208,7 @@ export default function SourcesPage() { ))} @@ -197,3 +222,7 @@ export default function SourcesPage() { ); } + +SourcesPage.propTypes = { + tags: PropTypes.array.isRequired, +}; diff --git a/assets/locale/cs.json b/assets/locale/cs.json index b0f02a84fe..20b2074514 100644 --- a/assets/locale/cs.json +++ b/assets/locale/cs.json @@ -43,6 +43,8 @@ "lang_source_title": "Název", "lang_source_autotitle_hint": "Pro automatické vyplnění ponechte prázdné", "lang_source_tags": "Štítky", + "lang_source_tags_placeholder": "Přidat nový štítek", + "lang_source_tag_remove_button_label": "Klikněte pro odstranění štítku", "lang_source_pwd_placeholder": "Beze změny", "lang_source_comma": "Oddělené čárkou", "lang_source_select": "Vyberte prosím zdroj", diff --git a/assets/locale/en.json b/assets/locale/en.json index 7f2880b89b..97f8f4f704 100644 --- a/assets/locale/en.json +++ b/assets/locale/en.json @@ -55,6 +55,8 @@ "lang_source_title": "Title", "lang_source_autotitle_hint": "Leave empty to fetch title", "lang_source_tags": "Tags", + "lang_source_tags_placeholder": "Add new tag", + "lang_source_tag_remove_button_label": "Click to remove tag", "lang_source_pwd_placeholder": "Not changed", "lang_source_comma": "Comma separated", "lang_source_select": "Please select source", diff --git a/assets/package-lock.json b/assets/package-lock.json index 916faba22c..bb1ef6f1dc 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -29,6 +29,7 @@ "react-aria-menubutton": "^7.0.3", "react-dom": "^17.0.1", "react-router-dom": "^5.2.0", + "react-tag-autocomplete": "^6.3.0", "reset-css": "^5.0.1", "rooks": "^7.1.1", "tinykeys": "^1.1.1", @@ -5648,6 +5649,19 @@ "react": ">=15" } }, + "node_modules/react-tag-autocomplete": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.3.0.tgz", + "integrity": "sha512-MUBVUFh5eOqshUm5NM20qp7zXk8TzSiKO4GoktlFzBLIOLs356npaMKtL02bm0nFV2f1zInUrXn1fq6+i5YX0w==", + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "prop-types": "^15.5.0", + "react": "^16.5.0 || ^17.0.0", + "react-dom": "^16.5.0 || ^17.0.0" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -10843,6 +10857,12 @@ "tiny-warning": "^1.0.0" } }, + "react-tag-autocomplete": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.3.0.tgz", + "integrity": "sha512-MUBVUFh5eOqshUm5NM20qp7zXk8TzSiKO4GoktlFzBLIOLs356npaMKtL02bm0nFV2f1zInUrXn1fq6+i5YX0w==", + "requires": {} + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", diff --git a/assets/package.json b/assets/package.json index 02aab8e3da..f82bd91839 100644 --- a/assets/package.json +++ b/assets/package.json @@ -24,6 +24,7 @@ "react-aria-menubutton": "^7.0.3", "react-dom": "^17.0.1", "react-router-dom": "^5.2.0", + "react-tag-autocomplete": "^6.3.0", "reset-css": "^5.0.1", "rooks": "^7.1.1", "tinykeys": "^1.1.1", diff --git a/assets/styles/main.scss b/assets/styles/main.scss index e4279539d5..d8bb9f370a 100644 --- a/assets/styles/main.scss +++ b/assets/styles/main.scss @@ -16,6 +16,7 @@ $text-color: black; // https://github.com/sass/libsass/issues/2621 --primary: #{$primary}; --primary-contrast: #ffffff; + --text-color-for-primary: #eeeeec; --primary-highlight: #{$primary-highlight}; --primary-highlight-shadow: #{$primary-highlight-shadow}; --text-color: #{$text-color}; @@ -30,6 +31,7 @@ $search-entry-width: 20rem; $search-button-width: 30px; @import 'color-chooser'; +@import 'tags'; html, body { @@ -47,7 +49,8 @@ button { } select, -input { +input, +.react-tags { border: solid 1px #cccccc; background: var(--background-color); color: var(--text-color); @@ -57,7 +60,8 @@ input { } select:focus, -input:focus { +input:not(.react-tags-search-input):focus, +.react-tags.is-focused { color: color.mix($text-color, $primary, 50%); border-color: var(--primary); outline: 0; @@ -719,8 +723,10 @@ span.offline-count.diff { /* sources */ -.source input { +.source input, +.react-tags { width: 60%; + box-sizing: border-box; } .source-title { diff --git a/assets/styles/tags.scss b/assets/styles/tags.scss new file mode 100644 index 0000000000..bf7f3d72e9 --- /dev/null +++ b/assets/styles/tags.scss @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +// Taken with minor changes from: +// https://github.com/i-like-robots/react-tags/blob/9ac82a9879f53d46759dbd559d8b0a99bcd376ae/example/styles.css +// © 2015 Prakhar Srivastav +// © 2016–2018 Matt Hinchliffe + +/** + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + */ + +.react-tags { + position: relative; + display: inline-block; + padding: 3px 0 0 3px; + + /* shared font styles */ + font-size: 1em; + line-height: 1.2; + + /* clicking anywhere will focus the input */ + cursor: text; +} + +.react-tags-selected { + display: inline; +} + +.react-tags .color { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; +} + +.react-tags-selected-tag { + display: inline-block; + box-sizing: border-box; + margin: 3px; + padding: 3px 4px; + border: 1px solid #d1d1d1; + border-radius: 4px; + background: #f1f1f1; + color: #1f1f1f; + cursor: pointer; + + /* match the font styles */ + font-size: inherit; + line-height: inherit; + + &:hover, + &:focus { + border-color: #b1b1b1; + } +} + +.react-tags-selected-tag::after { + content: '✕'; + color: #aaaaaa; + margin-left: 4px; +} + +.react-tags-search { + display: inline-block; + + /* match tag layout */ + padding: 4px 2px; + margin: 3px 0; + + /* prevent autoresize overflowing the container */ + max-width: 100%; +} + +@media screen and (min-width: 30em) { + .react-tags-search { + /* this will become the offsetParent for suggestions */ + position: relative; + } +} + +.react-tags-search-input { + /* prevent autoresize overflowing the container */ + max-width: 100%; + + /* remove styles and layout from this element */ + margin: 0; + padding: 0; + border: 0; + outline: none; + + /* match the font styles */ + font-size: inherit; + line-height: inherit; +} + +.react-tags-suggestions { + position: absolute; + top: 100%; + left: 0; + width: 100%; +} + +@media screen and (min-width: 30em) { + .react-tags-suggestions { + width: 240px; + } +} + +.react-tags-suggestions ul { + margin: 4px -1px; + padding: 0; + list-style: none; + background: var(--background-color); + color: var(--text-color); + border: 1px solid #d1d1d1; + border-radius: 2px; + box-shadow: 0 2px 3px rgb(0 0 0 / 20%); +} + +.react-tags-suggestions li { + border-bottom: 1px solid #dddddd; + padding: 3px 4px; +} + +.react-tags-suggestions li mark { + text-decoration: underline; + background: none; + font-weight: 600; +} + +.react-tags-suggestions li.is-active { + background: var(--primary-highlight); + color: var(--text-color); +} + +.react-tags-suggestions li:hover { + cursor: pointer; + background: var(--primary); + color: var(--text-color-for-primary); +} + +.react-tags-suggestions li.is-disabled { + opacity: 0.5; + cursor: auto; +}