* Add archived options to SearchRepository Signed-off-by: Andrew Thornton <art27@cantab.net> * Add only-private search Signed-off-by: Andrew Thornton <art27@cantab.net> * Add filter options and paging to dashboard repository page Signed-off-by: Andrew Thornton <art27@cantab.net> * swagger generate Signed-off-by: Andrew Thornton <art27@cantab.net> * fix-swagger-again Signed-off-by: Andrew Thornton <art27@cantab.net> * as per @mrsdizzie also remember state Signed-off-by: Andrew Thornton <art27@cantab.net> Co-authored-by: Lauris BH <lauris@nix.lv>tags/v1.13.0-dev
@@ -140,6 +140,7 @@ type SearchRepoOptions struct { | |||
PriorityOwnerID int64 | |||
OrderBy SearchOrderBy | |||
Private bool // Include private repositories in results | |||
OnlyPrivate bool // Include only private repositories in results | |||
StarredByID int64 | |||
AllPublic bool // Include also all public repositories of users and public organisations | |||
AllLimited bool // Include also all public repositories of limited organisations | |||
@@ -159,6 +160,10 @@ type SearchRepoOptions struct { | |||
// True -> include just mirrors | |||
// False -> include just non-mirrors | |||
Mirror util.OptionalBool | |||
// None -> include archived AND non-archived | |||
// True -> include just archived | |||
// False -> include just non-archived | |||
Archived util.OptionalBool | |||
// only search topic name | |||
TopicOnly bool | |||
// include description in keyword search | |||
@@ -205,14 +210,26 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond { | |||
} | |||
} else { | |||
// Not looking at private organisations | |||
// We should be able to see all non-private repositories that either: | |||
cond = cond.And(builder.Eq{"is_private": false}) | |||
accessCond := builder.Or( | |||
// A. Aren't in organisations __OR__ | |||
builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"type": UserTypeOrganization})), | |||
// B. Isn't a private or limited organisation. | |||
builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(builder.Or(builder.Eq{"visibility": structs.VisibleTypeLimited}, builder.Eq{"visibility": structs.VisibleTypePrivate})))) | |||
cond = cond.And(accessCond) | |||
// We should be able to see all non-private repositories that | |||
// isn't in a private or limited organisation. | |||
cond = cond.And( | |||
builder.Eq{"is_private": false}, | |||
builder.NotIn("owner_id", builder.Select("id").From("`user`").Where( | |||
builder.And( | |||
builder.Eq{"type": UserTypeOrganization}, | |||
builder.Or(builder.Eq{"visibility": structs.VisibleTypeLimited}, builder.Eq{"visibility": structs.VisibleTypePrivate}), | |||
)))) | |||
} | |||
if opts.OnlyPrivate { | |||
cond = cond.And( | |||
builder.Or( | |||
builder.Eq{"is_private": true}, | |||
builder.In("owner_id", builder.Select("id").From("`user`").Where( | |||
builder.And( | |||
builder.Eq{"type": UserTypeOrganization}, | |||
builder.Or(builder.Eq{"visibility": structs.VisibleTypeLimited}, builder.Eq{"visibility": structs.VisibleTypePrivate}), | |||
))))) | |||
} | |||
if opts.Template != util.OptionalBoolNone { | |||
@@ -299,6 +316,10 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond { | |||
cond = cond.And(accessibleRepositoryCondition(opts.Actor)) | |||
} | |||
if opts.Archived != util.OptionalBoolNone { | |||
cond = cond.And(builder.Eq{"is_archived": opts.Archived == util.OptionalBoolTrue}) | |||
} | |||
switch opts.HasMilestones { | |||
case util.OptionalBoolTrue: | |||
cond = cond.And(builder.Gt{"num_milestones": 0}) |
@@ -206,6 +206,17 @@ my_orgs = My Organizations | |||
my_mirrors = My Mirrors | |||
view_home = View %s | |||
search_repos = Find a repository… | |||
filter = Other Filters | |||
show_archived = Archived | |||
show_both_archived_unarchived = Showing both archived and unarchived | |||
show_only_archived = Showing only archived | |||
show_only_unarchived = Showing only unarchived | |||
show_private = Private | |||
show_both_private_public = Showing both public and private | |||
show_only_private = Showing only private | |||
show_only_public = Showing only public | |||
issues.in_your_repos = In your repositories | |||
@@ -78,10 +78,18 @@ func Search(ctx *context.APIContext) { | |||
// in: query | |||
// description: include private repositories this user has access to (defaults to true) | |||
// type: boolean | |||
// - name: onlyPrivate | |||
// in: query | |||
// description: only include private repositories this user has access to (defaults to false) | |||
// type: boolean | |||
// - name: template | |||
// in: query | |||
// description: include template repositories this user has access to (defaults to true) | |||
// type: boolean | |||
// - name: archived | |||
// in: query | |||
// description: show only archived, non-archived or all repositories (defaults to all) | |||
// type: boolean | |||
// - name: mode | |||
// in: query | |||
// description: type of repository to search for. Supported values are | |||
@@ -125,6 +133,7 @@ func Search(ctx *context.APIContext) { | |||
TopicOnly: ctx.QueryBool("topic"), | |||
Collaborate: util.OptionalBoolNone, | |||
Private: ctx.IsSigned && (ctx.Query("private") == "" || ctx.QueryBool("private")), | |||
OnlyPrivate: ctx.IsSigned && ctx.QueryBool("onlyPrivate"), | |||
Template: util.OptionalBoolNone, | |||
StarredByID: ctx.QueryInt64("starredBy"), | |||
IncludeDescription: ctx.QueryBool("includeDesc"), | |||
@@ -156,6 +165,10 @@ func Search(ctx *context.APIContext) { | |||
return | |||
} | |||
if ctx.Query("archived") != "" { | |||
opts.Archived = util.OptionalBoolOf(ctx.QueryBool("archived")) | |||
} | |||
var sortMode = ctx.Query("sort") | |||
if len(sortMode) > 0 { | |||
var sortOrder = ctx.Query("order") |
@@ -1769,12 +1769,24 @@ | |||
"name": "private", | |||
"in": "query" | |||
}, | |||
{ | |||
"type": "boolean", | |||
"description": "only include private repositories this user has access to (defaults to false)", | |||
"name": "onlyPrivate", | |||
"in": "query" | |||
}, | |||
{ | |||
"type": "boolean", | |||
"description": "include template repositories this user has access to (defaults to true)", | |||
"name": "template", | |||
"in": "query" | |||
}, | |||
{ | |||
"type": "boolean", | |||
"description": "show only archived, non-archived or all repositories (defaults to all)", | |||
"name": "archived", | |||
"in": "query" | |||
}, | |||
{ | |||
"type": "string", | |||
"description": "type of repository to search for. Supported values are \"fork\", \"source\", \"mirror\" and \"collaborative\"", |
@@ -35,9 +35,46 @@ | |||
{{end}} | |||
</h4> | |||
<div class="ui attached secondary segment repos-search"> | |||
<div class="ui fluid icon input" :class="{loading: isLoading}"> | |||
<div class="ui fluid right action left icon input" :class="{loading: isLoading}"> | |||
<input @input="searchRepos(reposFilter)" v-model="searchQuery" ref="search" placeholder="{{.i18n.Tr "home.search_repos"}}"> | |||
<i class="search icon"></i> | |||
<div class="ui dropdown button" title="{{.i18n.Tr "home.filter"}}"> | |||
<i class="icon filter"></i> | |||
<div class="menu"> | |||
<div class="item"> | |||
<a @click="toggleArchivedFilter()"> | |||
<div class="ui checkbox" id="archivedFilterCheckbox" title="{{.i18n.Tr "home.show_both_archived_unarchived"}}" v-if="archivedFilter === 'both'"> | |||
<input type="checkbox"> | |||
<label><i class="archive icon archived-icon"></i>{{.i18n.Tr "home.show_archived"}}</label> | |||
</div> | |||
<div class="ui checkbox" id="archivedFilterCheckbox" title="{{.i18n.Tr "home.show_only_unarchived"}}" v-if="archivedFilter === 'unarchived'"> | |||
<input type="checkbox"> | |||
<label><i class="archive icon archived-icon"></i>{{.i18n.Tr "home.show_archived"}}</label> | |||
</div> | |||
<div class="ui checkbox" id="archivedFilterCheckbox" title="{{.i18n.Tr "home.show_only_archived"}}" v-if="archivedFilter === 'archived'"> | |||
<input type="checkbox"> | |||
<label><i class="archive icon archived-icon"></i>{{.i18n.Tr "home.show_archived"}}</label> | |||
</div> | |||
</a> | |||
</div> | |||
<div class="item"> | |||
<a @click="togglePrivateFilter()"> | |||
<div class="ui checkbox" id="privateFilterCheckbox" title="{{.i18n.Tr "home.show_both_private_public"}}" v-if="privateFilter === 'both'"> | |||
<input type="checkbox"> | |||
<label><svg class="svg octicon-lock" width="16" height="16" aria-hidden="true"><use xlink:href="#octicon-lock" /></svg>{{.i18n.Tr "home.show_private"}}</label> | |||
</div> | |||
<div class="ui checkbox" id="privateFilterCheckbox" title="{{.i18n.Tr "home.show_only_public"}}" v-if="privateFilter === 'public'"> | |||
<input type="checkbox"> | |||
<label><svg class="svg octicon-lock" width="16" height="16" aria-hidden="true"><use xlink:href="#octicon-lock" /></svg>{{.i18n.Tr "home.show_private"}}</label> | |||
</div> | |||
<div class="ui checkbox" id="privateFilterCheckbox" title="{{.i18n.Tr "home.show_only_private"}}" v-if="privateFilter === 'private'"> | |||
<input type="checkbox"> | |||
<label><svg class="svg octicon-lock" width="16" height="16" aria-hidden="true"><use xlink:href="#octicon-lock" /></svg>{{.i18n.Tr "home.show_private"}}</label> | |||
</div> | |||
</a> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
<div class="ui secondary tiny pointing borderless menu center aligned grid repos-filter"> | |||
<a class="item" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')"> | |||
@@ -64,7 +101,7 @@ | |||
</div> | |||
<div class="ui attached table segment"> | |||
<ul class="repo-owner-name-list"> | |||
<li v-for="repo in repos" :class="{'private': repo.private}" v-show="showRepo(repo, reposFilter)"> | |||
<li v-for="repo in repos" :class="{'private': repo.private}" v-show="showRepo(repo)"> | |||
<a :href="suburl + '/' + repo.full_name"> | |||
<svg :class="'svg ' + repoClass(repo)" width="16" height="16" aria-hidden="true"><use :xlink:href="'#' + repoClass(repo)" /></svg> | |||
<strong class="text truncate item-name">${repo.full_name}</strong> | |||
@@ -75,7 +112,27 @@ | |||
</a> | |||
</li> | |||
<li v-if="showMoreReposLink"> | |||
<a :href="moreReposLink">{{.i18n.Tr "home.show_more_repos"}}</a> | |||
<div class="center"> | |||
<div class="ui borderless pagination menu narrow"> | |||
<a class="item navigation" :class="{'disabled': page === 1}" | |||
@click="changePage(1)" title="{{$.i18n.Tr "admin.first_page"}}"> | |||
<i class="angle double left icon"></i> | |||
</a> | |||
<a class="item navigation" :class="{'disabled': page === 1}" | |||
@click="changePage(page - 1)" title="{{$.i18n.Tr "repo.issues.previous"}}"> | |||
<i class="left arrow icon"></i> | |||
</a> | |||
<a class="active item">${page}</a> | |||
<a class="item navigation" :class="{'disabled': page === finalPage}" | |||
@click="changePage(page + 1)" title="{{$.i18n.Tr "repo.issues.next"}}"> | |||
<i class="icon right arrow"></i> | |||
</a> | |||
<a class="item navigation" :class="{'disabled': page === finalPage}" | |||
@click="changePage(finalPage)" title="{{$.i18n.Tr "admin.last_page"}}"> | |||
<i class="angle double right icon"></i> | |||
</a> | |||
</div> | |||
</div> | |||
</li> | |||
</ul> | |||
</div> |
@@ -2662,33 +2662,70 @@ function initVueComponents() { | |||
}, | |||
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 = 'both'; | |||
} | |||
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', | |||
tab, | |||
repos: [], | |||
reposTotalCount: 0, | |||
reposFilter: 'all', | |||
searchQuery: '', | |||
reposFilter, | |||
archivedFilter, | |||
privateFilter, | |||
page, | |||
finalPage: 1, | |||
searchQuery, | |||
isLoading: false, | |||
staticPrefix: StaticUrlPrefix, | |||
counts: {}, | |||
repoTypes: { | |||
all: { | |||
count: 0, | |||
searchMode: '', | |||
}, | |||
forks: { | |||
count: 0, | |||
searchMode: 'fork', | |||
}, | |||
mirrors: { | |||
count: 0, | |||
searchMode: 'mirror', | |||
}, | |||
sources: { | |||
count: 0, | |||
searchMode: 'source', | |||
}, | |||
collaborative: { | |||
count: 0, | |||
searchMode: 'collaborative', | |||
}, | |||
} | |||
@@ -2697,21 +2734,26 @@ function initVueComponents() { | |||
computed: { | |||
showMoreReposLink() { | |||
return this.repos.length > 0 && this.repos.length < this.repoTypes[this.reposFilter].count; | |||
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}&q=${this.searchQuery | |||
}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode | |||
}${this.reposFilter !== 'all' ? '&exclusive=1' : ''}`; | |||
}&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' ? '&onlyPrivate=true' : ''}${this.privateFilter === 'public' ? '&private=false' : '' | |||
}`; | |||
}, | |||
repoTypeCount() { | |||
return this.repoTypes[this.reposFilter].count; | |||
return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`]; | |||
} | |||
}, | |||
mounted() { | |||
this.searchRepos(this.reposFilter); | |||
$(this.$el).find('.poping.up').popup(); | |||
$(this.$el).find('.dropdown').dropdown(); | |||
this.setCheckboxes(); | |||
const self = this; | |||
Vue.nextTick(() => { | |||
self.$refs.search.focus(); | |||
@@ -2721,17 +2763,178 @@ function initVueComponents() { | |||
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 = 'both'; | |||
$('#archivedFilterCheckbox').checkbox('set indeterminate'); | |||
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.repoTypes[filter].count = 0; | |||
this.searchRepos(filter); | |||
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 === 'both') { | |||
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}`); | |||
} | |||
window.history.replaceState({}, '', `?${params.toString()}`); | |||
}, | |||
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 = 'both'; | |||
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(); | |||
}, | |||
showArchivedRepo(repo) { | |||
switch (this.archivedFilter) { | |||
case 'both': | |||
return true; | |||
case 'unarchived': | |||
return !repo.archived; | |||
case 'archived': | |||
return repo.archived; | |||
default: | |||
return true; | |||
} | |||
}, | |||
showRepo(repo, filter) { | |||
switch (filter) { | |||
showPrivateRepo(repo) { | |||
switch (this.privateFilter) { | |||
case 'both': | |||
return true; | |||
case 'public': | |||
return !repo.private; | |||
case 'private': | |||
return repo.private; | |||
default: | |||
return true; | |||
} | |||
}, | |||
showFilteredRepo(repo) { | |||
switch (this.reposFilter) { | |||
case 'sources': | |||
return repo.owner.id === this.uid && !repo.mirror && !repo.fork; | |||
case 'forks': | |||
@@ -2745,12 +2948,16 @@ function initVueComponents() { | |||
} | |||
}, | |||
searchRepos(reposFilter) { | |||
showRepo(repo) { | |||
return this.showArchivedRepo(repo) && this.showPrivateRepo(repo) && this.showFilteredRepo(repo); | |||
}, | |||
searchRepos() { | |||
const self = this; | |||
this.isLoading = true; | |||
const searchedMode = this.repoTypes[reposFilter].searchMode; | |||
const searchedMode = this.repoTypes[this.reposFilter].searchMode; | |||
const searchedURL = this.searchURL; | |||
const searchedQuery = this.searchQuery; | |||
@@ -2758,10 +2965,12 @@ function initVueComponents() { | |||
if (searchedURL === self.searchURL) { | |||
self.repos = result.data; | |||
const count = request.getResponseHeader('X-Total-Count'); | |||
if (searchedQuery === '' && searchedMode === '') { | |||
if (searchedQuery === '' && searchedMode === '' && self.archivedFilter === 'both') { | |||
self.reposTotalCount = count; | |||
} | |||
self.repoTypes[reposFilter].count = count; | |||
Vue.set(self.counts, `${self.reposFilter}:${self.archivedFilter}:${self.privateFilter}`, count); | |||
self.finalPage = Math.floor(count / self.searchLimit) + 1; | |||
self.updateHistory(); | |||
} | |||
}).always(() => { | |||
if (searchedURL === self.searchURL) { |
@@ -318,11 +318,11 @@ code, | |||
} | |||
.ui { | |||
&.left { | |||
&.left:not(.action) { | |||
float: left; | |||
} | |||
&.right { | |||
&.right:not(.action) { | |||
float: right; | |||
} | |||
@@ -727,6 +727,15 @@ code, | |||
display: none; | |||
} | |||
} | |||
&.narrow .item { | |||
padding-left: 8px; | |||
padding-right: 8px; | |||
min-width: 1em; | |||
text-align: center; | |||
.icon { | |||
margin-right: 0; | |||
} | |||
} | |||
} | |||
} | |||
@@ -174,6 +174,11 @@ | |||
} | |||
} | |||
#privateFilterCheckbox .svg { | |||
color: #888888; | |||
margin-right: .25rem; | |||
} | |||
.repo-owner-name-list { | |||
.item-name { | |||
max-width: 70%; |