bidirectional linked records
This commit is contained in:
parent
099e394270
commit
1b1b804bbd
|
@ -4,7 +4,8 @@
|
||||||
import api from "builderStore/api"
|
import api from "builderStore/api"
|
||||||
|
|
||||||
export let modelId
|
export let modelId
|
||||||
export let linkedRecords
|
export let linkName
|
||||||
|
export let linked = []
|
||||||
|
|
||||||
let records = []
|
let records = []
|
||||||
|
|
||||||
|
@ -19,16 +20,16 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
function linkRecord(record) {
|
function linkRecord(record) {
|
||||||
linkedRecords.push(record)
|
linked.push(record._id)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
<h3>{linkName}</h3>
|
||||||
{#each records as record}
|
{#each records as record}
|
||||||
<div class="linked-record" on:click={linkRecord}>
|
<div class="linked-record" on:click={() => linkRecord(record)}>
|
||||||
<h3>{record.name}</h3>
|
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
{#each Object.keys(record) as key}
|
{#each Object.keys(record).slice(0, 2) as key}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span>{key}</span>
|
<span>{key}</span>
|
||||||
<p>{record[key]}</p>
|
<p>{record[key]}</p>
|
||||||
|
@ -40,21 +41,24 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
section {
|
h3 {
|
||||||
background: var(--grey);
|
font-size: 20px;
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fields {
|
.fields {
|
||||||
padding: 20px;
|
padding: 15px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
grid-gap: 20px;
|
grid-gap: 20px;
|
||||||
background: var(--white);
|
background: var(--grey);
|
||||||
border: 1px solid var(--grey);
|
border: 1px solid var(--grey);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.field span {
|
.field span {
|
||||||
color: var(--ink-lighter);
|
color: var(--ink-lighter);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
@ -64,5 +68,7 @@
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
import { fade } from "svelte/transition"
|
||||||
|
import { backendUiStore } from "builderStore"
|
||||||
|
import api from "builderStore/api"
|
||||||
|
|
||||||
|
export let ids = []
|
||||||
|
export let header
|
||||||
|
|
||||||
|
let records = []
|
||||||
|
let open = false
|
||||||
|
|
||||||
|
async function fetchRecords() {
|
||||||
|
const FETCH_RECORDS_URL = `/api/${$backendUiStore.selectedDatabase._id}/records/search`
|
||||||
|
const response = await api.post(FETCH_RECORDS_URL, {
|
||||||
|
keys: ids,
|
||||||
|
})
|
||||||
|
records = await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
$: ids && fetchRecords()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<a on:click={() => (open = !open)}>{records.length}</a>
|
||||||
|
{#if open}
|
||||||
|
<div class="popover" transition:fade>
|
||||||
|
<h3>{header}</h3>
|
||||||
|
{#each records as record}
|
||||||
|
<div class="linked-record">
|
||||||
|
<div class="fields">
|
||||||
|
{#each Object.keys(record).slice(0, 2) as key}
|
||||||
|
<div class="field">
|
||||||
|
<span>{key}</span>
|
||||||
|
<p>{record[key]}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
a {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
right: 15%;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--light-grey);
|
||||||
|
border: 1px solid var(--grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
padding: 15px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
grid-gap: 20px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--grey);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
color: var(--ink-lighter);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field p {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -4,6 +4,7 @@
|
||||||
import { Button } from "@budibase/bbui"
|
import { Button } from "@budibase/bbui"
|
||||||
import Select from "components/common/Select.svelte"
|
import Select from "components/common/Select.svelte"
|
||||||
import ActionButton from "components/common/ActionButton.svelte"
|
import ActionButton from "components/common/ActionButton.svelte"
|
||||||
|
import LinkedRecord from "./LinkedRecord.svelte";
|
||||||
import TablePagination from "./TablePagination.svelte"
|
import TablePagination from "./TablePagination.svelte"
|
||||||
import { DeleteRecordModal, CreateEditRecordModal } from "./modals"
|
import { DeleteRecordModal, CreateEditRecordModal } from "./modals"
|
||||||
import * as api from "./api"
|
import * as api from "./api"
|
||||||
|
@ -41,6 +42,7 @@
|
||||||
let headers = []
|
let headers = []
|
||||||
let views = []
|
let views = []
|
||||||
let currentPage = 0
|
let currentPage = 0
|
||||||
|
let search
|
||||||
|
|
||||||
$: instanceId = $backendUiStore.selectedDatabase._id
|
$: instanceId = $backendUiStore.selectedDatabase._id
|
||||||
|
|
||||||
|
@ -91,6 +93,10 @@
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="search">
|
||||||
|
<i class="ri-search-line"></i>
|
||||||
|
<input placeholder="Search" class="budibase__input" bind:value={search} />
|
||||||
|
</div>
|
||||||
<table class="uk-table">
|
<table class="uk-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -130,7 +136,13 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{#each headers as header}
|
{#each headers as header}
|
||||||
<td>{row[header]}</td>
|
<td>
|
||||||
|
{#if Array.isArray(row[header])}
|
||||||
|
<LinkedRecord {header} ids={row[header]} />
|
||||||
|
{:else}
|
||||||
|
{row[header] || 0}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
@ -15,7 +15,7 @@ export async function createDatabase(appname, instanceName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecord(record, instanceId) {
|
export async function deleteRecord(record, instanceId) {
|
||||||
const DELETE_RECORDS_URL = `/api/${instanceId}/${record._modelId}/records/${record._id}/${record._rev}`
|
const DELETE_RECORDS_URL = `/api/${instanceId}/${record.modelId}/records/${record._id}/${record._rev}`
|
||||||
const response = await api.delete(DELETE_RECORDS_URL)
|
const response = await api.delete(DELETE_RECORDS_URL)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,7 +77,7 @@
|
||||||
{#each modelSchema as [key, meta]}
|
{#each modelSchema as [key, meta]}
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
{#if meta.type === 'link'}
|
{#if meta.type === 'link'}
|
||||||
<LinkedRecordSelector modelId={meta.modelId} />
|
<LinkedRecordSelector bind:linked={record[key]} linkName={key} modelId={meta.modelId} />
|
||||||
{:else}
|
{:else}
|
||||||
<RecordFieldControl
|
<RecordFieldControl
|
||||||
type={determineInputType(meta)}
|
type={determineInputType(meta)}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import ActionButton from "components/common/ActionButton.svelte"
|
import ActionButton from "components/common/ActionButton.svelte"
|
||||||
|
import { notifier } from "@beyonk/svelte-notifications"
|
||||||
import { store, backendUiStore } from "builderStore"
|
import { store, backendUiStore } from "builderStore"
|
||||||
import * as api from "../api"
|
import * as api from "../api"
|
||||||
|
|
||||||
|
@ -26,6 +27,7 @@
|
||||||
alert
|
alert
|
||||||
on:click={async () => {
|
on:click={async () => {
|
||||||
await api.deleteRecord(record, instanceId)
|
await api.deleteRecord(record, instanceId)
|
||||||
|
notifier.danger("Record deleted")
|
||||||
backendUiStore.actions.records.delete(record)
|
backendUiStore.actions.records.delete(record)
|
||||||
onClosed()
|
onClosed()
|
||||||
}}>
|
}}>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import * as blockDefinitions from "constants/backend"
|
import * as blockDefinitions from "constants/backend"
|
||||||
import { backendUiStore } from "builderStore"
|
import { backendUiStore } from "builderStore";
|
||||||
import Block from "components/common/Block.svelte"
|
import Block from "components/common/Block.svelte"
|
||||||
|
|
||||||
const HEADINGS = [
|
const HEADINGS = [
|
||||||
|
@ -11,11 +11,7 @@
|
||||||
{
|
{
|
||||||
title: "Blocks",
|
title: "Blocks",
|
||||||
key: "BLOCKS",
|
key: "BLOCKS",
|
||||||
},
|
}
|
||||||
{
|
|
||||||
title: "Model",
|
|
||||||
key: "MODELS",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
let selectedTab = "FIELDS"
|
let selectedTab = "FIELDS"
|
||||||
|
|
|
@ -7,6 +7,18 @@
|
||||||
function addNewField(field) {
|
function addNewField(field) {
|
||||||
backendUiStore.actions.models.addField(field)
|
backendUiStore.actions.models.addField(field)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createModel(model) {
|
||||||
|
const { schema, ...rest } = $backendUiStore.selectedModel
|
||||||
|
|
||||||
|
backendUiStore.actions.models.save({
|
||||||
|
model: {
|
||||||
|
...model,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
instanceId: $backendUiStore.selectedDatabase._id
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section transition:fade>
|
<section transition:fade>
|
||||||
|
@ -48,7 +60,7 @@
|
||||||
<p>Blocks are pre-made fields and help you build your model quicker.</p>
|
<p>Blocks are pre-made fields and help you build your model quicker.</p>
|
||||||
<div class="blocks">
|
<div class="blocks">
|
||||||
{#each Object.values(MODELS) as model}
|
{#each Object.values(MODELS) as model}
|
||||||
<Block tertiary title={model.name} icon={model.icon} />
|
<Block tertiary title={model.name} icon={model.icon} on:click={() => createModel(model)}/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -28,15 +28,15 @@ export const FIELDS = {
|
||||||
presence: false,
|
presence: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
OPTIONS: {
|
// OPTIONS: {
|
||||||
name: "Options",
|
// name: "Options",
|
||||||
icon: "ri-list-check-2",
|
// icon: "ri-list-check-2",
|
||||||
type: "options",
|
// type: "options",
|
||||||
constraints: {
|
// constraints: {
|
||||||
type: "string",
|
// type: "string",
|
||||||
presence: false,
|
// presence: false,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
DATETIME: {
|
DATETIME: {
|
||||||
name: "Date/Time",
|
name: "Date/Time",
|
||||||
icon: "ri-calendar-event-fill",
|
icon: "ri-calendar-event-fill",
|
||||||
|
@ -47,24 +47,24 @@ export const FIELDS = {
|
||||||
presence: false,
|
presence: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
IMAGE: {
|
// IMAGE: {
|
||||||
name: "File",
|
// name: "File",
|
||||||
icon: "ri-image-line",
|
// icon: "ri-image-line",
|
||||||
type: "file",
|
// type: "file",
|
||||||
constraints: {
|
// constraints: {
|
||||||
type: "string",
|
// type: "string",
|
||||||
presence: false,
|
// presence: false,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
FILE: {
|
// FILE: {
|
||||||
name: "Image",
|
// name: "Image",
|
||||||
icon: "ri-file-line",
|
// icon: "ri-file-line",
|
||||||
type: "file",
|
// type: "file",
|
||||||
constraints: {
|
// constraints: {
|
||||||
type: "string",
|
// type: "string",
|
||||||
presence: false,
|
// presence: false,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
DATA_LINK: {
|
DATA_LINK: {
|
||||||
name: "Data Links",
|
name: "Data Links",
|
||||||
icon: "ri-link",
|
icon: "ri-link",
|
||||||
|
@ -106,16 +106,16 @@ export const BLOCKS = {
|
||||||
presence: false,
|
presence: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
PRIORITY: {
|
// PRIORITY: {
|
||||||
name: "Options",
|
// name: "Options",
|
||||||
icon: "ri-list-check-2",
|
// icon: "ri-list-check-2",
|
||||||
type: "options",
|
// type: "options",
|
||||||
constraints: {
|
// constraints: {
|
||||||
type: "string",
|
// type: "string",
|
||||||
presence: false,
|
// presence: false,
|
||||||
inclusion: ["low", "medium", "high"],
|
// inclusion: ["low", "medium", "high"],
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
END_DATE: {
|
END_DATE: {
|
||||||
name: "End Date",
|
name: "End Date",
|
||||||
icon: "ri-calendar-event-fill",
|
icon: "ri-calendar-event-fill",
|
||||||
|
@ -126,39 +126,29 @@ export const BLOCKS = {
|
||||||
presence: false,
|
presence: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AVATAR: {
|
// AVATAR: {
|
||||||
name: "Avatar",
|
// name: "Avatar",
|
||||||
icon: "ri-image-line",
|
// icon: "ri-image-line",
|
||||||
type: "image",
|
// type: "image",
|
||||||
constraints: {
|
// constraints: {
|
||||||
type: "string",
|
// type: "string",
|
||||||
presence: false,
|
// presence: false,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
PDF: {
|
// PDF: {
|
||||||
name: "PDF",
|
// name: "PDF",
|
||||||
icon: "ri-file-line",
|
// icon: "ri-file-line",
|
||||||
type: "file",
|
// type: "file",
|
||||||
constraints: {
|
// constraints: {
|
||||||
type: "string",
|
// type: "string",
|
||||||
presence: false,
|
// presence: false,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
DATA_LINK: {
|
|
||||||
name: "Data Links",
|
|
||||||
icon: "ri-link",
|
|
||||||
type: "link",
|
|
||||||
modelId: null,
|
|
||||||
constraints: {
|
|
||||||
type: "array",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Needs more thought, need to come up with the constraints etc for each one
|
|
||||||
export const MODELS = {
|
export const MODELS = {
|
||||||
CONTACTS: {
|
CONTACTS: {
|
||||||
icon: "ri-link",
|
icon: "ri-contacts-book-line",
|
||||||
name: "Contacts",
|
name: "Contacts",
|
||||||
schema: {
|
schema: {
|
||||||
Name: BLOCKS.NAME,
|
Name: BLOCKS.NAME,
|
||||||
|
@ -170,7 +160,21 @@ export const MODELS = {
|
||||||
name: "Recipes",
|
name: "Recipes",
|
||||||
schema: {
|
schema: {
|
||||||
Name: BLOCKS.NAME,
|
Name: BLOCKS.NAME,
|
||||||
"Phone Number": BLOCKS.PHONE_NUMBER,
|
Cuisine: {
|
||||||
|
...FIELDS.PLAIN_TEXT,
|
||||||
|
name: "Cuisine"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SPORTS_TEAM: {
|
||||||
|
icon: "ri-basketball-line",
|
||||||
|
name: "Sports Team",
|
||||||
|
schema: {
|
||||||
|
Name: BLOCKS.NAME,
|
||||||
|
Championships: {
|
||||||
|
...FIELDS.NUMBER,
|
||||||
|
name: "Championships"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ exports.save = async function(ctx) {
|
||||||
// create the link field in the other model
|
// create the link field in the other model
|
||||||
const linkedModel = await db.get(schema[key].modelId)
|
const linkedModel = await db.get(schema[key].modelId)
|
||||||
linkedModel.schema[modelToSave.name] = {
|
linkedModel.schema[modelToSave.name] = {
|
||||||
|
name: modelToSave.name,
|
||||||
type: "link",
|
type: "link",
|
||||||
modelId: modelToSave._id,
|
modelId: modelToSave._id,
|
||||||
constraints: {
|
constraints: {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const CouchDB = require("../../db")
|
const CouchDB = require("../../db")
|
||||||
const validateJs = require("validate.js")
|
const validateJs = require("validate.js")
|
||||||
const newid = require("../../db/newid")
|
const newid = require("../../db/newid")
|
||||||
|
const { link } = require("pouchdb-adapter-memory")
|
||||||
|
|
||||||
exports.save = async function(ctx) {
|
exports.save = async function(ctx) {
|
||||||
const db = new CouchDB(ctx.params.instanceId)
|
const db = new CouchDB(ctx.params.instanceId)
|
||||||
|
@ -43,6 +44,28 @@ exports.save = async function(ctx) {
|
||||||
const response = await db.post(record)
|
const response = await db.post(record)
|
||||||
record._rev = response.rev
|
record._rev = response.rev
|
||||||
|
|
||||||
|
// create links in other tables
|
||||||
|
for (let key in record) {
|
||||||
|
// link
|
||||||
|
if (Array.isArray(record[key])) {
|
||||||
|
const linked = await db.allDocs({
|
||||||
|
include_docs: true,
|
||||||
|
keys: record[key],
|
||||||
|
})
|
||||||
|
|
||||||
|
// add this record to the linked records in attached models
|
||||||
|
const linkedDocs = linked.rows.map(row => {
|
||||||
|
const doc = row.doc
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
[model.name]: doc[model.name] ? [...doc[model.name], record._id] : [record._id]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.bulkDocs(linkedDocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.eventEmitter &&
|
ctx.eventEmitter &&
|
||||||
ctx.eventEmitter.emit(`record:save`, {
|
ctx.eventEmitter.emit(`record:save`, {
|
||||||
record,
|
record,
|
||||||
|
|
Loading…
Reference in New Issue