Upgrade lifecycle and vue parent context
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> Signed-off-by: npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com>
This commit is contained in:
parent
843d799a2e
commit
4de6e80771
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -3704,11 +3704,17 @@
|
|||
id: tabView.id,
|
||||
name: tabView.getLabel(),
|
||||
icon: tabView.getIcon(),
|
||||
render: function(el, fileInfo) {
|
||||
mount: function(el, fileInfo) {
|
||||
tabView.setFileInfo(fileInfo)
|
||||
el.appendChild(tabView.el)
|
||||
},
|
||||
enabled,
|
||||
update: function(fileInfo) {
|
||||
tabView.setFileInfo(fileInfo)
|
||||
},
|
||||
destroy: function() {
|
||||
tabView.el.remove()
|
||||
},
|
||||
enabled: enabled
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
|
|
@ -23,21 +23,28 @@
|
|||
<template>
|
||||
<AppSidebarTab
|
||||
:id="id"
|
||||
ref="tab"
|
||||
:name="name"
|
||||
:icon="icon">
|
||||
<!-- Fallback loading -->
|
||||
<EmptyContent v-if="loading" icon="icon-loading" />
|
||||
|
||||
<!-- Using a dummy div as Vue mount replace the element directly
|
||||
It does NOT append to the content -->
|
||||
<div ref="mount"></div>
|
||||
<div ref="mount" />
|
||||
</AppSidebarTab>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppSidebarTab from '@nextcloud/vue/dist/Components/AppSidebarTab'
|
||||
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
|
||||
|
||||
export default {
|
||||
name: 'SidebarTab',
|
||||
|
||||
components: {
|
||||
AppSidebarTab,
|
||||
EmptyContent,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
@ -58,36 +65,61 @@ export default {
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
render: {
|
||||
|
||||
/**
|
||||
* Lifecycle methods.
|
||||
* They are prefixed with `on` to avoid conflict with Vue
|
||||
* methods like this.destroy
|
||||
*/
|
||||
onMount: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onUpdate: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onDestroy: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// TODO: implement a better way to force pass a prop fromm Sidebar
|
||||
// TODO: implement a better way to force pass a prop from Sidebar
|
||||
activeTab() {
|
||||
return this.$parent.activeTab
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
fileInfo(newFile, oldFile) {
|
||||
async fileInfo(newFile, oldFile) {
|
||||
// Update fileInfo on change
|
||||
if (newFile.id !== oldFile.id) {
|
||||
this.mountTab()
|
||||
this.loading = true
|
||||
await this.onUpdate(this.fileInfo)
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.mountTab()
|
||||
async mounted() {
|
||||
this.loading = true
|
||||
// Mount the tab: mounting point, fileInfo, vue context
|
||||
await this.onMount(this.$refs.mount, this.fileInfo, this.$refs.tab)
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
methods: {
|
||||
mountTab() {
|
||||
// Mount the tab into this component
|
||||
this.render(this.$refs.mount, this.fileInfo)
|
||||
},
|
||||
async beforeDestroy() {
|
||||
// unmount the tab
|
||||
await this.onDestroy()
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -25,7 +25,9 @@ export default class Tab {
|
|||
#id
|
||||
#name
|
||||
#icon
|
||||
#render
|
||||
#mount
|
||||
#update
|
||||
#destroy
|
||||
#enabled
|
||||
|
||||
/**
|
||||
|
@ -35,10 +37,12 @@ export default class Tab {
|
|||
* @param {string} options.id the unique id of this tab
|
||||
* @param {string} options.name the translated tab name
|
||||
* @param {string} options.icon the vue component
|
||||
* @param {Function} options.render function to render the tab
|
||||
* @param {Function} options.mount function to mount the tab
|
||||
* @param {Function} options.update function to update the tab
|
||||
* @param {Function} options.destroy function to destroy the tab
|
||||
* @param {Function} [options.enabled] define conditions whether this tab is active. Must returns a boolean
|
||||
*/
|
||||
constructor({ id, name, icon, render, enabled }) {
|
||||
constructor({ id, name, icon, mount, update, destroy, enabled } = {}) {
|
||||
if (enabled === undefined) {
|
||||
enabled = () => true
|
||||
}
|
||||
|
@ -53,8 +57,14 @@ export default class Tab {
|
|||
if (typeof icon !== 'string' || icon.trim() === '') {
|
||||
throw new Error('The icon argument is not a valid string')
|
||||
}
|
||||
if (typeof render !== 'function') {
|
||||
throw new Error('The render argument should be a function')
|
||||
if (typeof mount !== 'function') {
|
||||
throw new Error('The mount argument should be a function')
|
||||
}
|
||||
if (typeof update !== 'function') {
|
||||
throw new Error('The update argument should be a function')
|
||||
}
|
||||
if (typeof destroy !== 'function') {
|
||||
throw new Error('The destroy argument should be a function')
|
||||
}
|
||||
if (typeof enabled !== 'function') {
|
||||
throw new Error('The enabled argument should be a function')
|
||||
|
@ -63,7 +73,9 @@ export default class Tab {
|
|||
this.#id = id
|
||||
this.#name = name
|
||||
this.#icon = icon
|
||||
this.#render = render
|
||||
this.#mount = mount
|
||||
this.#update = update
|
||||
this.#destroy = destroy
|
||||
this.#enabled = enabled
|
||||
|
||||
}
|
||||
|
@ -80,8 +92,16 @@ export default class Tab {
|
|||
return this.#icon
|
||||
}
|
||||
|
||||
get render() {
|
||||
return this.#render
|
||||
get mount() {
|
||||
return this.#mount
|
||||
}
|
||||
|
||||
get update() {
|
||||
return this.#update
|
||||
}
|
||||
|
||||
get destroy() {
|
||||
return this.#destroy
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
|
|
|
@ -21,6 +21,8 @@
|
|||
*/
|
||||
|
||||
import Vue from 'vue'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
import SidebarView from './views/Sidebar.vue'
|
||||
import Sidebar from './services/Sidebar'
|
||||
import Tab from './models/Tab'
|
||||
|
|
|
@ -52,33 +52,38 @@
|
|||
</template>
|
||||
|
||||
<!-- Error display -->
|
||||
<div v-if="error" class="emptycontent">
|
||||
<div class="icon-error" />
|
||||
<h2>{{ error }}</h2>
|
||||
</div>
|
||||
<EmptyContent v-if="error" icon="icon-error">
|
||||
{{ error }}
|
||||
</EmptyContent>
|
||||
|
||||
<!-- If fileInfo fetch is complete, display tabs -->
|
||||
<template v-else-if="fileInfo" v-for="tab in tabs">
|
||||
<!-- If fileInfo fetch is complete, render tabs -->
|
||||
<template v-for="tab in tabs" v-else-if="fileInfo">
|
||||
<!-- Hide them if we're loading another file but keep them mounted -->
|
||||
<SidebarTab
|
||||
v-if="tab.enabled(fileInfo)"
|
||||
v-show="!loading"
|
||||
:id="tab.id"
|
||||
:key="tab.id"
|
||||
:name="tab.name"
|
||||
:icon="tab.icon"
|
||||
:render="tab.render"
|
||||
:on-mount="tab.mount"
|
||||
:on-update="tab.update"
|
||||
:on-destroy="tab.destroy"
|
||||
:file-info="fileInfo" />
|
||||
</template>
|
||||
</AppSidebar>
|
||||
</template>
|
||||
<script>
|
||||
import { encodePath } from '@nextcloud/paths'
|
||||
import $ from 'jquery'
|
||||
import axios from '@nextcloud/axios'
|
||||
import AppSidebar from '@nextcloud/vue/dist/Components/AppSidebar'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
|
||||
|
||||
import FileInfo from '../services/FileInfo'
|
||||
import SidebarTab from '../components/SidebarTab'
|
||||
import LegacyView from '../components/LegacyView'
|
||||
import { encodePath } from '@nextcloud/paths'
|
||||
|
||||
export default {
|
||||
name: 'Sidebar',
|
||||
|
@ -86,8 +91,9 @@ export default {
|
|||
components: {
|
||||
ActionButton,
|
||||
AppSidebar,
|
||||
SidebarTab,
|
||||
EmptyContent,
|
||||
LegacyView,
|
||||
SidebarTab,
|
||||
},
|
||||
|
||||
data() {
|
||||
|
@ -95,6 +101,7 @@ export default {
|
|||
// reactive state
|
||||
Sidebar: OCA.Files.Sidebar.state,
|
||||
error: null,
|
||||
loading: true,
|
||||
fileInfo: null,
|
||||
starLoading: false,
|
||||
}
|
||||
|
@ -185,15 +192,16 @@ export default {
|
|||
appSidebar() {
|
||||
if (this.fileInfo) {
|
||||
return {
|
||||
background: this.background,
|
||||
'data-mimetype': this.fileInfo.mimetype,
|
||||
'star-loading': this.starLoading,
|
||||
active: this.activeTab,
|
||||
background: this.background,
|
||||
class: { 'has-preview': this.fileInfo.hasPreview },
|
||||
compact: !this.fileInfo.hasPreview,
|
||||
'star-loading': this.starLoading,
|
||||
loading: this.loading,
|
||||
starred: this.fileInfo.isFavourited,
|
||||
subtitle: this.subtitle,
|
||||
title: this.fileInfo.name,
|
||||
'data-mimetype': this.fileInfo.mimetype,
|
||||
}
|
||||
} else if (this.error) {
|
||||
return {
|
||||
|
@ -201,12 +209,12 @@ export default {
|
|||
subtitle: '',
|
||||
title: '',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
class: 'icon-loading',
|
||||
subtitle: '',
|
||||
title: '',
|
||||
}
|
||||
}
|
||||
// no fileInfo yet, showing empty data
|
||||
return {
|
||||
loading: this.loading,
|
||||
subtitle: '',
|
||||
title: '',
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -241,35 +249,6 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
// update the sidebar data
|
||||
async file(curr, prev) {
|
||||
this.resetData()
|
||||
if (curr && curr.trim() !== '') {
|
||||
try {
|
||||
this.fileInfo = await FileInfo(this.davPath)
|
||||
// adding this as fallback because other apps expect it
|
||||
this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/')
|
||||
|
||||
// DEPRECATED legacy views
|
||||
// TODO: remove
|
||||
this.views.forEach(view => {
|
||||
view.setFileInfo(this.fileInfo)
|
||||
})
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.tabs) {
|
||||
this.$refs.tabs.updateTabs()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
this.error = t('files', 'Error while loading the file data')
|
||||
console.error('Error while loading the file data', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Can this tab be displayed ?
|
||||
|
@ -403,9 +382,11 @@ export default {
|
|||
// update current opened file
|
||||
this.Sidebar.file = path
|
||||
|
||||
// reset previous data
|
||||
this.resetData()
|
||||
if (path && path.trim() !== '') {
|
||||
// reset data, keep old fileInfo to not reload all tabs and just hide them
|
||||
this.error = null
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
this.fileInfo = await FileInfo(this.davPath)
|
||||
// adding this as fallback because other apps expect it
|
||||
|
@ -427,6 +408,8 @@ export default {
|
|||
console.error('Error while loading the file data', error)
|
||||
|
||||
throw new Error(error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,2 +1,2 @@
|
|||
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=152)}({152:function(e,n,r){r.p=OC.linkTo("files_sharing","js/dist/"),r.nc=btoa(OC.requestToken),window.OCP.Collaboration.registerType("file",{action:function(){return new Promise((function(e,n){OC.dialogs.filepicker(t("files_sharing","Link to a file"),(function(t){OC.Files.getClient().getFileInfo(t).then((function(n,t){e(t.id)})).fail((function(){n(new Error("Cannot get fileinfo"))}))}),!1,null,!1,OC.dialogs.FILEPICKER_TYPE_CHOOSE,"",{allowDirectoryChooser:!0})}))},typeString:t("files_sharing","Link to a file"),typeIconClass:"icon-files-dark"})}});
|
||||
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=153)}({153:function(e,n,r){r.p=OC.linkTo("files_sharing","js/dist/"),r.nc=btoa(OC.requestToken),window.OCP.Collaboration.registerType("file",{action:function(){return new Promise((function(e,n){OC.dialogs.filepicker(t("files_sharing","Link to a file"),(function(t){OC.Files.getClient().getFileInfo(t).then((function(n,t){e(t.id)})).fail((function(){n(new Error("Cannot get fileinfo"))}))}),!1,null,!1,OC.dialogs.FILEPICKER_TYPE_CHOOSE,"",{allowDirectoryChooser:!0})}))},typeString:t("files_sharing","Link to a file"),typeIconClass:"icon-files-dark"})}});
|
||||
//# sourceMappingURL=collaboration.js.map
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,2 +1,2 @@
|
|||
!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="/js/",r(r.s=277)}({277:function(e,t){Object.assign(OC,{Share:{SHARE_TYPE_USER:0,SHARE_TYPE_GROUP:1,SHARE_TYPE_LINK:3,SHARE_TYPE_EMAIL:4,SHARE_TYPE_REMOTE:6,SHARE_TYPE_CIRCLE:7,SHARE_TYPE_GUEST:8,SHARE_TYPE_REMOTE_GROUP:9,SHARE_TYPE_ROOM:10}})}});
|
||||
!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="/js/",r(r.s=278)}({278:function(e,t){Object.assign(OC,{Share:{SHARE_TYPE_USER:0,SHARE_TYPE_GROUP:1,SHARE_TYPE_LINK:3,SHARE_TYPE_EMAIL:4,SHARE_TYPE_REMOTE:6,SHARE_TYPE_CIRCLE:7,SHARE_TYPE_GUEST:8,SHARE_TYPE_REMOTE_GROUP:9,SHARE_TYPE_ROOM:10}})}});
|
||||
//# sourceMappingURL=main.js.map
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -42,6 +42,7 @@ Vue.use(VueClipboard)
|
|||
|
||||
// Init Sharing tab component
|
||||
const View = Vue.extend(SharingTab)
|
||||
let TabInstance = null
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
if (OCA.Files && OCA.Files.Sidebar) {
|
||||
|
@ -50,13 +51,24 @@ window.addEventListener('DOMContentLoaded', function() {
|
|||
name: t('files_sharing', 'Sharing'),
|
||||
icon: 'icon-share',
|
||||
|
||||
render: (el, fileInfo) => {
|
||||
new View({
|
||||
propsData: {
|
||||
fileInfo,
|
||||
},
|
||||
}).$mount(el)
|
||||
console.info(el)
|
||||
async mount(el, fileInfo, context) {
|
||||
if (TabInstance) {
|
||||
TabInstance.$destroy()
|
||||
}
|
||||
TabInstance = new View({
|
||||
// Better integration with vue parent component
|
||||
parent: context,
|
||||
})
|
||||
// Only mount after we have all the info we need
|
||||
await TabInstance.update(fileInfo)
|
||||
TabInstance.$mount(el)
|
||||
},
|
||||
update(fileInfo) {
|
||||
TabInstance.update(fileInfo)
|
||||
},
|
||||
destroy() {
|
||||
TabInstance.$destroy()
|
||||
TabInstance = null
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -117,24 +117,20 @@ export default {
|
|||
|
||||
mixins: [ShareTypes],
|
||||
|
||||
props: {
|
||||
fileInfo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: '',
|
||||
expirationInterval: null,
|
||||
loading: true,
|
||||
|
||||
fileInfo: null,
|
||||
|
||||
// reshare Share object
|
||||
reshare: null,
|
||||
sharedWithMe: {},
|
||||
shares: [],
|
||||
linkShares: [],
|
||||
|
||||
sections: OCA.Sharing.ShareTabSections.getSections(),
|
||||
}
|
||||
},
|
||||
|
@ -155,20 +151,17 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
fileInfo(newFile, oldFile) {
|
||||
if (newFile.id !== oldFile.id) {
|
||||
this.resetState()
|
||||
this.getShares()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
this.getShares()
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Update current fileInfo and fetch new data
|
||||
* @param {Object} fileInfo the current file FileInfo
|
||||
*/
|
||||
async update(fileInfo) {
|
||||
this.fileInfo = fileInfo
|
||||
this.resetState()
|
||||
this.getShares()
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the existing shares infos
|
||||
*/
|
||||
|
@ -221,6 +214,7 @@ export default {
|
|||
this.error = ''
|
||||
this.sharedWithMe = {}
|
||||
this.shares = []
|
||||
this.linkShares = []
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue