KEYCLOAK-14158 Polished the My Resource page

empty state

change case

added dropdown menu instead of buttons

now on edit you can add and remove permissions

changed how the actions work

updated success messages

use live region alerts toast alerts

username or email search

labels for the buttons

margin between accecpt and deny button

fixed test and types

changed to bigger distance with split component

changed to use seperate empty state component
This commit is contained in:
Erik Jan de Wit 2020-05-26 14:50:39 +02:00 committed by Stan Silvert
parent 33863ba161
commit 8b0760a6d1
15 changed files with 547 additions and 256 deletions

View file

@ -19,6 +19,7 @@ package org.keycloak.services.resources.account.resources;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
@ -205,7 +206,11 @@ public class ResourceService extends AbstractResourceService {
UserModel user = users.getUserByUsername(requester, provider.getRealm());
if (user == null) {
user = users.getUserById(requester, provider.getRealm());
user = users.getUserByEmail(requester, provider.getRealm());
}
if (user == null) {
throw new NotFoundException("invalid_username_or_email");
}
return user;

View file

@ -30,29 +30,27 @@ public class MyResourcesPage extends AbstractLoggedInPage {
}
public void clickShareButton(int row) {
final WebElement webElement = driver.findElement(By.id("ex-expand" + row));
//first button is share
webElement.findElements(By.tagName("button")).get(0).click();
driver.findElement(By.id("share-" + row)).click();
}
public void clickEditButton(int row) {
final WebElement webElement = driver.findElement(By.id("ex-expand" + row));
//first button share 2rd is the edit button
webElement.findElements(By.tagName("button")).get(1).click();
final WebElement webElement = driver.findElement(By.id("action-menu-" + row));
webElement.click();
webElement.findElement(By.id("edit-" + row)).click();
}
public void createShare(String userName) {
driver.findElement(By.id("username")).sendKeys(userName);
driver.findElement(By.id("add")).click();
driver.findElement(By.id("remove_pf-random-id-1")).click();
driver.findElement(By.id("pf-toggle-id-6")).click();
driver.findElement(By.id("Scope A-1")).click();
driver.findElement(By.id("pf-toggle-id-9")).click();
driver.findElement(By.id("done")).click();
}
public void removeAllPermissions() {
driver.findElement(By.id("remove_pf-random-id-1")).click();
driver.findElement(By.id("remove_pf-random-id-2")).click();
driver.findElement(By.className("pf-c-select__toggle-clear")).click();
driver.findElement(By.id("save-0")).click();
driver.findElement(By.id("done")).click();
}
}

View file

@ -43,7 +43,12 @@ firstPage=First Page
resourceSharedWith=Resource is shared with {0}
and=\ and {0} other users
add=Add
share=Share
edit=Edit
unShare=Unshare
shareSuccess=Resource successfully shared.
unShareSuccess=Resource successfully un-shared.
updateSuccess=Resource successfully updated.
resourceAlreadyShared=Resource is already shared with this user.
resourceNotShared=This resource is not shared.
permissionRequests=Permission Requests

View file

