diff --git a/frontend/package.json b/frontend/package.json index b0e9bd78..ac32490f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "angular2-multiselect-dropdown": "^4.6.6", "angular2-toaster": "^11.0.1", "bootstrap": "^4.5.0", + "papaparse": "^5.5.2", "rxjs": "~6.6.0", "tslib": "^2.0.0", "zone.js": "~0.10.2" diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 7c7b44d9..7eea0060 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -100,7 +100,7 @@ import { SuggestedFieldsComponent, SuggestedFieldsCreateComponent, SuggestedFieldsListComponent, - PreviewLinkComponent + PreviewLinkComponent, ], providers: [ CommonsService, diff --git a/frontend/src/app/components/details/rule-management/rule-management.component.ts b/frontend/src/app/components/details/rule-management/rule-management.component.ts index 5cb6dd83..4e4003a6 100755 --- a/frontend/src/app/components/details/rule-management/rule-management.component.ts +++ b/frontend/src/app/components/details/rule-management/rule-management.component.ts @@ -27,6 +27,7 @@ import { UpDownRule, PreviewSection } from '../../../models'; +import {randomUUID} from '../../../lib/uuid'; import { CommonsService, FeatureToggleService, @@ -284,7 +285,7 @@ export class RuleManagementComponent implements OnChanges, OnInit, AfterContentC console.log('In SearchInputDetailComponent :: addNewSynonym'); const emptySynonymRule: SynonymRule = { - id: this.randomUUID(), + id: randomUUID(), synonymType: 0, term: '', isActive: true @@ -308,7 +309,7 @@ export class RuleManagementComponent implements OnChanges, OnInit, AfterContentC console.log('In SearchInputDetailComponent :: addNewUpDownRule'); const emptyUpDownRule: UpDownRule = { - id: this.randomUUID(), + id: randomUUID(), term: '', isActive: true }; @@ -340,7 +341,7 @@ export class RuleManagementComponent implements OnChanges, OnInit, AfterContentC console.log('In SearchInputDetailComponent :: addNewFilterRule'); const emptyFilterRule: FilterRule = { - id: this.randomUUID(), + id: randomUUID(), term: '', isActive: true }; @@ -369,7 +370,7 @@ export class RuleManagementComponent implements OnChanges, OnInit, AfterContentC console.log('In SearchInputDetailComponent :: addNewDeleteRule'); const emptyDeleteRule: DeleteRule = { - id: this.randomUUID(), + id: randomUUID(), term: '', isActive: true }; @@ -393,7 +394,7 @@ export class RuleManagementComponent implements OnChanges, OnInit, AfterContentC console.log('In SearchInputDetailComponent :: addNewRedirectRule'); const emptyRedirectRule: RedirectRule = { - id: this.randomUUID(), + id: randomUUID(), target: '', isActive: true }; @@ -560,17 +561,6 @@ export class RuleManagementComponent implements OnChanges, OnInit, AfterContentC return idxMinimumDistance; } - // taken from https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript - private randomUUID() { - /* eslint-disable */ - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = (Math.random() * 16) | 0, - v = c == 'x' ? r : (r & 0x3) | 0x8 - return v.toString(16) - }) - /* eslint-enable */ - } - private updateSelectedTagsInModel() { if (this.detailSearchInput) { this.detailSearchInput.tags = this.selectedTags; diff --git a/frontend/src/app/components/rules-panel/rules-search/rules-search.component.css b/frontend/src/app/components/rules-panel/rules-search/rules-search.component.css index 8d957cca..f16c4954 100755 --- a/frontend/src/app/components/rules-panel/rules-search/rules-search.component.css +++ b/frontend/src/app/components/rules-panel/rules-search/rules-search.component.css @@ -10,3 +10,7 @@ .create-button { margin-left: 0.5rem; } + +.full-width { + width: 100%; +} diff --git a/frontend/src/app/components/rules-panel/rules-search/rules-search.component.html b/frontend/src/app/components/rules-panel/rules-search/rules-search.component.html index 8cf49374..8c546b3d 100755 --- a/frontend/src/app/components/rules-panel/rules-search/rules-search.component.html +++ b/frontend/src/app/components/rules-panel/rules-search/rules-search.component.html @@ -45,6 +45,13 @@ > New + @@ -77,3 +84,50 @@ + +
+
+
+
+

+ Your csv must be in the following format (do not include the header row) +

+ + + + + + + + + + + + + + + +
search termsynonymcomment
bootshoeRequested by footware
+
+
+
+ + +
+
+
diff --git a/frontend/src/app/components/rules-panel/rules-search/rules-search.component.ts b/frontend/src/app/components/rules-panel/rules-search/rules-search.component.ts index a617bc33..7186100b 100755 --- a/frontend/src/app/components/rules-panel/rules-search/rules-search.component.ts +++ b/frontend/src/app/components/rules-panel/rules-search/rules-search.component.ts @@ -4,7 +4,8 @@ import { Output, EventEmitter, OnChanges, - SimpleChanges + SimpleChanges, + ChangeDetectorRef } from '@angular/core'; import { @@ -12,9 +13,11 @@ import { ModalService, RuleManagementService, SpellingsService, - TagsService + TagsService, + CSVImportService } from '../../../services'; -import {InputTag, ListItem} from '../../../models'; +import {InputTag, ListItem, ApiResult} from '../../../models'; +const fileImportModal = 'file-import'; @Component({ selector: 'app-smui-rules-search', @@ -35,6 +38,7 @@ export class RulesSearchComponent implements OnChanges { @Output() showErrorMsg: EventEmitter = new EventEmitter(); allTags: InputTag[] = []; + progress: number = 0; readonly isTaggingActive = this.featureToggleService.isRuleTaggingActive(); private readonly isSpellingActive = this.featureToggleService.getSyncToggleActivateSpelling(); @@ -43,7 +47,9 @@ export class RulesSearchComponent implements OnChanges { private ruleManagementService: RuleManagementService, private spellingsService: SpellingsService, private tagsService: TagsService, - private modalService: ModalService + private modalService: ModalService, + private csvImportService: CSVImportService, + private cdr: ChangeDetectorRef ) {} ngOnChanges(changes: SimpleChanges): void { @@ -65,6 +71,37 @@ export class RulesSearchComponent implements OnChanges { }); } + openFileModal(): void { + this.modalService.open(fileImportModal); + } + + fileSelect(event: Event): void { + const element = event.currentTarget as HTMLInputElement; + if (element?.files?.length && this.currentSolrIndexId) { + const files: FileList = element?.files; + const file = element?.files?.[0]; + const ruleCreations = this.csvImportService.import( + file, + this.currentSolrIndexId, + (percentage: number) => { + this.progress = percentage; + this.cdr.detectChanges(); + } + ); + ruleCreations + .then( + () => { + this.progress = 0; + this.modalService.close(fileImportModal) + this.refreshAndSelectListItemById.emit(); + } + ).catch(err => { + this.showErrorMsg.emit(err.message) + this.progress = 0; + }); + } + } + createNewSpellingItem() { if (this.currentSolrIndexId) { this.spellingsService diff --git a/frontend/src/app/lib/csv.spec.ts b/frontend/src/app/lib/csv.spec.ts new file mode 100644 index 00000000..c5d6cfbc --- /dev/null +++ b/frontend/src/app/lib/csv.spec.ts @@ -0,0 +1,50 @@ +import {rowsToSearchInputs} from './csv'; + +describe('rowsToSearchInputs', () => { + it('parses no inputs when there are no rows', () => { + expect(rowsToSearchInputs([])).toHaveSize(0); + }); + + it('ignores rows with less than 3 columns', () => { + expect(rowsToSearchInputs([['abc', '123']])).toHaveSize(0); + }); + + it('ignores rows with more than 3 columns', () => { + expect(rowsToSearchInputs([['abc', '123', 'def', '456']])).toHaveSize(0); + }); + + it('parses rows with 3 columns', () => { + const inputs = rowsToSearchInputs([ + ['term', 'synonym', 'comment'] + ]); + expect(inputs).toHaveSize(1); + expect(inputs[0].id).toBeInstanceOf(String); + expect(inputs[0].term).toEqual('term'); + expect(inputs[0].redirectRules).toEqual([]); + expect(inputs[0].filterRules).toEqual([]); + expect(inputs[0].tags).toEqual([]); + expect(inputs[0].upDownRules).toEqual([]); + expect(inputs[0].synonymRules).toHaveSize(1); + expect(inputs[0].synonymRules[0].term).toEqual('synonym'); + expect(inputs[0].synonymRules[0].isActive).toEqual(true); + expect(inputs[0].synonymRules[0].synonymType).toEqual(0); + expect(inputs[0].synonymRules[0].id).toBeInstanceOf(String); + }); + + it('groups rows with the same search term', () => { + const inputs = rowsToSearchInputs([ + ['term', 'synonym', 'comment'], + ['term', 'another synonym', 'comment'], + ]); + expect(inputs).toHaveSize(1); + expect(inputs[0].synonymRules).toHaveSize(2); + }); + + it('creates one SearchInput per term', () => { + const inputs = rowsToSearchInputs([ + ['term', 'synonym', 'comment'], + ['term2', 'another synonym', 'comment'], + ]); + expect(inputs).toHaveSize(2); + }); +}); diff --git a/frontend/src/app/lib/csv.ts b/frontend/src/app/lib/csv.ts new file mode 100644 index 00000000..f9392b5c --- /dev/null +++ b/frontend/src/app/lib/csv.ts @@ -0,0 +1,37 @@ +import {SynonymRule, SearchInput} from '../models'; +import {randomUUID} from './uuid'; + +function makeActiveSynonymTerm(synonymTerm: string): SynonymRule { + return { + term: synonymTerm, + isActive: true, + synonymType: 0, + id: randomUUID() + }; +} + +export function rowsToSearchInputs(rows: string[][]): SearchInput[] { + return rows + .filter(row => row.length === 3) + .reduce((searchInputs, row) => { + const [term, synonymTerm, comment] = row; + const searchInput = searchInputs.find(searchInput => searchInput.term === term); + if (!searchInput) { + searchInputs = searchInputs.concat({ + id: randomUUID(), + term, + synonymRules: [makeActiveSynonymTerm(synonymTerm)], + isActive: true, + redirectRules: [], + deleteRules: [], + filterRules: [], + tags: [], + upDownRules: [], + comment, + }) + } else { + searchInput.synonymRules.push(makeActiveSynonymTerm(synonymTerm)) + } + return searchInputs; + }, [] as SearchInput[]); +} diff --git a/frontend/src/app/lib/uuid.ts b/frontend/src/app/lib/uuid.ts new file mode 100644 index 00000000..93fd69fc --- /dev/null +++ b/frontend/src/app/lib/uuid.ts @@ -0,0 +1,10 @@ + +// taken from https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript +export function randomUUID(): string { + /* eslint-disable */ + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + /* eslint-enable */ +} diff --git a/frontend/src/app/services/commons.service.ts b/frontend/src/app/services/commons.service.ts index c7559e64..5a7715a1 100644 --- a/frontend/src/app/services/commons.service.ts +++ b/frontend/src/app/services/commons.service.ts @@ -1,15 +1,11 @@ import { Injectable, SimpleChanges } from '@angular/core'; +import {randomUUID} from '../lib/uuid'; @Injectable() export class CommonsService { generateUUID(): string { - /* eslint-disable */ - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - /* eslint-enable */ + return randomUUID(); } isDirty(obj: any, origObj: string): boolean { diff --git a/frontend/src/app/services/csv-import.service.spec.ts b/frontend/src/app/services/csv-import.service.spec.ts new file mode 100644 index 00000000..830e2f18 --- /dev/null +++ b/frontend/src/app/services/csv-import.service.spec.ts @@ -0,0 +1,54 @@ +import { CSVImportService } from './csv-import.service'; +import {RuleManagementService} from './rule-management.service' + +describe('CSVImportService', () => { + let csvImportService: CSVImportService; + + it('saves rule items', (done) => { + const csv = "term,synonym,comment" as unknown as File; + + csvImportService.import(csv, 'indexId', () => {}).then(x => { + expect(addRuleItem).toHaveBeenCalledOnceWith('indexId', 'term', []); + + const searchInput = updateSearchInput.calls.first().args[0]; + expect(searchInput.id).toEqual('123'); + expect(searchInput.term).toEqual('term'); + expect(searchInput.synonymRules.length).toEqual(1); + expect(searchInput.synonymRules[0].term).toEqual('synonym'); + expect(searchInput.synonymRules[0].isActive).toEqual(true); + expect(searchInput.synonymRules[0].synonymType).toEqual(0); + expect(searchInput.isActive).toEqual(true); + expect(searchInput.comment).toEqual('comment'); + done(); + }); + }); + + it('calls progress for each saved rule item', (done) => { + const ruleManagementService = { + addNewRuleItem() {}, + updateSearchInput() {} + } as unknown as RuleManagementService; + const ctx = {progress: (percentage: number) => {}} + const progress = spyOn(ctx, 'progress'); + const addRuleItem = spyOn( + ruleManagementService, 'addNewRuleItem' + ).and.returnValue( + Promise.resolve({returnId: '123', message: '', result: ''}) + ); + const updateSearchInput = spyOn( + ruleManagementService, 'updateSearchInput' + ).and.returnValue( + Promise.resolve({result: '', message: '', returnId: ''}) + ); + csvImportService = new CSVImportService(ruleManagementService); + const csv = "term,synonym,comment\nterm2,synonym2,comment2" as unknown as File; + + csvImportService.import(csv, 'indexId', ctx.progress).then(x => { + expect(progress).toHaveBeenCalledTimes(2); + expect(progress).toHaveBeenCalledWith(50); + expect(progress).toHaveBeenCalledWith(100); + done(); + }); + }); + +}); diff --git a/frontend/src/app/services/csv-import.service.ts b/frontend/src/app/services/csv-import.service.ts new file mode 100644 index 00000000..442463cc --- /dev/null +++ b/frontend/src/app/services/csv-import.service.ts @@ -0,0 +1,44 @@ +import {parse} from 'papaparse'; +import { Injectable } from '@angular/core'; +import {ApiResult} from '../models'; +import {rowsToSearchInputs} from '../lib/csv'; +import {RuleManagementService} from './rule-management.service' +import {ModalService} from './modal.service'; + +const fileImportModal = 'file-import'; +@Injectable({ + providedIn: 'root' +}) +export class CSVImportService { + constructor(private ruleManagementService: RuleManagementService) {} + + import(file: File, indexId: string, progress: (percentage: number) => void): Promise { + return new Promise((resolve, reject) => { + parse(file, { + complete: (results: {data: string[][]}) => { + const searchInputs = rowsToSearchInputs(results.data); + let i = 1; + const ruleCreations: Promise = searchInputs + .reduce((chain: Promise, searchInput): Promise => { + return chain + .then(() => { + return this.ruleManagementService.addNewRuleItem(indexId, searchInput.term, []) + .then(inputId => { + const onePercent = 100 / (searchInputs.length); + + progress(onePercent * i); + i ++; + searchInput.id = inputId.returnId; + return this.ruleManagementService.updateSearchInput(searchInput) + }); + }); + }, Promise.resolve(null)); + resolve(ruleCreations); + }, + error: (err: Error) => { + reject(err); + } + }); + }); + } +} diff --git a/frontend/src/app/services/index.ts b/frontend/src/app/services/index.ts index ec789ae3..2365e212 100644 --- a/frontend/src/app/services/index.ts +++ b/frontend/src/app/services/index.ts @@ -11,3 +11,4 @@ export * from './config.service'; export * from './modal.service'; export * from './deployment-detailed-info.service'; export * from './preview-link.service'; +export * from './csv-import.service'; diff --git a/frontend/src/papaparse.d.ts b/frontend/src/papaparse.d.ts new file mode 100644 index 00000000..f5e5feef --- /dev/null +++ b/frontend/src/papaparse.d.ts @@ -0,0 +1 @@ +declare module 'papaparse';