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