@ -15,16 +15,17 @@
*/
import * as React from 'react';
import {Alert, AlertActionCloseButton} from '@patternfly/react-core';
import {Msg} from '../widgets/Msg';
import { Alert, AlertActionCloseButton, AlertGroup, AlertVariant } from '@patternfly/react-core';
import { Msg } from '../widgets/Msg';
interface ContentAlertProps {}
interface ContentAlertProps { }
type AlertVariant = 'success' | 'danger' | 'warning' | 'info';
interface ContentAlertState {
isVisible: boolean;
alerts: {
key: number;
message: string;
variant: AlertVariant;
}[];
}
export class ContentAlert extends React.Component<ContentAlertProps, ContentAlertState> {
private static instance: ContentAlert;
@ -32,7 +33,9 @@ export class ContentAlert extends React.Component<ContentAlertProps, ContentAler
private constructor(props: ContentAlertProps) {
super(props);
this.state = {isVisible: false, message: '', variant: 'success'};
this.state = {
alerts: []
};
ContentAlert.instance = this;
}
@ -40,62 +43,69 @@ export class ContentAlert extends React.Component<ContentAlertProps, ContentAler
* @param message A literal text message or localization key.
*/
public static success(message: string, params?: string[]): void {
ContentAlert.instance.postAlert('success', message, params);
ContentAlert.instance.postAlert(AlertVariant.success, message, params);
}
/**
* @param message A literal text message or localization key.
*/
public static danger(message: string, params?: string[]): void {
ContentAlert.instance.postAlert('danger', message, params);
ContentAlert.instance.postAlert(AlertVariant.danger, message, params);
}
/**
* @param message A literal text message or localization key.
*/
public static warning(message: string, params?: string[]): void {
ContentAlert.instance.postAlert('warning', message, params);
ContentAlert.instance.postAlert(AlertVariant.warning, message, params);
}
/**
* @param message A literal text message or localization key.
*/
public static info(message: string, params?: string[]): void {
ContentAlert.instance.postAlert('info', message, params);
ContentAlert.instance.postAlert(AlertVariant.info, message, params);
}
private hideAlert = () => {
this.setState({isVisible: false});
private hideAlert = (key: number) => {
this.setState({ alerts: [...this.state.alerts.filter(el => el.key !== key)] });
}
private getUniqueId = () => (new Date().getTime());
private postAlert = (variant: AlertVariant, message: string, params?: string[]) => {
this.setState({isVisible: true,
const alerts = this.state.alerts;
const key = this.getUniqueId();
alerts.push({
key,
message: Msg.localize(message, params),
variant});
variant
});
this.setState({ alerts });
if (variant !== 'danger') {
setTimeout(() => this.setState({isVisible: false}), 5000);
if (variant !== AlertVariant.danger) {
setTimeout(() => this.hideAlert(key), 8000);
}
}
public render(): React.ReactNode {
return (
<React.Fragment>
{ this.state.isVisible &&
<section className="pf-c-page__main-section pf-m-light">
<AlertGroup isToast>
{this.state.alerts.map(({ key, variant, message }) => (
<Alert
id="content-alert"
title=''
variant={this.state.variant}
variantLabel=''
aria-label=''
action={<AlertActionCloseButton id="content-alert-close" onClose={this.hideAlert} />}
>
{this.state.message}
</Alert>
</section>
isLiveRegion
variant={variant}
title={message}
action={
<AlertActionCloseButton
title={message}
variantLabel={`${variant} alert`}
onClose={() => this.hideAlert(key)}
/>
}
</React.Fragment>
key={key} />
))}
</AlertGroup>
);
}
}

View file

@ -19,76 +19,76 @@ import * as React from 'react';
import {
Button,
Modal,
DataList,
DataListItemRow,
DataListItemCells,
DataListCell,
DataListItem,
ChipGroup,
ChipGroupToolbarItem,
Chip
Form,
FormGroup,
TextInput,
InputGroup
} from '@patternfly/react-core';
import { EditAltIcon } from '@patternfly/react-icons';
import { OkIcon } from '@patternfly/react-icons';
import { Resource, Permission, Scope } from './MyResourcesPage';
import { Msg } from '../../widgets/Msg';
import AccountService, {HttpResponse} from '../../account-service/account.service';
import AccountService from '../../account-service/account.service';
import { ContentAlert } from '../ContentAlert';
import { PermissionSelect } from './PermissionSelect';
interface EditTheResourceProps {
resource: Resource;
permissions: Permission[];
onClose: (resource: Resource, row: number) => void;
row: number;
onClose: () => void;
children: (toggle: () => void) => void;
}
interface EditTheResourceState {
changed: boolean[];
isOpen: boolean;
}
export class EditTheResource extends React.Component<EditTheResourceProps, EditTheResourceState> {
protected static defaultProps = { permissions: [], row: 0 };
protected static defaultProps = { permissions: [] };
public constructor(props: EditTheResourceProps) {
super(props);
this.state = {
changed: [],
isOpen: false,
};
}
private clearState(): void {
this.setState({
});
this.setState({});
}
private handleToggleDialog = () => {
if (this.state.isOpen) {
this.setState({ isOpen: false });
this.props.onClose();
} else {
this.clearState();
this.setState({ isOpen: true });
}
};
async deletePermission(permission: Permission, scope: Scope): Promise<void> {
permission.scopes.splice(permission.scopes.indexOf(scope), 1);
private updateChanged = (row: number) => {
const changed = this.state.changed;
changed[row] = !changed[row];
this.setState({ changed });
}
async savePermission(permission: Permission): Promise<void> {
await AccountService.doPut(`/resources/${this.props.resource._id}/permissions`, [permission]);
ContentAlert.success(Msg.localize('shareSuccess'));
this.props.onClose(this.props.resource, this.props.row);
ContentAlert.success(Msg.localize('updateSuccess'));
}
public render(): React.ReactNode {
return (
<React.Fragment>
<Button variant="link" onClick={this.handleToggleDialog}>
<EditAltIcon /> Edit
</Button>
{this.props.children(this.handleToggleDialog)}
<Modal
title={'Edit the resource - ' + this.props.resource.name}
isLarge={true}
isLarge
isOpen={this.state.isOpen}
onClose={this.handleToggleDialog}
actions={[
@ -97,48 +97,44 @@ export class EditTheResource extends React.Component<EditTheResourceProps, EditT
</Button>,
]}
>
<DataList aria-label={Msg.localize('resources')}>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key='resource-name-header' width={3}>
<strong><Msg msgKey='User' /></strong>
</DataListCell>,
<DataListCell key='permissions-header' width={5}>
<strong><Msg msgKey='permissions' /></strong>
</DataListCell>,
]}
<Form isHorizontal>
{this.props.permissions.map((p, row) => (
<React.Fragment>
<FormGroup
fieldId={`username-${row}`}
label={Msg.localize('User')}
>
<TextInput id={`username-${row}`} type="text" value={p.username} isDisabled />
</FormGroup>
<FormGroup
fieldId={`permissions-${row}`}
label={Msg.localize('permissions')}
isRequired
>
<InputGroup>
<PermissionSelect
scopes={this.props.resource.scopes}
selected={(p.scopes as string[]).map(s => new Scope(s))}
direction={row === this.props.permissions.length - 1 ? "up" : "down"}
onSelect={selection => {
p.scopes = selection.map(s => s.name);
this.updateChanged(row);
}}
/>
</DataListItemRow>
{this.props.permissions.map((p, row) => {
return (
<DataListItem key={'resource-' + row} aria-labelledby={p.username}>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key={'userName-' + row} width={5}>
{p.username}
</DataListCell>,
<DataListCell key={'permission-' + row} width={5}>
<ChipGroup withToolbar>
<ChipGroupToolbarItem key='permissions' categoryName={Msg.localize('permissions')}>
{
p.scopes.length > 0 && p.scopes.map(scope => (
<Chip key={scope.toString()} onClick={() => this.deletePermission(p, scope)}>
{scope.displayName || scope}
</Chip>
))
}
</ChipGroupToolbarItem>
</ChipGroup>
</DataListCell>
]}
/>
</DataListItemRow>
</DataListItem>
);
})}
</DataList>
<Button
id={`save-${row}`}
isDisabled={!this.state.changed[row]}
onClick={() => this.savePermission(p)}
>
<OkIcon />
</Button>
</InputGroup>
</FormGroup>
<hr />
</React.Fragment>
))}
</Form>
</Modal>
</React.Fragment>
);

View file

@ -75,7 +75,7 @@ export interface Permission {
email?: string;
firstName?: string;
lastName?: string;
scopes: Scope[]; // this should be Scope[] - fix API
scopes: Scope[] | string[]; // this should be Scope[] - fix API
username: string;
}

View file

@ -14,7 +14,21 @@
* limitations under the License.
*/
import * as React from 'react';
import { Button, Modal, Text, Badge, DataListItem, DataList, TextVariants, DataListItemRow, DataListItemCells, DataListCell, Chip } from '@patternfly/react-core';
import {
Button,
Modal,
Text,
Badge,
DataListItem,
DataList,
TextVariants,
DataListItemRow,
DataListItemCells,
DataListCell,
Chip,
Split,
SplitItem
} from '@patternfly/react-core';
import { UserCheckIcon } from '@patternfly/react-icons';
import AccountService, {HttpResponse} from '../../account-service/account.service';
@ -59,7 +73,7 @@ export class PermissionRequest extends React.Component<PermissionRequestProps, P
const permissions: Permission[] = permissionsRequest.data || [];
// Erik - I had to add the exclamation point. Can we be sure that the 'find' will not return undefined?
const userScopes = permissions.find((p: Permission) => p.username === username)!.scopes;
const userScopes = permissions.find((p: Permission) => p.username === username)!.scopes as Scope[];
if (approve) {
userScopes.push(...scopes);
}
@ -124,15 +138,22 @@ export class PermissionRequest extends React.Component<PermissionRequestProps, P
<Text component={TextVariants.small}>{shareRequest.email}</Text>
</DataListCell>,
<DataListCell key={`permissions${i}`}>
{shareRequest.scopes.map((scope, j) => <Chip key={j} isReadOnly>{scope}</Chip>)}
{(shareRequest.scopes as Scope[]).map((scope, j) => <Chip key={j} isReadOnly>{scope}</Chip>)}
</DataListCell>,
<DataListCell key={`actions${i}`}>
<Button onClick={() => this.handleApprove(shareRequest.username, shareRequest.scopes)}>
<Split gutter="sm">
<SplitItem>
<Button
onClick={() => this.handleApprove(shareRequest.username, shareRequest.scopes as Scope[])}>
Accept
</Button>
<Button variant="danger" onClick={() => this.handleDeny(shareRequest.username, shareRequest.scopes)}>
</SplitItem>
<SplitItem>
<Button variant="danger" onClick={() => this.handleDeny(shareRequest.username, shareRequest.scopes as Scope[])}>
Deny
</Button>
</SplitItem>
</Split>
</DataListCell>
]}
/>

