Merge branch 'master' into fix/button-icons

This commit is contained in:
Andrew Kingston 2024-05-28 08:33:54 +01:00 committed by GitHub
commit 65340c657e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 416 additions and 339 deletions

View File

@ -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: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:single": "./scripts/build-single-image.sh",
"build:docker:dependencies": "docker build -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest ./hosting", "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": "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.2.1-sqs --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", "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", "release:helm": "node scripts/releaseHelmChart",
"env:multi:enable": "lerna run --stream env:multi:enable", "env:multi:enable": "lerna run --stream env:multi:enable",

View File

@ -157,7 +157,7 @@
useAnchorWidth={!autoWidth} useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null} maxWidth={autoWidth ? 400 : null}
customHeight={customPopoverHeight} customHeight={customPopoverHeight}
maxHeight={240} maxHeight={360}
> >
<div <div
class="popover-content" class="popover-content"

View File

@ -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>

View File

@ -89,7 +89,6 @@ export { default as ListItem } from "./List/ListItem.svelte"
export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte" export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte" export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
export { default as Accordion } from "./Accordion/Accordion.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 { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte" export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"

View File

@ -92,7 +92,6 @@
/> />
{:else if type === "attachment"} {:else if type === "attachment"}
<Dropzone <Dropzone
compact
{label} {label}
{error} {error}
{value} {value}
@ -102,7 +101,6 @@
/> />
{:else if type === "attachment_single"} {:else if type === "attachment_single"}
<Dropzone <Dropzone
compact
{label} {label}
{error} {error}
value={value ? [value] : []} value={value ? [value] : []}

View File

@ -9,7 +9,6 @@
DatePicker, DatePicker,
Modal, Modal,
notifications, notifications,
OptionSelectDnD,
Layout, Layout,
AbsTooltip, AbsTooltip,
ProgressCircle, ProgressCircle,
@ -42,6 +41,7 @@
import RelationshipSelector from "components/common/RelationshipSelector.svelte" import RelationshipSelector from "components/common/RelationshipSelector.svelte"
import { RowUtils } from "@budibase/frontend-core" import { RowUtils } from "@budibase/frontend-core"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte" import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte"
const AUTO_TYPE = FieldType.AUTO const AUTO_TYPE = FieldType.AUTO
const FORMULA_TYPE = FieldType.FORMULA const FORMULA_TYPE = FieldType.FORMULA
@ -95,6 +95,7 @@
}, },
} }
let autoColumnInfo = getAutoColumnInformation() let autoColumnInfo = getAutoColumnInformation()
let optionsValid = true
$: rowGoldenSample = RowUtils.generateGoldenSample($rows) $: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: if (primaryDisplay) { $: if (primaryDisplay) {
@ -138,7 +139,8 @@
$: invalid = $: invalid =
!editableColumn?.name || !editableColumn?.name ||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) || (editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
Object.keys(errors).length !== 0 Object.keys(errors).length !== 0 ||
!optionsValid
$: errors = checkErrors(editableColumn) $: errors = checkErrors(editableColumn)
$: datasource = $datasources.list.find( $: datasource = $datasources.list.find(
source => source._id === table?.sourceId source => source._id === table?.sourceId
@ -559,9 +561,10 @@
bind:value={editableColumn.constraints.length.maximum} bind:value={editableColumn.constraints.length.maximum}
/> />
{:else if editableColumn.type === FieldType.OPTIONS} {:else if editableColumn.type === FieldType.OPTIONS}
<OptionSelectDnD <OptionsEditor
bind:constraints={editableColumn.constraints} bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors} bind:optionColors={editableColumn.optionColors}
bind:valid={optionsValid}
/> />
{:else if editableColumn.type === FieldType.LONGFORM} {:else if editableColumn.type === FieldType.LONGFORM}
<div> <div>
@ -582,9 +585,10 @@
/> />
</div> </div>
{:else if editableColumn.type === FieldType.ARRAY} {:else if editableColumn.type === FieldType.ARRAY}
<OptionSelectDnD <OptionsEditor
bind:constraints={editableColumn.constraints} bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors} bind:optionColors={editableColumn.optionColors}
bind:valid={optionsValid}
/> />
{:else if editableColumn.type === DATE_TYPE && !editableColumn.autocolumn} {:else if editableColumn.type === DATE_TYPE && !editableColumn.autocolumn}
<div class="split-label"> <div class="split-label">

View File

@ -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>

View File

@ -309,7 +309,7 @@
{#if links?.length} {#if links?.length}
<DataSourceCategory <DataSourceCategory
dividerState={true} dividerState={true}
heading="Links" heading="Relationships"
dataSet={links} dataSet={links}
{value} {value}
onSelect={handleSelected} onSelect={handleSelected}

View File

@ -70,13 +70,18 @@ export const duplicateName = (name, allNames) => {
* @param getName optional function to extract the name for an item, if not a * @param getName optional function to extract the name for an item, if not a
* flat array of strings * 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) { if (!prefix?.length || !getName) {
return null return null
} }
const trimmedPrefix = prefix.trim() const trimmedPrefix = prefix.trim()
const firstName = numberFirstItem ? `${prefix}1` : trimmedPrefix
if (!items?.length) { if (!items?.length) {
return trimmedPrefix return firstName
} }
let max = 0 let max = 0
items.forEach(item => { items.forEach(item => {
@ -96,5 +101,5 @@ export const getSequentialName = (items, prefix, getName = x => x) => {
max = num max = num
} }
}) })
return max === 0 ? trimmedPrefix : `${prefix}${max + 1}` return max === 0 ? firstName : `${prefix}${max + 1}`
} }

