diff --git a/lib/Coocook/Controller/Ajax/Autocomplete.pm b/lib/Coocook/Controller/Ajax/Autocomplete.pm index 2e064a1a6a4c97143168e0c44c7ffc218449193b..65b79416333d1a3acec30776b2adaf59213e642c 100644 --- a/lib/Coocook/Controller/Ajax/Autocomplete.pm +++ b/lib/Coocook/Controller/Ajax/Autocomplete.pm @@ -11,28 +11,28 @@ sub base : Chained('/base') PathPart('autocomplete') CaptureArgs(0) { } # TODO limit number of results # TODO sanitize input? # TODO split words in input -# TODO deterministic sort order? -# TODO rank direct matches first -sub organizations_users : GET HEAD Chained('base') Args(0) RequiresCapability('autocomplete_users') -{ +sub organizations_users : GET HEAD Chained('base') Args(0) + RequiresCapability('autocomplete_organizations') # wrap these two lines + RequiresCapability('autocomplete_users') { my ( $self, $c ) = @_; - my $search = $c->req->params->get('search'); + my $organizations_users = $c->model('Autocomplete') + ->organizations_users( $c->req->params->get('search'), [ $c->req->params->get_all('ignore') ] ); - my $users = $c->model('Autocomplete')->organizations_users($search) + $organizations_users or $c->detach('/error/bad_request'); - $c->stash( ajax_response => $users ); + $c->stash( ajax_response => $organizations_users ); } -sub users : GET HEAD Chained('base') Args(0) RequiresCapability('autocomplete_organizations') - RequiresCapability('autocomplete_users') { +sub users : GET HEAD Chained('base') Args(0) RequiresCapability('autocomplete_users') { my ( $self, $c ) = @_; - my $search = $c->req->params->get('search'); + my $users = $c->model('Autocomplete') + ->users( $c->req->params->get('search'), [ $c->req->params->get_all('ignore') ] ); - my $users = $c->model('Autocomplete')->users($search) + $users or $c->detach('/error/bad_request'); $c->stash( ajax_response => $users ); diff --git a/lib/Coocook/Controller/Article.pm b/lib/Coocook/Controller/Article.pm index 4a5c0ad01af622ac320e957b3c5aee61e622b4b0..6b564d85270ff3a46fde159b697ab19d4a8775fe 100644 --- a/lib/Coocook/Controller/Article.pm +++ b/lib/Coocook/Controller/Article.pm @@ -113,7 +113,10 @@ sub edit : GET HEAD Chained('base') PathPart('') Args(0) RequiresCapability('vie my $units = $article->units; - $c->stash( submit_url => $c->project_uri( $self->action_for('update'), $article->id ) ); + $c->stash( + submit_url => $c->project_uri( $self->action_for('update'), $article->id ), + static_base_url => $c->uri_for_static('/'), + ); } =head1 CRUD ENDPOINTS diff --git a/lib/Coocook/Controller/Dish.pm b/lib/Coocook/Controller/Dish.pm index 0a380a1fbc32bf7a17dd35deef35369eb0ac0dbb..9c3bc1cfd3878939e184da750b6f68c4296c9420 100644 --- a/lib/Coocook/Controller/Dish.pm +++ b/lib/Coocook/Controller/Dish.pm @@ -68,6 +68,7 @@ sub edit : GET HEAD Chained('base') PathPart('') Args(0) RequiresCapability('vie prepare_meals => [ $prepare_meals->all ], add_ingredient_url => $c->project_uri( '/dish/add', $dish->id ), delete_url => $c->project_uri( '/dish/delete', $dish->id ), + static_base_url => $c->uri_for_static('/'), ); for my $ingredient ( $c->stash->{ingredients}->@* ) { diff --git a/lib/Coocook/Controller/Organization/Member.pm b/lib/Coocook/Controller/Organization/Member.pm index 7ba42b02a51901142eb6dc4d826a714a1eb65009..2540a0e262ceea6ea18d4186d5c625fbc1469a47 100644 --- a/lib/Coocook/Controller/Organization/Member.pm +++ b/lib/Coocook/Controller/Organization/Member.pm @@ -57,10 +57,15 @@ sub index : GET HEAD Chained('/organization/base') PathPart('members') Args(0) $_->{edit_url} = $c->uri_for( $self->action_for('edit'), [ $organization->name, $user->name ] ); } - if ( my @other_users = $organization->users_without_membership->verified->sorted->hri->all ) { + if ( $c->has_capability('manage_organization_memberships') + and my @other_users = $organization->users_without_membership->verified->sorted->hri->all ) + { $c->stash( - add_url => $c->uri_for( $self->action_for('add'), [ $organization->name ] ), - other_users => \@other_users, + add_url => $c->uri_for( $self->action_for('add'), [ $organization->name ] ), + static_base_url => $c->uri_for_static('/'), + other_users => \@other_users, + autocomplete_data_url => + $c->uri_for_action( '/ajax/autocomplete/users', { exclude_organization => $organization->id } ), ); } diff --git a/lib/Coocook/Controller/Permission.pm b/lib/Coocook/Controller/Permission.pm index e8b6b5a91100414f04665f03ee36a30f6129d126..2bfbc932096baa61462732a13ffaf6d5bbb6ef41 100644 --- a/lib/Coocook/Controller/Permission.pm +++ b/lib/Coocook/Controller/Permission.pm @@ -76,7 +76,14 @@ sub index : GET HEAD Chained('/project/submenu') PathPart('permissions') Args(0) @other_identities > 0 and $c->stash( other_identities => \@other_identities ); - $c->stash( add_permission_url => $c->project_uri( $self->action_for('add') ) ); + $c->stash( + add_permission_url => $c->project_uri( $self->action_for('add') ), + static_base_url => $c->uri_for_static('/'), + autocomplete_data_url => $c->uri_for_action( + '/ajax/autocomplete/organizations_users', + { exclude_project => $c->project->id } + ), + ); } $c->stash( diff --git a/lib/Coocook/Controller/Recipe.pm b/lib/Coocook/Controller/Recipe.pm index 2e6e2b496658a6ded168d7409e3e9522b2145eca..69d05e6f1a187d7ffd803b61fe2b475eb2d19777 100644 --- a/lib/Coocook/Controller/Recipe.pm +++ b/lib/Coocook/Controller/Recipe.pm @@ -119,6 +119,7 @@ sub edit : GET HEAD Chained('base') PathPart('') Args(0) RequiresCapability('vie dishes => \@dishes, update_url => $c->project_uri( $self->action_for('update'), $recipe->id ), add_ingredient_url => $c->project_uri( $self->action_for('add'), $recipe->id ), + static_base_url => $c->uri_for_static('/'), ); $recipe->project->is_public diff --git a/lib/Coocook/Model/Authorization.pm b/lib/Coocook/Model/Authorization.pm index c7f0a2b8cb1bb5ca1d3802c229c22ddedaa57441..af3b0c6452f01b920e8fb3cf07576e91ad2f52bd 100644 --- a/lib/Coocook/Model/Authorization.pm +++ b/lib/Coocook/Model/Authorization.pm @@ -56,7 +56,7 @@ my @rules = ( or $user->has_any_organization_role( $organization, 'owner', 'admin' ) ); }, - grants_capabilities => [qw< edit_organization >], + grants_capabilities => [qw< edit_organization manage_organization_memberships >], }, { needs_input => [ 'user', 'organization', 'user_object', 'role' ], diff --git a/lib/Coocook/Model/Autocomplete.pm b/lib/Coocook/Model/Autocomplete.pm index 514ff8a38ebf04ed4c6bcdf97b47b4c3669adf36..3d275e06497b9fe7468dcf26d7cc13a5fe2440a8 100644 --- a/lib/Coocook/Model/Autocomplete.pm +++ b/lib/Coocook/Model/Autocomplete.pm @@ -20,41 +20,55 @@ sub ACCEPT_CONTEXT ( $self, $c, @args ) { return $self; } -sub organizations_users ( $self, $search ) { +sub organizations_users ( $self, $search, $ignore = undef ) { defined $search or return; - my $arrayref = $self->users($search); - $_->{type} = 'user' for @$arrayref; + my @columns = qw( name display_name ); - my $organizations = $self->schema->resultset('Organization')->search( + my ($ignore_cond) = + map { + ref eq 'ARRAY' ? { name => { -not_in => $_ } } + : defined ? { name => { '!=' => $_ } } + : undef + } $ignore; + + my $organizations = $self->schema->resultset('Organization')->hri->search( + $ignore_cond, + { + columns => \@columns, + '+columns' => { type => \q('organization' AS type) }, + } + ); + + my $users = $self->schema->resultset('User')->verified->hri->search( + $ignore_cond, { - -or => [ - name => { like => "%$search%" }, - display_name => { like => "%$search%" }, - ], + columns => \@columns, + '+columns' => { type => \q('user' AS type) }, } ); - push @$arrayref, - map { $_->{type} = 'organization'; $_ } - $organizations->search( undef, { columns => [ 'name', 'display_name' ] } )->hri->all; + my $union = $organizations->union($users)->like_best_match( $search, \@columns ); - return $arrayref; + return [ $union->all ]; } -sub users ( $self, $search ) { +sub users ( $self, $search, $ignore = undef ) { defined $search or return; + my @columns = qw( name display_name ); + my $users = $self->schema->resultset('User')->verified->search( - { - -or => [ - name => { like => "%$search%" }, - display_name => { like => "%$search%" }, - ], - } + map( # perltidy + ref eq 'ARRAY' ? { name => { -not_in => $_ } } + : defined ? { name => { '!=' => $_ } } + : undef, + $ignore + ), + { columns => \@columns } ); - return [ $users->search( undef, { columns => [ 'name', 'display_name' ] } )->hri->all ]; + return [ $users->like_best_match( $search, \@columns )->hri->all ]; } 1; diff --git a/lib/Coocook/Schema/ResultSet.pm b/lib/Coocook/Schema/ResultSet.pm index 6156e35252298425cb83d399f55a014f522ec3a7..3a32af686695b9d9a45422975d58589c9b97211c 100644 --- a/lib/Coocook/Schema/ResultSet.pm +++ b/lib/Coocook/Schema/ResultSet.pm @@ -70,4 +70,76 @@ sub assert_no_sth ($self) { defined( $self->cursor->{sth} ) and croak "Statement already running"; } +=head2 $rs->like_best_match( $search_string, \@columns ) + +Search for rows that have any of C<@columns> C C<$search_string> +(case-insensitively!) and order results by + +=over 4 + +=item 1. + +complete match in any column + +=item 2. + +match at the start of any column + +=item 3. + +match anywhere in any column + +=item 4. + +alphabetically by C<@columns> in given order + +=back + +=cut + +sub like_best_match { + my ( $self, $search, $columns ) = @_; + + my $sqlt_type = $self->result_source->storage->sqlt_type; + my $ILIKE = { + 'SQLite' => 'LIKE', # has no ILIKE operator, is case-insensitively anyway + 'PostgreSQL' => 'ILIKE', + }->{$sqlt_type} + || die "unexpected database type: " . $sqlt_type; + + for ($search) { # escape special characters + s/\\/\\\\/g; + s/%/\\%/g; + s/_/\\_/g; + } + + my $match_complete = $search; + my $match_start = "$search%"; + my $match_anywhere = "%$search%"; + + my $columns_like = join ' OR ', map { qq($_ $ILIKE ? ESCAPE '\\') } @$columns; + + return $self->search( + [ # OR + map +{ $_ => \[ qq($ILIKE ? ESCAPE '\\'), $match_anywhere ] }, @$columns + ], + { + order_by => [ + \[ + <<~SQL, + CASE + WHEN $columns_like THEN 1 + WHEN $columns_like THEN 2 + WHEN $columns_like THEN 3 + ELSE NULL -- PostgreSQL calculates this for all rows, even not matching at all + END + SQL + map { ($_) x @$columns } ( $match_complete, $match_start, $match_anywhere ) + ], + @$columns # fallback: alphabetically + ], + } + ); +} + 1; diff --git a/root/static/css/autocomplete.css b/root/static/css/autocomplete.css deleted file mode 100644 index 474c748df1faa237184fc11e20309f736759ac57..0000000000000000000000000000000000000000 --- a/root/static/css/autocomplete.css +++ /dev/null @@ -1,165 +0,0 @@ -:host { - --primary: #3498db; - --light: #f3f3f3; - --border-color: rgb(206, 212, 218); - --border-radius: 0.25em; - --list-bg: #fff; - --list-col: #333; - --list-border: #d4d4d4; - --list-active-col: #fff; - --list-hover-bg: #e9e9e9; - --list-hover-col: var(--list-col); -} - -*, -::after, -::before { - box-sizing: border-box; -} - -.hide { - display: none !important; -} - -.form-control:focus { - color: #212529; - background-color: #fff; - border-color: #a6d7a8; - outline: 0; -} - -.form-control { - display: block; - width: 100%; - padding: 0.375rem 0.75rem; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #212529; - background-color: #fff; - background-clip: padding-box; - border: 1px solid var(--border-color); - border-color: rgb(206, 212, 218); - appearance: none; - border-radius: var(--border-radius); - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} - -input[type=search] { - padding-left: 40px; - background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' class='bi bi-search' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z'%3E%3C/path%3E%3C/svg%3E") no-repeat 13px center; -} - -/*** close btn ***/ -.close { - box-sizing: content-box; - width: 1em; - height: 1em; - padding: 0.25em 0.25em; - color: #000; - background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat; - border: 0; - border-radius: var(--border-radius); - opacity: 0.5; -} - -.close:focus { - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(132, 32, 41, 0.25); - opacity: 1; -} - -.close:hover { - color: #000; - text-decoration: none; - opacity: 0.75; -} - -/*** display value ***/ -.value { - display: flex; - align-items: center; - justify-content: space-between; - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - margin-top: 2px; - padding: 1em; -} - -.value :last-child { - margin-left: 1em; -} - -/*** autocomplete list ***/ -.autocomplete { - position: relative; -} - -.autocomplete .items { - position: absolute; - border: 1px solid var(--border-color); - z-index: 99; - /*position the autocomplete items to be the same width as the container:*/ - top: 100%; - left: 0; - right: 0; - max-height: 40vh; - color: var(--list-col); - background-color: var(--list-bg); - border-radius: var(--border-radius); - overflow: auto; -} - -.autocomplete .items.top { - bottom: calc(100% + 39px); - top: unset; -} - -.autocomplete .items .option { - padding: 10px; - cursor: pointer; - background-color: var(--list-bg); - border-bottom: 1px solid var(--list-border); -} - -.autocomplete .items .option:last-child { - border-bottom: none; -} - -.autocomplete .items .option:hover { - color: var(--list-hover-col); - background-color: var(--list-hover-bg); -} - -.autocomplete .active { - background-color: var(--primary) !important; - color: var(--list-active-col); -} - -/*** Loader ***/ -.load-wrapper { - display: flex; - align-items: center; - justify-content: center; - background: var(--list-bg); -} - -.loader { - border: 5px solid var(--light); - border-top: 5px solid var(--primary); - border-radius: 50%; - width: 60px; - height: 60px; - margin: 1em; - animation: spin 2s linear infinite; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} diff --git a/root/static/js/autocomplete.js b/root/static/js/autocomplete.js deleted file mode 100644 index 86d6352ac590cbce6de28f1d0bd8ba756e44be5a..0000000000000000000000000000000000000000 --- a/root/static/js/autocomplete.js +++ /dev/null @@ -1,260 +0,0 @@ -const DEFAULT_ENDPOINT = { - url: window.location, - searchKey: "search", - limitKey: "limit", - limit: 0, - additionalQuery: "" -}; - -const DEFAULT_PREFIX = { - default: "" -}; - -class Autocomplete extends HTMLElement { - constructor() { - super(); - - this.alertFunc = window[this.getAttribute("msg-func")]; - this.invalid = JSON.parse(this.getAttribute("invalid")); - this.minLength = parseInt(this.getAttribute("min-length")) || 2; - this.renderFunc = window[this.getAttribute("render")]; - if (typeof this.renderFunc !== "function") this.renderFunc = data => [JSON.stringify(data), JSON.stringify(data)]; - this.filterFunc = window[this.getAttribute("filter")]; - if (typeof this.filterFunc !== "function") this.filterFunc = data => data; - this.currentFocus = -1; - const self = this; - this.setupEndpoint(); - - // delete all elements with class 'delete-by-autocomplete-js' - for (const node of document.getElementsByClassName('delete-by-autocomplete-js')) { - node.remove(); - } - - // Create a shadow root - const shadow = this.attachShadow({mode: "open"}); - - const addInput = () => { - const input = document.createElement("input"); - input.required = true; - input.name = this.getAttribute("input-name") || null; - input.type = "hidden"; - this.input = input; - - this.parentNode.insertBefore(input, this.nextSibling) - this.closest("form").addEventListener("submit", e => { - if (!input.value) { - e.preventDefault(); - - if (this.alertFunc && this.invalid) { - this.alertFunc(...this.invalid); - } - } - }) - } - - const createContent = () => { - const input = document.createElement("input"); - self.search = input; - input.className = "form-control"; - input.type = "search"; - shadow.append(input); - - const container = document.createElement("div"); - container.className = "autocomplete"; - shadow.append(container); - - const options = document.createElement("div"); - self.options = options; - options.className = "items hide"; - container.append(options); - self.setPosition(); - - const value = document.createElement("div"); - value.className = "value"; - shadow.append(value); - - self.dValue = document.createElement("div"); - value.append(self.dValue); - - const clear = document.createElement("button"); - clear.className = "close"; - clear.addEventListener("click", () => self.setInput()); - value.append(clear); - }; - - const createLoader = () => { - self.loader = document.createElement("div"); - self.loader.className = "load-wrapper"; - - const circle = document.createElement("div"); - circle.className = "loader"; - self.loader.append(circle); - }; - - // Add input to form - addInput(); - - // Create loader - createLoader(); - - // Apply styles to the shadow DOM - const linkElem = document.createElement("link"); - linkElem.setAttribute("rel", "stylesheet"); - linkElem.setAttribute("href", "/static/css/autocomplete.css"); - shadow.append(linkElem); - - createContent(); - - // Add event listener - const label = document.querySelector(`[for=${this.id}]`); - if (label) label.addEventListener("click", e => { - e.preventDefault(); - this.search.focus(); - }) - - this.search.addEventListener("keydown", e => { - let list = this.options.children; - if (e.key === "ArrowDown") { - this.currentFocus++; - this.addActive(list); - } else if (e.key === "ArrowUp") { - this.currentFocus--; - this.addActive(list); - } else if (e.key === "Enter") { - e.preventDefault(); - if (this.currentFocus > -1) { - if (list) list[this.currentFocus].click(); - } - } - }); - - this.search.addEventListener("input", e => { - let val = e.target.value; - this.options.innerHTML = ""; - if (!val || val.length <= this.minLength) { - return; - } - this.currentFocus = -1; - let ep = this.endpoint; - this.options.append(this.loader) - this.options.classList.remove("hide"); - this.setPosition(); - - fetch(`${ep.url}?${ep.searchKey}=${val}${ep.limitKey && ep.limit ? `&${ep.limitKey}=${ep.limit}` : ""}${ep.additionalQuery ? `&${ep.additionalQuery}` : ""}`) - .then(res => res.json()) - .then(data => { - this.loader.remove(); - data = this.filterFunc(data); - for (let obj of data) { - this.createOption(obj, val); - } - }); - }); - - let ticking = false; - - document.addEventListener("scroll", (e) => { - if (!ticking) { - window.requestAnimationFrame(() => { - this.setPosition(); - ticking = false; - }); - - ticking = true; - } - }); - - document.addEventListener("click", e => { - if (e.target !== this) { - this.options.classList.add("hide"); - this.search.value = ""; - } - }); - } - - setupEndpoint() { - let tmp = JSON.parse(this.getAttribute("endpoint")); - if (tmp === null || Array.isArray(tmp) || typeof tmp !== "object") tmp = {}; - - this.endpoint = {...DEFAULT_ENDPOINT}; - Object.assign(this.endpoint, tmp); - } - - setInput(value="", display="") { - this.search.value = ""; - this.input.value = value; - this.dValue.innerText = display; - } - - removeActive(list) { - for (let elem of list) { - elem.classList.remove("active"); - } - } - - addActive(list) { - if (!list) return false; - this.removeActive(list); - if (this.currentFocus >= list.length) this.currentFocus = 0; - if (this.currentFocus < 0) this.currentFocus = (list.length - 1); - list[this.currentFocus].classList.add("active"); - scroll(list[this.currentFocus]); - - function scroll(elem) { - const container = elem.parentElement; - const cBox = container.getBoundingClientRect(); - const eBox = elem.getBoundingClientRect(); - - if (cBox.bottom < eBox.bottom) { - container.scrollBy(0, Math.floor(eBox.bottom - cBox.bottom)); - } - - if (eBox.top < cBox.top) { - container.scrollBy(0, Math.floor(eBox.top - cBox.top)); - } - } - } - - createOption(data, value) { - const elem = document.createElement("div"); - elem.className = "option"; - - const [display, val] = this.renderFunc(data); - elem.innerHTML = `${markSearch(display, value)}`; - elem.addEventListener("click", () => { - this.setInput(val, display); - this.options.classList.add("hide"); - }); - this.options.append(elem); - - function markSearch(str, search) { - const re = new RegExp(search.trim(), "gi"); - - for (let match of re.exec(str)) { - str = str.replace(match, `${match}`); - } - - return str - } - } - - setPosition() { - const iBox = this.search.getBoundingClientRect(); - const height = document.documentElement.clientHeight; - const lowerSpace = height - Math.floor(iBox.bottom) - 1; - const upperSpace = Math.floor(iBox.top); - const boxHeight = Math.floor(height * 40 / 100) + 1; - - let action = "remove"; - if (lowerSpace < boxHeight) { - action = "add"; - if (upperSpace < boxHeight) { - action = "remove"; - } - } - - this.options.classList[action]("top"); - } -} - -customElements.define("cc-autocomplete", Autocomplete); diff --git a/root/static/js/organization/members.js b/root/static/js/organization/members.js index 11cac60febee1233a588a5e66271b991756a1fc0..5b926a5ce2f85c2ef4f894e77423faa54354931f 100644 --- a/root/static/js/organization/members.js +++ b/root/static/js/organization/members.js @@ -9,12 +9,12 @@ function renderUserOption(data) { .map((elem) => ({ id: elem.name, name: elem.name, - mark: true, - display: `groups ${elem.display_name} (${elem.name})`, + mark: `${elem.display_name} (${elem.name})`, + display: `groups {{label}}`, })); } setTimeout(() => { - const search = document.getElementById("user"); - search.renderFunc = renderUserOption; + const ajaxAutocomplete = document.getElementById("user"); + ajaxAutocomplete.renderFunc = renderUserOption; }, 1_000); diff --git a/root/static/js/project/permissions.js b/root/static/js/project/permissions.js index d49d8a14a8bcc9e6595b1d6ec3b1a84b78a58ad9..b18c61a49a903102b11567d97d125df5198d3359 100644 --- a/root/static/js/project/permissions.js +++ b/root/static/js/project/permissions.js @@ -1,28 +1,15 @@ function renderUserOrgOption(data) { - return data - .filter((elem) => !userList.includes(elem.name)) - .filter((elem) => !orgList.includes(elem.name)) - .sort((a, b) => a.display_name > b.display_name) - .map((elem) => ({ - id: elem.name, - name: elem.name, - mark: true, - display: `${ - elem.type === "user" ? "person" : "groups" - } ${elem.display_name} (${elem.name})`, - })); + return data.map((elem) => ({ + id: elem.name, + name: elem.name, + mark: `${elem.display_name} (${elem.name})`, + display: `${ + elem.type === "user" ? "person" : "groups" + } {{label}}`, + })); } -const userList = Array.from( - document.querySelectorAll(".username"), - (elem) => elem.innerText -); -const orgList = Array.from( - document.querySelectorAll(".organization"), - (elem) => elem.innerText -); - setTimeout(() => { - const search = document.getElementById("user"); - search.renderFunc = renderUserOrgOption; + const ajaxAutocomplete = document.getElementById("user"); + ajaxAutocomplete.renderFunc = renderUserOrgOption; }, 1_000); diff --git a/root/static/js/tagAutocomplete.js b/root/static/js/tagAutocomplete.js index abfeb8024f802c2bea03a9d0f5ea8a4ebda7f7f7..7681261de6bc0d704137e72acd87d4359cf62f97 100644 --- a/root/static/js/tagAutocomplete.js +++ b/root/static/js/tagAutocomplete.js @@ -5,7 +5,7 @@ function getTagOptions() { return tags.map((elem) => ({ id: elem.name, name: elem.name, - mark: true, + mark: elem.name, })); } diff --git a/root/templates/organization/members.tt b/root/templates/organization/members.tt index 3348f5afd314059829a6932ed679d0c8a3cbe202..851c873b48096cc67c895fe54dd7344276dbb82c 100644 --- a/root/templates/organization/members.tt +++ b/root/templates/organization/members.tt @@ -64,7 +64,7 @@ has_admin = 0 %] [% END %] - +[% IF add_url %]

