pass field values in binding drawer
This commit is contained in:
parent
267e85c2bf
commit
1b51113c44
|
@ -9,6 +9,7 @@ const INITIAL_BACKEND_UI_STATE = {
|
||||||
roles: [],
|
roles: [],
|
||||||
datasources: [],
|
datasources: [],
|
||||||
queries: [],
|
queries: [],
|
||||||
|
integrations: {},
|
||||||
selectedDatabase: {},
|
selectedDatabase: {},
|
||||||
selectedTable: {},
|
selectedTable: {},
|
||||||
draftTable: {},
|
draftTable: {},
|
||||||
|
@ -27,11 +28,15 @@ export const getBackendUiStore = () => {
|
||||||
const datasources = await datasourcesResponse.json()
|
const datasources = await datasourcesResponse.json()
|
||||||
const queriesResponse = await api.get(`/api/queries`)
|
const queriesResponse = await api.get(`/api/queries`)
|
||||||
const queries = await queriesResponse.json()
|
const queries = await queriesResponse.json()
|
||||||
|
const integrationsResponse = await api.get("/api/integrations")
|
||||||
|
const integrations = await integrationsResponse.json()
|
||||||
|
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
state.selectedDatabase = db
|
state.selectedDatabase = db
|
||||||
state.tables = tables
|
state.tables = tables
|
||||||
state.datasources = datasources
|
state.datasources = datasources
|
||||||
state.queries = queries
|
state.queries = queries
|
||||||
|
state.integrations = integrations
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -132,6 +137,7 @@ export const getBackendUiStore = () => {
|
||||||
state.selectedQueryId = json._id
|
state.selectedQueryId = json._id
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
return json
|
||||||
},
|
},
|
||||||
select: queryId =>
|
select: queryId =>
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
|
|
|
@ -1,26 +1,33 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
|
import { backendUiStore } from "builderStore"
|
||||||
import { Input, TextArea, Spacer } from "@budibase/bbui"
|
import { Input, TextArea, Spacer } from "@budibase/bbui"
|
||||||
import api from "builderStore/api"
|
|
||||||
import ICONS from "../icons"
|
import ICONS from "../icons"
|
||||||
|
|
||||||
const INTEGRATION_ICON_MAP = {
|
|
||||||
POSTGRES: "ri-database-2-line",
|
|
||||||
}
|
|
||||||
|
|
||||||
export let integration = {}
|
export let integration = {}
|
||||||
|
|
||||||
let integrationsPromise = fetchIntegrations()
|
let schema
|
||||||
let selectedIntegrationConfig
|
|
||||||
let integrations = []
|
let integrations = []
|
||||||
|
|
||||||
async function fetchIntegrations() {
|
async function fetchIntegrations() {
|
||||||
const response = await api.get("/api/integrations")
|
const response = await api.get("/api/integrations")
|
||||||
const json = await response.json()
|
const json = await response.json()
|
||||||
|
|
||||||
integrations = json
|
integrations = json
|
||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectIntegration(integrationType) {
|
||||||
|
schema = integrations[integrationType].datasource
|
||||||
|
integration = {
|
||||||
|
type: integrationType,
|
||||||
|
...Object.keys(schema).reduce(
|
||||||
|
(acc, next) => ({ ...acc, [next]: schema[next].default }),
|
||||||
|
{}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchIntegrations()
|
fetchIntegrations()
|
||||||
})
|
})
|
||||||
|
@ -32,18 +39,7 @@
|
||||||
<div
|
<div
|
||||||
class="integration hoverable"
|
class="integration hoverable"
|
||||||
class:selected={integration.type === integrationType}
|
class:selected={integration.type === integrationType}
|
||||||
on:click={() => {
|
on:click={() => selectIntegration(integrationType)}>
|
||||||
selectedIntegrationConfig = integrations[integrationType].datasource
|
|
||||||
integration = { type: integrationType, ...Object.keys(selectedIntegrationConfig).reduce(
|
|
||||||
(acc, next) => {
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[next]: selectedIntegrationConfig[next].default,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
) }
|
|
||||||
}}>
|
|
||||||
<svelte:component
|
<svelte:component
|
||||||
this={ICONS[integrationType]}
|
this={ICONS[integrationType]}
|
||||||
height="100"
|
height="100"
|
||||||
|
@ -53,11 +49,11 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if selectedIntegrationConfig}
|
{#if schema}
|
||||||
{#each Object.keys(selectedIntegrationConfig) as configKey}
|
{#each Object.keys(schema) as configKey}
|
||||||
<Input
|
<Input
|
||||||
thin
|
thin
|
||||||
type={selectedIntegrationConfig[configKey].type}
|
type={schema[configKey].type}
|
||||||
label={configKey}
|
label={configKey}
|
||||||
bind:value={integration[configKey]} />
|
bind:value={integration[configKey]} />
|
||||||
<Spacer medium />
|
<Spacer medium />
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
export let fields = {}
|
export let fields = {}
|
||||||
export let schema
|
export let schema
|
||||||
|
export let editable
|
||||||
|
|
||||||
let customSchema = {}
|
let customSchema = {}
|
||||||
let draftField = {}
|
let draftField = {}
|
||||||
|
@ -29,12 +30,13 @@
|
||||||
{#each Object.keys(schema.fields) as field}
|
{#each Object.keys(schema.fields) as field}
|
||||||
<Label extraSmall grey>{field}</Label>
|
<Label extraSmall grey>{field}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
disabled={!editable}
|
||||||
type={schema.fields[field]?.type}
|
type={schema.fields[field]?.type}
|
||||||
required={schema.fields[field]?.required}
|
required={schema.fields[field]?.required}
|
||||||
bind:value={fields[field]} />
|
bind:value={fields[field]} />
|
||||||
<Spacer medium />
|
<Spacer medium />
|
||||||
{/each}
|
{/each}
|
||||||
{#if schema.customisable}
|
{#if schema.customisable && editable}
|
||||||
<Spacer large />
|
<Spacer large />
|
||||||
<Label>Add Custom Field</Label>
|
<Label>Add Custom Field</Label>
|
||||||
{#each Object.keys(customSchema) as field}
|
{#each Object.keys(customSchema) as field}
|
||||||
|
|
|
@ -40,8 +40,8 @@
|
||||||
<div />
|
<div />
|
||||||
{/if}
|
{/if}
|
||||||
{#each parameters as parameter, idx}
|
{#each parameters as parameter, idx}
|
||||||
<Input thin bind:value={parameter.name} />
|
<Input thin disabled={bindable} bind:value={parameter.name} />
|
||||||
<Input thin bind:value={parameter.default} />
|
<Input thin disabled={bindable} bind:value={parameter.default} />
|
||||||
{#if bindable}
|
{#if bindable}
|
||||||
<BindableInput
|
<BindableInput
|
||||||
type="string"
|
type="string"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
|
import { goto } from "@sveltech/routify"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
Button,
|
Button,
|
||||||
|
@ -55,8 +56,9 @@
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
|
|
||||||
$: datasourceType = datasource.source
|
$: datasourceType = datasource?.source
|
||||||
$: datasourceType && fetchQueryConfig()
|
|
||||||
|
$: config = $backendUiStore.integrations[datasourceType]?.query
|
||||||
|
|
||||||
$: shouldShowQueryConfig = config && query.queryVerb && query.queryType
|
$: shouldShowQueryConfig = config && query.queryVerb && query.queryType
|
||||||
|
|
||||||
|
@ -69,17 +71,6 @@
|
||||||
fields = fields
|
fields = fields
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchQueryConfig() {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/integrations/${datasource.source}`)
|
|
||||||
const json = await response.json()
|
|
||||||
config = json.query
|
|
||||||
} catch (err) {
|
|
||||||
notifier.danger("Error fetching integration configuration.")
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewQuery() {
|
async function previewQuery() {
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/api/queries/preview`, {
|
const response = await api.post(`/api/queries/preview`, {
|
||||||
|
@ -115,8 +106,12 @@
|
||||||
|
|
||||||
async function saveQuery() {
|
async function saveQuery() {
|
||||||
try {
|
try {
|
||||||
await backendUiStore.actions.queries.save(query.datasourceId, query)
|
const { _id } = await backendUiStore.actions.queries.save(
|
||||||
|
query.datasourceId,
|
||||||
|
query
|
||||||
|
)
|
||||||
notifier.success(`Query saved successfully.`)
|
notifier.success(`Query saved successfully.`)
|
||||||
|
$goto(`../../${_id}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
notifier.danger(`Error creating query. ${err.message}`)
|
notifier.danger(`Error creating query. ${err.message}`)
|
||||||
|
@ -161,9 +156,10 @@
|
||||||
<Spacer medium />
|
<Spacer medium />
|
||||||
|
|
||||||
<IntegrationQueryEditor
|
<IntegrationQueryEditor
|
||||||
schema={config[query.queryVerb][query.queryType]}
|
|
||||||
{query}
|
{query}
|
||||||
bind:parameters />
|
schema={config[query.queryVerb][query.queryType]}
|
||||||
|
bind:parameters
|
||||||
|
/>
|
||||||
|
|
||||||
<Spacer medium />
|
<Spacer medium />
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,23 @@
|
||||||
<script>
|
<script>
|
||||||
import CodeMirror from "./codemirror"
|
import CodeMirror from "./codemirror"
|
||||||
import { onMount, createEventDispatcher } from "svelte"
|
import { onMount, createEventDispatcher } from "svelte"
|
||||||
|
import { themeStore } from "builderStore"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
const THEMES = {
|
||||||
|
DARK: "tomorrow-night-eighties",
|
||||||
|
LIGHT: "default",
|
||||||
|
}
|
||||||
|
|
||||||
export let value = ""
|
export let value = ""
|
||||||
export let readonly = false
|
export let readOnly = false
|
||||||
export let errorLoc = null
|
|
||||||
export let lineNumbers = true
|
export let lineNumbers = true
|
||||||
export let tab = true
|
export let tab = true
|
||||||
export let mode
|
export let mode
|
||||||
|
|
||||||
let w
|
let width
|
||||||
let h
|
let height
|
||||||
|
|
||||||
// We have to expose set and update methods, rather
|
// We have to expose set and update methods, rather
|
||||||
// than making this state-driven through props,
|
// than making this state-driven through props,
|
||||||
|
@ -72,43 +77,10 @@
|
||||||
let error_line
|
let error_line
|
||||||
let destroyed = false
|
let destroyed = false
|
||||||
|
|
||||||
$: if (editor && w && h) {
|
$: if (editor && width && height) {
|
||||||
editor.refresh()
|
editor.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
|
||||||
if (marker) marker.clear()
|
|
||||||
|
|
||||||
if (errorLoc) {
|
|
||||||
const line = errorLoc.line - 1
|
|
||||||
const ch = errorLoc.column
|
|
||||||
|
|
||||||
marker = editor.markText(
|
|
||||||
{ line, ch },
|
|
||||||
{ line, ch: ch + 1 },
|
|
||||||
{
|
|
||||||
className: "error-loc",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
error_line = line
|
|
||||||
} else {
|
|
||||||
error_line = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let previous_error_line
|
|
||||||
$: if (editor) {
|
|
||||||
if (previous_error_line != null) {
|
|
||||||
editor.removeLineClass(previous_error_line, "wrap", "error-line")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error_line && error_line !== previous_error_line) {
|
|
||||||
editor.addLineClass(error_line, "wrap", "error-line")
|
|
||||||
previous_error_line = error_line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
createEditor(mode).then(() => {
|
createEditor(mode).then(() => {
|
||||||
if (editor) editor.setValue(value || "")
|
if (editor) editor.setValue(value || "")
|
||||||
|
@ -137,9 +109,10 @@
|
||||||
mode: modes[mode] || {
|
mode: modes[mode] || {
|
||||||
name: mode,
|
name: mode,
|
||||||
},
|
},
|
||||||
readOnly: readonly,
|
readOnly,
|
||||||
autoCloseBrackets: true,
|
autoCloseBrackets: true,
|
||||||
autoCloseTags: true,
|
autoCloseTags: true,
|
||||||
|
theme: $themeStore.darkMode ? THEMES.DARK : THEMES.LIGHT,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tab)
|
if (!tab)
|
||||||
|
@ -182,6 +155,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.CodeMirror) {
|
:global(.CodeMirror) {
|
||||||
|
height: auto !important;
|
||||||
border-radius: var(--border-radius-m);
|
border-radius: var(--border-radius-m);
|
||||||
font-family: var(--font-sans) !important;
|
font-family: var(--font-sans) !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import CodeMirror from "codemirror"
|
import CodeMirror from "codemirror"
|
||||||
import "codemirror/lib/codemirror.css"
|
import "codemirror/lib/codemirror.css"
|
||||||
|
import "codemirror/theme/tomorrow-night-eighties.css"
|
||||||
|
import "codemirror/theme/neo.css"
|
||||||
import "codemirror/mode/sql/sql"
|
import "codemirror/mode/sql/sql"
|
||||||
import "codemirror/mode/css/css"
|
import "codemirror/mode/css/css"
|
||||||
import "codemirror/mode/handlebars/handlebars"
|
import "codemirror/mode/handlebars/handlebars"
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { onMount } from "svelte"
|
||||||
import { TextArea, Label, Input, Heading, Spacer } from "@budibase/bbui"
|
import { TextArea, Label, Input, Heading, Spacer } from "@budibase/bbui"
|
||||||
import Editor from "./SvelteEditor.svelte"
|
import Editor from "./SvelteEditor.svelte"
|
||||||
import ParameterBuilder from "./QueryParameterBuilder.svelte"
|
import ParameterBuilder from "./QueryParameterBuilder.svelte"
|
||||||
|
@ -12,14 +13,17 @@
|
||||||
|
|
||||||
export let query
|
export let query
|
||||||
export let schema
|
export let schema
|
||||||
|
export let editable = true
|
||||||
|
|
||||||
function updateQuery({ detail }) {
|
function updateQuery({ detail }) {
|
||||||
query.fields[schema.type] = detail.value
|
query.fields[schema.type] = detail.value
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if editable}
|
||||||
<ParameterBuilder bind:parameters={query.parameters} bindable={false} />
|
<ParameterBuilder bind:parameters={query.parameters} bindable={false} />
|
||||||
<Spacer large />
|
<Spacer large />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Heading extraSmall black>Query</Heading>
|
<Heading extraSmall black>Query</Heading>
|
||||||
<Spacer large />
|
<Spacer large />
|
||||||
|
@ -30,6 +34,7 @@
|
||||||
label="Query"
|
label="Query"
|
||||||
mode="sql"
|
mode="sql"
|
||||||
on:change={updateQuery}
|
on:change={updateQuery}
|
||||||
|
readOnly={!editable}
|
||||||
value={query.fields.sql} />
|
value={query.fields.sql} />
|
||||||
{:else if schema.type === QueryTypes.JSON}
|
{:else if schema.type === QueryTypes.JSON}
|
||||||
<Spacer large />
|
<Spacer large />
|
||||||
|
@ -37,8 +42,9 @@
|
||||||
label="Query"
|
label="Query"
|
||||||
mode="json"
|
mode="json"
|
||||||
on:change={updateQuery}
|
on:change={updateQuery}
|
||||||
|
readOnly={!editable}
|
||||||
value={query.fields.json} />
|
value={query.fields.json} />
|
||||||
{:else if schema.type === QueryTypes.FIELDS}
|
{:else if schema.type === QueryTypes.FIELDS}
|
||||||
<FieldsBuilder bind:fields={query.fields} {schema} />
|
<FieldsBuilder bind:fields={query.fields} {schema} {editable} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
import { notifier } from "builderStore/store/notifications"
|
import { notifier } from "builderStore/store/notifications"
|
||||||
import BottomDrawer from "components/common/BottomDrawer.svelte"
|
import BottomDrawer from "components/common/BottomDrawer.svelte"
|
||||||
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
|
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
|
||||||
|
import IntegrationQueryEditor from "components/integration/index.svelte"
|
||||||
import fetchBindableProperties from "../../builderStore/fetchBindableProperties"
|
import fetchBindableProperties from "../../builderStore/fetchBindableProperties"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
@ -13,18 +14,6 @@
|
||||||
|
|
||||||
export let value = {}
|
export let value = {}
|
||||||
|
|
||||||
function handleSelected(selected) {
|
|
||||||
dispatch("change", selected)
|
|
||||||
dropdownRight.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openBindingDrawer() {
|
|
||||||
bindingDrawerOpen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDatabindingDrawer() {
|
|
||||||
bindingDrawerOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
$: tables = $backendUiStore.tables.map(m => ({
|
$: tables = $backendUiStore.tables.map(m => ({
|
||||||
label: m.name,
|
label: m.name,
|
||||||
|
@ -78,6 +67,24 @@
|
||||||
type: "link",
|
type: "link",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleSelected(selected) {
|
||||||
|
dispatch("change", selected)
|
||||||
|
dropdownRight.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBindingDrawer() {
|
||||||
|
bindingDrawerOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDatabindingDrawer() {
|
||||||
|
bindingDrawerOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchDatasourceSchema(query) {
|
||||||
|
const source = $backendUiStore.datasources.find(ds => ds._id === query.datasourceId).source
|
||||||
|
return $backendUiStore.integrations[source].query[query.queryVerb][query.queryType];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -98,11 +105,18 @@
|
||||||
}}>Save</Button>
|
}}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-contents" slot="body">
|
<div class="drawer-contents" slot="body">
|
||||||
<pre>{value.queryString}</pre>
|
<IntegrationQueryEditor
|
||||||
|
query={value}
|
||||||
|
schema={fetchDatasourceSchema(value)}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
<Spacer large />
|
||||||
|
{#if value.parameters.length > 0}
|
||||||
<ParameterBuilder
|
<ParameterBuilder
|
||||||
bind:customParams={value.queryParams}
|
bind:customParams={value.queryParams}
|
||||||
parameters={value.parameters || []}
|
parameters={value.parameters}
|
||||||
bindings={queryBindableProperties} />
|
bindings={queryBindableProperties} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</BottomDrawer>
|
</BottomDrawer>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -229,6 +243,8 @@
|
||||||
|
|
||||||
.drawer-contents {
|
.drawer-contents {
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
|
height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
i {
|
i {
|
||||||
|
|
|
@ -17,24 +17,33 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
$: selectedQuery = $backendUiStore.queries.find(
|
||||||
if ($params.query !== "new") {
|
query => query._id === $backendUiStore.selectedQueryId
|
||||||
query = $backendUiStore.queries.find(query => query._id === $params.query)
|
) || {
|
||||||
} else {
|
|
||||||
// New query
|
|
||||||
query = {
|
|
||||||
datasourceId: $params.selectedDatasource,
|
datasourceId: $params.selectedDatasource,
|
||||||
name: "New Query",
|
name: "New Query",
|
||||||
parameters: [],
|
parameters: [],
|
||||||
fields: {},
|
fields: {},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// $: {
|
||||||
|
// if ($params.query !== "new") {
|
||||||
|
// query = $backendUiStore.queries.find(query => query._id === $params.query)
|
||||||
|
// } else {
|
||||||
|
// // New query
|
||||||
|
// query = {
|
||||||
|
// datasourceId: $params.selectedDatasource,
|
||||||
|
// name: "New Query",
|
||||||
|
// parameters: [],
|
||||||
|
// fields: {},
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if $backendUiStore.selectedDatabase._id && query}
|
{#if $backendUiStore.selectedDatabase._id && selectedQuery}
|
||||||
<QueryInterface {query} />
|
<QueryInterface query={selectedQuery} />
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue