began UI for custom views
This commit is contained in:
parent
f62e7e06d8
commit
6862be744b
|
@ -59,7 +59,6 @@ export const getBackendUiStore = () => {
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
state.selectedModel = model
|
state.selectedModel = model
|
||||||
state.draftModel = cloneDeep(model)
|
state.draftModel = cloneDeep(model)
|
||||||
state.selectedField = ""
|
|
||||||
state.selectedView = `all_${model._id}`
|
state.selectedView = `all_${model._id}`
|
||||||
return state
|
return state
|
||||||
}),
|
}),
|
||||||
|
@ -87,9 +86,7 @@ export const getBackendUiStore = () => {
|
||||||
delete: async model => {
|
delete: async model => {
|
||||||
await api.delete(`/api/models/${model._id}/${model._rev}`)
|
await api.delete(`/api/models/${model._id}/${model._rev}`)
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
state.models = state.models.filter(
|
state.models = state.models.filter(existing => existing._id !== model._id)
|
||||||
existing => existing._id !== model._id
|
|
||||||
)
|
|
||||||
state.selectedModel = state.models[0] || {}
|
state.selectedModel = state.models[0] || {}
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
@ -113,14 +110,24 @@ export const getBackendUiStore = () => {
|
||||||
store.actions.models.save(state.draftModel)
|
store.actions.models.save(state.draftModel)
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
views: {
|
views: {
|
||||||
select: view =>
|
select: view =>
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
state.selectedView = view
|
state.selectedView = view
|
||||||
|
state.selectedModel = {}
|
||||||
return state
|
return state
|
||||||
}),
|
}),
|
||||||
|
save: async view => {
|
||||||
|
const response = await api.post(`/api/views`, view)
|
||||||
|
const savedView = await response.json()
|
||||||
|
await store.actions.models.fetch()
|
||||||
|
store.update(state => {
|
||||||
|
state.selectedView = view.name
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
create: user =>
|
create: user =>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import fsort from "fast-sort"
|
import fsort from "fast-sort"
|
||||||
|
import getOr from "lodash/fp/getOr"
|
||||||
import { store, backendUiStore } from "builderStore"
|
import { store, backendUiStore } from "builderStore"
|
||||||
import { Button, Icon } from "@budibase/bbui"
|
import { Button, Icon } from "@budibase/bbui"
|
||||||
import Select from "components/common/Select.svelte"
|
import Select from "components/common/Select.svelte"
|
||||||
|
@ -10,6 +11,7 @@
|
||||||
import { DeleteRecordModal, CreateEditRecordModal } from "./modals"
|
import { DeleteRecordModal, CreateEditRecordModal } from "./modals"
|
||||||
import RowPopover from "./popovers/Row.svelte"
|
import RowPopover from "./popovers/Row.svelte"
|
||||||
import ColumnPopover from "./popovers/Column.svelte"
|
import ColumnPopover from "./popovers/Column.svelte"
|
||||||
|
import ViewPopover from "./popovers/View.svelte"
|
||||||
import ColumnHeaderPopover from "./popovers/ColumnHeader.svelte"
|
import ColumnHeaderPopover from "./popovers/ColumnHeader.svelte"
|
||||||
import EditRowPopover from "./popovers/EditRow.svelte"
|
import EditRowPopover from "./popovers/EditRow.svelte"
|
||||||
import * as api from "./api"
|
import * as api from "./api"
|
||||||
|
@ -72,6 +74,7 @@
|
||||||
<ColumnPopover />
|
<ColumnPopover />
|
||||||
{#if Object.keys($backendUiStore.selectedModel.schema).length > 0}
|
{#if Object.keys($backendUiStore.selectedModel.schema).length > 0}
|
||||||
<RowPopover />
|
<RowPopover />
|
||||||
|
<ViewPopover />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -102,7 +105,7 @@
|
||||||
<td>
|
<td>
|
||||||
{#if schema[header].type === 'link'}
|
{#if schema[header].type === 'link'}
|
||||||
<LinkedRecord field={schema[header]} ids={row[header]} />
|
<LinkedRecord field={schema[header]} ids={row[header]} />
|
||||||
{:else}{row[header] || ''}{/if}
|
{:else}{getOr("", header, row)}{/if}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -2,12 +2,10 @@
|
||||||
import { Input, Select } from "@budibase/bbui"
|
import { Input, Select } from "@budibase/bbui"
|
||||||
|
|
||||||
export let type = "text"
|
export let type = "text"
|
||||||
export let value = ""
|
export let value = type === "checkbox" ? false : ""
|
||||||
export let label
|
export let label
|
||||||
export let options = []
|
export let options = []
|
||||||
|
|
||||||
let checked = type === "checkbox" ? value : false
|
|
||||||
|
|
||||||
const handleInput = event => {
|
const handleInput = event => {
|
||||||
if (event.target.type === "checkbox") {
|
if (event.target.type === "checkbox") {
|
||||||
value = event.target.checked
|
value = event.target.checked
|
||||||
|
@ -38,7 +36,7 @@
|
||||||
thin
|
thin
|
||||||
placeholder={label}
|
placeholder={label}
|
||||||
data-cy="{label}-input"
|
data-cy="{label}-input"
|
||||||
{checked}
|
checked={value}
|
||||||
{type}
|
{type}
|
||||||
{value}
|
{value}
|
||||||
on:input={handleInput}
|
on:input={handleInput}
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
import { backendUiStore } from "builderStore"
|
import { backendUiStore } from "builderStore"
|
||||||
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui"
|
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui"
|
||||||
import { FIELDS } from "constants/backend"
|
import { FIELDS } from "constants/backend"
|
||||||
import { ModelSetupNav } from "components/nav/ModelSetupNav"
|
|
||||||
import ModelFieldEditor from "components/nav/ModelSetupNav/ModelFieldEditor.svelte"
|
|
||||||
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
|
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
|
||||||
|
|
||||||
let anchor
|
let anchor
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
import { backendUiStore } from "builderStore"
|
import { backendUiStore } from "builderStore"
|
||||||
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui"
|
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui"
|
||||||
import { FIELDS } from "constants/backend"
|
import { FIELDS } from "constants/backend"
|
||||||
import { ModelSetupNav } from "components/nav/ModelSetupNav"
|
|
||||||
import ModelFieldEditor from "components/nav/ModelSetupNav/ModelFieldEditor.svelte"
|
|
||||||
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
|
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
|
||||||
|
|
||||||
export let field
|
export let field
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
<script>
|
||||||
|
import { Popover, Button, Icon, Input, Select } from "@budibase/bbui"
|
||||||
|
import { backendUiStore } from "builderStore"
|
||||||
|
import CreateEditRecord from "../modals/CreateEditRecord.svelte"
|
||||||
|
|
||||||
|
let anchor
|
||||||
|
let dropdown
|
||||||
|
|
||||||
|
let name
|
||||||
|
|
||||||
|
function saveView() {
|
||||||
|
backendUiStore.actions.views.save({
|
||||||
|
name,
|
||||||
|
modelId: $backendUiStore.selectedModel._id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={anchor}>
|
||||||
|
<Button text small on:click={dropdown.show}>
|
||||||
|
<Icon name="addrow" />
|
||||||
|
Create New View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Popover bind:this={dropdown} {anchor} align="left">
|
||||||
|
<h5>Create View</h5>
|
||||||
|
<div class="input-group-column">
|
||||||
|
<Input placeholder="View Name" thin bind:value={name} />
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<Button secondary on:click={dropdown.hide}>Cancel</Button>
|
||||||
|
<Button primary on:click={saveView}>Save View</Button>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h5 {
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script>
|
||||||
|
import { getContext } from "svelte"
|
||||||
|
import { backendUiStore } from "builderStore"
|
||||||
|
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui"
|
||||||
|
import { FIELDS } from "constants/backend"
|
||||||
|
import DeleteTableModal from "components/database/ModelDataTable/modals/DeleteTable.svelte"
|
||||||
|
|
||||||
|
const { open, close } = getContext("simple-modal")
|
||||||
|
|
||||||
|
export let table
|
||||||
|
|
||||||
|
let anchor
|
||||||
|
let dropdown
|
||||||
|
|
||||||
|
let editing
|
||||||
|
|
||||||
|
function showEditor() {
|
||||||
|
editing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideEditor() {
|
||||||
|
dropdown.hide()
|
||||||
|
editing = false
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTable = () => {
|
||||||
|
open(
|
||||||
|
DeleteTableModal,
|
||||||
|
{
|
||||||
|
onClosed: close,
|
||||||
|
table,
|
||||||
|
},
|
||||||
|
{ styleContent: { padding: "0" } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
backendUiStore.actions.models.save(table)
|
||||||
|
hideEditor()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={anchor} on:click={dropdown.show}>
|
||||||
|
<i class="ri-more-line" />
|
||||||
|
</div>
|
||||||
|
<DropdownMenu bind:this={dropdown} {anchor} align="left">
|
||||||
|
{#if editing}
|
||||||
|
<h5>Edit View</h5>
|
||||||
|
<div class="container">
|
||||||
|
<Input placeholder="Table Name" thin bind:value={table.name} />
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<div class="button-margin-3">
|
||||||
|
<Button secondary on:click={hideEditor}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
<div class="button-margin-4">
|
||||||
|
<Button primary on:click={save}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
<li on:click={showEditor}>
|
||||||
|
<Icon name="edit" />
|
||||||
|
Edit
|
||||||
|
</li>
|
||||||
|
<li data-cy="delete-table" on:click={deleteTable}>
|
||||||
|
<Icon name="delete" />
|
||||||
|
Delete
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h5 {
|
||||||
|
padding: var(--spacing-xl) 0 0 var(--spacing-xl);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding: var(--spacing-xl) 0 0 var(--spacing-xl);
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-s) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: var(--spacing-s) var(--spacing-m);
|
||||||
|
margin: auto 0px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover {
|
||||||
|
background-color: var(--grey-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
li:active {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
background: var(--grey-1);
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-margin-1 {
|
||||||
|
grid-column-start: 1;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-margin-3 {
|
||||||
|
grid-column-start: 3;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-margin-4 {
|
||||||
|
grid-column-start: 4;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -6,15 +6,19 @@
|
||||||
export let indented
|
export let indented
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class:selected on:click class={className}>
|
<div class:indented class:selected on:click class={className}>
|
||||||
<i class:indented class={icon} />
|
<i class={icon} />
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.indented {
|
.indented {
|
||||||
margin-left: 10px;
|
grid-template-columns: 50px 1fr 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indented i {
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
div {
|
||||||
|
|
|
@ -8,20 +8,20 @@
|
||||||
import { Button } from "@budibase/bbui"
|
import { Button } from "@budibase/bbui"
|
||||||
import CreateTablePopover from "./CreateTable.svelte"
|
import CreateTablePopover from "./CreateTable.svelte"
|
||||||
import EditTablePopover from "./EditTable.svelte"
|
import EditTablePopover from "./EditTable.svelte"
|
||||||
|
import EditViewPopover from "./EditView.svelte"
|
||||||
|
|
||||||
const { open, close } = getContext("simple-modal")
|
const { open, close } = getContext("simple-modal")
|
||||||
|
|
||||||
$: selectedTab = $backendUiStore.tabs.NAVIGATION_PANEL
|
$: selectedTab = $backendUiStore.tabs.NAVIGATION_PANEL
|
||||||
|
|
||||||
function selectModel(model, fieldId) {
|
function selectModel(model) {
|
||||||
backendUiStore.actions.models.select(model)
|
backendUiStore.actions.models.select(model)
|
||||||
$goto(`./model/${model._id}`)
|
$goto(`./model/${model._id}`)
|
||||||
if (fieldId) {
|
|
||||||
backendUiStore.update(state => {
|
|
||||||
state.selectedField = fieldId
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectView(view) {
|
||||||
|
backendUiStore.actions.views.select(view)
|
||||||
|
$goto(`./view/${view}`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -34,12 +34,22 @@
|
||||||
<div class="hierarchy-items-container">
|
<div class="hierarchy-items-container">
|
||||||
{#each $backendUiStore.models as model}
|
{#each $backendUiStore.models as model}
|
||||||
<ListItem
|
<ListItem
|
||||||
selected={!$backendUiStore.selectedField && model._id === $backendUiStore.selectedModel._id}
|
selected={$backendUiStore.selectedView === `all_${model._id}`}
|
||||||
title={model.name}
|
title={model.name}
|
||||||
icon="ri-table-fill"
|
icon="ri-table-fill"
|
||||||
on:click={() => selectModel(model)}>
|
on:click={() => selectModel(model)}>
|
||||||
<EditTablePopover table={model} />
|
<EditTablePopover table={model} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
{#each Object.keys(model.views || {}) as view}
|
||||||
|
<ListItem
|
||||||
|
indented
|
||||||
|
selected={$backendUiStore.selectedView === view}
|
||||||
|
title={view}
|
||||||
|
icon="ri-eye-line"
|
||||||
|
on:click={() => selectView(view)}>
|
||||||
|
<EditViewPopover {view} />
|
||||||
|
</ListItem>
|
||||||
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,120 +0,0 @@
|
||||||
<script>
|
|
||||||
import { backendUiStore } from "builderStore"
|
|
||||||
import { Button } from "@budibase/bbui"
|
|
||||||
import Dropdown from "components/common/Dropdown.svelte"
|
|
||||||
import Textbox from "components/common/Textbox.svelte"
|
|
||||||
import ButtonGroup from "components/common/ButtonGroup.svelte"
|
|
||||||
import NumberBox from "components/common/NumberBox.svelte"
|
|
||||||
import ValuesList from "components/common/ValuesList.svelte"
|
|
||||||
import ErrorsBox from "components/common/ErrorsBox.svelte"
|
|
||||||
import Checkbox from "components/common/Checkbox.svelte"
|
|
||||||
import ActionButton from "components/common/ActionButton.svelte"
|
|
||||||
import DatePicker from "components/common/DatePicker.svelte"
|
|
||||||
import { keys, cloneDeep } from "lodash/fp"
|
|
||||||
|
|
||||||
const FIELD_TYPES = ["string", "number", "boolean", "link"]
|
|
||||||
|
|
||||||
let field = {}
|
|
||||||
|
|
||||||
$: field =
|
|
||||||
$backendUiStore.draftModel.schema[$backendUiStore.selectedField] || {}
|
|
||||||
$: required =
|
|
||||||
field.constraints &&
|
|
||||||
field.constraints.presence &&
|
|
||||||
!field.constraints.presence.allowEmpty
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<div class="field-box">
|
|
||||||
<header>Name</header>
|
|
||||||
<input class="budibase__input" type="text" bind:value={field.name} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<div class="field-box">
|
|
||||||
<header>Type</header>
|
|
||||||
<span>{field.type}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<div class="field">
|
|
||||||
<label>Required</label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={required}
|
|
||||||
on:change={() => (field.constraints.presence.allowEmpty = required)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if field.type === 'string'}
|
|
||||||
<NumberBox
|
|
||||||
label="Max Length"
|
|
||||||
bind:value={field.constraints.length.maximum} />
|
|
||||||
<ValuesList label="Categories" bind:values={field.constraints.inclusion} />
|
|
||||||
{:else if field.type === 'datetime'}
|
|
||||||
<DatePicker
|
|
||||||
label="Min Value"
|
|
||||||
bind:value={field.constraints.datetime.earliest} />
|
|
||||||
<DatePicker
|
|
||||||
label="Max Value"
|
|
||||||
bind:value={field.constraints.datetime.latest} />
|
|
||||||
{:else if field.type === 'number'}
|
|
||||||
<NumberBox
|
|
||||||
label="Min Value"
|
|
||||||
bind:value={field.constraints.numericality.greaterThanOrEqualTo} />
|
|
||||||
<NumberBox
|
|
||||||
label="Max Value"
|
|
||||||
bind:value={field.constraints.numericality.lessThanOrEqualTo} />
|
|
||||||
{:else if field.type === 'link'}
|
|
||||||
<div class="field">
|
|
||||||
<label>Link</label>
|
|
||||||
<select class="budibase__input" bind:value={field.modelId}>
|
|
||||||
<option value={''} />
|
|
||||||
{#each $backendUiStore.models as model}
|
|
||||||
{#if model._id !== $backendUiStore.draftModel._id}
|
|
||||||
<option value={model._id}>{model.name}</option>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.info {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-box header {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-box span {
|
|
||||||
background: var(--grey-2);
|
|
||||||
color: var(--grey-6);
|
|
||||||
font-weight: 400;
|
|
||||||
height: 36px;
|
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
padding-left: 12px;
|
|
||||||
text-transform: capitalize;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,156 +0,0 @@
|
||||||
<script>
|
|
||||||
import { getContext, onMount } from "svelte"
|
|
||||||
import { Button, Switcher } from "@budibase/bbui"
|
|
||||||
import { notifier } from "builderStore/store/notifications"
|
|
||||||
import { store, backendUiStore } from "builderStore"
|
|
||||||
import api from "builderStore/api"
|
|
||||||
import ModelFieldEditor from "./ModelFieldEditor.svelte"
|
|
||||||
|
|
||||||
const { open, close } = getContext("simple-modal")
|
|
||||||
|
|
||||||
const ITEMS = [
|
|
||||||
{
|
|
||||||
title: "Setup",
|
|
||||||
key: "SETUP",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Delete",
|
|
||||||
key: "DELETE",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
let edited = false
|
|
||||||
|
|
||||||
$: selectedTab = $backendUiStore.tabs.SETUP_PANEL
|
|
||||||
|
|
||||||
$: edited =
|
|
||||||
$backendUiStore.selectedField ||
|
|
||||||
($backendUiStore.draftModel &&
|
|
||||||
$backendUiStore.draftModel.name !== $backendUiStore.selectedModel.name)
|
|
||||||
|
|
||||||
async function deleteModel() {
|
|
||||||
const model = $backendUiStore.selectedModel
|
|
||||||
const field = $backendUiStore.selectedField
|
|
||||||
|
|
||||||
if (field) {
|
|
||||||
const name = model.schema[field].name
|
|
||||||
delete model.schema[field]
|
|
||||||
backendUiStore.actions.models.save(model)
|
|
||||||
notifier.danger(`Field ${name} deleted.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const DELETE_MODEL_URL = `/api/models/${model._id}/${model._rev}`
|
|
||||||
const response = await api.delete(DELETE_MODEL_URL)
|
|
||||||
backendUiStore.update(state => {
|
|
||||||
state.selectedView = null
|
|
||||||
state.selectedModel = {}
|
|
||||||
state.draftModel = {}
|
|
||||||
state.models = state.models.filter(({ _id }) => _id !== model._id)
|
|
||||||
notifier.danger(`${model.name} deleted successfully.`)
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate() {
|
|
||||||
let errors = []
|
|
||||||
for (let field of Object.values($backendUiStore.draftModel.schema)) {
|
|
||||||
const restrictedFieldNames = ["type", "modelId"]
|
|
||||||
if (field.name.startsWith("_")) {
|
|
||||||
errors.push(`field '${field.name}' - name cannot begin with '_''`)
|
|
||||||
} else if (restrictedFieldNames.includes(field.name)) {
|
|
||||||
errors.push(
|
|
||||||
`field '${field.name}' - is a restricted name, please rename`
|
|
||||||
)
|
|
||||||
} else if (!field.name || !field.name.trim()) {
|
|
||||||
errors.push("field name cannot be blank")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$backendUiStore.draftModel.name) {
|
|
||||||
errors.push("Table name cannot be blank")
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveModel() {
|
|
||||||
const errors = validate()
|
|
||||||
if (errors.length > 0) {
|
|
||||||
notifier.danger(errors.join("/n"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await backendUiStore.actions.models.save($backendUiStore.draftModel)
|
|
||||||
notifier.success(
|
|
||||||
"Success! Your changes have been saved. Please continue on with your greatness."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="items-root">
|
|
||||||
<Switcher headings={ITEMS} bind:value={$backendUiStore.tabs.SETUP_PANEL}>
|
|
||||||
{#if selectedTab === 'SETUP'}
|
|
||||||
{#if $backendUiStore.selectedField}
|
|
||||||
<ModelFieldEditor />
|
|
||||||
{:else if $backendUiStore.draftModel.schema}
|
|
||||||
<div class="titled-input">
|
|
||||||
<header>Name</header>
|
|
||||||
<input
|
|
||||||
data-cy="table-name-input"
|
|
||||||
type="text"
|
|
||||||
class="budibase__input"
|
|
||||||
bind:value={$backendUiStore.draftModel.name} />
|
|
||||||
</div>
|
|
||||||
<!-- dont have this capability yet..
|
|
||||||
<div class="titled-input">
|
|
||||||
<header>Import Data</header>
|
|
||||||
<Button wide secondary>Import CSV</Button>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
{/if}
|
|
||||||
<footer>
|
|
||||||
<Button disabled={!edited} green={edited} wide on:click={saveModel}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
{:else if selectedTab === 'DELETE'}
|
|
||||||
<div class="titled-input">
|
|
||||||
<header>Danger Zone</header>
|
|
||||||
<Button red wide on:click={deleteModel}>Delete</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Switcher>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
header {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
width: 260px;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-root {
|
|
||||||
padding: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.titled-input {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titled-input header {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1 +0,0 @@
|
||||||
export { default as ModelSetupNav } from "./ModelSetupNav.svelte"
|
|
|
@ -1,38 +0,0 @@
|
||||||
<script>
|
|
||||||
import { getContext } from "svelte"
|
|
||||||
import { store, backendUiStore } from "builderStore"
|
|
||||||
import * as api from "components/database/ModelDataTable/api"
|
|
||||||
|
|
||||||
import ModelNavigator from "components/nav/ModelNavigator/ModelNavigator.svelte"
|
|
||||||
import { ModelSetupNav } from "components/nav/ModelSetupNav"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="nav">
|
|
||||||
<ModelNavigator />
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root {
|
|
||||||
height: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 300px minmax(0, 1fr);
|
|
||||||
background: var(--grey-1);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
margin: 20px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
flex: 0 1 auto;
|
|
||||||
width: 300px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,27 +0,0 @@
|
||||||
const FORMULA_MAP = {
|
|
||||||
SUM: "_sum",
|
|
||||||
COUNT: "_count",
|
|
||||||
STATS: "_stats"
|
|
||||||
};
|
|
||||||
|
|
||||||
function customViewTemplate({
|
|
||||||
field,
|
|
||||||
formula,
|
|
||||||
modelId
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
meta: {
|
|
||||||
field,
|
|
||||||
formula,
|
|
||||||
modelId
|
|
||||||
},
|
|
||||||
map: `function (doc) {
|
|
||||||
if (doc.modelId === "${modelId}") {
|
|
||||||
emit(doc._id, doc["${field}"]);
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
reduce: "_stats"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = customViewTemplate
|
|
|
@ -1,23 +1,25 @@
|
||||||
const CouchDB = require("../../../db")
|
const CouchDB = require("../../../db")
|
||||||
const customViewTemplate = require("./customViews");
|
const statsViewTemplate = require("./viewBuilder");
|
||||||
|
|
||||||
const controller = {
|
const controller = {
|
||||||
query: async ctx => {
|
query: async ctx => {
|
||||||
// const db = new CouchDB(ctx.user.instanceId)
|
const db = new CouchDB(ctx.user.instanceId)
|
||||||
const db = new CouchDB("inst_4e6f424_970ca7f2b9e24ec8896eb10862d7f22b")
|
const { meta } = ctx.request.body
|
||||||
const response = await db.query(`database/${ctx.params.viewName}`, {
|
const response = await db.query(`database/${ctx.params.viewName}`, {
|
||||||
group: false
|
group: !!meta.groupBy
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (row of response.rows) {
|
||||||
|
row.value = {
|
||||||
|
...row.value,
|
||||||
|
avg: row.value.sum / row.value.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = response.rows
|
ctx.body = response.rows
|
||||||
// ctx.body = {
|
|
||||||
// ...data,
|
|
||||||
// avg: data.sum / data.count
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
fetch: async ctx => {
|
fetch: async ctx => {
|
||||||
// const db = new CouchDB(ctx.user.instanceId)
|
const db = new CouchDB(ctx.user.instanceId)
|
||||||
const db = new CouchDB("inst_4e6f424_970ca7f2b9e24ec8896eb10862d7f22b")
|
|
||||||
const designDoc = await db.get("_design/database")
|
const designDoc = await db.get("_design/database")
|
||||||
const response = []
|
const response = []
|
||||||
|
|
||||||
|
@ -37,14 +39,13 @@ const controller = {
|
||||||
|
|
||||||
ctx.body = response
|
ctx.body = response
|
||||||
},
|
},
|
||||||
create: async ctx => {
|
save: async ctx => {
|
||||||
// const db = new CouchDB(ctx.user.instanceId)
|
const db = new CouchDB(ctx.user.instanceId)
|
||||||
const db = new CouchDB("inst_4e6f424_970ca7f2b9e24ec8896eb10862d7f22b")
|
|
||||||
const newView = ctx.request.body
|
const newView = ctx.request.body
|
||||||
|
|
||||||
const designDoc = await db.get("_design/database")
|
const designDoc = await db.get("_design/database")
|
||||||
|
|
||||||
const view = customViewTemplate(ctx.request.body)
|
const view = statsViewTemplate(newView)
|
||||||
|
|
||||||
designDoc.views = {
|
designDoc.views = {
|
||||||
...designDoc.views,
|
...designDoc.views,
|
||||||
|
@ -53,8 +54,17 @@ const controller = {
|
||||||
|
|
||||||
await db.put(designDoc)
|
await db.put(designDoc)
|
||||||
|
|
||||||
ctx.body = newView
|
|
||||||
ctx.message = `View ${newView.name} created successfully.`
|
// add views to model document
|
||||||
|
const model = await db.get(ctx.request.body.modelId)
|
||||||
|
model.views = {
|
||||||
|
...(model.views ? model.views : {}),
|
||||||
|
[newView.name]: view.meta
|
||||||
|
}
|
||||||
|
await db.put(model)
|
||||||
|
|
||||||
|
ctx.body = view
|
||||||
|
ctx.message = `View ${newView.name} saved successfully.`
|
||||||
},
|
},
|
||||||
destroy: async ctx => {
|
destroy: async ctx => {
|
||||||
const db = new CouchDB(ctx.user.instanceId)
|
const db = new CouchDB(ctx.user.instanceId)
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
function statsViewTemplate({
|
||||||
|
field,
|
||||||
|
modelId,
|
||||||
|
groupBy
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
field,
|
||||||
|
modelId,
|
||||||
|
groupBy
|
||||||
|
},
|
||||||
|
map: `function (doc) {
|
||||||
|
if (doc.modelId === "${modelId}") {
|
||||||
|
emit(doc["${groupBy || "_id"}"], doc["${field}"]);
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
reduce: "_stats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = statsViewTemplate
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`/views query returns data for the created view 1`] = `
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"key": null,
|
||||||
|
"value": Object {
|
||||||
|
"avg": 2333.3333333333335,
|
||||||
|
"count": 3,
|
||||||
|
"max": 4000,
|
||||||
|
"min": 1000,
|
||||||
|
"sum": 7000,
|
||||||
|
"sumsqr": 21000000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`/views query returns data for the created view using a group by 1`] = `
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"key": "One",
|
||||||
|
"value": Object {
|
||||||
|
"avg": 1500,
|
||||||
|
"count": 2,
|
||||||
|
"max": 2000,
|
||||||
|
"min": 1000,
|
||||||
|
"sum": 3000,
|
||||||
|
"sumsqr": 5000000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"key": "Two",
|
||||||
|
"value": Object {
|
||||||
|
"avg": 4000,
|
||||||
|
"count": 1,
|
||||||
|
"max": 4000,
|
||||||
|
"min": 4000,
|
||||||
|
"sum": 4000,
|
||||||
|
"sumsqr": 16000000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
|
@ -193,9 +193,6 @@ const createUserWithPermissions = async (
|
||||||
accessLevelId: accessRes.body._id,
|
accessLevelId: accessRes.body._id,
|
||||||
})
|
})
|
||||||
|
|
||||||
//const db = new CouchDB(instanceId)
|
|
||||||
//const designDoc = await db.get("_design/database")
|
|
||||||
|
|
||||||
const anonUser = {
|
const anonUser = {
|
||||||
userId: "ANON",
|
userId: "ANON",
|
||||||
accessLevelId: ANON_LEVEL_ID,
|
accessLevelId: ANON_LEVEL_ID,
|
||||||
|
|
|
@ -4,7 +4,8 @@ const {
|
||||||
createInstance,
|
createInstance,
|
||||||
createModel,
|
createModel,
|
||||||
supertest,
|
supertest,
|
||||||
defaultHeaders
|
defaultHeaders,
|
||||||
|
getDocument
|
||||||
} = require("./couchTestUtils")
|
} = require("./couchTestUtils")
|
||||||
|
|
||||||
describe("/views", () => {
|
describe("/views", () => {
|
||||||
|
@ -14,6 +15,25 @@ describe("/views", () => {
|
||||||
let instance
|
let instance
|
||||||
let model
|
let model
|
||||||
|
|
||||||
|
const createView = async (config = {
|
||||||
|
name: "TestView",
|
||||||
|
field: "Price",
|
||||||
|
modelId: model._id
|
||||||
|
}) =>
|
||||||
|
await request
|
||||||
|
.post(`/api/views`)
|
||||||
|
.send(config)
|
||||||
|
.set(defaultHeaders(app._id, instance._id))
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
const createRecord = async record => request
|
||||||
|
.post(`/api/${model._id}/records`)
|
||||||
|
.send(record)
|
||||||
|
.set(defaultHeaders(app._id, instance._id))
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ request, server } = await supertest())
|
({ request, server } = await supertest())
|
||||||
await createClientDatabase(request)
|
await createClientDatabase(request)
|
||||||
|
@ -28,46 +48,111 @@ describe("/views", () => {
|
||||||
server.close()
|
server.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
const createView = async () =>
|
|
||||||
await request
|
|
||||||
.post(`/api/views`)
|
|
||||||
.send({
|
|
||||||
name: "TestView",
|
|
||||||
map: `function(doc) {
|
|
||||||
if (doc.id) {
|
|
||||||
emit(doc.name, doc._id);
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
reduce: `function(keys, values) { }`
|
|
||||||
})
|
|
||||||
.set(defaultHeaders(app._id, instance._id))
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect(200)
|
|
||||||
|
|
||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
model = await createModel(request, app._id, instance._id);
|
||||||
|
})
|
||||||
|
|
||||||
it("returns a success message when the view is successfully created", async () => {
|
it("returns a success message when the view is successfully created", async () => {
|
||||||
const res = await createView()
|
const res = await createView()
|
||||||
expect(res.res.statusMessage).toEqual("View TestView created successfully.");
|
expect(res.res.statusMessage).toEqual("View TestView saved successfully.");
|
||||||
expect(res.body.name).toEqual("TestView");
|
})
|
||||||
|
|
||||||
|
it("updates the model record with the new view metadata", async () => {
|
||||||
|
const res = await createView()
|
||||||
|
expect(res.res.statusMessage).toEqual("View TestView saved successfully.");
|
||||||
|
const updatedModel = await getDocument(instance._id, model._id)
|
||||||
|
expect(updatedModel.views).toEqual({
|
||||||
|
TestView: {
|
||||||
|
field: "Price",
|
||||||
|
modelId: model._id
|
||||||
|
}
|
||||||
|
});
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetch", () => {
|
describe("fetch", () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
model = await createModel(request, app._id, instance._id);
|
model = await createModel(request, app._id, instance._id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should only return custom views", async () => {
|
it("returns only custom views", async () => {
|
||||||
const view = await createView()
|
await createView()
|
||||||
const res = await request
|
const res = await request
|
||||||
.get(`/api/views`)
|
.get(`/api/views`)
|
||||||
.set(defaultHeaders(app._id, instance._id))
|
.set(defaultHeaders(app._id, instance._id))
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
expect(res.body.length).toBe(1)
|
expect(res.body.length).toBe(1)
|
||||||
expect(res.body.find(v => v.name === view.body.name)).toBeDefined()
|
expect(res.body.find(({ name }) => name === "TestView")).toBeDefined()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("query", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
model = await createModel(request, app._id, instance._id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns data for the created view", async () => {
|
||||||
|
await createView()
|
||||||
|
await createRecord({
|
||||||
|
modelId: model._id,
|
||||||
|
Price: 1000
|
||||||
|
})
|
||||||
|
await createRecord({
|
||||||
|
modelId: model._id,
|
||||||
|
Price: 2000
|
||||||
|
})
|
||||||
|
await createRecord({
|
||||||
|
modelId: model._id,
|
||||||
|
Price: 4000
|
||||||
|
})
|
||||||
|
const res = await request
|
||||||
|
.post(`/api/views/query/TestView`)
|
||||||
|
.send({
|
||||||
|
meta: {}
|
||||||
|
})
|
||||||
|
.set(defaultHeaders(app._id, instance._id))
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
expect(res.body.length).toBe(1)
|
||||||
|
expect(res.body).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns data for the created view using a group by", async () => {
|
||||||
|
await createView({
|
||||||
|
name: "TestView",
|
||||||
|
field: "Price",
|
||||||
|
groupBy: "Category",
|
||||||
|
modelId: model._id
|
||||||
|
})
|
||||||
|
await createRecord({
|
||||||
|
modelId: model._id,
|
||||||
|
Price: 1000,
|
||||||
|
Category: "One"
|
||||||
|
})
|
||||||
|
await createRecord({
|
||||||
|
modelId: model._id,
|
||||||
|
Price: 2000,
|
||||||
|
Category: "One"
|
||||||
|
})
|
||||||
|
await createRecord({
|
||||||
|
modelId: model._id,
|
||||||
|
Price: 4000,
|
||||||
|
Category: "Two"
|
||||||
|
})
|
||||||
|
const res = await request
|
||||||
|
.post(`/api/views/query/TestView`)
|
||||||
|
.send({
|
||||||
|
meta: {
|
||||||
|
groupBy: "Category"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.set(defaultHeaders(app._id, instance._id))
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
expect(res.body.length).toBe(2)
|
||||||
|
expect(res.body).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
|
@ -13,9 +13,7 @@ router
|
||||||
recordController.fetchView
|
recordController.fetchView
|
||||||
)
|
)
|
||||||
.get("/api/views", viewController.fetch)
|
.get("/api/views", viewController.fetch)
|
||||||
.get("/api/views/query/:viewName", viewController.query)
|
.post("/api/views/query/:viewName", viewController.query)
|
||||||
// .patch("/api/:databaseId/views", controller.update);
|
.post("/api/views", viewController.save)
|
||||||
// .delete("/api/:instanceId/views/:viewId/:revId", controller.destroy);
|
|
||||||
.post("/api/views", viewController.create)
|
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
@ -14,6 +14,10 @@ module.exports = async (ctx, next) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ctx.user = {
|
||||||
|
// instanceId: "inst_4e6f424_970ca7f2b9e24ec8896eb10862d7f22b"
|
||||||
|
// }
|
||||||
|
|
||||||
const appToken = ctx.cookies.get("budibase:token")
|
const appToken = ctx.cookies.get("budibase:token")
|
||||||
const builderToken = ctx.cookies.get("builder:token")
|
const builderToken = ctx.cookies.get("builder:token")
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue