Refactor dashboard repo list to Vue SFC (#23405)

Similar to #23394

The dashboard repo list mixes jQuery/Fomantic UI/Vue together, it's very
diffcult to maintain and causes unfixable a11y problems.

This PR uses two steps to refactor the repo list:

1. move `data-` attributes to JS object and use Vue data as much as
possible
d3adc0dcac
2. move the code into a Vue SFC
7ebe55df6e

Total: +516 −585

Screenshots:

<details>

![image](https://user-images.githubusercontent.com/2114189/224271457-a23e05be-d7d3-4247-a803-f0ee30c36f44.png)

![image](https://user-images.githubusercontent.com/2114189/224271504-76fbd3da-4d7a-4725-b0d1-fbff83caac63.png)

![image](https://user-images.githubusercontent.com/2114189/224271845-f007cadf-6c49-46bd-a65c-a3fc75bdba3b.png)

</details>

---------

Co-authored-by: John Olheiser <john.olheiser@gmail.com>
This commit is contained in:
2023-03-14 12:09:06 +08:00
committed by GitHub
parent b942838bd4
commit e82f1b15c7
8 changed files with 516 additions and 585 deletions

View File

@ -1,181 +1,54 @@
<div id="dashboard-repo-list" class="six wide column">
<repo-search
:search-limit="searchLimit"
:sub-url="subUrl"
:uid="uid"
{{if .Team}}
:team-id="{{.Team.ID}}"
{{end}}
:more-repos-link="'{{.ContextUser.HomeLink}}'"
{{if not .ContextUser.IsOrganization}}
:organizations="[
{{range .Orgs}}
{name: '{{.Name}}', num_repos: '{{.NumRepos}}'},
{{end}}
]"
:is-organization="false"
:organizations-total-count="{{.UserOrgsCount}}"
:can-create-organization="{{.SignedUser.CanCreateOrganization}}"
{{end}}
inline-template
v-cloak
></repo-search>
</div>
<script type="module">
const data = {
...window.config.pageData.dashboardRepoList, // it only contains searchLimit and uid
<template id="dashboard-repo-list-template">
<div>
<div v-if="!isOrganization" class="ui two item tabable menu">
<a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{.locale.Tr "repository"}}</a>
<a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{.locale.Tr "organization"}}</a>
</div>
<div v-show="tab === 'repos'" class="ui tab active list dashboard-repos">
<h4 class="ui top attached header gt-df gt-ac">
<div class="gt-f1 gt-df gt-ac">
{{.locale.Tr "home.my_repos"}}
<span class="ui grey label gt-ml-3">${reposTotalCount}</span>
</div>
<a class="tooltip" :href="subUrl + '/repo/create'" data-content="{{.locale.Tr "new_repo"}}" data-position="left center">
{{svg "octicon-plus"}}
<span class="sr-only">{{.locale.Tr "new_repo"}}</span>
</a>
</h4>
<div class="ui attached segment repos-search">
<div class="ui fluid right action left icon input" :class="{loading: isLoading}">
<input @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" placeholder="{{.locale.Tr "home.search_repos"}}">
<i class="icon gt-df gt-ac gt-jc">{{svg "octicon-search" 16}}</i>
<div class="ui dropdown icon button" title="{{.locale.Tr "home.filter"}}">
<i class="icon gt-df gt-ac gt-jc gt-m-0">{{svg "octicon-filter" 16}}</i>
<div class="menu">
<a class="item" @click="toggleArchivedFilter()">
<div class="ui checkbox"
ref="checkboxArchivedFilter"
data-title-both="{{.locale.Tr "home.show_both_archived_unarchived"}}"
data-title-unarchived="{{.locale.Tr "home.show_only_unarchived"}}"
data-title-archived="{{.locale.Tr "home.show_only_archived"}}"
:title="checkboxArchivedFilterTitle"
>
<!--the "hidden" is necessary to make the checkbox work without Fomantic UI js,
otherwise if the "input" handles click event for intermediate status, it breaks the internal state-->
<input type="checkbox" class="hidden" v-bind.prop="checkboxArchivedFilterProps">
<label>
{{svg "octicon-archive" 16 "gt-mr-2"}}
{{.locale.Tr "home.show_archived"}}
</label>
</div>
</a>
<a class="item" @click="togglePrivateFilter()">
<div class="ui checkbox"
ref="checkboxPrivateFilter"
data-title-both="{{.locale.Tr "home.show_both_private_public"}}"
data-title-public="{{.locale.Tr "home.show_only_public"}}"
data-title-private="{{.locale.Tr "home.show_only_private"}}"
:title="checkboxPrivateFilterTitle"
>
<input type="checkbox" class="hidden" v-bind.prop="checkboxPrivateFilterProps">
<label>
{{svg "octicon-lock" 16 "gt-mr-2"}}
{{.locale.Tr "home.show_private"}}
</label>
</div>
</a>
</div>
</div>
</div>
<div class="ui secondary tiny pointing borderless menu center grid repos-filter">
<a class="item" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')">
{{.locale.Tr "all"}}
<div v-show="reposFilter === 'all'" class="ui circular mini grey label">${repoTypeCount}</div>
</a>
<a class="item" :class="{active: reposFilter === 'sources'}" @click="changeReposFilter('sources')">
{{.locale.Tr "sources"}}
<div v-show="reposFilter === 'sources'" class="ui circular mini grey label">${repoTypeCount}</div>
</a>
<a class="item" :class="{active: reposFilter === 'forks'}" @click="changeReposFilter('forks')">
{{.locale.Tr "forks"}}
<div v-show="reposFilter === 'forks'" class="ui circular mini grey label">${repoTypeCount}</div>
</a>
{{if .MirrorsEnabled}}
<a class="item" :class="{active: reposFilter === 'mirrors'}" @click="changeReposFilter('mirrors')">
{{.locale.Tr "mirrors"}}
<div v-show="reposFilter === 'mirrors'" class="ui circular mini grey label">${repoTypeCount}</div>
</a>
{{end}}
<a class="item" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')">
{{.locale.Tr "collaborative"}}
<div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">${repoTypeCount}</div>
</a>
</div>
</div>
<div v-if="repos.length" class="ui attached table segment gt-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 gt-df gt-ac gt-sb" :href="repo.link">
<div class="item-name gt-df gt-ac gt-f1 gt-mr-2">
<component v-bind:is="repoIcon(repo)" size="16" class="gt-mr-2"></component>
<div class="text gt-bold truncate gt-ml-1">${repo.full_name}</div>
<span v-if="repo.archived">
{{svg "octicon-archive" 16 "gt-ml-2"}}
</span>
</div>
{{if not .DisableStars}}
<div class="text light grey gt-df gt-ac">
${repo.stars_count}
{{svg "octicon-star" 16 "gt-ml-2"}}
</div>
{{end}}
</a>
</li>
</ul>
<div v-if="showMoreReposLink" class="center gt-py-3 gt-border-secondary-top">
<div class="ui borderless pagination menu narrow">
<a class="item navigation gt-py-2" :class="{'disabled': page === 1}"
@click="changePage(1)" title="{{$.locale.Tr "admin.first_page"}}">
{{svg "gitea-double-chevron-left" 16 "gt-mr-2"}}
</a>
<a class="item navigation gt-py-2" :class="{'disabled': page === 1}"
@click="changePage(page - 1)" title="{{$.locale.Tr "repo.issues.previous"}}">
{{svg "octicon-chevron-left" 16 "gt-mr-2"}}
</a>
<a class="active item gt-py-2">${page}</a>
<a class="item navigation" :class="{'disabled': page === finalPage}"
@click="changePage(page + 1)" title="{{$.locale.Tr "repo.issues.next"}}">
{{svg "octicon-chevron-right" 16 "gt-ml-2"}}
</a>
<a class="item navigation gt-py-2" :class="{'disabled': page === finalPage}"
@click="changePage(finalPage)" title="{{$.locale.Tr "admin.last_page"}}">
{{svg "gitea-double-chevron-right" 16 "gt-ml-2"}}
</a>
</div>
</div>
</div>
</div>
<div v-if="!isOrganization" v-show="tab === 'organizations'" class="ui tab active list dashboard-orgs">
<h4 class="ui top attached header gt-df gt-ac">
<div class="gt-f1 gt-df gt-ac">
{{.locale.Tr "home.my_orgs"}}
<span class="ui grey label gt-ml-3">${organizationsTotalCount}</span>
</div>
<a v-if="canCreateOrganization" class="tooltip" :href="subUrl + '/org/create'" data-content="{{.locale.Tr "new_org"}}" data-position="left center">
{{svg "octicon-plus"}}
<span class="sr-only">{{.locale.Tr "new_org"}}</span>
</a>
</h4>
<div v-if="organizations.length" class="ui attached table segment gt-rounded-bottom">
<ul class="repo-owner-name-list">
<li v-for="org in organizations">
<a class="repo-list-link gt-df gt-ac gt-sb" :href="subUrl + '/' + encodeURIComponent(org.name)">
<div class="text truncate item-name gt-f1">
{{svg "octicon-organization" 16 "gt-mr-2"}}
<strong>${org.name}</strong>
</div>
<div class="text light grey gt-df gt-ac">
${org.num_repos}
{{svg "octicon-repo" 16 "gt-ml-2 gt-mt-1"}}
</div>
</a>
</li>
</ul>
</div>
</div>
</div>
</template>
isMirrorsEnabled: {{.IsMirrorsEnabled}},
isStarsEnabled: {{not .IsDisableStars}},
textRepository: {{.locale.Tr "repository"}},
textOrganization: {{.locale.Tr "organization"}},
textMyRepos: {{.locale.Tr "home.my_repos"}},
textNewRepo: {{.locale.Tr "new_repo"}},
textSearchRepos: {{.locale.Tr "home.search_repos"}},
textFilter: {{.locale.Tr "home.filter"}},
textShowArchived: {{.locale.Tr "home.show_archived"}},
textShowPrivate: {{.locale.Tr "home.show_private"}},
textShowBothArchivedUnarchived: {{.locale.Tr "home.show_both_archived_unarchived"}},
textShowOnlyUnarchived: {{.locale.Tr "home.show_only_unarchived"}},
textShowOnlyArchived: {{.locale.Tr "home.show_only_archived"}},
textShowBothPrivatePublic: {{.locale.Tr "home.show_both_private_public"}},
textShowOnlyPublic: {{.locale.Tr "home.show_only_public"}},
textShowOnlyPrivate: {{.locale.Tr "home.show_only_private"}},
textAll: {{.locale.Tr "all"}},
textSources: {{.locale.Tr "sources"}},
textForks: {{.locale.Tr "forks"}},
textMirrors: {{.locale.Tr "mirrors"}},
textCollaborative: {{.locale.Tr "collaborative"}},
textFirstPage: {{.locale.Tr "admin.first_page"}},
textPreviousPage: {{.locale.Tr "repo.issues.previous"}},
textNextPage: {{.locale.Tr "repo.issues.next"}},
textLastPage: {{.locale.Tr "admin.last_page"}},
textMyOrgs: {{.locale.Tr "home.my_orgs"}},
textNewOrg: {{.locale.Tr "new_org"}},
};
{{if .Team}}
data.teamId = {{.Team.ID}};
{{end}}
{{if not .ContextUser.IsOrganization}}
data.organizations = [{{range .Orgs}}{'name': {{.Name}}, 'num_repos': {{.NumRepos}}},{{end}}];
data.isOrganization = false;
data.organizationsTotalCount = {{.UserOrgsCount}}
data.canCreateOrganization = {{.SignedUser.CanCreateOrganization}}
{{end}}
window.config.pageData.dashboardRepoList = data;
</script>
<div id="dashboard-repo-list" class="six wide column"></div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -51,7 +51,7 @@
<script>
import VueBarGraph from 'vue-bar-graph';
import {initVueApp} from './VueComponentLoader.js';
import {createApp} from 'vue';
const sfc = {
components: {VueBarGraph},
@ -102,8 +102,11 @@ const sfc = {
};
export function initRepoActivityTopAuthorsChart() {
initVueApp('#repo-activity-top-authors-chart', sfc);
const el = document.getElementById('repo-activity-top-authors-chart');
if (el) {
createApp(sfc).mount(el);
}
}
export default sfc; // this line is necessary to activate the IDE's Vue plugin
export default sfc; // activate the IDE's Vue plugin
</script>

View File

@ -1,6 +1,5 @@
import {createApp, nextTick} from 'vue';
import $ from 'jquery';
import {vueDelimiters} from './VueComponentLoader.js';
export function initRepoBranchTagDropdown(selector) {
$(selector).each(function (dropdownIndex, elRoot) {
@ -39,7 +38,7 @@ export function initRepoBranchTagDropdown(selector) {
}
const view = createApp({
delimiters: vueDelimiters,
delimiters: ['${', '}'],
data() {
return data;
},

View File

@ -1,49 +0,0 @@
import {createApp} from 'vue';
import {svgs} from '../svg.js';
export const vueDelimiters = ['${', '}'];
let vueEnvInited = false;
export function initVueEnv() {
if (vueEnvInited) return;
vueEnvInited = true;
// As far as I could tell, this is no longer possible.
// But there seem not to be a guide what to do instead.
// const isProd = window.config.runModeIsProd;
// Vue.config.devtools = !isProd;
}
let vueSvgInited = false;
export function initVueSvg(app) {
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"');
app.component(name, {
props: {
size: {
type: String,
default: '16',
},
},
template,
});
}
}
export function initVueApp(el, opts = {}) {
if (typeof el === 'string') {
el = document.querySelector(el);
}
if (!el) return null;
return createApp(
{delimiters: vueDelimiters, ...opts}
).mount(el);
}

View File

@ -2,9 +2,8 @@
import './bootstrap.js';
import $ from 'jquery';
import {initVueEnv} from './components/VueComponentLoader.js';
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
import {initDashboardRepoList} from './components/DashboardRepoList.js';
import {initDashboardRepoList} from './components/DashboardRepoList.vue';
import {attachTribute} from './features/tribute.js';
import {initGlobalCopyToClipboardListener} from './features/clipboard.js';
@ -100,7 +99,6 @@ $.fn.tab.settings.silent = true;
// Disable the behavior of fomantic to toggle the checkbox when you press enter on a checkbox element.
$.fn.checkbox.settings.enableEnterKey = false;
initVueEnv();
$(document).ready(() => {
initGlobalCommon();

View File

@ -31,8 +31,17 @@ import octiconSkip from '../../public/img/svg/octicon-skip.svg';
import octiconMeter from '../../public/img/svg/octicon-meter.svg';
import octiconBlocked from '../../public/img/svg/octicon-blocked.svg';
import octiconSync from '../../public/img/svg/octicon-sync.svg';
import octiconFilter from '../../public/img/svg/octicon-filter.svg';
import octiconPlus from '../../public/img/svg/octicon-plus.svg';
import octiconSearch from '../../public/img/svg/octicon-search.svg';
import octiconArchive from '../../public/img/svg/octicon-archive.svg';
import octiconStar from '../../public/img/svg/octicon-star.svg';
import giteaDoubleChevronLeft from '../../public/img/svg/gitea-double-chevron-left.svg';
import giteaDoubleChevronRight from '../../public/img/svg/gitea-double-chevron-right.svg';
import octiconChevronLeft from '../../public/img/svg/octicon-chevron-left.svg';
import octiconOrganization from '../../public/img/svg/octicon-organization.svg';
export const svgs = {
const svgs = {
'octicon-blocked': octiconBlocked,
'octicon-check-circle-fill': octiconCheckCircleFill,
'octicon-chevron-down': octiconChevronDown,
@ -66,14 +75,25 @@ export const svgs = {
'octicon-triangle-down': octiconTriangleDown,
'octicon-x': octiconX,
'octicon-x-circle-fill': octiconXCircleFill,
'octicon-filter': octiconFilter,
'octicon-plus': octiconPlus,
'octicon-search': octiconSearch,
'octicon-archive': octiconArchive,
'octicon-star': octiconStar,
'gitea-double-chevron-left': giteaDoubleChevronLeft,
'gitea-double-chevron-right': giteaDoubleChevronRight,
'octicon-chevron-left': octiconChevronLeft,
'octicon-organization': octiconOrganization,
};
// TODO: use a more general approach to access SVG icons. At the moment, developers must check, pick and fill the names manually, most of the SVG icons in assets couldn't be used directly.
const parser = new DOMParser();
const serializer = new XMLSerializer();
// retrieve a HTML string for given SVG icon name, size and additional classes
// retrieve an HTML string for given SVG icon name, size and additional classes
export function svg(name, size = 16, className = '') {
if (!(name in svgs)) return '';
if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`);
if (size === 16 && !className) return svgs[name];
const document = parser.parseFromString(svgs[name], 'image/svg+xml');