Merge branch 'master' of https://github.com/Budibase/budibase into builder/consolidating-missing-code
This commit is contained in:
commit
2b0436eb99
|
@ -6,7 +6,6 @@ import { writable, get } from "svelte/store"
|
|||
import api from "../api"
|
||||
import { DEFAULT_PAGES_OBJECT } from "../../constants"
|
||||
import { getExactComponent } from "components/userInterface/pagesParsing/searchComponents"
|
||||
import { rename } from "components/userInterface/pagesParsing/renameScreen"
|
||||
import {
|
||||
createProps,
|
||||
makePropsSafe,
|
||||
|
@ -25,6 +24,7 @@ import {
|
|||
saveCurrentPreviewItem as _saveCurrentPreviewItem,
|
||||
saveScreenApi as _saveScreenApi,
|
||||
regenerateCssForCurrentScreen,
|
||||
renameCurrentScreen,
|
||||
} from "../storeUtils"
|
||||
|
||||
export const getStore = () => {
|
||||
|
@ -53,7 +53,6 @@ export const getStore = () => {
|
|||
store.createDatabaseForApp = backendStoreActions.createDatabaseForApp(store)
|
||||
|
||||
store.saveScreen = saveScreen(store)
|
||||
store.renameScreen = renameScreen(store)
|
||||
store.deleteScreen = deleteScreen(store)
|
||||
store.setCurrentScreen = setCurrentScreen(store)
|
||||
store.setCurrentPage = setCurrentPage(store)
|
||||
|
@ -64,6 +63,7 @@ export const getStore = () => {
|
|||
store.addChildComponent = addChildComponent(store)
|
||||
store.selectComponent = selectComponent(store)
|
||||
store.setComponentProp = setComponentProp(store)
|
||||
store.setPageOrScreenProp = setPageOrScreenProp(store)
|
||||
store.setComponentStyle = setComponentStyle(store)
|
||||
store.setComponentCode = setComponentCode(store)
|
||||
store.setScreenType = setScreenType(store)
|
||||
|
@ -208,46 +208,6 @@ const deleteScreen = store => name => {
|
|||
})
|
||||
}
|
||||
|
||||
const renameScreen = store => (oldname, newname) => {
|
||||
store.update(s => {
|
||||
const { screens, pages, error, changedScreens } = rename(
|
||||
s.pages,
|
||||
s.screens,
|
||||
oldname,
|
||||
newname
|
||||
)
|
||||
|
||||
if (error) {
|
||||
// should really do something with this
|
||||
return s
|
||||
}
|
||||
|
||||
s.screens = screens
|
||||
s.pages = pages
|
||||
if (s.currentPreviewItem.name === oldname)
|
||||
s.currentPreviewItem.name = newname
|
||||
|
||||
const saveAllChanged = async () => {
|
||||
for (let screenName of changedScreens) {
|
||||
const changedScreen = getExactComponent(screens, screenName)
|
||||
await api.post(`/_builder/api/${s.appId}/screen`, changedScreen)
|
||||
}
|
||||
}
|
||||
|
||||
api
|
||||
.patch(`/_builder/api/${s.appId}/screen`, {
|
||||
oldname,
|
||||
newname,
|
||||
})
|
||||
.then(() => saveAllChanged())
|
||||
.then(() => {
|
||||
_savePage(s)
|
||||
})
|
||||
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
const savePage = store => async page => {
|
||||
store.update(state => {
|
||||
if (state.currentFrontEndType !== "page" || !state.currentPageName) {
|
||||
|
@ -403,6 +363,18 @@ const setComponentProp = store => (name, value) => {
|
|||
})
|
||||
}
|
||||
|
||||
const setPageOrScreenProp = store => (name, value) => {
|
||||
store.update(state => {
|
||||
if (name === "name" && state.currentFrontEndType === "screen") {
|
||||
state = renameCurrentScreen(value, state)
|
||||
} else {
|
||||
state.currentPreviewItem[name] = value
|
||||
_saveCurrentPreviewItem(state)
|
||||
}
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
const setComponentStyle = store => (type, name, value) => {
|
||||
store.update(state => {
|
||||
if (!state.currentComponentInfo._styles) {
|
||||
|
|
|
@ -45,6 +45,19 @@ export const saveScreenApi = (screen, s) => {
|
|||
.then(() => savePage(s))
|
||||
}
|
||||
|
||||
export const renameCurrentScreen = (newname, state) => {
|
||||
const oldname = state.currentPreviewItem.name
|
||||
state.currentPreviewItem.name = newname
|
||||
api.patch(
|
||||
`/_builder/api/${state.appId}/pages/${state.currentPageName}/screen`,
|
||||
{
|
||||
oldname,
|
||||
newname,
|
||||
}
|
||||
)
|
||||
return state
|
||||
}
|
||||
|
||||
export const walkProps = (props, action, cancelToken = null) => {
|
||||
cancelToken = cancelToken || { cancelled: false }
|
||||
action(props, () => {
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
import CodeEditor from "./CodeEditor.svelte"
|
||||
import LayoutEditor from "./LayoutEditor.svelte"
|
||||
import EventsEditor from "./EventsEditor"
|
||||
|
||||
import panelStructure from "./temporaryPanelStructure.js"
|
||||
import CategoryTab from "./CategoryTab.svelte"
|
||||
import DesignView from "./DesignView.svelte"
|
||||
|
@ -40,28 +39,8 @@
|
|||
|
||||
let panelDefinition = {}
|
||||
|
||||
$: {
|
||||
if (componentPropDefinition.properties) {
|
||||
if (selectedCategory.value === "design") {
|
||||
panelDefinition = componentPropDefinition.properties["design"]
|
||||
} else {
|
||||
let panelDef = componentPropDefinition.properties["settings"]
|
||||
if (
|
||||
$store.currentFrontEndType === "page" &&
|
||||
$store.currentView !== "component"
|
||||
) {
|
||||
panelDefinition = [...page, ...panelDef]
|
||||
} else if (
|
||||
$store.currentFrontEndType === "screen" &&
|
||||
$store.currentView !== "component"
|
||||
) {
|
||||
panelDefinition = [...screen, ...panelDef]
|
||||
} else {
|
||||
panelDefinition = panelDef
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$: panelDefinition = componentPropDefinition.properties &&
|
||||
componentPropDefinition.properties[selectedCategory.value]
|
||||
|
||||
const onStyleChanged = store.setComponentStyle
|
||||
const onPropChanged = store.setComponentProp
|
||||
|
@ -117,6 +96,8 @@
|
|||
{panelDefinition}
|
||||
displayNameField={displayName}
|
||||
onChange={onPropChanged} />
|
||||
onScreenPropChange={store.setPageOrScreenProp}
|
||||
screenOrPageInstance={$store.currentView !== "component" && $store.currentPreviewItem} />
|
||||
{:else if selectedCategory.value === 'events'}
|
||||
<EventsEditor component={componentInstance} />
|
||||
{/if}
|
||||
|
|
|
@ -9,11 +9,11 @@
|
|||
|
||||
const pages = [
|
||||
{
|
||||
title: "Main",
|
||||
title: "Private",
|
||||
id: "main",
|
||||
},
|
||||
{
|
||||
title: "Login",
|
||||
title: "Public",
|
||||
id: "unauthenticated",
|
||||
},
|
||||
]
|
||||
|
|
|
@ -3,23 +3,61 @@
|
|||
import InputGroup from "../common/Inputs/InputGroup.svelte"
|
||||
import Input from "../common/Input.svelte"
|
||||
import Colorpicker from "../common/Colorpicker.svelte"
|
||||
import { goto } from "@sveltech/routify"
|
||||
import { excludeProps } from "./propertyCategories.js"
|
||||
import Input from "../common/Input.svelte"
|
||||
|
||||
export let panelDefinition = []
|
||||
export let componentDefinition = {}
|
||||
export let componentInstance = {}
|
||||
export let onChange = () => {}
|
||||
export let displayNameField = false
|
||||
export let onScreenPropChange = () => {}
|
||||
export let screenOrPageInstance
|
||||
|
||||
const propExistsOnComponentDef = prop => prop in componentDefinition.props
|
||||
|
||||
function handleChange(key, data) {
|
||||
data.target ? onChange(key, data.target.value) : onChange(key, data)
|
||||
}
|
||||
|
||||
function handleScreenPropChange (name, value) {
|
||||
onScreenPropChange(name,value)
|
||||
if(!isPage && name === "name") {
|
||||
// screen name is changed... change URL
|
||||
$goto(`./:page/${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const screenDefinition = [
|
||||
{ key: "name", label: "Name", control: Input },
|
||||
{ key: "description", label: "Description", control: Input },
|
||||
{ key: "route", label: "Route", control: Input },
|
||||
]
|
||||
|
||||
const pageDefinition = [
|
||||
{ key: "title", label: "Title", control: Input },
|
||||
{ key: "favicon", label: "Favicon", control: Input },
|
||||
]
|
||||
|
||||
$: isPage = screenOrPageInstance && screenOrPageInstance.favicon
|
||||
$: screenOrPageDefinition = isPage ? pageDefinition : screenDefinition
|
||||
|
||||
</script>
|
||||
|
||||
{#if displayNameField}
|
||||
<PropertyControl control={Input} label="Name" key="_instanceName" value={componentInstance._instanceName} {onChange} />
|
||||
<!-- {#if displayNameField}
|
||||
<PropertyControl control={Input} label="Name" key="_instanceName" value={componentInstance._instanceName} {onChange} /> -->
|
||||
{#if screenOrPageInstance}
|
||||
{#each screenOrPageDefinition as def}
|
||||
<PropertyControl
|
||||
control={def.control}
|
||||
label={def.label}
|
||||
key={def.key}
|
||||
value={screenOrPageInstance[def.key]}
|
||||
onChange={handleScreenPropChange}
|
||||
props={{ ...excludeProps(def, ['control', 'label']) }} />
|
||||
{/each}
|
||||
<hr/>
|
||||
{/if}
|
||||
|
||||
{#if panelDefinition && panelDefinition.length > 0}
|
||||
|
|
|
@ -11,6 +11,18 @@ export default {
|
|||
name: "Basic",
|
||||
isCategory: true,
|
||||
children: [
|
||||
{
|
||||
_component: "@budibase/standard-components/embed",
|
||||
icon: "ri-code-line",
|
||||
name: "Embed",
|
||||
description: "Embed content from 3rd party sources",
|
||||
properties: {
|
||||
design: {
|
||||
...all,
|
||||
},
|
||||
settings: [{ label: "Embed", key: "embed", control: Input }],
|
||||
},
|
||||
},
|
||||
{
|
||||
_component: "@budibase/standard-components/container",
|
||||
name: "Container",
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
"scripts": {
|
||||
"test": "jest routes --runInBand",
|
||||
"test:integration": "jest workflow --runInBand",
|
||||
"test:watch": "jest -w",
|
||||
"test:watch": "jest --watch",
|
||||
"initialise": "node ../cli/bin/budi init -b local -q",
|
||||
"budi": "node ../cli/bin/budi",
|
||||
"dev:builder": "nodemon ../cli/bin/budi run",
|
||||
|
|
|
@ -27,6 +27,23 @@ exports.create = async function(ctx) {
|
|||
const result = await db.post(newModel)
|
||||
newModel._rev = result.rev
|
||||
|
||||
const { schema } = ctx.request.body
|
||||
for (let key in schema) {
|
||||
// model has a linked record
|
||||
if (schema[key].type === "link") {
|
||||
// create the link field in the other model
|
||||
const linkedModel = await db.get(schema[key].modelId)
|
||||
linkedModel.schema[newModel.name] = {
|
||||
type: "link",
|
||||
modelId: newModel._id,
|
||||
constraints: {
|
||||
type: "array",
|
||||
},
|
||||
}
|
||||
await db.put(linkedModel)
|
||||
}
|
||||
}
|
||||
|
||||
const designDoc = await db.get("_design/database")
|
||||
designDoc.views = {
|
||||
...designDoc.views,
|
||||
|
@ -50,7 +67,10 @@ exports.update = async function() {}
|
|||
exports.destroy = async function(ctx) {
|
||||
const db = new CouchDB(ctx.params.instanceId)
|
||||
|
||||
await db.remove(ctx.params.modelId, ctx.params.revId)
|
||||
const modelToDelete = await db.get(ctx.params.modelId)
|
||||
|
||||
await db.remove(modelToDelete)
|
||||
|
||||
const modelViewId = `all_${ctx.params.modelId}`
|
||||
|
||||
// Delete all records for that model
|
||||
|
@ -59,6 +79,16 @@ exports.destroy = async function(ctx) {
|
|||
records.rows.map(record => ({ id: record.id, _deleted: true }))
|
||||
)
|
||||
|
||||
// Delete linked record fields in dependent models
|
||||
for (let key in modelToDelete.schema) {
|
||||
const { type, modelId } = modelToDelete.schema[key]
|
||||
if (type === "link") {
|
||||
const linkedModel = await db.get(modelId)
|
||||
delete linkedModel.schema[modelToDelete.name]
|
||||
await db.put(linkedModel)
|
||||
}
|
||||
}
|
||||
|
||||
// delete the "all" view
|
||||
const designDoc = await db.get("_design/database")
|
||||
delete designDoc.views[modelViewId]
|
||||
|
|
|
@ -61,7 +61,7 @@ exports.fetchView = async function(ctx) {
|
|||
ctx.body = response.rows.map(row => row.doc)
|
||||
}
|
||||
|
||||
exports.fetchModel = async function(ctx) {
|
||||
exports.fetchModelRecords = async function(ctx) {
|
||||
const db = new CouchDB(ctx.params.instanceId)
|
||||
const response = await db.query(`database/all_${ctx.params.modelId}`, {
|
||||
include_docs: true,
|
||||
|
@ -69,6 +69,15 @@ exports.fetchModel = async function(ctx) {
|
|||
ctx.body = response.rows.map(row => row.doc)
|
||||
}
|
||||
|
||||
exports.search = async function(ctx) {
|
||||
const db = new CouchDB(ctx.params.instanceId)
|
||||
const response = await db.allDocs({
|
||||
include_docs: true,
|
||||
...ctx.request.body,
|
||||
})
|
||||
ctx.body = response.rows.map(row => row.doc)
|
||||
}
|
||||
|
||||
exports.find = async function(ctx) {
|
||||
const db = new CouchDB(ctx.params.instanceId)
|
||||
const record = await db.get(ctx.params.recordId)
|
||||
|
|
|
@ -10,6 +10,7 @@ const {
|
|||
instanceRoutes,
|
||||
clientRoutes,
|
||||
applicationRoutes,
|
||||
recordRoutes,
|
||||
modelRoutes,
|
||||
viewRoutes,
|
||||
staticRoutes,
|
||||
|
@ -69,6 +70,9 @@ router.use(viewRoutes.allowedMethods())
|
|||
router.use(modelRoutes.routes())
|
||||
router.use(modelRoutes.allowedMethods())
|
||||
|
||||
router.use(recordRoutes.routes())
|
||||
router.use(recordRoutes.allowedMethods())
|
||||
|
||||
router.use(userRoutes.routes())
|
||||
router.use(userRoutes.allowedMethods())
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ const instanceRoutes = require("./instance")
|
|||
const clientRoutes = require("./client")
|
||||
const applicationRoutes = require("./application")
|
||||
const modelRoutes = require("./model")
|
||||
const recordRoutes = require("./record")
|
||||
const viewRoutes = require("./view")
|
||||
const staticRoutes = require("./static")
|
||||
const componentRoutes = require("./component")
|
||||
|
@ -18,6 +19,7 @@ module.exports = {
|
|||
instanceRoutes,
|
||||
clientRoutes,
|
||||
applicationRoutes,
|
||||
recordRoutes,
|
||||
modelRoutes,
|
||||
viewRoutes,
|
||||
staticRoutes,
|
||||
|
|
|
@ -1,46 +1,10 @@
|
|||
const Router = require("@koa/router")
|
||||
const modelController = require("../controllers/model")
|
||||
const recordController = require("../controllers/record")
|
||||
const authorized = require("../../middleware/authorized")
|
||||
const {
|
||||
READ_MODEL,
|
||||
WRITE_MODEL,
|
||||
BUILDER,
|
||||
} = require("../../utilities/accessLevels")
|
||||
const { BUILDER } = require("../../utilities/accessLevels")
|
||||
|
||||
const router = Router()
|
||||
|
||||
// records
|
||||
|
||||
router
|
||||
.get(
|
||||
"/api/:instanceId/:modelId/records",
|
||||
authorized(READ_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.fetchModel
|
||||
)
|
||||
.get(
|
||||
"/api/:instanceId/:modelId/records/:recordId",
|
||||
authorized(READ_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.find
|
||||
)
|
||||
.post(
|
||||
"/api/:instanceId/:modelId/records",
|
||||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.save
|
||||
)
|
||||
.post(
|
||||
"/api/:instanceId/:modelId/records/validate",
|
||||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.validate
|
||||
)
|
||||
.delete(
|
||||
"/api/:instanceId/:modelId/records/:recordId/:revId",
|
||||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.destroy
|
||||
)
|
||||
|
||||
// models
|
||||
|
||||
router
|
||||
.get("/api/:instanceId/models", authorized(BUILDER), modelController.fetch)
|
||||
.get("/api/:instanceId/models/:id", authorized(BUILDER), modelController.find)
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
const Router = require("@koa/router")
|
||||
const recordController = require("../controllers/record")
|
||||
const authorized = require("../../middleware/authorized")
|
||||
const { READ_MODEL, WRITE_MODEL } = require("../../utilities/accessLevels")
|
||||
|
||||
const router = Router()
|
||||
|
||||
router
|
||||
.get(
|
||||
"/api/:instanceId/:modelId/records",
|
||||
authorized(READ_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.fetchModelRecords
|
||||
)
|
||||
.get(
|
||||
"/api/:instanceId/:modelId/records/:recordId",
|
||||
authorized(READ_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.find
|
||||
)
|
||||
.post("/api/:instanceId/records/search", recordController.search)
|
||||
.post(
|
||||
"/api/:instanceId/:modelId/records",
|
||||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.save
|
||||
)
|
||||
.post(
|
||||
"/api/:instanceId/:modelId/records/validate",
|
||||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.validate
|
||||
)
|
||||
.delete(
|
||||
"/api/:instanceId/:modelId/records/:recordId/:revId",
|
||||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.destroy
|
||||
)
|
||||
|
||||
module.exports = router
|
|
@ -253,3 +253,7 @@ exports.insertDocument = async (databaseId, document) => {
|
|||
exports.destroyDocument = async (databaseId, documentId) => {
|
||||
return await new CouchDB(databaseId).destroy(documentId)
|
||||
}
|
||||
|
||||
exports.getDocument = async (databaseId, documentId) => {
|
||||
return await new CouchDB(databaseId).get(documentId)
|
||||
}
|
||||
|
|
|
@ -5,7 +5,8 @@ const {
|
|||
createClientDatabase,
|
||||
createApplication,
|
||||
defaultHeaders,
|
||||
builderEndpointShouldBlockNormalUsers
|
||||
builderEndpointShouldBlockNormalUsers,
|
||||
getDocument
|
||||
} = require("./couchTestUtils")
|
||||
|
||||
describe("/models", () => {
|
||||
|
@ -97,7 +98,6 @@ describe("/models", () => {
|
|||
instanceId: instance._id,
|
||||
})
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
|
@ -108,7 +108,11 @@ describe("/models", () => {
|
|||
testModel = await createModel(request, instance._id, testModel)
|
||||
});
|
||||
|
||||
it("returns a success response when a model is deleted.", done => {
|
||||
afterEach(() => {
|
||||
delete testModel._rev
|
||||
})
|
||||
|
||||
it("returns a success response when a model is deleted.", async done => {
|
||||
request
|
||||
.delete(`/api/${instance._id}/models/${testModel._id}/${testModel._rev}`)
|
||||
.set(defaultHeaders)
|
||||
|
@ -120,6 +124,41 @@ describe("/models", () => {
|
|||
});
|
||||
})
|
||||
|
||||
it("deletes linked references to the model after deletion", async done => {
|
||||
const linkedModel = await createModel(request, instance._id, {
|
||||
name: "LinkedModel",
|
||||
type: "model",
|
||||
key: "name",
|
||||
schema: {
|
||||
name: {
|
||||
type: "text",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
TestModel: {
|
||||
type: "link",
|
||||
modelId: testModel._id,
|
||||
constraints: {
|
||||
type: "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
request
|
||||
.delete(`/api/${instance._id}/models/${testModel._id}/${testModel._rev}`)
|
||||
.set(defaultHeaders)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.end(async (_, res) => {
|
||||
expect(res.res.statusMessage).toEqual(`Model ${testModel._id} deleted.`);
|
||||
const dependentModel = await getDocument(instance._id, linkedModel._id)
|
||||
expect(dependentModel.schema.TestModel).not.toBeDefined();
|
||||
done();
|
||||
});
|
||||
})
|
||||
|
||||
it("should apply authorization to endpoint", async () => {
|
||||
await builderEndpointShouldBlockNormalUsers({
|
||||
request,
|
||||
|
|
|
@ -110,6 +110,30 @@ describe("/records", () => {
|
|||
expect(res.body.find(r => r.name === record.name)).toBeDefined()
|
||||
})
|
||||
|
||||
it("lists records when queried by their ID", async () => {
|
||||
const newRecord = {
|
||||
modelId: model._id,
|
||||
name: "Second Contact",
|
||||
status: "new"
|
||||
}
|
||||
const record = await createRecord()
|
||||
const secondRecord = await createRecord(newRecord)
|
||||
|
||||
const recordIds = [record.body._id, secondRecord.body._id]
|
||||
|
||||
const res = await request
|
||||
.post(`/api/${instance._id}/records/search`)
|
||||
.set(defaultHeaders)
|
||||
.send({
|
||||
keys: recordIds
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.length).toBe(2)
|
||||
expect(res.body.map(response => response._id)).toEqual(expect.arrayContaining(recordIds))
|
||||
})
|
||||
|
||||
it("load should return 404 when record does not exist", async () => {
|
||||
await createRecord()
|
||||
await request
|
||||
|
|
|
@ -6,6 +6,13 @@
|
|||
"component": "button"
|
||||
}
|
||||
},
|
||||
"embed": {
|
||||
"name": "Embed",
|
||||
"description": "Embed stuff",
|
||||
"props": {
|
||||
"embed": "string"
|
||||
}
|
||||
},
|
||||
"Navigation": {
|
||||
"name": "Navigation",
|
||||
"description": "A basic header navigation component",
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<script>
|
||||
export let embed
|
||||
</script>
|
||||
|
||||
{@html embed}
|
|
@ -21,3 +21,4 @@ export { default as datalist } from "./DataList.svelte"
|
|||
export { default as list } from "./List.svelte"
|
||||
export { default as datasearch } from "./DataSearch.svelte"
|
||||
export { default as datamap } from "./DataMap.svelte"
|
||||
export { default as embed } from "./Embed.svelte"
|
||||
|
|
Loading…
Reference in New Issue