1
0
Fork 0
mirror of synced 2024-05-17 02:42:53 +12:00
budibase/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte
2023-07-05 18:00:50 +01:00

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>