Merge branch 'master'
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 30 Oct 2018 13:56:35 +0000 (14:56 +0100)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 30 Oct 2018 13:56:35 +0000 (14:56 +0100)
Feature #14365

Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski@contractors.roche.com>

32 files changed:
src/components/autocomplete/autocomplete.tsx [new file with mode: 0644]
src/components/dialog-actions/dialog-actions.tsx [new file with mode: 0644]
src/components/select-field/select-field.tsx
src/components/text-field/text-field.tsx
src/models/link.ts
src/models/permission.ts [new file with mode: 0644]
src/services/link-service/link-service.ts
src/services/permission-service/permission-service.ts [new file with mode: 0644]
src/services/services.ts
src/store/sharing-dialog/sharing-dialog-actions.ts [new file with mode: 0644]
src/store/sharing-dialog/sharing-dialog-types.ts [new file with mode: 0644]
src/store/workflow-panel/workflow-panel-actions.ts
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/context-menu/action-sets/collection-resource-action-set.ts
src/views-components/context-menu/action-sets/process-action-set.ts
src/views-components/context-menu/action-sets/process-resource-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/sharing-dialog/advanced-view-switch.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/people-select.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/permission-select.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/select-item.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-dialog-component.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-dialog-content.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-dialog.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-invitation-form-component.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-invitation-form.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-management-form-component.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-management-form.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-public-access-form-component.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/sharing-public-access-form.tsx [new file with mode: 0644]
src/views-components/sharing-dialog/visibility-level-select.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

diff --git a/src/components/autocomplete/autocomplete.tsx b/src/components/autocomplete/autocomplete.tsx
new file mode 100644 (file)
index 0000000..85704c3
--- /dev/null
@@ -0,0 +1,194 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List } from '@material-ui/core';
+import { PopperProps } from '@material-ui/core/Popper';
+import { WithStyles } from '@material-ui/core/styles';
+import { noop } from 'lodash';
+
+export interface AutocompleteProps<Item, Suggestion> {
+    label?: string;
+    value: string;
+    items: Item[];
+    suggestions?: Suggestion[];
+    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+    onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onCreate?: () => void;
+    onDelete?: (item: Item, index: number) => void;
+    onSelect?: (suggestion: Suggestion) => void;
+    renderChipValue?: (item: Item) => string;
+    renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
+}
+
+export interface AutocompleteState {
+    suggestionsOpen: boolean;
+}
+export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
+
+    state = {
+        suggestionsOpen: false,
+    };
+
+    containerRef = React.createRef<HTMLDivElement>();
+    inputRef = React.createRef<HTMLInputElement>();
+
+    render() {
+        return (
+            <RootRef rootRef={this.containerRef}>
+                <FormControl fullWidth>
+                    {this.renderLabel()}
+                    {this.renderInput()}
+                    {this.renderSuggestions()}
+                </FormControl>
+            </RootRef>
+        );
+    }
+
+    renderLabel() {
+        const { label } = this.props;
+        return label && <InputLabel>{label}</InputLabel>;
+    }
+
+    renderInput() {
+        return <Input
+            inputRef={this.inputRef}
+            value={this.props.value}
+            startAdornment={this.renderChips()}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+            onChange={this.props.onChange}
+            onKeyPress={this.handleKeyPress}
+        />;
+    }
+
+    renderSuggestions() {
+        const { suggestions = [] } = this.props;
+        return (
+            <Popper
+                open={this.state.suggestionsOpen && suggestions.length > 0}
+                anchorEl={this.containerRef.current}>
+                <Paper onMouseDown={this.preventBlur}>
+                    <List dense style={{ width: this.getSuggestionsWidth() }}>
+                        {suggestions.map(
+                            (suggestion, index) =>
+                                <ListItem button key={index} onClick={this.handleSelect(suggestion)}>
+                                    {this.renderSuggestion(suggestion)}
+                                </ListItem>
+                        )}
+                    </List>
+                </Paper>
+            </Popper>
+        );
+    }
+
+    handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
+        const { onFocus = noop } = this.props;
+        this.setState({ suggestionsOpen: true });
+        onFocus(event);
+    }
+
+    handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
+        setTimeout(() => {
+            const { onBlur = noop } = this.props;
+            this.setState({ suggestionsOpen: false });
+            onBlur(event);
+        });
+    }
+
+    handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
+        const { onCreate = noop } = this.props;
+        if (key === 'Enter' && this.props.value.length > 0) {
+            onCreate();
+        }
+    }
+
+    renderChips() {
+        const { items, onDelete } = this.props;
+        return items.map(
+            (item, index) =>
+                <Chip
+                    label={this.renderChipValue(item)}
+                    key={index}
+                    onDelete={() => onDelete ? onDelete(item, index) : undefined} />
+        );
+    }
+
+    renderChipValue(value: Value) {
+        const { renderChipValue } = this.props;
+        return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
+    }
+
+    preventBlur = (event: React.MouseEvent<HTMLElement>) => {
+        event.preventDefault();
+    }
+
+    handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
+        if (event.target !== this.inputRef.current) {
+            this.setState({ suggestionsOpen: false });
+        }
+    }
+
+    handleSelect(suggestion: Suggestion) {
+        return () => {
+            const { onSelect = noop } = this.props;
+            const { current } = this.inputRef;
+            if (current) {
+                current.focus();
+            }
+            onSelect(suggestion);
+        };
+    }
+
+    renderSuggestion(suggestion: Suggestion) {
+        const { renderSuggestion } = this.props;
+        return renderSuggestion
+            ? renderSuggestion(suggestion)
+            : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
+    }
+
+    getSuggestionsWidth() {
+        return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
+    }
+}
+
+type ChipClasses = 'root';
+
+const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
+    root: {
+        marginRight: theme.spacing.unit / 4,
+        height: theme.spacing.unit * 3,
+    }
+});
+
+const Chip = withStyles(chipStyles)(MuiChip);
+
+type PopperClasses = 'root';
+
+const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
+    root: {
+        zIndex: theme.zIndex.modal,
+    }
+});
+
+const Popper = withStyles(popperStyles)(
+    ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
+        <MuiPopper {...props} className={classes.root} />
+);
+
+type InputClasses = 'root';
+
+const inputStyles: StyleRulesCallback<InputClasses> = () => ({
+    root: {
+        display: 'flex',
+        flexWrap: 'wrap',
+    },
+    input: {
+        minWidth: '20%',
+        flex: 1,
+    },
+});
+
+const Input = withStyles(inputStyles)(MuiInput);
diff --git a/src/components/dialog-actions/dialog-actions.tsx b/src/components/dialog-actions/dialog-actions.tsx
new file mode 100644 (file)
index 0000000..6987a10
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DialogActions as MuiDialogActions } from '@material-ui/core/';
+import { StyleRulesCallback, withStyles } from '@material-ui/core';
+
+const styles: StyleRulesCallback<'root'> = theme => {
+    const margin = theme.spacing.unit * 3;
+    return {
+        root: {
+            marginRight: margin,
+            marginBottom: margin,
+            marginLeft: margin,
+        },
+    };
+};
+export const DialogActions = withStyles(styles)(MuiDialogActions);
index 1c3dec3510dc7fc12b36c79460a1deafbe417375..4a25ea7019f86bc7a9391f6c269e8d10a9c2e865 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { WrappedFieldProps } from 'redux-form';
 import { ArvadosTheme } from '~/common/custom-theme';
