refactor(webui): Selector refactor, non-destructive search
ci/woodpecker/push/woodpecker Pipeline failed Details

feat/selector-improvements
Tomáš Mládek 2023-11-17 19:20:31 +01:00
parent 91d8688bc9
commit 48a398f4f2
12 changed files with 400 additions and 271 deletions

View File

@ -38,10 +38,6 @@ export type IValue =
| {
t: "Null";
c: null;
}
| {
t: "Invalid";
c: null;
};
export interface InvariantEntry {

View File

@ -31,8 +31,7 @@
<div class="controls">
<Selector
bind:this={selector}
type="value"
valueTypes={["Address"]}
types={["Address", "NewAddress", "Attribute"]}
on:input={(ev) => {
dispatch("input", ev.detail);
editable = false;

View File

@ -1,8 +1,7 @@
<script lang="ts">
import UpObjectDisplay from "./display/UpObject.svelte";
import Selector from "./utils/Selector.svelte";
import Selector, { type SelectorValue } from "./utils/Selector.svelte";
import IconButton from "./utils/IconButton.svelte";
import type { IValue } from "@upnd/upend/types";
import { i18n } from "../i18n";
import LabelBorder from "./utils/LabelBorder.svelte";
import { createEventDispatcher } from "svelte";
@ -22,13 +21,11 @@
$: if (adding && selector) selector.focus();
let entityToAdd: IValue | undefined;
async function add() {
if (!entityToAdd) {
async function add(ev: CustomEvent<SelectorValue>) {
if (ev.detail.t !== "Address") {
return;
}
dispatch("add", entityToAdd.c as string);
entityToAdd = undefined;
dispatch("add", ev.detail.c);
}
async function remove(address: string) {
@ -45,9 +42,7 @@
<div class="selector">
<Selector
bind:this={selector}
type="value"
valueTypes={["Address"]}
bind:value={entityToAdd}
types={["Address", "NewAddress"]}
on:input={add}
on:focus={(ev) => {
if (!ev.detail) adding = false;

View File

@ -1,6 +1,6 @@
<script lang="ts">
import UpObjectDisplay from "./display/UpObject.svelte";
import Selector from "./utils/Selector.svelte";
import Selector, { type SelectorValue } from "./utils/Selector.svelte";
import IconButton from "./utils/IconButton.svelte";
import api from "../lib/api";
import { i18n } from "../i18n";
@ -20,14 +20,15 @@
$: typeEntries = $entity?.attr[`~${ATTR_OF}`] || [];
let attributeToAdd: string;
async function add() {
if (!attributeToAdd) return;
async function add(ev: CustomEvent<SelectorValue>) {
if (ev.detail.t !== "Attribute") {
return;
}
await api.putEntry({
entity: {
t: "Attribute",
c: attributeToAdd,
c: ev.detail.name,
},
attribute: ATTR_OF,
value: { t: "Address", c: $entity.address },
@ -57,8 +58,7 @@
<div class="selector">
<Selector
bind:this={typeSelector}
type="attribute"
bind:attribute={attributeToAdd}
types={["Attribute", "NewAttribute"]}
on:input={add}
placeholder={$i18n.t("Assign an attribute to this type...")}
on:focus={(ev) => {

View File

@ -367,9 +367,8 @@
<div class="content">
{#key currentAnnotation}
<Selector
type="value"
valueTypes={["String", "Address"]}
value={currentAnnotation.data}
types={["String", "Address"]}
initial={currentAnnotation.data}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({ data: ev.detail });

View File

@ -1,30 +1,49 @@
<script lang="ts">
import { Link, useLocation, useNavigate } from "svelte-navigator";
import { useMatch } from "svelte-navigator";
import { Link, useNavigate } from "svelte-navigator";
// import { useMatch } from "svelte-navigator";
import { addEmitter } from "../AddModal.svelte";
import Icon from "../utils/Icon.svelte";
import Input from "../utils/Input.svelte";
import { jobsEmitter } from "./Jobs.svelte";
import api from "../../lib/api";
import Selector, { type SelectorValue } from "../utils/Selector.svelte";
import { i18n } from "../../i18n";
const navigate = useNavigate();
const location = useLocation();
// const location = useLocation();
const searchMatch = useMatch("/search/:query");
// const searchMatch = useMatch("/search/:query");
let searchQuery = $searchMatch?.params.query
? decodeURIComponent($searchMatch?.params.query)
: "";
$: if (!$location.pathname.includes("search")) searchQuery = "";
function onInput(event: CustomEvent<string>) {
searchQuery = event.detail;
// let searchQuery = $searchMatch?.params.query
// ? decodeURIComponent($searchMatch?.params.query)
// : "";
// $: if (!$location.pathname.includes("search")) searchQuery = "";
if (searchQuery.length > 0) {
navigate(`/search/${encodeURIComponent(searchQuery)}`, {
replace: $location.pathname.includes("search"),
});
} else {
navigate("/");
let selector: Selector;
async function onInput(event: CustomEvent<SelectorValue>) {
const value = event.detail;
switch (value.t) {
case "Address":
navigate(`/browse/${value.c}`);
break;
case "Attribute":
{
const attributeAddress = await api.componentsToAddress({
t: "Attribute",
c: value.name,
});
navigate(`/browse/${attributeAddress}`);
}
break;
}
selector.reset();
// searchQuery = event.detail;
// if (searchQuery.length > 0) {
// navigate(`/search/${encodeURIComponent(searchQuery)}`, {
// replace: $location.pathname.includes("search"),
// });
// }
}
let fileInput: HTMLInputElement;
@ -48,13 +67,14 @@
</Link>
</h1>
<div class="input">
<Input
placeholder="Search or add..."
value={searchQuery}
<Selector
types={["Address", "NewAddress", "Attribute"]}
placeholder={$i18n.t("Search or add")}
on:input={onInput}
bind:this={selector}
>
<Icon name="search" slot="prefix" />
</Input>
</Selector>
</div>
<button class="button" on:click={() => fileInput.click()}>
<Icon name="upload" />

View File

@ -1,12 +1,16 @@
<script lang="ts">
import Selector from "./Selector.svelte";
import Selector, {
type SELECTOR_TYPE,
type SelectorValue,
} from "./Selector.svelte";
import { createEventDispatcher } from "svelte";
import type { IValue } from "@upnd/upend/types";
import IconButton from "./IconButton.svelte";
const dispatch = createEventDispatcher();
export let value: IValue | undefined = undefined;
let newValue: IValue = value;
export let types: SELECTOR_TYPE[] | undefined = undefined;
let newValue: SelectorValue = value;
let editing = false;
@ -16,6 +20,11 @@
$: if (editing && selector) selector.focus();
$: if (!focus && !hover) editing = false;
function onInput(ev: CustomEvent<SelectorValue>) {
newValue = ev.detail;
selector.focus();
}
</script>
<div
@ -35,11 +44,10 @@
}}
>
<Selector
type="value"
bind:value={newValue}
{types}
bind:this={selector}
on:focus={(ev) => (focus = ev.detail)}
on:input={() => selector.focus()}
on:input={onInput}
/>
</div>
<IconButton

View File

@ -1,70 +1,154 @@
<script lang="ts" context="module">
export type SELECTOR_TYPE =
| "Address"
| "NewAddress"
| "Attribute"
| "NewAttribute"
| "String"
| "Number"
| "Null";
export type SelectorValue = {
t: SELECTOR_TYPE;
} & (
| {
t: "Address";
c: Address;
labels?: string[];
}
| {
t: "Attribute";
name: string;
labels?: string[];
}
| {
t: "String";
c: string;
}
| {
t: "Number";
c: number;
}
| {
t: "Null";
c: null;
}
);
export type SelectorOption =
| SelectorValue
| { t: "NewAddress"; c: string }
| { t: "NewAttribute"; name: string; label: string };
export async function selectorValueAsValue(
value: SelectorValue,
): Promise<IValue> {
switch (value.t) {
case "Address":
return {
t: "Address",
c: value.c,
};
case "Attribute":
return {
t: "Address",
c: await api.componentsToAddress({ t: "Attribute", c: value.name }),
};
case "String":
return {
t: "String",
c: value.c,
};
case "Number":
return {
t: "Number",
c: value.c,
};
case "Null":
return {
t: "Null",
c: null,
};
}
}
</script>
<script lang="ts">
import { debounce } from "lodash";
import { createEventDispatcher } from "svelte";
import type { UpListing } from "@upnd/upend";
import type { IValue, VALUE_TYPE } from "@upnd/upend/types";
import type { Address, IValue } from "@upnd/upend/types";
import { baseSearchOnce, createLabelled } from "../../util/search";
import UpObject from "../display/UpObject.svelte";
import IconButton from "./IconButton.svelte";
import Input from "./Input.svelte";
const dispatch = createEventDispatcher();
import { matchSorter } from "match-sorter";
import api from "../../lib/api";
import { ATTR_LABEL } from "@upnd/upend/constants";
import { i18n } from "../../i18n";
import debug from "debug";
const dispatch = createEventDispatcher();
const dbg = debug("kestrel:Selector");
const MAX_OPTIONS = 25;
export let MAX_OPTIONS = 25;
export let type: "attribute" | "value";
export let types: SELECTOR_TYPE[] = [
"Address",
"NewAddress",
"Attribute",
"String",
"Number",
];
export let attributeOptions: string[] | undefined = undefined;
export let valueTypes: VALUE_TYPE[] | undefined = undefined;
export let placeholder = "";
export let disabled = false;
export let attribute: string | undefined = undefined;
export let value: IValue | undefined = undefined;
if (type == "attribute") {
value = {
c: attribute,
t: "String",
};
}
let inputValue = String(value?.c || "");
$: if (value === undefined) inputValue = undefined;
function onInput(ev: CustomEvent<string>) {
if (type == "attribute") {
attribute = ev.detail;
} else {
value = {
t: "String",
c: ev.detail,
};
export let initial: SelectorValue | undefined = undefined;
let inputValue = "";
$: {
if (initial) {
switch (initial.t) {
case "Address":
case "String":
inputValue = initial.c;
break;
case "Attribute":
inputValue = initial.name;
break;
case "Number":
inputValue = String(initial.c);
break;
}
}
}
interface SelectorOption {
attribute?: {
labels: string[];
name: string;
};
value?: IValue;
labelToCreate?: string;
let current:
| (SelectorOption & { t: "Address" | "Attribute" | "String" | "Number" })
| undefined = undefined;
export function reset() {
inputValue = "";
current = undefined;
dispatch("input", current);
}
let options: SelectorOption[] = [];
let searchResult: UpListing;
const updateOptions = debounce(async (query: string, doSearch: boolean) => {
switch (type) {
case "attribute": {
const allAttributes = await api.fetchAllAttributes();
const attributes = attributeOptions
? allAttributes.filter((attr) => attributeOptions.includes(attr.name))
: allAttributes;
options = attributes
options = [];
if (query.length === 0 && !types.includes("Attribute")) {
return;
}
if (types.includes("Attribute")) {
const allAttributes = await api.fetchAllAttributes();
const attributes = attributeOptions
? allAttributes.filter((attr) => attributeOptions.includes(attr.name))
: allAttributes;
options.push(
...attributes
.filter(
(attr) =>
attr.name.toLowerCase().includes(query.toLowerCase()) ||
@ -72,110 +156,96 @@
label.toLowerCase().includes(query.toLowerCase()),
),
)
.map((attribute) => {
return {
attribute,
};
});
const attributeToCreate = inputValue
.toUpperCase()
.replaceAll(/[^A-Z0-9]/g, "_");
if (
!attributeOptions &&
inputValue &&
!allAttributes.map((attr) => attr.name).includes(attributeToCreate)
) {
options.push({
attribute: {
labels: [],
name: attributeToCreate,
},
labelToCreate: inputValue,
});
}
options = options;
break;
.map(
(attribute) =>
({
t: "Attribute",
...attribute,
}) as SelectorOption,
),
);
const attributeToCreate = query
.toUpperCase()
.replaceAll(/[^A-Z0-9]/g, "_");
if (
!attributeOptions &&
query &&
!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
types.includes("NewAttribute")
) {
options.push({
t: "NewAttribute",
name: attributeToCreate,
label: query,
});
}
case "value": {
options = [];
options = options;
}
if (query.length == 0) {
return;
}
if (valueTypes === undefined || valueTypes.includes("Number")) {
const number = parseFloat(query);
if (!Number.isNaN(number)) {
options.push({
value: {
t: "Number",
c: number,
},
});
}
}
if (valueTypes === undefined || valueTypes.includes("String")) {
options.push({
value: {
t: "String",
c: query,
},
});
}
if (valueTypes === undefined || valueTypes.includes("Address")) {
if (doSearch) {
searchResult = await baseSearchOnce(query);
}
let exactHits = Object.entries(addressToLabels)
.filter(([_, labels]) =>
labels
.map((l) => l.toLowerCase())
.includes(inputValue.toLowerCase()),
)
.map(([addr, _]) => addr);
if (exactHits.length) {
exactHits.forEach((addr) =>
options.push({
value: {
t: "Address",
c: addr,
},
}),
);
} else {
options.push({
labelToCreate: inputValue,
});
}
const validOptions = searchResult.entries
.filter((e) => e.attribute === ATTR_LABEL)
.filter((e) => !exactHits.includes(e.entity));
const sortedOptions = matchSorter(validOptions, inputValue, {
keys: ["value.c"],
});
options.push(
...sortedOptions.map((e) => {
return {
value: {
t: "Address",
c: e.entity,
},
} as SelectorOption;
}),
);
}
options = options;
break;
if (types.includes("Number")) {
const number = parseFloat(query);
if (!Number.isNaN(number)) {
options.push({
t: "Number",
c: number,
});
}
}
if (types.includes("String") && query.length) {
options.push({
t: "String",
c: query,
});
}
if (types.includes("Address")) {
if (doSearch) {
searchResult = await baseSearchOnce(query);
}
let exactHits = Object.entries(addressToLabels)
.filter(([_, labels]) =>
labels.map((l) => l.toLowerCase()).includes(query.toLowerCase()),
)
.map(([addr, _]) => addr);
if (exactHits.length) {
exactHits.forEach((addr) =>
options.push({
t: "Address",
c: addr,
labels: addressToLabels[addr],
}),
);
} else if (query.length && types.includes("NewAddress")) {
options.push({
t: "NewAddress",
c: query,
});
}
const validOptions = searchResult.entries
.filter((e) => e.attribute === ATTR_LABEL)
.filter((e) => !exactHits.includes(e.entity));
const sortedOptions = matchSorter(validOptions, inputValue, {
keys: ["value.c"],
});
options.push(
...sortedOptions.map((e) => {
return {
t: "Address",
c: e.entity,
labels: [e.value.c],
} as SelectorOption;
}),
);
}
options = options;
}, 200);
$: dbg("Options: %O", options);
@ -194,38 +264,62 @@
}
async function set(option: SelectorOption) {
switch (type) {
case "attribute":
if (option.labelToCreate) {
// First, "create" attribute, getting its address.
const [_, address] = await api.putEntry({
entity: { t: "Attribute", c: option.attribute.name },
});
// Second, label it.
await api.putEntityAttribute(address, ATTR_LABEL, {
t: "String",
c: option.labelToCreate,
});
}
attribute = option.attribute.name;
inputValue = option.attribute.name;
dbg("Setting option %O", option);
switch (option.t) {
case "Address":
inputValue = option.c;
current = option;
break;
case "value":
if (!option.labelToCreate) {
value = option.value;
inputValue = String(option.value.c);
} else {
const addr = await createLabelled(option.labelToCreate);
value = {
case "NewAddress":
{
const addr = await createLabelled(option.c);
inputValue = addr;
current = {
t: "Address",
c: addr,
labels: [option.c],
};
}
break;
case "Attribute":
inputValue = option.name;
current = option;
break;
case "NewAttribute":
inputValue = option.name;
{
const address = await api.componentsToAddress({
t: "Attribute",
c: option.name,
});
await api.putEntityAttribute(address, ATTR_LABEL, {
t: "String",
c: option.label,
});
current = {
t: "Attribute",
name: option.name,
labels: [option.label],
};
inputValue = addr;
}
break;
case "String":
inputValue = option.c;
current = option;
break;
case "Number":
inputValue = String(option.c);
current = option;
break;
}
dbg("Setting value to %O", value);
dispatch("input", value);
dbg("Result set value: %O", current);
dispatch("input", current);
options = [];
optionFocusIndex = -1;
hover = false;
@ -278,7 +372,7 @@
let input: Input;
export function focus() {
dbg("Focusing input");
// dbg("Focusing input");
input.focus();
}
@ -292,10 +386,10 @@
</script>
<div class="selector">
{#if value?.t == "Address" && inputValue}
{#if current?.t === "Address" && inputValue.length > 0}
<div class="input">
<div class="label">
<UpObject link address={String(value.c)} />
<UpObject link address={String(current.c)} />
</div>
<IconButton name="x" on:click={() => (inputValue = "")} />
</div>
@ -303,12 +397,13 @@
<Input
bind:this={input}
bind:value={inputValue}
on:input={onInput}
on:focusChange={(ev) => (inputFocused = ev.detail)}
on:keydown={handleArrowKeys}
{disabled}
{placeholder}
/>
>
<slot name="prefix" slot="prefix" />
</Input>
{/if}
<ul
class="options"
@ -333,36 +428,35 @@
}
}}
>
{#if option.attribute}
{#if option.labelToCreate}
<div class="content">{option.labelToCreate}</div>
<div class="type">Create attribute ({option.attribute.name})</div>
{:else if option.attribute.labels.length}
{#if option.t === "Address"}
{@const address = option.c}
<UpObject
{address}
labels={option.labels}
on:resolved={(ev) => onAddressResolved(address, ev)}
/>
{:else if option.t === "NewAddress"}
<div class="content">{option.c}</div>
<div class="type">{$i18n.t("Create object")}</div>
{:else if option.t === "Attribute"}
{#if option.labels.length}
<div class="content">
{#each option.attribute.labels as label}
{#each option.labels as label}
<div class="label">{label}</div>
{/each}
</div>
<div class="type">{option.attribute.name}</div>
<div class="type">{option.name}</div>
{:else}
<div class="content">
{option.attribute.name}
{option.name}
</div>
{/if}
{:else if option.value}
{#if option.value.t == "Address"}
<UpObject
address={String(option.value.c)}
on:resolved={(ev) =>
onAddressResolved(String(option.value.c), ev)}
/>
{:else}
<div class="type">{option.value.t}</div>
<div class="content">{option.value.c}</div>
{/if}
{:else if option.labelToCreate}
<div class="type">{$i18n.t("Add object")}</div>
<div class="content">{option.labelToCreate}</div>
{:else if option.t === "NewAttribute"}
<div class="content">{option.label}</div>
<div class="type">{$i18n.t("Create attribute")} ({option.name})</div>
{:else}
<div class="type">{option.t}</div>
<div class="content">{option.c}</div>
{/if}
</li>
{/each}

View File

@ -1,14 +1,14 @@
<script lang="ts">
import { readable, type Readable } from "svelte/store";
import type { UpListing } from "@upnd/upend";
import type { Address, IValue } from "@upnd/upend/types";
import type { Address } from "@upnd/upend/types";
import { query } from "../../lib/entity";
import UpObject from "../display/UpObject.svelte";
import UpObjectCard from "../display/UpObjectCard.svelte";
import { ATTR_LABEL } from "@upnd/upend/constants";
import { i18n } from "../../i18n";
import IconButton from "../utils/IconButton.svelte";
import Selector from "../utils/Selector.svelte";
import Selector, { type SelectorValue } from "../utils/Selector.svelte";
import { createEventDispatcher } from "svelte";
import type { WidgetChange } from "src/types/base";
import debug from "debug";
@ -131,7 +131,7 @@
$: if (adding && addSelector) addSelector.focus();
function addEntity(ev: CustomEvent<IValue>) {
function addEntity(ev: CustomEvent<SelectorValue>) {
dbg("Adding entity", ev.detail);
const addAddress = ev.detail?.t == "Address" ? ev.detail.c : undefined;
if (!addAddress) return;
@ -224,8 +224,7 @@
<Selector
bind:this={addSelector}
placeholder={$i18n.t("Search database or paste an URL")}
type="value"
valueTypes={["Address"]}
types={["Address", "NewAddress"]}
on:input={addEntity}
on:focus={(ev) => {
if (!ev.detail) {

View File

@ -7,8 +7,10 @@
import type { WidgetChange, AttributeUpdate } from "../../types/base";
import type { UpEntry, UpListing } from "@upnd/upend";
import IconButton from "../utils/IconButton.svelte";
import Selector from "../utils/Selector.svelte";
import type { IValue } from "@upnd/upend/types";
import Selector, {
selectorValueAsValue,
type SelectorValue,
} from "../utils/Selector.svelte";
import Editable from "../utils/Editable.svelte";
import { query } from "../../lib/entity";
import { type Readable, readable } from "svelte/store";
@ -28,7 +30,6 @@
export let entries: UpEntry[];
export let attributes: string[] | undefined = undefined;
export let attributeOptions: string[] | undefined = undefined;
// Display
$: displayColumns = (columns || "entity, attribute, value")
@ -62,16 +63,16 @@
let addFocus = false;
let newAttrSelector: Selector;
let newEntryAttribute = "";
let newEntryValue: IValue | undefined;
let newEntryValue: SelectorValue | undefined;
$: if (adding && newAttrSelector) newAttrSelector.focus();
$: if (!addFocus && !addHover) adding = false;
async function addEntry(attribute: string, value: IValue) {
async function addEntry(attribute: string, value: SelectorValue) {
dispatch("change", {
type: "create",
attribute,
value,
value: await selectorValueAsValue(value),
} as WidgetChange);
newEntryAttribute = "";
newEntryValue = undefined;
@ -84,13 +85,13 @@
async function updateEntry(
address: string,
attribute: string,
value: IValue,
value: SelectorValue,
) {
dispatch("change", {
type: "update",
address,
attribute,
value,
value: await selectorValueAsValue(value),
} as AttributeUpdate);
}
@ -386,9 +387,8 @@
{#if column == ATTR_COL}
<div class="cell mark-attribute">
<Selector
type="attribute"
bind:attribute={newEntryAttribute}
{attributeOptions}
types={["Attribute", "NewAttribute"]}
on:input={(ev) => (newEntryAttribute = ev.detail.name)}
on:focus={(ev) => (addFocus = ev.detail)}
bind:this={newAttrSelector}
/>
@ -396,8 +396,7 @@
{:else if column === VALUE_COL}
<div class="cell mark-value">
<Selector
type="value"
bind:value={newEntryValue}
on:input={(ev) => (newEntryValue = ev.detail)}
on:focus={(ev) => (addFocus = ev.detail)}
/>
</div>

View File

@ -24,33 +24,49 @@ type Story = StoryObj<Selector>;
export const Attribute: Story = {
args: {
type: "attribute",
types: ["Attribute", "NewAttribute"],
},
};
export const AllValues: Story = {
args: {
type: "value",
types: [
"Attribute",
"NewAttribute",
"Address",
"NewAddress",
"String",
"Number",
],
},
};
export const Entities: Story = {
args: {
type: "value",
valueTypes: ["Address"],
types: ["Address", "NewAddress"],
},
};
export const ExistingEntities: Story = {
args: {
types: ["Address"],
},
};
export const Strings: Story = {
args: {
type: "value",
valueTypes: ["String"],
types: ["String"],
},
};
export const Numbers: Story = {
args: {
type: "value",
valueTypes: ["Number"],
types: ["Number"],
},
};
export const MultipleValues: Story = {
args: {
types: ["String", "Number"],
},
};

View File

@ -9,6 +9,7 @@
import CombineColumn from "../components/CombineColumn.svelte";
import GroupColumn from "../components/GroupColumn.svelte";
import SurfaceColumn from "../components/SurfaceColumn.svelte";
import type { SelectorValue } from "src/components/utils/Selector.svelte";
const navigate = useNavigate();
const params = useParams();
@ -16,7 +17,10 @@
let identities: string[] = [];
$: addresses = $params.addresses.split(",");
function add(address: string) {
function add(value: SelectorValue) {
if (value.t !== "Address") return;
const address = value.c;
let _addresses = addresses.concat();
_addresses.push(address);
navigate(`/browse/${_addresses.join(",")}`);
@ -148,7 +152,7 @@
{#key addresses}
<div class="column" data-index="add">
<BrowseAdd
on:input={(ev) => add(ev.detail.c)}
on:input={(ev) => add(ev.detail)}
on:editable={() => scrollToVisible("add")}
/>
</div>