Add member

@@ -81,6 +81,7 @@ has_admin = 0 %] name="name" data-url="[% autocomplete_data_url | html %]" static-base-url="[% static_base_url | html %]" + additionalQuery="exclude_organization=[% organization.id %]" >
@@ -101,6 +102,7 @@ has_admin = 0 %] [% END %]
+[% END %] [% WRAPPER includes/infobox.tt %] Organization members share the same permissions on projects diff --git a/root/templates/project/permissions.tt b/root/templates/project/permissions.tt index 7798a281d0129389abab3302715a539a9ae05c2e..e0147f1978c90482583e09d735dc36316f7e9b16 100644 --- a/root/templates/project/permissions.tt +++ b/root/templates/project/permissions.tt @@ -114,6 +114,7 @@ has_admin = 0 %] name="id" data-url="[% autocomplete_data_url | html %]" static-base-url="[% static_base_url | html %]" + additionalQuery="exclude_project=[% project.id %]" >
diff --git a/t/controller_Autocomplete.t b/t/controller_Autocomplete.t index 29aae4c3a19bf1c968a4a817b2ec7a7ac37d0c61..a4b0ef284d49aba64bf0024b02888c4f77abaedc 100644 --- a/t/controller_Autocomplete.t +++ b/t/controller_Autocomplete.t @@ -11,41 +11,46 @@ $t->get_ok('/'); $t->login_ok( 'john_doe', 'P@ssw0rd' ); subtest users => sub { - $t->get_ok('/autocomplete/users?search=o'); + $t->get('/autocomplete/users'); + $t->status_is(400); + + $t->get_ok('/autocomplete/users?search=zzz'); + $t->status_is(200); + $t->header_is( 'Content-Type' => 'application/json; charset=utf-8' ); + $t->json_is( [] ); + + $t->get_ok( my $matching_url = '/autocomplete/users?search=o' ); $t->status_is(200); $t->header_is( 'Content-Type' => 'application/json; charset=utf-8' ); $t->json_is( [ - { name => "john_doe", display_name => "John Doe" }, { name => "other", display_name => "Other User" }, + { name => "john_doe", display_name => "John Doe" }, ] ); - $t->get_ok('/autocomplete/users?search=zzz'); + $t->get_ok( $matching_url . '&ignore=other' ); $t->status_is(200); $t->header_is( 'Content-Type' => 'application/json; charset=utf-8' ); - $t->json_is( [] ); - - $t->get('/autocomplete/users'); - $t->status_is(400); + $t->json_is( [ hash { field name => 'john_doe'; etc } ] ); }; subtest organizations_users => sub { + $t->get('/autocomplete/organizations_users'); + $t->status_is(400); + + $t->get_ok('/autocomplete/organizations_users?search=zzz'); + $t->status_is(200); + $t->header_is( 'Content-Type' => 'application/json; charset=utf-8' ); + $t->json_is( [] ); + $t->get_ok('/autocomplete/organizations_users?search=s'); $t->status_is(200); $t->header_is( 'Content-Type' => 'application/json; charset=utf-8' ); $t->json_is( [ - { type => 'user', name => 'other', display_name => 'Other User' }, { type => 'organization', name => 'TestData', display_name => 'Test Data' }, + { type => 'user', name => 'other', display_name => 'Other User' }, ] ); - - $t->get_ok('/autocomplete/organizations_users?search=zzz'); - $t->status_is(200); - $t->header_is( 'Content-Type' => 'application/json; charset=utf-8' ); - $t->json_is( [] ); - - $t->get('/autocomplete/organizations_users'); - $t->status_is(400); }; diff --git a/t/model_Autocomplete.t b/t/model_Autocomplete.t new file mode 100644 index 0000000000000000000000000000000000000000..2f4b837621ff2870218069888400347356093219 --- /dev/null +++ b/t/model_Autocomplete.t @@ -0,0 +1,105 @@ +use Test2::V0; + +use Coocook::Model::Autocomplete; + +use lib 't/lib/'; +use TestDB; + +plan(3); + +my $db = TestDB->new; + +ok my $autocomplete = Coocook::Model::Autocomplete->new( schema => $db ); + +subtest organizations_users => sub { + is $autocomplete->organizations_users('foobar') => [], "no matches"; + + is $autocomplete->organizations_users('s') => bag { + item hash { field name => 'other'; field type => 'user'; etc }; + item hash { field name => 'TestData'; field type => 'organization'; etc }; + }, + "matches of both types with type field"; + + $db->resultset('Organization')->populate( + [ + map +{ + owner_id => 1, + description_md => '', + name => $_, + name_fc => $_, + display_name => $_, + }, + qw( + _x x_ x y z + sort_c sort_a sort_b + ) + ] + ); + + is $autocomplete->organizations_users('x') => [ + hash { field name => 'x'; etc }, # complete match + hash { field name => 'x_'; etc }, # match at start + hash { field name => '_x'; etc }, # match anywhere + ], + "matches sorted by best match"; + + is $autocomplete->organizations_users('sort_') => [ + hash { field name => 'sort_a'; etc }, + hash { field name => 'sort_b'; etc }, + hash { field name => 'sort_c'; etc }, + ], + "matches of equal priority sorted alphabetically"; +}; + +subtest users => sub { + is $autocomplete->users('foobar') => []; + + is $autocomplete->users('john_doe') => [ + { + name => 'john_doe', + display_name => 'John Doe', + } + ], + "format of a single user hash"; + + $db->resultset('User')->populate( + [ + map +{ + name => $_, + name_fc => $_, + display_name => $_, + email_fc => $_, + email_verified => \'CURRENT_TIMESTAMP', + password_hash => $_, + }, + qw( + _x x_ x y z + sort_c sort_a sort_b + so%rt so\rt so_rt + ) + ] + ); + + is $autocomplete->users('x') => [ + hash { field name => 'x'; etc }, # complete match + hash { field name => 'x_'; etc }, # match at start + hash { field name => '_x'; etc }, + ], + "matches sorted by best match"; + + is $autocomplete->users('sort_') => [ + hash { field name => 'sort_a'; etc }, + hash { field name => 'sort_b'; etc }, + hash { field name => 'sort_c'; etc }, + ], + "matches of equal priority sorted alphabetically"; + + for ( [ percent => '%' ], [ backslash => '\\' ], [ underscore => '_' ] ) { + my ( $name, $symbol ) = @$_; + + is $autocomplete->users("o$symbol") => [ + hash { field name => "so${symbol}rt"; etc }, # but not sort_* + ], + "$name sign '$symbol' is escaped"; + } +}; diff --git a/t/postgresql.t b/t/postgresql.t index 7f95b9f13689e33ffb1cfa4bda5ad8c5d7e0eeec..6a55480ed132c6c44131b9d195ab09bfd53c84b7 100644 --- a/t/postgresql.t +++ b/t/postgresql.t @@ -55,7 +55,7 @@ BEGIN { # show progress 1/x as early as possible $FIRST_PGSQL_SCHEMA_VERSION = 21; - plan tests => 3 + ( $Coocook::Schema::VERSION - $FIRST_PGSQL_SCHEMA_VERSION ) + 14; + plan tests => 3 + ( $Coocook::Schema::VERSION - $FIRST_PGSQL_SCHEMA_VERSION ) + 15; } END { # remove temporary databases @@ -65,6 +65,7 @@ END { # remove temporary databases } # these are only loaded if test requirements are fulfilled +use Coocook::Model::Autocomplete; use Coocook::Model::ProjectImporter; use Coocook::Script::Dbck; use Data::Dumper; @@ -424,6 +425,54 @@ subtest "issue #346 unit conversions not normalized after import" => sub { ok !$test_non_normalized_units_exist->(), "all unit conversions are normalized"; }; +subtest "ResultSet->like_best_match()" => sub { + my $users = $schema_from_dbic->resultset('User'); + + ok my @users_with_o = $users->verified->like_best_match( 'o', [ 'name', 'display_name' ] )->all, + "like_best_match('o')"; + + my @ids = map { $_->id } @users_with_o; + isnt \@ids => [ sort @ids ], + "matches aren't sorted by ID"; # test would be broken + + my @unordered_users = $users->verified->get_column('id')->all; + isnt \@ids => \@unordered_users, + "match order doesn't equal default order without ORDER BY clause"; # test would be broken + + is [ map { $_->name } @users_with_o ] => [qw( other john_doe )], + "starting match ordered before middle match"; + + ok my $autocomplete = Coocook::Model::Autocomplete->new( schema => $schema_from_dbic ); + + is $autocomplete->organizations_users('S') => array { + item hash { field display_name => 'Other User'; etc }; + item hash { field display_name => 'Test Data'; etc }; + }, + "Model::Autocomplete->organizations_users('S') matches lower case S"; + + for (qw( T t )) { + is $autocomplete->organizations_users($_) => array { + item hash { field display_name => 'Test Data'; etc }; + item hash { field display_name => 'Other User'; etc }; + }, + "Model::Autocomplete->organizations_users('$_') matches both cases"; + } + + is $autocomplete->users('E') => array { + item hash { field display_name => 'John Doe'; etc }; + item hash { field display_name => 'Other User'; etc }; + }, + "Model::Autocomplete->users('E') matches lower case E"; + + for (qw( O o )) { + is $autocomplete->users($_) => array { + item hash { field display_name => 'Other User'; etc }; + item hash { field display_name => 'John Doe'; etc }; + }, + "Model::Autocomplete->users('$_') matches both cases"; + } +}; + # explicitly destroy DBIC objects before Pg. # otherwise order of destruction is random # and DBIC might throw errors if Pg is already dead diff --git a/web-dependencies.yaml b/web-dependencies.yaml index 10dd5a062c710382c2bd652194751ae2eca1d69d..fb4b404679521cb13235fd0742415419848ccc0a 100644 --- a/web-dependencies.yaml +++ b/web-dependencies.yaml @@ -1,7 +1,7 @@ dependencies: - name: coocook-web-components url: https://gitlab.com/api/v4/projects/37211446/packages/generic/coocook-web-components/v${VERSION}/coocook-web-components-v${VERSION}.tar.gz - version: 0.7.1 + version: 0.7.3 - name: bootstrap url: https://github.com/twbs/bootstrap/archive/refs/tags/v${VERSION}.tar.gz version: 5.1.3