-import { StyleRulesCallback, WithStyles, withStyles, FormControl, InputLabel, Select, MenuItem } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles, FormControl, InputLabel, Select, MenuItem, FormHelperText } from '@material-ui/core';
 
 type CssRules = 'formControl' | 'selectWrapper' | 'select' | 'option';
 
@@ -55,4 +55,30 @@ export const NativeSelectField = withStyles(styles)
                 ))}
             </Select>
         </FormControl>
-    );
\ No newline at end of file
+    );
+
+interface SelectFieldProps {
+    children: React.ReactNode;
+    label: string;
+}
+
+type SelectFieldCssRules = 'formControl';
+
+const selectFieldStyles: StyleRulesCallback<SelectFieldCssRules> = (theme: ArvadosTheme) => ({
+    formControl: {
+        marginBottom: theme.spacing.unit * 3
+    },
+});
+export const SelectField = withStyles(selectFieldStyles)(
+    (props: WrappedFieldProps & SelectFieldProps &  WithStyles<SelectFieldCssRules>) =>
+        <FormControl error={props.meta.invalid} className={props.classes.formControl}>
+            <InputLabel>
+                {props.label}
+            </InputLabel>
+            <Select
+                {...props.input}>
+                {props.children}
+            </Select>
+            <FormHelperText>{props.meta.error}</FormHelperText>
+        </FormControl>
+);
\ No newline at end of file
index dd864a938be2be39ab83a741cab820f907d49bc1..d57c4a8c41c4a7a11f0c9152c5f1172c9ed0b022 100644 (file)
@@ -18,7 +18,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 type TextFieldProps = WrappedFieldProps & WithStyles<CssRules>;
 
