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 { Icon, Popover } from "@budibase/bbui"
|
||||||
import { onMount, tick } from "svelte"
|
import { onMount, tick } from "svelte"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
|
import { getSequentialName } from "helpers/duplicate"
|
||||||
|
|
||||||
export let constraints
|
export let constraints
|
||||||
export let optionColors = {}
|
export let optionColors = {}
|
||||||
|
@ -15,25 +16,33 @@
|
||||||
let colorPopovers = []
|
let colorPopovers = []
|
||||||
let anchors = []
|
let anchors = []
|
||||||
|
|
||||||
const removeInput = idx => {
|
$: enrichedOptions = options.map((option, idx) => ({
|
||||||
delete optionColors[options[idx].name]
|
...option,
|
||||||
constraints.inclusion = constraints.inclusion.filter((e, i) => i !== idx)
|
color: optionColors?.[option.name] || defaultColor(idx),
|
||||||
options = options.filter((e, i) => i !== 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)
|
colorPopovers.pop(undefined)
|
||||||
anchors.pop(undefined)
|
anchors.pop(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addNewInput = async () => {
|
const addNewInput = async () => {
|
||||||
const newName = `Option ${constraints.inclusion.length + 1}`
|
const newName = getSequentialName(constraints.inclusion, "Option ", {
|
||||||
const id = Math.random()
|
numberFirstItem: true,
|
||||||
options = [...options, { name: newName, id }]
|
})
|
||||||
|
const newId = Math.random()
|
||||||
|
options = [...options, { name: newName, id: newId }]
|
||||||
constraints.inclusion = [...constraints.inclusion, newName]
|
constraints.inclusion = [...constraints.inclusion, newName]
|
||||||
optionColors[newName] =
|
optionColors[newName] = defaultColor(options.length - 1)
|
||||||
OptionColours[(options.length - 1) % OptionColours.length]
|
|
||||||
colorPopovers.push(undefined)
|
colorPopovers.push(undefined)
|
||||||
anchors.push(undefined)
|
anchors.push(undefined)
|
||||||
await tick()
|
await tick()
|
||||||
document.getElementById(`option-${id}`)?.focus()
|
document.getElementById(`option-${newId}`)?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDndConsider = e => {
|
const handleDndConsider = e => {
|
||||||
|
@ -44,16 +53,29 @@
|
||||||
constraints.inclusion = options.map(option => option.name)
|
constraints.inclusion = options.map(option => option.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleColorChange = (optionName, color, idx) => {
|
const handleColorChange = (name, color, idx) => {
|
||||||
optionColors[optionName] = color
|
optionColors[name] = color
|
||||||
colorPopovers[idx].hide()
|
colorPopovers[idx].hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNameChange = (optionName, idx, value) => {
|
const handleNameChange = (name, idx, newName) => {
|
||||||
constraints.inclusion[idx] = value
|
// Check we don't already have this option
|
||||||
options[idx].name = value
|
const existing = options.some((option, optionIdx) => {
|
||||||
optionColors[value] = optionColors[optionName]
|
return newName === option.name && idx !== optionIdx
|
||||||
delete optionColors[optionName]
|
})
|
||||||
|
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 => {
|
const openColorPickerPopover = optionIdx => {
|
||||||
|
@ -66,79 +88,84 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOptionColor = (name, idx) => {
|
|
||||||
return optionColors?.[name] || OptionColours[idx % OptionColours.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Initialize anchor arrays on mount, assuming 'options' is already populated
|
// Initialize anchor arrays on mount, assuming 'options' is already populated
|
||||||
colorPopovers = constraints.inclusion.map(() => undefined)
|
colorPopovers = constraints.inclusion.map(() => undefined)
|
||||||
anchors = constraints.inclusion.map(() => undefined)
|
anchors = constraints.inclusion.map(() => undefined)
|
||||||
|
|
||||||
options = constraints.inclusion.map(value => ({
|
options = constraints.inclusion.map(value => ({
|
||||||
name: value,
|
|
||||||
id: Math.random(),
|
id: Math.random(),
|
||||||
|
name: value,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- 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 class="wrapper">
|
||||||
class="options"
|
<div
|
||||||
use:dndzone={{
|
class="options"
|
||||||
items: options,
|
use:dndzone={{
|
||||||
flipDurationMs,
|
items: options,
|
||||||
dropTargetStyle: { outline: "none" },
|
flipDurationMs,
|
||||||
}}
|
dropTargetStyle: { outline: "none" },
|
||||||
on:consider={handleDndConsider}
|
}}
|
||||||
on:finalize={handleDndFinalize}
|
on:consider={handleDndConsider}
|
||||||
>
|
on:finalize={handleDndFinalize}
|
||||||
{#each options as option, idx (`${option.id}-${idx}`)}
|
>
|
||||||
{@const color = getOptionColor(option.name, idx)}
|
{#each enrichedOptions as option, idx (option.id)}
|
||||||
<div class="option" animate:flip={{ duration: flipDurationMs }}>
|
|
||||||
<div class="drag-handle">
|
|
||||||
<Icon name="DragHandle" size="L" />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
bind:this={anchors[idx]}
|
class="option"
|
||||||
class="color-picker"
|
animate:flip={{ duration: flipDurationMs }}
|
||||||
on:click={e => openColorPickerPopover(idx, e.target)}
|
class:invalid={option.invalid}
|
||||||
>
|
>
|
||||||
<div class="circle" style="--color:{color}">
|
<div class="drag-handle">
|
||||||
<Popover
|
<Icon name="DragHandle" size="L" />
|
||||||
bind:this={colorPopovers[idx]}
|
|
||||||
anchor={anchors[idx]}
|
|
||||||
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.name, colorOption, idx)}
|
|
||||||
style="--color:{colorOption};"
|
|
||||||
class="circle"
|
|
||||||
class:selected={colorOption === color}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
bind:this={anchors[idx]}
|
||||||
|
class="color-picker"
|
||||||
|
on:click={e => openColorPickerPopover(idx, e.target)}
|
||||||
|
>
|
||||||
|
<div class="circle" style="--color:{option.color}">
|
||||||
|
<Popover
|
||||||
|
bind:this={colorPopovers[idx]}
|
||||||
|
anchor={anchors[idx]}
|
||||||
|
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.name, colorOption, idx)}
|
||||||
|
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.name, idx, e.target.value)}
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name="Close"
|
||||||
|
hoverable
|
||||||
|
size="S"
|
||||||
|
on:click={removeInput(option.name)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
{/each}
|
||||||
class="option-name"
|
</div>
|
||||||
type="text"
|
|
||||||
on:change={e => handleNameChange(option.name, idx, e.target.value)}
|
|
||||||
value={option.name}
|
|
||||||
placeholder="Option name"
|
|
||||||
id="option-{option.id}"
|
|
||||||
/>
|
|
||||||
<Icon name="Close" hoverable size="S" on:click={removeInput(idx)} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div on:click={addNewInput} class="add-option">
|
<div on:click={addNewInput} class="add-option">
|
||||||
<Icon name="Add" />
|
<Icon name="Add" />
|
||||||
<div>Add option</div>
|
<div>Add option</div>
|
||||||
|
@ -147,13 +174,14 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Container */
|
/* Container */
|
||||||
.options {
|
.wrapper {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||||
background-color: var(--spectrum-global-color-gray-50);
|
background-color: var(--spectrum-global-color-gray-50);
|
||||||
}
|
}
|
||||||
.options > * {
|
.options > *,
|
||||||
|
.add-option {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,12 +192,16 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
|
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
|
||||||
gap: var(--spacing-m);
|
gap: var(--spacing-m);
|
||||||
padding: 0 var(--spacing-m) 0 var(--spacing-s);
|
padding: 0 var(--spacing-m) 0 var(--spacing-s);
|
||||||
}
|
}
|
||||||
.option:hover,
|
.option.invalid {
|
||||||
.option:focus {
|
border: 1px solid var(--spectrum-global-color-red-400);
|
||||||
|
}
|
||||||
|
.option:not(.invalid):hover,
|
||||||
|
.option:not(.invalid):focus {
|
||||||
background: var(--spectrum-global-color-gray-100);
|
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
|
* @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}`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue