From c71e8abbc331e2a68186aa11a4797ecd24ff6d27 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 27 Jun 2023 04:45:24 +0200 Subject: [PATCH] Add toasts to UI (#25449) Fixes https://github.com/go-gitea/gitea/issues/24353 In some case like async success/error, it is useful to show toasts in UI. --- .eslintrc.yaml | 7 +- package-lock.json | 6 ++ package.json | 1 + templates/devtest/gitea-ui.tmpl | 23 +++--- web_src/css/index.css | 1 + web_src/css/modules/toast.css | 78 +++++++++++++++++++ web_src/css/standalone/devtest.css | 16 ++++ web_src/js/features/common-global.js | 3 +- .../js/features/comp/ComboMarkdownEditor.js | 3 +- web_src/js/features/repo-issue-content.js | 5 +- web_src/js/features/repo-issue-list.js | 3 +- web_src/js/modules/toast.js | 60 ++++++++++++++ web_src/js/modules/toast.test.js | 17 ++++ web_src/js/standalone/devtest.js | 11 +++ webpack.config.js | 6 ++ 15 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 web_src/css/modules/toast.css create mode 100644 web_src/css/standalone/devtest.css create mode 100644 web_src/js/modules/toast.js create mode 100644 web_src/js/modules/toast.test.js create mode 100644 web_src/js/standalone/devtest.js diff --git a/.eslintrc.yaml b/.eslintrc.yaml index ea85ab12981..71d8dc38148 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -25,10 +25,11 @@ env: es2022: true node: true -globals: - __webpack_public_path__: true - overrides: + - files: ["web_src/**/*"] + globals: + __webpack_public_path__: true + process: false # https://github.com/webpack/webpack/issues/15833 - files: ["web_src/**/*", "docs/**/*"] env: browser: true diff --git a/package-lock.json b/package-lock.json index 001fedb6cfa..dcda7d2fed3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "swagger-ui-dist": "5.0.0", "throttle-debounce": "5.0.0", "tippy.js": "6.3.7", + "toastify-js": "1.12.0", "tributejs": "5.1.3", "uint8-to-base64": "0.2.0", "vue": "3.3.4", @@ -10122,6 +10123,11 @@ "node": ">=8.0" } }, + "node_modules/toastify-js": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/toastify-js/-/toastify-js-1.12.0.tgz", + "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index 9219dbbe1d5..9659771212c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "swagger-ui-dist": "5.0.0", "throttle-debounce": "5.0.0", "tippy.js": "6.3.7", + "toastify-js": "1.12.0", "tributejs": "5.1.3", "uint8-to-base64": "0.2.0", "vue": "3.3.4", diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl index 2c5a54c1c22..8b31957f2e0 100644 --- a/templates/devtest/gitea-ui.tmpl +++ b/templates/devtest/gitea-ui.tmpl @@ -1,4 +1,5 @@ {{template "base/head" .}} +

Button

@@ -14,11 +15,6 @@
-
  • General purpose:

    @@ -242,17 +238,20 @@
+
+

Toast

+
+ + + +
+
+

ComboMarkdownEditor

ps: no JS code attached, so just a layout
{{template "shared/combomarkdowneditor" .}}
- - + {{template "base/footer" .}} diff --git a/web_src/css/index.css b/web_src/css/index.css index 66a1a9ffd3a..d7ac9f453d7 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -8,6 +8,7 @@ @import "./modules/card.css"; @import "./modules/comment.css"; @import "./modules/navbar.css"; +@import "./modules/toast.css"; @import "./shared/issuelist.css"; @import "./shared/milestone.css"; diff --git a/web_src/css/modules/toast.css b/web_src/css/modules/toast.css new file mode 100644 index 00000000000..c96521f2736 --- /dev/null +++ b/web_src/css/modules/toast.css @@ -0,0 +1,78 @@ +.toastify { + color: var(--color-white); + position: fixed; + opacity: 0; + transition: all .2s ease; + z-index: 500; + border-radius: 4px; + box-shadow: 0 8px 24px var(--color-shadow); + display: flex; + max-width: 50vw; + min-width: 300px; + padding: 4px; +} + +.toastify.on { + opacity: 1; +} + +.toast-body { + flex: 1; + padding: 5px 0; + overflow-wrap: anywhere; +} + +.toast-close, +.toast-icon { + color: currentcolor; + border-radius: 3px; + background: transparent; + border: none; + display: inline-block; + display: flex; + width: 30px; + height: 30px; + justify-content: center; + align-items: center; +} + +.toast-close:hover { + background: var(--color-hover); +} + +.toast-close:active { + background: var(--color-active); +} + +.toastify-right { + right: 15px; +} + +.toastify-left { + left: 15px; +} + +.toastify-top { + top: -150px; +} + +.toastify-bottom { + bottom: -150px; +} + +.toastify-center { + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; +} + +@media (max-width: 360px) { + .toastify-right, .toastify-left { + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + max-width: fit-content; + } +} diff --git a/web_src/css/standalone/devtest.css b/web_src/css/standalone/devtest.css new file mode 100644 index 00000000000..a7b00e1e561 --- /dev/null +++ b/web_src/css/standalone/devtest.css @@ -0,0 +1,16 @@ +.button-sample-groups { + margin: 0; padding: 0; +} + +.button-sample-groups .sample-group { + list-style: none; margin: 0; padding: 0; +} + +.button-sample-groups .sample-group .ui.button { + margin-bottom: 5px; +} + +h1, h2 { + margin: 0; + padding: 10px 0; +} diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js index e5fd7c29fce..a99b29141d3 100644 --- a/web_src/js/features/common-global.js +++ b/web_src/js/features/common-global.js @@ -9,6 +9,7 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js'; import {htmlEscape} from 'escape-goat'; import {createTippy} from '../modules/tippy.js'; import {confirmModal} from './comp/ConfirmModal.js'; +import {showErrorToast} from '../modules/toast.js'; const {appUrl, appSubUrl, csrfToken, i18n} = window.config; @@ -439,7 +440,7 @@ export function initGlobalButtons() { return; } // should never happen, otherwise there is a bug in code - alert('Nothing to hide'); + showErrorToast('Nothing to hide'); }); initGlobalShowModal(); diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index 103e71daae4..3d696be75b8 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -8,6 +8,7 @@ import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; import {renderPreviewPanelContent} from '../repo-editor.js'; import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; import {initTextExpander} from './TextExpander.js'; +import {showErrorToast} from '../../modules/toast.js'; let elementIdCounter = 0; @@ -26,7 +27,7 @@ export function validateTextareaNonEmpty($textarea) { $form[0]?.reportValidity(); } else { // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places. - alert('Require non-empty content'); + showErrorToast('Require non-empty content'); } return false; } diff --git a/web_src/js/features/repo-issue-content.js b/web_src/js/features/repo-issue-content.js index d66f6ad4a4d..fc916aea192 100644 --- a/web_src/js/features/repo-issue-content.js +++ b/web_src/js/features/repo-issue-content.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import {svg} from '../svg.js'; +import {showErrorToast} from '../modules/toast.js'; const {appSubUrl, csrfToken} = window.config; let i18nTextEdited; @@ -39,12 +40,12 @@ function showContentHistoryDetail(issueBaseUrl, commentId, historyId, itemTitleH if (resp.ok) { $dialog.modal('hide'); } else { - alert(resp.message); + showErrorToast(resp.message); } }); } } else { // required by eslint - window.alert(`unknown option item: ${optionItem}`); + showErrorToast(`unknown option item: ${optionItem}`); } }, onHide() { diff --git a/web_src/js/features/repo-issue-list.js b/web_src/js/features/repo-issue-list.js index 4d61de0ce59..5402f958f2a 100644 --- a/web_src/js/features/repo-issue-list.js +++ b/web_src/js/features/repo-issue-list.js @@ -4,6 +4,7 @@ import {toggleElem} from '../utils/dom.js'; import {htmlEscape} from 'escape-goat'; import {Sortable} from 'sortablejs'; import {confirmModal} from './comp/ConfirmModal.js'; +import {showErrorToast} from '../modules/toast.js'; function initRepoIssueListCheckboxes() { const $issueSelectAll = $('.issue-checkbox-all'); @@ -75,7 +76,7 @@ function initRepoIssueListCheckboxes() { ).then(() => { window.location.reload(); }).catch((reason) => { - window.alert(reason.responseJSON.error); + showErrorToast(reason.responseJSON.error); }); }); } diff --git a/web_src/js/modules/toast.js b/web_src/js/modules/toast.js new file mode 100644 index 00000000000..b0d02dc6447 --- /dev/null +++ b/web_src/js/modules/toast.js @@ -0,0 +1,60 @@ +import {htmlEscape} from 'escape-goat'; +import {svg} from '../svg.js'; + +const levels = { + info: { + icon: 'octicon-check', + background: 'var(--color-green)', + duration: 2500, + }, + warning: { + icon: 'gitea-exclamation', + background: 'var(--color-orange)', + duration: -1, // requires dismissal to hide + }, + error: { + icon: 'gitea-exclamation', + background: 'var(--color-red)', + duration: -1, // requires dismissal to hide + }, +}; + +// See https://github.com/apvarun/toastify-js#api for options +async function showToast(message, level, {gravity, position, duration, ...other} = {}) { + if (!message) return; + + const {default: Toastify} = await import(/* webpackChunkName: 'toastify' */'toastify-js'); + const {icon, background, duration: levelDuration} = levels[level ?? 'info']; + + const toast = Toastify({ + text: ` +
${svg(icon)}
+
${htmlEscape(message)}
+ + `, + escapeMarkup: false, + gravity: gravity ?? 'top', + position: position ?? 'center', + duration: duration ?? levelDuration, + style: {background}, + ...other, + }); + + toast.showToast(); + + toast.toastElement.querySelector('.toast-close').addEventListener('click', () => { + toast.removeElement(toast.toastElement); + }); +} + +export async function showInfoToast(message, opts) { + return await showToast(message, 'info', opts); +} + +export async function showWarningToast(message, opts) { + return await showToast(message, 'warning', opts); +} + +export async function showErrorToast(message, opts) { + return await showToast(message, 'error', opts); +} diff --git a/web_src/js/modules/toast.test.js b/web_src/js/modules/toast.test.js new file mode 100644 index 00000000000..b691aaebb63 --- /dev/null +++ b/web_src/js/modules/toast.test.js @@ -0,0 +1,17 @@ +import {test, expect} from 'vitest'; +import {showInfoToast, showErrorToast, showWarningToast} from './toast.js'; + +test('showInfoToast', async () => { + await showInfoToast('success 😀', {duration: -1}); + expect(document.querySelector('.toastify')).toBeTruthy(); +}); + +test('showWarningToast', async () => { + await showWarningToast('warning 😐', {duration: -1}); + expect(document.querySelector('.toastify')).toBeTruthy(); +}); + +test('showErrorToast', async () => { + await showErrorToast('error 🙁', {duration: -1}); + expect(document.querySelector('.toastify')).toBeTruthy(); +}); diff --git a/web_src/js/standalone/devtest.js b/web_src/js/standalone/devtest.js new file mode 100644 index 00000000000..d0ca511c0f3 --- /dev/null +++ b/web_src/js/standalone/devtest.js @@ -0,0 +1,11 @@ +import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.js'; + +document.getElementById('info-toast').addEventListener('click', () => { + showInfoToast('success 😀'); +}); +document.getElementById('warning-toast').addEventListener('click', () => { + showWarningToast('warning 😐'); +}); +document.getElementById('error-toast').addEventListener('click', () => { + showErrorToast('error 🙁'); +}); diff --git a/webpack.config.js b/webpack.config.js index fb442521e99..6070dae2479 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -73,6 +73,12 @@ export default { 'eventsource.sharedworker': [ fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.js', import.meta.url)), ], + ...(!isProduction && { + devtest: [ + fileURLToPath(new URL('web_src/js/standalone/devtest.js', import.meta.url)), + fileURLToPath(new URL('web_src/css/standalone/devtest.css', import.meta.url)), + ], + }), ...themes, }, devtool: false,