View file

@ -0,0 +1,108 @@
import * as React from 'react';
import { Select, SelectOption, SelectVariant, SelectOptionObject } from '@patternfly/react-core';
import { Scope } from './MyResourcesPage';
interface PermissionSelectState {
selected: ScopeValue[];
isExpanded: boolean;
scopes: JSX.Element[];
}
interface PermissionSelectProps {
scopes: Scope[];
selected?: Scope[];
direction?: 'up' | 'down';
onSelect: (selected: Scope[]) => void;
}
class ScopeValue implements SelectOptionObject {
value: Scope;
constructor(value: Scope) {
this.value = value;
}
toString() {
return this.value.displayName ? this.value.displayName : this.value.name;
}
compareTo(selectOption: Scope): boolean {
return selectOption.name === this.value.name;
}
}
export class PermissionSelect extends React.Component<PermissionSelectProps, PermissionSelectState> {
constructor(props: PermissionSelectProps) {
super(props);
let values: ScopeValue[] = [];
if (this.props.selected) {
values = this.props.selected!.map(s => new ScopeValue(s))
}
this.state = {
isExpanded: false,
selected: values,
scopes: this.props.scopes.map((option, index) => (
<SelectOption key={index} value={values.find(s => s.compareTo(option)) || new ScopeValue(option)} />
))
};
}
private onSelect = (_event: React.MouseEvent | React.ChangeEvent, selection: ScopeValue): void => {
const { selected } = this.state;
const { onSelect } = this.props;
if (selected.includes(selection)) {
this.setState(
prevState => ({ selected: prevState.selected.filter(item => item !== selection) }),
() => onSelect(this.state.selected.map(sv => sv.value))
);
} else {
this.setState(
prevState => ({ selected: [...prevState.selected, selection] }),
() => onSelect(this.state.selected.map(sv => sv.value))
);
}
}
private onToggle = (isExpanded: boolean) => {
this.setState({
isExpanded
});
}
private clearSelection = () => {
this.setState({
selected: [],
isExpanded: false
});
this.props.onSelect([]);
};
render() {
const { isExpanded, selected } = this.state;
const titleId = 'permission-id';
return (
<div>
<span id={titleId} hidden>
Select the permissions
</span>
<Select
direction={this.props.direction || 'down'}
variant={SelectVariant.typeaheadMulti}
ariaLabelTypeAhead="Select the permissions"
onToggle={this.onToggle}
onSelect={this.onSelect}
onClear={this.clearSelection}
selections={selected}
isExpanded={isExpanded}
ariaLabelledBy={titleId}
placeholderText="Select the permissions"
>
{this.state.scopes}
</Select>
</div>
);
}
}

