"react-transition-group": "2.4.0",
"redux": "4.0.0",
"redux-thunk": "2.3.0",
- "unionize": "2.1.2"
+ "unionize": "2.1.2",
+ "uuid": "3.3.2"
},
"scripts": {
"start": "react-scripts-ts start",
"@types/react-router-redux": "5.0.15",
"@types/redux-devtools": "3.0.44",
"@types/redux-form": "7.4.5",
+ "@types/uuid": "3.4.4",
"axios-mock-adapter": "1.15.0",
"enzyme": "3.4.4",
"enzyme-adapter-react-16": "1.2.0",
yellow700: string;
red900: string;
blue500: string;
- grey500: string;
- grey700: string;
}
const arvadosPurple = '#361336';
yellow700: yellow["700"],
red900: red['900'],
blue500: blue['500'],
- grey500,
- grey700
}
},
overrides: {
page: number;
contextMenuColumn: boolean;
dataTableDefaultView?: React.ReactNode;
+ working?: boolean;
}
interface DataExplorerActionProps<T> {
}
render() {
const {
- columns, onContextMenu, onFiltersChange, onSortToggle, extractKey,
+ columns, onContextMenu, onFiltersChange, onSortToggle, working, extractKey,
rowsPerPage, rowsPerPageOptions, onColumnToggle, searchValue, onSearch,
items, itemsAvailable, onRowClick, onRowDoubleClick, classes,
dataTableDefaultView
onFiltersChange={onFiltersChange}
onSortToggle={onSortToggle}
extractKey={extractKey}
+ working={working}
defaultView={dataTableDefaultView}
/>
<Toolbar>
onSortToggle: (column: DataColumn<T>) => void;
onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<T>) => void;
extractKey?: (item: T) => React.Key;
+ working?: boolean;
defaultView?: React.ReactNode;
}
{items.map(this.renderBodyRow)}
</TableBody>
</Table>
- {items.length === 0 && this.renderNoItemsPlaceholder()}
+ {items.length === 0 && this.props.working !== undefined && !this.props.working && this.renderNoItemsPlaceholder()}
</div>
</div>;
}
renderNoItemsPlaceholder = () => {
return this.props.defaultView
? this.props.defaultView
- : <DataTableDefaultView />;
+ : <DataTableDefaultView/>;
}
renderHeadCell = (column: DataColumn<T>, index: number) => {
import { addRouteChangeHandlers } from './routes/route-change-handlers';
import { setCurrentTokenDialogApiHost } from '~/store/current-token-dialog/current-token-dialog-actions';
import { processResourceActionSet } from './views-components/context-menu/action-sets/process-resource-action-set';
+import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
const getBuildNumber = () => "BN-" + (process.env.REACT_APP_BUILD_NUMBER || "dev");
const getGitCommit = () => "GIT-" + (process.env.REACT_APP_GIT_COMMIT || "latest").substr(0, 7);
fetchConfig()
.then(({ config, apiHost }) => {
const history = createBrowserHistory();
- const services = createServices(config);
+ const services = createServices(config, {
+ progressFn: (id, working) => {
+ store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working }));
+ },
+ errorFn: (id, error) => {
+ console.error("Backend error:", error);
+ store.dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Backend error", kind: SnackbarKind.ERROR }));
+ }
+ });
const store = configureStore(history, services);
store.subscribe(initListener(history, store, services, config));
<App />,
document.getElementById('root') as HTMLElement
);
-
-
});
const initListener = (history: History, store: RootStore, services: ServiceRepository, config: Config) => {
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type ProgressFn = (id: string, working: boolean) => void;
+export type ErrorFn = (id: string, error: any) => void;
+
+export interface ApiActions {
+ progressFn: ProgressFn;
+ errorFn: ErrorFn;
+}
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { OrderBuilder } from "./order-builder";
+import { joinUrls } from "~/services/api/url-builder";
+
+describe("UrlBuilder", () => {
+ it("should join urls properly 1", () => {
+ expect(joinUrls('http://localhost:3000', '/main')).toEqual('http://localhost:3000/main');
+ });
+ it("should join urls properly 2", () => {
+ expect(joinUrls('http://localhost:3000/', '/main')).toEqual('http://localhost:3000/main');
+ });
+ it("should join urls properly 3", () => {
+ expect(joinUrls('http://localhost:3000//', '/main')).toEqual('http://localhost:3000/main');
+ });
+ it("should join urls properly 4", () => {
+ expect(joinUrls('http://localhost:3000', '//main')).toEqual('http://localhost:3000/main');
+ });
+ it("should join urls properly 5", () => {
+ expect(joinUrls('http://localhost:3000///', 'main')).toEqual('http://localhost:3000/main');
+ });
+ it("should join urls properly 6", () => {
+ expect(joinUrls('http://localhost:3000///', '//main')).toEqual('http://localhost:3000/main');
+ });
+ it("should join urls properly 7", () => {
+ expect(joinUrls(undefined, '//main')).toEqual('/main');
+ });
+ it("should join urls properly 8", () => {
+ expect(joinUrls(undefined, 'main')).toEqual('/main');
+ });
+ it("should join urls properly 9", () => {
+ expect(joinUrls('http://localhost:3000///', undefined)).toEqual('http://localhost:3000');
+ });
+});
return this.url + this.query;
}
}
+
+export function joinUrls(url0?: string, url1?: string) {
+ let u0 = "";
+ if (url0) {
+ let idx0 = url0.length - 1;
+ while (url0[idx0] === '/') { --idx0; }
+ u0 = url0.substr(0, idx0 + 1);
+ }
+ let u1 = "";
+ if (url1) {
+ let idx1 = 0;
+ while (url1[idx1] === '/') { ++idx1; }
+ u1 = url1.substr(idx1);
+ }
+ let url = u0;
+ if (u1.length > 0) {
+ url += '/';
+ }
+ url += u1;
+ return url;
+}
import { User } from "~/models/user";
import { AxiosInstance } from "axios";
+import { ApiActions, ProgressFn } from "~/services/api/api-actions";
+import * as uuid from "uuid/v4";
export const API_TOKEN_KEY = 'apiToken';
export const USER_EMAIL_KEY = 'userEmail';
constructor(
protected apiClient: AxiosInstance,
- protected baseUrl: string) { }
+ protected baseUrl: string,
+ protected actions: ApiActions) { }
public saveApiToken(token: string) {
localStorage.setItem(API_TOKEN_KEY, token);
}
public getUserDetails = (): Promise<User> => {
+ const reqId = uuid();
+ this.actions.progressFn(reqId, true);
return this.apiClient
.get<UserDetailsResponse>('/users/current')
- .then(resp => ({
- email: resp.data.email,
- firstName: resp.data.first_name,
- lastName: resp.data.last_name,
- uuid: resp.data.uuid,
- ownerUuid: resp.data.owner_uuid
- }));
+ .then(resp => {
+ this.actions.progressFn(reqId, false);
+ return {
+ email: resp.data.email,
+ firstName: resp.data.first_name,
+ lastName: resp.data.last_name,
+ uuid: resp.data.uuid,
+ ownerUuid: resp.data.owner_uuid
+ };
+ })
+ .catch(e => {
+ this.actions.progressFn(reqId, false);
+ this.actions.errorFn(reqId, e);
+ throw e;
+ });
}
public getRootUuid() {
import { parseFilesResponse } from "./collection-service-files-response";
import { fileToArrayBuffer } from "~/common/file";
import { TrashableResourceService } from "~/services/common-service/trashable-resource-service";
+import { ApiActions } from "~/services/api/api-actions";
export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
export class CollectionService extends TrashableResourceService<CollectionResource> {
- constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService) {
- super(serverApi, "collections");
+ constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
+ super(serverApi, "collections", actions);
}
async files(uuid: string) {
import axios, { AxiosInstance } from "axios";
import MockAdapter from "axios-mock-adapter";
import { Resource } from "src/models/resource";
+import { ApiActions } from "~/services/api/api-actions";
-export const mockResourceService = <R extends Resource, C extends CommonResourceService<R>>(Service: new (client: AxiosInstance) => C) => {
+const actions: ApiActions = {
+ progressFn: (id: string, working: boolean) => {},
+ errorFn: (id: string, message: string) => {}
+};
+
+export const mockResourceService = <R extends Resource, C extends CommonResourceService<R>>(
+ Service: new (client: AxiosInstance, actions: ApiActions) => C) => {
const axiosInstance = axios.create();
const axiosMock = new MockAdapter(axiosInstance);
- const service = new Service(axiosInstance);
+ const service = new Service(axiosInstance, actions);
Object.keys(service).map(key => service[key] = jest.fn());
return service;
};
.onPost("/resource/")
.reply(200, { owner_uuid: "ownerUuidValue" });
- const commonResourceService = new CommonResourceService(axiosInstance, "resource");
+ const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
const resource = await commonResourceService.create({ ownerUuid: "ownerUuidValue" });
expect(resource).toEqual({ ownerUuid: "ownerUuidValue" });
});
it("#create maps request params to snake case", async () => {
axiosInstance.post = jest.fn(() => Promise.resolve({data: {}}));
- const commonResourceService = new CommonResourceService(axiosInstance, "resource");
+ const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
await commonResourceService.create({ ownerUuid: "ownerUuidValue" });
expect(axiosInstance.post).toHaveBeenCalledWith("/resource/", {owner_uuid: "ownerUuidValue"});
});
.onDelete("/resource/uuid")
.reply(200, { deleted_at: "now" });
- const commonResourceService = new CommonResourceService(axiosInstance, "resource");
+ const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
const resource = await commonResourceService.delete("uuid");
expect(resource).toEqual({ deletedAt: "now" });
});
.onGet("/resource/uuid")
.reply(200, { modified_at: "now" });
- const commonResourceService = new CommonResourceService(axiosInstance, "resource");
+ const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
const resource = await commonResourceService.get("uuid");
expect(resource).toEqual({ modifiedAt: "now" });
});
items_available: 20
});
- const commonResourceService = new CommonResourceService(axiosInstance, "resource");
+ const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
const resource = await commonResourceService.list({ limit: 10, offset: 1 });
expect(resource).toEqual({
kind: "kind",
import * as _ from "lodash";
import { AxiosInstance, AxiosPromise } from "axios";
import { Resource } from "src/models/resource";
+import * as uuid from "uuid/v4";
+import { ApiActions } from "~/services/api/api-actions";
export interface ListArguments {
limit?: number;
export class CommonResourceService<T extends Resource> {
- static mapResponseKeys = (response: { data: any }): Promise<any> =>
+ static mapResponseKeys = (response: { data: any }) =>
CommonResourceService.mapKeys(_.camelCase)(response.data)
static mapKeys = (mapFn: (key: string) => string) =>
}
}
- static defaultResponse<R>(promise: AxiosPromise<R>): Promise<R> {
+ static defaultResponse<R>(promise: AxiosPromise<R>, actions: ApiActions): Promise<R> {
+ const reqId = uuid();
+ actions.progressFn(reqId, true);
return promise
+ .then(data => {
+ actions.progressFn(reqId, false);
+ return data;
+ })
.then(CommonResourceService.mapResponseKeys)
- .catch(({ response }) => Promise.reject<Errors>(CommonResourceService.mapResponseKeys(response)));
+ .catch(({ response }) => {
+ actions.progressFn(reqId, false);
+ const errors = CommonResourceService.mapResponseKeys(response) as Errors;
+ actions.errorFn(reqId, errors);
+ throw errors;
+ });
}
protected serverApi: AxiosInstance;
protected resourceType: string;
+ protected actions: ApiActions;
- constructor(serverApi: AxiosInstance, resourceType: string) {
+ constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions) {
this.serverApi = serverApi;
this.resourceType = '/' + resourceType + '/';
+ this.actions = actions;
}
create(data?: Partial<T> | any) {
return CommonResourceService.defaultResponse(
this.serverApi
- .post<T>(this.resourceType, data && CommonResourceService.mapKeys(_.snakeCase)(data)));
+ .post<T>(this.resourceType, data && CommonResourceService.mapKeys(_.snakeCase)(data)),
+ this.actions
+ );
}
delete(uuid: string): Promise<T> {
return CommonResourceService.defaultResponse(
this.serverApi
- .delete(this.resourceType + uuid));
+ .delete(this.resourceType + uuid),
+ this.actions
+ );
}
get(uuid: string) {
return CommonResourceService.defaultResponse(
this.serverApi
- .get<T>(this.resourceType + uuid));
+ .get<T>(this.resourceType + uuid),
+ this.actions
+ );
}
list(args: ListArguments = {}): Promise<ListResults<T>> {
this.serverApi
.get(this.resourceType, {
params: CommonResourceService.mapKeys(_.snakeCase)(params)
- }));
+ }),
+ this.actions
+ );
}
update(uuid: string, data: Partial<T>) {
return CommonResourceService.defaultResponse(
this.serverApi
- .put<T>(this.resourceType + uuid, data && CommonResourceService.mapKeys(_.snakeCase)(data)));
-
+ .put<T>(this.resourceType + uuid, data && CommonResourceService.mapKeys(_.snakeCase)(data)),
+ this.actions
+ );
}
}
import { AxiosInstance } from "axios";
import { TrashableResource } from "src/models/resource";
import { CommonResourceService } from "~/services/common-service/common-resource-service";
+import { ApiActions } from "~/services/api/api-actions";
export class TrashableResourceService<T extends TrashableResource> extends CommonResourceService<T> {
- constructor(serverApi: AxiosInstance, resourceType: string) {
- super(serverApi, resourceType);
+ constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions) {
+ super(serverApi, resourceType, actions);
}
trash(uuid: string): Promise<T> {
- return this.serverApi
- .post(this.resourceType + `${uuid}/trash`)
- .then(CommonResourceService.mapResponseKeys);
+ return CommonResourceService.defaultResponse(
+ this.serverApi
+ .post(this.resourceType + `${uuid}/trash`),
+ this.actions
+ );
}
untrash(uuid: string): Promise<T> {
const params = {
ensure_unique_name: true
};
- return this.serverApi
- .post(this.resourceType + `${uuid}/untrash`, {
- params: CommonResourceService.mapKeys(_.snakeCase)(params)
- })
- .then(CommonResourceService.mapResponseKeys);
+ return CommonResourceService.defaultResponse(
+ this.serverApi
+ .post(this.resourceType + `${uuid}/untrash`, {
+ params: CommonResourceService.mapKeys(_.snakeCase)(params)
+ }),
+ this.actions
+ );
}
}
import { CommonResourceService } from "~/services/common-service/common-resource-service";
import { AxiosInstance } from "axios";
-import { ContainerRequestResource } from '../../models/container-request';
+import { ContainerRequestResource } from '~/models/container-request';
+import { ApiActions } from "~/services/api/api-actions";
export class ContainerRequestService extends CommonResourceService<ContainerRequestResource> {
- constructor(serverApi: AxiosInstance) {
- super(serverApi, "container_requests");
+ constructor(serverApi: AxiosInstance, actions: ApiActions) {
+ super(serverApi, "container_requests", actions);
}
}
import { CommonResourceService } from "~/services/common-service/common-resource-service";
import { AxiosInstance } from "axios";
-import { ContainerResource } from '../../models/container';
+import { ContainerResource } from '~/models/container';
+import { ApiActions } from "~/services/api/api-actions";
export class ContainerService extends CommonResourceService<ContainerResource> {
- constructor(serverApi: AxiosInstance) {
- super(serverApi, "containers");
+ constructor(serverApi: AxiosInstance, actions: ApiActions) {
+ super(serverApi, "containers", actions);
}
}
export class FavoriteService {
constructor(
private linkService: LinkService,
- private groupsService: GroupsService
+ private groupsService: GroupsService,
) {}
create(data: { userUuid: string; resource: { uuid: string; name: string } }) {
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { GroupsService } from "./groups-service";
+import { ApiActions } from "~/services/api/api-actions";
describe("GroupsService", () => {
const axiosMock = new MockAdapter(axios);
+ const actions: ApiActions = {
+ progressFn: (id: string, working: boolean) => {},
+ errorFn: (id: string, message: string) => {}
+ };
+
beforeEach(() => {
axiosMock.reset();
});
items_available: 20
});
- const groupsService = new GroupsService(axios);
+ const groupsService = new GroupsService(axios, actions);
const resource = await groupsService.contents("1", { limit: 10, offset: 1 });
expect(resource).toEqual({
kind: "kind",
import { ProcessResource } from "~/models/process";
import { TrashableResource } from "~/models/resource";
import { TrashableResourceService } from "~/services/common-service/trashable-resource-service";
-import { GroupResource } from '~/models/group';
+import { ApiActions } from "~/services/api/api-actions";
+import { GroupResource } from "~/models/group";
export interface ContentsArguments {
limit?: number;
export class GroupsService<T extends GroupResource = GroupResource> extends TrashableResourceService<T> {
- constructor(serverApi: AxiosInstance) {
- super(serverApi, "groups");
+ constructor(serverApi: AxiosInstance, actions: ApiActions) {
+ super(serverApi, "groups", actions);
}
contents(uuid: string, args: ContentsArguments = {}): Promise<ListResults<GroupContentsResource>> {
filters: filters ? `[${filters}]` : undefined,
order: order ? order : undefined
};
- return this.serverApi
- .get(this.resourceType + `${uuid}/contents`, {
- params: CommonResourceService.mapKeys(_.snakeCase)(params)
- })
- .then(CommonResourceService.mapResponseKeys);
+ return CommonResourceService.defaultResponse(
+ this.serverApi
+ .get(this.resourceType + `${uuid}/contents`, {
+ params: CommonResourceService.mapKeys(_.snakeCase)(params)
+ }),
+ this.actions
+ );
}
shared(params: SharedArguments = {}): Promise<ListResults<GroupContentsResource>> {
- return this.serverApi
- .get(this.resourceType + 'shared', { params })
- .then(CommonResourceService.mapResponseKeys);
+ return CommonResourceService.defaultResponse(
+ this.serverApi
+ .get(this.resourceType + 'shared', { params }),
+ this.actions
+ );
}
}
import { CommonResourceService } from "~/services/common-service/common-resource-service";\r
import { AxiosInstance } from "axios";\r
import { KeepResource } from "~/models/keep";\r
+import { ApiActions } from "~/services/api/api-actions";\r
\r
export class KeepService extends CommonResourceService<KeepResource> {\r
- constructor(serverApi: AxiosInstance) {\r
- super(serverApi, "keep_services");\r
+ constructor(serverApi: AxiosInstance, actions: ApiActions) {\r
+ super(serverApi, "keep_services", actions);\r
}\r
}\r
import { CommonResourceService } from "~/services/common-service/common-resource-service";
import { LinkResource } from "~/models/link";
import { AxiosInstance } from "axios";
+import { ApiActions } from "~/services/api/api-actions";
export class LinkService extends CommonResourceService<LinkResource> {
- constructor(serverApi: AxiosInstance) {
- super(serverApi, "links");
+ constructor(serverApi: AxiosInstance, actions: ApiActions) {
+ super(serverApi, "links", actions);
}
}
import { AxiosInstance } from "axios";
import { LogResource } from '~/models/log';
import { CommonResourceService } from "~/services/common-service/common-resource-service";
+import { ApiActions } from "~/services/api/api-actions";
export class LogService extends CommonResourceService<LogResource> {
- constructor(serverApi: AxiosInstance) {
- super(serverApi, "logs");
+ constructor(serverApi: AxiosInstance, actions: ApiActions) {
+ super(serverApi, "logs", actions);
}
}
import axios from "axios";
import { ProjectService } from "./project-service";
import { FilterBuilder } from "~/services/api/filter-builder";
+import { ApiActions } from "~/services/api/api-actions";
describe("CommonResourceService", () => {
const axiosInstance = axios.create();
+ const actions: ApiActions = {
+ progressFn: (id: string, working: boolean) => {},
+ errorFn: (id: string, message: string) => {}
+ };
it(`#create has groupClass set to "project"`, async () => {
axiosInstance.post = jest.fn(() => Promise.resolve({ data: {} }));
- const projectService = new ProjectService(axiosInstance);
+ const projectService = new ProjectService(axiosInstance, actions);
const resource = await projectService.create({ name: "nameValue" });
expect(axiosInstance.post).toHaveBeenCalledWith("/groups/", {
name: "nameValue",
it("#list has groupClass filter set by default", async () => {
axiosInstance.get = jest.fn(() => Promise.resolve({ data: {} }));
- const projectService = new ProjectService(axiosInstance);
+ const projectService = new ProjectService(axiosInstance, actions);
const resource = await projectService.list();
expect(axiosInstance.get).toHaveBeenCalledWith("/groups/", {
params: {
import { TagService } from "./tag-service/tag-service";
import { CollectionFilesService } from "./collection-files-service/collection-files-service";
import { KeepService } from "./keep-service/keep-service";
-import { WebDAV } from "../common/webdav";
-import { Config } from "../common/config";
+import { WebDAV } from "~/common/webdav";
+import { Config } from "~/common/config";
import { UserService } from './user-service/user-service';
import { AncestorService } from "~/services/ancestors-service/ancestors-service";
import { ResourceKind } from "~/models/resource";
import { ContainerRequestService } from './container-request-service/container-request-service';
import { ContainerService } from './container-service/container-service';
import { LogService } from './log-service/log-service';
+import { ApiActions } from "~/services/api/api-actions";
export type ServiceRepository = ReturnType<typeof createServices>;
-export const createServices = (config: Config) => {
+export const createServices = (config: Config, actions: ApiActions) => {
const apiClient = Axios.create();
apiClient.defaults.baseURL = config.baseUrl;
const webdavClient = new WebDAV();
webdavClient.defaults.baseURL = config.keepWebServiceUrl;
- const containerRequestService = new ContainerRequestService(apiClient);
- const containerService = new ContainerService(apiClient);
- const groupsService = new GroupsService(apiClient);
- const keepService = new KeepService(apiClient);
- const linkService = new LinkService(apiClient);
- const logService = new LogService(apiClient);
- const projectService = new ProjectService(apiClient);
- const userService = new UserService(apiClient);
-
+ const containerRequestService = new ContainerRequestService(apiClient, actions);
+ const containerService = new ContainerService(apiClient, actions);
+ const groupsService = new GroupsService(apiClient, actions);
+ const keepService = new KeepService(apiClient, actions);
+ const linkService = new LinkService(apiClient, actions);
+ const logService = new LogService(apiClient, actions);
+ const projectService = new ProjectService(apiClient, actions);
+ const userService = new UserService(apiClient, actions);
+
const ancestorsService = new AncestorService(groupsService, userService);
- const authService = new AuthService(apiClient, config.rootUrl);
- const collectionService = new CollectionService(apiClient, webdavClient, authService);
+ const authService = new AuthService(apiClient, config.rootUrl, actions);
+ const collectionService = new CollectionService(apiClient, webdavClient, authService, actions);
const collectionFilesService = new CollectionFilesService(collectionService);
const favoriteService = new FavoriteService(linkService, groupsService);
const tagService = new TagService(linkService);
default:
return undefined;
}
-};
\ No newline at end of file
+};
import { AxiosInstance } from "axios";
import { CommonResourceService } from "~/services/common-service/common-resource-service";
import { UserResource } from "~/models/user";
+import { ApiActions } from "~/services/api/api-actions";
export class UserService extends CommonResourceService<UserResource> {
- constructor(serverApi: AxiosInstance) {
- super(serverApi, "users");
+ constructor(serverApi: AxiosInstance, actions: ApiActions) {
+ super(serverApi, "users", actions);
}
}
import { configureStore, RootStore } from "../store";
import createBrowserHistory from "history/createBrowserHistory";
import { mockConfig } from '~/common/config';
+import { ApiActions } from "~/services/api/api-actions";
describe('auth-actions', () => {
let reducer: (state: AuthState | undefined, action: AuthAction) => any;
let store: RootStore;
+ const actions: ApiActions = {
+ progressFn: (id: string, working: boolean) => {},
+ errorFn: (id: string, message: string) => {}
+ };
beforeEach(() => {
- store = configureStore(createBrowserHistory(), createServices(mockConfig({})));
+ store = configureStore(createBrowserHistory(), createServices(mockConfig({}), actions));
localStorage.clear();
- reducer = authReducer(createServices(mockConfig({})));
+ reducer = authReducer(createServices(mockConfig({}), actions));
});
it('should initialise state with user and api token from local storage', () => {
import 'jest-localstorage-mock';
import { createServices } from "~/services/services";
import { mockConfig } from '~/common/config';
+import { ApiActions } from "~/services/api/api-actions";
describe('auth-reducer', () => {
let reducer: (state: AuthState | undefined, action: AuthAction) => any;
+ const actions: ApiActions = {
+ progressFn: (id: string, working: boolean) => {},
+ errorFn: (id: string, message: string) => {}
+ };
beforeAll(() => {
localStorage.clear();
- reducer = authReducer(createServices(mockConfig({})));
+ reducer = authReducer(createServices(mockConfig({}), actions));
});
it('should correctly initialise state', () => {
import { ServiceRepository } from '~/services/services';
import { getCommonResourceServiceError, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
export const COLLECTION_COPY_FORM_NAME = 'collectionCopyFormName';
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
dispatch(startSubmit(COLLECTION_COPY_FORM_NAME));
try {
+ dispatch(progressIndicatorActions.START_WORKING(COLLECTION_COPY_FORM_NAME));
const collection = await services.collectionService.get(resource.uuid);
const uuidKey = 'uuid';
delete collection[uuidKey];
await services.collectionService.create({ ...collection, ownerUuid: resource.ownerUuid, name: resource.name });
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_COPY_FORM_NAME));
return collection;
} catch (e) {
const error = getCommonResourceServiceError(e);
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
throw new Error('Could not copy the collection.');
}
- return ;
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_COPY_FORM_NAME));
+ return;
}
};
import { getCommonResourceServiceError, CommonResourceServiceError } from "~/services/common-service/common-resource-service";
import { uploadCollectionFiles } from './collection-upload-actions';
import { fileUploaderActions } from '~/store/file-uploader/file-uploader-actions';
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
export interface CollectionCreateFormDialogData {
ownerUuid: string;
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
dispatch(startSubmit(COLLECTION_CREATE_FORM_NAME));
try {
+ dispatch(progressIndicatorActions.START_WORKING(COLLECTION_CREATE_FORM_NAME));
const newCollection = await services.collectionService.create(data);
await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME }));
dispatch(reset(COLLECTION_CREATE_FORM_NAME));
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_CREATE_FORM_NAME));
return newCollection;
} catch (e) {
const error = getCommonResourceServiceError(e);
if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME, { name: 'Collection with the same name already exists.' }));
}
- return ;
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_CREATE_FORM_NAME));
+ return;
}
};
import { ServiceRepository } from '~/services/services';
import { RootState } from '~/store/store';
import { getCommonResourceServiceError, CommonResourceServiceError } from "~/services/common-service/common-resource-service";
-import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
import { projectPanelActions } from '~/store/project-panel/project-panel-action';
import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
export const COLLECTION_MOVE_FORM_NAME = 'collectionMoveFormName';
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
dispatch(startSubmit(COLLECTION_MOVE_FORM_NAME));
try {
+ dispatch(progressIndicatorActions.START_WORKING(COLLECTION_MOVE_FORM_NAME));
const collection = await services.collectionService.get(resource.uuid);
await services.collectionService.update(resource.uuid, { ...collection, ownerUuid: resource.ownerUuid });
dispatch(projectPanelActions.REQUEST_ITEMS());
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been moved', hideDuration: 2000 }));
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: 'Collection has been moved',
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
return collection;
} catch (e) {
const error = getCommonResourceServiceError(e);
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the collection.', hideDuration: 2000 }));
}
- return ;
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
+ return;
}
};
import { dialogActions } from '~/store/dialog/dialog-actions';
import { ServiceRepository } from '~/services/services';
import { filterCollectionFilesBySelection } from '../collection-panel/collection-panel-files/collection-panel-files-state';
-import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
import { getCommonResourceServiceError, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
export const COLLECTION_PARTIAL_COPY_FORM_NAME = 'COLLECTION_PARTIAL_COPY_DIALOG';
const currentCollection = state.collectionPanel.item;
if (currentCollection) {
try {
+ dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
const collection = await services.collectionService.get(currentCollection.uuid);
const collectionCopy = {
...collection,
const paths = filterCollectionFilesBySelection(state.collectionPanelFiles, false).map(file => file.id);
await services.collectionService.deleteFiles(newCollection.uuid, paths);
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'New collection created.', hideDuration: 2000 }));
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: 'New collection created.',
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
} catch (e) {
const error = getCommonResourceServiceError(e);
if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000 }));
}
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
}
}
};
import { ServiceRepository } from "~/services/services";
import { CollectionResource } from '~/models/collection';
import { ContextMenuResource } from "~/store/context-menu/context-menu-actions";
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
export interface CollectionUpdateFormDialogData {
uuid: string;
const uuid = collection.uuid || '';
dispatch(startSubmit(COLLECTION_UPDATE_FORM_NAME));
try {
+ dispatch(progressIndicatorActions.START_WORKING(COLLECTION_UPDATE_FORM_NAME));
const updatedCollection = await services.collectionService.update(uuid, collection);
dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: updatedCollection as CollectionResource }));
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME }));
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPDATE_FORM_NAME));
return updatedCollection;
} catch (e) {
const error = getCommonResourceServiceError(e);
if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
dispatch(stopSubmit(COLLECTION_UPDATE_FORM_NAME, { name: 'Collection with the same name already exists.' }));
}
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPDATE_FORM_NAME));
return;
}
};
import { ServiceRepository } from '~/services/services';
import { dialogActions } from '~/store/dialog/dialog-actions';
import { loadCollectionFiles } from '../collection-panel/collection-panel-files/collection-panel-files-actions';
-import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
import { fileUploaderActions } from '~/store/file-uploader/file-uploader-actions';
import { reset, startSubmit } from 'redux-form';
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
export const uploadCollectionFiles = (collectionUuid: string) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const currentCollection = getState().collectionPanel.item;
if (currentCollection) {
- dispatch(startSubmit(COLLECTION_UPLOAD_FILES_DIALOG));
- await dispatch<any>(uploadCollectionFiles(currentCollection.uuid));
- dispatch<any>(loadCollectionFiles(currentCollection.uuid));
- dispatch(closeUploadCollectionFilesDialog());
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Data has been uploaded.', hideDuration: 2000 }));
+ try {
+ dispatch(progressIndicatorActions.START_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));
+ dispatch(startSubmit(COLLECTION_UPLOAD_FILES_DIALOG));
+ await dispatch<any>(uploadCollectionFiles(currentCollection.uuid));
+ dispatch<any>(loadCollectionFiles(currentCollection.uuid));
+ dispatch(closeUploadCollectionFilesDialog());
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: 'Data has been uploaded.',
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));
+ } catch (e) {
+ dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));
+ }
}
};
const handleUploadProgress = (dispatch: Dispatch) => (fileId: number, loaded: number, total: number, currentTime: number) => {
dispatch(fileUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));
-};
\ No newline at end of file
+};
rowsPerPage: number;
rowsPerPageOptions: number[];
searchValue: string;
+ working?: boolean;
}
export const initialDataExplorer: DataExplorer = {
import { LinkResource } from "~/models/link";
import { GroupContentsResource, GroupContentsResourcePrefix } from "~/services/groups-service/groups-service";
import { resourcesActions } from "~/store/resources/resources-actions";
-import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions.ts';
import { getDataExplorer } from "~/store/data-explorer/data-explorer-reducer";
import { loadMissingProcessesInformation } from "~/store/project-panel/project-panel-middleware-service";
if (!dataExplorer) {
api.dispatch(favoritesPanelDataExplorerIsNotSet());
} else {
-
const columns = dataExplorer.columns as DataColumns<string, FavoritePanelFilter>;
const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE);
const typeFilters = this.getColumnFilters(columns, FavoritePanelColumnNames.TYPE);
.addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT);
}
try {
+ api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
const response = await this.services.favoriteService
.list(this.services.authService.getUuid()!, {
limit: dataExplorer.rowsPerPage,
.addILike("name", dataExplorer.searchValue)
.getFilters()
});
+ api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
api.dispatch(resourcesActions.SET_RESOURCES(response.items));
await api.dispatch<any>(loadMissingProcessesInformation(response.items));
api.dispatch(favoritePanelActions.SET_ITEMS({
}));
api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
} catch (e) {
+ api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
api.dispatch(favoritePanelActions.SET_ITEMS({
items: [],
itemsAvailable: 0,
page: 0,
rowsPerPage: dataExplorer.rowsPerPage
}));
+ api.dispatch(couldNotFetchFavoritesContents());
}
}
}
snackbarActions.OPEN_SNACKBAR({
message: 'Favorites panel is not ready.'
});
+
+const couldNotFetchFavoritesContents = () =>
+ snackbarActions.OPEN_SNACKBAR({
+ message: 'Could not fetch favorites contents.',
+ kind: SnackbarKind.ERROR
+ });
import { Dispatch } from "redux";
import { RootState } from "../store";
import { checkFavorite } from "./favorites-reducer";
-import { snackbarActions } from "../snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
import { ServiceRepository } from "~/services/services";
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
export const favoritesActions = unionize({
TOGGLE_FAVORITE: ofType<{ resourceUuid: string }>(),
export const toggleFavorite = (resource: { uuid: string; name: string }) =>
(dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+ dispatch(progressIndicatorActions.START_WORKING("toggleFavorite"));
const userUuid = getState().auth.user!.uuid;
dispatch(favoritesActions.TOGGLE_FAVORITE({ resourceUuid: resource.uuid }));
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Working..." }));
const isFavorite = checkFavorite(resource.uuid, getState().favorites);
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: isFavorite
+ ? "Removing from favorites..."
+ : "Adding to favorites..."
+ }));
+
const promise: any = isFavorite
? services.favoriteService.delete({ userUuid, resourceUuid: resource.uuid })
: services.favoriteService.create({ userUuid, resource });
message: isFavorite
? "Removed from favorites"
: "Added to favorites",
- hideDuration: 2000
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
}));
+ dispatch(progressIndicatorActions.STOP_WORKING("toggleFavorite"));
+ })
+ .catch((e: any) => {
+ dispatch(progressIndicatorActions.STOP_WORKING("toggleFavorite"));
+ throw e;
});
};
? getTimeDiff(container.finishedAt || '', container.startedAt || '')
: 0;
-export const getProcessStatusColor = (status: string, { customs }: ArvadosTheme) => {
+export const getProcessStatusColor = (status: string, { customs, palette }: ArvadosTheme) => {
switch (status) {
case ProcessStatus.RUNNING:
return customs.colors.blue500;
case ProcessStatus.FAILED:
return customs.colors.red900;
default:
- return customs.colors.grey500;
+ return palette.grey["500"];
}
};
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "~/common/unionize";
+
+export const progressIndicatorActions = unionize({
+ START_WORKING: ofType<string>(),
+ STOP_WORKING: ofType<string>(),
+ PERSIST_STOP_WORKING: ofType<string>(),
+ TOGGLE_WORKING: ofType<{ id: string, working: boolean }>()
+});
+
+export type ProgressIndicatorAction = UnionOf<typeof progressIndicatorActions>;
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ProgressIndicatorAction, progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
+
+export type ProgressIndicatorState = { id: string, working: boolean }[];
+
+const initialState: ProgressIndicatorState = [];
+
+export const progressIndicatorReducer = (state: ProgressIndicatorState = initialState, action: ProgressIndicatorAction) => {
+ const startWorking = (id: string) => state.find(p => p.id === id) ? state : state.concat({ id, working: true });
+ const stopWorking = (id: string) => state.filter(p => p.id !== id);
+
+ return progressIndicatorActions.match(action, {
+ START_WORKING: id => startWorking(id),
+ STOP_WORKING: id => stopWorking(id),
+ PERSIST_STOP_WORKING: id => state.map(p => ({
+ ...p,
+ working: p.id === id ? false : p.working
+ })),
+ TOGGLE_WORKING: ({ id, working }) => working ? startWorking(id) : stopWorking(id),
+ default: () => state,
+ });
+};
+
+export function isSystemWorking(state: ProgressIndicatorState): boolean {
+ return state.length > 0 && state.reduce((working, p) => working ? true : p.working, false);
+}
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { RootState } from '~/store/store';
+
+export type WithProgressStateProps = {
+ working: boolean;
+};
+
+export const withProgress = (id: string) =>
+ (component: React.ComponentType<WithProgressStateProps>) =>
+ connect(mapStateToProps(id))(component);
+
+export const mapStateToProps = (id: string) => (state: RootState): WithProgressStateProps => {
+ const progress = state.progressIndicator.find(p => p.id === id);
+ return { working: progress ? progress.working : false };
+};
import { ProjectResource } from "~/models/project";
import { updateResources } from "~/store/resources/resources-actions";
import { getProperty } from "~/store/properties/properties";
-import { snackbarActions } from '../snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from '../snackbar/snackbar-actions';
+import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions.ts';
import { DataExplorer, getDataExplorer } from '../data-explorer/data-explorer-reducer';
import { ListResults } from '~/services/common-service/common-resource-service';
import { loadContainers } from '../processes/processes-actions';
api.dispatch(projectPanelDataExplorerIsNotSet());
} else {
try {
+ api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
const response = await this.services.groupsService.contents(projectUuid, getParams(dataExplorer));
+ api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
api.dispatch(updateResources(response.items));
await api.dispatch<any>(loadMissingProcessesInformation(response.items));
api.dispatch(setItems(response));
} catch (e) {
+ api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+ api.dispatch(projectPanelActions.SET_ITEMS({
+ items: [],
+ itemsAvailable: 0,
+ page: 0,
+ rowsPerPage: dataExplorer.rowsPerPage
+ }));
api.dispatch(couldNotFetchProjectContents());
}
}
const couldNotFetchProjectContents = () =>
snackbarActions.OPEN_SNACKBAR({
- message: 'Could not fetch project contents.'
+ message: 'Could not fetch project contents.',
+ kind: SnackbarKind.ERROR
});
const projectPanelDataExplorerIsNotSet = () =>
import { TreeItemStatus } from "~/components/tree/tree";
import { getNodeAncestors, getNodeValue, getNodeAncestorsIds, getNode } from '~/models/tree';
import { ProjectResource } from '~/models/project';
+import { progressIndicatorActions } from '../progress-indicator/progress-indicator-actions';
export enum SidePanelTreeCategory {
PROJECTS = 'Projects',
import { unionize, ofType, UnionOf } from "~/common/unionize";
+export interface SnackbarMessage {
+ message: string;
+ hideDuration: number;
+ kind: SnackbarKind;
+}
+
+export enum SnackbarKind {
+ SUCCESS = 1,
+ ERROR = 2,
+ INFO = 3,
+ WARNING = 4
+}
+
export const snackbarActions = unionize({
- OPEN_SNACKBAR: ofType<{message: string; hideDuration?: number}>(),
- CLOSE_SNACKBAR: ofType<{}>()
+ OPEN_SNACKBAR: ofType<{message: string; hideDuration?: number, kind?: SnackbarKind}>(),
+ CLOSE_SNACKBAR: ofType<{}>(),
+ SHIFT_MESSAGES: ofType<{}>()
});
export type SnackbarAction = UnionOf<typeof snackbarActions>;
//
// SPDX-License-Identifier: AGPL-3.0
-import { SnackbarAction, snackbarActions } from "./snackbar-actions";
+import { SnackbarAction, snackbarActions, SnackbarKind, SnackbarMessage } from "./snackbar-actions";
export interface SnackbarState {
- message: string;
+ messages: SnackbarMessage[];
open: boolean;
- hideDuration: number;
}
const DEFAULT_HIDE_DURATION = 3000;
const initialState: SnackbarState = {
- message: "",
- open: false,
- hideDuration: DEFAULT_HIDE_DURATION
+ messages: [],
+ open: false
};
export const snackbarReducer = (state = initialState, action: SnackbarAction) => {
return snackbarActions.match(action, {
- OPEN_SNACKBAR: data => ({ ...initialState, ...data, open: true }),
- CLOSE_SNACKBAR: () => initialState,
+ OPEN_SNACKBAR: data => {
+ return {
+ open: true,
+ messages: state.messages.concat({
+ message: data.message,
+ hideDuration: data.hideDuration ? data.hideDuration : DEFAULT_HIDE_DURATION,
+ kind: data.kind ? data.kind : SnackbarKind.INFO
+ })
+ };
+ },
+ CLOSE_SNACKBAR: () => ({
+ ...state,
+ open: false
+ }),
+ SHIFT_MESSAGES: () => {
+ const messages = state.messages.filter((m, idx) => idx > 0);
+ return {
+ open: messages.length > 0,
+ messages
+ };
+ },
default: () => state,
});
};
import { processPanelReducer } from '~/store/process-panel/process-panel-reducer';
import { SHARED_WITH_ME_PANEL_ID } from '~/store/shared-with-me-panel/shared-with-me-panel-actions';
import { SharedWithMeMiddlewareService } from './shared-with-me-panel/shared-with-me-middleware-service';
+import { progressIndicatorReducer } from './progress-indicator/progress-indicator-reducer';
const composeEnhancers =
(process.env.NODE_ENV === 'development' &&
snackbar: snackbarReducer,
treePicker: treePickerReducer,
fileUploader: fileUploaderReducer,
- processPanel: processPanelReducer
+ processPanel: processPanelReducer,
+ progressIndicator: progressIndicatorReducer
});
import { ProjectResource } from "~/models/project";
import { ProjectPanelColumnNames } from "~/views/project-panel/project-panel";
import { updateFavorites } from "~/store/favorites/favorites-actions";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
import { updateResources } from "~/store/resources/resources-actions";
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
constructor(private services: ServiceRepository, id: string) {
}
try {
+ api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
const userUuid = this.services.authService.getUuid()!;
const listResults = await this.services.groupsService
.contents(userUuid, {
recursive: true,
includeTrash: true
});
+ api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
const items = listResults.items.map(it => it.uuid);
api.dispatch<any>(updateFavorites(items));
api.dispatch(updateResources(listResults.items));
} catch (e) {
+ api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+ api.dispatch(trashPanelActions.SET_ITEMS({
+ items: [],
+ itemsAvailable: 0,
+ page: 0,
+ rowsPerPage: dataExplorer.rowsPerPage
+ }));
api.dispatch(couldNotFetchTrashContents());
}
}
const couldNotFetchTrashContents = () =>
snackbarActions.OPEN_SNACKBAR({
- message: 'Could not fetch trash contents.'
+ message: 'Could not fetch trash contents.',
+ kind: SnackbarKind.ERROR
});
+
import { Dispatch } from "redux";
import { RootState } from "~/store/store";
import { ServiceRepository } from "~/services/services";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
import { trashPanelActions } from "~/store/trash-panel/trash-panel-action";
import { activateSidePanelTreeItem, loadSidePanelTreeProjects } from "~/store/side-panel-tree/side-panel-tree-actions";
import { projectPanelActions } from "~/store/project-panel/project-panel-action";
-import { ResourceKind, TrashableResource } from "~/models/resource";
+import { ResourceKind } from "~/models/resource";
export const toggleProjectTrashed = (uuid: string, ownerUuid: string, isTrashed: boolean) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
- if (isTrashed) {
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Restoring from trash..." }));
- await services.groupsService.untrash(uuid);
- dispatch<any>(activateSidePanelTreeItem(uuid));
- dispatch(trashPanelActions.REQUEST_ITEMS());
- dispatch(snackbarActions.CLOSE_SNACKBAR());
+ try {
+ if (isTrashed) {
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Restoring from trash..." }));
+ await services.groupsService.untrash(uuid);
+ dispatch<any>(activateSidePanelTreeItem(uuid));
+ dispatch(trashPanelActions.REQUEST_ITEMS());
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: "Restored from trash",
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ } else {
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Moving to trash..." }));
+ await services.groupsService.trash(uuid);
+ dispatch<any>(loadSidePanelTreeProjects(ownerUuid));
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: "Added to trash",
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ }
+ } catch (e) {
dispatch(snackbarActions.OPEN_SNACKBAR({
- message: "Restored from trash",
- hideDuration: 2000
- }));
- } else {
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Moving to trash..." }));
- await services.groupsService.trash(uuid);
- dispatch<any>(loadSidePanelTreeProjects(ownerUuid));
- dispatch(snackbarActions.CLOSE_SNACKBAR());
- dispatch(snackbarActions.OPEN_SNACKBAR({
- message: "Added to trash",
- hideDuration: 2000
+ message: "Could not move project to trash",
+ kind: SnackbarKind.ERROR
}));
}
};
export const toggleCollectionTrashed = (uuid: string, isTrashed: boolean) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
- if (isTrashed) {
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Restoring from trash..." }));
- await services.collectionService.untrash(uuid);
- dispatch(trashPanelActions.REQUEST_ITEMS());
- dispatch(snackbarActions.OPEN_SNACKBAR({
- message: "Restored from trash",
- hideDuration: 2000
- }));
- } else {
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Moving to trash..." }));
- await services.collectionService.trash(uuid);
- dispatch(projectPanelActions.REQUEST_ITEMS());
+ try {
+ if (isTrashed) {
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Restoring from trash..." }));
+ await services.collectionService.untrash(uuid);
+ dispatch(trashPanelActions.REQUEST_ITEMS());
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: "Restored from trash",
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ } else {
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Moving to trash..." }));
+ await services.collectionService.trash(uuid);
+ dispatch(projectPanelActions.REQUEST_ITEMS());
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: "Added to trash",
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ }
+ } catch (e) {
dispatch(snackbarActions.OPEN_SNACKBAR({
- message: "Added to trash",
- hideDuration: 2000
+ message: "Could not move collection to trash",
+ kind: SnackbarKind.ERROR
}));
}
};
}
const mapStateToProps = (state: RootState, { id }: Props) => {
- return getDataExplorer(state.dataExplorer, id);
+ const progress = state.progressIndicator.find(p => p.id === id);
+ const working = progress && progress.working;
+ return { ...getDataExplorer(state.dataExplorer, id), working };
};
const mapDispatchToProps = () => {
import { AccountMenu } from "~/views-components/main-app-bar/account-menu";
import { AnonymousMenu } from "~/views-components/main-app-bar/anonymous-menu";
import { HelpMenu } from './help-menu';
+import { ReactNode } from "react";
type CssRules = 'toolbar' | 'link';
searchDebounce?: number;
user?: User;
buildInfo?: string;
+ children?: ReactNode;
}
export interface MainAppBarActionProps {
export const MainAppBar = withStyles(styles)(
(props: MainAppBarProps) => {
- return <AppBar position="static">
+ return <AppBar position="absolute">
<Toolbar className={props.classes.toolbar}>
<Grid container justify="space-between">
<Grid container item xs={3} direction="column" justify="center">
</Grid>
</Grid>
</Toolbar>
+ {props.children}
</AppBar>;
}
);
--- /dev/null
+// // Copyright (C) The Arvados Authors. All rights reserved.
+// //
+// // SPDX-License-Identifier: AGPL-3.0
+//
+// import * as React from 'react';
+// import { CircularProgress } from '@material-ui/core';
+// import { withProgress } from '~/store/progress-indicator/with-progress';
+// import { WithProgressStateProps } from '~/store/progress-indicator/with-progress';
+// import { ProgressIndicatorData } from '~/store/progress-indicator/progress-indicator-reducer';
+//
+// export const ContentProgress = withProgress(ProgressIndicatorData.CONTENT_PROGRESS)((props: WithProgressStateProps) =>
+// props.started ? <CircularProgress /> : null
+// );
--- /dev/null
+// // Copyright (C) The Arvados Authors. All rights reserved.
+// //
+// // SPDX-License-Identifier: AGPL-3.0
+//
+// import * as React from 'react';
+// import { CircularProgress } from '@material-ui/core';
+// import { withProgress } from '~/store/progress-indicator/with-progress';
+// import { WithProgressStateProps } from '~/store/progress-indicator/with-progress';
+// import { ProgressIndicatorData } from '~/store/progress-indicator/progress-indicator-reducer';
+//
+// export const SidePanelProgress = withProgress(ProgressIndicatorData.SIDE_PANEL_PROGRESS)((props: WithProgressStateProps) =>
+// props.started ? <span style={{ display: 'flex', justifyContent: 'center', marginTop: "40px" }}><CircularProgress /></span> : null
+// );
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// import * as React from 'react';
+// import { LinearProgress } from '@material-ui/core';
+// import { withProgress } from '~/store/progress-indicator/with-progress';
+// import { WithProgressStateProps } from '~/store/progress-indicator/with-progress';
+// import { ProgressIndicatorData } from '~/store/progress-indicator/progress-indicator-reducer';
+
+// export const WorkbenchProgress = withProgress(ProgressIndicatorData.WORKBENCH_PROGRESS)(
+// (props: WithProgressStateProps) =>
+// props.started ? <LinearProgress color="secondary" /> : null
+// );
export interface SidePanelTreeProps {
onItemActivation: (id: string) => void;
+ sidePanelProgress?: boolean;
}
type SidePanelTreeActionProps = Pick<TreePickerProps, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen'>;
import { navigateFromSidePanel } from '../../store/side-panel/side-panel-action';
import { Grid } from '@material-ui/core';
import { SidePanelButton } from '~/views-components/side-panel-button/side-panel-button';
+import { RootState } from '~/store/store';
const DRAWER_WITDH = 240;
}
});
+const mapStateToProps = (state: RootState) => ({
+});
+
export const SidePanel = compose(
withStyles(styles),
- connect(undefined, mapDispatchToProps)
+ connect(mapStateToProps, mapDispatchToProps)
)(({ classes, ...props }: WithStyles<CssRules> & SidePanelTreeProps) =>
<Grid item xs>
<SidePanelButton />
<SidePanelTree {...props} />
- </Grid>);
\ No newline at end of file
+ </Grid>);
import { RootState } from "~/store/store";
import MaterialSnackbar, { SnackbarProps } from "@material-ui/core/Snackbar";
import { Dispatch } from "redux";
-import { snackbarActions } from "~/store/snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
+import IconButton from '@material-ui/core/IconButton';
+import SnackbarContent from '@material-ui/core/SnackbarContent';
+import WarningIcon from '@material-ui/icons/Warning';
+import CheckCircleIcon from '@material-ui/icons/CheckCircle';
+import ErrorIcon from '@material-ui/icons/Error';
+import InfoIcon from '@material-ui/icons/Info';
+import CloseIcon from '@material-ui/icons/Close';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from "~/common/custom-theme";
+import { amber, green } from "@material-ui/core/colors";
+import * as classNames from 'classnames';
-const mapStateToProps = (state: RootState): SnackbarProps => ({
- anchorOrigin: { vertical: "bottom", horizontal: "center" },
- open: state.snackbar.open,
- message: <span>{state.snackbar.message}</span>,
- autoHideDuration: state.snackbar.hideDuration
-});
+const mapStateToProps = (state: RootState): SnackbarProps & ArvadosSnackbarProps => {
+ const messages = state.snackbar.messages;
+ return {
+ anchorOrigin: { vertical: "bottom", horizontal: "right" },
+ open: state.snackbar.open,
+ message: <span>{messages.length > 0 ? messages[0].message : ""}</span>,
+ autoHideDuration: messages.length > 0 ? messages[0].hideDuration : 0,
+ kind: messages.length > 0 ? messages[0].kind : SnackbarKind.INFO
+ };
+};
-const mapDispatchToProps = (dispatch: Dispatch): Pick<SnackbarProps, "onClose"> => ({
+const mapDispatchToProps = (dispatch: Dispatch) => ({
onClose: (event: any, reason: string) => {
if (reason !== "clickaway") {
dispatch(snackbarActions.CLOSE_SNACKBAR());
}
+ },
+ onExited: () => {
+ dispatch(snackbarActions.SHIFT_MESSAGES());
}
});
-export const Snackbar = connect(mapStateToProps, mapDispatchToProps)(MaterialSnackbar);
+const ArvadosSnackbar = (props: any) => <MaterialSnackbar {...props}>
+ <ArvadosSnackbarContent {...props}/>
+</MaterialSnackbar>;
+
+type CssRules = "success" | "error" | "info" | "warning" | "icon" | "iconVariant" | "message";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+ success: {
+ backgroundColor: green[600]
+ },
+ error: {
+ backgroundColor: theme.palette.error.dark
+ },
+ info: {
+ backgroundColor: theme.palette.primary.dark
+ },
+ warning: {
+ backgroundColor: amber[700]
+ },
+ icon: {
+ fontSize: 20
+ },
+ iconVariant: {
+ opacity: 0.9,
+ marginRight: theme.spacing.unit
+ },
+ message: {
+ display: 'flex',
+ alignItems: 'center'
+ },
+});
+
+interface ArvadosSnackbarProps {
+ kind: SnackbarKind;
+}
+
+const ArvadosSnackbarContent = (props: SnackbarProps & ArvadosSnackbarProps & WithStyles<CssRules>) => {
+ const { classes, className, message, onClose, kind } = props;
+
+ let Icon = InfoIcon;
+ let cssClass = classes.info;
+
+ switch (kind) {
+ case SnackbarKind.INFO:
+ Icon = InfoIcon;
+ cssClass = classes.info;
+ break;
+ case SnackbarKind.WARNING:
+ Icon = WarningIcon;
+ cssClass = classes.warning;
+ break;
+ case SnackbarKind.SUCCESS:
+ Icon = CheckCircleIcon;
+ cssClass = classes.success;
+ break;
+ case SnackbarKind.ERROR:
+ Icon = ErrorIcon;
+ cssClass = classes.error;
+ break;
+ }
+
+ return (
+ <SnackbarContent
+ className={classNames(cssClass, className)}
+ aria-describedby="client-snackbar"
+ message={
+ <span id="client-snackbar" className={classes.message}>
+ <Icon className={classNames(classes.icon, classes.iconVariant)}/>
+ {message}
+ </span>
+ }
+ action={
+ <IconButton
+ key="close"
+ aria-label="Close"
+ color="inherit"
+ onClick={e => {
+ if (onClose) {
+ onClose(e, '');
+ }
+ }}>
+ <CloseIcon className={classes.icon}/>
+ </IconButton>
+ }
+ />
+ );
+};
+
+export const Snackbar = connect(mapStateToProps, mapDispatchToProps)(
+ withStyles(styles)(ArvadosSnackbar)
+);
connect(mapStateToProps, mapDispatchToProps)(
class extends React.Component<FavoritePanelProps> {
render() {
- return this.hasAnyFavorites()
- ? <DataExplorer
- id={FAVORITE_PANEL_ID}
- onRowClick={this.props.onItemClick}
- onRowDoubleClick={this.props.onItemDoubleClick}
- onContextMenu={this.props.onContextMenu}
- contextMenuColumn={true}
- dataTableDefaultView={<DataTableDefaultView icon={FavoriteIcon}/>} />
- : <PanelDefaultView
- icon={FavoriteIcon}
- messages={['Your favorites list is empty.']} />;
- }
-
- hasAnyFavorites = () => {
- return Object
- .keys(this.props.favorites)
- .find(uuid => this.props.favorites[uuid]);
+ return <DataExplorer
+ id={FAVORITE_PANEL_ID}
+ onRowClick={this.props.onItemClick}
+ onRowDoubleClick={this.props.onItemDoubleClick}
+ onContextMenu={this.props.onContextMenu}
+ contextMenuColumn={true}
+ dataTableDefaultView={
+ <DataTableDefaultView
+ icon={FavoriteIcon}
+ messages={['Your favorites list is empty.']}
+ />
+ } />;
}
}
)
height: '100%'
},
title: {
- color: theme.customs.colors.grey700
+ color: theme.palette.grey["700"]
},
gridFilter: {
height: '20px',
import { navigateTo } from '~/store/navigation/navigation-action';
import { getProperty } from '~/store/properties/properties';
import { PROJECT_PANEL_CURRENT_UUID } from '~/store/project-panel/project-panel-action';
-import { filterResources } from '~/store/resources/resources';
-import { PanelDefaultView } from '~/components/panel-default-view/panel-default-view';
import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
+import { StyleRulesCallback, WithStyles } from "@material-ui/core";
+import { ArvadosTheme } from "~/common/custom-theme";
+import withStyles from "@material-ui/core/styles/withStyles";
+
+type CssRules = 'root' | "button";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+ root: {
+ position: 'relative',
+ width: '100%',
+ height: '100%'
+ },
+ button: {
+ marginLeft: theme.spacing.unit
+ },
+});
export enum ProjectPanelColumnNames {
NAME = "Name",
resources: ResourcesState;
}
-type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & RouteComponentProps<{ id: string }>;
+type ProjectPanelProps = ProjectPanelDataProps & DispatchProp
+ & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
-export const ProjectPanel = connect((state: RootState) => ({
+export const ProjectPanel = withStyles(styles)(
+ connect((state: RootState) => ({
currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
resources: state.resources
}))(
class extends React.Component<ProjectPanelProps> {
render() {
- return this.hasAnyItems()
- ? <DataExplorer
+ const { classes } = this.props;
+ return <div className={classes.root}>
+ <DataExplorer
id={PROJECT_PANEL_ID}
onRowClick={this.handleRowClick}
onRowDoubleClick={this.handleRowDoubleClick}
onContextMenu={this.handleContextMenu}
contextMenuColumn={true}
- dataTableDefaultView={<DataTableDefaultView icon={ProjectIcon}/>} />
- : <PanelDefaultView
- icon={ProjectIcon}
- messages={['Your project is empty.', 'Please create a project or create a collection and upload a data.']} />;
- }
-
- hasAnyItems = () => {
- const resources = filterResources(this.isCurrentItemChild)(this.props.resources);
- return resources.length > 0;
+ dataTableDefaultView={
+ <DataTableDefaultView
+ icon={ProjectIcon}
+ messages={[
+ 'Your project is empty.',
+ 'Please create a project or create a collection and upload a data.'
+ ]}/>
+ }/>
+ </div>;
}
isCurrentItemChild = (resource: Resource) => {
}
}
- );
+ )
+);
}))(
class extends React.Component<TrashPanelProps> {
render() {
- return this.hasAnyTrashedResources()
- ? <DataExplorer
- id={TRASH_PANEL_ID}
- onRowClick={this.handleRowClick}
- onRowDoubleClick={this.handleRowDoubleClick}
- onContextMenu={this.handleContextMenu}
- contextMenuColumn={false}
- dataTableDefaultView={<DataTableDefaultView icon={TrashIcon} />} />
- : <PanelDefaultView
- icon={TrashIcon}
- messages={['Your trash list is empty.']} />;
- }
-
- hasAnyTrashedResources = () => {
- // TODO: implement check if there is anything in the trash,
- // without taking pagination into the account
- return true;
+ return <DataExplorer
+ id={TRASH_PANEL_ID}
+ onRowClick={this.handleRowClick}
+ onRowDoubleClick={this.handleRowDoubleClick}
+ onContextMenu={this.handleContextMenu}
+ contextMenuColumn={false}
+ dataTableDefaultView={
+ <DataTableDefaultView
+ icon={TrashIcon}
+ messages={['Your trash list is empty.']}/>
+ } />;
}
handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
import { PartialCopyCollectionDialog } from '~/views-components/dialog-forms/partial-copy-collection-dialog';
import { TrashPanel } from "~/views/trash-panel/trash-panel";
import { MainContentBar } from '~/views-components/main-content-bar/main-content-bar';
-import { Grid } from '@material-ui/core';
+import { Grid, LinearProgress } from '@material-ui/core';
import { SharedWithMePanel } from '../shared-with-me-panel/shared-with-me-panel';
-import { ProcessCommandDialog } from '~/views-components/process-command-dialog/process-command-dialog';
import SplitterLayout from 'react-splitter-layout';
+import { ProcessCommandDialog } from '~/views-components/process-command-dialog/process-command-dialog';
+import { isSystemWorking } from "~/store/progress-indicator/progress-indicator-reducer";
type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content' | 'appBar';
root: {
overflow: 'hidden',
width: '100vw',
- height: '100vh'
+ height: '100vh',
+ paddingTop: theme.spacing.unit * 8
},
container: {
position: 'relative'
interface WorkbenchDataProps {
user?: User;
currentToken?: string;
+ working: boolean;
}
interface WorkbenchGeneralProps {
(state: RootState) => ({
user: state.auth.user,
currentToken: state.auth.apiToken,
+ working: isSystemWorking(state.progressIndicator)
})
)(
class extends React.Component<WorkbenchProps, WorkbenchState> {
render() {
const { classes } = this.props;
return <>
+ <MainAppBar
+ searchText={this.state.searchText}
+ user={this.props.user}
+ onSearch={this.onSearch}
+ buildInfo={this.props.buildInfo}>
+ {this.props.working ? <LinearProgress color="secondary" /> : null}
+ </MainAppBar>
<Grid container direction="column" className={classes.root}>
- <Grid className={classes.appBar}>
- <MainAppBar
- searchText={this.state.searchText}
- user={this.props.user}
- onSearch={this.onSearch}
- buildInfo={this.props.buildInfo} />
- </Grid>
{this.props.user &&
<Grid container item xs alignItems="stretch" wrap="nowrap">
<Grid container item className={classes.container}>
"no-shadowed-variable": false,
"semicolon": true,
"array-type": false,
- "interface-over-type-literal": false
+ "interface-over-type-literal": false,
+ "no-empty": false
},
"linterOptions": {
"exclude": [
"@types/react" "*"
redux "^3.6.0 || ^4.0.0"
+"@types/uuid@3.4.4":
+ version "3.4.4"
+ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.4.tgz#7af69360fa65ef0decb41fd150bf4ca5c0cefdf5"
+ dependencies:
+ "@types/node" "*"
+
abab@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e"
version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+uuid@3.3.2, uuid@^3.1.0:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
+
uuid@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
-uuid@^3.1.0:
- version "3.3.2"
- resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
-
validate-npm-package-license@^3.0.1:
version "3.0.3"
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.3.tgz#81643bcbef1bdfecd4623793dc4648948ba98338"