530 lines
13 KiB
Svelte
530 lines
13 KiB
Svelte
<script context="module">
|
|
// We can create a module level cache for all relationship cells to avoid
|
|
// having to fetch the table definition one time for each cell
|
|
let primaryDisplayCache = {}
|
|
|
|
const getPrimaryDisplayForTableId = async (API, tableId) => {
|
|
if (primaryDisplayCache[tableId]) {
|
|
return primaryDisplayCache[tableId]
|
|
}
|
|
const definition = await API.fetchTableDefinition(tableId)
|
|
const primaryDisplay =
|
|
definition?.primaryDisplay || definition?.schema?.[0]?.name
|
|
primaryDisplayCache[tableId] = primaryDisplay
|
|
return primaryDisplay
|
|
}
|
|
</script>
|
|
|
|
<script>
|
|
import { getColor } from "../lib/utils"
|
|
import { onMount, getContext } from "svelte"
|
|
import { Icon, Input, ProgressCircle, clickOutside } from "@budibase/bbui"
|
|
import { debounce } from "../../../utils/utils"
|
|
|
|
export let value
|
|
export let api
|
|
export let readonly
|
|
export let focused
|
|
export let schema
|
|
export let onChange
|
|
export let invertX = false
|
|
export let invertY = false
|
|
export let contentLines = 1
|
|
|
|
const { API, dispatch } = getContext("grid")
|
|
const color = getColor(0)
|
|
|
|
let isOpen = false
|
|
let searchResults
|
|
let searchString
|
|
let lastSearchString
|
|
let primaryDisplay
|
|
let candidateIndex
|
|
let lastSearchId
|
|
let searching = false
|
|
let valuesHeight = 0
|
|
let container
|
|
|
|
$: oneRowOnly = schema?.relationshipType === "one-to-many"
|
|
$: editable = focused && !readonly
|
|
$: lookupMap = buildLookupMap(value, isOpen)
|
|
$: debouncedSearch(searchString)
|
|
$: {
|
|
if (!focused) {
|
|
close()
|
|
}
|
|
}
|
|
|
|
// Builds a lookup map to quickly check which rows are selected
|
|
const buildLookupMap = (value, isOpen) => {
|
|
let map = {}
|
|
if (!isOpen || !value?.length) {
|
|
return map
|
|
}
|
|
for (let i = 0; i < value.length; i++) {
|
|
map[value[i]._id] = true
|
|
}
|
|
return map
|
|
}
|
|
|
|
// Checks if a certain row is currently selected
|
|
const isRowSelected = row => {
|
|
if (!row?._id) {
|
|
return false
|
|
}
|
|
return lookupMap?.[row._id] === true
|
|
}
|
|
|
|
// Search for rows based on the search string
|
|
const search = async (searchString, force = false) => {
|
|
// Avoid update state at all if we've already handled the update and this is
|
|
// a wasted search due to svelte reactivity
|
|
if (!force && !searchString && !lastSearchString) {
|
|
return
|
|
}
|
|
|
|
// Reset state if this search is invalid
|
|
if (!schema?.tableId || !isOpen) {
|
|
lastSearchString = null
|
|
candidateIndex = null
|
|
searchResults = []
|
|
return
|
|
}
|
|
|
|
// Search for results, using IDs to track invocations and ensure we're
|
|
// handling the latest update
|
|
lastSearchId = Math.random()
|
|
searching = true
|
|
const thisSearchId = lastSearchId
|
|
const results = await API.searchTable({
|
|
paginate: false,
|
|
tableId: schema.tableId,
|
|
limit: 20,
|
|
query: {
|
|
string: {
|
|
[`1:${primaryDisplay}`]: searchString || "",
|
|
},
|
|
},
|
|
})
|
|
searching = false
|
|
|
|
// In case searching takes longer than our debounced update, abandon these
|
|
// results
|
|
if (thisSearchId !== lastSearchId) {
|
|
return
|
|
}
|
|
|
|
// Sort and process results
|
|
searchResults = sortRows(
|
|
results.rows?.map(row => ({
|
|
...row,
|
|
primaryDisplay: row[primaryDisplay],
|
|
}))
|
|
)
|
|
candidateIndex = searchResults?.length ? 0 : null
|
|
lastSearchString = searchString
|
|
}
|
|
|
|
// Debounced version of searching
|
|
const debouncedSearch = debounce(search, 250)
|
|
|
|
// Alphabetically sorts rows by their primary display column
|
|
const sortRows = rows => {
|
|
if (!rows?.length) {
|
|
return []
|
|
}
|
|
return rows.slice().sort((a, b) => {
|
|
return a.primaryDisplay < b.primaryDisplay ? -1 : 1
|
|
})
|
|
}
|
|
|
|
const open = async () => {
|
|
isOpen = true
|
|
valuesHeight = container.getBoundingClientRect().height
|
|
|
|
// Find the primary display for the related table
|
|
if (!primaryDisplay) {
|
|
searching = true
|
|
primaryDisplay = await getPrimaryDisplayForTableId(API, schema.tableId)
|
|
}
|
|
|
|
// Show initial list of results
|
|
await search(null, true)
|
|
}
|
|
|
|
const close = () => {
|
|
isOpen = false
|
|
searchResults = []
|
|
searchString = null
|
|
lastSearchString = null
|
|
candidateIndex = null
|
|
}
|
|
|
|
const onKeyDown = e => {
|
|
if (!isOpen) {
|
|
return false
|
|
}
|
|
if (e.key === "ArrowDown") {
|
|
// Select next result on down arrow
|
|
e.preventDefault()
|
|
if (candidateIndex == null) {
|
|
candidateIndex = 0
|
|
} else {
|
|
candidateIndex = Math.min(searchResults.length - 1, candidateIndex + 1)
|
|
}
|
|
} else if (e.key === "ArrowUp") {
|
|
// Select previous result on up array
|
|
e.preventDefault()
|
|
if (candidateIndex === 0) {
|
|
candidateIndex = null
|
|
} else if (candidateIndex != null) {
|
|
candidateIndex = Math.max(0, candidateIndex - 1)
|
|
}
|
|
} else if (e.key === "Enter") {
|
|
// Toggle the highlighted result on enter press
|
|
if (candidateIndex != null && searchResults[candidateIndex] != null) {
|
|
toggleRow(searchResults[candidateIndex])
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Toggles whether a row is included in the relationship or not
|
|
const toggleRow = async row => {
|
|
if (value?.some(x => x._id === row._id)) {
|
|
// If the row is already included, remove it and update the candidate
|
|
// row to be the the same position if possible
|
|
if (oneRowOnly) {
|
|
await onChange([])
|
|
} else {
|
|
const newValue = value.filter(x => x._id !== row._id)
|
|
if (!newValue.length) {
|
|
candidateIndex = null
|
|
} else {
|
|
candidateIndex = Math.min(candidateIndex, newValue.length - 1)
|
|
}
|
|
await onChange(newValue)
|
|
}
|
|
} else {
|
|
// If we don't have this row, include it
|
|
if (oneRowOnly) {
|
|
await onChange([row])
|
|
} else {
|
|
await onChange(sortRows([...(value || []), row]))
|
|
}
|
|
candidateIndex = null
|
|
}
|
|
close()
|
|
}
|
|
|
|
const showRelationship = async id => {
|
|
const relatedRow = await API.fetchRow({
|
|
tableId: schema.tableId,
|
|
rowId: id,
|
|
})
|
|
dispatch("edit-row", relatedRow)
|
|
}
|
|
|
|
const readable = value => {
|
|
if (value == null) {
|
|
return ""
|
|
}
|
|
if (value instanceof Object) {
|
|
return JSON.stringify(value)
|
|
}
|
|
return value
|
|
}
|
|
|
|
onMount(() => {
|
|
api = {
|
|
focus: open,
|
|
blur: close,
|
|
isActive: () => isOpen,
|
|
onKeyDown,
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<div
|
|
class="wrapper"
|
|
class:editable
|
|
class:focused
|
|
class:invertY
|
|
style="--color:{color};"
|
|
>
|
|
<div class="container" bind:this={container}>
|
|
<div
|
|
class="values"
|
|
class:wrap={editable || contentLines > 1}
|
|
on:wheel={e => (focused ? e.stopPropagation() : null)}
|
|
>
|
|
{#each value || [] as relationship}
|
|
{#if relationship.primaryDisplay}
|
|
<div class="badge">
|
|
<span
|
|
on:click={editable
|
|
? () => showRelationship(relationship._id)
|
|
: null}
|
|
>
|
|
{readable(relationship.primaryDisplay)}
|
|
</span>
|
|
{#if editable}
|
|
<Icon
|
|
name="Close"
|
|
size="XS"
|
|
hoverable
|
|
on:click={() => toggleRow(relationship)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{#if editable}
|
|
<div class="add" on:click={open}>
|
|
<Icon name="Add" size="S" />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if value?.length}
|
|
<div class="count">
|
|
{value?.length || 0}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isOpen}
|
|
<div
|
|
class="dropdown"
|
|
class:invertX
|
|
class:invertY
|
|
on:wheel|stopPropagation
|
|
use:clickOutside={close}
|
|
style="--values-height:{valuesHeight}px;"
|
|
>
|
|
<div class="search">
|
|
<Input
|
|
autofocus
|
|
quiet
|
|
type="text"
|
|
bind:value={searchString}
|
|
placeholder={primaryDisplay ? `Search by ${primaryDisplay}` : null}
|
|
/>
|
|
</div>
|
|
{#if searching}
|
|
<div class="searching">
|
|
<ProgressCircle size="S" />
|
|
</div>
|
|
{:else if searchResults?.length}
|
|
<div class="results">
|
|
{#each searchResults as row, idx}
|
|
<div
|
|
class="result"
|
|
on:click={() => toggleRow(row)}
|
|
class:candidate={idx === candidateIndex}
|
|
on:mouseenter={() => (candidateIndex = idx)}
|
|
>
|
|
<div class="badge">
|
|
<span>
|
|
{readable(row.primaryDisplay)}
|
|
</span>
|
|
</div>
|
|
{#if isRowSelected(row)}
|
|
<Icon size="S" name="Checkmark" color="var(--accent-color)" />
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.wrapper {
|
|
flex: 1 1 auto;
|
|
align-self: flex-start;
|
|
min-height: var(--row-height);
|
|
max-height: var(--row-height);
|
|
overflow: hidden;
|
|
--max-relationship-height: 96px;
|
|
}
|
|
.wrapper.focused {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
background: var(--cell-background);
|
|
z-index: 1;
|
|
max-height: none;
|
|
overflow: visible;
|
|
}
|
|
.wrapper.invertY {
|
|
top: auto;
|
|
bottom: 0;
|
|
}
|
|
|
|
.container {
|
|
min-height: var(--row-height);
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
}
|
|
.focused .container {
|
|
overflow-y: auto;
|
|
border-radius: 2px;
|
|
max-height: var(--max-relationship-height);
|
|
}
|
|
.focused .container:after {
|
|
content: " ";
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100%;
|
|
width: 100%;
|
|
border: 2px solid var(--cell-color);
|
|
pointer-events: none;
|
|
border-radius: 2px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.values {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: flex-start;
|
|
align-items: flex-start;
|
|
flex: 1 1 auto;
|
|
grid-column-gap: var(--cell-spacing);
|
|
grid-row-gap: var(--cell-padding);
|
|
overflow: hidden;
|
|
padding: var(--cell-padding);
|
|
flex-wrap: nowrap;
|
|
}
|
|
.values.wrap {
|
|
flex-wrap: wrap;
|
|
}
|
|
.count {
|
|
display: none;
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
color: var(--spectrum-global-color-gray-500);
|
|
padding: var(--cell-padding) var(--cell-padding) var(--cell-padding) 20px;
|
|
background: linear-gradient(
|
|
to right,
|
|
transparent 0%,
|
|
var(--cell-background) 40%
|
|
);
|
|
}
|
|
.wrapper:hover:not(.focused) .count {
|
|
display: block;
|
|
}
|
|
.badge {
|
|
flex: 0 0 auto;
|
|
padding: 0 var(--cell-padding);
|
|
background: var(--color);
|
|
border-radius: var(--cell-padding);
|
|
user-select: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--cell-spacing);
|
|
height: 20px;
|
|
max-width: 100%;
|
|
}
|
|
.badge span {
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.editable .values .badge span:hover {
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.add {
|
|
background: var(--spectrum-global-color-gray-200);
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
}
|
|
.add :global(.spectrum-Icon) {
|
|
width: 12px;
|
|
height: 12px;
|
|
}
|
|
.add:hover {
|
|
background: var(--spectrum-global-color-gray-300);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.dropdown {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
width: 100%;
|
|
max-height: calc(
|
|
var(--max-cell-render-height) + var(--row-height) - var(--values-height)
|
|
);
|
|
background: var(--grid-background-alt);
|
|
border: var(--cell-border);
|
|
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
padding: 0 0 8px 0;
|
|
border-bottom-left-radius: 2px;
|
|
border-bottom-right-radius: 2px;
|
|
}
|
|
.dropdown.invertY {
|
|
transform: translateY(-100%);
|
|
top: -1px;
|
|
}
|
|
.dropdown.invertX {
|
|
left: auto;
|
|
right: 0;
|
|
}
|
|
|
|
.searching {
|
|
padding: var(--cell-padding);
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
.results {
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
.result {
|
|
padding: 0 var(--cell-padding);
|
|
flex: 0 0 var(--default-row-height);
|
|
display: flex;
|
|
gap: var(--cell-spacing);
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.result.candidate {
|
|
background-color: var(--spectrum-global-color-gray-200);
|
|
cursor: pointer;
|
|
}
|
|
.result .badge {
|
|
max-width: calc(100% - 30px);
|
|
}
|
|
|
|
.search {
|
|
flex: 0 0 calc(var(--default-row-height) - 1px);
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 4px var(--cell-padding);
|
|
width: calc(100% - 2 * var(--cell-padding));
|
|
}
|
|
.search :global(.spectrum-Textfield) {
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
.search :global(.spectrum-Textfield-input) {
|
|
font-size: 13px;
|
|
}
|
|
.search :global(.spectrum-Form-item) {
|
|
flex: 1 1 auto;
|
|
}
|
|
</style>
|