From 80c5f7018989375532bf531d3fc8bd0f947e7b01 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sun, 16 Jul 2023 12:53:58 +0200 Subject: [PATCH 01/12] client: Add tag autocomplete Fixes: https://github.com/fossar/selfoss/issues/669 --- NEWS.md | 1 + client/js/templates/App.jsx | 4 +- client/js/templates/Source.jsx | 114 ++++++++++++++++---- client/js/templates/SourcesPage.jsx | 31 +++++- client/locale/cs.json | 2 + client/locale/en.json | 2 + client/package-lock.json | 12 +++ client/package.json | 1 + client/styles/main.scss | 12 ++- client/styles/tags.scss | 158 ++++++++++++++++++++++++++++ 10 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 client/styles/tags.scss diff --git a/NEWS.md b/NEWS.md index 8bbe947cc1..78ee7287a6 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. ([#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/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..8bb282bc55 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.name) : []; const values = { @@ -189,7 +187,7 @@ 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; @@ -197,7 +195,7 @@ function handleEdit({ event, source, setEditedSource }) { setEditedSource({ id, title: title ? unescape(title) : '', - tags: tags ? tags.map(unescape).join(',') : '', + tags: tags ? tags.map(unescape).map((name) => ({ id: tagInfo[name]?.id, name })) : [], filter, spout, params @@ -264,6 +262,21 @@ function daysAgo(date) { return Math.floor((today - old) / MS_PER_DAY); } +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, @@ -271,6 +284,7 @@ function SourceEditForm({ setSources, spouts, setSpouts, + tagInfo, setEditedSource, sourceActionLoading, setSourceActionLoading, @@ -302,8 +316,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] ); @@ -363,6 +408,11 @@ function SourceEditForm({ [source, sourceElem, setSources, setEditedSource, dirty, setDirty] ); + const tagSuggestions = useMemo( + () => Object.entries(tagInfo).map(([name, { id }]) => ({ id, name })), + [tagInfo] + ); + const _ = React.useContext(LocalizationContext); const sourceParamsContent = ( @@ -400,6 +450,8 @@ function SourceEditForm({ ); + const reactTags = useRef(); + return (
    @@ -428,18 +480,23 @@ function SourceEditForm({ - - - {' '} - {_('source_comma')} - {sourceErrors['tags'] ? ( {sourceErrors['tags']} ) : null} @@ -545,6 +602,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 +617,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 +654,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 +783,7 @@ export default function Source({ source, setSources, spouts, setSpouts, dirty, s setSources, spouts, setSpouts, + tagInfo, setEditedSource, sourceActionLoading, setSourceActionLoading, @@ -744,6 +811,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..8c1b5be943 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,26 @@ 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 }) => { + if (typeof info[tag] === 'undefined') { + info[tag] = { + id: maxTagId++, + }; + } + }); + + return info; + }, + [tags] + ); const [loadingState, setLoadingState] = React.useState(LoadingState.INITIAL); @@ -105,6 +123,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 +206,7 @@ export default function SourcesPage() { ))}
@@ -197,3 +220,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..20b2074514 100644 --- a/client/locale/cs.json +++ b/client/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/client/locale/en.json b/client/locale/en.json index f4865aa66d..888e2a1e91 100644 --- a/client/locale/en.json +++ b/client/locale/en.json @@ -56,6 +56,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/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..dc14db7739 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-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/client/styles/tags.scss b/client/styles/tags.scss new file mode 100644 index 0000000000..43ec43d088 --- /dev/null +++ b/client/styles/tags.scss @@ -0,0 +1,158 @@ +// 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 + +/** + *
+ *
+ * + *
+ *