forked from extern/egroupware
Add edit button to freeEntry selectbox tags
This commit is contained in:
parent
dc3e8c5b7d
commit
7518278948
@ -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";
|
||||
|
@ -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)
|
||||
|
@ -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);
|
135
api/js/etemplate/Et2Select/test/EditableTag.test.ts
Normal file
135
api/js/etemplate/Et2Select/test/EditableTag.test.ts
Normal 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");
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user