diff --git a/NEWS.md b/NEWS.md index 8bbe947cc1..efc03cb349 100644 --- a/NEWS.md +++ b/NEWS.md @@ -11,6 +11,7 @@ - Sources can be filtered based on item’s author, URL or categories. ([#1423](https://github.com/fossar/selfoss/pull/1423), [#1424](https://github.com/fossar/selfoss/pull/1424)) - Source filter expression is now validated whenever a source is modified. ([#1423](https://github.com/fossar/selfoss/pull/1423)) - Garbage collection can be completely disabled by setting `items_lifetime=0`. +- Tags are now autocompleted when editing a new source. ([#1445](https://github.com/fossar/selfoss/pull/1445), [#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/client/js/templates/App.jsx b/client/js/templates/App.jsx index 32d13bbfa3..e7b2bea123 100644 --- a/client/js/templates/App.jsx +++ b/client/js/templates/App.jsx @@ -307,7 +307,9 @@ function PureApp({ )} - + diff --git a/client/js/templates/Source.jsx b/client/js/templates/Source.jsx index ec1dce046b..53ed95b09d 100644 --- a/client/js/templates/Source.jsx +++ b/client/js/templates/Source.jsx @@ -1,7 +1,8 @@ import React from 'react'; -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { Menu, MenuButton, MenuItem } from '@szhsin/react-menu'; 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.label) : []; const values = { @@ -189,15 +187,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((label) => ({ + value: tagInfo[label]?.id, + label, + color: tagInfo[label]?.color, + })) + : []; + setEditedSource({ id, title: title ? unescape(title) : '', - tags: tags ? tags.map(unescape).join(',') : '', + tags: newTags, filter, spout, params @@ -264,6 +271,96 @@ 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, tag, ...tagProps }) { + return ( + + + {' '} + {tag.label} + + ); + } + + Tag.propTypes = { + classNames: PropTypes.object.isRequired, + tag: PropTypes.object.isRequired, + tagProps: PropTypes.object.isRequired, + 'aria-disabled': PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + }; + + return Tag; +} + + +function mkTagOption(tagInfo) { + function TagOption({ children, classNames, option, ...optionProps }) { + const classes = [ + classNames.option, + option.active ? 'is-active' : '', + option.selected ? 'is-selected' : '', + ]; + + return ( + + + {' '} + {children} + + ); + } + + TagOption.propTypes = { + classNames: PropTypes.object.isRequired, + tag: PropTypes.object.isRequired, + children: PropTypes.any.isRequired, + // TODO: Add extra proptypes. + }; + + return TagOption; +} + + +const reactTagsClassNames = { + root: 'react-tags', + rootIsActive: 'is-active', + rootIsDisabled: 'is-disabled', + rootIsInvalid: 'is-invalid', + label: 'react-tags-label', + tagList: 'react-tags-list', + tagListItem: 'react-tags-list-item', + tag: 'react-tags-tag', + tagName: 'react-tags-tag-name', + comboBox: 'react-tags-combobox', + input: 'react-tags-combobox-input', + listBox: 'react-tags-list-box', + option: 'react-tags-list-box-option', + optionIsActive: 'is-active', + highligh: 'react-tags-listbox-option-highlight', +}; + function SourceEditForm({ source, sourceElem, @@ -271,6 +368,7 @@ function SourceEditForm({ setSources, spouts, setSpouts, + tagInfo, setEditedSource, sourceActionLoading, setSourceActionLoading, @@ -302,8 +400,40 @@ function SourceEditForm({ [updateEditedSource] ); - const tagsOnChange = React.useCallback( - (event) => updateEditedSource({ tags: event.target.value }), + const tagsOnAdd = React.useCallback( + (input) => { + // TODO: Paste not working, + // We need to handle pasting as well. + const tagsToAdd = + typeof input.value !== 'undefined' + ? [input] + : input.label + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag !== '') + .map((tag) => ({ label: tag, value: undefined })); + updateEditedSource(({ tags }) => { + const usedTagLabels = tags.map(({ label }) => label); + const freshTagsToAdd = tagsToAdd.filter((tag) => !usedTagLabels.includes(tag.label)); + 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] ); @@ -363,6 +493,11 @@ function SourceEditForm({ [source, sourceElem, setSources, setEditedSource, dirty, setDirty] ); + const tagSuggestions = useMemo( + () => Object.entries(tagInfo).map(([label, { id }]) => ({ value: id, label })), + [tagInfo] + ); + const _ = React.useContext(LocalizationContext); const sourceParamsContent = ( @@ -400,6 +535,19 @@ function SourceEditForm({ ); + const reactTags = useRef(); + + const { + tagComponent, + tagOptionComponent, + } = useMemo( + () => ({ + tagComponent: mkTag(tagInfo), + tagOptionComponent: mkTagOption(tagInfo), + }), + [tagInfo] + ); + return ( @@ -428,18 +576,27 @@ function SourceEditForm({ {_('source_tags')} - - - {' '} - {_('source_comma')} - {sourceErrors['tags'] ? ( {sourceErrors['tags']} ) : null} @@ -545,6 +702,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, @@ -559,7 +717,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, @@ -588,8 +754,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( @@ -717,6 +883,7 @@ export default function Source({ source, setSources, spouts, setSpouts, dirty, s setSources, spouts, setSpouts, + tagInfo, setEditedSource, sourceActionLoading, setSourceActionLoading, @@ -744,6 +911,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/client/js/templates/SourcesPage.jsx b/client/js/templates/SourcesPage.jsx index 62e7105868..8259efc921 100644 --- a/client/js/templates/SourcesPage.jsx +++ b/client/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/client/locale/cs.json b/client/locale/cs.json index b0f02a84fe..ee49d22103 100644 --- a/client/locale/cs.json +++ b/client/locale/cs.json @@ -43,6 +43,9 @@ "lang_source_title": "Název", "lang_source_autotitle_hint": "Pro automatické vyplnění ponechte prázdné", "lang_source_tags": "Štítky", + "lang_source_tags_create_new": "Vytvořit nový štítek “{0}” a přidat jej.", + "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/client/locale/en.json b/client/locale/en.json index f4865aa66d..cda8277173 100644 --- a/client/locale/en.json +++ b/client/locale/en.json @@ -56,6 +56,9 @@ "lang_source_title": "Title", "lang_source_autotitle_hint": "Leave empty to fetch title", "lang_source_tags": "Tags", + "lang_source_tags_create_new": "Create a new tag “{0}” and add it.", + "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/client/package-lock.json b/client/package-lock.json index 5e28869473..2da49edd44 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -27,6 +27,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^5.2.0", + "react-tag-autocomplete": "^7.0.0", "reset-css": "^5.0.1", "rooks": "^7.1.1", "tinykeys": "^2.0.0", @@ -5588,6 +5589,17 @@ "react": ">=15" } }, + "node_modules/react-tag-autocomplete": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.0.0.tgz", + "integrity": "sha512-PFxT7fpMB8Au+S9cJYAGRVTnacZpeXybc5SkpTCyuJHmUN1Bt8gHb9vZi3f+aX/eDX44x2WIwYiqfRBi2E5AMg==", + "engines": { + "node": ">= 16.12.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/react-transition-state": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/react-transition-state/-/react-transition-state-2.1.0.tgz", diff --git a/client/package.json b/client/package.json index 1286edaaa9..b67584d5a2 100644 --- a/client/package.json +++ b/client/package.json @@ -22,6 +22,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^5.2.0", + "react-tag-autocomplete": "^7.0.0", "reset-css": "^5.0.1", "rooks": "^7.1.1", "tinykeys": "^2.0.0", diff --git a/client/styles/main.scss b/client/styles/main.scss index efd84b1f52..ce313733d3 100644 --- a/client/styles/main.scss +++ b/client/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__combobox-input):focus, +.react-tags.is-active { 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/client/styles/tags.scss b/client/styles/tags.scss new file mode 100644 index 0000000000..1c63faea92 --- /dev/null +++ b/client/styles/tags.scss @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// Taken with minor changes from: +// https://github.com/i-like-robots/react-tag-autocomplete/blob/de2d9a4b76b7349e44d1e9953fe7c329bf17c162/example/src/styles.css +// SPDX-FileCopyrightText: 2021–2023 Matt Hinchliffe + +/** + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + */ + +.react-tags { + position: relative; + padding: 0.25rem 0 0 0.25rem; + border: 2px solid #afb8c1; + border-radius: 6px; + background: #ffffff; + /* shared font styles */ + font-size: 1rem; + line-height: 1.2; + /* clicking anywhere will focus the input */ + cursor: text; +} + +.react-tags.is-active { + border-color: #4f46e5; +} + +.react-tags.is-disabled { + opacity: 0.75; + background-color: #eaeef2; + /* Prevent any clicking on the component */ + pointer-events: none; + cursor: not-allowed; +} + +.react-tags.is-invalid { + border-color: #fd5956; + box-shadow: 0 0 0 2px rgba(253, 86, 83, 0.25); +} + +.react-tags__label { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + +.react-tags__list { + /* Do not use display: contents, it's too buggy */ + display: inline; + padding: 0; +} + +.react-tags__list-item { + display: inline; + list-style: none; +} + +.react-tags .color { + display: inline-block; + width: 0.625rem; + height: 0.625rem; + border-radius: 2px; +} + +.react-tags__tag { + margin: 0 0.25rem 0.25rem 0; + padding: 0.375rem 0.5rem; + border: 0; + border-radius: 3px; + background: #eaeef2; + /* match the font styles */ + font-size: inherit; + line-height: inherit; +} + +.react-tags__tag:hover { + color: #ffffff; + background-color: #4f46e5; +} + +.react-tags__tag::after { + content: ''; + display: inline-block; + width: 0.65rem; + height: 0.65rem; + clip-path: polygon(10% 0, 0 10%, 40% 50%, 0 90%, 10% 100%, 50% 60%, 90% 100%, 100% 90%, 60% 50%, 100% 10%, 90% 0, 50% 40%); + margin-left: 0.5rem; + font-size: 0.875rem; + background-color: #7c7d86; +} + +.react-tags__tag:hover::after { + background-color: #ffffff; +} + +.react-tags__combobox { + display: inline-block; + /* match tag layout */ + padding: 0.375rem 0.25rem; + margin-bottom: 0.25rem; + /* prevents autoresize overflowing the container */ + max-width: 100%; +} + +.react-tags__combobox-input { + /* prevent autoresize overflowing the container */ + max-width: 100%; + /* remove styles and layout from this element */ + margin: 0; + padding: 0; + border: 0; + outline: none; + background: none; + /* match the font styles */ + font-size: inherit; + line-height: inherit; +} + +.react-tags__combobox-input::placeholder { + color: #7c7d86; + opacity: 1; +} + +.react-tags__listbox { + position: absolute; + z-index: 1; + top: calc(100% + 5px); + /* Negate the border width on the container */ + left: -2px; + right: -2px; + max-height: 12.5rem; + overflow-y: auto; + background: #ffffff; + border: 1px solid #afb8c1; + border-radius: 6px; + box-shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -4px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; +} + +.react-tags__listbox-option { + padding: 0.375rem 0.5rem; +} + +.react-tags__listbox-option:hover { + cursor: pointer; + background: #eaeef2; +} + +.react-tags__listbox-option:not([aria-disabled='true']).is-active { + background: #4f46e5; + color: #ffffff; +} + +.react-tags__listbox-option[aria-disabled='true'] { + color: #7c7d86; + cursor: not-allowed; + pointer-events: none; +} + +.react-tags__listbox-option[aria-selected='true']::after { + content: '✓'; + margin-left: 0.5rem; +} + +.react-tags__listbox-option[aria-selected='true']:not(.is-active)::after { + color: #4f46e5; +} + +.react-tags__listbox-option-highlight { + background-color: #ffdd00; +}