mirror of https://github.com/go-gitea/gitea
Frontend refactor: move Vue related code from `index.js` to `components` dir, and remove unused codes. (#17301)
* frontend refactor
* Apply suggestions from code review
Co-authored-by: delvh <dev.lh@web.de>
* Update templates/base/head.tmpl
Co-authored-by: delvh <dev.lh@web.de>
* Update docs/content/doc/developers/guidelines-frontend.md
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
* fix typo
* fix typo
* refactor PageData to pageData
* Apply suggestions from code review
Co-authored-by: delvh <dev.lh@web.de>
* Simply for the visual difference.
Co-authored-by: delvh <dev.lh@web.de>
* Revert "Apply suggestions from code review"
This reverts commit 4d78ad9b0e
.
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: 6543 <6543@obermui.de>
pull/16829/head
parent
96ff3e310f
commit
56362043d3
@ -0,0 +1,51 @@ |
||||
--- |
||||
date: "2021-10-13T16:00:00+02:00" |
||||
title: "Guidelines for Frontend Development" |
||||
slug: "guidelines-frontend" |
||||
weight: 20 |
||||
toc: false |
||||
draft: false |
||||
menu: |
||||
sidebar: |
||||
parent: "developers" |
||||
name: "Guidelines for Frontend" |
||||
weight: 20 |
||||
identifier: "guidelines-frontend" |
||||
--- |
||||
|
||||
# Guidelines for Frontend Development |
||||
|
||||
**Table of Contents** |
||||
|
||||
{{< toc >}} |
||||
|
||||
## Background |
||||
|
||||
Gitea uses [Less CSS](https://lesscss.org), [Fomantic-UI](https://fomantic-ui.com/introduction/getting-started.html) (based on [jQuery](https://api.jquery.com)) and [Vue2](https://vuejs.org/v2/guide/) for its frontend. |
||||
|
||||
The HTML pages are rendered by [Go HTML Template](https://pkg.go.dev/html/template) |
||||
|
||||
## General Guidelines |
||||
|
||||
We recommend [Google HTML/CSS Style Guide](https://google.github.io/styleguide/htmlcssguide.html) and [Google JavaScript Style Guide](https://google.github.io/styleguide/jsguide.html) |
||||
|
||||
### Gitea specific guidelines: |
||||
|
||||
1. Every feature (Fomantic-UI/jQuery module) should be put in separate files/directories. |
||||
2. HTML ids and classes should use kebab-case. |
||||
3. HTML ids and classes used in JavaScript should be unique for the whole project, and should contain 2-3 feature related keywords. We recommend to use the `js-` prefix for classes that are only used in JavaScript. |
||||
4. jQuery events across different features should use their own namespaces. |
||||
5. CSS styling for classes provided by frameworks should not be overwritten. Always use new class-names to overwrite framework styles. We recommend to use the `us-` prefix for user defined styles. |
||||
6. The backend can pass complex data to the frontend by using `ctx.PageData["myModuleData"] = map[]{}` |
||||
7. Simple pages and SEO-related pages use Go HTML Template render to generate static Fomantic-UI HTML output. Complex pages can use Vue2 (or Vue3 in future). |
||||
|
||||
## Legacy Problems and Solutions |
||||
|
||||
### Too much code in `web_src/index.js` |
||||
|
||||
Previously, most JavaScript code was written into `web_src/index.js` directly, making the file unmaintainable. |
||||
Try to keep this file small by creating new modules instead. These modules can be put in the `web_src/js/features` directory for now. |
||||
|
||||
### Vue2/Vue3 and JSX |
||||
|
||||
Gitea is using Vue2 now, we plan to upgrade to Vue3. We decided not to introduce JSX to keep the HTML and the JavaScript code separated. |
@ -0,0 +1,370 @@ |
||||
import Vue from 'vue'; |
||||
import {initVueSvg, vueDelimiters} from './VueComponentLoader.js'; |
||||
|
||||
const {AppSubUrl, AssetUrlPrefix, pageData} = window.config; |
||||
|
||||
function initVueComponents() { |
||||
Vue.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 { |
||||
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}/api/v1/repos/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}`]; |
||||
} |
||||
}, |
||||
|
||||
mounted() { |
||||
this.changeReposFilter(this.reposFilter); |
||||
$(this.$el).find('.poping.up').popup(); |
||||
$(this.$el).find('.dropdown').dropdown(); |
||||
this.setCheckboxes(); |
||||
Vue.nextTick(() => { |
||||
this.$refs.search.focus(); |
||||
}); |
||||
}, |
||||
|
||||
methods: { |
||||
changeTab(t) { |
||||
this.tab = t; |
||||
this.updateHistory(); |
||||
}, |
||||
|
||||
setCheckboxes() { |
||||
switch (this.archivedFilter) { |
||||
case 'unarchived': |
||||
$('#archivedFilterCheckbox').checkbox('set unchecked'); |
||||
break; |
||||
case 'archived': |
||||
$('#archivedFilterCheckbox').checkbox('set checked'); |
||||
break; |
||||
case 'both': |
||||
$('#archivedFilterCheckbox').checkbox('set indeterminate'); |
||||
break; |
||||
default: |
||||
this.archivedFilter = 'unarchived'; |
||||
$('#archivedFilterCheckbox').checkbox('set unchecked'); |
||||
break; |
||||
} |
||||
switch (this.privateFilter) { |
||||
case 'public': |
||||
$('#privateFilterCheckbox').checkbox('set unchecked'); |
||||
break; |
||||
case 'private': |
||||
$('#privateFilterCheckbox').checkbox('set checked'); |
||||
break; |
||||
case 'both': |
||||
$('#privateFilterCheckbox').checkbox('set indeterminate'); |
||||
break; |
||||
default: |
||||
this.privateFilter = 'both'; |
||||
$('#privateFilterCheckbox').checkbox('set indeterminate'); |
||||
break; |
||||
} |
||||
}, |
||||
|
||||
changeReposFilter(filter) { |
||||
this.reposFilter = filter; |
||||
this.repos = []; |
||||
this.page = 1; |
||||
Vue.set(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() { |
||||
switch (this.archivedFilter) { |
||||
case 'both': |
||||
this.archivedFilter = 'unarchived'; |
||||
break; |
||||
case 'unarchived': |
||||
this.archivedFilter = 'archived'; |
||||
break; |
||||
case 'archived': |
||||
this.archivedFilter = 'both'; |
||||
break; |
||||
default: |
||||
this.archivedFilter = 'unarchived'; |
||||
break; |
||||
} |
||||
this.page = 1; |
||||
this.repos = []; |
||||
this.setCheckboxes(); |
||||
Vue.set(this.counts, `${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`, 0); |
||||
this.searchRepos(); |
||||
}, |
||||
|
||||
togglePrivateFilter() { |
||||
switch (this.privateFilter) { |
||||
case 'both': |
||||
this.privateFilter = 'public'; |
||||
break; |
||||
case 'public': |
||||
this.privateFilter = 'private'; |
||||
break; |
||||
case 'private': |
||||
this.privateFilter = 'both'; |
||||
break; |
||||
default: |
||||
this.privateFilter = 'both'; |
||||
break; |
||||
} |
||||
this.page = 1; |
||||
this.repos = []; |
||||
this.setCheckboxes(); |
||||
Vue.set(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 = []; |
||||
Vue.set(this.counts, `${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`, 0); |
||||
this.searchRepos(); |
||||
}, |
||||
|
||||
searchRepos() { |
||||
this.isLoading = true; |
||||
|
||||
if (!this.reposTotalCount) { |
||||
const totalCountSearchURL = `${this.subUrl}/api/v1/repos/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; |
||||
$.getJSON(totalCountSearchURL, (_result, _textStatus, request) => { |
||||
this.reposTotalCount = request.getResponseHeader('X-Total-Count'); |
||||
}); |
||||
} |
||||
|
||||
const searchedMode = this.repoTypes[this.reposFilter].searchMode; |
||||
const searchedURL = this.searchURL; |
||||
const searchedQuery = this.searchQuery; |
||||
|
||||
$.getJSON(searchedURL, (result, _textStatus, request) => { |
||||
if (searchedURL === this.searchURL) { |
||||
this.repos = result.data; |
||||
const count = request.getResponseHeader('X-Total-Count'); |
||||
if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') { |
||||
this.reposTotalCount = count; |
||||
} |
||||
Vue.set(this.counts, `${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`, count); |
||||
this.finalPage = Math.ceil(count / this.searchLimit); |
||||
this.updateHistory(); |
||||
} |
||||
}).always(() => { |
||||
if (searchedURL === this.searchURL) { |
||||
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'; |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
|
||||
|
||||
function initDashboardRepoList() { |
||||
const el = document.getElementById('dashboard-repo-list'); |
||||
const dashboardRepoListData = pageData.dashboardRepoList || null; |
||||
if (!el || !dashboardRepoListData) return; |
||||
|
||||
initVueSvg(); |
||||
initVueComponents(); |
||||
new Vue({ |
||||
el, |
||||
delimiters: vueDelimiters, |
||||
data: () => { |
||||
return { |
||||
searchLimit: dashboardRepoListData.searchLimit || 0, |
||||
subUrl: AppSubUrl, |
||||
}; |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
export {initDashboardRepoList}; |
@ -0,0 +1,161 @@ |
||||
import Vue from 'vue'; |
||||
|
||||
function initRepoBranchTagDropdown(selector) { |
||||
$(selector).each(function () { |
||||
const $dropdown = $(this); |
||||
const $data = $dropdown.find('.data'); |
||||
const data = { |
||||
items: [], |
||||
mode: $data.data('mode'), |
||||
searchTerm: '', |
||||
noResults: '', |
||||
canCreateBranch: false, |
||||
menuVisible: false, |
||||
createTag: false, |
||||
active: 0 |
||||
}; |
||||
$data.find('.item').each(function () { |
||||
data.items.push({ |
||||
name: $(this).text(), |
||||
url: $(this).data('url'), |
||||
branch: $(this).hasClass('branch'), |
||||
tag: $(this).hasClass('tag'), |
||||
selected: $(this).hasClass('selected') |
||||
}); |
||||
}); |
||||
$data.remove(); |
||||
new Vue({ |
||||
el: this, |
||||
delimiters: ['${', '}'], |
||||
data, |
||||
computed: { |
||||
filteredItems() { |
||||
const items = this.items.filter((item) => { |
||||
return ((this.mode === 'branches' && item.branch) || (this.mode === 'tags' && item.tag)) && |
||||
(!this.searchTerm || item.name.toLowerCase().includes(this.searchTerm.toLowerCase())); |
||||
}); |
||||
|
||||
// no idea how to fix this so linting rule is disabled instead
|
||||
this.active = (items.length === 0 && this.showCreateNewBranch ? 0 : -1); // eslint-disable-line vue/no-side-effects-in-computed-properties
|
||||
return items; |
||||
}, |
||||
showNoResults() { |
||||
return this.filteredItems.length === 0 && !this.showCreateNewBranch; |
||||
}, |
||||
showCreateNewBranch() { |
||||
if (!this.canCreateBranch || !this.searchTerm) { |
||||
return false; |
||||
} |
||||
|
||||
return this.items.filter((item) => item.name.toLowerCase() === this.searchTerm.toLowerCase()).length === 0; |
||||
} |
||||
}, |
||||
|
||||
watch: { |
||||
menuVisible(visible) { |
||||
if (visible) { |
||||
this.focusSearchField(); |
||||
} |
||||
} |
||||
}, |
||||
|
||||
beforeMount() { |
||||
this.noResults = this.$el.getAttribute('data-no-results'); |
||||
this.canCreateBranch = this.$el.getAttribute('data-can-create-branch') === 'true'; |
||||
|
||||
document.body.addEventListener('click', (event) => { |
||||
if (this.$el.contains(event.target)) return; |
||||
if (this.menuVisible) { |
||||
Vue.set(this, 'menuVisible', false); |
||||
} |
||||
}); |
||||
}, |
||||
|
||||
methods: { |
||||
selectItem(item) { |
||||
const prev = this.getSelected(); |
||||
if (prev !== null) { |
||||
prev.selected = false; |
||||
} |
||||
item.selected = true; |
||||
window.location.href = item.url; |
||||
}, |
||||
createNewBranch() { |
||||
if (!this.showCreateNewBranch) return; |
||||
$(this.$refs.newBranchForm).trigger('submit'); |
||||
}, |
||||
focusSearchField() { |
||||
Vue.nextTick(() => { |
||||
this.$refs.searchField.focus(); |
||||
}); |
||||
}, |
||||
getSelected() { |
||||
for (let i = 0, j = this.items.length; i < j; ++i) { |
||||
if (this.items[i].selected) return this.items[i]; |
||||
} |
||||
return null; |
||||
}, |
||||
getSelectedIndexInFiltered() { |
||||
for (let i = 0, j = this.filteredItems.length; i < j; ++i) { |
||||
if (this.filteredItems[i].selected) return i; |
||||
} |
||||
return -1; |
||||
}, |
||||
scrollToActive() { |
||||
let el = this.$refs[`listItem${this.active}`]; |
||||
if (!el || !el.length) return; |
||||
if (Array.isArray(el)) { |
||||
el = el[0]; |
||||
} |
||||
|
||||
const cont = this.$refs.scrollContainer; |
||||
if (el.offsetTop < cont.scrollTop) { |
||||
cont.scrollTop = el.offsetTop; |
||||
} else if (el.offsetTop + el.clientHeight > cont.scrollTop + cont.clientHeight) { |
||||
cont.scrollTop = el.offsetTop + el.clientHeight - cont.clientHeight; |
||||
} |
||||
}, |
||||
keydown(event) { |
||||
if (event.keyCode === 40) { // arrow down
|
||||
event.preventDefault(); |
||||
|
||||
if (this.active === -1) { |
||||
this.active = this.getSelectedIndexInFiltered(); |
||||
} |
||||
|
||||
if (this.active + (this.showCreateNewBranch ? 0 : 1) >= this.filteredItems.length) { |
||||
return; |
||||
} |
||||
this.active++; |
||||
this.scrollToActive(); |
||||
} else if (event.keyCode === 38) { // arrow up
|
||||
event.preventDefault(); |
||||
|
||||
if (this.active === -1) { |
||||
this.active = this.getSelectedIndexInFiltered(); |
||||
} |
||||
|
||||
if (this.active <= 0) { |
||||
return; |
||||
} |
||||
this.active--; |
||||
this.scrollToActive(); |
||||
} else if (event.keyCode === 13) { // enter
|
||||
event.preventDefault(); |
||||
|
||||
if (this.active >= this.filteredItems.length) { |
||||
this.createNewBranch(); |
||||
} else if (this.active >= 0) { |
||||
this.selectItem(this.filteredItems[this.active]); |
||||
} |
||||
} else if (event.keyCode === 27) { // escape
|
||||
event.preventDefault(); |
||||
this.menuVisible = false; |
||||
} |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
export {initRepoBranchTagDropdown}; |
@ -0,0 +1,52 @@ |
||||
import Vue from 'vue'; |
||||
import {svgs} from '../svg.js'; |
||||
|
||||
const vueDelimiters = ['${', '}']; |
||||
|
||||
let vueEnvInited = false; |
||||
function initVueEnv() { |
||||
if (vueEnvInited) return; |
||||
vueEnvInited = true; |
||||
|
||||
const isProd = window.config.IsProd; |
||||
Vue.config.productionTip = false; |
||||
Vue.config.devtools = !isProd; |
||||
} |
||||
|
||||
let vueSvgInited = false; |
||||
function initVueSvg() { |
||||
if (vueSvgInited) return; |
||||
vueSvgInited = true; |
||||
|
||||
// register svg icon vue components, e.g. <octicon-repo size="16"/>
|
||||
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"'); |
||||
|
||||
Vue.component(name, { |
||||
props: { |
||||
size: { |
||||
type: String, |
||||
default: '16', |
||||
}, |
||||
}, |
||||
template, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
|
||||
function initVueApp(el, opts = {}) { |
||||
if (typeof el === 'string') { |
||||
el = document.querySelector(el); |
||||
} |
||||
if (!el) return null; |
||||
|
||||
return new Vue(Object.assign({ |
||||
el, |
||||
delimiters: vueDelimiters, |
||||
}, opts)); |
||||
} |
||||
|
||||
export {vueDelimiters, initVueEnv, initVueSvg, initVueApp}; |
Loading…
Reference in new issue