View File

@ -43,61 +43,71 @@ describe("duplicate", () => {
describe("getSequentialName", () => { describe("getSequentialName", () => {
it("handles nullish items", async () => { it("handles nullish items", async () => {
const name = getSequentialName(null, "foo", () => {}) const name = getSequentialName(null, "foo")
expect(name).toBe("foo") expect(name).toBe("foo")
}) })
it("handles nullish prefix", async () => { it("handles nullish prefix", async () => {
const name = getSequentialName([], null, () => {}) const name = getSequentialName([], null)
expect(name).toBe(null)
})
it("handles nullish getName function", async () => {
const name = getSequentialName([], "foo", null)
expect(name).toBe(null) expect(name).toBe(null)
}) })
it("handles just the prefix", async () => { it("handles just the prefix", async () => {
const name = getSequentialName(["foo"], "foo", x => x) const name = getSequentialName(["foo"], "foo")
expect(name).toBe("foo2") expect(name).toBe("foo2")
}) })
it("handles continuous ranges", async () => { 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") expect(name).toBe("foo4")
}) })
it("handles discontinuous ranges", async () => { it("handles discontinuous ranges", async () => {
const name = getSequentialName(["foo", "foo3"], "foo", x => x) const name = getSequentialName(["foo", "foo3"], "foo")
expect(name).toBe("foo4") expect(name).toBe("foo4")
}) })
it("handles a space inside the prefix", async () => { 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") expect(name).toBe("foo 4")
}) })
it("handles a space inside the prefix with just the prefix", async () => { 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") expect(name).toBe("foo 2")
}) })
it("handles no matches", async () => { it("handles no matches", async () => {
const name = getSequentialName(["aaa", "bbb"], "foo", x => x) const name = getSequentialName(["aaa", "bbb"], "foo")
expect(name).toBe("foo") expect(name).toBe("foo")
}) })
it("handles similar names", async () => { it("handles similar names", async () => {
const name = getSequentialName( const name = getSequentialName(["fooo1", "2foo", "a3foo4", "5foo5"], "foo")
["fooo1", "2foo", "a3foo4", "5foo5"],
"foo",
x => x
)
expect(name).toBe("foo") expect(name).toBe("foo")
}) })
it("handles non-string names", async () => { it("handles non-string names", async () => {
const name = getSequentialName([null, 4123, [], {}], "foo", x => x) const name = getSequentialName([null, 4123, [], {}], "foo")
expect(name).toBe("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")
})
}) })

View File

