Update option auto naming
This commit is contained in:
parent
8378afb3c2
commit
3e13a06a8f
3 changed files with 147 additions and 100 deletions
|
@ -4,6 +4,7 @@
|
|||
import { Icon, Popover } from "@budibase/bbui"
|
||||
import { onMount, tick } from "svelte"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { getSequentialName } from "helpers/duplicate"
|
||||
|
||||
export let constraints
|
||||
export let optionColors = {}
|
||||
|
@ -15,25 +16,33 @@
|
|||
let colorPopovers = []
|
||||
let anchors = []
|
||||
|
||||
const removeInput = idx => {
|
||||
delete optionColors[options[idx].name]
|
||||
constraints.inclusion = constraints.inclusion.filter((e, i) => i !== idx)
|
||||
options = options.filter((e, i) => i !== idx)
|
||||
$: enrichedOptions = options.map((option, idx) => ({
|
||||
...option,
|
||||
color: optionColors?.[option.name] || defaultColor(idx),
|
||||
}))
|
||||
|
||||
const defaultColor = idx => OptionColours[idx % OptionColours.length]
|
||||
|
||||
const removeInput = name => {
|
||||
delete optionColors[name]
|
||||
constraints.inclusion = constraints.inclusion.filter(opt => opt !== name)
|
||||
options = options.filter(opt => opt.name !== name)
|
||||
colorPopovers.pop(undefined)
|
||||
anchors.pop(undefined)
|
||||
}
|
||||
|
||||
const addNewInput = async () => {
|
||||
const newName = `Option ${constraints.inclusion.length + 1}`
|
||||
const id = Math.random()
|
||||
options = [...options, { name: newName, id }]
|
||||
const newName = getSequentialName(constraints.inclusion, "Option ", {
|
||||
numberFirstItem: true,
|
||||
})
|
||||
const newId = Math.random()
|
||||
options = [...options, { name: newName, id: newId }]
|
||||
constraints.inclusion = [...constraints.inclusion, newName]
|
||||
optionColors[newName] =
|
||||
OptionColours[(options.length - 1) % OptionColours.length]
|
||||
optionColors[newName] = defaultColor(options.length - 1)
|
||||
colorPopovers.push(undefined)
|
||||
anchors.push(undefined)
|
||||
await tick()
|
||||
document.getElementById(`option-${id}`)?.focus()
|
||||
document.getElementById(`option-${newId}`)?.focus()
|
||||
}
|
||||
|
||||
const handleDndConsider = e => {
|
||||
|
@ -44,16 +53,29 @@
|
|||
constraints.inclusion = options.map(option => option.name)
|
||||
}
|
||||
|
||||
const handleColorChange = (optionName, color, idx) => {
|
||||
optionColors[optionName] = color
|
||||
const handleColorChange = (name, color, idx) => {
|
||||
optionColors[name] = 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 handleNameChange = (name, idx, newName) => {
|
||||
// Check we don't already have this option
|
||||
const existing = options.some((option, optionIdx) => {
|
||||
return newName === option.name && idx !== optionIdx
|
||||
})
|
||||
const invalid = !newName || existing
|
||||
options.find(option => option.name === name).invalid = invalid
|
||||
options = options.slice()
|
||||
|
||||
// Stop if invalid or no change
|
||||
if (invalid || name === newName) {
|
||||
return
|
||||
}
|
||||
|
||||
constraints.inclusion[idx] = newName
|
||||
options[idx].name = newName
|
||||
optionColors[newName] = optionColors[name]
|
||||
delete optionColors[name]
|
||||
}
|
||||
|
||||
const openColorPickerPopover = optionIdx => {
|
||||
|
@ -66,25 +88,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
const getOptionColor = (name, idx) => {
|
||||
return optionColors?.[name] || OptionColours[idx % OptionColours.length]
|
||||
}
|
||||
|
||||
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(),
|
||||
name: value,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
<div class="wrapper">
|
||||
<div
|
||||
class="options"
|
||||
use:dndzone={{
|
||||
items: options,
|
||||
|
@ -93,10 +111,13 @@
|
|||
}}
|
||||
on:consider={handleDndConsider}
|
||||
on:finalize={handleDndFinalize}
|
||||
>
|
||||
{#each options as option, idx (`${option.id}-${idx}`)}
|
||||
{@const color = getOptionColor(option.name, idx)}
|
||||
<div class="option" animate:flip={{ duration: flipDurationMs }}>
|
||||
>
|
||||
{#each enrichedOptions as option, idx (option.id)}
|
||||
<div
|
||||
class="option"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class:invalid={option.invalid}
|
||||
>
|
||||
<div class="drag-handle">
|
||||
<Icon name="DragHandle" size="L" />
|
||||
</div>
|
||||
|
@ -105,7 +126,7 @@
|
|||
class="color-picker"
|
||||
on:click={e => openColorPickerPopover(idx, e.target)}
|
||||
>
|
||||
<div class="circle" style="--color:{color}">
|
||||
<div class="circle" style="--color:{option.color}">
|
||||
<Popover
|
||||
bind:this={colorPopovers[idx]}
|
||||
anchor={anchors[idx]}
|
||||
|
@ -121,7 +142,7 @@
|
|||
handleColorChange(option.name, colorOption, idx)}
|
||||
style="--color:{colorOption};"
|
||||
class="circle"
|
||||
class:selected={colorOption === color}
|
||||
class:selected={colorOption === option.color}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
@ -131,14 +152,20 @@
|
|||
<input
|
||||
class="option-name"
|
||||
type="text"
|
||||
on:change={e => handleNameChange(option.name, idx, e.target.value)}
|
||||
value={option.name}
|
||||
placeholder="Option name"
|
||||
id="option-{option.id}"
|
||||
on:input={e => handleNameChange(option.name, idx, e.target.value)}
|
||||
/>
|
||||
<Icon
|
||||
name="Close"
|
||||
hoverable
|
||||
size="S"
|
||||
on:click={removeInput(option.name)}
|
||||
/>
|
||||
<Icon name="Close" hoverable size="S" on:click={removeInput(idx)} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div on:click={addNewInput} class="add-option">
|
||||
<Icon name="Add" />
|
||||
<div>Add option</div>
|
||||
|
@ -147,13 +174,14 @@
|
|||
|
||||
<style>
|
||||
/* Container */
|
||||
.options {
|
||||
.wrapper {
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
}
|
||||
.options > * {
|
||||
.options > *,
|
||||
.add-option {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
|
@ -164,12 +192,16 @@
|
|||
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);
|
||||
}
|
||||
.option:hover,
|
||||
.option:focus {
|
||||
.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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue