From e82f1b15c7120ad13fd3b67cf7e2c6cb9915c22d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 14 Mar 2023 12:09:06 +0800 Subject: [PATCH] Refactor dashboard repo list to Vue SFC (#23405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Similar to #23394 The dashboard repo list mixes jQuery/Fomantic UI/Vue together, it's very diffcult to maintain and causes unfixable a11y problems. This PR uses two steps to refactor the repo list: 1. move `data-` attributes to JS object and use Vue data as much as possible https://github.com/go-gitea/gitea/pull/23405/commits/d3adc0dcacf7de87b9819277e6598ac3993bbfa3 2. move the code into a Vue SFC https://github.com/go-gitea/gitea/pull/23405/commits/7ebe55df6e67adfd272a4bf0a96ad6688edf661f Total: +516 −585 Screenshots:
![image](https://user-images.githubusercontent.com/2114189/224271457-a23e05be-d7d3-4247-a803-f0ee30c36f44.png) ![image](https://user-images.githubusercontent.com/2114189/224271504-76fbd3da-4d7a-4725-b0d1-fbff83caac63.png) ![image](https://user-images.githubusercontent.com/2114189/224271845-f007cadf-6c49-46bd-a65c-a3fc75bdba3b.png)
--------- Co-authored-by: John Olheiser --- templates/user/dashboard/repolist.tmpl | 233 +++------- web_src/js/components/DashboardRepoList.js | 345 -------------- web_src/js/components/DashboardRepoList.vue | 432 ++++++++++++++++++ .../js/components/RepoActivityTopAuthors.vue | 9 +- .../js/components/RepoBranchTagDropdown.js | 3 +- web_src/js/components/VueComponentLoader.js | 49 -- web_src/js/index.js | 4 +- web_src/js/svg.js | 26 +- 8 files changed, 516 insertions(+), 585 deletions(-) delete mode 100644 web_src/js/components/DashboardRepoList.js create mode 100644 web_src/js/components/DashboardRepoList.vue delete mode 100644 web_src/js/components/VueComponentLoader.js diff --git a/templates/user/dashboard/repolist.tmpl b/templates/user/dashboard/repolist.tmpl index 97234176bd..0a8f427f9d 100644 --- a/templates/user/dashboard/repolist.tmpl +++ b/templates/user/dashboard/repolist.tmpl @@ -1,181 +1,54 @@ -
- -
+ + +
diff --git a/web_src/js/components/DashboardRepoList.js b/web_src/js/components/DashboardRepoList.js deleted file mode 100644 index 2328cc83a9..0000000000 --- a/web_src/js/components/DashboardRepoList.js +++ /dev/null @@ -1,345 +0,0 @@ -import {createApp, nextTick} from 'vue'; -import $ from 'jquery'; -import {initVueSvg, vueDelimiters} from './VueComponentLoader.js'; -import {initTooltip} from '../modules/tippy.js'; - -const {appSubUrl, assetUrlPrefix, pageData} = window.config; - -function initVueComponents(app) { - app.component('repo-search', { - delimiters: vueDelimiters, - props: { - searchLimit: { - type: Number, - default: 10 - }, - subUrl: { - type: String, - required: true - }, - uid: { - type: Number, - default: 0 - }, - teamId: { - type: Number, - required: false, - default: 0 - }, - organizations: { - type: Array, - default: () => [], - }, - isOrganization: { - type: Boolean, - default: true - }, - canCreateOrganization: { - type: Boolean, - default: false - }, - organizationsTotalCount: { - type: Number, - default: 0 - }, - moreReposLink: { - type: String, - default: '' - } - }, - - data() { - const params = new URLSearchParams(window.location.search); - - let tab = params.get('repo-search-tab'); - if (!tab) { - tab = 'repos'; - } - - let reposFilter = params.get('repo-search-filter'); - if (!reposFilter) { - reposFilter = 'all'; - } - - let privateFilter = params.get('repo-search-private'); - if (!privateFilter) { - privateFilter = 'both'; - } - - let archivedFilter = params.get('repo-search-archived'); - if (!archivedFilter) { - archivedFilter = 'unarchived'; - } - - let searchQuery = params.get('repo-search-query'); - if (!searchQuery) { - searchQuery = ''; - } - - let page = 1; - try { - page = parseInt(params.get('repo-search-page')); - } catch { - // noop - } - if (!page) { - page = 1; - } - - return { - hasMounted: false, // accessing $refs in computed() need to wait for mounted - tab, - repos: [], - reposTotalCount: 0, - reposFilter, - archivedFilter, - privateFilter, - page, - finalPage: 1, - searchQuery, - isLoading: false, - staticPrefix: assetUrlPrefix, - counts: {}, - repoTypes: { - all: { - searchMode: '', - }, - forks: { - searchMode: 'fork', - }, - mirrors: { - searchMode: 'mirror', - }, - sources: { - searchMode: 'source', - }, - collaborative: { - searchMode: 'collaborative', - }, - } - }; - }, - - computed: { - // used in `repolist.tmpl` - showMoreReposLink() { - return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`]; - }, - searchURL() { - return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery - }&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode - }${this.reposFilter !== 'all' ? '&exclusive=1' : '' - }${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : '' - }${this.privateFilter === 'private' ? '&is_private=true' : ''}${this.privateFilter === 'public' ? '&is_private=false' : '' - }`; - }, - repoTypeCount() { - return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`]; - }, - checkboxArchivedFilterTitle() { - return this.hasMounted && this.$refs.checkboxArchivedFilter?.getAttribute(`data-title-${this.archivedFilter}`); - }, - checkboxArchivedFilterProps() { - return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'}; - }, - checkboxPrivateFilterTitle() { - return this.hasMounted && this.$refs.checkboxPrivateFilter?.getAttribute(`data-title-${this.privateFilter}`); - }, - checkboxPrivateFilterProps() { - return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'}; - }, - }, - - mounted() { - const el = document.getElementById('dashboard-repo-list'); - this.changeReposFilter(this.reposFilter); - for (const elTooltip of el.querySelectorAll('.tooltip')) { - initTooltip(elTooltip); - } - $(el).find('.dropdown').dropdown(); - nextTick(() => { - this.$refs.search.focus(); - }); - - this.hasMounted = true; - }, - - methods: { - changeTab(t) { - this.tab = t; - this.updateHistory(); - }, - - changeReposFilter(filter) { - this.reposFilter = filter; - this.repos = []; - this.page = 1; - this.counts[`${filter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); - }, - - updateHistory() { - const params = new URLSearchParams(window.location.search); - - if (this.tab === 'repos') { - params.delete('repo-search-tab'); - } else { - params.set('repo-search-tab', this.tab); - } - - if (this.reposFilter === 'all') { - params.delete('repo-search-filter'); - } else { - params.set('repo-search-filter', this.reposFilter); - } - - if (this.privateFilter === 'both') { - params.delete('repo-search-private'); - } else { - params.set('repo-search-private', this.privateFilter); - } - - if (this.archivedFilter === 'unarchived') { - params.delete('repo-search-archived'); - } else { - params.set('repo-search-archived', this.archivedFilter); - } - - if (this.searchQuery === '') { - params.delete('repo-search-query'); - } else { - params.set('repo-search-query', this.searchQuery); - } - - if (this.page === 1) { - params.delete('repo-search-page'); - } else { - params.set('repo-search-page', `${this.page}`); - } - - const queryString = params.toString(); - if (queryString) { - window.history.replaceState({}, '', `?${queryString}`); - } else { - window.history.replaceState({}, '', window.location.pathname); - } - }, - - toggleArchivedFilter() { - if (this.archivedFilter === 'unarchived') { - this.archivedFilter = 'archived'; - } else if (this.archivedFilter === 'archived') { - this.archivedFilter = 'both'; - } else { // including both - this.archivedFilter = 'unarchived'; - } - this.page = 1; - this.repos = []; - this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); - }, - - togglePrivateFilter() { - if (this.privateFilter === 'both') { - this.privateFilter = 'public'; - } else if (this.privateFilter === 'public') { - this.privateFilter = 'private'; - } else { // including private - this.privateFilter = 'both'; - } - this.page = 1; - this.repos = []; - this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); - }, - - - changePage(page) { - this.page = page; - if (this.page > this.finalPage) { - this.page = this.finalPage; - } - if (this.page < 1) { - this.page = 1; - } - this.repos = []; - this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); - }, - - async searchRepos() { - this.isLoading = true; - - const searchedMode = this.repoTypes[this.reposFilter].searchMode; - const searchedURL = this.searchURL; - const searchedQuery = this.searchQuery; - - let response, json; - try { - if (!this.reposTotalCount) { - const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; - response = await fetch(totalCountSearchURL); - this.reposTotalCount = response.headers.get('X-Total-Count'); - } - - response = await fetch(searchedURL); - json = await response.json(); - } catch { - if (searchedURL === this.searchURL) { - this.isLoading = false; - } - return; - } - - if (searchedURL === this.searchURL) { - this.repos = json.data; - const count = response.headers.get('X-Total-Count'); - if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') { - this.reposTotalCount = count; - } - this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = count; - this.finalPage = Math.ceil(count / this.searchLimit); - this.updateHistory(); - this.isLoading = false; - } - }, - - repoIcon(repo) { - if (repo.fork) { - return 'octicon-repo-forked'; - } else if (repo.mirror) { - return 'octicon-mirror'; - } else if (repo.template) { - return `octicon-repo-template`; - } else if (repo.private) { - return 'octicon-lock'; - } else if (repo.internal) { - return 'octicon-repo'; - } - return 'octicon-repo'; - } - }, - - template: document.getElementById('dashboard-repo-list-template'), - }); -} - -export function initDashboardRepoList() { - const el = document.getElementById('dashboard-repo-list'); - const dashboardRepoListData = pageData.dashboardRepoList || null; - if (!el || !dashboardRepoListData) return; - - const app = createApp({ - delimiters: vueDelimiters, - data() { - return { - searchLimit: dashboardRepoListData.searchLimit || 0, - subUrl: appSubUrl, - uid: dashboardRepoListData.uid || 0, - }; - }, - }); - initVueSvg(app); - initVueComponents(app); - app.mount(el); -} diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue new file mode 100644 index 0000000000..e295910fd0 --- /dev/null +++ b/web_src/js/components/DashboardRepoList.vue @@ -0,0 +1,432 @@ + + + diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue index 37b6df9187..294ee6f7bc 100644 --- a/web_src/js/components/RepoActivityTopAuthors.vue +++ b/web_src/js/components/RepoActivityTopAuthors.vue @@ -51,7 +51,7 @@ diff --git a/web_src/js/components/RepoBranchTagDropdown.js b/web_src/js/components/RepoBranchTagDropdown.js index e1bf35c129..a8945b82d1 100644 --- a/web_src/js/components/RepoBranchTagDropdown.js +++ b/web_src/js/components/RepoBranchTagDropdown.js @@ -1,6 +1,5 @@ import {createApp, nextTick} from 'vue'; import $ from 'jquery'; -import {vueDelimiters} from './VueComponentLoader.js'; export function initRepoBranchTagDropdown(selector) { $(selector).each(function (dropdownIndex, elRoot) { @@ -39,7 +38,7 @@ export function initRepoBranchTagDropdown(selector) { } const view = createApp({ - delimiters: vueDelimiters, + delimiters: ['${', '}'], data() { return data; }, diff --git a/web_src/js/components/VueComponentLoader.js b/web_src/js/components/VueComponentLoader.js deleted file mode 100644 index 33ebf95eff..0000000000 --- a/web_src/js/components/VueComponentLoader.js +++ /dev/null @@ -1,49 +0,0 @@ -import {createApp} from 'vue'; -import {svgs} from '../svg.js'; - -export const vueDelimiters = ['${', '}']; - -let vueEnvInited = false; -export function initVueEnv() { - if (vueEnvInited) return; - vueEnvInited = true; - - // As far as I could tell, this is no longer possible. - // But there seem not to be a guide what to do instead. - // const isProd = window.config.runModeIsProd; - // Vue.config.devtools = !isProd; -} - -let vueSvgInited = false; -export function initVueSvg(app) { - if (vueSvgInited) return; - vueSvgInited = true; - - // register svg icon vue components, e.g. - for (const [name, htmlString] of Object.entries(svgs)) { - const template = htmlString - .replace(/height="[0-9]+"/, 'v-bind:height="size"') - .replace(/width="[0-9]+"/, 'v-bind:width="size"'); - - app.component(name, { - props: { - size: { - type: String, - default: '16', - }, - }, - template, - }); - } -} - -export function initVueApp(el, opts = {}) { - if (typeof el === 'string') { - el = document.querySelector(el); - } - if (!el) return null; - - return createApp( - {delimiters: vueDelimiters, ...opts} - ).mount(el); -} diff --git a/web_src/js/index.js b/web_src/js/index.js index 6b4f4ef3eb..480661118b 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -2,9 +2,8 @@ import './bootstrap.js'; import $ from 'jquery'; -import {initVueEnv} from './components/VueComponentLoader.js'; import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue'; -import {initDashboardRepoList} from './components/DashboardRepoList.js'; +import {initDashboardRepoList} from './components/DashboardRepoList.vue'; import {attachTribute} from './features/tribute.js'; import {initGlobalCopyToClipboardListener} from './features/clipboard.js'; @@ -100,7 +99,6 @@ $.fn.tab.settings.silent = true; // Disable the behavior of fomantic to toggle the checkbox when you press enter on a checkbox element. $.fn.checkbox.settings.enableEnterKey = false; -initVueEnv(); $(document).ready(() => { initGlobalCommon(); diff --git a/web_src/js/svg.js b/web_src/js/svg.js index 6476f16bfb..9eabca3fd3 100644 --- a/web_src/js/svg.js +++ b/web_src/js/svg.js @@ -31,8 +31,17 @@ import octiconSkip from '../../public/img/svg/octicon-skip.svg'; import octiconMeter from '../../public/img/svg/octicon-meter.svg'; import octiconBlocked from '../../public/img/svg/octicon-blocked.svg'; import octiconSync from '../../public/img/svg/octicon-sync.svg'; +import octiconFilter from '../../public/img/svg/octicon-filter.svg'; +import octiconPlus from '../../public/img/svg/octicon-plus.svg'; +import octiconSearch from '../../public/img/svg/octicon-search.svg'; +import octiconArchive from '../../public/img/svg/octicon-archive.svg'; +import octiconStar from '../../public/img/svg/octicon-star.svg'; +import giteaDoubleChevronLeft from '../../public/img/svg/gitea-double-chevron-left.svg'; +import giteaDoubleChevronRight from '../../public/img/svg/gitea-double-chevron-right.svg'; +import octiconChevronLeft from '../../public/img/svg/octicon-chevron-left.svg'; +import octiconOrganization from '../../public/img/svg/octicon-organization.svg'; -export const svgs = { +const svgs = { 'octicon-blocked': octiconBlocked, 'octicon-check-circle-fill': octiconCheckCircleFill, 'octicon-chevron-down': octiconChevronDown, @@ -66,14 +75,25 @@ export const svgs = { 'octicon-triangle-down': octiconTriangleDown, 'octicon-x': octiconX, 'octicon-x-circle-fill': octiconXCircleFill, + 'octicon-filter': octiconFilter, + 'octicon-plus': octiconPlus, + 'octicon-search': octiconSearch, + 'octicon-archive': octiconArchive, + 'octicon-star': octiconStar, + 'gitea-double-chevron-left': giteaDoubleChevronLeft, + 'gitea-double-chevron-right': giteaDoubleChevronRight, + 'octicon-chevron-left': octiconChevronLeft, + 'octicon-organization': octiconOrganization, }; +// TODO: use a more general approach to access SVG icons. At the moment, developers must check, pick and fill the names manually, most of the SVG icons in assets couldn't be used directly. + const parser = new DOMParser(); const serializer = new XMLSerializer(); -// retrieve a HTML string for given SVG icon name, size and additional classes +// retrieve an HTML string for given SVG icon name, size and additional classes export function svg(name, size = 16, className = '') { - if (!(name in svgs)) return ''; + if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`); if (size === 16 && !className) return svgs[name]; const document = parser.parseFromString(svgs[name], 'image/svg+xml');