Skip to content

Commit

Permalink
[ACS-7338] Simple search input component (#9454)
Browse files Browse the repository at this point in the history
* feat(content-services): simple search input component for applications

* feat(content-services): support i18n for search input

* feat(content-services): apply review suggestions

* feat(content-services): apply review suggestions

* chore: fix husky install
  • Loading branch information
DenysVuika authored Mar 22, 2024
1 parent adec3e6 commit aab03cc
Show file tree
Hide file tree
Showing 14 changed files with 342 additions and 9 deletions.
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

0 comments on commit aab03cc

Please sign in to comment.