View file

@ -26,24 +26,32 @@ import {
DataListItemCells,
Level,
LevelItem,
Stack,
StackItem,
Button
} from '@patternfly/react-core';
Button,
DataListAction,
DataListActionVisibility,
Dropdown,
DropdownPosition,
DropdownItem,
KebabToggle
} from '@patternfly/react-core';
import { css } from '@patternfly/react-styles';
import { Remove2Icon } from '@patternfly/react-icons';
import { Remove2Icon, RepositoryIcon, ShareAltIcon, EditAltIcon } from '@patternfly/react-icons';
import AccountService, {HttpResponse} from '../../account-service/account.service';
import {PermissionRequest} from "./PermissionRequest";
import {ShareTheResource} from "./ShareTheResource";
import {Permission, Resource} from "./MyResourcesPage";
import AccountService, { HttpResponse } from '../../account-service/account.service';
import { PermissionRequest } from "./PermissionRequest";
import { ShareTheResource } from "./ShareTheResource";
import { Permission, Resource } from "./MyResourcesPage";
import { Msg } from '../../widgets/Msg';
import { ResourcesTableState, ResourcesTableProps, AbstractResourcesTable } from './AbstractResourceTable';
import { EditTheResource } from './EditTheResource';
import { ContentAlert } from '../ContentAlert';
import EmptyMessageState from '../../widgets/EmptyMessageState';
export interface CollapsibleResourcesTableState extends ResourcesTableState {
isRowOpen: boolean[];
contextOpen: boolean[];
isModalActive: boolean;
}
export class ResourcesTable extends AbstractResourcesTable<CollapsibleResourcesTableState> {
@ -51,7 +59,9 @@ export class ResourcesTable extends AbstractResourcesTable<CollapsibleResourcesT
public constructor(props: ResourcesTableProps) {
super(props);
this.state = {
isRowOpen: new Array<boolean>(props.resources.data.length).fill(false),
isRowOpen: [],
contextOpen: [],
isModalActive: false,
permissions: new Map()
}
}
@ -60,35 +70,50 @@ export class ResourcesTable extends AbstractResourcesTable<CollapsibleResourcesT
const newIsRowOpen: boolean[] = this.state.isRowOpen;
newIsRowOpen[row] = !newIsRowOpen[row];
if (newIsRowOpen[row]) this.fetchPermissions(this.props.resources.data[row], row);
this.setState({isRowOpen: newIsRowOpen});
this.setState({ isRowOpen: newIsRowOpen });
};
private onContextToggle = (row: number, isOpen: boolean): void => {
if (this.state.isModalActive) return;
const data = this.props.resources.data;
const contextOpen = this.state.contextOpen;
contextOpen[row] = isOpen;
if (isOpen) {
const index = row > data.length ? row - data.length - 1 : row;
this.fetchPermissions(data[index], index);
}
this.setState({ contextOpen });
}
private fetchPermissions(resource: Resource, row: number): void {
AccountService.doGet(`/resources/${resource._id}/permissions`)
.then((response: HttpResponse<Permission[]>) => {
const newPermissions: Map<number, Permission[]> = new Map(this.state.permissions);
newPermissions.set(row, response.data || []);
this.setState({permissions: newPermissions});
}
);
this.setState({ permissions: newPermissions });
});
}
private removeShare(resource: Resource, row: number): void {
private removeShare(resource: Resource, row: number): Promise<void> {
const permissions = this.state.permissions.get(row)!.map(a => ({ username: a.username, scopes: [] }));
AccountService.doPut(`/resources/${resource._id}/permissions`, permissions)
return AccountService.doPut(`/resources/${resource._id}/permissions`, permissions)
.then(() => {
ContentAlert.success(Msg.localize('shareSuccess'));
this.onToggle(row);
ContentAlert.success(Msg.localize('unShareSuccess'));
});
}
public render(): React.ReactNode {
if (this.props.resources.data.length === 0) {
return (
<EmptyMessageState icon={RepositoryIcon} messageKey="notHaveAnyResource"/>
);
}
return (
<DataList aria-label={Msg.localize('resources')} id="resourcesList">
<DataListItem key='resource-header' aria-labelledby='resource-header'>
<DataListItemRow>
// invisible toggle allows headings to line up properly
<span style={{visibility: 'hidden'}}>
<span style={{ visibility: 'hidden' }}>
<DataListToggle
isExpanded={false}
id='resource-header-invisible-toggle'
@ -98,25 +123,23 @@ export class ResourcesTable extends AbstractResourcesTable<CollapsibleResourcesT
<DataListItemCells
dataListCells={[
<DataListCell key='resource-name-header' width={5}>
<strong><Msg msgKey='resourceName'/></strong>
<strong><Msg msgKey='resourceName' /></strong>
</DataListCell>,
<DataListCell key='application-name-header' width={5}>
<strong><Msg msgKey='application'/></strong>
<strong><Msg msgKey='application' /></strong>
</DataListCell>,
<DataListCell key='permission-request-header' width={5}>
<strong><Msg msgKey='permissionRequests'/></strong>
<strong><Msg msgKey='permissionRequests' /></strong>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
{(this.props.resources.data.length === 0) && <Msg msgKey="notHaveAnyResource"/>}
{this.props.resources.data.map((resource: Resource, row: number) => {
return (
{this.props.resources.data.map((resource: Resource, row: number) => (
<DataListItem key={'resource-' + row} aria-labelledby={resource.name} isExpanded={this.state.isRowOpen[row]}>
<DataListItemRow>
<DataListToggle
onClick={()=>this.onToggle(row)}
onClick={() => this.onToggle(row)}
isExpanded={this.state.isRowOpen[row]}
id={'resourceToggle-' + row}
aria-controls="ex-expand1"
@ -124,7 +147,7 @@ export class ResourcesTable extends AbstractResourcesTable<CollapsibleResourcesT
<DataListItemCells
dataListCells={[
<DataListCell key={'resourceName-' + row} width={5}>
<Msg msgKey={resource.name}/>
<Msg msgKey={resource.name} />
</DataListCell>,
<DataListCell key={'resourceClient-' + row} width={5}>
<a href={resource.client.baseUrl}>{this.getClientName(resource.client)}</a>
@ -139,6 +162,143 @@ export class ResourcesTable extends AbstractResourcesTable<CollapsibleResourcesT
</DataListCell>
]}
/>
<DataListAction
className={DataListActionVisibility.hiddenOnXl}
aria-labelledby="check-action-item3 check-action-action3"
id="check-action-action3"
aria-label="Actions"
>
<Dropdown
isPlain
position={DropdownPosition.right}
onSelect={() => this.setState({ isModalActive: true })}
toggle={<KebabToggle onToggle={isOpen => this.onContextToggle(row + this.props.resources.data.length + 1, isOpen)} />}
isOpen={this.state.contextOpen[row + this.props.resources.data.length + 1]}
dropdownItems={[
<ShareTheResource
resource={resource}
permissions={this.state.permissions.get(row)!}
sharedWithUsersMsg={this.sharedWithUsersMessage(row)}
onClose={() => {
this.setState({ isModalActive: false }, () => {
this.onContextToggle(row + this.props.resources.data.length + 1, false);
});
}}
>
{
(toggle: () => void) => (
<DropdownItem id={'mob-share-' + row} key="mob-share" onClick={toggle}>
<ShareAltIcon /> <Msg msgKey="share"/>
</DropdownItem>)
}
</ShareTheResource>,
<EditTheResource
resource={resource}
permissions={this.state.permissions.get(row)!}
onClose={() => {
this.setState({ isModalActive: false }, () => {
this.onContextToggle(row + this.props.resources.data.length + 1, false);
});
}}
>
{
(toggle: () => void) => (
<DropdownItem
id={'mob-edit-' + row} key="mob-edit"
isDisabled={this.numOthers(row) < 0}
onClick={toggle}
>
<EditAltIcon /> <Msg msgKey="edit"/>
</DropdownItem>)
}
</EditTheResource>,
<DropdownItem
id={'mob-remove-' + row}
key="mob-remove"
isDisabled={this.numOthers(row) < 0}
onClick={() => {
this.removeShare(resource, row).then(() =>
this.setState({ isModalActive: false }, () => {
this.onContextToggle(row + this.props.resources.data.length + 1, false);
})
);
}}
>
<Remove2Icon /> <Msg msgKey="unShare"/>
</DropdownItem>
]}
/>
</DataListAction>
<DataListAction
id={`actions-${row}`}
className={css(DataListActionVisibility.visibleOnXl, DataListActionVisibility.hidden)}
aria-labelledby="Row actions"
aria-label="Actions"
>
<ShareTheResource
resource={resource}
permissions={this.state.permissions.get(row)!}
sharedWithUsersMsg={this.sharedWithUsersMessage(row)}
onClose={() => this.fetchPermissions(resource, row)}
>
{
(toggle: () => void) => (
<Button id={`share-${row}`} variant="link" onClick={toggle}>
<ShareAltIcon /> <Msg msgKey="share"/>
</Button>
)
}
</ShareTheResource>
<Dropdown
id={`action-menu-${row}`}
isPlain
position={DropdownPosition.right}
toggle={<KebabToggle onToggle={isOpen => this.onContextToggle(row, isOpen)} />}
onSelect={() => this.setState({ isModalActive: true })}
isOpen={this.state.contextOpen[row]}
dropdownItems={[
<EditTheResource
resource={resource}
permissions={this.state.permissions.get(row)!}
onClose={() => {
this.setState({ isModalActive: false }, () => {
this.onContextToggle(row, false);
this.fetchPermissions(resource, row);
});
}}
>
{
(toggle: () => void) => (
<DropdownItem
id={'edit-' + row}
key="edit"
component="button"
isDisabled={this.numOthers(row) < 0}
onClick={toggle}
>
<EditAltIcon /> <Msg msgKey="edit"/>
</DropdownItem>)
}
</EditTheResource>,
<DropdownItem
id={'remove-' + row}
key="remove"
component="button"
isDisabled={this.numOthers(row) < 0}
onClick={() => {
this.removeShare(resource, row).then(() =>
this.setState({ isModalActive: false }, () => {
this.onContextToggle(row, false);
})
);
}}
>
<Remove2Icon /> <Msg msgKey="unShare"/>
</DropdownItem>
]}
/>
</DataListAction>
</DataListItemRow>
<DataListContent
noPadding={false}
@ -146,43 +306,14 @@ export class ResourcesTable extends AbstractResourcesTable<CollapsibleResourcesT
id={'ex-expand' + row}
isHidden={!this.state.isRowOpen[row]}
>
<Stack gutter='md'>
<StackItem isFilled>
<Level gutter='md'>
<LevelItem><span/></LevelItem>
<LevelItem><span /></LevelItem>
<LevelItem id={'shared-with-user-message-' + row}>{this.sharedWithUsersMessage(row)}</LevelItem>
<LevelItem><span/></LevelItem>
<LevelItem><span /></LevelItem>
</Level>
</StackItem>
<StackItem isFilled>
<Level gutter='md'>
<LevelItem><span/></LevelItem>
<LevelItem>
<ShareTheResource resource={resource}
permissions={this.state.permissions.get(row)!}
sharedWithUsersMsg={this.sharedWithUsersMessage(row)}
onClose={this.fetchPermissions.bind(this)}
row={row}/>
</LevelItem>
<LevelItem>
<EditTheResource resource={resource} permissions={this.state.permissions.get(row)!} row={row} onClose={this.fetchPermissions.bind(this)}/>
</LevelItem>
<LevelItem>
<Button
isDisabled={this.numOthers(row) < 0}
variant="link"
onClick={() => this.removeShare(resource, row)}
>
<Remove2Icon/> Remove
</Button>
</LevelItem>
<LevelItem><span/></LevelItem>
</Level>
</StackItem>
</Stack>
</DataListContent>
</DataListItem>
)})}
))}
</DataList>
);
}

View file

@ -31,19 +31,18 @@ import {
TextInput
} from '@patternfly/react-core';
import { ShareAltIcon } from '@patternfly/react-icons';
import AccountService, {HttpResponse} from '../../account-service/account.service';
import AccountService from '../../account-service/account.service';
import { Resource, Permission, Scope } from './MyResourcesPage';
import { Msg } from '../../widgets/Msg';
import {ContentAlert} from '../ContentAlert';
import { PermissionSelect } from './PermissionSelect';
interface ShareTheResourceProps {
resource: Resource;
permissions: Permission[];
sharedWithUsersMsg: React.ReactNode;
onClose: (resource: Resource, row: number) => void;
row: number;
onClose: () => void;
children: (toggle: () => void) => void;
}
interface ShareTheResourceState {
@ -58,7 +57,7 @@ interface ShareTheResourceState {
* @author Stan Silvert ssilvert@redhat.com (C) 2019 Red Hat Inc.
*/
export class ShareTheResource extends React.Component<ShareTheResourceProps, ShareTheResourceState> {
protected static defaultProps = {permissions: [], row: 0};
protected static defaultProps = {permissions: []};
public constructor(props: ShareTheResourceProps) {
super(props);
@ -100,13 +99,14 @@ export class ShareTheResource extends React.Component<ShareTheResourceProps, Sha
AccountService.doPut(`/resources/${rscId}/permissions`, permissions)
.then(() => {
ContentAlert.success('shareSuccess');
this.props.onClose(this.props.resource, this.props.row);
this.props.onClose();
})
};
private handleToggleDialog = () => {
if (this.state.isOpen) {
this.setState({isOpen: false});
this.props.onClose();
} else {
this.clearState();
this.setState({isOpen: true});
@ -136,21 +136,6 @@ export class ShareTheResource extends React.Component<ShareTheResourceProps, Sha
this.setState({usernames: newUsernames});
}
private handleSelectPermission = (selectedPermission: Scope) => {
let newPermissionsSelected: Scope[] = this.state.permissionsSelected;
let newPermissionsUnSelected: Scope[] = this.state.permissionsUnSelected;
if (newPermissionsSelected.includes(selectedPermission)) {
newPermissionsSelected = newPermissionsSelected.filter(permission => permission !== selectedPermission);
newPermissionsUnSelected.push(selectedPermission);
} else {
newPermissionsUnSelected = newPermissionsUnSelected.filter(permission => permission !== selectedPermission);
newPermissionsSelected.push(selectedPermission);
}
this.setState({permissionsSelected: newPermissionsSelected, permissionsUnSelected: newPermissionsUnSelected});
}
private isAddDisabled(): boolean {
return this.state.usernameInput === '' || this.isAlreadyShared();
}
@ -168,12 +153,9 @@ export class ShareTheResource extends React.Component<ShareTheResourceProps, Sha
}
public render(): React.ReactNode {
return (
<React.Fragment>
<Button variant="link" onClick={this.handleToggleDialog}>
<ShareAltIcon/> Share
</Button>
{this.props.children(this.handleToggleDialog)}
<Modal
title={'Share the resource - ' + this.props.resource.name}
@ -207,7 +189,7 @@ export class ShareTheResource extends React.Component<ShareTheResourceProps, Sha
isValid={!this.isAlreadyShared()}
id="username"
aria-describedby="username-helper"
placeholder="username or email"
placeholder="Username or email"
onChange={this.handleUsernameChange}
onKeyPress={this.handleEnterKeyInAddField}
/>
@ -233,30 +215,11 @@ export class ShareTheResource extends React.Component<ShareTheResourceProps, Sha
label=""
fieldId="permissions-selected"
>
{this.state.permissionsSelected.length < 1 && <strong>Select permissions below:</strong>}
<ChipGroup withToolbar>
<ChipGroupToolbarItem key='permissions-selected' categoryName='Grant Permissions '>
{this.state.permissionsSelected.map((currentChip: Scope) => (
<Chip key={currentChip.toString()} onClick={() => this.handleSelectPermission(currentChip)}>
{currentChip.toString()}
</Chip>
))}
</ChipGroupToolbarItem>
</ChipGroup>
</FormGroup>
<FormGroup
label=""
fieldId="permissions-not-selected"
>
<ChipGroup withToolbar>
<ChipGroupToolbarItem key='permissions-unselected' categoryName='Not Selected '>
{this.state.permissionsUnSelected.map((currentChip: Scope) => (
<Chip key={currentChip.toString()} onClick={() => this.handleSelectPermission(currentChip)}>
{currentChip.toString()}
</Chip>
))}
</ChipGroupToolbarItem>
</ChipGroup>
<PermissionSelect
scopes={this.state.permissionsUnSelected}
onSelect={selection => this.setState({ permissionsSelected: selection })}
direction="up"
/>
</FormGroup>
</Form>
</StackItem>

View file

@ -26,11 +26,13 @@ import {
ChipGroupToolbarItem,
Chip
} from '@patternfly/react-core';
import { RepositoryIcon } from '@patternfly/react-icons';
import {PaginatedResources, Resource} from "./MyResourcesPage";
import {PaginatedResources, Resource, Scope} from "./MyResourcesPage";
import { Msg } from '../../widgets/Msg';
import { AbstractResourcesTable, ResourcesTableState } from './AbstractResourceTable';
import EmptyMessageState from '../../widgets/EmptyMessageState';
export interface ResourcesTableProps {
resources: PaginatedResources;
@ -47,6 +49,11 @@ export class SharedResourcesTable extends AbstractResourcesTable<ResourcesTableS
}
public render(): React.ReactNode {
if (this.props.resources.data.length === 0) {
return (
<EmptyMessageState icon={RepositoryIcon} messageKey="noResourcesSharedWithYou"/>
);
}
return (
<DataList aria-label={Msg.localize('resources')}>
<DataListItem key='resource-header' aria-labelledby='resource-header'>
@ -65,7 +72,6 @@ export class SharedResourcesTable extends AbstractResourcesTable<ResourcesTableS
/>
</DataListItemRow>
</DataListItem>
{(this.props.resources.data.length === 0) && <Msg msgKey="noResourcesSharedWithYou"/>}
{this.props.resources.data.map((resource: Resource, row: number) => (
<DataListItem key={'resource-' + row} aria-labelledby={resource.name}>
<DataListItemRow>
@ -96,7 +102,7 @@ export class SharedResourcesTable extends AbstractResourcesTable<ResourcesTableS
<ChipGroup withToolbar>
<ChipGroupToolbarItem key='permissions' categoryName={Msg.localize('pending')}>
{
resource.shareRequests[0].scopes.map(scope => (
(resource.shareRequests[0].scopes as Scope[]).map(scope => (
<Chip key={scope.name} isReadOnly>
{scope.displayName || scope.name}
</Chip>

View file

@ -6,10 +6,10 @@
import * as React from 'react';
import {EmptyState, EmptyStateBody, EmptyStateIcon, Title, TitleLevel} from '@patternfly/react-core';
import { WarningTriangleIcon } from '@patternfly/react-icons';
import {withRouter, RouteComponentProps} from 'react-router-dom';
import {Msg} from '../../widgets/Msg';
import EmptyMessageState from '../../widgets/EmptyMessageState';
export interface PageNotFoundProps extends RouteComponentProps {}
@ -21,15 +21,9 @@ class PgNotFound extends React.Component<PageNotFoundProps> {
public render(): React.ReactNode {
return (
<EmptyState variant='full'>
<EmptyStateIcon icon={WarningTriangleIcon} />
<Title headingLevel={TitleLevel.h5} size="lg">
<Msg msgKey='pageNotFound'/>
</Title>
<EmptyStateBody>
<Msg msgKey='invalidRoute' params={[this.props.location.pathname]} />
</EmptyStateBody>
</EmptyState>
<EmptyMessageState icon={WarningTriangleIcon} messageKey="pageNotFound">
<Msg msgKey="invalidRoute" params={[this.props.location.pathname]} />
</EmptyMessageState>
);
}
};

View file

@ -0,0 +1,53 @@
/*
* Copyright 2018 Red Hat, Inc. and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import {
EmptyState,
EmptyStateVariant,
Title,
EmptyStateIcon,
TitleLevel,
EmptyStateBody,
IconProps,
} from '@patternfly/react-core'
import { Msg } from './Msg';
export interface EmptyMessageStateProps {
icon: React.FunctionComponent<IconProps>;
messageKey: string;
}
export default class EmptyMessageState extends React.Component<EmptyMessageStateProps, {}> {
constructor(props: EmptyMessageStateProps) {
super(props);
}
render() {
return (
<EmptyState variant={EmptyStateVariant.full}>
<EmptyStateIcon icon={this.props.icon} />
<Title headingLevel={TitleLevel.h5} size="lg">
<Msg msgKey={this.props.messageKey} />
</Title>
<EmptyStateBody>
{this.props.children}
</EmptyStateBody>
</EmptyState>
);
}
}

View file

@ -2582,9 +2582,9 @@
}
},
"@patternfly/react-styles": {
"version": "3.7.12",
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-3.7.12.tgz",
"integrity": "sha512-vTKyC78oKlrS6VTQ3GPYevc17qgxj2Ono+SCDwoMyhUexPEyXRuZHLoZA1/MkJHvSCqJHGBageBAFcRq5wb0XQ==",
"version": "3.7.14",
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-3.7.14.tgz",
"integrity": "sha512-NVwbPP9JroulfQgj0LOLWKP4DumArW8RrP1FB1lLOCuw13KkuAcFbLN9MSF8ZBwJ8syxGEdux5mDC3jPjsrQiw==",
"requires": {
"camel-case": "^3.0.0",
"css": "^2.2.3",
@ -3211,9 +3211,9 @@
},
"dependencies": {
"resolve": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz",
"integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==",
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
"integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
"requires": {
"path-parse": "^1.0.6"
}

View file

@ -17,6 +17,7 @@
"dependencies": {
"@patternfly/react-core": "^3.153.3",
"@patternfly/react-icons": "^3.15.16",
"@patternfly/react-styles": "^3.7.14",
"react": "npm:@pika/react@^16.13.1",
"react-dom": "npm:@pika/react-dom@^16.13.1",
"react-router-dom": "^4.3.1"