@ -48,7 +48,9 @@
...navItems, ...navItems,
{ {
id: generate(), id: generate(),
text: getSequentialName(navItems, "Nav Item ", x => x.text), text: getSequentialName(navItems, "Nav Item ", {
getName: x => x.text,
}),
url: "", url: "",
roleId: Constants.Roles.BASIC, roleId: Constants.Roles.BASIC,
type: "link", type: "link",

View File

@ -29,6 +29,7 @@
focusedCellId, focusedCellId,
filter, filter,
inlineFilters, inlineFilters,
keyboardBlocked,
} = getContext("grid") } = getContext("grid")
const searchableTypes = [ const searchableTypes = [
@ -57,6 +58,8 @@
$: searching = searchValue != null $: searching = searchValue != null
$: debouncedUpdateFilter(searchValue) $: debouncedUpdateFilter(searchValue)
$: orderable = !column.primaryDisplay $: orderable = !column.primaryDisplay
$: editable = $config.canEditColumns && !column.schema.disabled
$: keyboardBlocked.set(open)
const close = () => { const close = () => {
open = false open = false
@ -231,6 +234,14 @@
} }
const debouncedUpdateFilter = debounce(updateFilter, 250) const debouncedUpdateFilter = debounce(updateFilter, 250)
const handleDoubleClick = () => {
if (!editable || searching) {
return
}
open = true
editColumn()
}
onMount(() => subscribe("close-edit-column", close)) onMount(() => subscribe("close-edit-column", close))
</script> </script>
@ -241,14 +252,15 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
bind:this={anchor}
class="header-cell" class="header-cell"
style="flex: 0 0 {column.width}px;"
class:open class:open
class:searchable class:searchable
class:searching class:searching
style="flex: 0 0 {column.width}px;"
bind:this={anchor}
class:disabled={$isReordering || $isResizing} class:disabled={$isReordering || $isResizing}
class:sticky={idx === "sticky"} class:sticky={idx === "sticky"}
on:dblclick={handleDoubleClick}
> >
<GridCell <GridCell
on:mousedown={onMouseDown} on:mousedown={onMouseDown}
@ -311,7 +323,7 @@
{#if open} {#if open}
<GridPopover <GridPopover
{anchor} {anchor}
align="right" align="left"
on:close={close} on:close={close}
maxHeight={null} maxHeight={null}
resizable resizable
@ -322,11 +334,7 @@
</div> </div>
{:else} {:else}
<Menu> <Menu>
<MenuItem <MenuItem icon="Edit" on:click={editColumn} disabled={!editable}>
icon="Edit"
on:click={editColumn}
disabled={!$config.canEditColumns || column.schema.disabled}
>
Edit column Edit column
</MenuItem> </MenuItem>
<MenuItem <MenuItem

View File

@ -1,8 +1,8 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { getColor } from "../lib/utils"
import { onMount } from "svelte" import { onMount } from "svelte"
import GridPopover from "../overlays/GridPopover.svelte" import GridPopover from "../overlays/GridPopover.svelte"
import { OptionColours } from "../../../constants"
export let value export let value
export let schema export let schema
@ -13,6 +13,8 @@
export let api export let api
export let contentLines = 1 export let contentLines = 1
const InvalidColor = "hsla(0, 0%, 70%, 0.3)"
let isOpen = false let isOpen = false
let focusedOptionIdx = null let focusedOptionIdx = null
let anchor let anchor
@ -38,8 +40,11 @@
} }
const getOptionColor = value => { const getOptionColor = value => {
const index = value ? options.indexOf(value) : null let idx = value ? options.indexOf(value) : null
return getColor(index) if (idx == null || idx === -1) {
return InvalidColor
}
return OptionColours[idx % OptionColours.length]
} }
const toggleOption = option => { const toggleOption = option => {

View File

@ -1,9 +1,9 @@
<script> <script>
import { getColor } from "../lib/utils"
import { onMount, getContext } from "svelte" import { onMount, getContext } from "svelte"
import { Icon, Input, ProgressCircle } from "@budibase/bbui" import { Icon, Input, ProgressCircle } from "@budibase/bbui"
import { debounce } from "../../../utils/utils" import { debounce } from "../../../utils/utils"
import GridPopover from "../overlays/GridPopover.svelte" import GridPopover from "../overlays/GridPopover.svelte"
import { OptionColours } from "../../../constants"
const { API, cache } = getContext("grid") const { API, cache } = getContext("grid")
@ -18,7 +18,7 @@
export let primaryDisplay export let primaryDisplay
export let hideCounter = false export let hideCounter = false
const color = getColor(0) const color = OptionColours[0]
let isOpen = false let isOpen = false
let searchResults let searchResults

View File

@ -3,7 +3,8 @@
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import GridPopover from "../overlays/GridPopover.svelte" 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 anchor
let isOpen = false let isOpen = false
@ -14,6 +15,7 @@
) )
$: end = columnsWidth - 1 - $scroll.left $: end = columnsWidth - 1 - $scroll.left
$: left = Math.min($width - 40, end) $: left = Math.min($width - 40, end)
$: keyboardBlocked.set(isOpen)
const open = () => { const open = () => {
ui.actions.blur() ui.actions.blur()

View File

@ -209,7 +209,7 @@
<GridScrollWrapper scrollHorizontally attachHandlers> <GridScrollWrapper scrollHorizontally attachHandlers>
<div class="row"> <div class="row">
{#each $visibleColumns as column} {#each $visibleColumns as column}
{@const cellId = `new-${column.name}`} {@const cellId = getCellID(NewRowID, column.name)}
<DataCell <DataCell
{cellId} {cellId}
{column} {column}

View File

@ -66,7 +66,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}> <div class="content">
<GridScrollWrapper scrollVertically attachHandlers> <GridScrollWrapper scrollVertically attachHandlers>
{#each $renderedRows as row, idx} {#each $renderedRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]} {@const rowSelected = !!$selectedRows[row._id]}

View File

@ -18,13 +18,6 @@ export const getCellID = (rowId, fieldName) => {
return `${rowId}${JOINING_CHARACTER}${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 => { export const getColumnIcon = column => {
if (column.schema.autocolumn) { if (column.schema.autocolumn) {
return "MagicWand" return "MagicWand"

View File

@ -17,6 +17,7 @@
config, config,
menu, menu,
gridFocused, gridFocused,
keyboardBlocked,
} = getContext("grid") } = getContext("grid")
const ignoredOriginSelectors = [ const ignoredOriginSelectors = [
@ -29,7 +30,7 @@
// Global key listener which intercepts all key events // Global key listener which intercepts all key events
const handleKeyDown = e => { const handleKeyDown = e => {
// Ignore completely if the grid is not focused // Ignore completely if the grid is not focused
if (!$gridFocused) { if (!$gridFocused || $keyboardBlocked) {
return return
} }

View File

@ -19,6 +19,7 @@ export const createStores = context => {
const previousFocusedRowId = writable(null) const previousFocusedRowId = writable(null)
const previousFocusedCellId = writable(null) const previousFocusedCellId = writable(null)
const gridFocused = writable(false) const gridFocused = writable(false)
const keyboardBlocked = writable(false)
const isDragging = writable(false) const isDragging = writable(false)
const buttonColumnWidth = writable(0) const buttonColumnWidth = writable(0)
@ -54,6 +55,7 @@ export const createStores = context => {
hoveredRowId, hoveredRowId,
rowHeight, rowHeight,
gridFocused, gridFocused,
keyboardBlocked,
isDragging, isDragging,
buttonColumnWidth, buttonColumnWidth,
selectedRows: { selectedRows: {

View File

@ -141,3 +141,7 @@ export const TypeIconMap = {
[BBReferenceFieldSubType.USER]: "User", [BBReferenceFieldSubType.USER]: "User",
}, },
} }
export const OptionColours = [...new Array(12).keys()].map(idx => {
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, 0.3)`
})

View File

@ -64,13 +64,11 @@ describe("rest", () => {
cached = await getCachedVariable(basedOnQuery._id!, "foo") cached = await getCachedVariable(basedOnQuery._id!, "foo")
expect(cached).toBeNull() expect(cached).toBeNull()
nock("http://one.example.com") const body1 = [{ name: "one" }]
.get("/") const body2 = [{ name: "two" }]
.reply(200, [{ name: "one" }]) 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(500)
nock("http://two.example.com") nock("http://two.example.com").get("/?test=one").reply(200, body2)
.get("/?test=one")
.reply(200, [{ name: "two" }])
const res = await config.api.query.preview({ const res = await config.api.query.preview({
datasourceId: datasource._id!, datasourceId: datasource._id!,

View File

@ -148,6 +148,10 @@ class RestIntegration implements IntegrationBase {
response.headers, response.headers,
{ downloadImages: this.config.downloadImages } { downloadImages: this.config.downloadImages }
) )
let contentLength = response.headers.get("content-length")
if (!contentLength && raw) {
contentLength = Buffer.byteLength(raw, "utf8").toString()
}
if ( if (
contentDisposition.includes("filename") || contentDisposition.includes("filename") ||
contentDisposition.includes("attachment") || contentDisposition.includes("attachment") ||
@ -156,36 +160,46 @@ class RestIntegration implements IntegrationBase {
filename = filename =
path.basename(parse(contentDisposition).parameters?.filename) || "" path.basename(parse(contentDisposition).parameters?.filename) || ""
} }
let triedParsing: boolean = false,
responseTxt: string | undefined
try { try {
if (filename) { if (filename) {
return handleFileResponse(response, filename, this.startTimeMs) return handleFileResponse(response, filename, this.startTimeMs)
} else { } else {
responseTxt = response.text ? await response.text() : ""
const hasContent =
(contentLength && parseInt(contentLength) > 0) ||
responseTxt.length > 0
if (response.status === 204) { if (response.status === 204) {
data = [] data = []
raw = "" raw = ""
} else if (contentType.includes("application/json")) { } else if (hasContent && contentType.includes("application/json")) {
data = await response.json() triedParsing = true
raw = JSON.stringify(data) data = JSON.parse(responseTxt)
raw = responseTxt
} else if ( } else if (
contentType.includes("text/xml") || (hasContent && contentType.includes("text/xml")) ||
contentType.includes("application/xml") contentType.includes("application/xml")
) { ) {
let xmlResponse = await handleXml(response) triedParsing = true
let xmlResponse = await handleXml(responseTxt)
data = xmlResponse.data data = xmlResponse.data
raw = xmlResponse.rawXml raw = xmlResponse.rawXml
} else { } else {
data = await response.text() data = responseTxt
raw = data as string raw = data as string
} }
} }
} catch (err) { } 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 size = formatBytes(contentLength || "0")
const time = `${Math.round(performance.now() - this.startTimeMs)}ms` const time = `${Math.round(performance.now() - this.startTimeMs)}ms`
headers = response.headers.raw() headers = response.headers.raw()

View File

@ -1,19 +1,27 @@
jest.mock("node-fetch", () => { jest.mock("node-fetch", () => {
const obj = {
my_next_cursor: 123,
}
const str = JSON.stringify(obj)
return jest.fn(() => ({ return jest.fn(() => ({
headers: { headers: {
raw: () => { raw: () => {
return { "content-type": ["application/json"] } return {
"content-type": ["application/json"],
"content-length": str.length,
}
}, },
get: (name: string) => { get: (name: string) => {
if (name.toLowerCase() === "content-type") { const lcName = name.toLowerCase()
if (lcName === "content-type") {
return ["application/json"] return ["application/json"]
} else if (lcName === "content-length") {
return str.length
} }
}, },
}, },
json: jest.fn(() => ({ json: jest.fn(() => obj),
my_next_cursor: 123, text: jest.fn(() => str),
})),
text: jest.fn(),
})) }))
}) })
@ -231,7 +239,8 @@ describe("REST Integration", () => {
} }
it("should be able to parse JSON response", async () => { 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) const output = await config.integration.parseResponse(input)
expect(output.data).toEqual({ a: 1 }) expect(output.data).toEqual({ a: 1 })
expect(output.info.code).toEqual(200) expect(output.info.code).toEqual(200)
@ -261,7 +270,7 @@ describe("REST Integration", () => {
test.each([...contentTypes, undefined])( test.each([...contentTypes, undefined])(
"should not throw an error on 204 no content", "should not throw an error on 204 no content",
async contentType => { async contentType => {
const input = buildInput(undefined, null, contentType, 204) const input = buildInput(undefined, "", contentType, 204)
const output = await config.integration.parseResponse(input) const output = await config.integration.parseResponse(input)
expect(output.data).toEqual([]) expect(output.data).toEqual([])
expect(output.extra.raw).toEqual("") expect(output.extra.raw).toEqual("")

View File

@ -485,9 +485,8 @@ export function isValidFilter(value: any) {
return value != null && value !== "" return value != null && value !== ""
} }
export async function handleXml(response: any) { export async function handleXml(rawXml: string) {
let data, let data
rawXml = await response.text()
data = data =
(await xmlParser(rawXml, { (await xmlParser(rawXml, {
explicitArray: false, explicitArray: false,

View File

@ -22,5 +22,29 @@
"devDependencies": { "devDependencies": {
"rimraf": "3.0.2", "rimraf": "3.0.2",
"typescript": "5.2.2" "typescript": "5.2.2"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/types"
],
"target": "build"
}
]
},
"dev": {
"dependsOn": [
{
"projects": [
"@budibase/types"
],
"target": "build"
}
]
}
}
} }
} }

View File

@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"description": "Budibase types", "description": "Budibase types",
"main": "dist/index.js", "main": "dist/index.js",
"types": "src/index.ts", "types": "dist/index.d.ts",
"author": "Budibase", "author": "Budibase",
"license": "GPL-3.0", "license": "GPL-3.0",
"scripts": { "scripts": {