Add edit button to freeEntry selectbox tags

This commit is contained in:
nathan 2023-01-23 17:33:22 -07:00
parent dc3e8c5b7d
commit 7518278948
5 changed files with 365 additions and 60 deletions

View File

@ -443,7 +443,7 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
* @see createTagNode()
* @returns {string}
*/
public get tagTag()
public get tagTag() : string
{
return "et2-tag";
}
@ -458,7 +458,15 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
*/
protected _createTagNode(item)
{
const tag = <Et2Tag>document.createElement(this.tagTag);
let tag;
if(typeof super._createTagNode == "function")
{
tag = super._createTagNode(item);
}
else
{
tag = <Et2Tag>document.createElement(this.tagTag);
}
tag.value = item.value;
tag.textContent = item.getTextLabel().trim();
tag.class = item.classList.value + " search_tag";

View File

@ -13,6 +13,7 @@ import {cleanSelectOptions, SelectOption} from "./FindSelectOptions";
import {Validator} from "@lion/form-core";
import {Et2Tag} from "./Tag/Et2Tag";
import {SlMenuItem} from "@shoelace-style/shoelace";
import {waitForEvent} from "@shoelace-style/shoelace/dist/internal/event";
// Otherwise import gets stripped
let keep_import : Et2Tag;
@ -292,6 +293,7 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
this.handleMenuSelect = this.handleMenuSelect.bind(this);
this._handleChange = this._handleChange.bind(this);
this.handleTagEdit = this.handleTagEdit.bind(this);
this._handleAfterShow = this._handleAfterShow.bind(this);
this._handleSearchBlur = this._handleSearchBlur.bind(this);
this._handleClear = this._handleClear.bind(this);
@ -361,6 +363,11 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
// Normally this should be handled in render(), but we have to add our nodes in
this._addNodes();
}
// Update any tags if edit mode changes
if(changedProperties.has("editModeEnabled"))
{
this.shadowRoot.querySelectorAll(this.tagTag).forEach(tag => tag.editable = this.editModeEnabled);
}
}
/**
@ -393,6 +400,21 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
});
}
/**
* Customise how tags are rendered.
* Override to add edit
*
* @param item
* @protected
*/
protected _createTagNode(item)
{
let tag = <Et2Tag>document.createElement(this.tagTag);
tag.editable = this.editModeEnabled;
return tag;
}
protected _searchInputTemplate()
{
let edit = null;
@ -581,6 +603,8 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
this._searchInputNode?.removeEventListener("change", this._searchInputNode.handleChange);
this._searchInputNode?.addEventListener("change", this._handleSearchChange);
this.dropdown.querySelector('.select__label').addEventListener("change", this.handleTagEdit);
});
}
@ -726,7 +750,11 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
// Find the tag
const path = event.composedPath();
const tag = <Et2Tag>path.find((el) => el instanceof Et2Tag);
this.startEdit(tag);
this.dropdown.hide();
this.updateComplete.then(() =>
{
tag.startEdit(event);
});
}
/**
@ -1197,8 +1225,8 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
if(!this.querySelector("[value='" + text.replace(/'/g, "\\\'") + "']") && !this.__select_options.find(o => o.value == text))
{
this.__select_options.push(<SelectOption>{
value: text,
label: text,
value: text.trim(),
label: text.trim(),
class: "freeEntry"
});
this.requestUpdate('select_options');
@ -1239,8 +1267,41 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
return validators.length > 0 && result.length == 0 || validators.length == 0;
}
public handleTagEdit(event)
{
let value = event.target.value;
let original = event.target.dataset.original_value;
if(!value || !this.allowFreeEntries || !this.validateFreeEntry(value))
{
// Not a good value, reset it.
event.target.variant = "danger"
return false;
}
event.target.variant = "success";
// Add to internal list
this.createFreeEntry(value);
// Remove original from value & DOM
if(value != original)
{
if(this.multiple)
{
this.value = this.value.filter(v => v !== original);
}
else
{
this.value = value;
}
this.querySelector("[value='" + original.replace(/'/g, "\\\'") + "']")?.remove();
this.__select_options = this.__select_options.filter(v => v.value !== original);
}
}
/**
* Start editing an existing (free) tag, or the current value if multiple=false
* Start editing the current value if multiple=false
*
* @param {Et2Tag} tag
*/
@ -1249,23 +1310,21 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
const tag_value = tag ? tag.value : this.value;
// hide the menu
//this.dropdown.hide()
this.dropdown.hide()
// Turn on edit UI
this._activeControls.classList.add("editing", "active");
// Pre-set value to tag value
this._editInputNode.style.display = "";
this._editInputNode.value = tag_value
this._editInputNode.focus();
if(tag)
waitForEvent(this, "sl-after-hide").then(() =>
{
tag.remove();
}
// Turn on edit UI
this._activeControls.classList.add("editing", "active");
// If they abort the edit, they'll want the original back.
this._editInputNode.dataset.initial = tag_value;
// Pre-set value to tag value
this._editInputNode.style.display = "";
this._editInputNode.value = tag_value
this._editInputNode.focus();
// If they abort the edit, they'll want the original back.
this._editInputNode.dataset.initial = tag_value;
})
}
protected stopEdit(abort = false)

