From: Daniel Kos Date: Tue, 18 Sep 2018 11:55:53 +0000 (+0200) Subject: refs #14186 Merge branch 'origin/14186-progress-indicator-store' X-Git-Tag: 1.3.0~91 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/ea54fb82c3a59ca8a959643f8bec4776635433e0?hp=cc03991115a540c9a62f108e891c5270865e754a refs #14186 Merge branch 'origin/14186-progress-indicator-store' Arvados-DCO-1.1-Signed-off-by: Daniel Kos --- diff --git a/package.json b/package.json index 623e1170..620ff5a6 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "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", @@ -48,6 +49,7 @@ "@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", diff --git a/src/common/custom-theme.ts b/src/common/custom-theme.ts index 3d56f78b..ff0eb5e3 100644 --- a/src/common/custom-theme.ts +++ b/src/common/custom-theme.ts @@ -27,8 +27,6 @@ interface Colors { yellow700: string; red900: string; blue500: string; - grey500: string; - grey700: string; } const arvadosPurple = '#361336'; @@ -46,8 +44,6 @@ export const themeOptions: ArvadosThemeOptions = { yellow700: yellow["700"], red900: red['900'], blue500: blue['500'], - grey500, - grey700 } }, overrides: { diff --git a/src/components/data-explorer/data-explorer.tsx b/src/components/data-explorer/data-explorer.tsx index d63d1ccc..59f4dbeb 100644 --- a/src/components/data-explorer/data-explorer.tsx +++ b/src/components/data-explorer/data-explorer.tsx @@ -33,6 +33,7 @@ interface DataExplorerDataProps { page: number; contextMenuColumn: boolean; dataTableDefaultView?: React.ReactNode; + working?: boolean; } interface DataExplorerActionProps { @@ -60,7 +61,7 @@ export const DataExplorer = withStyles(styles)( } render() { const { - columns, onContextMenu, onFiltersChange, onSortToggle, extractKey, + columns, onContextMenu, onFiltersChange, onSortToggle, working, extractKey, rowsPerPage, rowsPerPageOptions, onColumnToggle, searchValue, onSearch, items, itemsAvailable, onRowClick, onRowDoubleClick, classes, dataTableDefaultView @@ -87,6 +88,7 @@ export const DataExplorer = withStyles(styles)( onFiltersChange={onFiltersChange} onSortToggle={onSortToggle} extractKey={extractKey} + working={working} defaultView={dataTableDefaultView} /> diff --git a/src/components/data-table/data-table.tsx b/src/components/data-table/data-table.tsx index 65531a5b..25d81c62 100644 --- a/src/components/data-table/data-table.tsx +++ b/src/components/data-table/data-table.tsx @@ -19,6 +19,7 @@ export interface DataTableDataProps { onSortToggle: (column: DataColumn) => void; onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn) => void; extractKey?: (item: T) => React.Key; + working?: boolean; defaultView?: React.ReactNode; } @@ -63,7 +64,7 @@ export const DataTable = withStyles(styles)( {items.map(this.renderBodyRow)} - {items.length === 0 && this.renderNoItemsPlaceholder()} + {items.length === 0 && this.props.working !== undefined && !this.props.working && this.renderNoItemsPlaceholder()} ; } @@ -71,7 +72,7 @@ export const DataTable = withStyles(styles)( renderNoItemsPlaceholder = () => { return this.props.defaultView ? this.props.defaultView - : ; + : ; } renderHeadCell = (column: DataColumn, index: number) => { diff --git a/src/index.tsx b/src/index.tsx index a76a86ac..0d026f23 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -37,6 +37,8 @@ import { Config } from '~/common/config'; 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); @@ -61,7 +63,15 @@ addMenuActionSet(ContextMenuKind.TRASH, trashActionSet); 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)); @@ -87,8 +97,6 @@ fetchConfig() , document.getElementById('root') as HTMLElement ); - - }); const initListener = (history: History, store: RootStore, services: ServiceRepository, config: Config) => { diff --git a/src/services/api/api-actions.ts b/src/services/api/api-actions.ts new file mode 100644 index 00000000..f986786d --- /dev/null +++ b/src/services/api/api-actions.ts @@ -0,0 +1,11 @@ +// 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; +} diff --git a/src/services/api/url-builder.test.ts b/src/services/api/url-builder.test.ts new file mode 100644 index 00000000..2b489401 --- /dev/null +++ b/src/services/api/url-builder.test.ts @@ -0,0 +1,36 @@ +// 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'); + }); +}); diff --git a/src/services/api/url-builder.ts b/src/services/api/url-builder.ts index 0587c837..32039a50 100644 --- a/src/services/api/url-builder.ts +++ b/src/services/api/url-builder.ts @@ -24,3 +24,24 @@ export class UrlBuilder { 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; +} diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts index 57915f70..50760bb4 100644 --- a/src/services/auth-service/auth-service.ts +++ b/src/services/auth-service/auth-service.ts @@ -4,6 +4,8 @@ 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'; @@ -25,7 +27,8 @@ export class AuthService { constructor( protected apiClient: AxiosInstance, - protected baseUrl: string) { } + protected baseUrl: string, + protected actions: ApiActions) { } public saveApiToken(token: string) { localStorage.setItem(API_TOKEN_KEY, token); @@ -86,15 +89,25 @@ export class AuthService { } public getUserDetails = (): Promise => { + const reqId = uuid(); + this.actions.progressFn(reqId, true); return this.apiClient .get('/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() { diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts index 6e6f2a97..28de14f5 100644 --- a/src/services/collection-service/collection-service.ts +++ b/src/services/collection-service/collection-service.ts @@ -11,12 +11,13 @@ import { mapTreeValues } from "~/models/tree"; 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 { - 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) { diff --git a/src/services/common-service/common-resource-service.test.ts b/src/services/common-service/common-resource-service.test.ts index d67d5dbf..5a3bae25 100644 --- a/src/services/common-service/common-resource-service.test.ts +++ b/src/services/common-service/common-resource-service.test.ts @@ -6,11 +6,18 @@ import { CommonResourceService } from "./common-resource-service"; 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 = >(Service: new (client: AxiosInstance) => C) => { +const actions: ApiActions = { + progressFn: (id: string, working: boolean) => {}, + errorFn: (id: string, message: string) => {} +}; + +export const mockResourceService = >( + 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; }; @@ -28,14 +35,14 @@ describe("CommonResourceService", () => { .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"}); }); @@ -45,7 +52,7 @@ describe("CommonResourceService", () => { .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" }); }); @@ -55,7 +62,7 @@ describe("CommonResourceService", () => { .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" }); }); @@ -73,7 +80,7 @@ describe("CommonResourceService", () => { 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", diff --git a/src/services/common-service/common-resource-service.ts b/src/services/common-service/common-resource-service.ts index 09e034f5..f6810c04 100644 --- a/src/services/common-service/common-resource-service.ts +++ b/src/services/common-service/common-resource-service.ts @@ -5,6 +5,8 @@ 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; @@ -39,7 +41,7 @@ export enum CommonResourceServiceError { export class CommonResourceService { - static mapResponseKeys = (response: { data: any }): Promise => + static mapResponseKeys = (response: { data: any }) => CommonResourceService.mapKeys(_.camelCase)(response.data) static mapKeys = (mapFn: (key: string) => string) => @@ -60,36 +62,55 @@ export class CommonResourceService { } } - static defaultResponse(promise: AxiosPromise): Promise { + static defaultResponse(promise: AxiosPromise, actions: ApiActions): Promise { + const reqId = uuid(); + actions.progressFn(reqId, true); return promise + .then(data => { + actions.progressFn(reqId, false); + return data; + }) .then(CommonResourceService.mapResponseKeys) - .catch(({ response }) => Promise.reject(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 | any) { return CommonResourceService.defaultResponse( this.serverApi - .post(this.resourceType, data && CommonResourceService.mapKeys(_.snakeCase)(data))); + .post(this.resourceType, data && CommonResourceService.mapKeys(_.snakeCase)(data)), + this.actions + ); } delete(uuid: string): Promise { return CommonResourceService.defaultResponse( this.serverApi - .delete(this.resourceType + uuid)); + .delete(this.resourceType + uuid), + this.actions + ); } get(uuid: string) { return CommonResourceService.defaultResponse( this.serverApi - .get(this.resourceType + uuid)); + .get(this.resourceType + uuid), + this.actions + ); } list(args: ListArguments = {}): Promise> { @@ -103,14 +124,17 @@ export class CommonResourceService { this.serverApi .get(this.resourceType, { params: CommonResourceService.mapKeys(_.snakeCase)(params) - })); + }), + this.actions + ); } update(uuid: string, data: Partial) { return CommonResourceService.defaultResponse( this.serverApi - .put(this.resourceType + uuid, data && CommonResourceService.mapKeys(_.snakeCase)(data))); - + .put(this.resourceType + uuid, data && CommonResourceService.mapKeys(_.snakeCase)(data)), + this.actions + ); } } diff --git a/src/services/common-service/trashable-resource-service.ts b/src/services/common-service/trashable-resource-service.ts index 23e7366e..633b2fbd 100644 --- a/src/services/common-service/trashable-resource-service.ts +++ b/src/services/common-service/trashable-resource-service.ts @@ -6,27 +6,32 @@ import * as _ from "lodash"; 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 extends CommonResourceService { - constructor(serverApi: AxiosInstance, resourceType: string) { - super(serverApi, resourceType); + constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions) { + super(serverApi, resourceType, actions); } trash(uuid: string): Promise { - 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 { 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 + ); } } diff --git a/src/services/container-request-service/container-request-service.ts b/src/services/container-request-service/container-request-service.ts index 01805ff9..e035ed53 100644 --- a/src/services/container-request-service/container-request-service.ts +++ b/src/services/container-request-service/container-request-service.ts @@ -4,10 +4,11 @@ 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 { - constructor(serverApi: AxiosInstance) { - super(serverApi, "container_requests"); + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, "container_requests", actions); } } diff --git a/src/services/container-service/container-service.ts b/src/services/container-service/container-service.ts index 0ace1f60..86b3d2dc 100644 --- a/src/services/container-service/container-service.ts +++ b/src/services/container-service/container-service.ts @@ -4,10 +4,11 @@ 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 { - constructor(serverApi: AxiosInstance) { - super(serverApi, "containers"); + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, "containers", actions); } } diff --git a/src/services/favorite-service/favorite-service.ts b/src/services/favorite-service/favorite-service.ts index 46010543..92b0713d 100644 --- a/src/services/favorite-service/favorite-service.ts +++ b/src/services/favorite-service/favorite-service.ts @@ -19,7 +19,7 @@ export interface FavoriteListArguments { export class FavoriteService { constructor( private linkService: LinkService, - private groupsService: GroupsService + private groupsService: GroupsService, ) {} create(data: { userUuid: string; resource: { uuid: string; name: string } }) { diff --git a/src/services/groups-service/groups-service.test.ts b/src/services/groups-service/groups-service.test.ts index e1157f4b..95355440 100644 --- a/src/services/groups-service/groups-service.test.ts +++ b/src/services/groups-service/groups-service.test.ts @@ -5,11 +5,17 @@ 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(); }); @@ -27,7 +33,7 @@ describe("GroupsService", () => { 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", diff --git a/src/services/groups-service/groups-service.ts b/src/services/groups-service/groups-service.ts index 299e6808..e705b6e5 100644 --- a/src/services/groups-service/groups-service.ts +++ b/src/services/groups-service/groups-service.ts @@ -10,7 +10,8 @@ import { ProjectResource } from "~/models/project"; 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; @@ -32,8 +33,8 @@ export type GroupContentsResource = export class GroupsService extends TrashableResourceService { - constructor(serverApi: AxiosInstance) { - super(serverApi, "groups"); + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, "groups", actions); } contents(uuid: string, args: ContentsArguments = {}): Promise> { @@ -43,17 +44,21 @@ export class GroupsService extends Tras 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> { - return this.serverApi - .get(this.resourceType + 'shared', { params }) - .then(CommonResourceService.mapResponseKeys); + return CommonResourceService.defaultResponse( + this.serverApi + .get(this.resourceType + 'shared', { params }), + this.actions + ); } } diff --git a/src/services/keep-service/keep-service.ts b/src/services/keep-service/keep-service.ts index 77d06d93..17ee522e 100644 --- a/src/services/keep-service/keep-service.ts +++ b/src/services/keep-service/keep-service.ts @@ -5,9 +5,10 @@ import { CommonResourceService } from "~/services/common-service/common-resource-service"; import { AxiosInstance } from "axios"; import { KeepResource } from "~/models/keep"; +import { ApiActions } from "~/services/api/api-actions"; export class KeepService extends CommonResourceService { - constructor(serverApi: AxiosInstance) { - super(serverApi, "keep_services"); + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, "keep_services", actions); } } diff --git a/src/services/link-service/link-service.ts b/src/services/link-service/link-service.ts index c77def5f..2701279e 100644 --- a/src/services/link-service/link-service.ts +++ b/src/services/link-service/link-service.ts @@ -5,9 +5,10 @@ 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 { - constructor(serverApi: AxiosInstance) { - super(serverApi, "links"); + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, "links", actions); } } diff --git a/src/services/log-service/log-service.ts b/src/services/log-service/log-service.ts index 8f6c66c8..3a049a60 100644 --- a/src/services/log-service/log-service.ts +++ b/src/services/log-service/log-service.ts @@ -5,9 +5,10 @@ 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 { - constructor(serverApi: AxiosInstance) { - super(serverApi, "logs"); + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, "logs", actions); } } diff --git a/src/services/project-service/project-service.test.ts b/src/services/project-service/project-service.test.ts index 11c2f61f..90523606 100644 --- a/src/services/project-service/project-service.test.ts +++ b/src/services/project-service/project-service.test.ts @@ -5,13 +5,18 @@ 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", @@ -21,7 +26,7 @@ describe("CommonResourceService", () => { 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: { diff --git a/src/services/services.ts b/src/services/services.ts index 53721dd3..9c764b09 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -12,36 +12,37 @@ import { CollectionService } from "./collection-service/collection-service"; 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; -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); @@ -77,4 +78,4 @@ export const getResourceService = (kind?: ResourceKind) => (serviceRepository: S default: return undefined; } -}; \ No newline at end of file +}; diff --git a/src/services/user-service/user-service.ts b/src/services/user-service/user-service.ts index 31cc4bbb..a69203dc 100644 --- a/src/services/user-service/user-service.ts +++ b/src/services/user-service/user-service.ts @@ -5,9 +5,10 @@ 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 { - constructor(serverApi: AxiosInstance) { - super(serverApi, "users"); + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, "users", actions); } } diff --git a/src/store/auth/auth-actions.test.ts b/src/store/auth/auth-actions.test.ts index 4ac48a0b..a1cd7f4f 100644 --- a/src/store/auth/auth-actions.test.ts +++ b/src/store/auth/auth-actions.test.ts @@ -18,15 +18,20 @@ import { createServices } from "~/services/services"; 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', () => { diff --git a/src/store/auth/auth-reducer.test.ts b/src/store/auth/auth-reducer.test.ts index 2b1920a6..1202bacb 100644 --- a/src/store/auth/auth-reducer.test.ts +++ b/src/store/auth/auth-reducer.test.ts @@ -8,13 +8,18 @@ import { AuthAction, authActions } from "./auth-action"; 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', () => { diff --git a/src/store/collections/collection-copy-actions.ts b/src/store/collections/collection-copy-actions.ts index 09d4e04e..058d2dd4 100644 --- a/src/store/collections/collection-copy-actions.ts +++ b/src/store/collections/collection-copy-actions.ts @@ -10,6 +10,7 @@ import { RootState } from '~/store/store'; 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'; @@ -25,11 +26,13 @@ export const copyCollection = (resource: CopyFormDialogData) => 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); @@ -39,6 +42,7 @@ export const copyCollection = (resource: CopyFormDialogData) => 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; } }; diff --git a/src/store/collections/collection-create-actions.ts b/src/store/collections/collection-create-actions.ts index 254d6a8a..7f21887d 100644 --- a/src/store/collections/collection-create-actions.ts +++ b/src/store/collections/collection-create-actions.ts @@ -10,6 +10,7 @@ import { ServiceRepository } from '~/services/services'; 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; @@ -30,16 +31,19 @@ export const createCollection = (data: CollectionCreateFormDialogData) => 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(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; } }; diff --git a/src/store/collections/collection-move-actions.ts b/src/store/collections/collection-move-actions.ts index 420bcd01..9bdc5523 100644 --- a/src/store/collections/collection-move-actions.ts +++ b/src/store/collections/collection-move-actions.ts @@ -8,10 +8,11 @@ import { startSubmit, stopSubmit, initialize } from 'redux-form'; 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'; @@ -26,11 +27,17 @@ export const moveCollection = (resource: MoveToFormDialogData) => 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); @@ -40,6 +47,7 @@ export const moveCollection = (resource: MoveToFormDialogData) => 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; } }; diff --git a/src/store/collections/collection-partial-copy-actions.ts b/src/store/collections/collection-partial-copy-actions.ts index dedf75e1..4dac9c7d 100644 --- a/src/store/collections/collection-partial-copy-actions.ts +++ b/src/store/collections/collection-partial-copy-actions.ts @@ -9,8 +9,9 @@ import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree 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'; @@ -42,6 +43,7 @@ export const copyCollectionPartial = ({ name, description, projectUuid }: Collec 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, @@ -54,7 +56,12 @@ export const copyCollectionPartial = ({ name, description, projectUuid }: Collec 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) { @@ -66,6 +73,7 @@ export const copyCollectionPartial = ({ name, description, projectUuid }: Collec 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)); } } }; diff --git a/src/store/collections/collection-update-actions.ts b/src/store/collections/collection-update-actions.ts index bf05d4dd..9c859234 100644 --- a/src/store/collections/collection-update-actions.ts +++ b/src/store/collections/collection-update-actions.ts @@ -11,6 +11,7 @@ import { getCommonResourceServiceError, CommonResourceServiceError } from "~/ser 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; @@ -31,15 +32,18 @@ export const updateCollection = (collection: Partial) => 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; } }; diff --git a/src/store/collections/collection-upload-actions.ts b/src/store/collections/collection-upload-actions.ts index 4a5aff35..ef241a7c 100644 --- a/src/store/collections/collection-upload-actions.ts +++ b/src/store/collections/collection-upload-actions.ts @@ -7,9 +7,10 @@ import { RootState } from '~/store/store'; 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) => { @@ -31,11 +32,21 @@ export const submitCollectionFiles = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const currentCollection = getState().collectionPanel.item; if (currentCollection) { - dispatch(startSubmit(COLLECTION_UPLOAD_FILES_DIALOG)); - await dispatch(uploadCollectionFiles(currentCollection.uuid)); - dispatch(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(uploadCollectionFiles(currentCollection.uuid)); + dispatch(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)); + } } }; @@ -43,4 +54,4 @@ export const closeUploadCollectionFilesDialog = () => dialogActions.CLOSE_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 +}; diff --git a/src/store/data-explorer/data-explorer-reducer.ts b/src/store/data-explorer/data-explorer-reducer.ts index cc800244..d059d37a 100644 --- a/src/store/data-explorer/data-explorer-reducer.ts +++ b/src/store/data-explorer/data-explorer-reducer.ts @@ -15,6 +15,7 @@ export interface DataExplorer { rowsPerPage: number; rowsPerPageOptions: number[]; searchValue: string; + working?: boolean; } export const initialDataExplorer: DataExplorer = { diff --git a/src/store/favorite-panel/favorite-panel-middleware-service.ts b/src/store/favorite-panel/favorite-panel-middleware-service.ts index c385309f..acdc12b4 100644 --- a/src/store/favorite-panel/favorite-panel-middleware-service.ts +++ b/src/store/favorite-panel/favorite-panel-middleware-service.ts @@ -16,7 +16,8 @@ import { OrderBuilder, OrderDirection } from "~/services/api/order-builder"; 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"; @@ -30,7 +31,6 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic if (!dataExplorer) { api.dispatch(favoritesPanelDataExplorerIsNotSet()); } else { - const columns = dataExplorer.columns as DataColumns; const sortColumn = dataExplorer.columns.find(c => c.sortDirection !== SortDirection.NONE); const typeFilters = this.getColumnFilters(columns, FavoritePanelColumnNames.TYPE); @@ -50,6 +50,7 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic .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, @@ -61,6 +62,7 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic .addILike("name", dataExplorer.searchValue) .getFilters() }); + api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); api.dispatch(resourcesActions.SET_RESOURCES(response.items)); await api.dispatch(loadMissingProcessesInformation(response.items)); api.dispatch(favoritePanelActions.SET_ITEMS({ @@ -71,12 +73,14 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic })); api.dispatch(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()); } } } @@ -86,3 +90,9 @@ const favoritesPanelDataExplorerIsNotSet = () => snackbarActions.OPEN_SNACKBAR({ message: 'Favorites panel is not ready.' }); + +const couldNotFetchFavoritesContents = () => + snackbarActions.OPEN_SNACKBAR({ + message: 'Could not fetch favorites contents.', + kind: SnackbarKind.ERROR + }); diff --git a/src/store/favorites/favorites-actions.ts b/src/store/favorites/favorites-actions.ts index e5a8e591..5a3001fb 100644 --- a/src/store/favorites/favorites-actions.ts +++ b/src/store/favorites/favorites-actions.ts @@ -6,8 +6,9 @@ import { unionize, ofType, UnionOf } from "~/common/unionize"; 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 }>(), @@ -19,10 +20,16 @@ export type FavoritesAction = UnionOf; export const toggleFavorite = (resource: { uuid: string; name: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { + 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 }); @@ -35,8 +42,14 @@ export const toggleFavorite = (resource: { uuid: string; name: string }) => 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; }); }; diff --git a/src/store/processes/process.ts b/src/store/processes/process.ts index c9e62f94..ab8093b8 100644 --- a/src/store/processes/process.ts +++ b/src/store/processes/process.ts @@ -61,7 +61,7 @@ export const getProcessRuntime = ({ container }: Process) => ? 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; @@ -71,7 +71,7 @@ export const getProcessStatusColor = (status: string, { customs }: ArvadosTheme) case ProcessStatus.FAILED: return customs.colors.red900; default: - return customs.colors.grey500; + return palette.grey["500"]; } }; diff --git a/src/store/progress-indicator/progress-indicator-actions.ts b/src/store/progress-indicator/progress-indicator-actions.ts new file mode 100644 index 00000000..34a43d89 --- /dev/null +++ b/src/store/progress-indicator/progress-indicator-actions.ts @@ -0,0 +1,14 @@ +// 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(), + STOP_WORKING: ofType(), + PERSIST_STOP_WORKING: ofType(), + TOGGLE_WORKING: ofType<{ id: string, working: boolean }>() +}); + +export type ProgressIndicatorAction = UnionOf; diff --git a/src/store/progress-indicator/progress-indicator-reducer.ts b/src/store/progress-indicator/progress-indicator-reducer.ts new file mode 100644 index 00000000..849906b5 --- /dev/null +++ b/src/store/progress-indicator/progress-indicator-reducer.ts @@ -0,0 +1,29 @@ +// 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); +} diff --git a/src/store/progress-indicator/with-progress.ts b/src/store/progress-indicator/with-progress.ts new file mode 100644 index 00000000..24f7e327 --- /dev/null +++ b/src/store/progress-indicator/with-progress.ts @@ -0,0 +1,20 @@ +// 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) => + 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 }; +}; diff --git a/src/store/project-panel/project-panel-middleware-service.ts b/src/store/project-panel/project-panel-middleware-service.ts index 519943c1..09e76ae2 100644 --- a/src/store/project-panel/project-panel-middleware-service.ts +++ b/src/store/project-panel/project-panel-middleware-service.ts @@ -17,7 +17,8 @@ import { Dispatch, MiddlewareAPI } from "redux"; 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'; @@ -38,12 +39,21 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService 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(updateFavorites(response.items.map(item => item.uuid))); api.dispatch(updateResources(response.items)); await api.dispatch(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()); } } @@ -116,7 +126,8 @@ const projectPanelCurrentUuidIsNotSet = () => const couldNotFetchProjectContents = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Could not fetch project contents.' + message: 'Could not fetch project contents.', + kind: SnackbarKind.ERROR }); const projectPanelDataExplorerIsNotSet = () => diff --git a/src/store/side-panel-tree/side-panel-tree-actions.ts b/src/store/side-panel-tree/side-panel-tree-actions.ts index 073de22c..23c5ea22 100644 --- a/src/store/side-panel-tree/side-panel-tree-actions.ts +++ b/src/store/side-panel-tree/side-panel-tree-actions.ts @@ -13,6 +13,7 @@ import { getTreePicker, TreePicker } from '../tree-picker/tree-picker'; 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', diff --git a/src/store/snackbar/snackbar-actions.ts b/src/store/snackbar/snackbar-actions.ts index 55d9f3a8..d6d7128e 100644 --- a/src/store/snackbar/snackbar-actions.ts +++ b/src/store/snackbar/snackbar-actions.ts @@ -4,9 +4,23 @@ 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; diff --git a/src/store/snackbar/snackbar-reducer.ts b/src/store/snackbar/snackbar-reducer.ts index fc2f4a19..73c566fc 100644 --- a/src/store/snackbar/snackbar-reducer.ts +++ b/src/store/snackbar/snackbar-reducer.ts @@ -2,26 +2,43 @@ // // 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, }); }; diff --git a/src/store/store.ts b/src/store/store.ts index 43ab2310..012b7474 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -34,6 +34,7 @@ import { processLogsPanelReducer } from './process-logs-panel/process-logs-panel 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' && @@ -89,5 +90,6 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({ snackbar: snackbarReducer, treePicker: treePickerReducer, fileUploader: fileUploaderReducer, - processPanel: processPanelReducer + processPanel: processPanelReducer, + progressIndicator: progressIndicatorReducer }); diff --git a/src/store/trash-panel/trash-panel-middleware-service.ts b/src/store/trash-panel/trash-panel-middleware-service.ts index 6e8fa542..90838b20 100644 --- a/src/store/trash-panel/trash-panel-middleware-service.ts +++ b/src/store/trash-panel/trash-panel-middleware-service.ts @@ -19,8 +19,9 @@ import { TrashPanelColumnNames, TrashPanelFilter } from "~/views/trash-panel/tra 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) { @@ -47,6 +48,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { } try { + api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const userUuid = this.services.authService.getUuid()!; const listResults = await this.services.groupsService .contents(userUuid, { @@ -61,6 +63,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { recursive: true, includeTrash: true }); + api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); const items = listResults.items.map(it => it.uuid); @@ -71,6 +74,13 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { api.dispatch(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()); } } @@ -78,5 +88,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { const couldNotFetchTrashContents = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Could not fetch trash contents.' + message: 'Could not fetch trash contents.', + kind: SnackbarKind.ERROR }); + diff --git a/src/store/trash/trash-actions.ts b/src/store/trash/trash-actions.ts index cd6df556..5cf952eb 100644 --- a/src/store/trash/trash-actions.ts +++ b/src/store/trash/trash-actions.ts @@ -5,53 +5,69 @@ 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 => { - if (isTrashed) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Restoring from trash..." })); - await services.groupsService.untrash(uuid); - dispatch(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(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(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(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 => { - 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 })); } }; diff --git a/src/views-components/data-explorer/data-explorer.tsx b/src/views-components/data-explorer/data-explorer.tsx index 16dd5993..74c3e64a 100644 --- a/src/views-components/data-explorer/data-explorer.tsx +++ b/src/views-components/data-explorer/data-explorer.tsx @@ -21,7 +21,9 @@ interface Props { } 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 = () => { diff --git a/src/views-components/main-app-bar/main-app-bar.tsx b/src/views-components/main-app-bar/main-app-bar.tsx index ec2a511a..93cf4968 100644 --- a/src/views-components/main-app-bar/main-app-bar.tsx +++ b/src/views-components/main-app-bar/main-app-bar.tsx @@ -13,6 +13,7 @@ import { NotificationsMenu } from "~/views-components/main-app-bar/notifications 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'; @@ -31,6 +32,7 @@ interface MainAppBarDataProps { searchDebounce?: number; user?: User; buildInfo?: string; + children?: ReactNode; } export interface MainAppBarActionProps { @@ -41,7 +43,7 @@ export type MainAppBarProps = MainAppBarDataProps & MainAppBarActionProps & With export const MainAppBar = withStyles(styles)( (props: MainAppBarProps) => { - return + return @@ -80,6 +82,7 @@ export const MainAppBar = withStyles(styles)( + {props.children} ; } ); diff --git a/src/views-components/progress/content-progress.tsx b/src/views-components/progress/content-progress.tsx new file mode 100644 index 00000000..fa2cad58 --- /dev/null +++ b/src/views-components/progress/content-progress.tsx @@ -0,0 +1,13 @@ +// // 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 ? : null +// ); diff --git a/src/views-components/progress/side-panel-progress.tsx b/src/views-components/progress/side-panel-progress.tsx new file mode 100644 index 00000000..2d832a57 --- /dev/null +++ b/src/views-components/progress/side-panel-progress.tsx @@ -0,0 +1,13 @@ +// // 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 ? : null +// ); diff --git a/src/views-components/progress/workbench-progress.tsx b/src/views-components/progress/workbench-progress.tsx new file mode 100644 index 00000000..1fdd57c6 --- /dev/null +++ b/src/views-components/progress/workbench-progress.tsx @@ -0,0 +1,14 @@ +// 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 ? : null +// ); diff --git a/src/views-components/side-panel-tree/side-panel-tree.tsx b/src/views-components/side-panel-tree/side-panel-tree.tsx index d0b00d6f..4d4760fa 100644 --- a/src/views-components/side-panel-tree/side-panel-tree.tsx +++ b/src/views-components/side-panel-tree/side-panel-tree.tsx @@ -16,6 +16,7 @@ import { openSidePanelContextMenu } from '~/store/context-menu/context-menu-acti export interface SidePanelTreeProps { onItemActivation: (id: string) => void; + sidePanelProgress?: boolean; } type SidePanelTreeActionProps = Pick; diff --git a/src/views-components/side-panel/side-panel.tsx b/src/views-components/side-panel/side-panel.tsx index fffe3344..739e9eac 100644 --- a/src/views-components/side-panel/side-panel.tsx +++ b/src/views-components/side-panel/side-panel.tsx @@ -11,6 +11,7 @@ import { connect } from 'react-redux'; 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; @@ -32,11 +33,14 @@ const mapDispatchToProps = (dispatch: Dispatch): SidePanelTreeProps => ({ } }); +const mapStateToProps = (state: RootState) => ({ +}); + export const SidePanel = compose( withStyles(styles), - connect(undefined, mapDispatchToProps) + connect(mapStateToProps, mapDispatchToProps) )(({ classes, ...props }: WithStyles & SidePanelTreeProps) => - ); \ No newline at end of file + ); diff --git a/src/views-components/snackbar/snackbar.tsx b/src/views-components/snackbar/snackbar.tsx index 535777e1..7449e1e2 100644 --- a/src/views-components/snackbar/snackbar.tsx +++ b/src/views-components/snackbar/snackbar.tsx @@ -7,21 +7,129 @@ import { connect } from "react-redux"; 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: {state.snackbar.message}, - 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: {messages.length > 0 ? messages[0].message : ""}, + autoHideDuration: messages.length > 0 ? messages[0].hideDuration : 0, + kind: messages.length > 0 ? messages[0].kind : SnackbarKind.INFO + }; +}; -const mapDispatchToProps = (dispatch: Dispatch): Pick => ({ +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) => + +; + +type CssRules = "success" | "error" | "info" | "warning" | "icon" | "iconVariant" | "message"; + +const styles: StyleRulesCallback = (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) => { + 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 ( + + + {message} + + } + action={ + { + if (onClose) { + onClose(e, ''); + } + }}> + + + } + /> + ); +}; + +export const Snackbar = connect(mapStateToProps, mapDispatchToProps)( + withStyles(styles)(ArvadosSnackbar) +); diff --git a/src/views/favorite-panel/favorite-panel.tsx b/src/views/favorite-panel/favorite-panel.tsx index 73849562..4ba967c0 100644 --- a/src/views/favorite-panel/favorite-panel.tsx +++ b/src/views/favorite-panel/favorite-panel.tsx @@ -164,23 +164,18 @@ export const FavoritePanel = withStyles(styles)( connect(mapStateToProps, mapDispatchToProps)( class extends React.Component { render() { - return this.hasAnyFavorites() - ? } /> - : ; - } - - hasAnyFavorites = () => { - return Object - .keys(this.props.favorites) - .find(uuid => this.props.favorites[uuid]); + return + } />; } } ) diff --git a/src/views/process-panel/subprocesses-card.tsx b/src/views/process-panel/subprocesses-card.tsx index 0607c471..9cff1e98 100644 --- a/src/views/process-panel/subprocesses-card.tsx +++ b/src/views/process-panel/subprocesses-card.tsx @@ -16,7 +16,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ height: '100%' }, title: { - color: theme.customs.colors.grey700 + color: theme.palette.grey["700"] }, gridFilter: { height: '20px', diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx index 25579396..2b2be2e8 100644 --- a/src/views/project-panel/project-panel.tsx +++ b/src/views/project-panel/project-panel.tsx @@ -23,9 +23,23 @@ import { ProjectResource } from '~/models/project'; 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 = (theme: ArvadosTheme) => ({ + root: { + position: 'relative', + width: '100%', + height: '100%' + }, + button: { + marginLeft: theme.spacing.unit + }, +}); export enum ProjectPanelColumnNames { NAME = "Name", @@ -110,30 +124,33 @@ interface ProjectPanelDataProps { resources: ResourcesState; } -type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & RouteComponentProps<{ id: string }>; +type ProjectPanelProps = ProjectPanelDataProps & DispatchProp + & WithStyles & 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 { render() { - return this.hasAnyItems() - ? + } /> - : ; - } - - hasAnyItems = () => { - const resources = filterResources(this.isCurrentItemChild)(this.props.resources); - return resources.length > 0; + dataTableDefaultView={ + + }/> + ; } isCurrentItemChild = (resource: Resource) => { @@ -164,4 +181,5 @@ export const ProjectPanel = connect((state: RootState) => ({ } } - ); + ) +); diff --git a/src/views/trash-panel/trash-panel.tsx b/src/views/trash-panel/trash-panel.tsx index e627f4f2..92febd8a 100644 --- a/src/views/trash-panel/trash-panel.tsx +++ b/src/views/trash-panel/trash-panel.tsx @@ -155,23 +155,17 @@ export const TrashPanel = withStyles(styles)( }))( class extends React.Component { render() { - return this.hasAnyTrashedResources() - ? } /> - : ; - } - - hasAnyTrashedResources = () => { - // TODO: implement check if there is anything in the trash, - // without taking pagination into the account - return true; + return + } />; } handleContextMenu = (event: React.MouseEvent, resourceUuid: string) => { diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 1d7d47d0..ad1a2668 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -41,10 +41,11 @@ import { FilesUploadCollectionDialog } from '~/views-components/dialog-forms/fil 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'; @@ -52,7 +53,8 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ root: { overflow: 'hidden', width: '100vw', - height: '100vh' + height: '100vh', + paddingTop: theme.spacing.unit * 8 }, container: { position: 'relative' @@ -83,6 +85,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ interface WorkbenchDataProps { user?: User; currentToken?: string; + working: boolean; } interface WorkbenchGeneralProps { @@ -101,6 +104,7 @@ export const Workbench = withStyles(styles)( (state: RootState) => ({ user: state.auth.user, currentToken: state.auth.apiToken, + working: isSystemWorking(state.progressIndicator) }) )( class extends React.Component { @@ -110,14 +114,14 @@ export const Workbench = withStyles(styles)( render() { const { classes } = this.props; return <> + + {this.props.working ? : null} + - - - {this.props.user && diff --git a/tslint.json b/tslint.json index 85b43690..f9b81ca9 100644 --- a/tslint.json +++ b/tslint.json @@ -14,7 +14,8 @@ "no-shadowed-variable": false, "semicolon": true, "array-type": false, - "interface-over-type-literal": false + "interface-over-type-literal": false, + "no-empty": false }, "linterOptions": { "exclude": [ diff --git a/yarn.lock b/yarn.lock index 3765778d..30e94bde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -188,6 +188,12 @@ "@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" @@ -7758,14 +7764,14 @@ utils-merge@1.0.1: 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"