Merge branch 'master' into fix/button-icons
This commit is contained in:
commit
65340c657e
|
@ -74,8 +74,8 @@
|
|||
"build:docker:single:multiarch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/single/Dockerfile -t budibase:latest .",
|
||||
"build:docker:single": "./scripts/build-single-image.sh",
|
||||
"build:docker:dependencies": "docker build -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest ./hosting",
|
||||
"publish:docker:couch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile -t budibase/couchdb:latest -t budibase/couchdb:v3.2.1 --push ./hosting/couchdb",
|
||||
"publish:docker:couch-sqs": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile.v2 -t budibase/couchdb:v3.2.1-sqs --push ./hosting/couchdb",
|
||||
"publish:docker:couch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile -t budibase/couchdb:latest -t budibase/couchdb:v3.3.3 --push ./hosting/couchdb",
|
||||
"publish:docker:couch-sqs": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile.v2 -t budibase/couchdb:v3.3.3-sqs --push ./hosting/couchdb",
|
||||
"publish:docker:dependencies": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest -t budibase/dependencies:v3.2.1 --push ./hosting",
|
||||
"release:helm": "node scripts/releaseHelmChart",
|
||||
"env:multi:enable": "lerna run --stream env:multi:enable",
|
||||
|
|
|
@ -157,7 +157,7 @@
|
|||
useAnchorWidth={!autoWidth}
|
||||
maxWidth={autoWidth ? 400 : null}
|
||||
customHeight={customPopoverHeight}
|
||||
maxHeight={240}
|
||||
maxHeight={360}
|
||||
>
|
||||
<div
|
||||
class="popover-content"
|
||||
|
|
|
@ -1,252 +0,0 @@
|
|||
<script>
|
||||
import { flip } from "svelte/animate"
|
||||
import { dndzone } from "svelte-dnd-action"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import Popover from "../Popover/Popover.svelte"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
const flipDurationMs = 150
|
||||
|
||||
export let constraints
|
||||
export let optionColors = {}
|
||||
let options = []
|
||||
|
||||
let colorPopovers = []
|
||||
let anchors = []
|
||||
|
||||
let colorsArray = [
|
||||
"hsla(0, 90%, 75%, 0.3)",
|
||||
"hsla(50, 80%, 75%, 0.3)",
|
||||
"hsla(120, 90%, 75%, 0.3)",
|
||||
"hsla(200, 90%, 75%, 0.3)",
|
||||
"hsla(240, 90%, 75%, 0.3)",
|
||||
"hsla(320, 90%, 75%, 0.3)",
|
||||
]
|
||||
const removeInput = idx => {
|
||||
delete optionColors[options[idx].name]
|
||||
constraints.inclusion = constraints.inclusion.filter((e, i) => i !== idx)
|
||||
options = options.filter((e, i) => i !== idx)
|
||||
colorPopovers.pop(undefined)
|
||||
anchors.pop(undefined)
|
||||
}
|
||||
|
||||
const addNewInput = () => {
|
||||
options = [
|
||||
...options,
|
||||
{ name: `Option ${constraints.inclusion.length + 1}`, id: Math.random() },
|
||||
]
|
||||
constraints.inclusion = [
|
||||
...constraints.inclusion,
|
||||
`Option ${constraints.inclusion.length + 1}`,
|
||||
]
|
||||
|
||||
colorPopovers.push(undefined)
|
||||
anchors.push(undefined)
|
||||
}
|
||||
|
||||
const handleDndConsider = e => {
|
||||
options = e.detail.items
|
||||
}
|
||||
const handleDndFinalize = e => {
|
||||
options = e.detail.items
|
||||
constraints.inclusion = options.map(option => option.name)
|
||||
}
|
||||
|
||||
const handleColorChange = (optionName, color, idx) => {
|
||||
optionColors[optionName] = color
|
||||
colorPopovers[idx].hide()
|
||||
}
|
||||
|
||||
const handleNameChange = (optionName, idx, value) => {
|
||||
constraints.inclusion[idx] = value
|
||||
options[idx].name = value
|
||||
optionColors[value] = optionColors[optionName]
|
||||
delete optionColors[optionName]
|
||||
}
|
||||
|
||||
const openColorPickerPopover = (optionIdx, target) => {
|
||||
colorPopovers[optionIdx].show()
|
||||
anchors[optionIdx] = target
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Initialize anchor arrays on mount, assuming 'options' is already populated
|
||||
colorPopovers = constraints.inclusion.map(() => undefined)
|
||||
anchors = constraints.inclusion.map(() => undefined)
|
||||
|
||||
options = constraints.inclusion.map(value => ({
|
||||
name: value,
|
||||
id: Math.random(),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div>
|
||||
<div
|
||||
class="actions"
|
||||
use:dndzone={{
|
||||
items: options,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: { outline: "none" },
|
||||
}}
|
||||
on:consider={handleDndConsider}
|
||||
on:finalize={handleDndFinalize}
|
||||
>
|
||||
{#each options as option, idx (option.id)}
|
||||
<div
|
||||
class="no-border action-container"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
<div class="child drag-handle-spacing">
|
||||
<Icon name="DragHandle" size="L" />
|
||||
</div>
|
||||
<div class="child color-picker">
|
||||
<div
|
||||
id="color-picker"
|
||||
bind:this={anchors[idx]}
|
||||
style="--color:{optionColors?.[option.name] ||
|
||||
'hsla(0, 1%, 50%, 0.3)'}"
|
||||
class="circle"
|
||||
on:click={e => openColorPickerPopover(idx, e.target)}
|
||||
>
|
||||
<Popover
|
||||
bind:this={colorPopovers[idx]}
|
||||
anchor={anchors[idx]}
|
||||
align="left"
|
||||
offset={0}
|
||||
style=""
|
||||
popoverTarget={document.getElementById(`color-picker`)}
|
||||
animate={false}
|
||||
>
|
||||
<div class="colors">
|
||||
{#each colorsArray as color}
|
||||
<div
|
||||
on:click={() => handleColorChange(option.name, color, idx)}
|
||||
style="--color:{color};"
|
||||
class="circle circle-hover"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="child">
|
||||
<input
|
||||
class="input-field"
|
||||
type="text"
|
||||
on:change={e => handleNameChange(option.name, idx, e.target.value)}
|
||||
value={option.name}
|
||||
placeholder="Option name"
|
||||
/>
|
||||
</div>
|
||||
<div class="child">
|
||||
<Icon name="Close" hoverable size="S" on:click={removeInput(idx)} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div on:click={addNewInput} class="add-option">
|
||||
<Icon hoverable name="Add" />
|
||||
<div>Add option</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.action-container {
|
||||
background-color: var(--spectrum-alias-background-color-primary);
|
||||
border-radius: 0px;
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
|
||||
border-color 130ms ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.no-border {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.action-container:last-child {
|
||||
border-bottom: 1px solid var(--spectrum-global-color-gray-300) !important;
|
||||
}
|
||||
|
||||
.child {
|
||||
height: 30px;
|
||||
}
|
||||
.child:hover,
|
||||
.child:focus {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
}
|
||||
.add-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: var(--spacing-m);
|
||||
gap: var(--spacing-m);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.child input[type="text"] {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.input-field:hover,
|
||||
.input-field:focus {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
}
|
||||
|
||||
.action-container > :nth-child(1) {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.action-container > :nth-child(2) {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-container > :nth-child(3) {
|
||||
flex-grow: 4;
|
||||
display: flex;
|
||||
}
|
||||
.action-container > :nth-child(4) {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.circle {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background-color: var(--color);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.circle-hover:hover {
|
||||
border: 1px solid var(--spectrum-global-color-blue-400);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.colors {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
justify-items: center;
|
||||
margin: var(--spacing-m);
|
||||
}
|
||||
</style>
|
|
@ -89,7 +89,6 @@ export { default as ListItem } from "./List/ListItem.svelte"
|
|||
export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
|
||||
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
|
||||
export { default as Accordion } from "./Accordion/Accordion.svelte"
|
||||
export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte"
|
||||
export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
|
||||
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"
|
||||
|
||||
|
|
|
@ -92,7 +92,6 @@
|
|||
/>
|
||||
{:else if type === "attachment"}
|
||||
<Dropzone
|
||||
compact
|
||||
{label}
|
||||
{error}
|
||||
{value}
|
||||
|
@ -102,7 +101,6 @@
|
|||
/>
|
||||
{:else if type === "attachment_single"}
|
||||
<Dropzone
|
||||
compact
|
||||
{label}
|
||||
{error}
|
||||
value={value ? [value] : []}
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
DatePicker,
|
||||
Modal,
|
||||
notifications,
|
||||
OptionSelectDnD,
|
||||
Layout,
|
||||
AbsTooltip,
|
||||
ProgressCircle,
|
||||
|
@ -42,6 +41,7 @@
|
|||
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
|
||||
import { RowUtils } from "@budibase/frontend-core"
|
||||
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
||||
import OptionsEditor from "./OptionsEditor.svelte"
|
||||
|
||||
const AUTO_TYPE = FieldType.AUTO
|
||||
const FORMULA_TYPE = FieldType.FORMULA
|
||||
|
@ -95,6 +95,7 @@
|
|||
},
|
||||
}
|
||||
let autoColumnInfo = getAutoColumnInformation()
|
||||
let optionsValid = true
|
||||
|
||||
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
|
||||
$: if (primaryDisplay) {
|
||||
|
@ -138,7 +139,8 @@
|
|||
$: invalid =
|
||||
!editableColumn?.name ||
|
||||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
|
||||
Object.keys(errors).length !== 0
|
||||
Object.keys(errors).length !== 0 ||
|
||||
!optionsValid
|
||||
$: errors = checkErrors(editableColumn)
|
||||
$: datasource = $datasources.list.find(
|
||||
source => source._id === table?.sourceId
|
||||
|
@ -559,9 +561,10 @@
|
|||
bind:value={editableColumn.constraints.length.maximum}
|
||||
/>
|
||||
{:else if editableColumn.type === FieldType.OPTIONS}
|
||||
<OptionSelectDnD
|
||||
<OptionsEditor
|
||||
bind:constraints={editableColumn.constraints}
|
||||
bind:optionColors={editableColumn.optionColors}
|
||||
bind:valid={optionsValid}
|
||||
/>
|
||||
{:else if editableColumn.type === FieldType.LONGFORM}
|
||||
<div>
|
||||
|
@ -582,9 +585,10 @@
|
|||
/>
|
||||
</div>
|
||||
{:else if editableColumn.type === FieldType.ARRAY}
|
||||
<OptionSelectDnD
|
||||
<OptionsEditor
|
||||
bind:constraints={editableColumn.constraints}
|
||||
bind:optionColors={editableColumn.optionColors}
|
||||
bind:valid={optionsValid}
|
||||
/>
|
||||
{:else if editableColumn.type === DATE_TYPE && !editableColumn.autocolumn}
|
||||
<div class="split-label">
|
||||
|
|
|
@ -0,0 +1,252 @@
|
|||
<script>
|
||||
import { flip } from "svelte/animate"
|
||||
import { dndzone } from "svelte-dnd-action"
|
||||
import { Icon, Popover } from "@budibase/bbui"
|
||||
import { tick } from "svelte"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { getSequentialName } from "helpers/duplicate"
|
||||
import { derived, writable } from "svelte/store"
|
||||
|
||||
export let constraints
|
||||
export let optionColors = {}
|
||||
export let valid = true
|
||||
|
||||
const flipDurationMs = 130
|
||||
const { OptionColours } = Constants
|
||||
const getDefaultColor = idx => OptionColours[idx % OptionColours.length]
|
||||
const options = writable(
|
||||
constraints.inclusion.map((value, idx) => ({
|
||||
id: Math.random(),
|
||||
name: value,
|
||||
color: optionColors?.[value] || getDefaultColor(idx),
|
||||
invalid: false,
|
||||
}))
|
||||
)
|
||||
const enrichedOptions = derived(options, $options => {
|
||||
let enriched = []
|
||||
$options.forEach(option => {
|
||||
enriched.push({
|
||||
...option,
|
||||
valid: option.name && !enriched.some(opt => opt.name === option.name),
|
||||
})
|
||||
})
|
||||
return enriched
|
||||
})
|
||||
|
||||
let openOption = null
|
||||
let anchor = null
|
||||
|
||||
$: options.subscribe(updateConstraints)
|
||||
$: valid = $enrichedOptions.every(option => option.valid)
|
||||
|
||||
const updateConstraints = options => {
|
||||
constraints.inclusion = options.map(option => option.name)
|
||||
optionColors = options.reduce(
|
||||
(colors, option) => ({ ...colors, [option.name]: option.color }),
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
const addNewInput = async () => {
|
||||
const newId = Math.random()
|
||||
const newName = getSequentialName($options, "Option ", {
|
||||
numberFirstItem: true,
|
||||
getName: option => option.name,
|
||||
})
|
||||
options.update(state => {
|
||||
return [
|
||||
...state,
|
||||
{
|
||||
name: newName,
|
||||
id: newId,
|
||||
color: getDefaultColor(state.length),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// Focus new option
|
||||
await tick()
|
||||
document.getElementById(`option-${newId}`)?.focus()
|
||||
}
|
||||
|
||||
const removeInput = id => {
|
||||
options.update(state => state.filter(option => option.id !== id))
|
||||
}
|
||||
|
||||
const openColorPicker = id => {
|
||||
anchor = document.getElementById(`color-${id}`)
|
||||
openOption = id
|
||||
}
|
||||
|
||||
const handleColorChange = (id, color) => {
|
||||
options.update(state => {
|
||||
state.find(option => option.id === id).color = color
|
||||
return state.slice()
|
||||
})
|
||||
openOption = null
|
||||
}
|
||||
|
||||
const handleNameChange = (id, name) => {
|
||||
options.update(state => {
|
||||
state.find(option => option.id === id).name = name
|
||||
return state.slice()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="wrapper">
|
||||
<div
|
||||
class="options"
|
||||
use:dndzone={{
|
||||
items: $options,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: { outline: "none" },
|
||||
}}
|
||||
on:consider={e => options.set(e.detail.items)}
|
||||
on:finalize={e => options.set(e.detail.items)}
|
||||
>
|
||||
{#each $enrichedOptions as option (option.id)}
|
||||
<div
|
||||
class="option"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class:invalid={!option.valid}
|
||||
>
|
||||
<div class="drag-handle">
|
||||
<Icon name="DragHandle" size="L" />
|
||||
</div>
|
||||
<div
|
||||
id="color-{option.id}"
|
||||
class="color-picker"
|
||||
on:click={() => openColorPicker(option.id)}
|
||||
>
|
||||
<div class="circle" style="--color:{option.color}">
|
||||
<Popover
|
||||
open={openOption === option.id}
|
||||
{anchor}
|
||||
align="left"
|
||||
offset={0}
|
||||
animate={false}
|
||||
resizable={false}
|
||||
>
|
||||
<div class="colors" data-ignore-click-outside="true">
|
||||
{#each OptionColours as colorOption}
|
||||
<div
|
||||
on:click={() => handleColorChange(option.id, colorOption)}
|
||||
style="--color:{colorOption};"
|
||||
class="circle"
|
||||
class:selected={colorOption === option.color}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
class="option-name"
|
||||
type="text"
|
||||
value={option.name}
|
||||
placeholder="Option name"
|
||||
id="option-{option.id}"
|
||||
on:input={e => handleNameChange(option.id, e.target.value)}
|
||||
/>
|
||||
<Icon
|
||||
name="Close"
|
||||
hoverable
|
||||
size="S"
|
||||
on:click={() => removeInput(option.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div on:click={addNewInput} class="add-option">
|
||||
<Icon name="Add" />
|
||||
<div>Add option</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container */
|
||||
.wrapper {
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
}
|
||||
.options > *,
|
||||
.add-option {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Options row */
|
||||
.option {
|
||||
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
|
||||
border-color 130ms ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
|
||||
gap: var(--spacing-m);
|
||||
padding: 0 var(--spacing-m) 0 var(--spacing-s);
|
||||
outline: none !important;
|
||||
}
|
||||
.option.invalid {
|
||||
border: 1px solid var(--spectrum-global-color-red-400);
|
||||
}
|
||||
.option:not(.invalid):hover,
|
||||
.option:not(.invalid):focus {
|
||||
background: var(--spectrum-global-color-gray-100);
|
||||
}
|
||||
|
||||
/* Option row components */
|
||||
.color-picker {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.circle {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background-color: var(--color);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid transparent;
|
||||
transition: border 130ms ease-out;
|
||||
}
|
||||
.circle:hover,
|
||||
.circle.selected {
|
||||
border: 1px solid var(--spectrum-global-color-blue-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
.colors {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
justify-items: center;
|
||||
margin: var(--spacing-m);
|
||||
}
|
||||
.option-name {
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Add option */
|
||||
.add-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--spacing-m);
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.add-option:hover {
|
||||
cursor: pointer !important;
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
}
|
||||
</style>
|
|
@ -309,7 +309,7 @@
|
|||
{#if links?.length}
|
||||
<DataSourceCategory
|
||||
dividerState={true}
|
||||
heading="Links"
|
||||
heading="Relationships"
|
||||
dataSet={links}
|
||||
{value}
|
||||
onSelect={handleSelected}
|
||||
|
|
|
@ -70,13 +70,18 @@ export const duplicateName = (name, allNames) => {
|
|||
* @param getName optional function to extract the name for an item, if not a
|
||||
* flat array of strings
|
||||
*/
|
||||
export const getSequentialName = (items, prefix, getName = x => x) => {
|
||||
export const getSequentialName = (
|
||||
items,
|
||||
prefix,
|
||||
{ getName = x => x, numberFirstItem = false } = {}
|
||||
) => {
|
||||
if (!prefix?.length || !getName) {
|
||||
return null
|
||||
}
|
||||
const trimmedPrefix = prefix.trim()
|
||||
const firstName = numberFirstItem ? `${prefix}1` : trimmedPrefix
|
||||
if (!items?.length) {
|
||||
return trimmedPrefix
|
||||
return firstName
|
||||
}
|
||||
let max = 0
|
||||
items.forEach(item => {
|
||||
|
@ -96,5 +101,5 @@ export const getSequentialName = (items, prefix, getName = x => x) => {
|
|||
max = num
|
||||
}
|
||||
})
|
||||
return max === 0 ? trimmedPrefix : `${prefix}${max + 1}`
|
||||
return max === 0 ? firstName : `${prefix}${max + 1}`
|
||||
}
|
||||
|
|
|
@ -43,61 +43,71 @@ describe("duplicate", () => {
|
|||
|
||||
describe("getSequentialName", () => {
|
||||
it("handles nullish items", async () => {
|
||||
const name = getSequentialName(null, "foo", () => {})
|
||||
const name = getSequentialName(null, "foo")
|
||||
expect(name).toBe("foo")
|
||||
})
|
||||
|
||||
it("handles nullish prefix", async () => {
|
||||
const name = getSequentialName([], null, () => {})
|
||||
expect(name).toBe(null)
|
||||
})
|
||||
|
||||
it("handles nullish getName function", async () => {
|
||||
const name = getSequentialName([], "foo", null)
|
||||
const name = getSequentialName([], null)
|
||||
expect(name).toBe(null)
|
||||
})
|
||||
|
||||
it("handles just the prefix", async () => {
|
||||
const name = getSequentialName(["foo"], "foo", x => x)
|
||||
const name = getSequentialName(["foo"], "foo")
|
||||
expect(name).toBe("foo2")
|
||||
})
|
||||
|
||||
it("handles continuous ranges", async () => {
|
||||
const name = getSequentialName(["foo", "foo2", "foo3"], "foo", x => x)
|
||||
const name = getSequentialName(["foo", "foo2", "foo3"], "foo")
|
||||
expect(name).toBe("foo4")
|
||||
})
|
||||
|
||||
it("handles discontinuous ranges", async () => {
|
||||
const name = getSequentialName(["foo", "foo3"], "foo", x => x)
|
||||
const name = getSequentialName(["foo", "foo3"], "foo")
|
||||
expect(name).toBe("foo4")
|
||||
})
|
||||
|
||||
it("handles a space inside the prefix", async () => {
|
||||
const name = getSequentialName(["foo", "foo 2", "foo 3"], "foo ", x => x)
|
||||
const name = getSequentialName(["foo", "foo 2", "foo 3"], "foo ")
|
||||
expect(name).toBe("foo 4")
|
||||
})
|
||||
|
||||
it("handles a space inside the prefix with just the prefix", async () => {
|
||||
const name = getSequentialName(["foo"], "foo ", x => x)
|
||||
const name = getSequentialName(["foo"], "foo ")
|
||||
expect(name).toBe("foo 2")
|
||||
})
|
||||
|
||||
it("handles no matches", async () => {
|
||||
const name = getSequentialName(["aaa", "bbb"], "foo", x => x)
|
||||
const name = getSequentialName(["aaa", "bbb"], "foo")
|
||||
expect(name).toBe("foo")
|
||||
})
|
||||
|
||||
it("handles similar names", async () => {
|
||||
const name = getSequentialName(
|
||||
["fooo1", "2foo", "a3foo4", "5foo5"],
|
||||
"foo",
|
||||
x => x
|
||||
)
|
||||
const name = getSequentialName(["fooo1", "2foo", "a3foo4", "5foo5"], "foo")
|
||||
expect(name).toBe("foo")
|
||||
})
|
||||
|
||||
it("handles non-string names", async () => {
|
||||
const name = getSequentialName([null, 4123, [], {}], "foo", x => x)
|
||||
const name = getSequentialName([null, 4123, [], {}], "foo")
|
||||
expect(name).toBe("foo")
|
||||
})
|
||||
|
||||
it("handles deep getters", async () => {
|
||||
const name = getSequentialName([{ a: "foo 1" }], "foo ", {
|
||||
getName: x => x.a,
|
||||
})
|
||||
expect(name).toBe("foo 2")
|
||||
})
|
||||
|
||||
it("handles a mixture of spaces and not", async () => {
|
||||
const name = getSequentialName(["foo", "foo 1", "foo 2"], "foo")
|
||||
expect(name).toBe("foo3")
|
||||
})
|
||||
|
||||
it("handles numbering the first item", async () => {
|
||||
const name = getSequentialName(["foo1", "foo2", "foo"], "foo ", {
|
||||
numberFirstItem: true,
|
||||
})
|
||||
expect(name).toBe("foo 3")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -48,7 +48,9 @@
|
|||
...navItems,
|
||||
{
|
||||
id: generate(),
|
||||
text: getSequentialName(navItems, "Nav Item ", x => x.text),
|
||||
text: getSequentialName(navItems, "Nav Item ", {
|
||||
getName: x => x.text,
|
||||
}),
|
||||
url: "",
|
||||
roleId: Constants.Roles.BASIC,
|
||||
type: "link",
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
focusedCellId,
|
||||
filter,
|
||||
inlineFilters,
|
||||
keyboardBlocked,
|
||||
} = getContext("grid")
|
||||
|
||||
const searchableTypes = [
|
||||
|
@ -57,6 +58,8 @@
|
|||
$: searching = searchValue != null
|
||||
$: debouncedUpdateFilter(searchValue)
|
||||
$: orderable = !column.primaryDisplay
|
||||
$: editable = $config.canEditColumns && !column.schema.disabled
|
||||
$: keyboardBlocked.set(open)
|
||||
|
||||
const close = () => {
|
||||
open = false
|
||||
|
@ -231,6 +234,14 @@
|
|||
}
|
||||
const debouncedUpdateFilter = debounce(updateFilter, 250)
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (!editable || searching) {
|
||||
return
|
||||
}
|
||||
open = true
|
||||
editColumn()
|
||||
}
|
||||
|
||||
onMount(() => subscribe("close-edit-column", close))
|
||||
</script>
|
||||
|
||||
|
@ -241,14 +252,15 @@
|
|||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
bind:this={anchor}
|
||||
class="header-cell"
|
||||
style="flex: 0 0 {column.width}px;"
|
||||
class:open
|
||||
class:searchable
|
||||
class:searching
|
||||
style="flex: 0 0 {column.width}px;"
|
||||
bind:this={anchor}
|
||||
class:disabled={$isReordering || $isResizing}
|
||||
class:sticky={idx === "sticky"}
|
||||
on:dblclick={handleDoubleClick}
|
||||
>
|
||||
<GridCell
|
||||
on:mousedown={onMouseDown}
|
||||
|
@ -311,7 +323,7 @@
|
|||
{#if open}
|
||||
<GridPopover
|
||||
{anchor}
|
||||
align="right"
|
||||
align="left"
|
||||
on:close={close}
|
||||
maxHeight={null}
|
||||
resizable
|
||||
|
@ -322,11 +334,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="Edit"
|
||||
on:click={editColumn}
|
||||
disabled={!$config.canEditColumns || column.schema.disabled}
|
||||
>
|
||||
<MenuItem icon="Edit" on:click={editColumn} disabled={!editable}>
|
||||
Edit column
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script>
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { getColor } from "../lib/utils"
|
||||
import { onMount } from "svelte"
|
||||
import GridPopover from "../overlays/GridPopover.svelte"
|
||||
import { OptionColours } from "../../../constants"
|
||||
|
||||
export let value
|
||||
export let schema
|
||||
|
@ -13,6 +13,8 @@
|
|||
export let api
|
||||
export let contentLines = 1
|
||||
|
||||
const InvalidColor = "hsla(0, 0%, 70%, 0.3)"
|
||||
|
||||
let isOpen = false
|
||||
let focusedOptionIdx = null
|
||||
let anchor
|
||||
|
@ -38,8 +40,11 @@
|
|||
}
|
||||
|
||||
const getOptionColor = value => {
|
||||
const index = value ? options.indexOf(value) : null
|
||||
return getColor(index)
|
||||
let idx = value ? options.indexOf(value) : null
|
||||
if (idx == null || idx === -1) {
|
||||
return InvalidColor
|
||||
}
|
||||
return OptionColours[idx % OptionColours.length]
|
||||
}
|
||||
|
||||
const toggleOption = option => {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<script>
|
||||
import { getColor } from "../lib/utils"
|
||||
import { onMount, getContext } from "svelte"
|
||||
import { Icon, Input, ProgressCircle } from "@budibase/bbui"
|
||||
import { debounce } from "../../../utils/utils"
|
||||
import GridPopover from "../overlays/GridPopover.svelte"
|
||||
import { OptionColours } from "../../../constants"
|
||||
|
||||
const { API, cache } = getContext("grid")
|
||||
|
||||
|
@ -18,7 +18,7 @@
|
|||
export let primaryDisplay
|
||||
export let hideCounter = false
|
||||
|
||||
const color = getColor(0)
|
||||
const color = OptionColours[0]
|
||||
|
||||
let isOpen = false
|
||||
let searchResults
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
import { Icon } from "@budibase/bbui"
|
||||
import GridPopover from "../overlays/GridPopover.svelte"
|
||||
|
||||
const { visibleColumns, scroll, width, subscribe, ui } = getContext("grid")
|
||||
const { visibleColumns, scroll, width, subscribe, ui, keyboardBlocked } =
|
||||
getContext("grid")
|
||||
|
||||
let anchor
|
||||
let isOpen = false
|
||||
|
@ -14,6 +15,7 @@
|
|||
)
|
||||
$: end = columnsWidth - 1 - $scroll.left
|
||||
$: left = Math.min($width - 40, end)
|
||||
$: keyboardBlocked.set(isOpen)
|
||||
|
||||
const open = () => {
|
||||
ui.actions.blur()
|
||||
|
|
|
@ -209,7 +209,7 @@
|
|||
<GridScrollWrapper scrollHorizontally attachHandlers>
|
||||
<div class="row">
|
||||
{#each $visibleColumns as column}
|
||||
{@const cellId = `new-${column.name}`}
|
||||
{@const cellId = getCellID(NewRowID, column.name)}
|
||||
<DataCell
|
||||
{cellId}
|
||||
{column}
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}>
|
||||
<div class="content">
|
||||
<GridScrollWrapper scrollVertically attachHandlers>
|
||||
{#each $renderedRows as row, idx}
|
||||
{@const rowSelected = !!$selectedRows[row._id]}
|
||||
|
|
|
@ -18,13 +18,6 @@ export const getCellID = (rowId, fieldName) => {
|
|||
return `${rowId}${JOINING_CHARACTER}${fieldName}`
|
||||
}
|
||||
|
||||
export const getColor = (idx, opacity = 0.3) => {
|
||||
if (idx == null || idx === -1) {
|
||||
idx = 0
|
||||
}
|
||||
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, ${opacity})`
|
||||
}
|
||||
|
||||
export const getColumnIcon = column => {
|
||||
if (column.schema.autocolumn) {
|
||||
return "MagicWand"
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
config,
|
||||
menu,
|
||||
gridFocused,
|
||||
keyboardBlocked,
|
||||
} = getContext("grid")
|
||||
|
||||
const ignoredOriginSelectors = [
|
||||
|
@ -29,7 +30,7 @@
|
|||
// Global key listener which intercepts all key events
|
||||
const handleKeyDown = e => {
|
||||
// Ignore completely if the grid is not focused
|
||||
if (!$gridFocused) {
|
||||
if (!$gridFocused || $keyboardBlocked) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ export const createStores = context => {
|
|||
const previousFocusedRowId = writable(null)
|
||||
const previousFocusedCellId = writable(null)
|
||||
const gridFocused = writable(false)
|
||||
const keyboardBlocked = writable(false)
|
||||
const isDragging = writable(false)
|
||||
const buttonColumnWidth = writable(0)
|
||||
|
||||
|
@ -54,6 +55,7 @@ export const createStores = context => {
|
|||
hoveredRowId,
|
||||
rowHeight,
|
||||
gridFocused,
|
||||
keyboardBlocked,
|
||||
isDragging,
|
||||
buttonColumnWidth,
|
||||
selectedRows: {
|
||||
|
|
|
@ -141,3 +141,7 @@ export const TypeIconMap = {
|
|||
[BBReferenceFieldSubType.USER]: "User",
|
||||
},
|
||||
}
|
||||
|
||||
export const OptionColours = [...new Array(12).keys()].map(idx => {
|
||||
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, 0.3)`
|
||||
})
|
||||
|
|
|
@ -64,13 +64,11 @@ describe("rest", () => {
|
|||
cached = await getCachedVariable(basedOnQuery._id!, "foo")
|
||||
expect(cached).toBeNull()
|
||||
|
||||
nock("http://one.example.com")
|
||||
.get("/")
|
||||
.reply(200, [{ name: "one" }])
|
||||
const body1 = [{ name: "one" }]
|
||||
const body2 = [{ name: "two" }]
|
||||
nock("http://one.example.com").get("/").reply(200, body1)
|
||||
nock("http://two.example.com").get("/?test=one").reply(500)
|
||||
nock("http://two.example.com")
|
||||
.get("/?test=one")
|
||||
.reply(200, [{ name: "two" }])
|
||||
nock("http://two.example.com").get("/?test=one").reply(200, body2)
|
||||
|
||||
const res = await config.api.query.preview({
|
||||
datasourceId: datasource._id!,
|
||||
|
|
|
@ -148,6 +148,10 @@ class RestIntegration implements IntegrationBase {
|
|||
response.headers,
|
||||
{ downloadImages: this.config.downloadImages }
|
||||
)
|
||||
let contentLength = response.headers.get("content-length")
|
||||
if (!contentLength && raw) {
|
||||
contentLength = Buffer.byteLength(raw, "utf8").toString()
|
||||
}
|
||||
if (
|
||||
contentDisposition.includes("filename") ||
|
||||
contentDisposition.includes("attachment") ||
|
||||
|
@ -156,36 +160,46 @@ class RestIntegration implements IntegrationBase {
|
|||
filename =
|
||||
path.basename(parse(contentDisposition).parameters?.filename) || ""
|
||||
}
|
||||
|
||||
let triedParsing: boolean = false,
|
||||
responseTxt: string | undefined
|
||||
try {
|
||||
if (filename) {
|
||||
return handleFileResponse(response, filename, this.startTimeMs)
|
||||
} else {
|
||||
responseTxt = response.text ? await response.text() : ""
|
||||
const hasContent =
|
||||
(contentLength && parseInt(contentLength) > 0) ||
|
||||
responseTxt.length > 0
|
||||
if (response.status === 204) {
|
||||
data = []
|
||||
raw = ""
|
||||
} else if (contentType.includes("application/json")) {
|
||||
data = await response.json()
|
||||
raw = JSON.stringify(data)
|
||||
} else if (hasContent && contentType.includes("application/json")) {
|
||||
triedParsing = true
|
||||
data = JSON.parse(responseTxt)
|
||||
raw = responseTxt
|
||||
} else if (
|
||||
contentType.includes("text/xml") ||
|
||||
(hasContent && contentType.includes("text/xml")) ||
|
||||
contentType.includes("application/xml")
|
||||
) {
|
||||
let xmlResponse = await handleXml(response)
|
||||
triedParsing = true
|
||||
let xmlResponse = await handleXml(responseTxt)
|
||||
data = xmlResponse.data
|
||||
raw = xmlResponse.rawXml
|
||||
} else {
|
||||
data = await response.text()
|
||||
data = responseTxt
|
||||
raw = data as string
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw `Failed to parse response body: ${err}`
|
||||
if (triedParsing) {
|
||||
data = responseTxt
|
||||
raw = data as string
|
||||
} else {
|
||||
throw new Error(`Failed to parse response body: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
let contentLength = response.headers.get("content-length")
|
||||
if (!contentLength && raw) {
|
||||
contentLength = Buffer.byteLength(raw, "utf8").toString()
|
||||
}
|
||||
const size = formatBytes(contentLength || "0")
|
||||
const time = `${Math.round(performance.now() - this.startTimeMs)}ms`
|
||||
headers = response.headers.raw()
|
||||
|
|
|
@ -1,19 +1,27 @@
|
|||
jest.mock("node-fetch", () => {
|
||||
const obj = {
|
||||
my_next_cursor: 123,
|
||||
}
|
||||
const str = JSON.stringify(obj)
|
||||
return jest.fn(() => ({
|
||||
headers: {
|
||||
raw: () => {
|
||||
return { "content-type": ["application/json"] }
|
||||
return {
|
||||
"content-type": ["application/json"],
|
||||
"content-length": str.length,
|
||||
}
|
||||
},
|
||||
get: (name: string) => {
|
||||
if (name.toLowerCase() === "content-type") {
|
||||
const lcName = name.toLowerCase()
|
||||
if (lcName === "content-type") {
|
||||
return ["application/json"]
|
||||
} else if (lcName === "content-length") {
|
||||
return str.length
|
||||
}
|
||||
},
|
||||
},
|
||||
json: jest.fn(() => ({
|
||||
my_next_cursor: 123,
|
||||
})),
|
||||
text: jest.fn(),
|
||||
json: jest.fn(() => obj),
|
||||
text: jest.fn(() => str),
|
||||
}))
|
||||
})
|
||||
|
||||
|
@ -231,7 +239,8 @@ describe("REST Integration", () => {
|
|||
}
|
||||
|
||||
it("should be able to parse JSON response", async () => {
|
||||
const input = buildInput({ a: 1 }, null, "application/json")
|
||||
const obj = { a: 1 }
|
||||
const input = buildInput(obj, JSON.stringify(obj), "application/json")
|
||||
const output = await config.integration.parseResponse(input)
|
||||
expect(output.data).toEqual({ a: 1 })
|
||||
expect(output.info.code).toEqual(200)
|
||||
|
@ -261,7 +270,7 @@ describe("REST Integration", () => {
|
|||
test.each([...contentTypes, undefined])(
|
||||
"should not throw an error on 204 no content",
|
||||
async contentType => {
|
||||
const input = buildInput(undefined, null, contentType, 204)
|
||||
const input = buildInput(undefined, "", contentType, 204)
|
||||
const output = await config.integration.parseResponse(input)
|
||||
expect(output.data).toEqual([])
|
||||
expect(output.extra.raw).toEqual("")
|
||||
|
|
|
@ -485,9 +485,8 @@ export function isValidFilter(value: any) {
|
|||
return value != null && value !== ""
|
||||
}
|
||||
|
||||
export async function handleXml(response: any) {
|
||||
let data,
|
||||
rawXml = await response.text()
|
||||
export async function handleXml(rawXml: string) {
|
||||
let data
|
||||
data =
|
||||
(await xmlParser(rawXml, {
|
||||
explicitArray: false,
|
||||
|
|
|
@ -22,5 +22,29 @@
|
|||
"devDependencies": {
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": [
|
||||
"@budibase/types"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": [
|
||||
"@budibase/types"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"version": "0.0.0",
|
||||
"description": "Budibase types",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"author": "Budibase",
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
|
|
Loading…
Reference in New Issue