* 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>
tags/v1.16.0-rc1
@@ -3,8 +3,6 @@ reportUnusedDisableDirectives: true | |||
ignorePatterns: | |||
- /web_src/js/vendor | |||
- /templates/repo/activity.tmpl | |||
- /templates/repo/view_file.tmpl | |||
parserOptions: | |||
sourceType: module |
@@ -9,6 +9,8 @@ _test | |||
# IntelliJ | |||
.idea | |||
# Goland's output filename can not be set manually | |||
/go_build_* | |||
# MS VSCode | |||
.vscode |
@@ -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. |
@@ -132,7 +132,14 @@ See `make help` for all available `make` targets. Also see [`.drone.yml`](https: | |||
To run and continuously rebuild when source files change: | |||
```bash | |||
# for both frontend and backend | |||
make watch | |||
# or: watch frontend files (html/js/css) only | |||
make watch-frontend | |||
# or: watch backend files (go) only | |||
make watch-backend | |||
``` | |||
On macOS, watching all backend source files may hit the default open files limit which can be increased via `ulimit -n 12288` for the current shell or in your shell startup file for all future shells. | |||
@@ -167,7 +174,9 @@ make revive vet misspell-check | |||
### Working on JS and CSS | |||
Either use the `watch-frontend` target mentioned above or just build once: | |||
Frontend development should follow [Guidelines for Frontend Development](./guidelines-frontend.md) | |||
To build with frontend resources, either use the `watch-frontend` target mentioned above or just build once: | |||
```bash | |||
make build && ./gitea |
@@ -51,7 +51,7 @@ type Context struct { | |||
Resp ResponseWriter | |||
Req *http.Request | |||
Data map[string]interface{} // data used by MVC templates | |||
PageData map[string]interface{} // data used by JavaScript modules in one page | |||
PageData map[string]interface{} // data used by JavaScript modules in one page, it's `window.config.pageData` | |||
Render Render | |||
translation.Locale | |||
Cache cache.Cache | |||
@@ -645,9 +645,10 @@ func Contexter() func(next http.Handler) http.Handler { | |||
"CurrentURL": setting.AppSubURL + req.URL.RequestURI(), | |||
"PageStartTime": startTime, | |||
"Link": link, | |||
"IsProd": setting.IsProd(), | |||
}, | |||
} | |||
// PageData is passed by reference, and it will be rendered to `window.config.PageData` in `head.tmpl` for JavaScript modules | |||
// PageData is passed by reference, and it will be rendered to `window.config.pageData` in `head.tmpl` for JavaScript modules | |||
ctx.PageData = map[string]interface{}{} | |||
ctx.Data["PageData"] = ctx.PageData | |||
@@ -60,7 +60,7 @@ func Activity(ctx *context.Context) { | |||
return | |||
} | |||
if ctx.Data["ActivityTopAuthors"], err = models.GetActivityStatsTopAuthors(ctx.Repo.Repository, timeFrom, 10); err != nil { | |||
if ctx.PageData["repoActivityTopAuthors"], err = models.GetActivityStatsTopAuthors(ctx.Repo.Repository, timeFrom, 10); err != nil { | |||
ctx.ServerError("GetActivityStatsTopAuthors", err) | |||
return | |||
} |
@@ -106,7 +106,10 @@ func Dashboard(ctx *context.Context) { | |||
ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Tr("dashboard") | |||
ctx.Data["PageIsDashboard"] = true | |||
ctx.Data["PageIsNews"] = true | |||
ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum | |||
ctx.PageData["dashboardRepoList"] = map[string]interface{}{ | |||
"searchLimit": setting.UI.User.RepoPagingNum, | |||
} | |||
if setting.Service.EnableUserHeatmap { | |||
data, err := models.GetUserHeatmapDataByUserTeam(ctxUser, ctx.Org.Team, ctx.User) |
@@ -1,6 +1,6 @@ | |||
<!DOCTYPE html> | |||
<html lang="{{.Lang}}" class="theme-{{.SignedUser.Theme}}"> | |||
<head data-suburl="{{AppSubUrl}}"> | |||
<head> | |||
<meta charset="utf-8"> | |||
<meta name="viewport" content="width=device-width, initial-scale=1"> | |||
<title>{{if .Title}}{{.Title | RenderEmojiPlain}} - {{end}} {{if .Repository.Name}}{{.Repository.Name}} - {{end}}{{AppName}} </title> | |||
@@ -12,15 +12,6 @@ | |||
<meta name="keywords" content="{{MetaKeywords}}"> | |||
<meta name="referrer" content="no-referrer" /> | |||
<meta name="_csrf" content="{{.CsrfToken}}" /> | |||
{{if .IsSigned}} | |||
<meta name="_uid" content="{{.SignedUser.ID}}" /> | |||
{{end}} | |||
{{if .ContextUser}} | |||
<meta name="_context_uid" content="{{.ContextUser.ID}}" /> | |||
{{end}} | |||
{{if .SearchLimit}} | |||
<meta name="_search_limit" content="{{.SearchLimit}}" /> | |||
{{end}} | |||
{{if .GoGetImport}} | |||
<meta name="go-import" content="{{.GoGetImport}} git {{.CloneLink.HTTPS}}"> | |||
<meta name="go-source" content="{{.GoGetImport}} _ {{.GoDocDirectory}} {{.GoDocFile}}"> | |||
@@ -31,10 +22,11 @@ | |||
AppVer: '{{AppVer}}', | |||
AppSubUrl: '{{AppSubUrl}}', | |||
AssetUrlPrefix: '{{AssetUrlPrefix}}', | |||
IsProd: {{.IsProd}}, | |||
CustomEmojis: {{CustomEmojis}}, | |||
UseServiceWorker: {{UseServiceWorker}}, | |||
csrf: '{{.CsrfToken}}', | |||
PageData: {{ .PageData }}, | |||
pageData: {{ .PageData }}, | |||
HighlightJS: {{if .RequireHighlightJS}}true{{else}}false{{end}}, | |||
SimpleMDE: {{if .RequireSimpleMDE}}true{{else}}false{{end}}, | |||
Tribute: {{if .RequireTribute}}true{{else}}false{{end}}, |
@@ -108,11 +108,8 @@ | |||
{{.i18n.Tr "repo.activity.git_stats_and_deletions" }} | |||
<strong class="text red">{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n") .Activity.Code.Deletions }}</strong>. | |||
</div> | |||
<div class="ui attached segment" id="app"> | |||
<script type="text/javascript"> | |||
var ActivityTopAuthors = {{Json .ActivityTopAuthors | SafeJS}}; | |||
</script> | |||
<activity-top-authors :data="activityTopAuthors" /> | |||
<div class="ui attached segment"> | |||
<div id="repo-activity-top-authors-chart"></div> | |||
</div> | |||
</div> | |||
{{end}} | |||
@@ -126,7 +123,7 @@ | |||
<div class="list"> | |||
{{range .Activity.PublishedReleases}} | |||
<p class="desc"> | |||
<div class="ui green label">{{$.i18n.Tr "repo.activity.published_release_label"}}</div> | |||
<span class="ui green label">{{$.i18n.Tr "repo.activity.published_release_label"}}</span> | |||
{{.TagName}} | |||
{{if not .IsTag}} | |||
<a class="title" href="{{$.RepoLink}}/src/{{.TagName | EscapePound}}">{{.Title | RenderEmoji}}</a> | |||
@@ -145,7 +142,7 @@ | |||
<div class="list"> | |||
{{range .Activity.MergedPRs}} | |||
<p class="desc"> | |||
<div class="ui purple label">{{$.i18n.Tr "repo.activity.merged_prs_label"}}</div> | |||
<span class="ui purple label">{{$.i18n.Tr "repo.activity.merged_prs_label"}}</span> | |||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji}}</a> | |||
{{TimeSinceUnix .MergedUnix $.Lang}} | |||
</p> | |||
@@ -161,7 +158,7 @@ | |||
<div class="list"> | |||
{{range .Activity.OpenedPRs}} | |||
<p class="desc"> | |||
<div class="ui green label">{{$.i18n.Tr "repo.activity.opened_prs_label"}}</div> | |||
<span class="ui green label">{{$.i18n.Tr "repo.activity.opened_prs_label"}}</span> | |||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji}}</a> | |||
{{TimeSinceUnix .Issue.CreatedUnix $.Lang}} | |||
</p> | |||
@@ -177,7 +174,7 @@ | |||
<div class="list"> | |||
{{range .Activity.ClosedIssues}} | |||
<p class="desc"> | |||
<div class="ui red label">{{$.i18n.Tr "repo.activity.closed_issue_label"}}</div> | |||
<span class="ui red label">{{$.i18n.Tr "repo.activity.closed_issue_label"}}</span> | |||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji}}</a> | |||
{{TimeSinceUnix .ClosedUnix $.Lang}} | |||
</p> | |||
@@ -193,7 +190,7 @@ | |||
<div class="list"> | |||
{{range .Activity.OpenedIssues}} | |||
<p class="desc"> | |||
<div class="ui green label">{{$.i18n.Tr "repo.activity.new_issue_label"}}</div> | |||
<span class="ui green label">{{$.i18n.Tr "repo.activity.new_issue_label"}}</span> | |||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji}}</a> | |||
{{TimeSinceUnix .CreatedUnix $.Lang}} | |||
</p> | |||
@@ -212,7 +209,7 @@ | |||
<div class="list"> | |||
{{range .Activity.UnresolvedIssues}} | |||
<p class="desc"> | |||
<div class="ui green label">{{$.i18n.Tr "repo.activity.unresolved_conv_label"}}</div> | |||
<span class="ui green label">{{$.i18n.Tr "repo.activity.unresolved_conv_label"}}</span> | |||
#{{.Index}} | |||
{{if .IsPull}} | |||
<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji}}</a> |
@@ -9,7 +9,7 @@ | |||
{{end}} | |||
<!-- I know, there is probably a better way to do this (moved from sidebar.tmpl, original author: 6543 @ 2021-02-28) --> | |||
<!-- Agree, there should be a better way, eg: introduce window.config.PageData (original author: wxiaoguang @ 2021-09-05) --> | |||
<!-- Agree, there should be a better way, eg: introduce window.config.pageData (original author: wxiaoguang @ 2021-09-05) --> | |||
<input type="hidden" id="repolink" value="{{$.RepoRelPath}}"> | |||
<input type="hidden" id="repoId" value="{{.Repository.ID}}"> | |||
<input type="hidden" id="issueIndex" value="{{.Issue.Index}}"/> |
@@ -131,13 +131,3 @@ | |||
</div> | |||
</div> | |||
</div> | |||
<script> | |||
function submitDeleteForm() { | |||
var message = prompt("{{.i18n.Tr "repo.delete_confirm_message"}}\n\n{{.i18n.Tr "repo.delete_commit_summary"}}", "Delete '{{.TreeName}}'"); | |||
if (message != null) { | |||
$("#delete-message").val(message); | |||
$("#delete-file-form").submit() | |||
} | |||
} | |||
</script> |
@@ -1,8 +1,7 @@ | |||
<div id="app" class="six wide column"> | |||
<div id="dashboard-repo-list" class="six wide column"> | |||
<repo-search | |||
:search-limit="searchLimit" | |||
:suburl="suburl" | |||
:uid="uid" | |||
:sub-url="subUrl" | |||
{{if .Team}} | |||
:team-id="{{.Team.ID}}" | |||
{{end}} | |||
@@ -31,7 +30,7 @@ | |||
{{.i18n.Tr "home.my_repos"}} | |||
<span class="ui grey label ml-3">${reposTotalCount}</span> | |||
</div> | |||
<a class="poping up" :href="suburl + '/repo/create'" data-content="{{.i18n.Tr "new_repo"}}" data-variation="tiny inverted" data-position="left center"> | |||
<a class="poping up" :href="subUrl + '/repo/create'" data-content="{{.i18n.Tr "new_repo"}}" data-variation="tiny inverted" data-position="left center"> | |||
{{svg "octicon-plus"}} | |||
<span class="sr-only">{{.i18n.Tr "new_repo"}}</span> | |||
</a> | |||
@@ -122,7 +121,7 @@ | |||
<div v-if="repos.length" class="ui attached table segment rounded-bottom"> | |||
<ul class="repo-owner-name-list"> | |||
<li v-for="repo in repos" :class="{'private': repo.private || repo.internal}"> | |||
<a class="repo-list-link df ac sb" :href="suburl + '/' + repo.full_name"> | |||
<a class="repo-list-link df ac sb" :href="subUrl + '/' + repo.full_name"> | |||
<div class="text truncate item-name f1"> | |||
<component v-bind:is="repoIcon(repo)" size="16"></component> | |||
<strong>${repo.full_name}</strong> | |||
@@ -168,7 +167,7 @@ | |||
{{.i18n.Tr "home.my_orgs"}} | |||
<span class="ui grey label ml-3">${organizationsTotalCount}</span> | |||
</div> | |||
<a v-if="canCreateOrganization" class="poping up" :href="suburl + '/org/create'" data-content="{{.i18n.Tr "new_org"}}" data-variation="tiny inverted" data-position="left center"> | |||
<a v-if="canCreateOrganization" class="poping up" :href="subUrl + '/org/create'" data-content="{{.i18n.Tr "new_org"}}" data-variation="tiny inverted" data-position="left center"> | |||
{{svg "octicon-plus"}} | |||
<span class="sr-only">{{.i18n.Tr "new_org"}}</span> | |||
</a> | |||
@@ -176,7 +175,7 @@ | |||
<div v-if="organizations.length" class="ui attached table segment rounded-bottom"> | |||
<ul class="repo-owner-name-list"> | |||
<li v-for="org in organizations"> | |||
<a class="repo-list-link df ac sb" :href="suburl + '/' + org.name"> | |||
<a class="repo-list-link df ac sb" :href="subUrl + '/' + org.name"> | |||
<div class="text truncate item-name f1"> | |||
{{svg "octicon-organization" 16 "mr-2"}} | |||
<strong>${org.name}</strong> |
@@ -70,4 +70,3 @@ export default { | |||
}, | |||
}; | |||
</script> | |||
<style scoped/> |
@@ -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}; |
@@ -1,9 +1,9 @@ | |||
<template> | |||
<div> | |||
<div class="activity-bar-graph" ref="style" style="width:0px;height:0px"/> | |||
<div class="activity-bar-graph-alt" ref="altStyle" style="width:0px;height:0px"/> | |||
<div class="activity-bar-graph" ref="style" style="width: 0; height: 0;"/> | |||
<div class="activity-bar-graph-alt" ref="altStyle" style="width: 0; height: 0;"/> | |||
<vue-bar-graph | |||
:points="graphData" | |||
:points="graphPoints" | |||
:show-x-axis="true" | |||
:show-y-axis="false" | |||
:show-values="true" | |||
@@ -15,9 +15,9 @@ | |||
:label-height="20" | |||
> | |||
<template #label="opt"> | |||
<g v-for="(author, idx) in authors" :key="author.position"> | |||
<g v-for="(author, idx) in graphAuthors" :key="author.position"> | |||
<a | |||
v-if="opt.bar.index === idx && author.home_link !== ''" | |||
v-if="opt.bar.index === idx && author.home_link" | |||
:href="author.home_link" | |||
> | |||
<image | |||
@@ -39,7 +39,7 @@ | |||
</g> | |||
</template> | |||
<template #title="opt"> | |||
<tspan v-for="(author, idx) in authors" :key="author.position"> | |||
<tspan v-for="(author, idx) in graphAuthors" :key="author.position"> | |||
<tspan v-if="opt.bar.index === idx"> | |||
{{ author.name }} | |||
</tspan> | |||
@@ -48,32 +48,39 @@ | |||
</vue-bar-graph> | |||
</div> | |||
</template> | |||
<script> | |||
import VueBarGraph from 'vue-bar-graph'; | |||
import {initVueApp} from './VueComponentLoader.js'; | |||
export default { | |||
const sfc = { | |||
components: {VueBarGraph}, | |||
props: { | |||
data: {type: Array, default: () => []}, | |||
}, | |||
data: () => ({ | |||
colors: { | |||
barColor: 'green', | |||
textColor: 'black', | |||
textAltColor: 'white', | |||
}, | |||
// possible keys: | |||
// * avatar_link: (...) | |||
// * commits: (...) | |||
// * home_link: (...) | |||
// * login: (...) | |||
// * name: (...) | |||
activityTopAuthors: window.config.pageData.repoActivityTopAuthors || [], | |||
}), | |||
computed: { | |||
graphData() { | |||
return this.data.map((item) => { | |||
graphPoints() { | |||
return this.activityTopAuthors.map((item) => { | |||
return { | |||
value: item.commits, | |||
label: item.name, | |||
}; | |||
}); | |||
}, | |||
authors() { | |||
return this.data.map((item, idx) => { | |||
graphAuthors() { | |||
return this.activityTopAuthors.map((item, idx) => { | |||
return { | |||
position: idx + 1, | |||
...item, | |||
@@ -81,21 +88,23 @@ export default { | |||
}); | |||
}, | |||
graphWidth() { | |||
return this.data.length * 40; | |||
return this.activityTopAuthors.length * 40; | |||
}, | |||
}, | |||
mounted() { | |||
const st = window.getComputedStyle(this.$refs.style); | |||
const stalt = window.getComputedStyle(this.$refs.altStyle); | |||
const refStyle = window.getComputedStyle(this.$refs.style); | |||
const refAltStyle = window.getComputedStyle(this.$refs.altStyle); | |||
this.colors.barColor = st.backgroundColor; | |||
this.colors.textColor = st.color; | |||
this.colors.textAltColor = stalt.color; | |||
}, | |||
methods: { | |||
hasHomeLink(i) { | |||
return this.graphData[i].homeLink !== '' && this.graphData[i].homeLink !== null; | |||
}, | |||
this.colors.barColor = refStyle.backgroundColor; | |||
this.colors.textColor = refStyle.color; | |||
this.colors.textAltColor = refAltStyle.color; | |||
} | |||
}; | |||
function initRepoActivityTopAuthorsChart() { | |||
initVueApp('#repo-activity-top-authors-chart', sfc); | |||
} | |||
export default sfc; | |||
export {initRepoActivityTopAuthorsChart}; | |||
</script> |
@@ -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}; |
@@ -1,5 +1,5 @@ | |||
export function initAdminUserListSearchForm() { | |||
const searchForm = window.config.PageData.adminUserListSearchForm; | |||
const searchForm = window.config.pageData.adminUserListSearchForm; | |||
if (!searchForm) return; | |||
const $form = $('#user-list-search-form'); |
@@ -1,10 +1,13 @@ | |||
import './publicpath.js'; | |||
import Vue from 'vue'; | |||
import {htmlEscape} from 'escape-goat'; | |||
import 'jquery.are-you-sure'; | |||
import ActivityTopAuthors from './components/ActivityTopAuthors.vue'; | |||
import {initVueEnv} from './components/VueComponentLoader.js'; | |||
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue'; | |||
import {initDashboardRepoList} from './components/DashboardRepoList.js'; | |||
import {initRepoBranchTagDropdown} from './components/RepoBranchTagDropdown.js'; | |||
import attachTribute from './features/tribute.js'; | |||
import createColorPicker from './features/colorpicker.js'; | |||
import createDropzone from './features/dropzone.js'; | |||
@@ -27,20 +30,16 @@ import {initStopwatch} from './features/stopwatch.js'; | |||
import {showLineButton} from './code/linebutton.js'; | |||
import {initMarkupContent, initCommentContent} from './markup/content.js'; | |||
import {stripTags, mqBinarySearch} from './utils.js'; | |||
import {svg, svgs} from './svg.js'; | |||
import {svg} from './svg.js'; | |||
const {AppSubUrl, AssetUrlPrefix, csrf} = window.config; | |||
const {AppSubUrl, csrf} = window.config; | |||
let previewFileModes; | |||
const commentMDEditors = {}; | |||
// Silence fomantic's error logging when tabs are used without a target content element | |||
$.fn.tab.settings.silent = true; | |||
// Silence Vue's console advertisements in dev mode | |||
// To use the Vue browser extension, enable the devtools option temporarily | |||
Vue.config.productionTip = false; | |||
Vue.config.devtools = false; | |||
initVueEnv(); | |||
function initCommentPreviewTab($form) { | |||
const $tabMenu = $form.find('.tabular.menu'); | |||
@@ -806,7 +805,7 @@ async function initRepository() { | |||
// File list and commits | |||
if ($('.repository.file.list').length > 0 || | |||
$('.repository.commits').length > 0 || $('.repository.release').length > 0) { | |||
initFilterBranchTagDropdown('.choose.reference .dropdown'); | |||
initRepoBranchTagDropdown('.choose.reference .dropdown'); | |||
} | |||
// Wiki | |||
@@ -2858,7 +2857,8 @@ $(document).ready(async () => { | |||
initWebhook(); | |||
initAdmin(); | |||
initCodeView(); | |||
initVueApp(); | |||
initRepoActivityTopAuthorsChart(); | |||
initDashboardRepoList(); | |||
initTeamSettings(); | |||
initCtrlEnterSubmit(); | |||
initNavbarContentToggle(); | |||
@@ -3105,369 +3105,6 @@ function linkEmailAction(e) { | |||
e.preventDefault(); | |||
} | |||
function initVueComponents() { | |||
// 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, | |||
}); | |||
} | |||
const vueDelimeters = ['${', '}']; | |||
Vue.component('repo-search', { | |||
delimiters: vueDelimeters, | |||
props: { | |||
searchLimit: { | |||
type: Number, | |||
default: 10 | |||
}, | |||
suburl: { | |||
type: String, | |||
required: true | |||
}, | |||
uid: { | |||
type: Number, | |||
required: true | |||
}, | |||
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: { | |||
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 initCtrlEnterSubmit() { | |||
$('.js-quick-submit').on('keydown', function (e) { | |||
if (((e.ctrlKey && !e.altKey) || e.metaKey) && (e.keyCode === 13 || e.keyCode === 10)) { | |||
@@ -3476,31 +3113,6 @@ function initCtrlEnterSubmit() { | |||
}); | |||
} | |||
function initVueApp() { | |||
const el = document.getElementById('app'); | |||
if (!el) { | |||
return; | |||
} | |||
initVueComponents(); | |||
new Vue({ | |||
el, | |||
delimiters: ['${', '}'], | |||
components: { | |||
ActivityTopAuthors, | |||
}, | |||
data: () => { | |||
return { | |||
searchLimit: Number((document.querySelector('meta[name=_search_limit]') || {}).content), | |||
suburl: AppSubUrl, | |||
uid: Number((document.querySelector('meta[name=_context_uid]') || {}).content), | |||
activityTopAuthors: window.ActivityTopAuthors || [], | |||
}; | |||
}, | |||
}); | |||
} | |||
function initIssueTimetracking() { | |||
$(document).on('click', '.issue-add-time', () => { | |||
$('.issue-start-time-modal').modal({ | |||
@@ -3543,163 +3155,6 @@ function initBranchOrTagDropdown(selector) { | |||
}); | |||
} | |||
function initFilterBranchTagDropdown(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; | |||
} | |||
} | |||
} | |||
}); | |||
}); | |||
} | |||
$('.commit-button').on('click', function (e) { | |||
e.preventDefault(); |
@@ -415,10 +415,6 @@ | |||
opacity: var(--opacity-disabled); | |||
cursor: default; | |||
} | |||
#delete-file-form { | |||
display: inline-block; | |||
} | |||
} | |||
} | |||