mirror of https://github.com/go-gitea/gitea
parent
429258cff3
commit
50f6aebb03
@ -0,0 +1,53 @@ |
||||
--- |
||||
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 Text Template](https://pkg.go.dev/text/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) |
||||
|
||||
Guidelines specialized for Gitea: |
||||
|
||||
1. Every feature (Fomantic-UI/jQuery module) should be put in separated files/directories. |
||||
2. HTML id/css-class-name should use kebab-case. |
||||
3. HTML id/css-class-name used by JavaScript top-level selector should be unique in whole project, |
||||
and should contain 2-3 feature related keywords. Recommend to use `js-` prefix for CSS names for JavaScript usage only. |
||||
4. jQuery events across different features should use their own namespaces. |
||||
5. CSS styles provided by frameworks should not be overwritten by framework's selectors globally. |
||||
Always use new defined CSS names to overwrite framework styles. Recommend to use `us-` prefix for user defined styles. |
||||
6. Backend can pass data to frontend (JavaScript) by `ctx.PageData["myModuleData"] = map{}` |
||||
7. Simple pages and SEO-related pages use Go Text Template render to generate static Fomantic-UI HTML output. Complex pages can use Vue2 (or Vue3 in future). |
||||
|
||||
## Legacy Problems and Solutions |
||||
|
||||
### Too many codes in `web_src/index.js` |
||||
|
||||
In history, many JavaScript codes are written into `web_src/index.js` directly, which becomes too big to maintain. |
||||
We should split this file into small modules, the separated files can be put in `web_src/js/features` for the first step. |
||||
|
||||
### Vue2/Vue3 and JSX |
||||
|
||||
Gitea is using Vue2 now, we have plan to upgrade to Vue3. We decide not to introduce JSX now to make sure the HTML and JavaScript codes are not mixed together. |
@ -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