mirror of https://github.com/Budibase/budibase.git
119 changed files with 2778 additions and 1524 deletions
@ -0,0 +1,355 @@ |
|||
import { writable } from "svelte/store" |
|||
import api from "../api" |
|||
import { cloneDeep, sortBy, find, remove } from "lodash/fp" |
|||
import { hierarchy as hierarchyFunctions } from "../../../../core/src" |
|||
import { |
|||
getNode, |
|||
validate, |
|||
constructHierarchy, |
|||
templateApi, |
|||
isIndex, |
|||
canDeleteIndex, |
|||
canDeleteRecord, |
|||
} from "../../common/core" |
|||
|
|||
export const getBackendUiStore = () => { |
|||
const INITIAL_BACKEND_UI_STATE = { |
|||
leftNavItem: "DATABASE", |
|||
selectedView: { |
|||
records: [], |
|||
name: "", |
|||
}, |
|||
breadcrumbs: [], |
|||
selectedDatabase: {}, |
|||
selectedModel: {}, |
|||
} |
|||
|
|||
const store = writable(INITIAL_BACKEND_UI_STATE) |
|||
|
|||
store.actions = { |
|||
navigate: name => store.update(state => ({ ...state, leftNavItem: name })), |
|||
database: { |
|||
select: db => |
|||
store.update(state => { |
|||
state.selectedDatabase = db |
|||
state.breadcrumbs = [db.name] |
|||
return state |
|||
}), |
|||
}, |
|||
records: { |
|||
delete: () => |
|||
store.update(state => { |
|||
state.selectedView = state.selectedView |
|||
return state |
|||
}), |
|||
view: record => |
|||
store.update(state => { |
|||
state.breadcrumbs = [state.selectedDatabase.name, record.id] |
|||
return state |
|||
}), |
|||
select: record => |
|||
store.update(state => { |
|||
state.selectedRecord = record |
|||
return state |
|||
}), |
|||
}, |
|||
views: { |
|||
select: view => |
|||
store.update(state => { |
|||
state.selectedView = view |
|||
return state |
|||
}), |
|||
}, |
|||
modals: { |
|||
show: modal => store.update(state => ({ ...state, visibleModal: modal })), |
|||
hide: () => store.update(state => ({ ...state, visibleModal: null })), |
|||
}, |
|||
users: { |
|||
create: user => |
|||
store.update(state => { |
|||
state.users.push(user) |
|||
state.users = state.users |
|||
return state |
|||
}), |
|||
}, |
|||
} |
|||
|
|||
return store |
|||
} |
|||
|
|||
// Store Actions
|
|||
export const createShadowHierarchy = hierarchy => |
|||
constructHierarchy(JSON.parse(JSON.stringify(hierarchy))) |
|||
|
|||
export const createDatabaseForApp = store => appInstance => { |
|||
store.update(state => { |
|||
state.appInstances.push(appInstance) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const saveBackend = async state => { |
|||
await api.post(`/_builder/api/${state.appname}/backend`, { |
|||
appDefinition: { |
|||
hierarchy: state.hierarchy, |
|||
actions: state.actions, |
|||
triggers: state.triggers, |
|||
}, |
|||
accessLevels: state.accessLevels, |
|||
}) |
|||
|
|||
const instances_currentFirst = state.selectedDatabase |
|||
? [ |
|||
state.appInstances.find(i => i.id === state.selectedDatabase.id), |
|||
...state.appInstances.filter(i => i.id !== state.selectedDatabase.id), |
|||
] |
|||
: state.appInstances |
|||
|
|||
for (let instance of instances_currentFirst) { |
|||
await api.post( |
|||
`/_builder/instance/${state.appname}/${instance.id}/api/upgradeData`, |
|||
{ newHierarchy: state.hierarchy, accessLevels: state.accessLevels } |
|||
) |
|||
} |
|||
} |
|||
|
|||
export const newRecord = (store, useRoot) => () => { |
|||
store.update(state => { |
|||
state.currentNodeIsNew = true |
|||
const shadowHierarchy = createShadowHierarchy(state.hierarchy) |
|||
const parent = useRoot |
|||
? shadowHierarchy |
|||
: getNode(shadowHierarchy, state.currentNode.nodeId) |
|||
state.errors = [] |
|||
state.currentNode = templateApi(shadowHierarchy).getNewRecordTemplate( |
|||
parent, |
|||
"", |
|||
true |
|||
) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const selectExistingNode = store => nodeId => { |
|||
store.update(state => { |
|||
state.currentNode = getNode(state.hierarchy, nodeId) |
|||
state.currentNodeIsNew = false |
|||
state.errors = [] |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const newIndex = (store, useRoot) => () => { |
|||
store.update(state => { |
|||
state.shadowHierarchy = createShadowHierarchy(state.hierarchy) |
|||
state.currentNodeIsNew = true |
|||
state.errors = [] |
|||
const parent = useRoot |
|||
? state.shadowHierarchy |
|||
: getNode(state.shadowHierarchy, state.currentNode.nodeId) |
|||
|
|||
state.currentNode = templateApi(state.shadowHierarchy).getNewIndexTemplate( |
|||
parent |
|||
) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const saveCurrentNode = store => () => { |
|||
store.update(state => { |
|||
const errors = validate.node(state.currentNode) |
|||
state.errors = errors |
|||
if (errors.length > 0) { |
|||
return state |
|||
} |
|||
const parentNode = getNode( |
|||
state.hierarchy, |
|||
state.currentNode.parent().nodeId |
|||
) |
|||
|
|||
const existingNode = getNode(state.hierarchy, state.currentNode.nodeId) |
|||
|
|||
let index = parentNode.children.length |
|||
if (existingNode) { |
|||
// remove existing
|
|||
index = existingNode.parent().children.indexOf(existingNode) |
|||
if (isIndex(existingNode)) { |
|||
parentNode.indexes = parentNode.indexes.filter( |
|||
node => node.nodeId !== existingNode.nodeId |
|||
) |
|||
} else { |
|||
parentNode.children = parentNode.children.filter( |
|||
node => node.nodeId !== existingNode.nodeId |
|||
) |
|||
} |
|||
} |
|||
|
|||
// should add node into existing hierarchy
|
|||
const cloned = cloneDeep(state.currentNode) |
|||
templateApi(state.hierarchy).constructNode(parentNode, cloned) |
|||
|
|||
if (isIndex(existingNode)) { |
|||
parentNode.children = sortBy("name", parentNode.children) |
|||
} else { |
|||
parentNode.indexes = sortBy("name", parentNode.indexes) |
|||
} |
|||
|
|||
if (!existingNode && state.currentNode.type === "record") { |
|||
const defaultIndex = templateApi(state.hierarchy).getNewIndexTemplate( |
|||
cloned.parent() |
|||
) |
|||
defaultIndex.name = `all_${cloned.name}s` |
|||
defaultIndex.allowedRecordNodeIds = [cloned.nodeId] |
|||
} |
|||
|
|||
state.currentNodeIsNew = false |
|||
|
|||
saveBackend(state) |
|||
|
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const deleteCurrentNode = store => () => { |
|||
store.update(state => { |
|||
const nodeToDelete = getNode(state.hierarchy, state.currentNode.nodeId) |
|||
state.currentNode = hierarchyFunctions.isRoot(nodeToDelete.parent()) |
|||
? state.hierarchy.children.find(node => node !== state.currentNode) |
|||
: nodeToDelete.parent() |
|||
|
|||
const isRecord = hierarchyFunctions.isRecord(nodeToDelete) |
|||
|
|||
const check = isRecord |
|||
? canDeleteRecord(nodeToDelete) |
|||
: canDeleteIndex(nodeToDelete) |
|||
|
|||
if (!check.canDelete) { |
|||
state.errors = check.errors.map(e => ({ error: e })) |
|||
return state |
|||
} |
|||
|
|||
const recordOrIndexKey = isRecord ? "children" : "indexes" |
|||
|
|||
// remove the selected record or index
|
|||
const newCollection = remove( |
|||
node => node.nodeId === nodeToDelete.nodeId, |
|||
nodeToDelete.parent()[recordOrIndexKey] |
|||
) |
|||
|
|||
nodeToDelete.parent()[recordOrIndexKey] = newCollection |
|||
|
|||
state.errors = [] |
|||
saveBackend(state) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const saveField = store => field => { |
|||
store.update(state => { |
|||
state.currentNode.fields = state.currentNode.fields.filter( |
|||
f => f.id !== field.id |
|||
) |
|||
|
|||
templateApi(state.hierarchy).addField(state.currentNode, field) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const deleteField = store => field => { |
|||
store.update(state => { |
|||
state.currentNode.fields = state.currentNode.fields.filter( |
|||
f => f.name !== field.name |
|||
) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
const incrementAccessLevelsVersion = state => { |
|||
state.accessLevels.version = state.accessLevels.version |
|||
? state.accessLevels.version + 1 |
|||
: 1 |
|||
return state |
|||
} |
|||
|
|||
export const saveLevel = store => (newLevel, isNew, oldLevel = null) => { |
|||
store.update(state => { |
|||
const levels = state.accessLevels.levels |
|||
|
|||
const existingLevel = isNew |
|||
? null |
|||
: find(a => a.name === oldLevel.name)(levels) |
|||
|
|||
if (existingLevel) { |
|||
state.accessLevels.levels = levels.map(level => |
|||
level === existingLevel ? newLevel : level |
|||
) |
|||
} else { |
|||
state.accessLevels.levels.push(newLevel) |
|||
} |
|||
|
|||
incrementAccessLevelsVersion(state) |
|||
|
|||
saveBackend(state) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const deleteLevel = store => level => { |
|||
store.update(state => { |
|||
state.accessLevels.levels = state.accessLevels.levels.filter( |
|||
t => t.name !== level.name |
|||
) |
|||
incrementAccessLevelsVersion(s) |
|||
saveBackend(state) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const saveAction = store => (newAction, isNew, oldAction = null) => { |
|||
store.update(s => { |
|||
const existingAction = isNew |
|||
? null |
|||
: find(a => a.name === oldAction.name)(s.actions) |
|||
|
|||
if (existingAction) { |
|||
s.actions = s.actions.map(action => |
|||
action === existingAction ? newAction : action |
|||
) |
|||
} else { |
|||
s.actions.push(newAction) |
|||
} |
|||
saveBackend(s) |
|||
return s |
|||
}) |
|||
} |
|||
|
|||
export const deleteAction = store => action => { |
|||
store.update(state => { |
|||
state.actions = state.actions.filter(a => a.name !== action.name) |
|||
saveBackend(state) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
export const saveTrigger = store => (newTrigger, isNew, oldTrigger = null) => { |
|||
store.update(s => { |
|||
const existingTrigger = isNew |
|||
? null |
|||
: s.triggers.find(a => a.name === oldTrigger.name) |
|||
|
|||
if (existingTrigger) { |
|||
s.triggers = s.triggers.map(a => (a === existingTrigger ? newTrigger : a)) |
|||
} else { |
|||
s.triggers.push(newTrigger) |
|||
} |
|||
saveBackend(s) |
|||
return s |
|||
}) |
|||
} |
|||
|
|||
export const deleteTrigger = store => trigger => { |
|||
store.update(s => { |
|||
s.triggers = s.triggers.filter(t => t.name !== trigger.name) |
|||
return s |
|||
}) |
|||
} |
|||
|
Before Width: | Height: | Size: 349 B After Width: | Height: | Size: 362 B |
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 263 B |
@ -1,70 +0,0 @@ |
|||
<script> |
|||
import Button from "../common/Button.svelte" |
|||
import ActionButton from "../common/ActionButton.svelte" |
|||
import ButtonGroup from "../common/ButtonGroup.svelte" |
|||
import { store } from "../builderStore" |
|||
import Modal from "../common/Modal.svelte" |
|||
import ErrorsBox from "../common/ErrorsBox.svelte" |
|||
|
|||
export let left |
|||
let confirmDelete = false |
|||
const openConfirmDelete = () => { |
|||
confirmDelete = true |
|||
} |
|||
|
|||
const deleteCurrentNode = () => { |
|||
confirmDelete = false |
|||
store.deleteCurrentNode() |
|||
} |
|||
</script> |
|||
|
|||
<div class="root" style="left: {left}"> |
|||
<ButtonGroup> |
|||
<ActionButton |
|||
color="secondary" |
|||
grouped |
|||
on:click={store.saveCurrentNode}> |
|||
{#if $store.currentNodeIsNew}Create{:else}Update{/if} |
|||
</ActionButton> |
|||
|
|||
{#if !$store.currentNodeIsNew} |
|||
<ActionButton alert grouped on:click={openConfirmDelete}> |
|||
Delete |
|||
</ActionButton> |
|||
{/if} |
|||
</ButtonGroup> |
|||
|
|||
{#if !!$store.errors && $store.errors.length > 0} |
|||
<div style="width: 500px"> |
|||
<ErrorsBox errors={$store.errors} /> |
|||
</div> |
|||
{/if} |
|||
|
|||
<Modal onClosed={() => (confirmDelete = false)} bind:isOpen={confirmDelete}> |
|||
<span>Are you sure you want to delete {$store.currentNode.name}?</span> |
|||
<div class="uk-modal-footer uk-text-right"> |
|||
<ButtonGroup> |
|||
<ActionButton alert on:click={deleteCurrentNode}>Yes</ActionButton> |
|||
<ActionButton primary on:click={() => (confirmDelete = false)}> |
|||
No |
|||
</ActionButton> |
|||
</ButtonGroup> |
|||
</div> |
|||
</Modal> |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
padding: 1.5rem; |
|||
width: 100%; |
|||
align-items: right; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.actions-modal-body { |
|||
height: 100%; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
</style> |
|||
@ -1,42 +0,0 @@ |
|||
<script> |
|||
import { store } from "../builderStore" |
|||
import { cloneDeep } from "lodash/fp" |
|||
export let level = 0 |
|||
export let node |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<div |
|||
class="title" |
|||
on:click={() => store.selectExistingNode(node.nodeId)} |
|||
style="padding-left: {20 + level * 20}px"> |
|||
{node.name} |
|||
</div> |
|||
{#if node.children} |
|||
{#each node.children as child} |
|||
<svelte:self node={child} level={level + 1} /> |
|||
{/each} |
|||
{/if} |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
display: block; |
|||
font-size: 0.9rem; |
|||
width: 100%; |
|||
cursor: pointer; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.title { |
|||
font: var(--fontblack); |
|||
padding-top: 10px; |
|||
padding-right: 5px; |
|||
padding-bottom: 10px; |
|||
color: var(--secondary100); |
|||
} |
|||
|
|||
.title:hover { |
|||
background-color: var(--secondary10); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,203 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { store, backendUiStore } from "../../builderStore" |
|||
import { |
|||
tap, |
|||
get, |
|||
find, |
|||
last, |
|||
compose, |
|||
flatten, |
|||
map, |
|||
remove, |
|||
keys, |
|||
} from "lodash/fp" |
|||
import Select from "../../common/Select.svelte" |
|||
import { getIndexSchema } from "../../common/core" |
|||
import ActionButton from "../../common/ActionButton.svelte" |
|||
import TablePagination from "./TablePagination.svelte" |
|||
import { DeleteRecordModal } from "./modals" |
|||
import * as api from "./api" |
|||
|
|||
export let selectRecord |
|||
|
|||
const ITEMS_PER_PAGE = 10 |
|||
// Internal headers we want to hide from the user |
|||
const INTERNAL_HEADERS = ["key", "sortKey", "type", "id", "isNew"] |
|||
|
|||
let modalOpen = false |
|||
let data = [] |
|||
let headers = [] |
|||
let views = [] |
|||
let currentPage = 0 |
|||
|
|||
$: views = $backendUiStore.selectedRecord |
|||
? childViewsForRecord($store.hierarchy) |
|||
: $store.hierarchy.indexes |
|||
|
|||
$: currentAppInfo = { |
|||
appname: $store.appname, |
|||
instanceId: $backendUiStore.selectedDatabase.id, |
|||
} |
|||
|
|||
$: fetchRecordsForView( |
|||
$backendUiStore.selectedView, |
|||
$backendUiStore.selectedDatabase |
|||
).then(records => { |
|||
data = records || [] |
|||
headers = hideInternalHeaders($backendUiStore.selectedView) |
|||
}) |
|||
|
|||
$: paginatedData = data.slice( |
|||
currentPage * ITEMS_PER_PAGE, |
|||
currentPage * ITEMS_PER_PAGE + ITEMS_PER_PAGE |
|||
) |
|||
|
|||
const getSchema = getIndexSchema($store.hierarchy) |
|||
|
|||
const childViewsForRecord = compose(flatten, map("indexes"), get("children")) |
|||
|
|||
const hideInternalHeaders = compose( |
|||
remove(headerName => INTERNAL_HEADERS.includes(headerName)), |
|||
map(get("name")), |
|||
getSchema |
|||
) |
|||
|
|||
async function fetchRecordsForView(view, instance) { |
|||
if (!view || !view.name) return |
|||
|
|||
const viewName = $backendUiStore.selectedRecord |
|||
? `${$backendUiStore.selectedRecord.key}/${view.name}` |
|||
: view.name |
|||
|
|||
return await api.fetchDataForView(viewName, { |
|||
appname: $store.appname, |
|||
instanceId: instance.id, |
|||
}) |
|||
} |
|||
|
|||
function drillIntoRecord(record) { |
|||
backendUiStore.update(state => { |
|||
state.selectedRecord = record |
|||
state.breadcrumbs = [state.selectedDatabase.name, record.id] |
|||
state.selectedView = childViewsForRecord($store.hierarchy)[0] |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
onMount(() => { |
|||
if (views.length) { |
|||
backendUiStore.actions.views.select(views[0]) |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<section> |
|||
<div class="table-controls"> |
|||
<h4 class="budibase__title--3">{last($backendUiStore.breadcrumbs)}</h4> |
|||
<Select icon="ri-eye-line" bind:value={$backendUiStore.selectedView}> |
|||
{#each views as view} |
|||
<option value={view}>{view.name}</option> |
|||
{/each} |
|||
</Select> |
|||
</div> |
|||
<table class="uk-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>Edit</th> |
|||
{#each headers as header} |
|||
<th>{header}</th> |
|||
{/each} |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{#if paginatedData.length === 0} |
|||
<div class="no-data">No Data.</div> |
|||
{/if} |
|||
{#each paginatedData as row} |
|||
<tr class="hoverable"> |
|||
<td> |
|||
<div class="uk-inline"> |
|||
<i class="ri-more-line" /> |
|||
<div uk-dropdown="mode: click"> |
|||
<ul class="uk-nav uk-dropdown-nav"> |
|||
<li> |
|||
<div on:click={() => drillIntoRecord(row)}>View</div> |
|||
</li> |
|||
<li |
|||
on:click={() => { |
|||
selectRecord(row) |
|||
backendUiStore.actions.modals.show('RECORD') |
|||
}}> |
|||
<div>Edit</div> |
|||
</li> |
|||
<li> |
|||
<div |
|||
on:click={() => { |
|||
selectRecord(row) |
|||
backendUiStore.actions.modals.show('DELETE_RECORD') |
|||
}}> |
|||
Delete |
|||
</div> |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
</td> |
|||
{#each headers as header} |
|||
<td>{row[header]}</td> |
|||
{/each} |
|||
</tr> |
|||
{/each} |
|||
</tbody> |
|||
</table> |
|||
<TablePagination |
|||
{data} |
|||
bind:currentPage |
|||
pageItemCount={data.length} |
|||
{ITEMS_PER_PAGE} /> |
|||
</section> |
|||
|
|||
<style> |
|||
table { |
|||
border: 1px solid #ccc; |
|||
background: #fff; |
|||
border-radius: 3px; |
|||
border-collapse: collapse; |
|||
} |
|||
|
|||
thead { |
|||
background: var(--background-button); |
|||
} |
|||
|
|||
thead th { |
|||
color: var(--button-text); |
|||
text-transform: capitalize; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
tbody tr { |
|||
border-bottom: 1px solid #ccc; |
|||
transition: 0.3s background-color; |
|||
color: var(--darkslate); |
|||
} |
|||
|
|||
tbody tr:hover { |
|||
background: #fafafa; |
|||
} |
|||
|
|||
.table-controls { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
|
|||
.ri-more-line:hover, |
|||
.uk-dropdown-nav li:hover { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.no-data { |
|||
padding: 20px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,77 @@ |
|||
<script> |
|||
import { backendUiStore } from "../../builderStore" |
|||
|
|||
export let data |
|||
export let currentPage |
|||
export let pageItemCount |
|||
export let ITEMS_PER_PAGE |
|||
|
|||
let numPages = 0 |
|||
|
|||
$: numPages = Math.ceil(data.length / ITEMS_PER_PAGE) |
|||
|
|||
const next = () => { |
|||
if (currentPage + 1 === numPages) return |
|||
currentPage = currentPage + 1 |
|||
} |
|||
|
|||
const previous = () => { |
|||
if (currentPage == 0) return |
|||
currentPage = currentPage - 1 |
|||
} |
|||
|
|||
const selectPage = page => { |
|||
currentPage = page |
|||
} |
|||
</script> |
|||
|
|||
<div class="pagination"> |
|||
<div class="pagination__buttons"> |
|||
<button on:click={previous}>Previous</button> |
|||
<button on:click={next}>Next</button> |
|||
{#each Array(numPages) as _, idx} |
|||
<button |
|||
class:selected={idx === currentPage} |
|||
on:click={() => selectPage(idx)}> |
|||
{idx + 1} |
|||
</button> |
|||
{/each} |
|||
</div> |
|||
<p>Showing {pageItemCount} of {data.length} entries</p> |
|||
</div> |
|||
|
|||
<style> |
|||
.pagination { |
|||
width: 100%; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.pagination__buttons { |
|||
display: flex; |
|||
} |
|||
|
|||
.pagination__buttons button { |
|||
display: inline-block; |
|||
padding: 10px; |
|||
margin: 0; |
|||
background: #fff; |
|||
border: 1px solid #ccc; |
|||
text-transform: capitalize; |
|||
border-radius: 3px; |
|||
font-family: Roboto; |
|||
min-width: 20px; |
|||
transition: 0.3s background-color; |
|||
} |
|||
|
|||
.pagination__buttons button:hover { |
|||
cursor: pointer; |
|||
background-color: #fafafa; |
|||
} |
|||
|
|||
.selected { |
|||
color: var(--button-text); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,59 @@ |
|||
import api from "../../builderStore/api" |
|||
import { getNewRecord, getNewInstance } from "../../common/core" |
|||
|
|||
export async function createUser(password, user, { appname, instanceId }) { |
|||
const CREATE_USER_URL = `/_builder/instance/${appname}/${instanceId}/api/createUser` |
|||
const response = await api.post(CREATE_USER_URL, { user, password }) |
|||
return await response.json() |
|||
} |
|||
|
|||
export async function createDatabase(appname, instanceName) { |
|||
const CREATE_DATABASE_URL = `/_builder/instance/_master/0/api/record/` |
|||
const database = getNewInstance(appname, instanceName) |
|||
const response = await api.post(CREATE_DATABASE_URL, database) |
|||
return await response.json() |
|||
} |
|||
|
|||
export async function deleteRecord(record, { appname, instanceId }) { |
|||
const DELETE_RECORDS_URL = `/_builder/instance/${appname}/${instanceId}/api/record${record.key}` |
|||
const response = await api.delete(DELETE_RECORDS_URL) |
|||
return response |
|||
} |
|||
|
|||
export async function loadRecord(key, { appname, instanceId }) { |
|||
const LOAD_RECORDS_URL = `/_builder/instance/${appname}/${instanceId}/api/record${key}` |
|||
const response = await api.get(LOAD_RECORDS_URL) |
|||
return await response.json() |
|||
} |
|||
|
|||
export async function saveRecord(record, { appname, instanceId }) { |
|||
let recordBase = { ...record } |
|||
|
|||
// brand new record
|
|||
// car-model-id or name/specific-car-id/manus
|
|||
if (record.collectionName) { |
|||
const collectionKey = `/${record.collectionName}` |
|||
recordBase = getNewRecord(recordBase, collectionKey) |
|||
recordBase = overwritePresentProperties(recordBase, record) |
|||
} |
|||
|
|||
const SAVE_RECORDS_URL = `/_builder/instance/${appname}/${instanceId}/api/record/` |
|||
const response = await api.post(SAVE_RECORDS_URL, recordBase) |
|||
return await response.json() |
|||
} |
|||
|
|||
export async function fetchDataForView(viewName, { appname, instanceId }) { |
|||
const FETCH_RECORDS_URL = `/_builder/instance/${appname}/${instanceId}/api/listRecords/${viewName}` |
|||
|
|||
const response = await api.get(FETCH_RECORDS_URL) |
|||
return await response.json() |
|||
} |
|||
|
|||
function overwritePresentProperties(baseObj, overwrites) { |
|||
const base = { ...baseObj } |
|||
|
|||
for (let key in base) { |
|||
if (overwrites[key]) base[key] = overwrites[key] |
|||
} |
|||
return base |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export { default } from "./ModelDataTable.svelte" |
|||
@ -0,0 +1,38 @@ |
|||
<script> |
|||
import Modal from "../../../common/Modal.svelte" |
|||
import { store } from "../../../builderStore" |
|||
import ActionButton from "../../../common/ActionButton.svelte" |
|||
import * as api from "../api" |
|||
|
|||
export let onClosed |
|||
|
|||
let databaseName |
|||
|
|||
async function createDatabase() { |
|||
const response = await api.createDatabase($store.appId, databaseName) |
|||
store.createDatabaseForApp(response) |
|||
onClosed() |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
Database Name |
|||
<input class="uk-input" type="text" bind:value={databaseName} /> |
|||
<footer> |
|||
<ActionButton alert on:click={onClosed}>Cancel</ActionButton> |
|||
<ActionButton disabled={!databaseName} on:click={createDatabase}> |
|||
Save |
|||
</ActionButton> |
|||
</footer> |
|||
</section> |
|||
|
|||
<style> |
|||
footer { |
|||
position: absolute; |
|||
padding: 20px; |
|||
width: 100%; |
|||
bottom: 0; |
|||
left: 0; |
|||
background: #fafafa; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,11 @@ |
|||
<script> |
|||
import Modal from "../../../common/Modal.svelte" |
|||
import ActionButton from "../../../common/ActionButton.svelte" |
|||
import { backendUiStore } from "../../../builderStore" |
|||
import ModelView from "../../ModelView.svelte" |
|||
import * as api from "../api" |
|||
</script> |
|||
|
|||
<section> |
|||
<ModelView /> |
|||
</section> |
|||
@ -0,0 +1,107 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { store, backendUiStore } from "../../../builderStore" |
|||
import { compose, map, get, flatten } from "lodash/fp" |
|||
import Modal from "../../../common/Modal.svelte" |
|||
import ActionButton from "../../../common/ActionButton.svelte" |
|||
import Select from "../../../common/Select.svelte" |
|||
import { |
|||
getNewRecord, |
|||
joinKey, |
|||
getExactNodeForKey, |
|||
} from "../../../common/core" |
|||
import RecordFieldControl from "./RecordFieldControl.svelte" |
|||
import * as api from "../api" |
|||
import ErrorsBox from "../../../common/ErrorsBox.svelte" |
|||
|
|||
export let record |
|||
export let onClosed |
|||
|
|||
let errors = [] |
|||
|
|||
const childModelsForModel = compose(flatten, map("children"), get("children")) |
|||
|
|||
$: currentAppInfo = { |
|||
appname: $store.appname, |
|||
instanceId: $backendUiStore.selectedDatabase.id, |
|||
} |
|||
$: models = $backendUiStore.selectedRecord |
|||
? childModelsForModel($store.hierarchy) |
|||
: $store.hierarchy.children |
|||
|
|||
let selectedModel |
|||
$: { |
|||
if (record) { |
|||
selectedModel = getExactNodeForKey($store.hierarchy)(record.key) |
|||
} else { |
|||
selectedModel = selectedModel || models[0] |
|||
} |
|||
} |
|||
|
|||
$: modelFields = selectedModel ? selectedModel.fields : [] |
|||
|
|||
function getCurrentCollectionKey(selectedRecord) { |
|||
return selectedRecord |
|||
? joinKey(selectedRecord.key, selectedModel.collectionName) |
|||
: joinKey(selectedModel.collectionName) |
|||
} |
|||
|
|||
$: editingRecord = |
|||
record || |
|||
editingRecord || |
|||
getNewRecord( |
|||
selectedModel, |
|||
getCurrentCollectionKey($backendUiStore.selectedRecord) |
|||
) |
|||
|
|||
function closed() { |
|||
editingRecord = null |
|||
onClosed() |
|||
} |
|||
|
|||
async function saveRecord() { |
|||
const recordResponse = await api.saveRecord(editingRecord, currentAppInfo) |
|||
backendUiStore.update(state => { |
|||
state.selectedView = state.selectedView |
|||
return state |
|||
}) |
|||
closed() |
|||
} |
|||
</script> |
|||
|
|||
<div> |
|||
<h4 class="budibase__title--4">Create / Edit Record</h4> |
|||
<ErrorsBox {errors} /> |
|||
<div class="actions"> |
|||
<form class="uk-form-stacked"> |
|||
{#if !record} |
|||
<div class="uk-margin"> |
|||
<label class="uk-form-label" for="form-stacked-text">Model</label> |
|||
<Select bind:value={selectedModel}> |
|||
{#each models as model} |
|||
<option value={model}>{model.name}</option> |
|||
{/each} |
|||
</Select> |
|||
</div> |
|||
{/if} |
|||
{#each modelFields || [] as field} |
|||
<RecordFieldControl record={editingRecord} {field} {errors} /> |
|||
{/each} |
|||
</form> |
|||
<footer> |
|||
<ActionButton alert on:click={onClosed}>Cancel</ActionButton> |
|||
<ActionButton on:click={saveRecord}>Save</ActionButton> |
|||
</footer> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
footer { |
|||
position: absolute; |
|||
padding: 20px; |
|||
width: 100%; |
|||
bottom: 0; |
|||
left: 0; |
|||
background: #fafafa; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,8 @@ |
|||
<script> |
|||
import IndexView from "../../IndexView.svelte" |
|||
import * as api from "../api" |
|||
</script> |
|||
|
|||
<section> |
|||
<IndexView /> |
|||
</section> |
|||
@ -0,0 +1,64 @@ |
|||
<script> |
|||
import Modal from "../../../common/Modal.svelte" |
|||
import { store, backendUiStore } from "../../../builderStore" |
|||
import ActionButton from "../../../common/ActionButton.svelte" |
|||
import * as api from "../api" |
|||
|
|||
export let onClosed |
|||
|
|||
let username |
|||
let password |
|||
let accessLevels = [] |
|||
|
|||
$: valid = username && password && accessLevels.length |
|||
$: currentAppInfo = { |
|||
appname: $store.appname, |
|||
instanceId: $backendUiStore.selectedDatabase.id, |
|||
} |
|||
|
|||
async function createUser() { |
|||
const user = { |
|||
name: username, |
|||
accessLevels, |
|||
enabled: true, |
|||
temporaryAccessId: "", |
|||
} |
|||
const response = await api.createUser(password, user, currentAppInfo) |
|||
backendUiStore.actions.users.save(user) |
|||
onClosed() |
|||
} |
|||
</script> |
|||
|
|||
<form class="uk-form-stacked"> |
|||
<label class="uk-form-label" for="form-stacked-text">Username</label> |
|||
<input class="uk-input" type="text" bind:value={username} /> |
|||
<label class="uk-form-label" for="form-stacked-text">Password</label> |
|||
<input class="uk-input" type="password" bind:value={password} /> |
|||
<label class="uk-form-label" for="form-stacked-text">Access Levels</label> |
|||
<select multiple bind:value={accessLevels}> |
|||
{#each $store.accessLevels.levels as level} |
|||
<option value={level.name}>{level.name}</option> |
|||
{/each} |
|||
</select> |
|||
<footer> |
|||
<ActionButton alert on:click={onClosed}>Cancel</ActionButton> |
|||
<ActionButton disabled={!valid} on:click={createUser}>Save</ActionButton> |
|||
</footer> |
|||
</form> |
|||
|
|||
<style> |
|||
footer { |
|||
position: absolute; |
|||
padding: 20px; |
|||
width: 100%; |
|||
bottom: 0; |
|||
left: 0; |
|||
background: #fafafa; |
|||
} |
|||
select { |
|||
width: 100%; |
|||
} |
|||
option { |
|||
padding: 10px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,67 @@ |
|||
<script> |
|||
import Modal from "../../../common/Modal.svelte" |
|||
import ActionButton from "../../../common/ActionButton.svelte" |
|||
import { store, backendUiStore } from "../../../builderStore" |
|||
import * as api from "../api" |
|||
|
|||
export let record |
|||
|
|||
$: currentAppInfo = { |
|||
appname: $store.appname, |
|||
instanceId: $backendUiStore.selectedDatabase.id, |
|||
} |
|||
|
|||
function onClosed() { |
|||
backendUiStore.actions.modals.hide() |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
<heading> |
|||
<i class="ri-information-line alert" /> |
|||
<h4 class="budibase__title--4">Delete Record</h4> |
|||
</heading> |
|||
<p> |
|||
Are you sure you want to delete this record? All of your data will be |
|||
permanently removed. This action cannot be undone. |
|||
</p> |
|||
<div class="modal-actions"> |
|||
<ActionButton on:click={onClosed}>Cancel</ActionButton> |
|||
<ActionButton |
|||
alert |
|||
on:click={async () => { |
|||
await api.deleteRecord(record, currentAppInfo) |
|||
backendUiStore.actions.records.delete(record) |
|||
onClosed() |
|||
}}> |
|||
Delete |
|||
</ActionButton> |
|||
</div> |
|||
</section> |
|||
|
|||
<style> |
|||
.alert { |
|||
color: rgba(255, 0, 31, 1); |
|||
background: #fafafa; |
|||
padding: 5px; |
|||
} |
|||
|
|||
.modal-actions { |
|||
padding: 10px; |
|||
position: absolute; |
|||
bottom: 0; |
|||
left: 0; |
|||
background: #fafafa; |
|||
border-top: 1px solid #ccc; |
|||
width: 100%; |
|||
} |
|||
|
|||
heading { |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
h4 { |
|||
margin: 0 0 0 10px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,66 @@ |
|||
<script> |
|||
import Select from "../../../common/Select.svelte" |
|||
|
|||
export let record |
|||
export let field |
|||
export let errors |
|||
|
|||
$: isDropdown = |
|||
field.type === "string" && |
|||
field.typeOptions.values && |
|||
field.typeOptions.values.length > 0 |
|||
|
|||
$: isNumber = field.type === "number" |
|||
|
|||
$: isText = field.type === "string" && !isDropdown |
|||
|
|||
$: isCheckbox = field.type === "bool" |
|||
|
|||
$: isError = errors && errors.some(e => e.field && e.field === field.name) |
|||
|
|||
$: isDatetime = field.type === "datetime" |
|||
</script> |
|||
|
|||
<div class="uk-margin"> |
|||
{#if !isCheckbox} |
|||
<label class="uk-form-label" for={field.name}>{field.label}</label> |
|||
{/if} |
|||
<div class="uk-form-controls"> |
|||
{#if isDropdown} |
|||
<Select bind:value={record[field.name]}> |
|||
<option value="" /> |
|||
{#each field.typeOptions.values as val} |
|||
<option value={val}>{val}</option> |
|||
{/each} |
|||
</Select> |
|||
{:else if isText} |
|||
<input |
|||
class="uk-input" |
|||
class:uk-form-danger={isError} |
|||
id={field.name} |
|||
type="text" |
|||
bind:value={record[field.name]} /> |
|||
{:else if isNumber} |
|||
<input |
|||
class="uk-input" |
|||
class:uk-form-danger={isError} |
|||
type="number" |
|||
bind:value={record[field.name]} /> |
|||
{:else if isDatetime} |
|||
<input |
|||
class="uk-input" |
|||
class:uk-form-danger={isError} |
|||
type="date" |
|||
bind:value={record[field.name]} /> |
|||
{:else if isCheckbox} |
|||
<label> |
|||
<input |
|||
class="uk-checkbox" |
|||
class:uk-form-danger={isError} |
|||
type="checkbox" |
|||
bind:checked={record[field.name]} /> |
|||
{field.label} |
|||
</label> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,6 @@ |
|||
export { default as DeleteRecordModal } from "./DeleteRecord.svelte" |
|||
export { default as CreateEditRecordModal } from "./CreateEditRecord.svelte" |
|||
export { default as CreateEditModelModal } from "./CreateEditModel.svelte" |
|||
export { default as CreateEditViewModal } from "./CreateEditView.svelte" |
|||
export { default as CreateDatabaseModal } from "./CreateDatabase.svelte" |
|||
export { default as CreateUserModal } from "./CreateUser.svelte" |
|||
@ -0,0 +1,238 @@ |
|||
<script> |
|||
import { tick } from "svelte" |
|||
import Textbox from "../common/Textbox.svelte" |
|||
import Button from "../common/Button.svelte" |
|||
import Select from "../common/Select.svelte" |
|||
import ActionButton from "../common/ActionButton.svelte" |
|||
import getIcon from "../common/icon" |
|||
import FieldView from "./FieldView.svelte" |
|||
import Modal from "../common/Modal.svelte" |
|||
import { |
|||
get, |
|||
compose, |
|||
map, |
|||
join, |
|||
filter, |
|||
some, |
|||
find, |
|||
keys, |
|||
isDate, |
|||
} from "lodash/fp" |
|||
import { store, backendUiStore } from "../builderStore" |
|||
import { common, hierarchy } from "../../../core/src" |
|||
import { getNode } from "../common/core" |
|||
import { templateApi, pipe, validate } from "../common/core" |
|||
import ErrorsBox from "../common/ErrorsBox.svelte" |
|||
|
|||
let record |
|||
let getIndexAllowedRecords |
|||
let editingField = false |
|||
let fieldToEdit |
|||
let isNewField = false |
|||
let newField |
|||
let editField |
|||
let deleteField |
|||
let onFinishedFieldEdit |
|||
let editIndex |
|||
|
|||
$: models = $store.hierarchy.children |
|||
$: parent = record && record.parent() |
|||
$: isChildModel = parent && parent.name !== "root" |
|||
$: modelExistsInHierarchy = |
|||
$store.currentNode && getNode($store.hierarchy, $store.currentNode.nodeId) |
|||
|
|||
store.subscribe($store => { |
|||
record = $store.currentNode |
|||
const flattened = hierarchy.getFlattenedHierarchy($store.hierarchy) |
|||
|
|||
getIndexAllowedRecords = compose( |
|||
join(", "), |
|||
map(id => flattened.find(n => n.nodeId === id).name), |
|||
filter(id => flattened.some(n => n.nodeId === id)), |
|||
get("allowedRecordNodeIds") |
|||
) |
|||
|
|||
newField = () => { |
|||
isNewField = true |
|||
fieldToEdit = templateApi($store.hierarchy).getNewField("string") |
|||
editingField = true |
|||
} |
|||
|
|||
onFinishedFieldEdit = field => { |
|||
if (field) { |
|||
store.saveField(field) |
|||
} |
|||
editingField = false |
|||
} |
|||
|
|||
editField = field => { |
|||
isNewField = false |
|||
fieldToEdit = field |
|||
editingField = true |
|||
} |
|||
|
|||
deleteField = field => { |
|||
store.deleteField(field) |
|||
} |
|||
|
|||
editIndex = index => { |
|||
store.selectExistingNode(index.nodeId) |
|||
} |
|||
}) |
|||
|
|||
let getTypeOptionsValueText = value => { |
|||
if ( |
|||
value === Number.MAX_SAFE_INTEGER || |
|||
value === Number.MIN_SAFE_INTEGER || |
|||
new Date(value).getTime() === new Date(8640000000000000).getTime() || |
|||
new Date(value).getTime() === new Date(-8640000000000000).getTime() |
|||
) |
|||
return "(any)" |
|||
|
|||
if (value === null) return "(not set)" |
|||
return value |
|||
} |
|||
|
|||
const nameChanged = ev => { |
|||
const pluralName = n => `${n}s` |
|||
if (record.collectionName === "") { |
|||
record.collectionName = pluralName(ev.target.value) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<heading> |
|||
{#if !editingField} |
|||
<i class="ri-list-settings-line button--toggled" /> |
|||
<h3 class="budibase__title--3">Create / Edit Model</h3> |
|||
{:else} |
|||
<i class="ri-file-list-line button--toggled" /> |
|||
<h3 class="budibase__title--3">Create / Edit Field</h3> |
|||
{/if} |
|||
</heading> |
|||
{#if !editingField} |
|||
<h4 class="budibase__label--big">Settings</h4> |
|||
|
|||
{#if $store.errors && $store.errors.length > 0} |
|||
<ErrorsBox errors={$store.errors} /> |
|||
{/if} |
|||
|
|||
<form class="uk-form-stacked"> |
|||
|
|||
<Textbox label="Name" bind:text={record.name} on:change={nameChanged} /> |
|||
{#if isChildModel} |
|||
<div> |
|||
<label class="uk-form-label">Parent</label> |
|||
<div class="uk-form-controls parent-name">{parent.name}</div> |
|||
</div> |
|||
{/if} |
|||
</form> |
|||
|
|||
<div class="table-controls"> |
|||
<span class="budibase__label--big">Fields</span> |
|||
<h4 class="hoverable new-field" on:click={newField}>Add new field</h4> |
|||
</div> |
|||
|
|||
<table class="uk-table fields-table budibase__table"> |
|||
<thead> |
|||
<tr> |
|||
<th>Edit</th> |
|||
<th>Name</th> |
|||
<th>Type</th> |
|||
<th>Values</th> |
|||
<th /> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{#each record ? record.fields : [] as field} |
|||
<tr> |
|||
<td> |
|||
<i class="ri-more-line" on:click={() => editField(field)} /> |
|||
</td> |
|||
<td> |
|||
<div>{field.name}</div> |
|||
</td> |
|||
<td>{field.type}</td> |
|||
<td>{field.typeOptions.values || ""}</td> |
|||
<td> |
|||
<i |
|||
class="ri-delete-bin-6-line hoverable" |
|||
on:click={() => deleteField(field)} /> |
|||
</td> |
|||
</tr> |
|||
{/each} |
|||
</tbody> |
|||
</table> |
|||
<div class="uk-margin"> |
|||
<ActionButton color="secondary" on:click={store.saveCurrentNode}> |
|||
Save |
|||
</ActionButton> |
|||
{#if modelExistsInHierarchy} |
|||
<ActionButton color="primary" on:click={store.newChildRecord}> |
|||
Create Child Model on {record.name} |
|||
</ActionButton> |
|||
<ActionButton |
|||
color="primary" |
|||
on:click={async () => { |
|||
backendUiStore.actions.modals.show('VIEW') |
|||
await tick() |
|||
store.newChildIndex() |
|||
}}> |
|||
Create Child View on {record.name} |
|||
</ActionButton> |
|||
<ActionButton alert on:click={store.deleteCurrentNode}>Delete</ActionButton> |
|||
{/if} |
|||
</div> |
|||
{:else} |
|||
<FieldView |
|||
field={fieldToEdit} |
|||
onFinished={onFinishedFieldEdit} |
|||
allFields={record.fields} |
|||
store={$store} /> |
|||
{/if} |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
height: 100%; |
|||
} |
|||
|
|||
.new-field { |
|||
font-size: 16px; |
|||
font-weight: bold; |
|||
color: var(--button-text); |
|||
} |
|||
|
|||
.fields-table { |
|||
margin: 1rem 1rem 0rem 0rem; |
|||
border-collapse: collapse; |
|||
} |
|||
|
|||
tbody > tr:hover { |
|||
background-color: var(--primary10); |
|||
} |
|||
|
|||
.table-controls { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
|
|||
.ri-more-line:hover { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
heading { |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
h3 { |
|||
margin: 0 0 0 10px; |
|||
} |
|||
|
|||
.parent-name { |
|||
font-weight: bold; |
|||
} |
|||
</style> |
|||
@ -1,301 +0,0 @@ |
|||
<script> |
|||
import Textbox from "../common/Textbox.svelte" |
|||
import Button from "../common/Button.svelte" |
|||
import getIcon from "../common/icon" |
|||
import FieldView from "./FieldView.svelte" |
|||
import Modal from "../common/Modal.svelte" |
|||
import { map, join, filter, some, find, keys, isDate } from "lodash/fp" |
|||
import { store } from "../builderStore" |
|||
import { common, hierarchy as h } from "../../../core/src" |
|||
import { templateApi, pipe, validate } from "../common/core" |
|||
|
|||
let record |
|||
let getIndexAllowedRecords |
|||
let editingField = false |
|||
let fieldToEdit |
|||
let isNewField = false |
|||
let newField |
|||
let editField |
|||
let deleteField |
|||
let onFinishedFieldEdit |
|||
let editIndex |
|||
|
|||
store.subscribe($store => { |
|||
record = $store.currentNode |
|||
const flattened = h.getFlattenedHierarchy($store.hierarchy) |
|||
getIndexAllowedRecords = index => |
|||
pipe(index.allowedRecordNodeIds, [ |
|||
filter(id => some(n => n.nodeId === id)(flattened)), |
|||
map(id => find(n => n.nodeId === id)(flattened).name), |
|||
join(", "), |
|||
]) |
|||
|
|||
newField = () => { |
|||
isNewField = true |
|||
fieldToEdit = templateApi($store.hierarchy).getNewField("string") |
|||
editingField = true |
|||
} |
|||
|
|||
onFinishedFieldEdit = field => { |
|||
if (field) { |
|||
store.saveField(field) |
|||
} |
|||
editingField = false |
|||
} |
|||
|
|||
editField = field => { |
|||
isNewField = false |
|||
fieldToEdit = field |
|||
editingField = true |
|||
} |
|||
|
|||
deleteField = field => { |
|||
store.deleteField(field) |
|||
} |
|||
|
|||
editIndex = index => { |
|||
store.selectExistingNode(index.nodeId) |
|||
} |
|||
}) |
|||
|
|||
let getTypeOptionsValueText = value => { |
|||
if ( |
|||
value === Number.MAX_SAFE_INTEGER || |
|||
value === Number.MIN_SAFE_INTEGER || |
|||
new Date(value).getTime() === new Date(8640000000000000).getTime() || |
|||
new Date(value).getTime() === new Date(-8640000000000000).getTime() |
|||
) |
|||
return "(any)" |
|||
|
|||
if (value === null) return "(not set)" |
|||
return value |
|||
} |
|||
|
|||
let getTypeOptions = typeOptions => |
|||
pipe(typeOptions, [ |
|||
keys, |
|||
map( |
|||
k => |
|||
`<span style="color:var(--slate)">${k}: </span>${getTypeOptionsValueText( |
|||
typeOptions[k] |
|||
)}` |
|||
), |
|||
join("<br>"), |
|||
]) |
|||
|
|||
const nameChanged = ev => { |
|||
const pluralName = n => `${n}s` |
|||
if (record.collectionName === "") { |
|||
record.collectionName = pluralName(ev.target.value) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
|
|||
<form class="uk-form-horizontal"> |
|||
<h3 class="budibase__title--3">Settings</h3> |
|||
|
|||
<Textbox label="Name:" bind:text={record.name} on:change={nameChanged} /> |
|||
{#if !record.isSingle} |
|||
<Textbox label="Collection Name:" bind:text={record.collectionName} /> |
|||
<Textbox |
|||
label="Estimated Record Count:" |
|||
bind:text={record.estimatedRecordCount} /> |
|||
{/if} |
|||
<div class="recordkey">{record.nodeKey()}</div> |
|||
|
|||
</form> |
|||
<h3 class="budibase__title--3"> |
|||
Fields |
|||
<span class="add-field-button" on:click={newField}> |
|||
{@html getIcon('plus')} |
|||
</span> |
|||
</h3> |
|||
|
|||
{#if record.fields.length > 0} |
|||
<table class="fields-table uk-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>Name</th> |
|||
<th>Type</th> |
|||
<th>Options</th> |
|||
<th /> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{#each record.fields as field} |
|||
<tr> |
|||
<td> |
|||
<div class="field-label">{field.label}</div> |
|||
<div style="font-size: 0.8em; color: var(--slate)"> |
|||
{field.name} |
|||
</div> |
|||
</td> |
|||
<td>{field.type}</td> |
|||
<td> |
|||
{@html getTypeOptions(field.typeOptions)} |
|||
</td> |
|||
<td> |
|||
<span class="edit-button" on:click={() => editField(field)}> |
|||
{@html getIcon('edit')} |
|||
</span> |
|||
<span class="edit-button" on:click={() => deleteField(field)}> |
|||
{@html getIcon('trash')} |
|||
</span> |
|||
</td> |
|||
</tr> |
|||
{/each} |
|||
</tbody> |
|||
</table> |
|||
{:else}(no fields added){/if} |
|||
|
|||
{#if editingField} |
|||
<Modal |
|||
title="Manage Index Fields" |
|||
bind:isOpen={editingField} |
|||
onClosed={() => onFinishedFieldEdit(false)}> |
|||
<FieldView |
|||
field={fieldToEdit} |
|||
onFinished={onFinishedFieldEdit} |
|||
allFields={record.fields} |
|||
store={$store} /> |
|||
</Modal> |
|||
{/if} |
|||
|
|||
<h3 class="budibase__title--3">Indexes</h3> |
|||
|
|||
{#each record.indexes as index} |
|||
<div class="index-container"> |
|||
<div class="index-name"> |
|||
{index.name} |
|||
<span style="margin-left: 7px" on:click={() => editIndex(index)}> |
|||
{@html getIcon('edit')} |
|||
</span> |
|||
</div> |
|||
<div class="index-field-row"> |
|||
<span class="index-label">records indexed:</span> |
|||
<span>{getIndexAllowedRecords(index)}</span> |
|||
<span class="index-label" style="margin-left: 15px">type:</span> |
|||
<span>{index.indexType}</span> |
|||
</div> |
|||
<div class="index-field-row"> |
|||
<span class="index-label">map:</span> |
|||
<code class="index-mapfilter">{index.map}</code> |
|||
</div> |
|||
{#if index.filter} |
|||
<div class="index-field-row"> |
|||
<span class="index-label">filter:</span> |
|||
<code class="index-mapfilter">{index.filter}</code> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
{:else} |
|||
<div class="no-indexes">No indexes added.</div> |
|||
{/each} |
|||
|
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
height: 100%; |
|||
padding: 2rem; |
|||
} |
|||
|
|||
.recordkey { |
|||
font-size: 14px; |
|||
font-weight: 600; |
|||
color: var(--primary100); |
|||
} |
|||
|
|||
.fields-table { |
|||
margin: 1rem 1rem 0rem 0rem; |
|||
border-collapse: collapse; |
|||
} |
|||
|
|||
.add-field-button { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.edit-button { |
|||
cursor: pointer; |
|||
color: var(--secondary25); |
|||
} |
|||
|
|||
.edit-button:hover { |
|||
cursor: pointer; |
|||
color: var(--secondary75); |
|||
} |
|||
|
|||
th { |
|||
text-align: left; |
|||
} |
|||
|
|||
td { |
|||
padding: 1rem 5rem 1rem 0rem; |
|||
margin: 0; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.field-label { |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
thead > tr { |
|||
border-width: 0px 0px 1px 0px; |
|||
border-style: solid; |
|||
border-color: var(--secondary75); |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
tbody > tr { |
|||
border-width: 0px 0px 1px 0px; |
|||
border-style: solid; |
|||
border-color: var(--primary10); |
|||
} |
|||
|
|||
tbody > tr:hover { |
|||
background-color: var(--primary10); |
|||
} |
|||
|
|||
tbody > tr:hover .edit-button { |
|||
color: var(--secondary75); |
|||
} |
|||
|
|||
.index-container { |
|||
border-style: solid; |
|||
border-width: 0 0 1px 0; |
|||
border-color: var(--secondary25); |
|||
padding: 10px; |
|||
margin-bottom: 5px; |
|||
} |
|||
|
|||
.index-label { |
|||
color: var(--slate); |
|||
} |
|||
|
|||
.index-name { |
|||
font-weight: bold; |
|||
color: var(--primary100); |
|||
} |
|||
|
|||
.index-container code { |
|||
margin: 0; |
|||
display: inline; |
|||
background-color: var(--primary10); |
|||
color: var(--secondary100); |
|||
padding: 3px; |
|||
} |
|||
|
|||
.index-field-row { |
|||
margin: 1rem 0rem 0rem 0rem; |
|||
} |
|||
|
|||
.no-indexes { |
|||
margin: 1rem 0rem 0rem 0rem; |
|||
font-family: var(--fontnormal); |
|||
font-size: 14px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,78 @@ |
|||
<script> |
|||
import { tick } from "svelte" |
|||
import { store, backendUiStore } from "../builderStore" |
|||
import getIcon from "../common/icon" |
|||
import { CheckIcon } from "../common/Icons" |
|||
|
|||
$: instances = $store.appInstances |
|||
$: views = $store.hierarchy.indexes |
|||
|
|||
async function selectDatabase(database) { |
|||
backendUiStore.actions.navigate("DATABASE") |
|||
backendUiStore.actions.records.select(null) |
|||
backendUiStore.actions.views.select(views[0]) |
|||
backendUiStore.actions.database.select(database) |
|||
} |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<ul> |
|||
{#each $store.appInstances as database} |
|||
<li> |
|||
<span class="icon"> |
|||
{#if database.id === $backendUiStore.selectedDatabase.id} |
|||
<CheckIcon /> |
|||
{/if} |
|||
</span> |
|||
|
|||
<button |
|||
class:active={database.id === $backendUiStore.selectedDatabase.id} |
|||
on:click={() => selectDatabase(database)}> |
|||
{database.name} |
|||
</button> |
|||
</li> |
|||
{/each} |
|||
</ul> |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
padding-bottom: 10px; |
|||
font-size: 0.9rem; |
|||
color: var(--secondary50); |
|||
font-weight: bold; |
|||
position: relative; |
|||
padding-left: 1.8rem; |
|||
} |
|||
|
|||
ul { |
|||
margin: 0; |
|||
padding: 0; |
|||
list-style: none; |
|||
} |
|||
|
|||
li { |
|||
margin: 0.5rem 0; |
|||
} |
|||
|
|||
button { |
|||
margin: 0 0 0 6px; |
|||
padding: 0; |
|||
border: none; |
|||
font-family: Roboto; |
|||
font-size: 0.8rem; |
|||
outline: none; |
|||
cursor: pointer; |
|||
background: rgba(0, 0, 0, 0); |
|||
} |
|||
|
|||
.active { |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.icon { |
|||
display: inline-block; |
|||
width: 14px; |
|||
color: #333; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,84 @@ |
|||
<script> |
|||
import { store, backendUiStore } from "../builderStore" |
|||
import HierarchyRow from "./HierarchyRow.svelte" |
|||
import DropdownButton from "../common/DropdownButton.svelte" |
|||
import { hierarchy as hierarchyFunctions } from "../../../core/src" |
|||
import NavItem from "./NavItem.svelte" |
|||
import getIcon from "../common/icon" |
|||
|
|||
function newModel() { |
|||
if ($store.currentNode) { |
|||
store.newChildRecord() |
|||
} else { |
|||
store.newRootRecord() |
|||
} |
|||
backendUiStore.actions.modals.show("MODEL") |
|||
} |
|||
|
|||
function newView() { |
|||
store.newRootIndex() |
|||
backendUiStore.actions.modals.show("VIEW") |
|||
} |
|||
</script> |
|||
|
|||
<div class="items-root"> |
|||
<div class="hierarchy"> |
|||
<div class="components-list-container"> |
|||
<div class="nav-group-header"> |
|||
<div class="hierarchy-title">Schema</div> |
|||
<div class="uk-inline"> |
|||
<i class="ri-add-line hoverable" /> |
|||
<div uk-dropdown="mode: click;"> |
|||
<ul class="uk-nav uk-dropdown-nav"> |
|||
<li class="hoverable" on:click={newModel}>Model</li> |
|||
<li class="hoverable" on:click={newView}>View</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="hierarchy-items-container"> |
|||
{#each $store.hierarchy.children as record} |
|||
<HierarchyRow node={record} type="record" /> |
|||
{/each} |
|||
|
|||
{#each $store.hierarchy.indexes as index} |
|||
<HierarchyRow node={index} type="index" /> |
|||
{/each} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.items-root { |
|||
display: flex; |
|||
flex-direction: column; |
|||
max-height: 100%; |
|||
height: 100%; |
|||
background-color: var(--secondary5); |
|||
} |
|||
|
|||
.nav-group-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 2rem 1rem 1rem 1rem; |
|||
} |
|||
|
|||
.hierarchy-title { |
|||
align-items: center; |
|||
text-transform: uppercase; |
|||
font-size: 0.85em; |
|||
} |
|||
|
|||
.hierarchy { |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.hierarchy-items-container { |
|||
flex: 1 1 auto; |
|||
overflow-y: auto; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,86 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { store, backendUiStore } from "../builderStore" |
|||
import api from "../builderStore/api" |
|||
import getIcon from "../common/icon" |
|||
import { CheckIcon } from "../common/Icons" |
|||
|
|||
const getPage = (s, name) => { |
|||
const props = s.pages[name] |
|||
return { name, props } |
|||
} |
|||
|
|||
let users = [] |
|||
|
|||
$: currentAppInfo = { |
|||
appname: $store.appname, |
|||
instanceId: $backendUiStore.selectedDatabase.id, |
|||
} |
|||
|
|||
async function fetchUsers() { |
|||
const FETCH_USERS_URL = `/_builder/instance/${currentAppInfo.appname}/${currentAppInfo.instanceId}/api/users` |
|||
const response = await api.get(FETCH_USERS_URL) |
|||
users = await response.json() |
|||
backendUiStore.update(state => { |
|||
state.users = users |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
onMount(fetchUsers) |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<ul> |
|||
{#each users as user} |
|||
<li> |
|||
<i class="ri-user-4-line" /> |
|||
<button class:active={user.id === $store.currentUserId}> |
|||
{user.name} |
|||
</button> |
|||
</li> |
|||
{/each} |
|||
</ul> |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
padding-bottom: 10px; |
|||
font-size: 0.9rem; |
|||
color: var(--secondary50); |
|||
font-weight: bold; |
|||
position: relative; |
|||
padding-left: 1.8rem; |
|||
} |
|||
|
|||
ul { |
|||
margin: 0; |
|||
padding: 0; |
|||
list-style: none; |
|||
} |
|||
|
|||
li { |
|||
margin: 0.5rem 0; |
|||
} |
|||
|
|||
button { |
|||
margin: 0 0 0 6px; |
|||
padding: 0; |
|||
border: none; |
|||
font-family: Roboto; |
|||
font-size: 0.8rem; |
|||
outline: none; |
|||
cursor: pointer; |
|||
background: rgba(0, 0, 0, 0); |
|||
} |
|||
|
|||
.active { |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.icon { |
|||
display: inline-block; |
|||
width: 14px; |
|||
color: #333; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,54 @@ |
|||
import { |
|||
findRoot, |
|||
getFlattenedHierarchy, |
|||
fieldReversesReferenceToIndex, |
|||
isRecord, |
|||
} from "./hierarchy" |
|||
import { $ } from "../common" |
|||
import { map, filter, reduce } from "lodash/fp" |
|||
|
|||
export const canDeleteIndex = indexNode => { |
|||
const flatHierarchy = $(indexNode, [findRoot, getFlattenedHierarchy]) |
|||
|
|||
const reverseIndexes = $(flatHierarchy, [ |
|||
filter(isRecord), |
|||
reduce((obj, r) => { |
|||
for (let field of r.fields) { |
|||
if (fieldReversesReferenceToIndex(indexNode)(field)) { |
|||
obj.push({ ...field, record: r }) |
|||
} |
|||
} |
|||
return obj |
|||
}, []), |
|||
map( |
|||
f => |
|||
`field "${f.name}" on record "${f.record.name}" uses this index as a reference` |
|||
), |
|||
]) |
|||
|
|||
const lookupIndexes = $(flatHierarchy, [ |
|||
filter(isRecord), |
|||
reduce((obj, r) => { |
|||
for (let field of r.fields) { |
|||
if ( |
|||
field.type === "reference" && |
|||
field.typeOptions.indexNodeKey === indexNode.nodeKey() |
|||
) { |
|||
obj.push({ ...field, record: r }) |
|||
} |
|||
} |
|||
return obj |
|||
}, []), |
|||
map( |
|||
f => |
|||
`field "${f.name}" on record "${f.record.name}" uses this index as a lookup` |
|||
), |
|||
]) |
|||
|
|||
const errors = [...reverseIndexes, ...lookupIndexes] |
|||
|
|||
return { |
|||
canDelete: errors.length === 0, |
|||
errors, |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
import { |
|||
findRoot, |
|||
getFlattenedHierarchy, |
|||
fieldReversesReferenceToIndex, |
|||
isRecord, |
|||
isAncestorIndex, |
|||
isAncestor, |
|||
} from "./hierarchy" |
|||
import { $ } from "../common" |
|||
import { map, filter, includes } from "lodash/fp" |
|||
|
|||
export const canDeleteRecord = recordNode => { |
|||
const flatHierarchy = $(recordNode, [findRoot, getFlattenedHierarchy]) |
|||
|
|||
const ancestors = $(flatHierarchy, [filter(isAncestor(recordNode))]) |
|||
|
|||
const belongsToAncestor = i => ancestors.includes(i.parent()) |
|||
|
|||
const errorsForNode = node => { |
|||
const errorsThisNode = $(flatHierarchy, [ |
|||
filter( |
|||
i => |
|||
isAncestorIndex(i) && |
|||
belongsToAncestor(i) && |
|||
includes(node.nodeId)(i.allowedRecordNodeIds) |
|||
), |
|||
map( |
|||
i => |
|||
`index "${i.name}" indexes this record. Please remove the record from the index, or delete the index` |
|||
), |
|||
]) |
|||
|
|||
for (let child of node.children) { |
|||
for (let err of errorsForNode(child)) { |
|||
errorsThisNode.push(err) |
|||
} |
|||
} |
|||
|
|||
return errorsThisNode |
|||
} |
|||
|
|||
const errors = errorsForNode(recordNode) |
|||
|
|||
return { errors, canDelete: errors.length === 0 } |
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
import { |
|||
setupApphierarchy, |
|||
basicAppHierarchyCreator_WithFields, |
|||
stubEventHandler, |
|||
basicAppHierarchyCreator_WithFields_AndIndexes, |
|||
} from "./specHelpers" |
|||
import { canDeleteIndex } from "../src/templateApi/canDeleteIndex" |
|||
import { canDeleteRecord } from "../src/templateApi/canDeleteRecord" |
|||
|
|||
describe("canDeleteIndex", () => { |
|||
it("should return no errors if deltion is valid", async () => { |
|||
const { appHierarchy } = await setupApphierarchy( |
|||
basicAppHierarchyCreator_WithFields |
|||
) |
|||
|
|||
const partnerIndex = appHierarchy.root.indexes.find(i => i.name === "partner_index") |
|||
|
|||
const result = canDeleteIndex(partnerIndex) |
|||
|
|||
expect(result.canDelete).toBe(true) |
|||
expect(result.errors).toEqual([]) |
|||
}) |
|||
|
|||
it("should return errors if index is a lookup for a reference field", async () => { |
|||
const { appHierarchy } = await setupApphierarchy( |
|||
basicAppHierarchyCreator_WithFields |
|||
) |
|||
|
|||
const customerIndex = appHierarchy.root.indexes.find(i => i.name === "customer_index") |
|||
|
|||
const result = canDeleteIndex(customerIndex) |
|||
|
|||
expect(result.canDelete).toBe(false) |
|||
expect(result.errors.length).toBe(1) |
|||
}) |
|||
|
|||
it("should return errors if index is a manyToOne index for a reference field", async () => { |
|||
const { appHierarchy } = await setupApphierarchy( |
|||
basicAppHierarchyCreator_WithFields |
|||
) |
|||
|
|||
const referredToCustomersIndex = appHierarchy.customerRecord.indexes.find(i => i.name === "referredToCustomers") |
|||
|
|||
const result = canDeleteIndex(referredToCustomersIndex) |
|||
|
|||
expect(result.canDelete).toBe(false) |
|||
expect(result.errors.length).toBe(1) |
|||
}) |
|||
}) |
|||
|
|||
|
|||
describe("canDeleteRecord", () => { |
|||
it("should return no errors when deletion is valid", async () => { |
|||
const { appHierarchy } = await setupApphierarchy( |
|||
basicAppHierarchyCreator_WithFields |
|||
) |
|||
|
|||
appHierarchy.root.indexes = appHierarchy.root.indexes.filter(i => !i.allowedRecordNodeIds.includes(appHierarchy.customerRecord.nodeId)) |
|||
const result = canDeleteRecord(appHierarchy.customerRecord) |
|||
|
|||
expect(result.canDelete).toBe(true) |
|||
expect(result.errors).toEqual([]) |
|||
}) |
|||
|
|||
it("should return errors when record is referenced by hierarchal index", async () => { |
|||
const { appHierarchy } = await setupApphierarchy( |
|||
basicAppHierarchyCreator_WithFields |
|||
) |
|||
|
|||
const result = canDeleteRecord(appHierarchy.customerRecord) |
|||
|
|||
expect(result.canDelete).toBe(false) |
|||
expect(result.errors.some(e => e.includes("customer_index"))).toBe(true) |
|||
}) |
|||
|
|||
it("should return errors when record has a child which cannot be deleted", async () => { |
|||
const { appHierarchy } = await setupApphierarchy( |
|||
basicAppHierarchyCreator_WithFields_AndIndexes |
|||
) |
|||
|
|||
const result = canDeleteRecord(appHierarchy.customerRecord) |
|||
|
|||
expect(result.canDelete).toBe(false) |
|||
expect(result.errors.some(e => e.includes("Outstanding Invoices"))).toBe(true) |
|||
}) |
|||
}) |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue