This commit is contained in:
Jordan Ellis 2019-01-10 19:43:55 -08:00
Родитель 9cceab038f 1556453d10
Коммит 63ab29cb77
46 изменённых файлов: 1578 добавлений и 447 удалений

Просмотреть файл

@ -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

5
.gitignore поставляемый
Просмотреть файл

@ -29,4 +29,7 @@ yarn-debug.log*
yarn-error.log*
# dev
secrets.sh
secrets.sh
# ide
.idea

8
package-lock.json сгенерированный
Просмотреть файл

@ -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));
}
}