Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ yarn-error.log
junit.xml

/src/mirador-viewer/config.local.js
.vscode
4 changes: 3 additions & 1 deletion cypress/e2e/search-navbar.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('Search from Navigation Bar', () => {
// NOTE: these tests currently assume this query will return results!
const query = Cypress.env('DSPACE_TEST_SEARCH_TERM');

it('should go to search page with correct query if submitted (from home)', () => {
it.only('should go to search page with correct query if submitted (from home)', () => {
cy.visit('/');
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
Expand All @@ -26,6 +26,8 @@ describe('Search from Navigation Bar', () => {
page.submitQueryByPressingEnter();
// New URL should include query param
cy.url().should('include', 'query='.concat(query));
// New URL should NOT include the extended param
cy.url().should('not.include', 'extended=true');
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed
Expand Down
2 changes: 1 addition & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module.exports = function (config) {
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
captureConsole: false,
captureConsole: false, // Set to true to get output from tests in the terminal
jasmine: {
failSpecWithNoExpectations: true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class PaginatedSearchOptions extends SearchOptions {
pagination?: PaginationComponentOptions;
sort?: SortOptions;

constructor(options: {configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[], fixedFilter?: any, pagination?: PaginationComponentOptions, sort?: SortOptions, view?: ViewMode}) {
constructor(options: {configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[], fixedFilter?: any, pagination?: PaginationComponentOptions, sort?: SortOptions, view?: ViewMode, expert?: boolean}) {
super(options);
this.pagination = options.pagination;
this.sort = options.sort;
Expand Down
109 changes: 108 additions & 1 deletion src/app/core/shared/search/models/search-options.model.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,101 @@
import { DSpaceObjectType } from '../../dspace-object-type.model';
import { SearchFilter } from './search-filter.model';
import { SearchOptions } from './search-options.model';
import {
escapeLuceneSpecialChars,
SearchOptions,
} from './search-options.model';

describe('escapeLuceneSpecialChars', () => {

it('should return plain text unchanged', () => {
expect(escapeLuceneSpecialChars('hello world')).toEqual('hello world');
});

it('should escape +', () => {
expect(escapeLuceneSpecialChars('a+b')).toEqual('a\\+b');
});

it('should escape -', () => {
expect(escapeLuceneSpecialChars('a-b')).toEqual('a\\-b');
});

it('should escape & (and thereby &&)', () => {
expect(escapeLuceneSpecialChars('a&&b')).toEqual('a\\&\\&b');
});

it('should escape | (and thereby ||)', () => {
expect(escapeLuceneSpecialChars('a||b')).toEqual('a\\|\\|b');
});

it('should escape !', () => {
expect(escapeLuceneSpecialChars('!a')).toEqual('\\!a');
});

it('should escape (', () => {
expect(escapeLuceneSpecialChars('(a')).toEqual('\\(a');
});

it('should escape )', () => {
expect(escapeLuceneSpecialChars('a)')).toEqual('a\\)');
});

it('should escape {', () => {
expect(escapeLuceneSpecialChars('{a')).toEqual('\\{a');
});

it('should escape }', () => {
expect(escapeLuceneSpecialChars('a}')).toEqual('a\\}');
});

it('should escape [', () => {
expect(escapeLuceneSpecialChars('[a')).toEqual('\\[a');
});

it('should escape ]', () => {
expect(escapeLuceneSpecialChars('a]')).toEqual('a\\]');
});

it('should escape ^', () => {
expect(escapeLuceneSpecialChars('^a')).toEqual('\\^a');
});

it('should escape "', () => {
expect(escapeLuceneSpecialChars('"a"')).toEqual('\\"a\\"');
});

it('should escape ~', () => {
expect(escapeLuceneSpecialChars('a~')).toEqual('a\\~');
});

it('should escape *', () => {
expect(escapeLuceneSpecialChars('a*')).toEqual('a\\*');
});

it('should escape ?', () => {
expect(escapeLuceneSpecialChars('a?')).toEqual('a\\?');
});

it('should escape :', () => {
expect(escapeLuceneSpecialChars('title:foo')).toEqual('title\\:foo');
});

it('should escape \\', () => {
expect(escapeLuceneSpecialChars('a\\b')).toEqual('a\\\\b');
});

it('should escape /', () => {
expect(escapeLuceneSpecialChars('a/b')).toEqual('a\\/b');
});

it('should escape multiple special characters in one string', () => {
expect(escapeLuceneSpecialChars('(hello+world)')).toEqual('\\(hello\\+world\\)');
});

it('should escape all special characters when they appear together', () => {
expect(escapeLuceneSpecialChars('+-&|!(){}[]^"~*?:\\/')).toEqual('\\+\\-\\&\\|\\!\\(\\)\\{\\}\\[\\]\\^\\"\\~\\*\\?\\:\\\\\\/');
});

});

describe('SearchOptions', () => {
let options: SearchOptions;
Expand Down Expand Up @@ -40,5 +135,17 @@ describe('SearchOptions', () => {
);
});

it('should escape Lucene special characters in the query', () => {
const specialOptions = new SearchOptions({ query: 'title:foo (bar)+baz' });
const outcome = specialOptions.toRestUrl(baseUrl);
expect(outcome).toEqual('www.rest.com?query=title%5C%3Afoo%20%5C(bar%5C)%5C%2Bbaz');
});

it('should not escape the query when expert mode is enabled', () => {
const expertOptions = new SearchOptions({ query: 'title:foo', expert: true });
const outcome = expertOptions.toRestUrl(baseUrl);
expect(outcome).toEqual('www.rest.com?query=title%3Afoo');
});

});
});
18 changes: 16 additions & 2 deletions src/app/core/shared/search/models/search-options.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import { DSpaceObjectType } from '../../dspace-object-type.model';
import { ViewMode } from '../../view-mode.model';
import { SearchFilter } from './search-filter.model';

/**
* Escapes all Lucene special characters in a query string so they are treated as literals.
* Special characters: + - & | ! ( ) { } [ ] ^ " ~ * ? : \ /
*/
export function escapeLuceneSpecialChars(query: string): string {
return query.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, '\\$&');
}

/**
* This model class represents all parameters needed to request information about a certain search request
*/
Expand All @@ -16,14 +24,15 @@ export class SearchOptions {
view?: ViewMode = ViewMode.ListElement;
scope?: string;
query?: string;
expert?: boolean;
dsoTypes?: DSpaceObjectType[];
filters?: SearchFilter[];
fixedFilter?: string;

constructor(
options: {
configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[],
fixedFilter?: string
fixedFilter?: string, expert?: boolean
},
) {
this.configuration = options.configuration;
Expand All @@ -32,6 +41,7 @@ export class SearchOptions {
this.dsoTypes = options.dsoTypes;
this.filters = options.filters;
this.fixedFilter = options.fixedFilter;
this.expert = options.expert;
}

/**
Expand All @@ -48,7 +58,11 @@ export class SearchOptions {
args.push(this.encodedFixedFilter);
}
if (isNotEmpty(this.query)) {
args.push(`query=${encodeURIComponent(this.query)}`);
if (!this.expert) {
args.push(`query=${encodeURIComponent(escapeLuceneSpecialChars(this.query))}`);
} else {
args.push(`query=${encodeURIComponent(this.query)}`);
}
}
if (isNotEmpty(this.scope)) {
args.push(`scope=${encodeURIComponent(this.scope)}`);
Expand Down
7 changes: 6 additions & 1 deletion src/app/shared/search-form/search-form.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
{{dsoNameService.getName(selectedScope | async) || ('search.form.scope.all' | translate)}}
</button>
}
<input type="text" [(ngModel)]="query" name="query" class="form-control"
<div class="checkbox-wrapper-8">
<input class="tgl" id="cb3-8" name="expert" type="checkbox" [(ngModel)]="expert"/>
<label class="tgl-btn" [attr.data-tg-off]="('search.form.expert_off' | translate )"
[attr.data-tg-on]="('search.form.expert_on' | translate )" for="cb3-8"></label>
</div>
<input type="text" [(ngModel)]="query" name="query" class="form-control"
[attr.aria-label]="searchPlaceholder" [attr.data-test]="'search-box' | dsBrowserOnly"
[placeholder]="searchPlaceholder" tabindex="0">
<button type="submit" class="search-button btn btn-{{brandColor}}" [attr.data-test]="'search-button' | dsBrowserOnly" role="button" tabindex="0"><i class="fas fa-search"></i> {{ ('search.form.search' | translate) }}</button>
Expand Down
124 changes: 124 additions & 0 deletions src/app/shared/search-form/search-form.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,127 @@
.scope-button {
max-width: var(--ds-search-form-scope-max-width);
}

// Copied from https://getcssscan.com/css-checkboxes-examples
.checkbox-wrapper-8 .tgl {
display: none;
}

.checkbox-wrapper-8 .tgl,
.checkbox-wrapper-8 .tgl:after,
.checkbox-wrapper-8 .tgl:before,
.checkbox-wrapper-8 .tgl *,
.checkbox-wrapper-8 .tgl *:after,
.checkbox-wrapper-8 .tgl *:before,
.checkbox-wrapper-8 .tgl+.tgl-btn {
box-sizing: border-box;
}

.checkbox-wrapper-8 .tgl::-moz-selection,
.checkbox-wrapper-8 .tgl:after::-moz-selection,
.checkbox-wrapper-8 .tgl:before::-moz-selection,
.checkbox-wrapper-8 .tgl *::-moz-selection,
.checkbox-wrapper-8 .tgl *:after::-moz-selection,
.checkbox-wrapper-8 .tgl *:before::-moz-selection,
.checkbox-wrapper-8 .tgl+.tgl-btn::-moz-selection,
.checkbox-wrapper-8 .tgl::selection,
.checkbox-wrapper-8 .tgl:after::selection,
.checkbox-wrapper-8 .tgl:before::selection,
.checkbox-wrapper-8 .tgl *::selection,
.checkbox-wrapper-8 .tgl *:after::selection,
.checkbox-wrapper-8 .tgl *:before::selection,
.checkbox-wrapper-8 .tgl+.tgl-btn::selection {
background: none;
}

.checkbox-wrapper-8 .tgl+.tgl-btn {
outline: 0;
display: block;
width: 6em;
height: 100%;
position: relative;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

.checkbox-wrapper-8 .tgl+.tgl-btn:after,
.checkbox-wrapper-8 .tgl+.tgl-btn:before {
position: relative;
display: block;
content: "";
width: 50%;
height: 100%;
}

.checkbox-wrapper-8 .tgl+.tgl-btn:after {
left: 0;
}

.checkbox-wrapper-8 .tgl+.tgl-btn:before {
display: none;
}

.checkbox-wrapper-8 .tgl:checked+.tgl-btn:after {
left: 50%;
}

.checkbox-wrapper-8 .tgl+.tgl-btn {
overflow: hidden;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transition: all 0.2s ease;
font-family: sans-serif;
background: #888;
}

.checkbox-wrapper-8 .tgl+.tgl-btn:after,
.checkbox-wrapper-8 .tgl+.tgl-btn:before {
display: inline-block;
transition: all 0.2s ease;
width: 100%;
text-align: center;
position: absolute;
line-height: 2em;
font-weight: bold;
color: #fff;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
}

.checkbox-wrapper-8 .tgl+.tgl-btn:after {
left: 100%;
margin-top: 3px;
content: attr(data-tg-on);
}

.checkbox-wrapper-8 .tgl+.tgl-btn:before {
left: 0;
margin-top: 3px;
content: attr(data-tg-off);
}

.checkbox-wrapper-8 .tgl+.tgl-btn:active {
background: #888;
}

.checkbox-wrapper-8 .tgl+.tgl-btn:active:before {
left: -10%;
}

.checkbox-wrapper-8 .tgl:checked+.tgl-btn {
background: #86d993;
}

.checkbox-wrapper-8 .tgl:checked+.tgl-btn:before {
left: -100%;
}

.checkbox-wrapper-8 .tgl:checked+.tgl-btn:after {
left: 0;
}

.checkbox-wrapper-8 .tgl:checked+.tgl-btn:active:after {
left: 10%;
}
2 changes: 1 addition & 1 deletion src/app/shared/search-form/search-form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('SearchFormComponent', () => {

fixture.detectChanges();
tick();
const queryInput = de.query(By.css('input')).nativeElement;
const queryInput = de.query(By.css('input[name="query"]')).nativeElement;

expect(queryInput.value).toBe(testString);
}));
Expand Down
5 changes: 5 additions & 0 deletions src/app/shared/search-form/search-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export class SearchFormComponent implements OnChanges {
*/
@Input() query: string;

/**
* True to pass the search query without esacping of special characters.
*/
@Input() expert: boolean;

/**
* True when the search component should show results on the current page
*/
Expand Down
3 changes: 3 additions & 0 deletions src/app/shared/search-form/themed-search-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export class ThemedSearchFormComponent extends ThemedComponent<SearchFormCompone

@Input() query: string;

@Input() expert: boolean;

@Input() inPlaceSearch: boolean;

@Input() scope: string;
Expand All @@ -39,6 +41,7 @@ export class ThemedSearchFormComponent extends ThemedComponent<SearchFormCompone

protected inAndOutputNames: (keyof SearchFormComponent & keyof this)[] = [
'query',
'expert',
'inPlaceSearch',
'scope',
'hideScopeInUrl',
Expand Down
Loading
Loading