Compare commits

...

2 Commits

Author SHA1 Message Date
jordi fita mas 82eb8a2733 Start the tag input custom element
This is more or less the same as a multiselect, except that now it
adds a list of string element that you write into the search element.

It is supposed to fetch a list of tag suggestions from the server, but i
have not implemented it yet.
2023-03-19 23:11:40 +01:00
jordi fita mas 356d0a0892 Handle case of no tag given for invoice
In that case, strings.Split() return an array with a single empty string
element, that does not pass the domain check for tag_name in the
database.

And an invoice with no tags would get an array of a single NULL in
array_agg, so i had to convert it to an empty string in order for it
to work as expected.
2023-03-19 23:10:01 +01:00
6 changed files with 169 additions and 14 deletions

View File

@ -56,7 +56,7 @@ func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, company *Company
, invoice_number
, contact.business_name
, contact.slug
, array_agg(tag.name::text)
, array_agg(coalesce(tag.name::text, ''))
, invoice.invoice_status
, isi18n.name
, to_price(total, decimal_digits)
@ -583,7 +583,7 @@ func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
, invoice_date
, notes
, payment_method_id
, string_agg(tag.name, ', ')
, string_agg(tag.name, ',')
from invoice
left join invoice_tag using (invoice_id)
left join tag using(tag_id) where slug = $1
@ -609,7 +609,11 @@ func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*Sel
func (form *invoiceForm) SplitTags() []string {
reg := regexp.MustCompile("[^a-z0-9-]+")
return strings.Split(reg.ReplaceAllString(form.Tags.Val, " "), " ")
tags := strings.Split(reg.ReplaceAllString(form.Tags.Val, ","), ",")
if len(tags) == 1 && len(tags[0]) == 0 {
return []string{}
}
return tags
}
type invoiceProductForm struct {

View File

@ -588,12 +588,12 @@ main > nav {
right: 0;
}
/* Multiselect */
/* Multiselect, tags */
[is="numerus-multiselect"] {
max-width: 35rem;
}
[is="numerus-multiselect"] .tags, [is="numerus-multiselect"] .options {
[is="numerus-multiselect"] .tags, [is="numerus-tags"] .tags, [is="numerus-multiselect"] .options {
font-size: 1em;
list-style: none;
color: var(--numerus--text-color);
@ -602,15 +602,23 @@ main > nav {
border-radius: 0;
}
[is="numerus-multiselect"] .tags {
[is="numerus-multiselect"] .tags, [is="numerus-tags"] .tags {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem 4rem 1rem 2rem;
position: relative;
min-width: 20rem;
}
[is="numerus-multiselect"] .tags {
padding: 1rem 4rem 1rem 2rem;
cursor: pointer;
min-height: calc(1.5em + 2rem);
min-width: 20rem;
}
[is="numerus-tags"] .tags {
padding: 1rem 2rem;
max-width: 40rem;
}
[is="numerus-multiselect"] .tags:after {
@ -625,22 +633,22 @@ main > nav {
cursor: pointer;
}
[is="numerus-multiselect"] .tag {
[is="numerus-multiselect"] .tag, [is="numerus-tags"] .tag {
background-color: var(--numerus--color--hay);
}
[is="numerus-multiselect"] .tag button {
[is="numerus-multiselect"] .tag button, [is="numerus-tags"] .tag button {
border: none;
cursor: pointer;
background: transparent;
min-width: initial;
}
[is="numerus-multiselect"] .tag button:hover {
[is="numerus-multiselect"] .tag button:hover, [is="numerus-tags"] .tag button:hover {
background: rgba(255, 255, 255, .4);
}
[is="numerus-multiselect"] .tags input {
[is="numerus-multiselect"] .tags input, [is="numerus-tags"] .tags input {
flex: 1;
width: 100%;
border: 0;

View File

@ -273,4 +273,130 @@ class Multiselect extends HTMLDivElement {
}
}
class Tags extends HTMLDivElement {
constructor() {
super();
this.initialized = false;
this.tags = [];
}
connectedCallback() {
if (!this.initialized) {
for (const child of this.children) {
switch (child.nodeName) {
case 'INPUT':
this.input = child;
break;
case 'LABEL':
this.label = child;
break;
}
}
if (this.label && this.input) {
this.init();
}
}
}
init() {
this.initialized = true;
this.tagList = document.createElement('div');
this.append(this.tagList);
this.tagList.classList.add('tags');
this.input.type = 'hidden';
const tagsText = this.input.value.split(',');
for (const tagText of tagsText) {
const tagNormalized = Tags.normalize(tagText);
if (tagNormalized !== '' && this.tags.indexOf(tagNormalized) === -1) {
this.tags.push(tagNormalized);
}
}
this.search = document.createElement('input');
this.tagList.append(this.search);
this.search.id = this.input.id;
this.input.removeAttribute('id');
this.input.setAttribute('aria-hidden', 'true');
this.search.setAttribute('spellcheck', 'false');
this.search.setAttribute('autocomplete', 'false');
this.search.addEventListener('keydown', (e) => {
switch (e.code) {
case "Space":
e.preventDefault();
// fallthrough
case "Enter":
if (e.target.value.trim() !== '') {
e.preventDefault();
this.createTag();
}
break;
case "Backspace":
if (e.target.value === '') {
this.removeLastTag();
}
break;
}
});
// Must come after the search input
this.tagList.append(this.label);
this.rebuild();
}
rebuild() {
this.input.value = this.tags.join(',');
this.tagList.querySelectorAll('.tag').forEach((tag) => tag.remove());
if (this.tags.length === 0) {
this.search.setAttribute('placeholder', this.label.textContent);
} else {
this.search.removeAttribute('placeholder');
for (const tagText of this.tags) {
const tag = document.createElement('div');
this.tagList.insertBefore(tag, this.search);
tag.classList.add('tag');
const span = document.createElement('span');
tag.append(span);
span.textContent = tagText;
const button = document.createElement('button');
tag.append(button);
button.type = 'button';
button.textContent = '×';
button.addEventListener('click', (e) => {
e.stopPropagation();
this.removeTag(tagText)
});
}
}
}
createTag() {
const tagText = Tags.normalize(this.search.value);
this.search.value = '';
if (this.tags.indexOf(tagText) === -1) {
this.tags.push(tagText);
this.rebuild();
}
}
static normalize(s) {
return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim();
}
removeLastTag() {
this.tags.pop();
this.rebuild();
}
removeTag(tagText) {
this.tags.splice(this.tags.indexOf(tagText), 1);
this.rebuild();
}
}
customElements.define('numerus-multiselect', Multiselect, {extends: 'div'});
customElements.define('numerus-tags', Tags, {extends: 'div'});

View File

@ -29,6 +29,23 @@
</div>
{{- end }}
{{ define "tags-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}}
<div class="input {{ if .Errors }}has-errors{{ end }}" is="numerus-tags">
<input type="text" name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
{{ if .Required }}required="required"{{ end }} value="{{ .Val }}" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}</label>
{{- if .Errors }}
<ul>
{{- range $error := .Errors }}
<li>{{ . }}</li>
{{- end }}
</ul>
{{- end }}
</div>
{{- end }}
{{ define "hidden-select-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.SelectField*/ -}}
{{- range $selected := .Selected }}

View File

@ -20,7 +20,7 @@
{{ template "select-field" .Customer }}
{{ template "hidden-field" .Number }}
{{ template "hidden-field" .Date }}
{{ template "input-field" .Tags }}
{{ template "tags-field" .Tags }}
{{ template "select-field" .PaymentMethod }}
{{ template "select-field" .InvoiceStatus }}
{{ template "input-field" .Notes }}

View File

@ -21,7 +21,7 @@
{{ template "select-field" .Customer }}
{{ template "input-field" .Number }}
{{ template "input-field" .Date }}
{{ template "input-field" .Tags }}
{{ template "tags-field" .Tags }}
{{ template "select-field" .PaymentMethod }}
{{ template "input-field" .Notes }}