-export const TextField = withStyles(styles)((props: TextFieldProps & { label?: string, autoFocus?: boolean, required?: boolean }) =>
+export const TextField = withStyles(styles)((props: TextFieldProps & { 
+    label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, children: React.ReactNode
+}) =>
     <MaterialTextField
         helperText={props.meta.touched && props.meta.error}
         className={props.classes.textField}
@@ -29,6 +31,8 @@ export const TextField = withStyles(styles)((props: TextFieldProps & { label?: s
         autoFocus={props.autoFocus}
         fullWidth={true}
         required={props.required}
+        select={props.select}
+        children={props.children}
         {...props.input}
     />);
 
@@ -51,7 +55,7 @@ export const RichEditorTextField = withStyles(styles)(
         }
 
         render() {
-            return <RichTextEditor 
+            return <RichTextEditor
                 value={this.state.value}
                 onChange={this.onChange}
                 placeholder={this.props.label} />;
@@ -60,7 +64,7 @@ export const RichEditorTextField = withStyles(styles)(
 );
 
 export const DateTextField = withStyles(styles)
-    ((props: TextFieldProps) => 
+    ((props: TextFieldProps) =>
         <MaterialTextField
             type="date"
             disabled={props.meta.submitting}
@@ -73,5 +77,5 @@ export const DateTextField = withStyles(styles)
             name={props.input.name}
             onChange={props.input.onChange}
             value={props.input.value}
-        />    
+        />
     );
\ No newline at end of file
index da9dfd030b39217f7649d6d896f9cfd3ad639a5e..9d1711d8316c3e327d1f8e2edf1b59cd267a852c 100644 (file)
@@ -14,5 +14,6 @@ export interface LinkResource extends Resource {
 
 export enum LinkClass {
     STAR = 'star',
-    TAG = 'tag'
+    TAG = 'tag',
+    PERMISSION = 'permission',
 }
\ No newline at end of file
diff --git a/src/models/permission.ts b/src/models/permission.ts
new file mode 100644 (file)
index 0000000..f340c50
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkResource, LinkClass } from './link';
+
+export interface PermissionResource extends LinkResource {
+    linkClass: LinkClass.PERMISSION;
+}
+
+export enum PermissionLevel {
+    NONE = 'none',
+    CAN_READ = 'can_read',
+    CAN_WRITE = 'can_write',
+    CAN_MANAGE = 'can_manage',
+}
index 2701279e7c5cee5dfabc6f8bf2d52babea51fc06..d1b806751e74206545360b624995ad4e9df82d7c 100644 (file)
@@ -7,7 +7,7 @@ import { LinkResource } from "~/models/link";
 import { AxiosInstance } from "axios";
 import { ApiActions } from "~/services/api/api-actions";
 
-export class LinkService extends CommonResourceService<LinkResource> {
+export class LinkService<Resource extends LinkResource = LinkResource> extends CommonResourceService<Resource> {
     constructor(serverApi: AxiosInstance, actions: ApiActions) {
         super(serverApi, "links", actions);
     }
diff --git a/src/services/permission-service/permission-service.ts b/src/services/permission-service/permission-service.ts
new file mode 100644 (file)
index 0000000..4bb0fe0
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "~/services/link-service/link-service";
+import { PermissionResource } from "~/models/permission";
+import { ListArguments, ListResults, CommonResourceService } from '~/services/common-service/common-resource-service';
+import { LinkClass } from '../../models/link';
+
+export class PermissionService extends LinkService<PermissionResource> {
+
+    permissionListService = new CommonResourceService(this.serverApi, 'permissions', this.actions);
+    create(data?: Partial<PermissionResource>) {
+        return super.create({ ...data, linkClass: LinkClass.PERMISSION });
+    }
+
+    listResourcePermissions(uuid: string, args: ListArguments = {}): Promise<ListResults<PermissionResource>> {
+        const service = new CommonResourceService<PermissionResource>(this.serverApi, `permissions/${uuid}`, this.actions);
+        return service.list(args);
+    }
+
+}
index 806fcae1f11b07276691967fd0560b54dd0f9269..5adf10b387891b0fecd1efddcfbdade21badb4e5 100644 (file)
@@ -23,6 +23,7 @@ import { LogService } from './log-service/log-service';
 import { ApiActions } from "~/services/api/api-actions";
 import { WorkflowService } from "~/services/workflow-service/workflow-service";
 import { SearchService } from '~/services/search-service/search-service';
+import { PermissionService } from "~/services/permission-service/permission-service";
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -39,6 +40,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const keepService = new KeepService(apiClient, actions);
     const linkService = new LinkService(apiClient, actions);
     const logService = new LogService(apiClient, actions);
+    const permissionService = new PermissionService(apiClient, actions);
     const projectService = new ProjectService(apiClient, actions);
     const userService = new UserService(apiClient, actions);
     const workflowService = new WorkflowService(apiClient, actions);
@@ -64,6 +66,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
         keepService,
         linkService,
         logService,
+        permissionService,
         projectService,
         searchService,
         tagService,
diff --git a/src/store/sharing-dialog/sharing-dialog-actions.ts b/src/store/sharing-dialog/sharing-dialog-actions.ts
new file mode 100644 (file)
index 0000000..205d308
--- /dev/null
@@ -0,0 +1,207 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { withDialog } from "~/store/dialog/with-dialog";
+import { SHARING_DIALOG_NAME, SharingPublicAccessFormData, SHARING_PUBLIC_ACCESS_FORM_NAME, SHARING_INVITATION_FORM_NAME, SharingManagementFormData, SharingInvitationFormData, VisibilityLevel, getSharingMangementFormData, getSharingPublicAccessFormData } from './sharing-dialog-types';
+import { Dispatch } from 'redux';
+import { ServiceRepository } from "~/services/services";
+import { FilterBuilder } from '~/services/api/filter-builder';
+import { initialize, getFormValues, reset } from 'redux-form';
+import { SHARING_MANAGEMENT_FORM_NAME } from '~/store/sharing-dialog/sharing-dialog-types';
+import { RootState } from '~/store/store';
+import { getDialog } from '~/store/dialog/dialog-reducer';
+import { PermissionLevel } from '~/models/permission';
+import { getPublicGroupUuid } from "~/store/workflow-panel/workflow-panel-actions";
+import { PermissionResource } from '~/models/permission';
+import { differenceWith } from "lodash";
+import { withProgress } from "~/store/progress-indicator/with-progress";
+import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions.ts';
+import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
+
+export const openSharingDialog = (resourceUuid: string) =>
+    (dispatch: Dispatch) => {
+        dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: resourceUuid }));
+        dispatch<any>(loadSharingDialog);
+    };
+
+export const closeSharingDialog = () =>
+    dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME });
+
+export const connectSharingDialog = withDialog(SHARING_DIALOG_NAME);
+export const connectSharingDialogProgress = withProgress(SHARING_DIALOG_NAME);
+
+
+export const saveSharingDialogChanges = async (dispatch: Dispatch) => {
+    dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+    await dispatch<any>(savePublicPermissionChanges);
+    await dispatch<any>(saveManagementChanges);
+    await dispatch<any>(sendInvitations);
+    dispatch(reset(SHARING_INVITATION_FORM_NAME));
+    await dispatch<any>(loadSharingDialog);
+};
+
+export const sendSharingInvitations = async (dispatch: Dispatch) => {
+    dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+    await dispatch<any>(sendInvitations);
+    dispatch(closeSharingDialog());
+    dispatch(snackbarActions.OPEN_SNACKBAR({
+        message: 'New permissions created',
+        kind: SnackbarKind.SUCCESS,
+    }));
+};
+
+const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+
+    const dialog = getDialog<string>(getState().dialog, SHARING_DIALOG_NAME);
+
+    if (dialog) {
+        dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+        const { items } = await permissionService.listResourcePermissions(dialog.data);
+        dispatch<any>(initializePublicAccessForm(items));
+        await dispatch<any>(initializeManagementForm(items));
+        dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+    }
+};
+
+const initializeManagementForm = (permissionLinks: PermissionResource[]) =>
+    async (dispatch: Dispatch, getState: () => RootState, { userService }: ServiceRepository) => {
+
+        const filters = new FilterBuilder()
+            .addIn('uuid', permissionLinks.map(({ tailUuid }) => tailUuid))
+            .getFilters();
+
+        const { items: users } = await userService.list({ filters });
+
+        const getEmail = (tailUuid: string) => {
+            const user = users.find(({ uuid }) => uuid === tailUuid);
+            return user
+                ? user.email
+                : tailUuid;
+        };
+
+        const managementPermissions = permissionLinks
+            .filter(item =>
+                item.tailUuid !== getPublicGroupUuid(getState()))
+            .map(({ tailUuid, name, uuid }) => ({
+                email: getEmail(tailUuid),
+                permissions: name as PermissionLevel,
+                permissionUuid: uuid,
+            }));
+
+        const managementFormData: SharingManagementFormData = {
+            permissions: managementPermissions,
+            initialPermissions: managementPermissions,
+        };
+
+        dispatch(initialize(SHARING_MANAGEMENT_FORM_NAME, managementFormData));
+    };
+
+const initializePublicAccessForm = (permissionLinks: PermissionResource[]) =>
+    (dispatch: Dispatch, getState: () => RootState, ) => {
+
+        const [publicPermission] = permissionLinks
+            .filter(item => item.tailUuid === getPublicGroupUuid(getState()));
+
+        const publicAccessFormData: SharingPublicAccessFormData = publicPermission
+            ? {
+                visibility: VisibilityLevel.PUBLIC,
+                permissionUuid: publicPermission.uuid,
+            }
+            : {
+                visibility: permissionLinks.length > 0
+                    ? VisibilityLevel.SHARED
+                    : VisibilityLevel.PRIVATE,
+                permissionUuid: '',
+            };
+
+        dispatch(initialize(SHARING_PUBLIC_ACCESS_FORM_NAME, publicAccessFormData));
+    };
+
+const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+    const state = getState();
+    const { user } = state.auth;
+    const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
+    if (dialog && user) {
+        const { permissionUuid, visibility } = getSharingPublicAccessFormData(state);
+
+        if (permissionUuid) {
+            if (visibility === VisibilityLevel.PUBLIC) {
+                await permissionService.update(permissionUuid, {
+                    name: PermissionLevel.CAN_READ
+                });
+            } else {
+                await permissionService.delete(permissionUuid);
+            }
+
+        } else if (visibility === VisibilityLevel.PUBLIC) {
+
+            await permissionService.create({
+                ownerUuid: user.uuid,
+                headUuid: dialog.data,
+                tailUuid: getPublicGroupUuid(state),
+                name: PermissionLevel.CAN_READ,
+            });
+        }
+    }
+};
+
+const saveManagementChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+    const state = getState();
+    const { user } = state.auth;
+    const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
+    if (dialog && user) {
+
+        const { initialPermissions, permissions } = getSharingMangementFormData(state);
+        const { visibility } = getSharingPublicAccessFormData(state);
+
+
+        if (visibility === VisibilityLevel.PRIVATE) {
+
+            for (const permission of initialPermissions) {
+                await permissionService.delete(permission.permissionUuid);
+            }
+
+        } else {
+
+            const cancelledPermissions = differenceWith(
+                initialPermissions,
+                permissions,
+                (a, b) => a.permissionUuid === b.permissionUuid
+            );
+
+            for (const { permissionUuid } of cancelledPermissions) {
+                await permissionService.delete(permissionUuid);
+            }
+
+            for (const permission of permissions) {
+                await permissionService.update(permission.permissionUuid, { name: permission.permissions });
+            }
+
+        }
+    }
+};
+
+const sendInvitations = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+    const state = getState();
+    const { user } = state.auth;
+    const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
+    if (dialog && user) {
+
+        const invitations = getFormValues(SHARING_INVITATION_FORM_NAME)(state) as SharingInvitationFormData;
+
+        const invitationData = invitations.invitedPeople
+            .map(person => ({
+                ownerUuid: user.uuid,
+                headUuid: dialog.data,
+                tailUuid: person.uuid,
+                name: invitations.permissions
+            }));
+
+        for (const invitation of invitationData) {
+            await permissionService.create(invitation);
+        }
+
+    }
+};
diff --git a/src/store/sharing-dialog/sharing-dialog-types.ts b/src/store/sharing-dialog/sharing-dialog-types.ts
new file mode 100644 (file)
index 0000000..da6ca9e
--- /dev/null
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PermissionLevel } from '~/models/permission';
+import { getFormValues, isDirty } from 'redux-form';
+import { RootState } from '~/store/store';
+
+export const SHARING_DIALOG_NAME = 'SHARING_DIALOG_NAME';
+export const SHARING_PUBLIC_ACCESS_FORM_NAME = 'SHARING_PUBLIC_ACCESS_FORM_NAME';
+export const SHARING_MANAGEMENT_FORM_NAME = 'SHARING_MANAGEMENT_FORM_NAME';
+export const SHARING_INVITATION_FORM_NAME = 'SHARING_INVITATION_FORM_NAME';
+
+export enum VisibilityLevel {
+    PRIVATE = 'Private',
+    SHARED = 'Shared',
+    PUBLIC = 'Public',
+}
+
+export interface SharingPublicAccessFormData {
+    visibility: VisibilityLevel;
+    permissionUuid: string;
+}
+
+export interface SharingManagementFormData {
+    permissions: SharingManagementFormDataRow[];
+    initialPermissions: SharingManagementFormDataRow[];
+}
+
+export interface SharingManagementFormDataRow {
+    email: string;
+    permissions: PermissionLevel;
+    permissionUuid: string;
+}
+
+export interface SharingInvitationFormData {
+    permissions: PermissionLevel;
+    invitedPeople: SharingInvitationFormPersonData[];
+}
+
+export interface SharingInvitationFormPersonData {
+    email: string;
+    name: string;
+    uuid: string;
+}
+
+export const getSharingMangementFormData = (state: any) =>
+    getFormValues(SHARING_MANAGEMENT_FORM_NAME)(state) as SharingManagementFormData;
+
+export const getSharingPublicAccessFormData = (state: any) =>
+    getFormValues(SHARING_PUBLIC_ACCESS_FORM_NAME)(state) as SharingPublicAccessFormData;
+
+export const hasChanges = (state: RootState) =>
+    isDirty(SHARING_PUBLIC_ACCESS_FORM_NAME)(state) ||
+    isDirty(SHARING_MANAGEMENT_FORM_NAME)(state) ||
+    isDirty(SHARING_INVITATION_FORM_NAME)(state);
index ca72e5a51a474fc8134d7f7aa6d144bb5a28bd9c..da0da54ddb46e8de55d9d7743145b5a49fb29da9 100644 (file)
@@ -28,6 +28,15 @@ export const getUuidPrefix = (state: RootState) => {
     return state.properties.uuidPrefix;
 };
 
