Add paging and archive/private repository filtering to dashboard list (#11321)

* 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>
This commit is contained in:
zeripath 2020-05-16 21:07:01 +01:00 committed by GitHub
parent c3d9a5f846
commit c86bc8e061
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 371 additions and 34 deletions

View File

@ -140,6 +140,7 @@ type SearchRepoOptions struct {
PriorityOwnerID int64 PriorityOwnerID int64
OrderBy SearchOrderBy OrderBy SearchOrderBy
Private bool // Include private repositories in results Private bool // Include private repositories in results
OnlyPrivate bool // Include only private repositories in results
StarredByID int64 StarredByID int64
AllPublic bool // Include also all public repositories of users and public organisations AllPublic bool // Include also all public repositories of users and public organisations
AllLimited bool // Include also all public repositories of limited organisations AllLimited bool // Include also all public repositories of limited organisations
@ -159,6 +160,10 @@ type SearchRepoOptions struct {
// True -> include just mirrors // True -> include just mirrors
// False -> include just non-mirrors // False -> include just non-mirrors
Mirror util.OptionalBool 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 // only search topic name
TopicOnly bool TopicOnly bool
// include description in keyword search // include description in keyword search
@ -205,14 +210,26 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
} }
} else { } else {
// Not looking at private organisations // Not looking at private organisations
// We should be able to see all non-private repositories that either: // We should be able to see all non-private repositories that
cond = cond.And(builder.Eq{"is_private": false}) // isn't in a private or limited organisation.
accessCond := builder.Or( cond = cond.And(
// A. Aren't in organisations __OR__ builder.Eq{"is_private": false},
builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"type": UserTypeOrganization})), builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(
// B. Isn't a private or limited organisation. builder.And(
builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(builder.Or(builder.Eq{"visibility": structs.VisibleTypeLimited}, builder.Eq{"visibility": structs.VisibleTypePrivate})))) builder.Eq{"type": UserTypeOrganization},
cond = cond.And(accessCond) 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 { if opts.Template != util.OptionalBoolNone {
@ -299,6 +316,10 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
cond = cond.And(accessibleRepositoryCondition(opts.Actor)) 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 { switch opts.HasMilestones {
case util.OptionalBoolTrue: case util.OptionalBoolTrue:
cond = cond.And(builder.Gt{"num_milestones": 0}) cond = cond.And(builder.Gt{"num_milestones": 0})

View File

@ -206,6 +206,17 @@ my_orgs = My Organizations
my_mirrors = My Mirrors my_mirrors = My Mirrors
view_home = View %s view_home = View %s
search_repos = Find a repository… 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 issues.in_your_repos = In your repositories

View File

@ -78,10 +78,18 @@ func Search(ctx *context.APIContext) {
// in: query // in: query
// description: include private repositories this user has access to (defaults to true) // description: include private repositories this user has access to (defaults to true)
// type: boolean // type: boolean
// - name: onlyPrivate
// in: query
// description: only include private repositories this user has access to (defaults to false)
// type: boolean
// - name: template // - name: template
// in: query // in: query
// description: include template repositories this user has access to (defaults to true) // description: include template repositories this user has access to (defaults to true)
// type: boolean // type: boolean
// - name: archived
// in: query
// description: show only archived, non-archived or all repositories (defaults to all)
// type: boolean
// - name: mode // - name: mode
// in: query // in: query
// description: type of repository to search for. Supported values are // description: type of repository to search for. Supported values are
@ -125,6 +133,7 @@ func Search(ctx *context.APIContext) {
TopicOnly: ctx.QueryBool("topic"), TopicOnly: ctx.QueryBool("topic"),
Collaborate: util.OptionalBoolNone, Collaborate: util.OptionalBoolNone,
Private: ctx.IsSigned && (ctx.Query("private") == "" || ctx.QueryBool("private")), Private: ctx.IsSigned && (ctx.Query("private") == "" || ctx.QueryBool("private")),
OnlyPrivate: ctx.IsSigned && ctx.QueryBool("onlyPrivate"),
Template: util.OptionalBoolNone, Template: util.OptionalBoolNone,
StarredByID: ctx.QueryInt64("starredBy"), StarredByID: ctx.QueryInt64("starredBy"),
IncludeDescription: ctx.QueryBool("includeDesc"), IncludeDescription: ctx.QueryBool("includeDesc"),
@ -156,6 +165,10 @@ func Search(ctx *context.APIContext) {
return return
} }
if ctx.Query("archived") != "" {
opts.Archived = util.OptionalBoolOf(ctx.QueryBool("archived"))
}
var sortMode = ctx.Query("sort") var sortMode = ctx.Query("sort")
if len(sortMode) > 0 { if len(sortMode) > 0 {
var sortOrder = ctx.Query("order") var sortOrder = ctx.Query("order")

View File

@ -1769,12 +1769,24 @@
"name": "private", "name": "private",
"in": "query" "in": "query"
}, },
{
"type": "boolean",
"description": "only include private repositories this user has access to (defaults to false)",
"name": "onlyPrivate",
"in": "query"
},
{ {
"type": "boolean", "type": "boolean",
"description": "include template repositories this user has access to (defaults to true)", "description": "include template repositories this user has access to (defaults to true)",
"name": "template", "name": "template",
"in": "query" "in": "query"
}, },
{
"type": "boolean",
"description": "show only archived, non-archived or all repositories (defaults to all)",
"name": "archived",
"in": "query"
},
{ {
"type": "string", "type": "string",
"description": "type of repository to search for. Supported values are \"fork\", \"source\", \"mirror\" and \"collaborative\"", "description": "type of repository to search for. Supported values are \"fork\", \"source\", \"mirror\" and \"collaborative\"",

View File

@ -35,9 +35,46 @@
{{end}} {{end}}
</h4> </h4>
<div class="ui attached secondary segment repos-search"> <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"}}"> <input @input="searchRepos(reposFilter)" v-model="searchQuery" ref="search" placeholder="{{.i18n.Tr "home.search_repos"}}">
<i class="search icon"></i> <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>
<div class="ui secondary tiny pointing borderless menu center aligned grid repos-filter"> <div class="ui secondary tiny pointing borderless menu center aligned grid repos-filter">
<a class="item" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')"> <a class="item" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')">
@ -64,7 +101,7 @@
</div> </div>
<div class="ui attached table segment"> <div class="ui attached table segment">
<ul class="repo-owner-name-list"> <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"> <a :href="suburl + '/' + repo.full_name">
<svg :class="'svg ' + repoClass(repo)" width="16" height="16" aria-hidden="true"><use :xlink:href="'#' + repoClass(repo)" /></svg> <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> <strong class="text truncate item-name">${repo.full_name}</strong>
@ -75,7 +112,27 @@
</a> </a>
</li> </li>
<li v-if="showMoreReposLink"> <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> </li>
</ul> </ul>
</div> </div>

View File

@ -2662,33 +2662,70 @@ function initVueComponents() {
}, },
data() { 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 { return {
tab: 'repos', tab,
repos: [], repos: [],
reposTotalCount: 0, reposTotalCount: 0,
reposFilter: 'all', reposFilter,
searchQuery: '', archivedFilter,
privateFilter,
page,
finalPage: 1,
searchQuery,
isLoading: false, isLoading: false,
staticPrefix: StaticUrlPrefix, staticPrefix: StaticUrlPrefix,
counts: {},
repoTypes: { repoTypes: {
all: { all: {
count: 0,
searchMode: '', searchMode: '',
}, },
forks: { forks: {
count: 0,
searchMode: 'fork', searchMode: 'fork',
}, },
mirrors: { mirrors: {
count: 0,
searchMode: 'mirror', searchMode: 'mirror',
}, },
sources: { sources: {
count: 0,
searchMode: 'source', searchMode: 'source',
}, },
collaborative: { collaborative: {
count: 0,
searchMode: 'collaborative', searchMode: 'collaborative',
}, },
} }
@ -2697,21 +2734,26 @@ function initVueComponents() {
computed: { computed: {
showMoreReposLink() { 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() { searchURL() {
return `${this.suburl}/api/v1/repos/search?sort=updated&order=desc&uid=${this.uid}&q=${this.searchQuery 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 }&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
}${this.reposFilter !== 'all' ? '&exclusive=1' : ''}`; }${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() { repoTypeCount() {
return this.repoTypes[this.reposFilter].count; return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
} }
}, },
mounted() { mounted() {
this.searchRepos(this.reposFilter); this.searchRepos(this.reposFilter);
$(this.$el).find('.poping.up').popup();
$(this.$el).find('.dropdown').dropdown();
this.setCheckboxes();
const self = this; const self = this;
Vue.nextTick(() => { Vue.nextTick(() => {
self.$refs.search.focus(); self.$refs.search.focus();
@ -2721,17 +2763,178 @@ function initVueComponents() {
methods: { methods: {
changeTab(t) { changeTab(t) {
this.tab = 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) { changeReposFilter(filter) {
this.reposFilter = filter; this.reposFilter = filter;
this.repos = []; this.repos = [];
this.repoTypes[filter].count = 0; this.page = 1;
this.searchRepos(filter); Vue.set(this.counts, `${filter}:${this.archivedFilter}:${this.privateFilter}`, 0);
this.searchRepos();
}, },
showRepo(repo, filter) { updateHistory() {
switch (filter) { 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;
}
},
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': case 'sources':
return repo.owner.id === this.uid && !repo.mirror && !repo.fork; return repo.owner.id === this.uid && !repo.mirror && !repo.fork;
case 'forks': 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; const self = this;
this.isLoading = true; this.isLoading = true;
const searchedMode = this.repoTypes[reposFilter].searchMode; const searchedMode = this.repoTypes[this.reposFilter].searchMode;
const searchedURL = this.searchURL; const searchedURL = this.searchURL;
const searchedQuery = this.searchQuery; const searchedQuery = this.searchQuery;
@ -2758,10 +2965,12 @@ function initVueComponents() {
if (searchedURL === self.searchURL) { if (searchedURL === self.searchURL) {
self.repos = result.data; self.repos = result.data;
const count = request.getResponseHeader('X-Total-Count'); const count = request.getResponseHeader('X-Total-Count');
if (searchedQuery === '' && searchedMode === '') { if (searchedQuery === '' && searchedMode === '' && self.archivedFilter === 'both') {
self.reposTotalCount = count; 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(() => { }).always(() => {
if (searchedURL === self.searchURL) { if (searchedURL === self.searchURL) {

View File

@ -318,11 +318,11 @@ code,
} }
.ui { .ui {
&.left { &.left:not(.action) {
float: left; float: left;
} }
&.right { &.right:not(.action) {
float: right; float: right;
} }
@ -727,6 +727,15 @@ code,
display: none; display: none;
} }
} }
&.narrow .item {
padding-left: 8px;
padding-right: 8px;
min-width: 1em;
text-align: center;
.icon {
margin-right: 0;
}
}
} }
} }

View File

@ -174,6 +174,11 @@
} }
} }
#privateFilterCheckbox .svg {
color: #888888;
margin-right: .25rem;
}
.repo-owner-name-list { .repo-owner-name-list {
.item-name { .item-name {
max-width: 70%; max-width: 70%;