Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ACS-7338] Simple search input component #9454

Merged
merged 5 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/content-services/components/search-input.component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Search Input

`Component`, `Standalone`

A minimalistic search input component that formats user query according to the provided fields.

```html
<app-search-input
[fields]="['cm:name']"
(changed)="onSearchQueryChanged($event)">
</app-search-input>
```

> Notes: this component does not perform search operations.
> It handles the user input, formats and produces the search query to use with `Search Query Builder` or other services.

## Properties

- `fields` **string[]** - optional, a list of fields to use in the formatted search query, defaults to `[cm:name]`
- `value` **string** - optional, initial input value
- `label` **string** - optional, display label
- `placeholder` **string** - optional, display placeholder

## Events

- `changed` **EventEmitter\<string\>**: emits when user presses `Enter` or moves the focus out of the input area

## Examples

```html
<app-search-input
[fields]="['cm:name', 'cm:title', 'cm:description', 'TEXT', 'TAG']"
(changed)="onSearchQueryChanged($event)">
</app-search-input>
```

In the example above, the search is performed against the following fields:
`cm:name`, `cm:title`, `cm:description`, `TEXT` and `TAG`.

The Search Input is going to produce the following results for user inputs:

user types `test`

```text
(cm:name:"test*" OR cm:title:"test*" OR cm:description:"test*" OR TEXT:"test*" OR TAG:"test*")
```

user types `*`

```text
(cm:name:"**" OR cm:title:"**" OR cm:description:"**" OR TEXT:"**" OR TAG:"**")
```

user types `one two`

```text
(cm:name:"one*" OR cm:title:"one*" OR cm:description:"one*" OR TEXT:"one*" OR TAG:"one*") AND (cm:name:"two*" OR cm:title:"two*" OR cm:description:"two*" OR TEXT:"two*" OR TAG:"two*")
```

user types `one AND two`

```text
(cm:name:"one*" OR cm:title:"one*" OR cm:description:"one*" OR TEXT:"one*" OR TAG:"one*") AND (cm:name:"two*" OR cm:title:"two*" OR cm:description:"two*" OR TEXT:"two*" OR TAG:"two*")
```

user types `one OR two`

```text
(cm:name:"one*" OR cm:title:"one*" OR cm:description:"one*" OR TEXT:"one*" OR TAG:"one*") OR (cm:name:"two*" OR cm:title:"two*" OR cm:description:"two*" OR TEXT:"two*" OR TAG:"two*")
```
2 changes: 2 additions & 0 deletions lib/content-services/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.storybook
coverage
2 changes: 2 additions & 0 deletions lib/content-services/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@
"ARIA-LABEL": "Search button"
},
"INPUT": {
"LABEL": "Search",
"PLACEHOLDER": "Search Query",
"ARIA-LABEL": "Search input"
},
"RESULTS": {
Expand Down
1 change: 1 addition & 0 deletions lib/content-services/src/lib/search/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export * from './search-filter-chips';
export * from './search-filter-container';
export * from './search-filter-tabbed';
export * from './search-form';
export * from './search-input';
export * from './search-logical-filter';
export * from './search-number-range';
export * from './search-panel';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export * from './search-input.component';
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="adf-search-input-container">
<mat-form-field appearance="outline">
<mat-label *ngIf="label">{{ label | translate }}</mat-label>
<input matInput [placeholder]="placeholder | translate" [value]="value" (change)="onSearchInputChanged($event)" />
</mat-form-field>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* stylelint-disable selector-class-pattern */
.adf-search-input-container {
margin: 10px;

mat-form-field {
width: 100%;

.mat-form-field-wrapper {
padding: 0;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { SearchInputComponent } from '@alfresco/adf-content-services';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TranslateModule } from '@ngx-translate/core';
import { ContentTestingModule } from '../../../testing/content.testing.module';

describe('SearchInputComponent', () => {
let loader: HarnessLoader;
let component: SearchInputComponent;
let fixture: ComponentFixture<SearchInputComponent>;

/**
* Sets the search input value
*
* @param value the value to set
*/
async function setInputValue(value: string) {
const input = await loader.getHarness(MatInputHarness);
await input.setValue(value);
await (await input.host()).dispatchEvent('change');

fixture.detectChanges();
await fixture.whenStable();
}

beforeEach(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), ContentTestingModule, SearchInputComponent]
});

fixture = TestBed.createComponent(SearchInputComponent);
component = fixture.componentInstance;

loader = TestbedHarnessEnvironment.loader(fixture);
});

