mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-07 01:16:28 +02:00
Merge pull request #225 from Unleash/feat/remove_apps
This commit is contained in:
commit
c2e16e7f73
@ -30,7 +30,7 @@
|
|||||||
"build:ico": "cp public/*.ico dist/.",
|
"build:ico": "cp public/*.ico dist/.",
|
||||||
"build:img": "cp public/*.png dist/public/.",
|
"build:img": "cp public/*.png dist/public/.",
|
||||||
"start": "NODE_ENV=development webpack-dev-server --progress --colors",
|
"start": "NODE_ENV=development webpack-dev-server --progress --colors",
|
||||||
"start:heroku": "UNLEASH_API=http://unleash.herokuapp.com npm run start",
|
"start:heroku": "UNLEASH_API=https://unleash.herokuapp.com npm run start",
|
||||||
"lint": "eslint . --ext js,jsx",
|
"lint": "eslint . --ext js,jsx",
|
||||||
"lint:fix": "eslint . --ext js,jsx --fix",
|
"lint:fix": "eslint . --ext js,jsx --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
@ -23,7 +23,7 @@ exports[`renders correctly with permissions 1`] = `
|
|||||||
<react-mdl-Icon
|
<react-mdl-Icon
|
||||||
name="apps"
|
name="apps"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
test-app
|
test-app
|
||||||
</react-mdl-CardTitle>
|
</react-mdl-CardTitle>
|
||||||
<react-mdl-CardText>
|
<react-mdl-CardText>
|
||||||
@ -41,6 +41,26 @@ exports[`renders correctly with permissions 1`] = `
|
|||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</react-mdl-CardMenu>
|
</react-mdl-CardMenu>
|
||||||
|
<div>
|
||||||
|
<react-mdl-CardActions
|
||||||
|
border={true}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"alignItems": "center",
|
||||||
|
"display": "flex",
|
||||||
|
"justifyContent": "space-between",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<react-mdl-Button
|
||||||
|
accent={true}
|
||||||
|
onClick={[Function]}
|
||||||
|
title="Delete application"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</react-mdl-Button>
|
||||||
|
</react-mdl-CardActions>
|
||||||
<hr />
|
<hr />
|
||||||
<react-mdl-Tabs
|
<react-mdl-Tabs
|
||||||
activeTab={0}
|
activeTab={0}
|
||||||
@ -62,6 +82,7 @@ exports[`renders correctly with permissions 1`] = `
|
|||||||
Edit
|
Edit
|
||||||
</react-mdl-Tab>
|
</react-mdl-Tab>
|
||||||
</react-mdl-Tabs>
|
</react-mdl-Tabs>
|
||||||
|
</div>
|
||||||
<react-mdl-Grid
|
<react-mdl-Grid
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
@ -71,6 +92,7 @@ exports[`renders correctly with permissions 1`] = `
|
|||||||
>
|
>
|
||||||
<react-mdl-Cell
|
<react-mdl-Cell
|
||||||
col={6}
|
col={6}
|
||||||
|
hidePhone={true}
|
||||||
phone={12}
|
phone={12}
|
||||||
tablet={4}
|
tablet={4}
|
||||||
>
|
>
|
||||||
@ -179,7 +201,6 @@ exports[`renders correctly with permissions 1`] = `
|
|||||||
<span>
|
<span>
|
||||||
123.123.123.123
|
123.123.123.123
|
||||||
last seen at
|
last seen at
|
||||||
|
|
||||||
<small>
|
<small>
|
||||||
02/23/2017, 03:56:49 PM
|
02/23/2017, 03:56:49 PM
|
||||||
</small>
|
</small>
|
||||||
@ -214,7 +235,7 @@ exports[`renders correctly without permission 1`] = `
|
|||||||
<react-mdl-Icon
|
<react-mdl-Icon
|
||||||
name="apps"
|
name="apps"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
test-app
|
test-app
|
||||||
</react-mdl-CardTitle>
|
</react-mdl-CardTitle>
|
||||||
<react-mdl-CardText>
|
<react-mdl-CardText>
|
||||||
@ -232,7 +253,6 @@ exports[`renders correctly without permission 1`] = `
|
|||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</react-mdl-CardMenu>
|
</react-mdl-CardMenu>
|
||||||
<hr />
|
|
||||||
|
|
||||||
<react-mdl-Grid
|
<react-mdl-Grid
|
||||||
style={
|
style={
|
||||||
@ -243,6 +263,7 @@ exports[`renders correctly without permission 1`] = `
|
|||||||
>
|
>
|
||||||
<react-mdl-Cell
|
<react-mdl-Cell
|
||||||
col={6}
|
col={6}
|
||||||
|
hidePhone={true}
|
||||||
phone={12}
|
phone={12}
|
||||||
tablet={4}
|
tablet={4}
|
||||||
>
|
>
|
||||||
@ -341,7 +362,6 @@ exports[`renders correctly without permission 1`] = `
|
|||||||
<span>
|
<span>
|
||||||
123.123.123.123
|
123.123.123.123
|
||||||
last seen at
|
last seen at
|
||||||
|
|
||||||
<small>
|
<small>
|
||||||
02/23/2017, 03:56:49 PM
|
02/23/2017, 03:56:49 PM
|
||||||
</small>
|
</small>
|
||||||
|
@ -1,61 +1,13 @@
|
|||||||
/* eslint react/no-multi-comp:off */
|
/* eslint react/no-multi-comp:off */
|
||||||
import React, { Component, PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Button, Card, CardActions, CardTitle, CardText, CardMenu, Icon, ProgressBar, Tabs, Tab } from 'react-mdl';
|
||||||
import {
|
import { IconLink, styles as commonStyles } from '../common';
|
||||||
Grid,
|
|
||||||
Cell,
|
|
||||||
Card,
|
|
||||||
CardTitle,
|
|
||||||
CardText,
|
|
||||||
CardMenu,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemContent,
|
|
||||||
Textfield,
|
|
||||||
Icon,
|
|
||||||
ProgressBar,
|
|
||||||
Tabs,
|
|
||||||
Tab,
|
|
||||||
Switch,
|
|
||||||
} from 'react-mdl';
|
|
||||||
import { IconLink, shorten, styles as commonStyles } from '../common';
|
|
||||||
import { formatFullDateTimeWithLocale } from '../common/util';
|
import { formatFullDateTimeWithLocale } from '../common/util';
|
||||||
import { CREATE_FEATURE, CREATE_STRATEGY, UPDATE_APPLICATION } from '../../permissions';
|
import { UPDATE_APPLICATION } from '../../permissions';
|
||||||
import icons from './icon-names';
|
import ApplicationView from './application-view';
|
||||||
import MySelect from '../common/select';
|
import ApplicationUpdate from './application-update';
|
||||||
|
|
||||||
class StatefulTextfield extends Component {
|
|
||||||
static propTypes = {
|
|
||||||
value: PropTypes.string,
|
|
||||||
label: PropTypes.string,
|
|
||||||
rows: PropTypes.number,
|
|
||||||
onBlur: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { value: props.value };
|
|
||||||
this.setValue = function setValue(e) {
|
|
||||||
this.setState({ value: e.target.value });
|
|
||||||
}.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Textfield
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
label={this.props.label}
|
|
||||||
floatingLabel
|
|
||||||
rows={this.props.rows}
|
|
||||||
value={this.state.value}
|
|
||||||
onChange={this.setValue}
|
|
||||||
onBlur={this.props.onBlur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ClientApplications extends PureComponent {
|
class ClientApplications extends PureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@ -64,154 +16,52 @@ class ClientApplications extends PureComponent {
|
|||||||
application: PropTypes.object,
|
application: PropTypes.object,
|
||||||
location: PropTypes.object,
|
location: PropTypes.object,
|
||||||
storeApplicationMetaData: PropTypes.func.isRequired,
|
storeApplicationMetaData: PropTypes.func.isRequired,
|
||||||
|
deleteApplication: PropTypes.func.isRequired,
|
||||||
hasPermission: PropTypes.func.isRequired,
|
hasPermission: PropTypes.func.isRequired,
|
||||||
|
history: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor() {
|
||||||
super(props);
|
super();
|
||||||
this.state = { activeTab: 0 };
|
this.state = { activeTab: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.fetchApplication(this.props.appName);
|
this.props.fetchApplication(this.props.appName);
|
||||||
}
|
}
|
||||||
formatFullDateTime(v) {
|
formatFullDateTime = v => formatFullDateTimeWithLocale(v, this.props.location.locale);
|
||||||
return formatFullDateTimeWithLocale(v, this.props.location.locale);
|
|
||||||
}
|
deleteApplication = async evt => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const { deleteApplication, appName } = this.props;
|
||||||
|
await deleteApplication(appName);
|
||||||
|
this.props.history.push('/applications');
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (!this.props.application) {
|
if (!this.props.application) {
|
||||||
return <ProgressBar indeterminate />;
|
return <ProgressBar indeterminate />;
|
||||||
}
|
}
|
||||||
const { application, storeApplicationMetaData, hasPermission } = this.props;
|
const { application, storeApplicationMetaData, hasPermission } = this.props;
|
||||||
const { appName, instances, strategies, seenToggles, url, description, icon = 'apps', color } = application;
|
const { appName, instances, strategies, seenToggles, url, description, icon = 'apps' } = application;
|
||||||
|
|
||||||
const content =
|
const content =
|
||||||
this.state.activeTab === 0 ? (
|
this.state.activeTab === 0 ? (
|
||||||
<Grid style={{ margin: 0 }}>
|
<ApplicationView
|
||||||
<Cell col={6} tablet={4} phone={12}>
|
strategies={strategies}
|
||||||
<h6> Toggles</h6>
|
instances={instances}
|
||||||
<hr />
|
seenToggles={seenToggles}
|
||||||
<List>
|
hasPermission={hasPermission}
|
||||||
{seenToggles.map(({ name, description, enabled, notFound }, i) =>
|
formatFullDateTime={this.formatFullDateTime}
|
||||||
notFound ? (
|
|
||||||
<ListItem twoLine key={i}>
|
|
||||||
{hasPermission(CREATE_FEATURE) ? (
|
|
||||||
<ListItemContent icon={'report'} subtitle={'Missing, want to create?'}>
|
|
||||||
<Link to={`/features/create?name=${name}`}>{name}</Link>
|
|
||||||
</ListItemContent>
|
|
||||||
) : (
|
|
||||||
<ListItemContent icon={'report'} subtitle={'Missing'}>
|
|
||||||
{name}
|
|
||||||
</ListItemContent>
|
|
||||||
)}
|
|
||||||
</ListItem>
|
|
||||||
) : (
|
|
||||||
<ListItem twoLine key={i}>
|
|
||||||
<ListItemContent
|
|
||||||
icon={
|
|
||||||
<span>
|
|
||||||
<Switch disabled checked={!!enabled} />
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
subtitle={shorten(description, 60)}
|
|
||||||
>
|
|
||||||
<Link to={`/features/view/${name}`}>{shorten(name, 50)}</Link>
|
|
||||||
</ListItemContent>
|
|
||||||
</ListItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</Cell>
|
|
||||||
<Cell col={6} tablet={4} phone={12}>
|
|
||||||
<h6>Implemented strategies</h6>
|
|
||||||
<hr />
|
|
||||||
<List>
|
|
||||||
{strategies.map(({ name, description, notFound }, i) =>
|
|
||||||
notFound ? (
|
|
||||||
<ListItem twoLine key={`${name}-${i}`}>
|
|
||||||
{hasPermission(CREATE_STRATEGY) ? (
|
|
||||||
<ListItemContent icon={'report'} subtitle={'Missing, want to create?'}>
|
|
||||||
<Link to={`/strategies/create?name=${name}`}>{name}</Link>
|
|
||||||
</ListItemContent>
|
|
||||||
) : (
|
|
||||||
<ListItemContent icon={'report'} subtitle={'Missing'}>
|
|
||||||
{name}
|
|
||||||
</ListItemContent>
|
|
||||||
)}
|
|
||||||
</ListItem>
|
|
||||||
) : (
|
|
||||||
<ListItem twoLine key={`${name}-${i}`}>
|
|
||||||
<ListItemContent icon={'extension'} subtitle={shorten(description, 60)}>
|
|
||||||
<Link to={`/strategies/view/${name}`}>{shorten(name, 50)}</Link>
|
|
||||||
</ListItemContent>
|
|
||||||
</ListItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</Cell>
|
|
||||||
<Cell col={12} tablet={12}>
|
|
||||||
<h6>{instances.length} Instances registered</h6>
|
|
||||||
<hr />
|
|
||||||
<List>
|
|
||||||
{instances.map(({ instanceId, clientIp, lastSeen, sdkVersion }, i) => (
|
|
||||||
<ListItem key={i} twoLine>
|
|
||||||
<ListItemContent
|
|
||||||
icon="timeline"
|
|
||||||
subtitle={
|
|
||||||
<span>
|
|
||||||
{clientIp} last seen at{' '}
|
|
||||||
<small>{this.formatFullDateTime(lastSeen)}</small>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{instanceId} {sdkVersion ? `(${sdkVersion})` : ''}
|
|
||||||
</ListItemContent>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Cell>
|
|
||||||
</Grid>
|
|
||||||
) : (
|
|
||||||
<Grid>
|
|
||||||
<Cell col={12}>
|
|
||||||
<h5>Edit app meta data</h5>
|
|
||||||
</Cell>
|
|
||||||
<Cell col={6} tablet={12}>
|
|
||||||
<StatefulTextfield
|
|
||||||
value={url}
|
|
||||||
label="URL"
|
|
||||||
type="url"
|
|
||||||
onBlur={e => storeApplicationMetaData(appName, 'url', e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
<br />
|
) : (
|
||||||
<StatefulTextfield
|
<ApplicationUpdate application={application} storeApplicationMetaData={storeApplicationMetaData} />
|
||||||
value={description}
|
|
||||||
label="Description"
|
|
||||||
rows={5}
|
|
||||||
onBlur={e => storeApplicationMetaData(appName, 'description', e.target.value)}
|
|
||||||
/>
|
|
||||||
</Cell>
|
|
||||||
<Cell col={6} tablet={12}>
|
|
||||||
<MySelect
|
|
||||||
label="Icon"
|
|
||||||
options={icons.map(v => ({ name: v, label: v }))}
|
|
||||||
value={icon}
|
|
||||||
onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)}
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
<StatefulTextfield
|
|
||||||
value={color}
|
|
||||||
label="Color"
|
|
||||||
onBlur={e => storeApplicationMetaData(appName, 'color', e.target.value)}
|
|
||||||
/>
|
|
||||||
</Cell>
|
|
||||||
</Grid>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card shadow={0} className={commonStyles.fullwidth}>
|
<Card shadow={0} className={commonStyles.fullwidth}>
|
||||||
<CardTitle style={{ paddingTop: '24px', paddingRight: '64px', wordBreak: 'break-all' }}>
|
<CardTitle style={{ paddingTop: '24px', paddingRight: '64px', wordBreak: 'break-all' }}>
|
||||||
<Icon name={icon} />
|
<Icon name={icon || 'apps'} />
|
||||||
{appName}
|
{appName}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{description && <CardText>{description}</CardText>}
|
{description && <CardText>{description}</CardText>}
|
||||||
@ -220,8 +70,22 @@ class ClientApplications extends PureComponent {
|
|||||||
<IconLink url={url} icon="link" />
|
<IconLink url={url} icon="link" />
|
||||||
</CardMenu>
|
</CardMenu>
|
||||||
)}
|
)}
|
||||||
<hr />
|
|
||||||
{hasPermission(UPDATE_APPLICATION) ? (
|
{hasPermission(UPDATE_APPLICATION) ? (
|
||||||
|
<div>
|
||||||
|
<CardActions
|
||||||
|
border
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<Button accent title="Delete application" onClick={this.deleteApplication}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
<hr />
|
||||||
<Tabs
|
<Tabs
|
||||||
activeTab={this.state.activeTab}
|
activeTab={this.state.activeTab}
|
||||||
onChange={tabId => this.setState({ activeTab: tabId })}
|
onChange={tabId => this.setState({ activeTab: tabId })}
|
||||||
@ -232,6 +96,7 @@ class ClientApplications extends PureComponent {
|
|||||||
<Tab>Details</Tab>
|
<Tab>Details</Tab>
|
||||||
<Tab>Edit</Tab>
|
<Tab>Edit</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
)}
|
)}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import ApplicationEdit from './application-edit-component';
|
import ApplicationEdit from './application-edit-component';
|
||||||
import { fetchApplication, storeApplicationMetaData } from './../../store/application/actions';
|
import { fetchApplication, storeApplicationMetaData, deleteApplication } from './../../store/application/actions';
|
||||||
import { hasPermission } from '../../permissions';
|
import { hasPermission } from '../../permissions';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
const mapStateToProps = (state, props) => {
|
||||||
@ -19,6 +19,7 @@ const mapStateToProps = (state, props) => {
|
|||||||
const Constainer = connect(mapStateToProps, {
|
const Constainer = connect(mapStateToProps, {
|
||||||
fetchApplication,
|
fetchApplication,
|
||||||
storeApplicationMetaData,
|
storeApplicationMetaData,
|
||||||
|
deleteApplication,
|
||||||
})(ApplicationEdit);
|
})(ApplicationEdit);
|
||||||
|
|
||||||
export default Constainer;
|
export default Constainer;
|
||||||
|
@ -2,6 +2,7 @@ import React, { Component } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { ProgressBar, Card, CardText, Icon } from 'react-mdl';
|
import { ProgressBar, Card, CardText, Icon } from 'react-mdl';
|
||||||
import { AppsLinkList, styles as commonStyles } from '../common';
|
import { AppsLinkList, styles as commonStyles } from '../common';
|
||||||
|
import SearchField from '../common/search-field';
|
||||||
|
|
||||||
const Empty = () => (
|
const Empty = () => (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
@ -22,6 +23,8 @@ class ClientStrategies extends Component {
|
|||||||
static propTypes = {
|
static propTypes = {
|
||||||
applications: PropTypes.array,
|
applications: PropTypes.array,
|
||||||
fetchAll: PropTypes.func.isRequired,
|
fetchAll: PropTypes.func.isRequired,
|
||||||
|
settings: PropTypes.object.isRequired,
|
||||||
|
updateSetting: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@ -35,9 +38,17 @@ class ClientStrategies extends Component {
|
|||||||
return <ProgressBar indeterminate />;
|
return <ProgressBar indeterminate />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={commonStyles.toolbar}>
|
||||||
|
<SearchField
|
||||||
|
value={this.props.settings.filter}
|
||||||
|
updateValue={this.props.updateSetting.bind(this, 'filter')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Card shadow={0} className={commonStyles.fullwidth}>
|
<Card shadow={0} className={commonStyles.fullwidth}>
|
||||||
{applications.length > 0 ? <AppsLinkList apps={applications} /> : <Empty />}
|
{applications.length > 0 ? <AppsLinkList apps={applications} /> : <Empty />}
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,21 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import ApplicationList from './application-list-component';
|
import ApplicationList from './application-list-component';
|
||||||
import { fetchAll } from './../../store/application/actions';
|
import { fetchAll } from './../../store/application/actions';
|
||||||
|
import { updateSettingForGroup } from '../../store/settings/actions';
|
||||||
|
|
||||||
const mapStateToProps = state => ({ applications: state.applications.get('list').toJS() });
|
const mapStateToProps = state => {
|
||||||
|
const applications = state.applications.get('list').toJS();
|
||||||
|
const settings = state.settings.toJS().application || {};
|
||||||
|
|
||||||
const Container = connect(mapStateToProps, { fetchAll })(ApplicationList);
|
const regex = new RegExp(settings.filter, 'i');
|
||||||
|
|
||||||
|
return {
|
||||||
|
applications: settings.filter ? applications.filter(a => regex.test(a.appName)) : applications,
|
||||||
|
settings,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const mapDispatchToProps = { fetchAll, updateSetting: updateSettingForGroup('application') };
|
||||||
|
|
||||||
|
const Container = connect(mapStateToProps, mapDispatchToProps)(ApplicationList);
|
||||||
|
|
||||||
export default Container;
|
export default Container;
|
||||||
|
46
frontend/src/component/application/application-update.jsx
Normal file
46
frontend/src/component/application/application-update.jsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Grid, Cell } from 'react-mdl';
|
||||||
|
import StatefulTextfield from './stateful-textfield';
|
||||||
|
import icons from './icon-names';
|
||||||
|
import MySelect from '../common/select';
|
||||||
|
|
||||||
|
function ApplicationUpdate({ application, storeApplicationMetaData }) {
|
||||||
|
const { appName, icon, url, description } = application;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid>
|
||||||
|
<Cell col={12}>
|
||||||
|
<MySelect
|
||||||
|
label="Icon"
|
||||||
|
options={icons.map(v => ({ key: v, label: v }))}
|
||||||
|
value={icon || 'apps'}
|
||||||
|
onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
<StatefulTextfield
|
||||||
|
value={url}
|
||||||
|
label="Application URL"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
type="url"
|
||||||
|
onBlur={e => storeApplicationMetaData(appName, 'url', e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<StatefulTextfield
|
||||||
|
value={description}
|
||||||
|
label="Description"
|
||||||
|
rows={2}
|
||||||
|
onBlur={e => storeApplicationMetaData(appName, 'description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationUpdate.propTypes = {
|
||||||
|
application: PropTypes.object.isRequired,
|
||||||
|
storeApplicationMetaData: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApplicationUpdate;
|
104
frontend/src/component/application/application-view.jsx
Normal file
104
frontend/src/component/application/application-view.jsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Grid, Cell, List, ListItem, ListItemContent, Switch } from 'react-mdl';
|
||||||
|
import { shorten } from '../common';
|
||||||
|
import { CREATE_FEATURE, CREATE_STRATEGY } from '../../permissions';
|
||||||
|
|
||||||
|
function ApplicationView({ seenToggles, hasPermission, strategies, instances, formatFullDateTime }) {
|
||||||
|
return (
|
||||||
|
<Grid style={{ margin: 0 }}>
|
||||||
|
<Cell col={6} tablet={4} phone={12} hidePhone>
|
||||||
|
<h6> Toggles</h6>
|
||||||
|
<hr />
|
||||||
|
<List>
|
||||||
|
{seenToggles.map(({ name, description, enabled, notFound }, i) =>
|
||||||
|
notFound ? (
|
||||||
|
<ListItem twoLine key={i}>
|
||||||
|
{hasPermission(CREATE_FEATURE) ? (
|
||||||
|
<ListItemContent icon={'report'} subtitle={'Missing, want to create?'}>
|
||||||
|
<Link to={`/features/create?name=${name}`}>{name}</Link>
|
||||||
|
</ListItemContent>
|
||||||
|
) : (
|
||||||
|
<ListItemContent icon={'report'} subtitle={'Missing'}>
|
||||||
|
{name}
|
||||||
|
</ListItemContent>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
) : (
|
||||||
|
<ListItem twoLine key={i}>
|
||||||
|
<ListItemContent
|
||||||
|
icon={
|
||||||
|
<span>
|
||||||
|
<Switch disabled checked={!!enabled} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
subtitle={shorten(description, 60)}
|
||||||
|
>
|
||||||
|
<Link to={`/features/view/${name}`}>{shorten(name, 50)}</Link>
|
||||||
|
</ListItemContent>
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Cell>
|
||||||
|
<Cell col={6} tablet={4} phone={12}>
|
||||||
|
<h6>Implemented strategies</h6>
|
||||||
|
<hr />
|
||||||
|
<List>
|
||||||
|
{strategies.map(({ name, description, notFound }, i) =>
|
||||||
|
notFound ? (
|
||||||
|
<ListItem twoLine key={`${name}-${i}`}>
|
||||||
|
{hasPermission(CREATE_STRATEGY) ? (
|
||||||
|
<ListItemContent icon={'report'} subtitle={'Missing, want to create?'}>
|
||||||
|
<Link to={`/strategies/create?name=${name}`}>{name}</Link>
|
||||||
|
</ListItemContent>
|
||||||
|
) : (
|
||||||
|
<ListItemContent icon={'report'} subtitle={'Missing'}>
|
||||||
|
{name}
|
||||||
|
</ListItemContent>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
) : (
|
||||||
|
<ListItem twoLine key={`${name}-${i}`}>
|
||||||
|
<ListItemContent icon={'extension'} subtitle={shorten(description, 60)}>
|
||||||
|
<Link to={`/strategies/view/${name}`}>{shorten(name, 50)}</Link>
|
||||||
|
</ListItemContent>
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Cell>
|
||||||
|
<Cell col={12} tablet={12}>
|
||||||
|
<h6>{instances.length} Instances registered</h6>
|
||||||
|
<hr />
|
||||||
|
<List>
|
||||||
|
{instances.map(({ instanceId, clientIp, lastSeen, sdkVersion }, i) => (
|
||||||
|
<ListItem key={i} twoLine>
|
||||||
|
<ListItemContent
|
||||||
|
icon="timeline"
|
||||||
|
subtitle={
|
||||||
|
<span>
|
||||||
|
{clientIp} last seen at <small>{formatFullDateTime(lastSeen)}</small>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{instanceId} {sdkVersion ? `(${sdkVersion})` : ''}
|
||||||
|
</ListItemContent>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Cell>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationView.propTypes = {
|
||||||
|
instances: PropTypes.array.isRequired,
|
||||||
|
seenToggles: PropTypes.array.isRequired,
|
||||||
|
strategies: PropTypes.array.isRequired,
|
||||||
|
hasPermission: PropTypes.func.isRequired,
|
||||||
|
formatFullDateTime: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApplicationView;
|
35
frontend/src/component/application/stateful-textfield.js
Normal file
35
frontend/src/component/application/stateful-textfield.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Textfield } from 'react-mdl';
|
||||||
|
|
||||||
|
function StatefulTextfield({ value, label, placeholder, rows, onBlur }) {
|
||||||
|
const [localValue, setLocalValue] = useState(value);
|
||||||
|
|
||||||
|
const onChange = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLocalValue(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textfield
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
label={label}
|
||||||
|
placeholder={placeholder}
|
||||||
|
floatingLabel
|
||||||
|
rows={rows}
|
||||||
|
value={localValue}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StatefulTextfield.propTypes = {
|
||||||
|
value: PropTypes.string,
|
||||||
|
label: PropTypes.string,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
rows: PropTypes.number,
|
||||||
|
onBlur: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatefulTextfield;
|
@ -86,3 +86,16 @@
|
|||||||
color: #37474f !important;
|
color: #37474f !important;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 24px 16px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
@ -11,14 +11,14 @@ export const shorten = (str, len = 50) => (str && str.length > len ? `${str.subs
|
|||||||
export const AppsLinkList = ({ apps }) => (
|
export const AppsLinkList = ({ apps }) => (
|
||||||
<List>
|
<List>
|
||||||
{apps.length > 0 &&
|
{apps.length > 0 &&
|
||||||
apps.map(({ appName, description = '-', icon }) => (
|
apps.map(({ appName, description, icon }) => (
|
||||||
<ListItem twoLine key={appName}>
|
<ListItem twoLine key={appName}>
|
||||||
<span className="mdl-list__item-primary-content" style={{ minWidth: 0 }}>
|
<span className="mdl-list__item-primary-content" style={{ minWidth: 0 }}>
|
||||||
<Icon name={icon || 'apps'} className="mdl-list__item-avatar" />
|
<Icon name={icon || 'apps'} className="mdl-list__item-avatar" />
|
||||||
<Link to={`/applications/${appName}`} className={[styles.listLink, styles.truncate].join(' ')}>
|
<Link to={`/applications/${appName}`} className={[styles.listLink, styles.truncate].join(' ')}>
|
||||||
{appName}
|
{appName}
|
||||||
<span className={['mdl-list__item-sub-title', styles.truncate].join(' ')}>
|
<span className={['mdl-list__item-sub-title', styles.truncate].join(' ')}>
|
||||||
{description}
|
{description || 'No descriptionn'}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
50
frontend/src/component/common/search-field.jsx
Normal file
50
frontend/src/component/common/search-field.jsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { debounce } from 'debounce';
|
||||||
|
import { FABButton, Icon, Textfield } from 'react-mdl';
|
||||||
|
|
||||||
|
function SearchField({ value, updateValue }) {
|
||||||
|
const [localValue, setLocalValue] = useState(value);
|
||||||
|
const debounceUpdateValue = debounce(updateValue, 500);
|
||||||
|
|
||||||
|
const handleCange = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const v = e.target.value || '';
|
||||||
|
setLocalValue(v);
|
||||||
|
debounceUpdateValue(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
updateValue(localValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNow = () => {
|
||||||
|
updateValue(localValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Textfield
|
||||||
|
floatingLabel
|
||||||
|
value={localValue}
|
||||||
|
onChange={handleCange}
|
||||||
|
onBlur={updateNow}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
label="Search"
|
||||||
|
style={{ width: '500px', maxWidth: '80%' }}
|
||||||
|
/>
|
||||||
|
<FABButton mini className={'mdl-cell--hide-phone'}>
|
||||||
|
<Icon name="search" />
|
||||||
|
</FABButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField.propTypes = {
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
updateValue: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchField;
|
@ -5,16 +5,29 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
<div
|
<div
|
||||||
className="toolbar"
|
className="toolbar"
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
<react-mdl-Textfield
|
<react-mdl-Textfield
|
||||||
floatingLabel={true}
|
floatingLabel={true}
|
||||||
label="Search"
|
label="Search"
|
||||||
|
onBlur={[Function]}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
|
onKeyPress={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"width": "100%",
|
"maxWidth": "80%",
|
||||||
|
"width": "500px",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<react-mdl-FABButton
|
||||||
|
className="mdl-cell--hide-phone"
|
||||||
|
mini={true}
|
||||||
|
>
|
||||||
|
<react-mdl-Icon
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
</react-mdl-FABButton>
|
||||||
|
</div>
|
||||||
<a
|
<a
|
||||||
className="toolbarButton"
|
className="toolbarButton"
|
||||||
href="/features/create"
|
href="/features/create"
|
||||||
@ -190,16 +203,29 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
|||||||
<div
|
<div
|
||||||
className="toolbar"
|
className="toolbar"
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
<react-mdl-Textfield
|
<react-mdl-Textfield
|
||||||
floatingLabel={true}
|
floatingLabel={true}
|
||||||
label="Search"
|
label="Search"
|
||||||
|
onBlur={[Function]}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
|
onKeyPress={[Function]}
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"width": "100%",
|
"maxWidth": "80%",
|
||||||
|
"width": "500px",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<react-mdl-FABButton
|
||||||
|
className="mdl-cell--hide-phone"
|
||||||
|
mini={true}
|
||||||
|
>
|
||||||
|
<react-mdl-Icon
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
</react-mdl-FABButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<react-mdl-Card
|
<react-mdl-Card
|
||||||
|
@ -1,15 +1,3 @@
|
|||||||
.toolbar {
|
|
||||||
position: relative;
|
|
||||||
padding: 0 104px 16px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarButton {
|
|
||||||
position: absolute;
|
|
||||||
top: 56px;
|
|
||||||
right: 24px;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listItemMetric {
|
.listItemMetric {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
@ -2,10 +2,10 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { debounce } from 'debounce';
|
import { debounce } from 'debounce';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Icon, FABButton, Textfield, Menu, MenuItem, Card, CardActions, List } from 'react-mdl';
|
import { Icon, FABButton, Menu, MenuItem, Card, CardActions, List } from 'react-mdl';
|
||||||
import Feature from './feature-list-item-component';
|
import Feature from './feature-list-item-component';
|
||||||
import { MenuItemWithIcon, DropdownButton, styles as commonStyles } from '../common';
|
import { MenuItemWithIcon, DropdownButton, styles as commonStyles } from '../common';
|
||||||
import styles from './feature.scss';
|
import SearchField from '../common/search-field';
|
||||||
import { CREATE_FEATURE } from '../../permissions';
|
import { CREATE_FEATURE } from '../../permissions';
|
||||||
|
|
||||||
export default class FeatureListComponent extends React.Component {
|
export default class FeatureListComponent extends React.Component {
|
||||||
@ -59,18 +59,13 @@ export default class FeatureListComponent extends React.Component {
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.toolbar}>
|
<div className={commonStyles.toolbar}>
|
||||||
<Textfield
|
<SearchField
|
||||||
floatingLabel
|
value={this.props.settings.filter}
|
||||||
value={this.state.filter}
|
updateValue={this.props.updateSetting.bind(this, 'filter')}
|
||||||
onChange={e => {
|
|
||||||
this.setFilter(e.target.value);
|
|
||||||
}}
|
|
||||||
label="Search"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
{hasPermission(CREATE_FEATURE) ? (
|
{hasPermission(CREATE_FEATURE) ? (
|
||||||
<Link to="/features/create" className={styles.toolbarButton}>
|
<Link to="/features/create" className={commonStyles.toolbarButton}>
|
||||||
<FABButton accent title="Create feature toggle">
|
<FABButton accent title="Create feature toggle">
|
||||||
<Icon name="add" />
|
<Icon name="add" />
|
||||||
</FABButton>
|
</FABButton>
|
||||||
|
@ -34,9 +34,18 @@ function storeApplicationMetaData(appName, key, value) {
|
|||||||
}).then(throwIfNotSuccess);
|
}).then(throwIfNotSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteApplication(appName) {
|
||||||
|
return fetch(`${URI}/${appName}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
}).then(throwIfNotSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
fetchApplication,
|
fetchApplication,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
fetchApplicationsWithStrategyName,
|
fetchApplicationsWithStrategyName,
|
||||||
storeApplicationMetaData,
|
storeApplicationMetaData,
|
||||||
|
deleteApplication,
|
||||||
};
|
};
|
||||||
|
@ -2,10 +2,11 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ApplicationEditComponent from '../../component/application/application-edit-container';
|
import ApplicationEditComponent from '../../component/application/application-edit-container';
|
||||||
|
|
||||||
const render = ({ match: { params } }) => <ApplicationEditComponent appName={params.name} />;
|
const render = ({ match: { params }, history }) => <ApplicationEditComponent appName={params.name} history={history} />;
|
||||||
|
|
||||||
render.propTypes = {
|
render.propTypes = {
|
||||||
match: PropTypes.object.isRequired,
|
match: PropTypes.object.isRequired,
|
||||||
|
history: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default render;
|
export default render;
|
||||||
|
@ -7,6 +7,8 @@ export const ERROR_UPDATING_APPLICATION_DATA = 'ERROR_UPDATING_APPLICATION_DATA'
|
|||||||
|
|
||||||
export const RECEIVE_APPLICATION = 'RECEIVE_APPLICATION';
|
export const RECEIVE_APPLICATION = 'RECEIVE_APPLICATION';
|
||||||
export const UPDATE_APPLICATION_FIELD = 'UPDATE_APPLICATION_FIELD';
|
export const UPDATE_APPLICATION_FIELD = 'UPDATE_APPLICATION_FIELD';
|
||||||
|
export const DELETE_APPLICATION = 'DELETE_APPLICATION';
|
||||||
|
export const ERROR_DELETE_APPLICATION = 'ERROR_DELETE_APPLICATION';
|
||||||
|
|
||||||
const recieveAllApplications = json => ({
|
const recieveAllApplications = json => ({
|
||||||
type: RECEIVE_ALL_APPLICATIONS,
|
type: RECEIVE_ALL_APPLICATIONS,
|
||||||
@ -41,3 +43,11 @@ export function fetchApplication(appName) {
|
|||||||
.then(json => dispatch(recieveApplication(json)))
|
.then(json => dispatch(recieveApplication(json)))
|
||||||
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_ALL_APPLICATIONS));
|
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_ALL_APPLICATIONS));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteApplication(appName) {
|
||||||
|
return dispatch =>
|
||||||
|
api
|
||||||
|
.deleteApplication(appName)
|
||||||
|
.then(() => dispatch({ type: DELETE_APPLICATION, appName }))
|
||||||
|
.catch(dispatchAndThrow(dispatch, ERROR_DELETE_APPLICATION));
|
||||||
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { fromJS, List, Map } from 'immutable';
|
import { fromJS, List, Map } from 'immutable';
|
||||||
import { RECEIVE_ALL_APPLICATIONS, RECEIVE_APPLICATION, UPDATE_APPLICATION_FIELD } from './actions';
|
import { RECEIVE_ALL_APPLICATIONS, RECEIVE_APPLICATION, UPDATE_APPLICATION_FIELD, DELETE_APPLICATION } from './actions';
|
||||||
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
|
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
|
||||||
|
|
||||||
function getInitState() {
|
function getInitState() {
|
||||||
@ -14,6 +14,11 @@ const store = (state = getInitState(), action) => {
|
|||||||
return state.set('list', new List(action.value.applications));
|
return state.set('list', new List(action.value.applications));
|
||||||
case UPDATE_APPLICATION_FIELD:
|
case UPDATE_APPLICATION_FIELD:
|
||||||
return state.setIn(['apps', action.appName, action.key], action.value);
|
return state.setIn(['apps', action.appName, action.key], action.value);
|
||||||
|
case DELETE_APPLICATION: {
|
||||||
|
const index = state.get('list').findIndex(item => item.appName === action.appName);
|
||||||
|
const result = state.removeIn(['list', index]);
|
||||||
|
return result.removeIn(['apps', action.appName]);
|
||||||
|
}
|
||||||
case USER_LOGOUT:
|
case USER_LOGOUT:
|
||||||
case USER_LOGIN:
|
case USER_LOGIN:
|
||||||
return getInitState();
|
return getInitState();
|
||||||
|
Loading…
Reference in New Issue
Block a user