View File

@ -21,36 +21,49 @@ export class Et2Tag extends Et2Widget(SlTag)
return [
super.styles,
shoelace, css`
:host {
flex: 1 1 auto;
overflow: hidden;
}
.tag--pill {
overflow: hidden;
}
::slotted(et2-image)
{
height: 20px;
width: 20px;
}
.tag__content {
padding: 0px 0.2rem;
flex: 1 2 auto;
overflow: hidden;
text-overflow: ellipsis;
}
/* Avoid button getting truncated by right side of button */
.tag__remove {
margin-right: 0;
margin-left: 0;
}
`];
:host {
flex: 1 1 auto;
overflow: hidden;
}
.tag--pill {
overflow: hidden;
}
::slotted(et2-image) {
height: 20px;
width: 20px;
}
.tag__content {
padding: 0px 0.2rem;
flex: 1 2 auto;
overflow: hidden;
text-overflow: ellipsis;
}
/* Avoid button getting truncated by right side of button */
.tag__remove {
margin-right: 0;
margin-left: 0;
}
et2-button-icon {
visibility: hidden;
}
:host(:hover) et2-button-icon {
visibility: visible;
}
`];
}
static get properties()
{
return {
...super.properties,
editable: {type: Boolean, reflect: true},
value: {type: String, reflect: true}
}
}
@ -60,7 +73,11 @@ export class Et2Tag extends Et2Widget(SlTag)
super(...args);
this.value = "";
this.pill = false;
this.editable = false;
this.removable = true;
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleChange = this.handleChange.bind(this);
}
protected _styleTemplate() : TemplateResult
@ -70,12 +87,44 @@ export class Et2Tag extends Et2Widget(SlTag)
render()
{
let content;
if(this.isEditing)
{
content = html`${this._editTemplate()}`
}
else
{
content = html`${this._contentTemplate()}
${this.editable ? html`
<et2-button-icon
label=${this.egw().lang("edit")}
name="pencil"
@click=${this.startEdit}
></et2-button-icon>` : ''
}
${this.removable
? html`
<sl-icon-button
part="remove-button"
exportparts="base:remove-button__base"
name="x"
library="system"
label=${this.egw().lang('remove')}
class="tag__remove"
@click=${this.handleRemoveClick}
></sl-icon-button>
`
: ''}
`;
}
return html`
${this._styleTemplate()}
<span
part="base"
class=${classMap({
tag: true,
'tag--editable': this.editable,
'tag--editing': this.isEditing,
// Types
'tag--primary': this.variant === 'primary',
'tag--success': this.variant === 'success',
@ -95,20 +144,7 @@ export class Et2Tag extends Et2Widget(SlTag)
<span part="prefix" class="tag__prefix">
<slot name="prefix"></slot>
</span>
${this._contentTemplate()}
${this.removable
? html`
<sl-icon-button
part="remove-button"
exportparts="base:remove-button__base"
name="x"
library="system"
label=${this.egw().lang('remove')}
class="tag__remove"
@click=${this.handleRemoveClick}
></sl-icon-button>
`
: ''}
${content}
</span>
`;
}
@ -120,6 +156,74 @@ export class Et2Tag extends Et2Widget(SlTag)
<slot></slot>
</span>`;
}
_editTemplate() : TemplateResult
{
return html`
<span part="content">
<et2-textbox value="${this.value}"
@sl-change=${this.handleChange}
@blur=${this.stopEdit}
@click=${e => e.stopPropagation()}
@keydown=${this.handleKeyDown}
></et2-textbox>
</span>
`;
}
startEdit(event? : MouseEvent)
{
if(event)
{
event.stopPropagation();
}
this.isEditing = true;
this.requestUpdate();
this.updateComplete.then(() =>
{
this._editNode.focus();
})
}
stopEdit()
{
this.isEditing = false;
this.dataset.original_value = this.value;
if(!this.editable)
{
return;
}
this.value = this.textContent = this._editNode.value.trim();
this.requestUpdate();
this.updateComplete.then(() =>
{
let event = new Event("change", {
bubbles: true
})
this.dispatchEvent(event);
})
}
get _editNode() : HTMLInputElement
{
return this.shadowRoot.querySelector('et2-textbox');
}
handleKeyDown(event : KeyboardEvent)
{
// Consume event so it doesn't bubble up to select
event.stopPropagation();
if(["Tab", "Enter"].indexOf(event.key) !== -1)
{
this._editNode.blur();
}
}
handleChange(event : CustomEvent)
{
}
}
customElements.define("et2-tag", Et2Tag);

View File

@ -0,0 +1,135 @@
import {assert, fixture, html, oneEvent} from '@open-wc/testing';
import {Et2Select} from "../Et2Select";
import * as sinon from 'sinon';
import {Et2Tag} from "../Tag/Et2Tag";
// Stub global egw for cssImage & widget.egw() to find
// @ts-ignore
window.egw = {
image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=",
lang: i => i + "*",
tooltipUnbind: () => {},
webserverUrl: ""
};
let element : Et2Select;
async function before(editable = true)
{
// Create an element to test with, and wait until it's ready
// @ts-ignore
element = await fixture<Et2Select>(html`
<et2-select label="I'm a select" value="one" multiple="true" .editModeEnabled=${editable}>
<sl-menu-item value="one">One</sl-menu-item>
<sl-menu-item value="two">Two</sl-menu-item>
</et2-select>
`);
// Stub egw()
sinon.stub(element, "egw").returns(window.egw);
await element.updateComplete;
return element;
}
describe("Editable tag", () =>
{
// Setup run before each test
beforeEach(before);
// Make sure it works
it('is defined', () =>
{
assert.instanceOf(element, Et2Select);
});
it("Tag editable matches editModeEnabled", async() =>
{
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
assert.isAbove(tag.length, 0, "No tags found");
assert.isTrue(tag[0].editable);
// Change it to false & force immediate update
element.editModeEnabled = false;
element.syncItemsFromValue();
element.requestUpdate();
await element.updateComplete;
tag = element.shadowRoot.querySelectorAll(element.tagTag);
assert.isAbove(tag.length, 0, "No tags found");
assert.isFalse(tag[0].editable);
});
it("Has edit button when editable ", async() =>
{
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
assert.isAbove(tag.length, 0, "No tags found");
assert.exists(tag[0].shadowRoot.querySelector("et2-button-icon[label='edit*']"), "No edit button");
});
it("Shows input when edit button is clicked", async() =>
{
let tag = element.shadowRoot.querySelectorAll(element.tagTag)[0];
let edit_button = tag.shadowRoot.querySelector("et2-button-icon");
edit_button.click();
await tag.updateComplete;
assert.exists(tag.shadowRoot.querySelector("et2-textbox"), "No input to edit");
});
it("Changes value when edited", async() =>
{
let tag = <Et2Tag>element.shadowRoot.querySelectorAll(element.tagTag)[0];
tag.isEditing = true;
tag.requestUpdate();
await tag.updateComplete;
const listener = oneEvent(tag, "change");
let textbox = tag.shadowRoot.querySelector('et2-textbox');
textbox.value = "changed";
tag.stopEdit();
await listener;
// Value changes
assert.equal(tag.value, "changed");
// Haven't turned on allow free entries, so no change here
assert.equal(element.value, "one", "Tag change caused a value change in parent select, but allowFreeEntries was off");
// Shown as invalid
assert.equal(tag.variant, "danger");
// Turn it on, check again
element.allowFreeEntries = true;
// Re-set to original value
tag.value = "one"
// Change again, this time select should change value too
tag.isEditing = true;
tag.requestUpdate();
await tag.updateComplete;
const listener2 = oneEvent(tag, "change");
textbox = tag.shadowRoot.querySelector('et2-textbox');
textbox.value = "change select too";
tag.stopEdit();
await listener2;
assert.equal(tag.value, "change select too");
// Haven't turned on allow free entries, so no change here
assert.equal(element.value, "change select too", "Tag change did not cause value change in parent select (allowFreeEntries was on)");
});
});
describe("Select is not editable", () =>
{
beforeEach(() => before(false));
it("Does not have edit button when not editable", async() =>
{
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
assert.isAbove(tag.length, 0, "No tags found");
assert.isNull(tag[0].shadowRoot.querySelector("et2-button-icon[label='edit*']"), "Unexpected edit button");
});
});

View File

@ -1304,7 +1304,6 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
{
return (<et2_widget>this.getParent()).egw();
}
// Get the window this object belongs to
let wnd = null;
// @ts-ignore Technically this doesn't have implements(), but it's mixed in
@ -1318,7 +1317,7 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
}
// If we're the root object, return the phpgwapi API instance
return typeof egw === "function" ? egw('phpgwapi', wnd) : null;
return typeof egw === "function" ? egw('phpgwapi', wnd) : (window['egw'] ? window['egw'] : null);
}
}