+export const getPublicUserUuid = (state: RootState) => {
+    const prefix = getProperty<string>(UUID_PREFIX_PROPERTY_NAME)(state.properties);
+    return `${prefix}-tpzed-anonymouspublic`;
+};
+export const getPublicGroupUuid = (state: RootState) => {
+    const prefix = getProperty<string>(UUID_PREFIX_PROPERTY_NAME)(state.properties);
+    return `${prefix}-j7d0g-anonymouspublic`;
+};
+
 export const showWorkflowDetails = (uuid: string) =>
     propertiesActions.SET_PROPERTY({ key: WORKFLOW_PANEL_DETAILS_UUID, value: uuid });
 
index f7dbf3a7b01c44ae19c9868b1b079a15f21a1c17..07e150245486d85442034849250a2a41acdb4d0d 100644 (file)
@@ -13,6 +13,7 @@ import { openCollectionCopyDialog } from "~/store/collections/collection-copy-ac
 import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
 import { toggleCollectionTrashed } from "~/store/trash/trash-actions";
 import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
+import { openSharingDialog } from '~/store/sharing-dialog/sharing-dialog-actions';
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
 
 export const collectionActionSet: ContextMenuActionSet = [[
@@ -26,8 +27,8 @@ export const collectionActionSet: ContextMenuActionSet = [[
     {
         icon: ShareIcon,
         name: "Share",
-        execute: (dispatch, resource) => {
-            // add code
+        execute: (dispatch, { uuid }) => {
+            dispatch<any>(openSharingDialog(uuid));
         }
     },
     {
index 565a7d090c930cbc2ea93f678c3e58a730927cbe..30ef4e795c74c20cec8aabd4351607c61080dadd 100644 (file)
@@ -13,6 +13,7 @@ import { openMoveCollectionDialog } from '~/store/collections/collection-move-ac
 import { openCollectionCopyDialog } from '~/store/collections/collection-copy-actions';
 import { toggleCollectionTrashed } from "~/store/trash/trash-actions";
 import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
+import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 
 export const collectionResourceActionSet: ContextMenuActionSet = [[
     {
@@ -25,8 +26,8 @@ export const collectionResourceActionSet: ContextMenuActionSet = [[
     {
         icon: ShareIcon,
         name: "Share",
-        execute: (dispatch, resource) => {
-            // add code
+        execute: (dispatch, { uuid }) => {
+            dispatch<any>(openSharingDialog(uuid));
         }
     },
     {
index d41b59d6f31b0547dff1d380091d4910d7038aa9..4ad68597098029c1e274688de9e67bfd07083e48 100644 (file)
@@ -16,6 +16,7 @@ import { openProcessUpdateDialog } from "~/store/processes/process-update-action
 import { openCopyProcessDialog } from '~/store/processes/process-copy-actions';
 import { openProcessCommandDialog } from '~/store/processes/process-command-actions';
 import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
+import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
 
 export const processActionSet: ContextMenuActionSet = [[
@@ -29,8 +30,8 @@ export const processActionSet: ContextMenuActionSet = [[
     {
         icon: ShareIcon,
         name: "Share",
-        execute: (dispatch, resource) => {
-            // add code
+        execute: (dispatch, { uuid }) => {
+            dispatch<any>(openSharingDialog(uuid));
         }
     },
     {
index 6f3efc31a4c31d2b356eafb481f4e116ab5f6cc9..cf8aba5975b834c0ccccceb9347359a231006429 100644 (file)
@@ -11,6 +11,7 @@ import { openMoveProcessDialog } from '~/store/processes/process-move-actions';
 import { openProcessUpdateDialog } from "~/store/processes/process-update-actions";
 import { openCopyProcessDialog } from '~/store/processes/process-copy-actions';
 import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
+import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 
 export const processResourceActionSet: ContextMenuActionSet = [[
     {
@@ -23,8 +24,8 @@ export const processResourceActionSet: ContextMenuActionSet = [[
     {
         icon: ShareIcon,
         name: "Share",
-        execute: (dispatch, resource) => {
-            // add code
+        execute: (dispatch, { uuid }) => {
+            dispatch<any>(openSharingDialog(uuid));
         }
     },
     {
index 7171c0af0285e9c201b699e6b3a24f6023e58fa1..aa82c7fa2864de5174a2f1779a8c00ba06b127d2 100644 (file)
@@ -13,6 +13,8 @@ import { openProjectUpdateDialog } from '~/store/projects/project-update-actions
 import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
 import { toggleProjectTrashed } from "~/store/trash/trash-actions";
 import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
+import { ShareIcon } from '~/components/icon/icon';
+import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
 
 export const projectActionSet: ContextMenuActionSet = [[
@@ -30,6 +32,13 @@ export const projectActionSet: ContextMenuActionSet = [[
             dispatch<any>(openProjectUpdateDialog(resource));
         }
     },
+    {
+        icon: ShareIcon,
+        name: "Share",
+        execute: (dispatch, { uuid }) => {
+            dispatch<any>(openSharingDialog(uuid));
+        }
+    },
     {
         component: ToggleFavoriteAction,
         execute: (dispatch, resource) => {
diff --git a/src/views-components/sharing-dialog/advanced-view-switch.tsx b/src/views-components/sharing-dialog/advanced-view-switch.tsx
new file mode 100644 (file)
index 0000000..0d9e143
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+
+export interface AdvancedViewSwitchInjectedProps {
+    toggleAdvancedView: () => void;
+    advancedViewOpen: boolean;
+}
+
+export const connectAdvancedViewSwitch = (Component: React.ComponentType<AdvancedViewSwitchInjectedProps>) =>
+    class extends React.Component<{}, { advancedViewOpen: boolean }> {
+
+        state = { advancedViewOpen: false };
+
+        toggleAdvancedView = () => {
+            this.setState(({ advancedViewOpen }) => ({ advancedViewOpen: !advancedViewOpen }));
+        }
+
+        render() {
+            return <Component {...this.state} {...this} />;
+        }
+    };
+    
\ No newline at end of file
diff --git a/src/views-components/sharing-dialog/people-select.tsx b/src/views-components/sharing-dialog/people-select.tsx
new file mode 100644 (file)
index 0000000..2aada00
--- /dev/null
@@ -0,0 +1,117 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Autocomplete } from '~/components/autocomplete/autocomplete';
+import { UserResource } from '~/models/user';
+import { connect, DispatchProp } from 'react-redux';
+import { ServiceRepository } from '~/services/services';
+import { FilterBuilder } from '../../services/api/filter-builder';
+import { debounce } from 'debounce';
+import { ListItemText, Typography } from '@material-ui/core';
+import { noop } from 'lodash/fp';
+
+export interface Person {
+    name: string;
+    email: string;
+    uuid: string;
+}
+export interface PeopleSelectProps {
+
+    items: Person[];
+
+    onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onCreate?: (person: Person) => void;
+    onDelete?: (index: number) => void;
+    onSelect?: (person: Person) => void;
+
+}
+
+export interface PeopleSelectState {
+    value: string;
+    suggestions: UserResource[];
+}
+
+export const PeopleSelect = connect()(
+    class PeopleSelect extends React.Component<PeopleSelectProps & DispatchProp, PeopleSelectState> {
+
+        state: PeopleSelectState = {
+            value: '',
+            suggestions: []
+        };
+
+        render() {
+            return (
+                <Autocomplete
+                    label='Invite people'
+                    value={this.state.value}
+                    items={this.props.items}
+                    suggestions={this.state.suggestions}
+                    onChange={this.handleChange}
+                    onCreate={this.handleCreate}
+                    onSelect={this.handleSelect}
+                    onDelete={this.handleDelete}
+                    onFocus={this.props.onFocus}
+                    onBlur={this.props.onBlur}
+                    renderChipValue={this.renderChipValue}
+                    renderSuggestion={this.renderSuggestion} />
+            );
+        }
+
+        renderChipValue({ name, uuid }: Person) {
+            return name ? name : uuid;
+        }
+
+        renderSuggestion({ firstName, lastName, email }: UserResource) {
+            return (
+                <ListItemText>
+                    <Typography noWrap>{`${firstName} ${lastName} <<${email}>>`}</Typography>
+                </ListItemText>
+            );
+        }
+
+        handleDelete = (_: Person, index: number) => {
+            const { onDelete = noop } = this.props;
+            onDelete(index);
+        }
+
+        handleCreate = () => {
+            const { onCreate } = this.props;
+            if (onCreate) {
+                this.setState({ value: '', suggestions: [] });
+                onCreate({
+                    email: '',
+                    name: '',
+                    uuid: this.state.value,
+                });
+            }
+        }
+
+        handleSelect = ({ email, firstName, lastName, uuid }: UserResource) => {
+            const { onSelect = noop } = this.props;
+            this.setState({ value: '', suggestions: [] });
+            onSelect({
+                email,
+                name: `${firstName} ${lastName}`,
+                uuid,
+            });
+        }
+
+        handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+            this.setState({ value: event.target.value }, this.getSuggestions);
+        }
+
+        getSuggestions = debounce(() => this.props.dispatch<any>(this.requestSuggestions), 500);
+
+        requestSuggestions = async (_: void, __: void, { userService }: ServiceRepository) => {
+            const { value } = this.state;
+            const filters = new FilterBuilder()
+                .addILike('email', value)
+                .getFilters();
+            const { items } = await userService.list({ filters, limit: 5 });
+            this.setState({ suggestions: items });
+        }
+
+    });
diff --git a/src/views-components/sharing-dialog/permission-select.tsx b/src/views-components/sharing-dialog/permission-select.tsx
new file mode 100644 (file)
index 0000000..da5cd01
--- /dev/null
@@ -0,0 +1,77 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { MenuItem, Select, withStyles, StyleRulesCallback } from '@material-ui/core';
+import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
+import Edit from '@material-ui/icons/Edit';
+import Computer from '@material-ui/icons/Computer';
+import { WithStyles } from '@material-ui/core/styles';
+import { SelectProps } from '@material-ui/core/Select';
+import { SelectItem } from './select-item';
+import { PermissionLevel } from '../../models/permission';
+
+export enum PermissionSelectValue {
+    READ = 'Read',
+    WRITE = 'Write',
+    MANAGE = 'Manage',
+}
+
+export const parsePermissionLevel = (value: PermissionSelectValue) => {
+    switch (value) {
+        case PermissionSelectValue.READ:
+            return PermissionLevel.CAN_READ;
+        case PermissionSelectValue.WRITE:
+            return PermissionLevel.CAN_WRITE;
+        case PermissionSelectValue.MANAGE:
+            return PermissionLevel.CAN_MANAGE;
+        default:
+            return PermissionLevel.NONE;
+    }
+};
+
+export const formatPermissionLevel = (value: PermissionLevel) => {
+    switch (value) {
+        case PermissionLevel.CAN_READ:
+            return PermissionSelectValue.READ;
+        case PermissionLevel.CAN_WRITE:
+            return PermissionSelectValue.WRITE;
+        case PermissionLevel.CAN_MANAGE:
+            return PermissionSelectValue.MANAGE;
+        default:
+            return PermissionSelectValue.READ;
+    }
+};
+
+
+export const PermissionSelect = (props: SelectProps) =>
+    <Select
+        {...props}
+        renderValue={renderPermissionItem}>
+        <MenuItem value={PermissionSelectValue.READ}>
+            {renderPermissionItem(PermissionSelectValue.READ)}
+        </MenuItem>
+        <MenuItem value={PermissionSelectValue.WRITE}>
+            {renderPermissionItem(PermissionSelectValue.WRITE)}
+        </MenuItem>
+        <MenuItem value={PermissionSelectValue.MANAGE}>
+            {renderPermissionItem(PermissionSelectValue.MANAGE)}
+        </MenuItem>
+    </Select>;
+
+const renderPermissionItem = (value: string) =>
+    <SelectItem {...{ value, icon: getIcon(value) }} />;
+
+const getIcon = (value: string) => {
+    switch (value) {
+        case PermissionSelectValue.READ:
+            return RemoveRedEye;
+        case PermissionSelectValue.WRITE:
+            return Edit;
+        case PermissionSelectValue.MANAGE:
+            return Computer;
+        default:
+            return Computer;
+    }
+};
diff --git a/src/views-components/sharing-dialog/select-item.tsx b/src/views-components/sharing-dialog/select-item.tsx
new file mode 100644 (file)
index 0000000..9dcfd71
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Grid, withStyles, StyleRulesCallback } from '@material-ui/core';
+import { WithStyles } from '@material-ui/core/styles';
+import { SvgIconProps } from '@material-ui/core/SvgIcon';
+
+type SelectItemClasses = 'value' | 'icon';
+
+const permissionItemStyles: StyleRulesCallback<SelectItemClasses> = theme => ({
+    value: {
+        marginLeft: theme.spacing.unit,
+    },
+    icon: {
+        margin: `-${theme.spacing.unit / 2}px 0`
+    }
+});
+
+/**
+ * This component should be used as a child of MenuItem component.
+ */
+export const SelectItem = withStyles(permissionItemStyles)(
+    ({ value, icon: Icon, classes }: { value: string, icon: React.ComponentType<SvgIconProps> } & WithStyles<SelectItemClasses>) => {
+        return (
+            <Grid container alignItems='center'>
+                <Icon className={classes.icon} />
+                <span className={classes.value}>
+                    {value}
+                </span>
+            </Grid>);
+    });
+
diff --git a/src/views-components/sharing-dialog/sharing-dialog-component.tsx b/src/views-components/sharing-dialog/sharing-dialog-component.tsx
new file mode 100644 (file)
index 0000000..1792bd6
--- /dev/null
@@ -0,0 +1,91 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Dialog, DialogTitle, Button, Grid, DialogContent, CircularProgress, Paper } from '@material-ui/core';
+import { DialogActions } from '~/components/dialog-actions/dialog-actions';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+
+
+export interface SharingDialogDataProps {
+    open: boolean;
+    loading: boolean;
+    saveEnabled: boolean;
+    advancedEnabled: boolean;
+    children: React.ReactNode;
+}
+export interface SharingDialogActionProps {
+    onClose: () => void;
+    onExited: () => void;
+    onSave: () => void;
+    onAdvanced: () => void;
+}
+export default (props: SharingDialogDataProps & SharingDialogActionProps) => {
+    const { children, open, loading, advancedEnabled, saveEnabled, onAdvanced, onClose, onExited, onSave } = props;
+    return <Dialog
+        {...{ open, onClose, onExited }}
+        fullWidth
+        maxWidth='sm'
+        disableBackdropClick
+        disableEscapeKeyDown>
+        <DialogTitle>
+            Sharing settings
+            </DialogTitle>
+        <DialogContent>
+            {children}
+        </DialogContent>
+        <DialogActions>
+            <Grid container spacing={8}>
+                {advancedEnabled &&
+                    <Grid item>
+                        <Button
+                            color='primary'
+                            onClick={onAdvanced}>
+                            Advanced
+                    </Button>
+                    </Grid>
+                }
+                <Grid item xs />
+                <Grid item>
+                    <Button onClick={onClose}>
+                        Close
+                    </Button>
+                </Grid>
+                <Grid item>
+                    <Button
+                        variant='contained'
+                        color='primary'
+                        onClick={onSave}
+                        disabled={!saveEnabled}>
+                        Save
+                    </Button>
+                </Grid>
+            </Grid>
+        </DialogActions>
+        {
+            loading && <LoadingIndicator />
+        }
+    </Dialog>;
+};
+
+const loadingIndicatorStyles: StyleRulesCallback<'root'> = theme => ({
+    root: {
+        position: 'absolute',
+        top: 0,
+        right: 0,
+        bottom: 0,
+        left: 0,
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        backgroundColor: 'rgba(255, 255, 255, 0.8)',
+    },
+});
+
+const LoadingIndicator = withStyles(loadingIndicatorStyles)(
+    (props: WithStyles<'root'>) =>
+        <Paper classes={props.classes}>
+            <CircularProgress />
+        </Paper>
+);
diff --git a/src/views-components/sharing-dialog/sharing-dialog-content.tsx b/src/views-components/sharing-dialog/sharing-dialog-content.tsx
new file mode 100644 (file)
index 0000000..7347c66
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Grid, Typography } from '@material-ui/core';
+
+import { SharingInvitationForm } from './sharing-invitation-form';
+import { SharingManagementForm } from './sharing-management-form';
+import { SharingPublicAccessForm } from './sharing-public-access-form';
+
+export const SharingDialogContent = (props: { advancedViewOpen: boolean }) =>
+    <Grid container direction='column' spacing={24}>
+        {props.advancedViewOpen &&
+            <>
+                <Grid item>
+                    <Typography variant='subheading'>
+                        Who can access
+                    </Typography>
+                    <SharingPublicAccessForm />
+                    <SharingManagementForm />
+                </Grid>
+            </>
+        }
+        <Grid item>
+            <SharingInvitationForm />
+        </Grid>
+    </Grid>;
diff --git a/src/views-components/sharing-dialog/sharing-dialog.tsx b/src/views-components/sharing-dialog/sharing-dialog.tsx
new file mode 100644 (file)
index 0000000..b295f6d
--- /dev/null
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose, Dispatch } from 'redux';
+import { connect } from 'react-redux';
+
+import * as React from 'react';
+import { connectSharingDialog, saveSharingDialogChanges, connectSharingDialogProgress, sendSharingInvitations } from '~/store/sharing-dialog/sharing-dialog-actions';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { RootState } from '~/store/store';
+
+import SharingDialogComponent, { SharingDialogDataProps, SharingDialogActionProps } from './sharing-dialog-component';
+import { SharingDialogContent } from './sharing-dialog-content';
+import { connectAdvancedViewSwitch, AdvancedViewSwitchInjectedProps } from './advanced-view-switch';
+import { hasChanges } from '~/store/sharing-dialog/sharing-dialog-types';
+import { WithProgressStateProps } from '~/store/progress-indicator/with-progress';
+
+type Props = WithDialogProps<string> & AdvancedViewSwitchInjectedProps & WithProgressStateProps;
+
+const mapStateToProps = (state: RootState, { advancedViewOpen, working, ...props }: Props): SharingDialogDataProps => ({
+    ...props,
+    saveEnabled: hasChanges(state),
+    loading: working,
+    advancedEnabled: !advancedViewOpen,
+    children: <SharingDialogContent {...{ advancedViewOpen }} />,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch, { toggleAdvancedView, advancedViewOpen, ...props }: Props): SharingDialogActionProps => ({
+    ...props,
+    onClose: props.closeDialog,
+    onExited: () => {
+        if (advancedViewOpen) {
+            toggleAdvancedView();
+        }
+    },
+    onSave: () => {
+        if (advancedViewOpen) {
+            dispatch<any>(saveSharingDialogChanges);
+        } else {
+            dispatch<any>(sendSharingInvitations);
+        }
+    },
+    onAdvanced: toggleAdvancedView,
+});
+
+export const SharingDialog = compose(
+    connectAdvancedViewSwitch,
+    connectSharingDialog,
+    connectSharingDialogProgress,
+    connect(mapStateToProps, mapDispatchToProps)
+)(SharingDialogComponent);
+
diff --git a/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx b/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx
new file mode 100644 (file)
index 0000000..5aec8fe
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Field, WrappedFieldProps, FieldArray, WrappedFieldArrayProps } from 'redux-form';
+import { Grid, FormControl, InputLabel } from '@material-ui/core';
+import { PermissionSelect, parsePermissionLevel, formatPermissionLevel } from './permission-select';
+import { PeopleSelect, Person } from './people-select';
+
+export default () =>
+    <Grid container spacing={8}>
+        <Grid item xs={8}>
+            <InvitedPeopleField />
+        </Grid>
+        <Grid item xs={4}>
+            <PermissionSelectField />
+        </Grid>
+    </Grid>;
+
+const InvitedPeopleField = () =>
+    <FieldArray
+        name='invitedPeople'
+        component={InvitedPeopleFieldComponent} />;
+
+
+const InvitedPeopleFieldComponent = ({ fields }: WrappedFieldArrayProps<Person>) =>
+    <PeopleSelect
+        items={fields.getAll() || []}
+        onSelect={fields.push}
+        onDelete={fields.remove} />;
+
+const PermissionSelectField = () =>
+    <Field
+        name='permissions'
+        component={PermissionSelectComponent}
+        format={formatPermissionLevel}
+        parse={parsePermissionLevel} />;
+
+const PermissionSelectComponent = ({ input }: WrappedFieldProps) =>
+    <FormControl fullWidth>
+        <InputLabel>Authorization</InputLabel>
+        <PermissionSelect {...input} />
+    </FormControl>;
diff --git a/src/views-components/sharing-dialog/sharing-invitation-form.tsx b/src/views-components/sharing-dialog/sharing-invitation-form.tsx
new file mode 100644 (file)
index 0000000..d8e86f9
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm } from 'redux-form';
+import { connect } from 'react-redux';
+import { compose } from 'redux';
+import SharingInvitationFormComponent from './sharing-invitation-form-component';
+import { SHARING_INVITATION_FORM_NAME } from '~/store/sharing-dialog/sharing-dialog-types';
+import { PermissionLevel } from '~/models/permission';
+
+export const SharingInvitationForm = compose(
+    connect(() => ({
+        initialValues: {
+            permissions: PermissionLevel.CAN_READ,
+            invitedPeople: [],
+        }
+    })),
+    reduxForm({ form: SHARING_INVITATION_FORM_NAME })
+)(SharingInvitationFormComponent);
\ No newline at end of file
diff --git a/src/views-components/sharing-dialog/sharing-management-form-component.tsx b/src/views-components/sharing-dialog/sharing-management-form-component.tsx
new file mode 100644 (file)
index 0000000..ad8f65f
--- /dev/null
@@ -0,0 +1,54 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Grid, StyleRulesCallback, Divider, IconButton, Typography } from '@material-ui/core';
+import { Field, WrappedFieldProps, WrappedFieldArrayProps, FieldArray, FieldsProps } from 'redux-form';
+import { PermissionSelect, formatPermissionLevel, parsePermissionLevel } from './permission-select';
+import { WithStyles } from '@material-ui/core/styles';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { CloseIcon } from '~/components/icon/icon';
+
+
+export default () =>
+    <FieldArray name='permissions' component={SharingManagementFieldArray} />;
+
+const SharingManagementFieldArray = ({ fields }: WrappedFieldArrayProps<{ email: string }>) =>
+    <div>
+        {
+            fields.map((field, index, fields) =>
+                <PermissionManagementRow key={field} {...{ field, index, fields }} />)
+        }
+        <Divider />
+    </div>;
+
+const permissionManagementRowStyles: StyleRulesCallback<'root'> = theme => ({
+    root: {
+        padding: `${theme.spacing.unit}px 0`,
+    }
+});
+const PermissionManagementRow = withStyles(permissionManagementRowStyles)(
+    ({ field, index, fields, classes }: { field: string, index: number, fields: FieldsProps<{ email: string }> } & WithStyles<'root'>) =>
+        <>
+            <Divider />
+            <Grid container alignItems='center' spacing={8} wrap='nowrap' className={classes.root}>
+                <Grid item xs={8}>
+                    <Typography noWrap variant='subheading'>{fields.get(index).email}</Typography>
+                </Grid>
+                <Grid item xs={4} container wrap='nowrap'>
+                    <Field
+                        name={`${field}.permissions`}
+                        component={PermissionSelectComponent}
+                        format={formatPermissionLevel}
+                        parse={parsePermissionLevel} />
+                    <IconButton onClick={() => fields.remove(index)}>
+                        <CloseIcon />
+                    </IconButton>
+                </Grid>
+            </Grid>
+        </>
+);
+
+const PermissionSelectComponent = ({ input }: WrappedFieldProps) =>
+    <PermissionSelect fullWidth disableUnderline {...input} />;
diff --git a/src/views-components/sharing-dialog/sharing-management-form.tsx b/src/views-components/sharing-dialog/sharing-management-form.tsx
new file mode 100644 (file)
index 0000000..d78c6fe
--- /dev/null
@@ -0,0 +1,11 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm } from 'redux-form';
+import SharingManagementFormComponent from './sharing-management-form-component';
+import { SHARING_MANAGEMENT_FORM_NAME } from '~/store/sharing-dialog/sharing-dialog-types';
+
+export const SharingManagementForm = reduxForm(
+    { form: SHARING_MANAGEMENT_FORM_NAME }
+)(SharingManagementFormComponent);
diff --git a/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx b/src/views-components/sharing-dialog/sharing-public-access-form-component.tsx
new file mode 100644 (file)
index 0000000..7f3c4b7
--- /dev/null
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Grid, StyleRulesCallback, Divider, Typography } from '@material-ui/core';
+import { Field, WrappedFieldProps } from 'redux-form';
+import { WithStyles } from '@material-ui/core/styles';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { VisibilityLevelSelect } from './visibility-level-select';
+import { VisibilityLevel } from '~/store/sharing-dialog/sharing-dialog-types';
+
+const sharingPublicAccessStyles: StyleRulesCallback<'root'> = theme => ({
+    root: {
+        padding: `${theme.spacing.unit * 2}px 0`,
+    }
+});
+
+const SharingPublicAccessForm = withStyles(sharingPublicAccessStyles)(
+    ({ classes, visibility }: WithStyles<'root'> & { visibility: VisibilityLevel }) =>
+        <>
+            <Divider />
+            <Grid container alignItems='center' spacing={8} className={classes.root}>
+                <Grid item xs={8}>
+                    <Typography variant='subheading'>
+                        {renderVisibilityInfo(visibility)}
+                    </Typography>
+                </Grid>
+                <Grid item xs={4} container wrap='nowrap'>
+                    <Field name='visibility' component={VisibilityLevelSelectComponent} />
+                </Grid>
+            </Grid>
+        </>
+);
+
+const renderVisibilityInfo = (visibility: VisibilityLevel) => {
+    switch (visibility) {
+        case VisibilityLevel.PUBLIC:
+            return 'Anyone can access';
+        case VisibilityLevel.SHARED:
+            return 'Specific people can access';
+        case VisibilityLevel.PRIVATE:
+            return 'Only you can access';
+        default:
+            return '';
+    }
+};
+
+export default ({ visibility }: { visibility: VisibilityLevel }) =>
+    <SharingPublicAccessForm {...{ visibility }} />;
+
+const VisibilityLevelSelectComponent = ({ input }: WrappedFieldProps) =>
+    <VisibilityLevelSelect fullWidth disableUnderline {...input} />;
diff --git a/src/views-components/sharing-dialog/sharing-public-access-form.tsx b/src/views-components/sharing-dialog/sharing-public-access-form.tsx
new file mode 100644 (file)
index 0000000..1d787d3
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { reduxForm } from 'redux-form';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
+import SharingPublicAccessFormComponent from './sharing-public-access-form-component';
+import { SHARING_PUBLIC_ACCESS_FORM_NAME } from '~/store/sharing-dialog/sharing-dialog-types';
+import { RootState } from '~/store/store';
+import { getSharingPublicAccessFormData } from '../../store/sharing-dialog/sharing-dialog-types';
+
+export const SharingPublicAccessForm = compose(
+    reduxForm(
+        { form: SHARING_PUBLIC_ACCESS_FORM_NAME }
+    ),
+    connect(
+        (state: RootState) => {
+            const { visibility } = getSharingPublicAccessFormData(state);
+            return { visibility };
+        }
+    )
+)(SharingPublicAccessFormComponent);
diff --git a/src/views-components/sharing-dialog/visibility-level-select.tsx b/src/views-components/sharing-dialog/visibility-level-select.tsx
new file mode 100644 (file)
index 0000000..7213155
--- /dev/null
@@ -0,0 +1,54 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { MenuItem, Select, withStyles, StyleRulesCallback } from '@material-ui/core';
+import Lock from '@material-ui/icons/Lock';
+import People from '@material-ui/icons/People';
+import Public from '@material-ui/icons/Public';
+import { WithStyles } from '@material-ui/core/styles';
+import { SelectProps } from '@material-ui/core/Select';
+import { SelectItem } from './select-item';
+import { VisibilityLevel } from '~/store/sharing-dialog/sharing-dialog-types';
+
+
+type VisibilityLevelSelectClasses = 'value';
+
+const VisibilityLevelSelectStyles: StyleRulesCallback<VisibilityLevelSelectClasses> = theme => ({
+    value: {
+        marginLeft: theme.spacing.unit,
+    }
+});
+export const VisibilityLevelSelect = withStyles(VisibilityLevelSelectStyles)(
+    ({ classes, ...props }: SelectProps & WithStyles<VisibilityLevelSelectClasses>) =>
+        <Select
+            {...props}
+            renderValue={renderPermissionItem}
+            inputProps={{ classes }}>
+            <MenuItem value={VisibilityLevel.PUBLIC}>
+                {renderPermissionItem(VisibilityLevel.PUBLIC)}
+            </MenuItem>
+            <MenuItem value={VisibilityLevel.SHARED}>
+                {renderPermissionItem(VisibilityLevel.SHARED)}
+            </MenuItem>
+            <MenuItem value={VisibilityLevel.PRIVATE}>
+                {renderPermissionItem(VisibilityLevel.PRIVATE)}
+            </MenuItem>
+        </Select>);
+
+const renderPermissionItem = (value: string) =>
+    <SelectItem {...{ value, icon: getIcon(value) }} />;
+
+const getIcon = (value: string) => {
+    switch (value) {
+        case VisibilityLevel.PUBLIC:
+            return Public;
+        case VisibilityLevel.SHARED:
+            return People;
+        case VisibilityLevel.PRIVATE:
+            return Lock;
+        default:
+            return Lock;
+    }
+};
index 1e6538485ec8e538c975b5d2eeca84b4b607a8fc..41f9682f639c8e76faed175f8bc2511ce680c196 100644 (file)
@@ -42,6 +42,7 @@ import { RunProcessPanel } from '~/views/run-process-panel/run-process-panel';
 import SplitterLayout from 'react-splitter-layout';
 import { WorkflowPanel } from '~/views/workflow-panel/workflow-panel';
 import { SearchResultsPanel } from '~/views/search-results-panel/search-results-panel';
+import { SharingDialog } from '~/views-components/sharing-dialog/sharing-dialog';
 import { AdvancedTabDialog } from '~/views-components/advanced-tab-dialog/advanced-tab-dialog';
 
 type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
@@ -126,6 +127,7 @@ export const WorkbenchPanel =
             <ProcessCommandDialog />
             <RenameFileDialog />
             <RichTextEditorDialog />
+            <SharingDialog />
             <Snackbar />
             <UpdateCollectionDialog />
             <UpdateProcessDialog />