merge
This commit is contained in:
Коммит
63ab29cb77
|
@ -11,4 +11,7 @@ coverage:
|
|||
default:
|
||||
target: 75% # min coverage ratio to be considered a success
|
||||
threshold: null # allow coverage to drop by X%
|
||||
base: auto
|
||||
base: auto
|
||||
patch: # provides an indication on how well the pull request is tested
|
||||
default:
|
||||
target: 70% # min coverage ratio to be considered a success
|
||||
|
|
|
@ -29,4 +29,7 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
|
||||
# dev
|
||||
secrets.sh
|
||||
secrets.sh
|
||||
|
||||
# ide
|
||||
.idea
|
||||
|
|
|
@ -14060,6 +14060,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"react-keydown": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/react-keydown/-/react-keydown-1.9.7.tgz",
|
||||
"integrity": "sha512-sT3/R45LvLCSpWzRKmxrg5n0kg387MmavHJfv0VI/ZjPjS86PC2n35z+2Y00kxVIkOoYRC6wpqTXB13kJUM60w==",
|
||||
"requires": {
|
||||
"core-js": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"md5.js": "^1.3.5",
|
||||
"react": "^16.6.3",
|
||||
"react-dom": "^16.6.3",
|
||||
"react-keydown": "^1.9.7",
|
||||
"react-localization": "^1.0.13",
|
||||
"react-redux": "^5.1.1",
|
||||
"react-router-dom": "^4.3.1",
|
||||
|
|
|
@ -4,11 +4,13 @@ import { ExportAssetState } from "../providers/export/exportProvider";
|
|||
import { IAssetProvider, IAssetProviderRegistrationOptions } from "../providers/storage/assetProviderFactory";
|
||||
import { IAzureCloudStorageOptions } from "../providers/storage/azureBlobStorage";
|
||||
import { IStorageProvider, IStorageProviderRegistrationOptions } from "../providers/storage/storageProviderFactory";
|
||||
import { ExportProviderFactory, IExportProviderRegistrationOptions } from "../providers/export/exportProviderFactory";
|
||||
import { IProjectSettingsPageProps } from "../react/components/pages/projectSettings/projectSettingsPage";
|
||||
import IConnectionActions from "../redux/actions/connectionActions";
|
||||
import IProjectActions, * as projectActions from "../redux/actions/projectActions";
|
||||
import { IProjectService } from "../services/projectService";
|
||||
import { IBingImageSearchOptions, BingImageSearchAspectRatio } from "../providers/storage/bingImageSearch";
|
||||
import { IEditorPageProps } from "../react/components/pages/editorPage/editorPage";
|
||||
|
||||
export default class MockFactory {
|
||||
|
||||
|
@ -247,6 +249,15 @@ export default class MockFactory {
|
|||
};
|
||||
}
|
||||
|
||||
public static createExportProviderRegistrations(count: number = 3): IExportProviderRegistrationOptions[] {
|
||||
const registrations: IExportProviderRegistrationOptions[] = [];
|
||||
registrations.push(MockFactory.createExportProviderRegistration("vottJson"));
|
||||
registrations.push(MockFactory.createExportProviderRegistration("tensorFlowPascalVOC"));
|
||||
registrations.push(MockFactory.createExportProviderRegistration("azureCustomVision"));
|
||||
|
||||
return registrations;
|
||||
}
|
||||
|
||||
public static createStorageProviderRegistrations(count: number = 10): IStorageProviderRegistrationOptions[] {
|
||||
const registrations: IStorageProviderRegistrationOptions[] = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
|
@ -265,6 +276,17 @@ export default class MockFactory {
|
|||
return registrations;
|
||||
}
|
||||
|
||||
public static createExportProviderRegistration(name: string) {
|
||||
const registration: IExportProviderRegistrationOptions = {
|
||||
name,
|
||||
displayName: `${name} display name`,
|
||||
description: `${name} short description`,
|
||||
factory: () => null,
|
||||
};
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
public static createStorageProviderRegistration(name: string) {
|
||||
const registration: IStorageProviderRegistrationOptions = {
|
||||
name,
|
||||
|
@ -325,18 +347,28 @@ export default class MockFactory {
|
|||
};
|
||||
}
|
||||
|
||||
public static projectSettingsProps(projectId?: string): IProjectSettingsPageProps {
|
||||
public static pageProps(projectId?: string) {
|
||||
return {
|
||||
project: null,
|
||||
recentProjects: MockFactory.createTestProjects(),
|
||||
actions: (projectActions as any) as IProjectActions,
|
||||
connections: MockFactory.createTestConnections(),
|
||||
history: this.history(),
|
||||
location: this.location(),
|
||||
match: this.match(projectId),
|
||||
};
|
||||
}
|
||||
|
||||
public static projectSettingsProps(projectId?: string): IProjectSettingsPageProps {
|
||||
return {
|
||||
...this.pageProps(projectId),
|
||||
connections: this.createTestConnections(),
|
||||
};
|
||||
}
|
||||
|
||||
public static editorPageProps(projectId?: string): IEditorPageProps {
|
||||
return this.pageProps(projectId);
|
||||
}
|
||||
|
||||
public static initialState(): IApplicationState {
|
||||
const testProjects = MockFactory.createTestProjects();
|
||||
const testConnections = MockFactory.createTestConnections();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { strings, addLocValues, IAppStrings } from "./strings";
|
||||
import { strings, addLocValues, IAppStrings, interpolate, interpolateJson } from "./strings";
|
||||
import { english } from "./localization/en-us";
|
||||
import { spanish } from "./localization/es-cl";
|
||||
|
||||
|
@ -60,9 +60,6 @@ describe("Localization tests", () => {
|
|||
const lExp = languageJson.export;
|
||||
|
||||
expect(formProps.providerType.title).toEqual(common.provider);
|
||||
expect(formProps.providerType.enumNames[0]).toEqual(lExp.providers.vottJson);
|
||||
expect(formProps.providerType.enumNames[1]).toEqual(lExp.providers.azureCV);
|
||||
expect(formProps.providerType.enumNames[2]).toEqual(lExp.providers.tfRecords);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -119,4 +116,49 @@ describe("Localization tests", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("Interpolate processing string template correctly", () => {
|
||||
const template = "Hello ${user.name}, my name is ${bot.handle}";
|
||||
const params = {
|
||||
user: {
|
||||
name: "John Doe",
|
||||
},
|
||||
bot: {
|
||||
handle: "VoTT bot",
|
||||
},
|
||||
};
|
||||
|
||||
const result = interpolate(template, params);
|
||||
expect(result).toEqual(`Hello ${params.user.name}, my name is ${params.bot.handle}`);
|
||||
});
|
||||
|
||||
it("Interpolate processes a JSON object template correctly", () => {
|
||||
const template = {
|
||||
user: {
|
||||
firstName: "${user.firstName}",
|
||||
lastName: "${user.lastName}",
|
||||
},
|
||||
address: {
|
||||
street: "${address.street}",
|
||||
city: "${address.city}",
|
||||
state: "${address.state}",
|
||||
zipCode: "${address.zipCode}",
|
||||
},
|
||||
};
|
||||
const params = {
|
||||
user: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
},
|
||||
address: {
|
||||
street: "1 Microsoft Way",
|
||||
city: "Redmond",
|
||||
state: "WA",
|
||||
zipCode: "98052",
|
||||
},
|
||||
};
|
||||
|
||||
const result = interpolateJson(template, params);
|
||||
expect(result).toEqual(params);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import LocalizedStrings, { LocalizedStringsMethods } from "react-localization";
|
||||
import { replaceVariablesInJson } from "./utils";
|
||||
import { english } from "./localization/en-us";
|
||||
import { spanish } from "./localization/es-cl";
|
||||
|
||||
|
@ -142,35 +141,25 @@ export interface IAppStrings {
|
|||
};
|
||||
}
|
||||
|
||||
interface IStrings extends LocalizedStringsMethods, IAppStrings {}
|
||||
interface IStrings extends LocalizedStringsMethods, IAppStrings { }
|
||||
|
||||
export const strings: IStrings = new LocalizedStrings({
|
||||
en: english,
|
||||
es: spanish,
|
||||
});
|
||||
|
||||
function getLocValue(variable: string): string {
|
||||
const varName = variable.replace(/\${}\s/g, "");
|
||||
if (varName.length === 0) {
|
||||
throw new Error("Empty variable name");
|
||||
}
|
||||
const split = varName.split(".");
|
||||
let result;
|
||||
try {
|
||||
result = strings[split[0]];
|
||||
} catch (e) {
|
||||
throw new Error(`Variable ${varName} not found in strings`);
|
||||
}
|
||||
for (let i = 1; i < split.length; i++) {
|
||||
try {
|
||||
result = result[split[i]];
|
||||
} catch (e) {
|
||||
throw new Error(`Variable ${varName} not found in strings`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
export function addLocValues(json: any) {
|
||||
return interpolateJson(json, { strings });
|
||||
}
|
||||
|
||||
export function addLocValues(json: any) {
|
||||
return replaceVariablesInJson(json, getLocValue);
|
||||
export function interpolateJson(json: any, params: any) {
|
||||
const template = JSON.stringify(json);
|
||||
const outputJson = interpolate(template, params);
|
||||
return JSON.parse(outputJson);
|
||||
}
|
||||
|
||||
export function interpolate(template: string, params: any) {
|
||||
const names = Object.keys(params);
|
||||
const vals = Object["values"](params);
|
||||
return new Function(...names, `return \`${template}\`;`)(...vals);
|
||||
}
|
||||
|
|
|
@ -1,66 +1,22 @@
|
|||
import {randomIntInRange, replaceVariablesInJson} from "./utils";
|
||||
import { randomIntInRange } from "./utils";
|
||||
|
||||
describe("Helper functions", () => {
|
||||
|
||||
describe("Random int in range", () => {
|
||||
it("generates a random number in range", () => {
|
||||
let lower = 0;
|
||||
let upper = 100;
|
||||
while (lower < upper) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = randomIntInRange(lower, upper);
|
||||
expect(result).toBeGreaterThanOrEqual(lower);
|
||||
expect(result).toBeLessThan(upper);
|
||||
}
|
||||
lower++;
|
||||
upper--;
|
||||
it("generates a random number in range", () => {
|
||||
let lower = 0;
|
||||
let upper = 100;
|
||||
while (lower < upper) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = randomIntInRange(lower, upper);
|
||||
expect(result).toBeGreaterThanOrEqual(lower);
|
||||
expect(result).toBeLessThan(upper);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws an error with inappropriate values", () => {
|
||||
expect(() => randomIntInRange(10, 0)).toThrowError();
|
||||
});
|
||||
lower++;
|
||||
upper--;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Replace variables in json", () => {
|
||||
const initial = {
|
||||
correct: "This is my ${variable.name}.",
|
||||
correct2: "${a}",
|
||||
noClosing: "This is not ${variable.name.",
|
||||
whitespace: "This is not ${variable name}",
|
||||
hyphen: "This is not ${variable-name}.",
|
||||
underscore: "This is not ${variable_name}",
|
||||
justPeriod: "${.}",
|
||||
};
|
||||
|
||||
const expected = {
|
||||
correct: "This is my CORRECT.",
|
||||
correct2: "CORRECT",
|
||||
noClosing: "This is not ${variable.name.",
|
||||
whitespace: "This is not ${variable name}",
|
||||
hyphen: "This is not ${variable-name}.",
|
||||
underscore: "This is not ${variable_name}",
|
||||
justPeriod: "${.}",
|
||||
};
|
||||
|
||||
const mapper = (value: string) => {
|
||||
return {
|
||||
"variable.name": "CORRECT",
|
||||
"a": "CORRECT",
|
||||
}[value];
|
||||
};
|
||||
|
||||
it("Replaces a variable", () => {
|
||||
const result = replaceVariablesInJson(initial, mapper);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("Returns the original if no change", () => {
|
||||
const original = {
|
||||
test: "No change needed",
|
||||
};
|
||||
const result = replaceVariablesInJson(original, mapper);
|
||||
expect(result).toEqual(original);
|
||||
});
|
||||
it("throws an error with inappropriate values", () => {
|
||||
expect(() => randomIntInRange(10, 0)).toThrowError();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,23 +16,13 @@ export function randomIntInRange(min, max) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Takes a JSON object with variable names of form ${this.is.my.variable}
|
||||
* and replaces them with the appropriate values using a provided mapping function
|
||||
* @param json JSON object
|
||||
* @param valueMapper function that maps variable names to values
|
||||
* Common key codes used throughout application
|
||||
*/
|
||||
export function replaceVariablesInJson(json: any, valueMapper: (variable: string) => string): any {
|
||||
let jsonStr = JSON.stringify(json);
|
||||
const variableRegex = /\${[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*}/g;
|
||||
const variables = jsonStr.match(variableRegex);
|
||||
if (variables) {
|
||||
for (const variable of variables) {
|
||||
const variableName = variable.replace(/\$|{|}/g, "");
|
||||
const value = valueMapper(variableName);
|
||||
jsonStr = jsonStr.replace(variable, value);
|
||||
}
|
||||
return JSON.parse(jsonStr);
|
||||
} else {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
export const KeyCodes = {
|
||||
comma: 188,
|
||||
enter: 13,
|
||||
backspace: 8,
|
||||
ctrl: 17,
|
||||
shift: 16,
|
||||
tab: 9,
|
||||
};
|
||||
|
|
|
@ -1 +1,112 @@
|
|||
{}
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Azure Custom Vision",
|
||||
"required": [
|
||||
"assetState",
|
||||
"apiKey"
|
||||
],
|
||||
"properties": {
|
||||
"assetState": {
|
||||
"type": "string",
|
||||
"title": "Asset State",
|
||||
"description": "Which assets to include in the export",
|
||||
"enum": [
|
||||
"all",
|
||||
"visited",
|
||||
"tagged"
|
||||
],
|
||||
"default": "all",
|
||||
"enumNames": [
|
||||
"All Assets",
|
||||
"Only Visisted Assets",
|
||||
"Only Tagged Assets"
|
||||
]
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"title": "API Key"
|
||||
},
|
||||
"newOrExisting": {
|
||||
"type": "string",
|
||||
"title": "New or existing project",
|
||||
"enum": [
|
||||
"New Project",
|
||||
"Existing Project"
|
||||
],
|
||||
"default": "Existing Project"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"newOrExisting": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"newOrExisting": {
|
||||
"enum": [
|
||||
"New Project"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Project Name"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"title": "Project Description"
|
||||
},
|
||||
"projectType": {
|
||||
"type": "string",
|
||||
"title": "Project Type",
|
||||
"enum": [
|
||||
"Classification",
|
||||
"Object Detection"
|
||||
],
|
||||
"enumNames": [
|
||||
"Classification",
|
||||
"Object Detection"
|
||||
],
|
||||
"default": "Classification"
|
||||
},
|
||||
"classificationType": {
|
||||
"type": "string",
|
||||
"title": "Classification Type",
|
||||
"enum": [
|
||||
"Multilabel",
|
||||
"Multiclass"
|
||||
],
|
||||
"enumNames": [
|
||||
"Multiple tags per image",
|
||||
"Single tag per image"
|
||||
],
|
||||
"default": "Multilabel"
|
||||
},
|
||||
"domainId": {
|
||||
"type": "string",
|
||||
"title": "Domain"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"domainId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"newOrExisting": {
|
||||
"enum": [
|
||||
"Existing Project"
|
||||
]
|
||||
},
|
||||
"projectId": {
|
||||
"type": "string",
|
||||
"title": "Project Name"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"projectId"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
import axios from "axios";
|
||||
import { AzureCustomVisionProvider, IAzureCustomVisionOptions, NewOrExisting } from "./azureCustomVision";
|
||||
import registerProviders from "../../registerProviders";
|
||||
import { ExportProviderFactory } from "./exportProviderFactory";
|
||||
import MockFactory from "../../common/mockFactory";
|
||||
import { IProject } from "../../models/applicationState";
|
||||
import { ExportAssetState } from "./exportProvider";
|
||||
|
||||
describe("Azure Custom Vision Export Provider", () => {
|
||||
let testProject: IProject = null;
|
||||
|
||||
beforeEach(() => {
|
||||
testProject = MockFactory.createTestProject("TestProject");
|
||||
testProject.exportFormat = {
|
||||
providerType: "azureCustomVision",
|
||||
providerOptions: {},
|
||||
};
|
||||
|
||||
axios.post = jest.fn(() => Promise.resolve({
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("Is Defined", () => {
|
||||
expect(AzureCustomVisionProvider).toBeDefined();
|
||||
});
|
||||
|
||||
it("Is Registered with the ExportProviderFactory", () => {
|
||||
registerProviders();
|
||||
|
||||
const provider = ExportProviderFactory.createFromProject(testProject);
|
||||
expect(provider).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Calling save with New project creates Azure Custom Vision project", async () => {
|
||||
const customVisionOptions: IAzureCustomVisionOptions = {
|
||||
apiKey: expect.any(String),
|
||||
assetState: ExportAssetState.All,
|
||||
newOrExisting: NewOrExisting.New,
|
||||
projectId: expect.any(String),
|
||||
};
|
||||
|
||||
testProject.exportFormat.providerOptions = customVisionOptions;
|
||||
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
|
||||
const newOptions = await provider.save(testProject.exportFormat);
|
||||
|
||||
expect(axios.post).toBeCalledWith(
|
||||
// tslint:disable-next-line:max-line-length
|
||||
expect.stringContaining("https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training/projects?"),
|
||||
null,
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
"Training-key": customVisionOptions.apiKey,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(newOptions).toEqual(expect.objectContaining({
|
||||
assetState: customVisionOptions.assetState,
|
||||
apiKey: customVisionOptions.apiKey,
|
||||
projectId: expect.any(String),
|
||||
newOrExisting: NewOrExisting.Existing,
|
||||
}));
|
||||
});
|
||||
|
||||
it("Save returns rejected promise during service call failure", async () => {
|
||||
const mockPost = axios.post as jest.Mock;
|
||||
mockPost.mockImplementationOnce(() => Promise.reject("Bad Request"));
|
||||
|
||||
const customVisionOptions: IAzureCustomVisionOptions = {
|
||||
apiKey: expect.any(String),
|
||||
assetState: ExportAssetState.All,
|
||||
newOrExisting: NewOrExisting.New,
|
||||
projectId: expect.any(String),
|
||||
};
|
||||
|
||||
testProject.exportFormat.providerOptions = customVisionOptions;
|
||||
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
|
||||
await expect(provider.save(testProject.exportFormat)).rejects.not.toBeNull();
|
||||
});
|
||||
|
||||
it("Calling save with Existing project returns existing provider settings", async () => {
|
||||
const customVisionOptions: IAzureCustomVisionOptions = {
|
||||
apiKey: expect.any(String),
|
||||
assetState: ExportAssetState.All,
|
||||
newOrExisting: NewOrExisting.Existing,
|
||||
projectId: expect.any(String),
|
||||
};
|
||||
|
||||
testProject.exportFormat.providerOptions = customVisionOptions;
|
||||
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
|
||||
const newOptions = await provider.save(testProject.exportFormat);
|
||||
|
||||
expect(newOptions).toEqual(customVisionOptions);
|
||||
expect(axios.post).not.toBeCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { ExportProvider, ExportAssetState } from "./exportProvider";
|
||||
import Guard from "../../common/guard";
|
||||
import { IProject, IExportFormat } from "../../models/applicationState";
|
||||
|
||||
export interface IAzureCustomVisionOptions {
|
||||
assetState: ExportAssetState;
|
||||
newOrExisting: NewOrExisting;
|
||||
apiKey: string;
|
||||
projectId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
projectType?: string;
|
||||
classificationType?: string;
|
||||
domainId?: string;
|
||||
}
|
||||
|
||||
export enum NewOrExisting {
|
||||
New = "New Project",
|
||||
Existing = "Existing Project",
|
||||
}
|
||||
|
||||
export class AzureCustomVisionProvider extends ExportProvider<IAzureCustomVisionOptions> {
|
||||
constructor(project: IProject, options: IAzureCustomVisionOptions) {
|
||||
super(project, options);
|
||||
Guard.null(options);
|
||||
}
|
||||
|
||||
public export(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
public async save(exportFormat: IExportFormat): Promise<IAzureCustomVisionOptions> {
|
||||
const customVisionOptions = exportFormat.providerOptions as IAzureCustomVisionOptions;
|
||||
|
||||
if (customVisionOptions.newOrExisting === NewOrExisting.Existing) {
|
||||
return Promise.resolve(customVisionOptions);
|
||||
}
|
||||
|
||||
const urlParams = {
|
||||
name: customVisionOptions.name,
|
||||
description: customVisionOptions.description,
|
||||
projectType: customVisionOptions.projectType,
|
||||
domainId: customVisionOptions.domainId,
|
||||
classificationType: customVisionOptions.classificationType,
|
||||
};
|
||||
|
||||
const queryString = this.createQueryString(urlParams);
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
"Training-key": customVisionOptions.apiKey,
|
||||
},
|
||||
};
|
||||
|
||||
// tslint:disable-next-line:max-line-length
|
||||
const url = `https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training/projects?${queryString}`;
|
||||
const response = await axios.post(url, null, config);
|
||||
|
||||
return {
|
||||
assetState: customVisionOptions.assetState,
|
||||
apiKey: customVisionOptions.apiKey,
|
||||
projectId: response.data.id,
|
||||
newOrExisting: NewOrExisting.Existing,
|
||||
};
|
||||
}
|
||||
|
||||
private createQueryString(object: any): string {
|
||||
const parts: any[] = [];
|
||||
|
||||
for (const key of Object.getOwnPropertyNames(object)) {
|
||||
parts.push(`${key}=${encodeURIComponent(object[key])}`);
|
||||
}
|
||||
|
||||
return parts.join("&");
|
||||
}
|
||||
}
|
|
@ -1 +1,27 @@
|
|||
{}
|
||||
{
|
||||
"description": {
|
||||
"ui:widget": "textarea"
|
||||
},
|
||||
"projectId": {
|
||||
"ui:widget": "externalPicker",
|
||||
"ui:options": {
|
||||
"method": "GET",
|
||||
"url": "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training/projects",
|
||||
"authHeaderName": "Training-key",
|
||||
"authHeaderValue": "${props.formContext.providerOptions.apiKey}",
|
||||
"keySelector": "${item.id}",
|
||||
"valueSelector": "${item.name}"
|
||||
}
|
||||
},
|
||||
"domainId": {
|
||||
"ui:widget": "externalPicker",
|
||||
"ui:options": {
|
||||
"method": "GET",
|
||||
"url": "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.2/Training/domains",
|
||||
"authHeaderName": "Training-key",
|
||||
"authHeaderValue": "${props.formContext.providerOptions.apiKey}",
|
||||
"keySelector": "${item.id}",
|
||||
"valueSelector": "${item.name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,11 @@ describe("Export Provider Base", () => {
|
|||
it("initializes the asset and storage providers", () => {
|
||||
registerProviders();
|
||||
|
||||
ExportProviderFactory.register("test", (project) => new TestExportProvider(project));
|
||||
ExportProviderFactory.register({
|
||||
name: "test",
|
||||
displayName: "Test DisplayName",
|
||||
factory: (project) => new TestExportProvider(project),
|
||||
});
|
||||
const exportProvider = ExportProviderFactory.create("test", testProject) as TestExportProvider;
|
||||
const assetProvider = exportProvider.getAssetProvider();
|
||||
const storageProvider = exportProvider.getStorageProvider();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Guard from "../../common/guard";
|
||||
import { IProject } from "../../models/applicationState";
|
||||
import { IProject, IExportFormat } from "../../models/applicationState";
|
||||
import { IStorageProvider, StorageProviderFactory } from "../storage/storageProviderFactory";
|
||||
import { IAssetProvider, AssetProviderFactory } from "../storage/assetProviderFactory";
|
||||
|
||||
|
@ -30,6 +30,7 @@ export interface IExportProvider {
|
|||
* Exports the configured project for specified export configuration
|
||||
*/
|
||||
export(): Promise<void>;
|
||||
save?(exportFormat: IExportFormat): Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -17,13 +17,22 @@ describe("Export Provider Factory", () => {
|
|||
};
|
||||
|
||||
it("registers new export providers", () => {
|
||||
expect(Object.keys(ExportProviderFactory.handlers).length).toEqual(0);
|
||||
ExportProviderFactory.register("testProvider", (project) => new TestExportProvider(project));
|
||||
expect(Object.keys(ExportProviderFactory.handlers).length).toEqual(1);
|
||||
expect(Object.keys(ExportProviderFactory.providers).length).toEqual(0);
|
||||
ExportProviderFactory.register({
|
||||
name: "testProvider",
|
||||
displayName: "Test Provider",
|
||||
factory: (project) => new TestExportProvider(project),
|
||||
});
|
||||
expect(Object.keys(ExportProviderFactory.providers).length).toEqual(1);
|
||||
expect(ExportProviderFactory.providers["testProvider"].displayName).toEqual("Test Provider");
|
||||
});
|
||||
|
||||
it("creates a new instance of the provider", () => {
|
||||
ExportProviderFactory.register("testProvider", (project) => new TestExportProvider(project));
|
||||
ExportProviderFactory.register({
|
||||
name: "testProvider",
|
||||
displayName: "Test Provider",
|
||||
factory: (project) => new TestExportProvider(project),
|
||||
});
|
||||
const provider = ExportProviderFactory.create(
|
||||
"testProvider",
|
||||
testProject,
|
||||
|
|
|
@ -2,25 +2,33 @@ import Guard from "../../common/guard";
|
|||
import { IExportProvider } from "./exportProvider";
|
||||
import { IProject } from "../../models/applicationState";
|
||||
|
||||
export interface IExportProviderRegistrationOptions {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
factory: (project, IProject, options?: any) => IExportProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name - Export Provider Factory
|
||||
* @description - Creates instance of export providers based on request providery type
|
||||
*/
|
||||
export class ExportProviderFactory {
|
||||
public static get handlers() {
|
||||
return { ...ExportProviderFactory.handlerRegistry };
|
||||
public static get providers() {
|
||||
return { ...ExportProviderFactory.providerRegistery };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a factory method for the specified export provider type
|
||||
* @param name - The name of the export provider
|
||||
* @param factory - The factory method to construct new instances
|
||||
* @param options - The options to use when registering an export provider
|
||||
*/
|
||||
public static register(name: string, factory: (project, IProject, options?: any) => IExportProvider) {
|
||||
Guard.emtpy(name);
|
||||
Guard.null(factory);
|
||||
public static register(options: IExportProviderRegistrationOptions) {
|
||||
Guard.null(options);
|
||||
Guard.emtpy(options.name);
|
||||
Guard.emtpy(options.displayName);
|
||||
Guard.null(options.factory);
|
||||
|
||||
ExportProviderFactory.handlerRegistry[name] = factory;
|
||||
ExportProviderFactory.providerRegistery[options.name] = options;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -33,13 +41,21 @@ export class ExportProviderFactory {
|
|||
Guard.emtpy(name);
|
||||
Guard.null(project);
|
||||
|
||||
const handler = ExportProviderFactory.handlerRegistry[name];
|
||||
const handler = ExportProviderFactory.providerRegistery[name];
|
||||
if (!handler) {
|
||||
throw new Error(`No export provider has been registered with name '${name}'`);
|
||||
}
|
||||
|
||||
return handler(project, options);
|
||||
return handler.factory(project, options);
|
||||
}
|
||||
|
||||
private static handlerRegistry: { [id: string]: (project: IProject, options?: any) => IExportProvider } = {};
|
||||
public static createFromProject(project: IProject): IExportProvider {
|
||||
return ExportProviderFactory.create(
|
||||
project.exportFormat.providerType,
|
||||
project,
|
||||
project.exportFormat.providerOptions,
|
||||
);
|
||||
}
|
||||
|
||||
private static providerRegistery: { [id: string]: IExportProviderRegistrationOptions } = {};
|
||||
}
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"type": "object",
|
||||
"title": "${connections.providers.bing.options}",
|
||||
"title": "${strings.connections.providers.bing.options}",
|
||||
"required": ["apiKey","query"],
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"title": "${connections.providers.bing.apiKey}"
|
||||
"title": "${strings.connections.providers.bing.apiKey}"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"title": "${connections.providers.bing.query}"
|
||||
"title": "${strings.connections.providers.bing.query}"
|
||||
},
|
||||
"aspectRatio": {
|
||||
"type": "string",
|
||||
"title": "${connections.providers.bing.aspectRatio.title}",
|
||||
"title": "${strings.connections.providers.bing.aspectRatio.title}",
|
||||
"enum": [
|
||||
"all",
|
||||
"square",
|
||||
|
@ -22,10 +22,10 @@
|
|||
],
|
||||
"default": "all",
|
||||
"enumNames": [
|
||||
"${connections.providers.bing.aspectRatio.all}",
|
||||
"${connections.providers.bing.aspectRatio.square}",
|
||||
"${connections.providers.bing.aspectRatio.wide}",
|
||||
"${connections.providers.bing.aspectRatio.tall}"
|
||||
"${strings.connections.providers.bing.aspectRatio.all}",
|
||||
"${strings.connections.providers.bing.aspectRatio.square}",
|
||||
"${strings.connections.providers.bing.aspectRatio.wide}",
|
||||
"${strings.connections.providers.bing.aspectRatio.tall}"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"title": "${connections.providers.local.title}",
|
||||
"title": "${strings.connections.providers.local.title}",
|
||||
"required": [
|
||||
"folderPath"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folderPath": {
|
||||
"title": "${connections.providers.local.folderPath}",
|
||||
"title": "${strings.connections.providers.local.folderPath}",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ export default function CustomField(Widget: any, mapProps?: (props: FieldProps)
|
|||
Guard.null(Widget);
|
||||
|
||||
return function render(props: FieldProps) {
|
||||
const { idSchema, schema, required } = props;
|
||||
const widgetProps = mapProps ? mapProps(props) : props;
|
||||
return (<Widget {...widgetProps} />);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import React from "react";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import _ from "lodash";
|
||||
import ExportProviderPicker, { IExportProviderPickerProps } from "./exportProviderPicker";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
|
||||
jest.mock("../../../../providers/export/exportProviderFactory");
|
||||
import { ExportProviderFactory } from "../../../../providers/export/exportProviderFactory";
|
||||
|
||||
describe("Export Provider Picker", () => {
|
||||
const exportProviderRegistrations = MockFactory.createExportProviderRegistrations();
|
||||
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
const onChangeHandler = jest.fn();
|
||||
const defaultProps: IExportProviderPickerProps = {
|
||||
id: "test-export-provider-picker",
|
||||
value: "azureCustomVision",
|
||||
onChange: onChangeHandler,
|
||||
};
|
||||
|
||||
function createComponent(props: IExportProviderPickerProps) {
|
||||
return mount(<ExportProviderPicker {...props} />);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(ExportProviderFactory, "providers", {
|
||||
get: jest.fn(() => exportProviderRegistrations),
|
||||
});
|
||||
});
|
||||
|
||||
describe("With default properties", () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent(defaultProps);
|
||||
});
|
||||
|
||||
it("Renders a dropdown with all export providers", () => {
|
||||
const exportProviders = _.values(exportProviderRegistrations);
|
||||
|
||||
const allProviders = _([])
|
||||
.concat(exportProviders)
|
||||
.orderBy("displayName")
|
||||
.value();
|
||||
|
||||
const picker = wrapper.find("select");
|
||||
const htmlNode = picker.getDOMNode() as HTMLSelectElement;
|
||||
|
||||
// Count of unique providers + the "Select" option
|
||||
expect(htmlNode.id).toEqual(defaultProps.id);
|
||||
expect(htmlNode.value).toEqual(defaultProps.value);
|
||||
expect(picker.find("option").length).toEqual(allProviders.length);
|
||||
});
|
||||
|
||||
it("Calls registred onChange handler when value changes", async () => {
|
||||
await MockFactory.flushUi(() => {
|
||||
wrapper.find("select").simulate("change", { target: { value: exportProviderRegistrations[1].name } });
|
||||
});
|
||||
|
||||
expect(onChangeHandler).toBeCalledWith(exportProviderRegistrations[1].name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("With property overrides", () => {
|
||||
it("Selects correct option based on value", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: exportProviderRegistrations[1].name,
|
||||
};
|
||||
wrapper = createComponent(props);
|
||||
|
||||
const htmlNode = wrapper.find("select").getDOMNode() as HTMLSelectElement;
|
||||
expect(htmlNode.value).toEqual(props.value);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { ExportProviderFactory } from "../../../../providers/export/exportProviderFactory";
|
||||
|
||||
export interface IExportProviderPickerProps {
|
||||
onChange: (value: string) => void;
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default function ExportProviderPicker(props: IExportProviderPickerProps) {
|
||||
const exportProviders = _.values(ExportProviderFactory.providers);
|
||||
|
||||
const allProviders = _([])
|
||||
.concat(exportProviders)
|
||||
.orderBy("displayName")
|
||||
.value();
|
||||
|
||||
function onChange(e) {
|
||||
props.onChange(e.target.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<select id={props.id}
|
||||
className="form-control"
|
||||
value={props.value}
|
||||
onChange={onChange}>
|
||||
{
|
||||
allProviders.map((provider) =>
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.displayName}
|
||||
</option>)
|
||||
}
|
||||
</select>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import React from "react";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import axios from "axios";
|
||||
import ExternalPicker, { IExternalPickerProps, IExternalPickerState } from "./externalPicker";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
|
||||
describe("External Picker", () => {
|
||||
let wrapper: ReactWrapper<IExternalPickerProps, IExternalPickerState> = null;
|
||||
const onChangeHandler = jest.fn();
|
||||
const defaultProps = createProps({
|
||||
id: "my-custom-control",
|
||||
value: "",
|
||||
schema: {
|
||||
title: "Item Name",
|
||||
},
|
||||
formContext: {
|
||||
providerOptions: {
|
||||
apiKey: "",
|
||||
},
|
||||
},
|
||||
onChange: onChangeHandler,
|
||||
options: {
|
||||
method: "GET",
|
||||
url: "https://myserver/api",
|
||||
keySelector: "${item.key}",
|
||||
valueSelector: "${item.value}",
|
||||
authHeaderName: "Authorization",
|
||||
authHeaderValue: "${props.formContext.providerOptions.apiKey}",
|
||||
},
|
||||
});
|
||||
|
||||
const testResponse = [
|
||||
{ key: "1", value: "Option 1" },
|
||||
{ key: "2", value: "Option 2" },
|
||||
{ key: "3", value: "Option 3" },
|
||||
{ key: "4", value: "Option 4" },
|
||||
];
|
||||
|
||||
function createComponent(props: IExternalPickerProps): ReactWrapper<IExternalPickerProps, IExternalPickerState> {
|
||||
return mount(<ExternalPicker {...props} />);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
axios.request = jest.fn(() => {
|
||||
return Promise.resolve({
|
||||
data: testResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent(defaultProps as IExternalPickerProps);
|
||||
});
|
||||
|
||||
it("Renders select element with default option", () => {
|
||||
expect(wrapper.find("select").length).toEqual(1);
|
||||
expect(wrapper.find("option").length).toEqual(1);
|
||||
});
|
||||
|
||||
it("Does not bind external data if authorization is missing", () => {
|
||||
expect(axios.request).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("Renders items bound from external data when formContext rebinds", async () => {
|
||||
const expectedApiKey = "ABC123";
|
||||
|
||||
await MockFactory.flushUi(() => {
|
||||
wrapper.setProps({
|
||||
formContext: {
|
||||
providerOptions: {
|
||||
apiKey: expectedApiKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
const expectedHeaders = {};
|
||||
expectedHeaders[defaultProps.options.authHeaderName] = expectedApiKey;
|
||||
|
||||
expect(axios.request).toBeCalledWith({
|
||||
method: defaultProps.options.method,
|
||||
url: defaultProps.options.url,
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
|
||||
const options = wrapper.find("option");
|
||||
expect(options.length).toEqual(testResponse.length + 1);
|
||||
expect(options.at(1).prop("value")).toEqual(testResponse[0].key);
|
||||
expect(options.at(1).text()).toEqual(testResponse[0].value);
|
||||
expect(wrapper.state("items").length).toEqual(testResponse.length);
|
||||
});
|
||||
|
||||
it("Calls onChange event handler on option selection", () => {
|
||||
wrapper.setProps({
|
||||
formContext: {},
|
||||
});
|
||||
|
||||
wrapper.find("select").simulate("change", { target: { value: testResponse[0].key } });
|
||||
expect(onChangeHandler).toBeCalledWith(testResponse[0].key);
|
||||
});
|
||||
|
||||
function createProps(otherProps: any): IExternalPickerProps {
|
||||
const props: IExternalPickerProps = {
|
||||
...otherProps,
|
||||
};
|
||||
|
||||
return props;
|
||||
}
|
||||
});
|
|
@ -0,0 +1,101 @@
|
|||
import React, { SyntheticEvent } from "react";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { FieldProps } from "react-jsonschema-form";
|
||||
import { interpolate } from "../../../../common/strings";
|
||||
|
||||
interface IKeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IExternalPickerUiOptions {
|
||||
method: string;
|
||||
url: string;
|
||||
keySelector: string;
|
||||
valueSelector: string;
|
||||
authHeaderName?: string;
|
||||
authHeaderValue?: string;
|
||||
}
|
||||
|
||||
export interface IExternalPickerProps extends FieldProps {
|
||||
options: IExternalPickerUiOptions;
|
||||
}
|
||||
|
||||
export interface IExternalPickerState {
|
||||
items: IKeyValuePair[];
|
||||
}
|
||||
|
||||
export default class ExternalPicker extends React.Component<IExternalPickerProps, any> {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<select id={this.props.id}
|
||||
className="form-control"
|
||||
value={this.props.value}
|
||||
onChange={this.onChange}>
|
||||
<option value="">Select {this.props.schema.title}</option>
|
||||
{this.state.items.map((item) => <option key={item.key} value={item.key}>{item.value}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
await this.bindExternalData();
|
||||
}
|
||||
|
||||
public async componentDidUpdate(prevProps: FieldProps) {
|
||||
if (prevProps.formContext !== this.props.formContext) {
|
||||
await this.bindExternalData();
|
||||
}
|
||||
}
|
||||
|
||||
private onChange(e: SyntheticEvent) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
this.props.onChange(target.value === "" ? undefined : target.value);
|
||||
}
|
||||
|
||||
private async bindExternalData() {
|
||||
const uiOptions = this.props.options;
|
||||
const customHeaders: any = {};
|
||||
const authHeaderValue = interpolate(uiOptions.authHeaderValue, {
|
||||
props: this.props,
|
||||
});
|
||||
|
||||
if (!authHeaderValue || authHeaderValue === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
customHeaders[uiOptions.authHeaderName] = authHeaderValue;
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
method: uiOptions.method,
|
||||
url: uiOptions.url,
|
||||
headers: customHeaders,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.request(config);
|
||||
const items: IKeyValuePair[] = response.data.map((item) => {
|
||||
return {
|
||||
key: interpolate(uiOptions.keySelector, { item }),
|
||||
value: interpolate(uiOptions.valueSelector, { item }),
|
||||
};
|
||||
});
|
||||
|
||||
this.setState({
|
||||
items,
|
||||
});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,11 +2,11 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "${tags.modal.name}",
|
||||
"title": "${strings.tags.modal.name}",
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"title": "${tags.modal.color}",
|
||||
"title": "${strings.tags.modal.color}",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"#FFFFFF", "#808080", "#FF0000", "#800000", "#FFFF00", "#808000", "#00FF00",
|
||||
|
@ -14,20 +14,20 @@
|
|||
],
|
||||
"default": "white",
|
||||
"enumNames": [
|
||||
"${tags.colors.white}",
|
||||
"${tags.colors.gray}",
|
||||
"${tags.colors.red}",
|
||||
"${tags.colors.maroon}",
|
||||
"${tags.colors.yellow}",
|
||||
"${tags.colors.olive}",
|
||||
"${tags.colors.lime}",
|
||||
"${tags.colors.green}",
|
||||
"${tags.colors.aqua}",
|
||||
"${tags.colors.teal}",
|
||||
"${tags.colors.blue}",
|
||||
"${tags.colors.navy}",
|
||||
"${tags.colors.fuschia}",
|
||||
"${tags.colors.purple}"
|
||||
"${strings.tags.colors.white}",
|
||||
"${strings.tags.colors.gray}",
|
||||
"${strings.tags.colors.red}",
|
||||
"${strings.tags.colors.maroon}",
|
||||
"${strings.tags.colors.yellow}",
|
||||
"${strings.tags.colors.olive}",
|
||||
"${strings.tags.colors.lime}",
|
||||
"${strings.tags.colors.green}",
|
||||
"${strings.tags.colors.aqua}",
|
||||
"${strings.tags.colors.teal}",
|
||||
"${strings.tags.colors.blue}",
|
||||
"${strings.tags.colors.navy}",
|
||||
"${strings.tags.colors.fuschia}",
|
||||
"${strings.tags.colors.purple}"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { mount } from "enzyme";
|
||||
import React from "react";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
import TagsInput, { ITagsInputProps, KeyCodes } from "./tagsInput";
|
||||
import { KeyCodes } from "../../../../common/utils";
|
||||
import TagsInput, { ITagsInputProps } from "./tagsInput";
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const TagColors = require("./tagColors.json");
|
||||
|
||||
|
@ -134,27 +135,6 @@ describe("Tags Input Component", () => {
|
|||
expect(wrapper.find(TagsInput).state().selectedTag.color).toEqual(originalTags[0].color);
|
||||
});
|
||||
|
||||
it("ctrl clicking tag does not call onTagClick or OnTagShiftClick", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const onTagShiftClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: onTagShiftClickHandler,
|
||||
onTagClick: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, ctrlKey: true});
|
||||
// Shows modal
|
||||
expect(wrapper.find(TagsInput).state().showModal).toBe(true);
|
||||
// Does not Call onTagShiftClick
|
||||
expect(onTagShiftClickHandler).not.toBeCalled();
|
||||
// Does not call onTagClick
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking 'ok' in modal closes and calls onChangeHandler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
|
@ -182,100 +162,4 @@ describe("Tags Input Component", () => {
|
|||
expect(wrapper.find(TagsInput).state().showModal).toBe(false);
|
||||
expect(onChangeHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking tag calls onTagClick handler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
onChange: onChangeHandler,
|
||||
onTagClick: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}});
|
||||
expect(onTagClickHandler).toBeCalledWith(originalTags[0]);
|
||||
});
|
||||
|
||||
it("clicking tag does not call onTagClick handler when not specified", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
onChange: onChangeHandler,
|
||||
onTagClick: null,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}});
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking tag does not open modal or call onTagShiftClick handler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const onTagShiftClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: onTagShiftClickHandler,
|
||||
onTagClick: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}});
|
||||
expect(onTagClickHandler).toBeCalledWith(originalTags[0]);
|
||||
// Does not show modal
|
||||
expect(wrapper.find(TagsInput).state().showModal).toBe(false);
|
||||
// Does not call onTagShiftClick
|
||||
expect(onTagShiftClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("shift clicking tag calls onTagShiftClick handler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagShiftClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: onTagShiftClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, shiftKey: true});
|
||||
expect(onTagShiftClickHandler).toBeCalledWith(originalTags[0]);
|
||||
});
|
||||
|
||||
it("shift clicking tag does not open modal or call onTagClick handler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const onTagShiftClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: onTagShiftClickHandler,
|
||||
onTagClick: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, shiftKey: true});
|
||||
expect(onTagShiftClickHandler).toBeCalledWith(originalTags[0]);
|
||||
// Does not show modal
|
||||
expect(wrapper.find(TagsInput).state().showModal).toBe(false);
|
||||
// Does not call onTagClick
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("shift clicking tag does not call onTagShiftClick handler when not specified", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: null,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, shiftKey: true});
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@ import { randomIntInRange } from "../../../../common/utils";
|
|||
import { ITag } from "../../../../models/applicationState";
|
||||
import "../common.scss";
|
||||
import TagEditorModal from "./tagEditorModal/tagEditorModal";
|
||||
import { KeyCodes } from "../../../../common/utils";
|
||||
import "./tagsInput.scss";
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const TagColors = require("./tagColors.json");
|
||||
|
@ -31,8 +32,6 @@ export interface IReactTag {
|
|||
export interface ITagsInputProps {
|
||||
tags: ITag[];
|
||||
onChange: (tags: ITag[]) => void;
|
||||
onTagClick?: (tag: ITag) => void;
|
||||
onTagShiftClick?: (tag: ITag) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -50,18 +49,6 @@ export interface ITagsInputState {
|
|||
showModal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key codes used within tag input component
|
||||
* comma - 188 (delimiter for new tag)
|
||||
* enter - 13 (delimiter for new tag)
|
||||
* backspace - 8 (override deletion of tags)
|
||||
*/
|
||||
export const KeyCodes = {
|
||||
comma: 188,
|
||||
enter: 13,
|
||||
backspace: 8,
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys that, when pressed, cause creation of new tag
|
||||
*/
|
||||
|
@ -70,7 +57,7 @@ const delimiters = [KeyCodes.comma, KeyCodes.enter];
|
|||
/**
|
||||
* Component for creating, modifying and using tags
|
||||
*/
|
||||
export default class TagsInput extends React.Component<ITagsInputProps, ITagsInputState> {
|
||||
export default class TagsInput<T extends ITagsInputProps> extends React.Component<T, ITagsInputState> {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -81,6 +68,7 @@ export default class TagsInput extends React.Component<ITagsInputProps, ITagsInp
|
|||
selectedTag: null,
|
||||
showModal: false,
|
||||
};
|
||||
|
||||
// UI Handlers
|
||||
this.handleTagClick = this.handleTagClick.bind(this);
|
||||
this.handleDrag = this.handleDrag.bind(this);
|
||||
|
@ -100,6 +88,7 @@ export default class TagsInput extends React.Component<ITagsInputProps, ITagsInp
|
|||
<div>
|
||||
<ReactTags tags={tags}
|
||||
placeholder={strings.tags.placeholder}
|
||||
autofocus={false}
|
||||
handleDelete={this.handleDelete}
|
||||
handleAddition={this.handleAddition}
|
||||
handleDrag={this.handleDrag}
|
||||
|
@ -128,24 +117,79 @@ export default class TagsInput extends React.Component<ITagsInputProps, ITagsInp
|
|||
* Calls the onTagClick handler if not null with clicked tag
|
||||
* @param event Click event
|
||||
*/
|
||||
private handleTagClick(event) {
|
||||
const text = (event.currentTarget.innerText || event.target.innerText).trim();
|
||||
protected handleTagClick(event) {
|
||||
const text = this.getTagText(event);
|
||||
const tag = this.getTag(text);
|
||||
if (event.ctrlKey) {
|
||||
// Opens up tag editor modal
|
||||
this.setState({
|
||||
selectedTag: tag,
|
||||
showModal: true,
|
||||
});
|
||||
} else if (event.shiftKey && this.props.onTagShiftClick) {
|
||||
// Calls provided onTagShiftClick
|
||||
this.props.onTagShiftClick(this.toItag(tag));
|
||||
} else if (this.props.onTagClick) {
|
||||
// Calls provided onTagClick function
|
||||
this.props.onTagClick(this.toItag(tag));
|
||||
this.openEditModal(tag);
|
||||
}
|
||||
}
|
||||
|
||||
protected openEditModal(tag: IReactTag) {
|
||||
this.setState({
|
||||
selectedTag: tag,
|
||||
showModal: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
/**
|
||||
* Gets the tag with the given name (id)
|
||||
* @param id string name of tag. param 'id' for lower level react component
|
||||
*/
|
||||
protected getTag(id: string): IReactTag {
|
||||
const match = this.state.tags.find((tag) => tag.id === id);
|
||||
if (!match) {
|
||||
throw new Error(`No tag by id: ${id}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
protected getTagText(event): string {
|
||||
if (event.target.lastChild) {
|
||||
return event.target.lastChild.data;
|
||||
}
|
||||
return (event.target.innerText || event.currentTarget.innerText).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate necessary HTML to render tag box appropriately
|
||||
* @param name name of tag
|
||||
* @param color color of tag
|
||||
*/
|
||||
protected ReactTagHtml(name: string, color: string) {
|
||||
return (
|
||||
<div className="tag inline-block" onClick={(event) => this.handleTagClick(event)}>
|
||||
<div className="tag-contents">
|
||||
<div className="tag-color-box" style={{ backgroundColor: color }}></div>
|
||||
{this.getTagSpan(name)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get span element for each tag
|
||||
*/
|
||||
protected getTagSpan(name: string) {
|
||||
return <span>{name}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts IReactTag to ITag
|
||||
* @param tag IReactTag to convert to ITag
|
||||
*/
|
||||
protected toItag(tag: IReactTag): ITag {
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: tag.id,
|
||||
color: tag.color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows for click-and-drag re-ordering of tags
|
||||
* @param tag Tag being dragged
|
||||
|
@ -159,8 +203,16 @@ export default class TagsInput extends React.Component<ITagsInputProps, ITagsInp
|
|||
newTags.splice(currPos, 1);
|
||||
newTags.splice(newPos, 0, tag);
|
||||
|
||||
this.setState({ tags: newTags },
|
||||
() => this.props.onChange(this.toITags(this.state.tags)));
|
||||
this.updateTagsHtml(newTags);
|
||||
|
||||
// Updating HTML is dependent upon state having most up to date
|
||||
// values. Setting filtered state and then setting state with
|
||||
// updated HTML in tags
|
||||
this.setState({
|
||||
tags: newTags,
|
||||
}, () => this.setState({
|
||||
tags: this.updateTagsHtml(newTags),
|
||||
}, () => this.props.onChange(this.toITags(this.state.tags))));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -233,27 +285,18 @@ export default class TagsInput extends React.Component<ITagsInputProps, ITagsInp
|
|||
if (event.keyCode === KeyCodes.backspace) {
|
||||
return;
|
||||
}
|
||||
const { tags } = this.state;
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
tags: tags.filter((tag, index) => index !== i),
|
||||
};
|
||||
}, () => this.props.onChange(this.toITags(this.state.tags)));
|
||||
const tags = this.state.tags.filter((tag, index) => index !== i);
|
||||
|
||||
// Updating HTML is dependent upon state having most up to date
|
||||
// values. Setting filtered state and then setting state with
|
||||
// updated HTML in tags
|
||||
this.setState({
|
||||
tags,
|
||||
}, () => this.setState({
|
||||
tags: this.updateTagsHtml(tags),
|
||||
}, () => this.props.onChange(this.toITags(this.state.tags))));
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
/**
|
||||
* Gets the tag with the given name (id)
|
||||
* @param id string name of tag. param 'id' for lower level react component
|
||||
*/
|
||||
private getTag(id: string): IReactTag {
|
||||
const match = this.state.tags.find((tag) => tag.id === id);
|
||||
if (!match) {
|
||||
throw new Error(`No tag by id: ${id}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
/**
|
||||
* Converts ITag to IReactTag
|
||||
* @param tag ITag to convert to IReactTag
|
||||
|
@ -269,34 +312,13 @@ export default class TagsInput extends React.Component<ITagsInputProps, ITagsInp
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate necessary HTML to render tag box appropriately
|
||||
* @param name name of tag
|
||||
* @param color color of tag
|
||||
*/
|
||||
private ReactTagHtml(name: string, color: string) {
|
||||
return (
|
||||
<div className="tag inline-block" onClick={(event) => this.handleTagClick(event)}>
|
||||
<div className="tag-contents">
|
||||
<div className="tag-color-box" style={{ backgroundColor: color }}></div>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts IReactTag to ITag
|
||||
* @param tag IReactTag to convert to ITag
|
||||
*/
|
||||
private toItag(tag: IReactTag): ITag {
|
||||
if (!tag) {
|
||||
return null;
|
||||
private updateTagsHtml(tags: IReactTag[]): IReactTag[] {
|
||||
const newTags = [];
|
||||
for (const tag of tags) {
|
||||
this.addHtml(tag);
|
||||
newTags.push(tag);
|
||||
}
|
||||
return {
|
||||
name: tag.id,
|
||||
color: tag.color,
|
||||
};
|
||||
return newTags;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"connectionId": {
|
||||
"title": "${appSettings.storageTitle}",
|
||||
"title": "${strings.appSettings.storageTitle}",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"connectionId": {
|
||||
"ui:widget": "connectionPicker",
|
||||
"ui:help": "${appSettings.uiHelp}"
|
||||
"ui:help": "${strings.appSettings.uiHelp}"
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"title": "${connections.title}",
|
||||
"title": "${strings.connections.title}",
|
||||
"required": [
|
||||
"name",
|
||||
"providerType"
|
||||
|
@ -7,15 +7,15 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "${common.displayName}",
|
||||
"title": "${strings.common.displayName}",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"title": "${common.description}",
|
||||
"title": "${strings.common.description}",
|
||||
"type": "string"
|
||||
},
|
||||
"providerType": {
|
||||
"title": "${common.provider}",
|
||||
"title": "${strings.common.provider}",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ describe("Footer Component", () => {
|
|||
wrapper = mount(
|
||||
<EditorFooter
|
||||
tags={originalTags}
|
||||
displayHotKeys={true}
|
||||
onTagsChanged={onChangeHandler} />,
|
||||
);
|
||||
});
|
||||
|
@ -37,6 +38,7 @@ describe("Footer Component", () => {
|
|||
const emptyWrapper = mount(
|
||||
<EditorFooter
|
||||
tags={[]}
|
||||
displayHotKeys={true}
|
||||
onTagsChanged={onChangeHandler} />,
|
||||
);
|
||||
const stateTags = emptyWrapper.state()["tags"];
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import React from "react";
|
||||
import { ITag } from "../../../../models/applicationState";
|
||||
import TagsInput from "../../common/tagsInput/tagsInput";
|
||||
import EditorTagsInput from "./editorTagsInput";
|
||||
import { debug } from "util";
|
||||
|
||||
export interface IEditorFooterProps {
|
||||
tags: ITag[];
|
||||
onTagsChanged: (value) => void;
|
||||
displayHotKeys: boolean;
|
||||
}
|
||||
|
||||
export interface IEditorFooterState {
|
||||
|
@ -23,7 +25,8 @@ export default class EditorFooter extends React.Component<IEditorFooterProps, IE
|
|||
public render() {
|
||||
return (
|
||||
<div>
|
||||
<TagsInput
|
||||
<EditorTagsInput
|
||||
displayHotKeys={this.props.displayHotKeys}
|
||||
tags={this.state.tags}
|
||||
onChange={this.onTagsChanged}
|
||||
/>
|
||||
|
|
|
@ -13,6 +13,7 @@ import { AssetService } from "../../../../services/assetService";
|
|||
|
||||
jest.mock("../../../../services/projectService");
|
||||
import ProjectService from "../../../../services/projectService";
|
||||
import { KeyCodes } from "../../../../common/utils";
|
||||
|
||||
describe("Editor Page Component", () => {
|
||||
let assetServiceMock: jest.Mocked<typeof AssetService> = null;
|
||||
|
@ -52,7 +53,7 @@ describe("Editor Page Component", () => {
|
|||
it("Sets project state from redux store", () => {
|
||||
const testProject = MockFactory.createTestProject("TestProject");
|
||||
const store = createStore(testProject, true);
|
||||
const props = createProps(testProject.id);
|
||||
const props = MockFactory.editorPageProps(testProject.id);
|
||||
const loadProjectSpy = jest.spyOn(props.actions, "loadProject");
|
||||
|
||||
const wrapper = createCompoent(store, props);
|
||||
|
@ -66,7 +67,7 @@ describe("Editor Page Component", () => {
|
|||
const testProject = MockFactory.createTestProject("TestProject");
|
||||
const testAssets = MockFactory.createTestAssets(5);
|
||||
const store = createStore(testProject, true);
|
||||
const props = createProps(testProject.id);
|
||||
const props = MockFactory.editorPageProps(testProject.id);
|
||||
|
||||
AssetProviderFactory.create = jest.fn(() => {
|
||||
return {
|
||||
|
@ -105,7 +106,7 @@ describe("Editor Page Component", () => {
|
|||
|
||||
// mock store and props
|
||||
const store = createStore(testProject, true);
|
||||
const props = createProps(testProject.id);
|
||||
const props = MockFactory.editorPageProps(testProject.id);
|
||||
|
||||
// mock out the asset provider create method
|
||||
AssetProviderFactory.create = jest.fn(() => {
|
||||
|
@ -141,42 +142,24 @@ describe("Editor Page Component", () => {
|
|||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createProps(projectId: string): IEditorPageProps {
|
||||
return {
|
||||
project: null,
|
||||
recentProjects: [],
|
||||
history: {
|
||||
length: 0,
|
||||
action: null,
|
||||
location: null,
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
go: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
goForward: jest.fn(),
|
||||
block: jest.fn(),
|
||||
listen: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
},
|
||||
location: {
|
||||
hash: null,
|
||||
pathname: null,
|
||||
search: null,
|
||||
state: null,
|
||||
},
|
||||
actions: (projectActions as any) as IProjectActions,
|
||||
match: {
|
||||
params: {
|
||||
projectId,
|
||||
},
|
||||
isExact: true,
|
||||
path: `https://localhost:3000/projects/${projectId}/edit`,
|
||||
url: `https://localhost:3000/projects/${projectId}/edit`,
|
||||
},
|
||||
};
|
||||
}
|
||||
it("calls onTagClick handler when hot key is pressed", () => {
|
||||
const project = MockFactory.createTestProject();
|
||||
const store = createReduxStore({
|
||||
...MockFactory.initialState(),
|
||||
currentProject: project,
|
||||
});
|
||||
const props = MockFactory.editorPageProps();
|
||||
const wrapper = createCompoent(store, props);
|
||||
|
||||
const editorPage = wrapper.find(EditorPage).childAt(0);
|
||||
const spy = jest.spyOn(editorPage.instance() as EditorPage, "onTagClicked");
|
||||
|
||||
const keyPressed = 2;
|
||||
(editorPage.instance() as EditorPage).handleTagHotKey({ctrlKey: true, key: keyPressed.toString()});
|
||||
expect(spy).toBeCalledWith(project.tags[keyPressed - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
function createStore(project: IProject, setCurrentProject: boolean = false): Store<any, AnyAction> {
|
||||
const initialState: IApplicationState = {
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { bindActionCreators } from "redux";
|
||||
import _ from "lodash";
|
||||
import { IApplicationState, IProject, IAsset, IAssetMetadata, AssetState } from "../../../../models/applicationState";
|
||||
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
|
||||
import React from "react";
|
||||
import keydown from "react-keydown";
|
||||
import { connect } from "react-redux";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { bindActionCreators } from "redux";
|
||||
import HtmlFileReader from "../../../../common/htmlFileReader";
|
||||
import "./editorPage.scss";
|
||||
import { strings } from "../../../../common/strings";
|
||||
import { AssetState, IApplicationState, IAsset,
|
||||
IAssetMetadata, IProject, ITag } from "../../../../models/applicationState";
|
||||
import { IToolbarItemRegistration, ToolbarItemFactory } from "../../../../providers/toolbar/toolbarItemFactory";
|
||||
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
|
||||
import AssetPreview from "./assetPreview";
|
||||
import EditorFooter from "./editorFooter";
|
||||
import "./editorPage.scss";
|
||||
import EditorSideBar from "./editorSideBar";
|
||||
import { EditorToolbar } from "./editorToolbar";
|
||||
import { IToolbarItemRegistration, ToolbarItemFactory } from "../../../../providers/toolbar/toolbarItemFactory";
|
||||
import { strings } from "../../../../common/strings";
|
||||
|
||||
export interface IEditorPageProps extends RouteComponentProps, React.Props<EditorPage> {
|
||||
project: IProject;
|
||||
|
@ -39,6 +41,14 @@ function mapDispatchToProps(dispatch) {
|
|||
};
|
||||
}
|
||||
|
||||
function getCtrlNumericKeys(): string[] {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
keys.push(`ctrl+${i.toString()}`);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class EditorPage extends React.Component<IEditorPageProps, IEditorPageState> {
|
||||
private loadingProjectAssets: boolean = false;
|
||||
|
@ -62,6 +72,8 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
|||
|
||||
this.selectAsset = this.selectAsset.bind(this);
|
||||
this.onFooterChange = this.onFooterChange.bind(this);
|
||||
this.handleTagHotKey = this.handleTagHotKey.bind(this);
|
||||
this.onTagClicked = this.onTagClicked.bind(this);
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
|
@ -114,6 +126,7 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
|||
</div>
|
||||
<div>
|
||||
<EditorFooter
|
||||
displayHotKeys={true}
|
||||
tags={this.props.project.tags}
|
||||
onTagsChanged={this.onFooterChange} />
|
||||
</div>
|
||||
|
@ -122,6 +135,29 @@ export default class EditorPage extends React.Component<IEditorPageProps, IEdito
|
|||
);
|
||||
}
|
||||
|
||||
public onTagClicked(tag: ITag) {
|
||||
// Stub for now, waiting for Phil's PR
|
||||
return;
|
||||
}
|
||||
|
||||
@keydown(getCtrlNumericKeys())
|
||||
public handleTagHotKey(event) {
|
||||
const key = parseInt(event.key, 10);
|
||||
if (isNaN(key)) {
|
||||
return;
|
||||
}
|
||||
let tag: ITag;
|
||||
const tags = this.props.project.tags;
|
||||
if (key === 0) {
|
||||
if (tags.length >= 10) {
|
||||
tag = tags[9];
|
||||
}
|
||||
} else if (tags.length >= key) {
|
||||
tag = tags[key - 1];
|
||||
}
|
||||
this.onTagClicked(tag);
|
||||
}
|
||||
|
||||
private onFooterChange(footerState) {
|
||||
const project = {
|
||||
...this.props.project,
|
||||
|
|
|
@ -0,0 +1,356 @@
|
|||
import { mount } from "enzyme";
|
||||
import React from "react";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
import { KeyCodes } from "../../../../common/utils";
|
||||
import EditorTagsInput, { IEditorTagsInputProps } from "./editorTagsInput";
|
||||
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const TagColors = require("../../common/tagsInput/tagColors.json");
|
||||
|
||||
describe("Tags Input Component", () => {
|
||||
|
||||
const originalTags = MockFactory.createTestTags(15);
|
||||
|
||||
function createComponent(props: IEditorTagsInputProps) {
|
||||
return mount(
|
||||
<EditorTagsInput {...props}/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("tags are initialized correctly", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
const stateTags = wrapper.find(EditorTagsInput).state().tags;
|
||||
expect(stateTags).toHaveLength(originalTags.length);
|
||||
for (let i = 0; i < stateTags.length; i++) {
|
||||
expect(stateTags[i].id).toEqual(originalTags[i].name);
|
||||
expect(stateTags[i].color).toEqual(originalTags[i].color);
|
||||
expect(stateTags[i].text).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders appropriate number of color boxes", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
expect(wrapper.find("div.tag-color-box")).toHaveLength(originalTags.length);
|
||||
});
|
||||
|
||||
it("one text input field is available", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
expect(wrapper.find("input")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("create a new tag from text box - enter key", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
const newTagName = "My new tag";
|
||||
wrapper.find("input").simulate("change", {target: {value: newTagName}});
|
||||
wrapper.find("input").simulate("keyDown", {keyCode: KeyCodes.enter});
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
expect(wrapper.find(EditorTagsInput).state().tags).toHaveLength(originalTags.length + 1);
|
||||
const newTagIndex = originalTags.length;
|
||||
expect(wrapper.find(EditorTagsInput).state().tags[newTagIndex].id).toEqual(newTagName);
|
||||
expect(TagColors).toContain(wrapper.find(EditorTagsInput).state().tags[newTagIndex].color);
|
||||
});
|
||||
|
||||
it("create a new tag from text box - comma key", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
const newTagName = "My new tag";
|
||||
wrapper.find("input").simulate("change", {target: {value: newTagName}});
|
||||
wrapper.find("input").simulate("keyDown", {keyCode: KeyCodes.comma});
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
expect(wrapper.find(EditorTagsInput).state().tags).toHaveLength(originalTags.length + 1);
|
||||
const newTagIndex = originalTags.length;
|
||||
expect(wrapper.find(EditorTagsInput).state().tags[newTagIndex].id).toEqual(newTagName);
|
||||
expect(TagColors).toContain(wrapper.find(EditorTagsInput).state().tags[newTagIndex].color);
|
||||
});
|
||||
|
||||
it("remove a tag", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
expect(wrapper.find(EditorTagsInput).state().tags).toHaveLength(originalTags.length);
|
||||
wrapper.find("a.ReactTags__remove")
|
||||
.last().simulate("click");
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
expect(wrapper.find(EditorTagsInput).state().tags).toHaveLength(originalTags.length - 1);
|
||||
expect(wrapper.find(EditorTagsInput).state().tags[0].id).toEqual(originalTags[0].name);
|
||||
expect(wrapper.find(EditorTagsInput).state().tags[0].color).toEqual(originalTags[0].color);
|
||||
});
|
||||
|
||||
it("typing backspace on empty field does NOT delete tag", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
// Root component calls handleDelete when backspace is pressed
|
||||
// Component should handle backspace and return, not deleting and not calling onChange
|
||||
wrapper.find("input").simulate("keyDown", {keyCode: KeyCodes.backspace}); // backspace
|
||||
expect(onChangeHandler).not.toBeCalled();
|
||||
expect(wrapper.find(EditorTagsInput).state().tags).toHaveLength(originalTags.length);
|
||||
});
|
||||
|
||||
it("ctrl clicking tag opens editor modal", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
expect(wrapper.find(EditorTagsInput).state().showModal).toBe(false);
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, ctrlKey: true});
|
||||
expect(wrapper.find(EditorTagsInput).state().showModal).toBe(true);
|
||||
});
|
||||
|
||||
it("ctrl clicking tag sets selected tag", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, ctrlKey: true});
|
||||
expect(wrapper.find(EditorTagsInput).state().selectedTag.id).toEqual(originalTags[0].name);
|
||||
expect(wrapper.find(EditorTagsInput).state().selectedTag.color).toEqual(originalTags[0].color);
|
||||
});
|
||||
|
||||
it("ctrl clicking tag does not call onTagClick or OnTagShiftClick", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const onTagShiftClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: onTagShiftClickHandler,
|
||||
onTagClick: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, ctrlKey: true});
|
||||
// Shows modal
|
||||
expect(wrapper.find(EditorTagsInput).state().showModal).toBe(true);
|
||||
// Does not Call onTagShiftClick
|
||||
expect(onTagShiftClickHandler).not.toBeCalled();
|
||||
// Does not call onTagClick
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking 'ok' in modal closes and calls onChangeHandler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, ctrlKey: true});
|
||||
wrapper.find("button.btn.btn-success").simulate("click");
|
||||
expect(wrapper.find(EditorTagsInput).state().showModal).toBe(false);
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking 'cancel' in modal closes and does not call onChangeHandler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, ctrlKey: true});
|
||||
wrapper.find("button.btn.btn-secondary").simulate("click");
|
||||
expect(wrapper.find(EditorTagsInput).state().showModal).toBe(false);
|
||||
expect(onChangeHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking tag calls onTagClick handler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
onTagClick: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}});
|
||||
expect(onTagClickHandler).toBeCalledWith(originalTags[0]);
|
||||
});
|
||||
|
||||
it("clicking tag does not call onTagClick handler when not specified", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
onTagClick: null,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}});
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("clicking tag does not open modal or call onTagShiftClick handler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const onTagShiftClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: onTagShiftClickHandler,
|
||||
onTagClick: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}});
|
||||
expect(onTagClickHandler).toBeCalledWith(originalTags[0]);
|
||||
// Does not show modal
|
||||
expect(wrapper.find(EditorTagsInput).state().showModal).toBe(false);
|
||||
// Does not call onTagShiftClick
|
||||
expect(onTagShiftClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("shift clicking tag calls onTagShiftClick handler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagShiftClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: onTagShiftClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, shiftKey: true});
|
||||
expect(onTagShiftClickHandler).toBeCalledWith(originalTags[0]);
|
||||
});
|
||||
|
||||
it("shift clicking tag does not open modal or call onTagClick handler", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const onTagShiftClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: onTagShiftClickHandler,
|
||||
onTagClick: onTagClickHandler,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, shiftKey: true});
|
||||
expect(onTagShiftClickHandler).toBeCalledWith(originalTags[0]);
|
||||
// Does not show modal
|
||||
expect(wrapper.find(EditorTagsInput).state().showModal).toBe(false);
|
||||
// Does not call onTagClick
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("shift clicking tag does not call onTagShiftClick handler when not specified", () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const onTagClickHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
onTagShiftClick: null,
|
||||
});
|
||||
wrapper.find("div.tag")
|
||||
.first()
|
||||
.simulate("click", { target: { innerText: originalTags[0].name}, shiftKey: true});
|
||||
expect(onTagClickHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("displays correct initial index in span", () => {
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: null,
|
||||
onTagShiftClick: null,
|
||||
});
|
||||
expect(wrapper.find(".tag-span-index")).toHaveLength(10);
|
||||
const tagSpans = wrapper.find(".tag-span-index");
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const tag = tagSpans.get(i);
|
||||
expect(tag.props.children[0]).toEqual(`[${i + 1}] `);
|
||||
}
|
||||
const tenthTag = tagSpans.get(9);
|
||||
expect(tenthTag.props.children[0]).toEqual(`[0] `);
|
||||
});
|
||||
|
||||
it("does not display indices when specified not to", () => {
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: false,
|
||||
onChange: null,
|
||||
onTagShiftClick: null,
|
||||
});
|
||||
expect(wrapper.find(".tag-span-index")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("updates indices in tags after removing first", (done) => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const wrapper = createComponent({
|
||||
tags: originalTags,
|
||||
displayHotKeys: true,
|
||||
onChange: onChangeHandler,
|
||||
});
|
||||
expect(wrapper.find(EditorTagsInput).state().tags).toHaveLength(originalTags.length);
|
||||
expect(wrapper.find(".tag-span-index")).toHaveLength(10);
|
||||
|
||||
wrapper.find("a.ReactTags__remove")
|
||||
.first().simulate("click");
|
||||
expect(onChangeHandler).toBeCalled();
|
||||
expect(wrapper.find(EditorTagsInput).state().tags).toHaveLength(originalTags.length - 1);
|
||||
setTimeout(() => {
|
||||
wrapper.update();
|
||||
expect(wrapper.find(".tag-span-index")).toHaveLength(10);
|
||||
const tagSpans = wrapper.find(".tag-span-index");
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const tag = tagSpans.get(i);
|
||||
expect(tag.props.children[0]).toEqual(`[${i + 1}] `);
|
||||
}
|
||||
const tenthTag = tagSpans.get(9);
|
||||
expect(tenthTag.props.children[0]).toEqual(`[0] `);
|
||||
done();
|
||||
}, 1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
import { ITag } from "../../../../models/applicationState";
|
||||
import TagsInput, { ITagsInputProps, IReactTag, ITagsInputState } from "../../common/tagsInput/tagsInput";
|
||||
import React from "react";
|
||||
|
||||
export interface IEditorTagsInputProps extends ITagsInputProps {
|
||||
displayHotKeys: boolean;
|
||||
onTagClick?: (tag: ITag) => void;
|
||||
onTagShiftClick?: (tag: ITag) => void;
|
||||
}
|
||||
|
||||
// const numericKeyCodes = [...inclusiveRange(48, 58), ...inclusiveRange(96, 105)]
|
||||
|
||||
export default class EditorTagsInput extends TagsInput<IEditorTagsInputProps> {
|
||||
|
||||
/**
|
||||
* Shows the of the tag in the span of the first 10 tags
|
||||
* @param name Name of tag
|
||||
*/
|
||||
protected getTagSpan(name: string) {
|
||||
const index = this.indexOfTag(name);
|
||||
const showIndex = this.props.displayHotKeys && index <= 9;
|
||||
const className = `tag-span${(showIndex) ? " tag-span-index" : ""}`;
|
||||
return (
|
||||
<span className={className}>
|
||||
{(showIndex) ? `[${index}] ` : ""}{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the onTagClick handler if not null with clicked tag
|
||||
* @param event Click event
|
||||
*/
|
||||
protected handleTagClick(event) {
|
||||
const text = this.getTagText(event);
|
||||
const tag = this.getTag(text);
|
||||
if (event.ctrlKey) {
|
||||
this.openEditModal(tag);
|
||||
} else if (event.shiftKey && this.props.onTagShiftClick) {
|
||||
// Calls provided onTagShiftClick
|
||||
this.props.onTagShiftClick(this.toItag(tag));
|
||||
} else if (this.props.onTagClick) {
|
||||
// Calls provided onTagClick function
|
||||
this.props.onTagClick(this.toItag(tag));
|
||||
}
|
||||
}
|
||||
|
||||
private indexOfTag(id: string): number {
|
||||
let index = -1;
|
||||
if (this.state) {
|
||||
index = this.state.tags.findIndex((tag) => tag.id === id);
|
||||
if (index < 0) {
|
||||
index = this.state.tags.length + 1;
|
||||
}
|
||||
} else {
|
||||
index = this.props.tags.findIndex((tag) => tag.name === id);
|
||||
}
|
||||
if (index < 0) {
|
||||
throw new Error(`No tag by id: ${id}`);
|
||||
}
|
||||
index += 1;
|
||||
return (index === 10) ? 0 : index;
|
||||
}
|
||||
}
|
|
@ -1,25 +1,12 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"providerType"
|
||||
],
|
||||
"properties": {
|
||||
"providerType": {
|
||||
"title": "${common.provider}",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"vottJson",
|
||||
"azureCustomVision",
|
||||
"tensorFlowRecords",
|
||||
"tensorFlowPascalVOC"
|
||||
],
|
||||
"default": "vottJson",
|
||||
"enumNames": [
|
||||
"${export.providers.vottJson}",
|
||||
"${export.providers.azureCV}",
|
||||
"${export.providers.tfRecords}",
|
||||
"${export.providers.tfPascalVoc}"
|
||||
]
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object"
|
||||
"title": "${strings.common.provider}",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,20 +3,37 @@ import ExportForm, { IExportFormProps, IExportFormState } from "./exportForm";
|
|||
import { mount } from "enzyme";
|
||||
import { IExportFormat } from "../../../../models/applicationState";
|
||||
import { ExportAssetState } from "../../../../providers/export/exportProvider";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
|
||||
jest.mock("../../../../providers/export/exportProviderFactory");
|
||||
import { ExportProviderFactory } from "../../../../providers/export/exportProviderFactory";
|
||||
|
||||
describe("Export Form Component", () => {
|
||||
const exportProviderRegistrations = MockFactory.createExportProviderRegistrations();
|
||||
|
||||
function createComponent(props: IExportFormProps) {
|
||||
return mount(
|
||||
<ExportForm {...props} />,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(ExportProviderFactory, "providers", {
|
||||
get: jest.fn(() => exportProviderRegistrations),
|
||||
});
|
||||
});
|
||||
|
||||
const onSubmitHandler = jest.fn();
|
||||
|
||||
it("State is initialized without export settings", () => {
|
||||
const defaultExportType = "vottJson";
|
||||
const props: IExportFormProps = {
|
||||
settings: null,
|
||||
settings: {
|
||||
providerType: "vottJson",
|
||||
providerOptions: {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
},
|
||||
},
|
||||
onSubmit: onSubmitHandler,
|
||||
};
|
||||
|
||||
|
@ -53,7 +70,12 @@ describe("Export Form Component", () => {
|
|||
|
||||
it("Form renders correctly", () => {
|
||||
const props: IExportFormProps = {
|
||||
settings: null,
|
||||
settings: {
|
||||
providerType: "vottJson",
|
||||
providerOptions: {
|
||||
assetState: ExportAssetState.Tagged,
|
||||
},
|
||||
},
|
||||
onSubmit: onSubmitHandler,
|
||||
};
|
||||
|
||||
|
@ -73,7 +95,7 @@ describe("Export Form Component", () => {
|
|||
};
|
||||
|
||||
const props: IExportFormProps = {
|
||||
settings: null,
|
||||
settings: defaultExportSettings,
|
||||
onSubmit: onSubmitHandler,
|
||||
};
|
||||
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import React from "react";
|
||||
import Form, { FormValidation, IChangeEvent, ISubmitEvent } from "react-jsonschema-form";
|
||||
import _ from "lodash";
|
||||
import Form, { Widget, FormValidation, IChangeEvent, ISubmitEvent } from "react-jsonschema-form";
|
||||
import { addLocValues, strings } from "../../../../common/strings";
|
||||
import { IExportFormat } from "../../../../models/applicationState";
|
||||
import ExportProviderPicker from "../../common/exportProviderPicker/exportProviderPicker";
|
||||
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
|
||||
import ExternalPicker from "../../common/externalPicker/externalPicker";
|
||||
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const formSchema = addLocValues(require("./exportForm.json"));
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
|
@ -23,6 +27,11 @@ export interface IExportFormState {
|
|||
}
|
||||
|
||||
export default class ExportForm extends React.Component<IExportFormProps, IExportFormState> {
|
||||
private widgets = {
|
||||
externalPicker: (ExternalPicker as any) as Widget,
|
||||
exportProviderPicker: (ExportProviderPicker as any) as Widget,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
|
@ -34,16 +43,18 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
|
|||
formData: this.props.settings,
|
||||
};
|
||||
|
||||
if (this.props.settings) {
|
||||
this.bindForm(this.props.settings);
|
||||
}
|
||||
|
||||
this.onFormSubmit = this.onFormSubmit.bind(this);
|
||||
this.onFormValidate = this.onFormValidate.bind(this);
|
||||
this.onFormChange = this.onFormChange.bind(this);
|
||||
this.onFormCancel = this.onFormCancel.bind(this);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
if (this.props.settings) {
|
||||
this.bindForm(this.props.settings);
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IExportFormProps) {
|
||||
if (prevProps.settings !== this.props.settings) {
|
||||
this.bindForm(this.props.settings);
|
||||
|
@ -59,6 +70,8 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
|
|||
noHtml5Validate={true}
|
||||
FieldTemplate={CustomFieldTemplate}
|
||||
validate={this.onFormValidate}
|
||||
widgets={this.widgets}
|
||||
formContext={this.state.formData}
|
||||
schema={this.state.formSchema}
|
||||
uiSchema={this.state.uiSchema}
|
||||
formData={this.state.formData}
|
||||
|
@ -79,6 +92,8 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
|
|||
|
||||
if (providerType !== this.state.providerName) {
|
||||
this.bindForm(args.formData, true);
|
||||
} else {
|
||||
this.setState({ formData: { ...args.formData } });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -103,13 +118,14 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
|
|||
}
|
||||
|
||||
private bindForm(exportFormat: IExportFormat, resetProviderOptions: boolean = false) {
|
||||
|
||||
const providerType = exportFormat ? exportFormat.providerType : null;
|
||||
let newFormSchema: any = this.state.formSchema;
|
||||
let newUiSchema: any = this.state.uiSchema;
|
||||
|
||||
if (providerType) {
|
||||
const providerSchema = addLocValues(require(`../../../../providers/export/${providerType}.json`));
|
||||
const providerUiSchema = addLocValues(require(`../../../../providers/export/${providerType}.ui.json`));
|
||||
const providerUiSchema = require(`../../../../providers/export/${providerType}.ui.json`);
|
||||
|
||||
newFormSchema = { ...formSchema };
|
||||
newFormSchema.properties["providerOptions"] = providerSchema;
|
||||
|
|
|
@ -1 +1,5 @@
|
|||
{}
|
||||
{
|
||||
"providerType": {
|
||||
"ui:widget": "exportProviderPicker"
|
||||
}
|
||||
}
|
|
@ -2,25 +2,25 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "${common.displayName}",
|
||||
"title": "${strings.common.displayName}",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceConnection": {
|
||||
"title": "${projectSettings.sourceConnection.title}",
|
||||
"description": "${projectSettings.sourceConnection.description}",
|
||||
"title": "${strings.projectSettings.sourceConnection.title}",
|
||||
"description": "${strings.projectSettings.sourceConnection.description}",
|
||||
"type": "object"
|
||||
},
|
||||
"targetConnection": {
|
||||
"title": "${projectSettings.targetConnection.title}",
|
||||
"description": "${projectSettings.targetConnection.description}",
|
||||
"title": "${strings.projectSettings.targetConnection.title}",
|
||||
"description": "${strings.projectSettings.targetConnection.description}",
|
||||
"type": "object"
|
||||
},
|
||||
"description": {
|
||||
"title": "${common.description}",
|
||||
"title": "${strings.common.description}",
|
||||
"type": "string"
|
||||
},
|
||||
"tags": {
|
||||
"title": "${tags.title}",
|
||||
"title": "${strings.tags.title}",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -2,10 +2,9 @@ import { mount, ReactWrapper } from "enzyme";
|
|||
import React from "react";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
import { KeyCodes } from "../../common/tagsInput/tagsInput";
|
||||
import { StorageProviderFactory } from "../../../../providers/storage/storageProviderFactory";
|
||||
import ProjectForm, { IProjectFormProps, IProjectFormState } from "./projectForm";
|
||||
import { KeyCodes } from "../../../../common/utils";
|
||||
import registerProviders from "../../../../registerProviders";
|
||||
import ProjectForm, { IProjectFormProps, IProjectFormState } from "./projectForm";
|
||||
|
||||
describe("Project Form Component", () => {
|
||||
const project = MockFactory.createTestProject("TestProject");
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
import Form, { FormValidation, ISubmitEvent } from "react-jsonschema-form";
|
||||
import { addLocValues, strings } from "../../../../common/strings";
|
||||
import { IConnection, IProject } from "../../../../models/applicationState";
|
||||
import ConnectionPicker from "../../common/connectionPicker/connectionPicker";
|
||||
import TagsInput from "../../common/tagsInput/tagsInput";
|
||||
import CustomField from "../../common/customField/customField";
|
||||
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
|
||||
import { strings, addLocValues } from "../../../../common/strings";
|
||||
import TagsInput from "../../common/tagsInput/tagsInput";
|
||||
import { StorageProviderFactory } from "../../../../providers/storage/storageProviderFactory";
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const formSchema = addLocValues(require("./projectForm.json"));
|
||||
|
|
|
@ -9,6 +9,7 @@ import { StorageProviderFactory } from "./providers/storage/storageProviderFacto
|
|||
import registerToolbar from "./registerToolbar";
|
||||
import { strings } from "./common/strings";
|
||||
import { HostProcessType } from "./common/hostProcess";
|
||||
import { AzureCustomVisionProvider } from "./providers/export/azureCustomVision";
|
||||
|
||||
export default function registerProviders() {
|
||||
// Storage Providers
|
||||
|
@ -43,9 +44,21 @@ export default function registerProviders() {
|
|||
});
|
||||
|
||||
// Export Providers
|
||||
ExportProviderFactory.register("vottJson", (project, options) => new VottJsonExportProvider(project, options));
|
||||
ExportProviderFactory.register("tensorFlowPascalVOC",
|
||||
(project, options) => new TFPascalVOCJsonExportProvider(project, options));
|
||||
ExportProviderFactory.register({
|
||||
name: "vottJson",
|
||||
displayName: strings.export.providers.vottJson,
|
||||
factory: (project, options) => new VottJsonExportProvider(project, options),
|
||||
});
|
||||
ExportProviderFactory.register({
|
||||
name: "tensorFlowPascalVOC",
|
||||
displayName: strings.export.providers.tfPascalVoc,
|
||||
factory: (project, options) => new TFPascalVOCJsonExportProvider(project, options),
|
||||
});
|
||||
ExportProviderFactory.register({
|
||||
name: "azureCustomVision",
|
||||
displayName: strings.export.providers.azureCV,
|
||||
factory: (project, options) => new AzureCustomVisionProvider(project, options),
|
||||
});
|
||||
|
||||
registerToolbar();
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import ProjectService, { IProjectService } from "./projectService";
|
||||
import MockFactory from "../common/mockFactory";
|
||||
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
|
||||
import { IProject } from "../models/applicationState";
|
||||
import { IProject, IExportFormat } from "../models/applicationState";
|
||||
import { error } from "util";
|
||||
import { constants } from "../common/constants";
|
||||
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
|
||||
|
||||
describe("Project Service", () => {
|
||||
let projectSerivce: IProjectService = null;
|
||||
|
@ -14,7 +15,13 @@ describe("Project Service", () => {
|
|||
deleteFile: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
const exportProviderMock = {
|
||||
export: jest.fn(() => Promise.resolve()),
|
||||
save: jest.fn((exportFormat: IExportFormat) => Promise.resolve(exportFormat.providerOptions)),
|
||||
};
|
||||
|
||||
StorageProviderFactory.create = jest.fn(() => storageProviderMock);
|
||||
ExportProviderFactory.create = jest.fn(() => exportProviderMock);
|
||||
|
||||
beforeEach(() => {
|
||||
testProject = MockFactory.createTestProject("TestProject");
|
||||
|
@ -35,6 +42,23 @@ describe("Project Service", () => {
|
|||
expect.any(String));
|
||||
});
|
||||
|
||||
it("Save calls configured export provider save when defined", async () => {
|
||||
testProject.exportFormat = {
|
||||
providerType: "azureCustomVision",
|
||||
providerOptions: {},
|
||||
};
|
||||
|
||||
const result = await projectSerivce.save(testProject);
|
||||
|
||||
expect(result).toEqual(testProject);
|
||||
expect(ExportProviderFactory.create).toBeCalledWith(
|
||||
testProject.exportFormat.providerType,
|
||||
testProject,
|
||||
testProject.exportFormat.providerOptions,
|
||||
);
|
||||
expect(exportProviderMock.save).toBeCalledWith(testProject.exportFormat);
|
||||
});
|
||||
|
||||
it("Save throws error if writing to storage provider fails", async () => {
|
||||
const expectedError = "Error writing to storage provider";
|
||||
storageProviderMock.writeText.mockImplementationOnce(() => Promise.reject(expectedError));
|
||||
|
|
|
@ -3,6 +3,7 @@ import { StorageProviderFactory } from "../providers/storage/storageProviderFact
|
|||
import { IProject } from "../models/applicationState";
|
||||
import Guard from "../common/guard";
|
||||
import { constants } from "../common/constants";
|
||||
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
|
||||
|
||||
export interface IProjectService {
|
||||
save(project: IProject): Promise<IProject>;
|
||||
|
@ -24,6 +25,9 @@ export default class ProjectService implements IProjectService {
|
|||
project.targetConnection.providerOptions,
|
||||
);
|
||||
|
||||
await this.saveExportSettings(project);
|
||||
await this.saveProjectFile(project);
|
||||
|
||||
await storageProvider.writeText(
|
||||
`${project.name}${constants.projectFileExtension}`,
|
||||
JSON.stringify(project, null, 4));
|
||||
|
@ -53,4 +57,29 @@ export default class ProjectService implements IProjectService {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async saveExportSettings(project: IProject): Promise<void> {
|
||||
if (!project.exportFormat || !project.exportFormat.providerType) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const exportProvider = ExportProviderFactory.createFromProject(project);
|
||||
|
||||
if (!exportProvider.save) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
project.exportFormat.providerOptions = await exportProvider.save(project.exportFormat);
|
||||
}
|
||||
|
||||
private async saveProjectFile(project: IProject): Promise<void> {
|
||||
const storageProvider = StorageProviderFactory.create(
|
||||
project.targetConnection.providerType,
|
||||
project.targetConnection.providerOptions,
|
||||
);
|
||||
|
||||
await storageProvider.writeText(
|
||||
`${project.name}${constants.projectFileExtension}`,
|
||||
JSON.stringify(project, null, 4));
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче