Update option auto naming

This commit is contained in:
Andrew Kingston 2024-05-23 12:10:53 +01:00
parent 8378afb3c2
commit 3e13a06a8f
3 changed files with 147 additions and 100 deletions

View File

@ -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);
} }

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")
})
}) })