it('should show custom placeholder', async () => {
component.placeholder = 'custom placeholder';

const input = await loader.getHarness(MatInputHarness);
const placeholder = await input.getPlaceholder();
expect(placeholder).toBe('custom placeholder');
});

it('should use multiple fields', async () => {
component.fields = ['cm:description', 'TAG'];

fixture.detectChanges();
await fixture.whenStable();

let formatted = '';
component.changed.subscribe((val) => (formatted = val));

await setInputValue('test');

expect(formatted).toBe('(cm:description:"test*" OR TAG:"test*")');
});

it('should emit changed event with [cm:name]', async () => {
let formatted = '';
component.changed.subscribe((val) => (formatted = val));

await setInputValue('test');

expect(formatted).toBe('(cm:name:"test*")');
});

it('should format with AND by default', async () => {
let formatted = '';
component.changed.subscribe((val) => (formatted = val));

await setInputValue('one two');

expect(formatted).toBe('(cm:name:"one*") AND (cm:name:"two*")');
});

it('should format with OR if specified directly', async () => {
let formatted = '';
component.changed.subscribe((val) => (formatted = val));

await setInputValue('one OR two');

expect(formatted).toBe('(cm:name:"one*") OR (cm:name:"two*")');
});

it('should format with AND if specified directly', async () => {
let formatted = '';
component.changed.subscribe((val) => (formatted = val));

await setInputValue('one AND two');

expect(formatted).toBe('(cm:name:"one*") AND (cm:name:"two*")');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslateModule } from '@ngx-translate/core';

@Component({
selector: 'app-search-input',
standalone: true,
imports: [CommonModule, MatFormFieldModule, MatInputModule, TranslateModule],
templateUrl: `./search-input.component.html`,
styleUrls: ['./search-input.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SearchInputComponent {
@Input()
value = '';

@Input()
label = 'SEARCH.INPUT.LABEL';

@Input()
placeholder = 'SEARCH.INPUT.PLACEHOLDER';

@Input()
fields = ['cm:name'];

@Output()
changed = new EventEmitter<string>();

onSearchInputChanged(event: Event) {
const input = event.target as HTMLInputElement;
const searchTerm = input.value;

const query = this.formatSearchQuery(searchTerm, this.fields);
if (query) {
this.changed.emit(decodeURIComponent(query));
}
}

private formatSearchQuery(userInput: string, fields = ['cm:name']): string {
if (!userInput) {
return null;
}

if (/^https?:\/\//.test(userInput)) {
return this.formatFields(fields, userInput);
}

userInput = userInput.trim();

if (userInput.includes(':') || userInput.includes('"')) {
return userInput;
}

const words = userInput.split(' ');

if (words.length > 1) {
const separator = words.some(this.isOperator) ? ' ' : ' AND ';
return words.map((term) => (this.isOperator(term) ? term : this.formatFields(fields, term))).join(separator);
}

return this.formatFields(fields, userInput);
}

private isOperator(input: string): boolean {
if (input) {
input = input.trim().toUpperCase();

const operators = ['AND', 'OR'];
return operators.includes(input);
}
return false;
}

private formatFields(fields: string[], term: string): string {
let prefix = '';
let suffix = '*';

if (term.startsWith('=')) {
prefix = '=';
suffix = '';
term = term.substring(1);
}

return '(' + fields.map((field) => `${prefix}${field}:"${term}${suffix}"`).join(' OR ') + ')';
}
}
4 changes: 3 additions & 1 deletion lib/content-services/src/lib/search/search.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { SearchDateRangeTabbedComponent } from './components/search-date-range-t
import { SearchFilterTabDirective } from './components/search-filter-tabbed/search-filter-tab.directive';
import { SearchFacetChipTabbedComponent } from './components/search-filter-chips/search-facet-chip-tabbed/search-facet-chip-tabbed.component';
import { SearchFacetTabbedContentComponent } from './components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component';
import { SearchInputComponent } from './components/search-input';

@NgModule({
imports: [
Expand All @@ -67,7 +68,8 @@ import { SearchFacetTabbedContentComponent } from './components/search-filter-ch
ReactiveFormsModule,
MaterialModule,
CoreModule,
SearchTextModule
SearchTextModule,
SearchInputComponent
],
declarations: [
SearchComponent,
Expand Down
1 change: 1 addition & 0 deletions lib/core/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.storybook
1 change: 1 addition & 0 deletions lib/process-services-cloud/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.storybook
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading