mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
add fields to strategies
This commit is contained in:
parent
66527bf085
commit
821bf0e19b
@ -29,9 +29,7 @@ export const HeaderTitle = ({ title, actions, subtitle }) => (
|
||||
{subtitle && <small>{subtitle}</small>}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: '1', textAlign: 'right' }}>
|
||||
{actions}
|
||||
</div>
|
||||
{actions && <div style={{ flex: '1', textAlign: 'right' }}>{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -14,9 +14,8 @@ class AddStrategy extends React.Component {
|
||||
addStrategy = (strategyName) => {
|
||||
const selectedStrategy = this.props.strategies.find(s => s.name === strategyName);
|
||||
const parameters = {};
|
||||
const keys = Object.keys(selectedStrategy.parametersTemplate || {});
|
||||
keys.forEach(prop => { parameters[prop] = ''; });
|
||||
|
||||
selectedStrategy.parameters.forEach(({ name }) => { parameters[name] = ''; });
|
||||
|
||||
this.props.addStrategy({
|
||||
name: selectedStrategy.name,
|
||||
@ -40,7 +39,9 @@ class AddStrategy extends React.Component {
|
||||
<IconButton name="add" id="strategies-add" raised accent title="Add Strategy" onClick={this.stopPropagation}/>
|
||||
<Menu target="strategies-add" valign="bottom" align="right" ripple style={menuStyle}>
|
||||
<MenuItem disabled>Add Strategy:</MenuItem>
|
||||
{this.props.strategies.map((s) => <MenuItem key={s.name} onClick={() => this.addStrategy(s.name)}>{s.name}</MenuItem>)}
|
||||
{this.props.strategies.map((s) =>
|
||||
<MenuItem key={s.name} title={s.description} onClick={() => this.addStrategy(s.name)}>{s.name}</MenuItem>)
|
||||
}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
|
@ -22,13 +22,13 @@ class StrategiesList extends React.Component {
|
||||
return <i style={{ color: 'red' }}>No strategies added</i>;
|
||||
}
|
||||
|
||||
const blocks = configuredStrategies.map((strat, i) => (
|
||||
const blocks = configuredStrategies.map((strategy, i) => (
|
||||
<ConfigureStrategy
|
||||
key={`${strat.name}-${i}`}
|
||||
strategy={strat}
|
||||
key={`${strategy.name}-${i}`}
|
||||
strategy={strategy}
|
||||
removeStrategy={this.props.removeStrategy.bind(null, i)}
|
||||
updateStrategy={this.props.updateStrategy.bind(null, i)}
|
||||
strategyDefinition={strategies.find(s => s.name === strat.name)} />
|
||||
strategyDefinition={strategies.find(s => s.name === strategy.name)} />
|
||||
));
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
|
@ -47,24 +47,23 @@ class StrategyConfigure extends React.Component {
|
||||
this.props.removeStrategy();
|
||||
}
|
||||
|
||||
renderInputFields (strategyDefinition) {
|
||||
if (strategyDefinition.parametersTemplate) {
|
||||
const keys = Object.keys(strategyDefinition.parametersTemplate);
|
||||
if (keys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return keys.map(field => {
|
||||
const type = strategyDefinition.parametersTemplate[field];
|
||||
let value = this.props.strategy.parameters[field];
|
||||
renderInputFields ({ parameters }) {
|
||||
if (parameters && parameters.length > 0) {
|
||||
return parameters.map(({ name, type, description, required }) => {
|
||||
let value = this.props.strategy.parameters[name];
|
||||
if (type === 'percentage') {
|
||||
if (value == null || (typeof value === 'string' && value === '')) {
|
||||
value = 50; // default value
|
||||
}
|
||||
return (<StrategyInputPersentage
|
||||
key={field}
|
||||
field={field}
|
||||
onChange={this.handleConfigChange.bind(this, field)}
|
||||
value={1 * value} />);
|
||||
return (
|
||||
<div key={name}>
|
||||
<StrategyInputPersentage
|
||||
name={name}
|
||||
onChange={this.handleConfigChange.bind(this, name)}
|
||||
value={1 * value} />
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
);
|
||||
} else if (type === 'list') {
|
||||
let list = [];
|
||||
if (typeof value === 'string') {
|
||||
@ -73,33 +72,44 @@ class StrategyConfigure extends React.Component {
|
||||
.split(',')
|
||||
.filter(Boolean);
|
||||
}
|
||||
return (<StrategyInputList key={field} field={field} list={list} setConfig={this.setConfig} />);
|
||||
return (
|
||||
<div key={name}>
|
||||
<StrategyInputList name={name} list={list} setConfig={this.setConfig} />
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
);
|
||||
} else if (type === 'number') {
|
||||
return (
|
||||
<Textfield
|
||||
pattern="-?[0-9]*(\.[0-9]+)?"
|
||||
error={`${field} is not a number!`}
|
||||
floatingLabel
|
||||
style={{ width: '100%' }}
|
||||
key={field}
|
||||
name={field}
|
||||
label={field}
|
||||
onChange={this.handleConfigChange.bind(this, field)}
|
||||
value={value}
|
||||
/>
|
||||
<div key={name}>
|
||||
<Textfield
|
||||
pattern="-?[0-9]*(\.[0-9]+)?"
|
||||
error={`${name} is not a number!`}
|
||||
floatingLabel
|
||||
required={required}
|
||||
style={{ width: '100%' }}
|
||||
name={name}
|
||||
label={name}
|
||||
onChange={this.handleConfigChange.bind(this, name)}
|
||||
value={value}
|
||||
/>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Textfield
|
||||
floatingLabel
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
key={field}
|
||||
name={field}
|
||||
label={field}
|
||||
onChange={this.handleConfigChange.bind(this, field)}
|
||||
value={value}
|
||||
/>
|
||||
<div key={name}>
|
||||
<Textfield
|
||||
floatingLabel
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
required={required}
|
||||
name={name}
|
||||
label={name}
|
||||
onChange={this.handleConfigChange.bind(this, name)}
|
||||
value={value}
|
||||
/>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -1,36 +1,80 @@
|
||||
import React from 'react';
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import {
|
||||
Textfield,
|
||||
IconButton,
|
||||
Chip,
|
||||
} from 'react-mdl';
|
||||
|
||||
export default ({ field, list, setConfig }) => (
|
||||
<div style={{ margin: '16px 20px' }}>
|
||||
<h6>{field}</h6>
|
||||
{list.map((entryValue, index) => (
|
||||
<Chip style={{ marginRight: '3px' }} onClose={() => {
|
||||
list[index] = null;
|
||||
setConfig(field, list.filter(Boolean).join(','));
|
||||
}}>{entryValue}</Chip>
|
||||
))}
|
||||
export default class InputList extends Component {
|
||||
|
||||
<form style={{ display: 'block', padding: 0, margin: 0 }} onSubmit={(e) => {
|
||||
static propTypes = {
|
||||
field: PropTypes.string.isRequired,
|
||||
list: PropTypes.array.isRequired,
|
||||
setConfig: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
onBlur = (e) => {
|
||||
this.setValue(e);
|
||||
window.removeEventListener('keydown', this.onKeyHandler, false);
|
||||
}
|
||||
|
||||
onFocus = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.addEventListener('keydown', this.onKeyHandler, false);
|
||||
}
|
||||
|
||||
onKeyHandler = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.setValue();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
setValue = (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const { field, list, setConfig } = this.props;
|
||||
const inputValue = document.querySelector(`[name="${field}_input"]`);
|
||||
if (inputValue && inputValue.value) {
|
||||
list.push(inputValue.value);
|
||||
inputValue.value = '';
|
||||
setConfig(field, list.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
onClose (index) {
|
||||
const { field, list, setConfig } = this.props;
|
||||
list[index] = null;
|
||||
setConfig(field, list.length === 1 ? '' : list.filter(Boolean).join(','));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { name, list } = this.props;
|
||||
return (<div>
|
||||
<p>{name}</p>
|
||||
{list.map((entryValue, index) => (
|
||||
<Chip
|
||||
key={index + entryValue}
|
||||
style={{ marginRight: '3px' }}
|
||||
onClose={() => this.onClose(index)}>{entryValue}</Chip>
|
||||
))}
|
||||
|
||||
const inputValue = document.querySelector(`[name="${field}_input"]`);
|
||||
if (inputValue && inputValue.value) {
|
||||
list.push(inputValue.value);
|
||||
inputValue.value = '';
|
||||
setConfig(field, list.join(','));
|
||||
}
|
||||
}}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Textfield name={`${field}_input`} style={{ width: '100%', flex: 1 }} floatingLabel label="Add list entry" />
|
||||
<IconButton name="add" raised style={{ flex: 1, flexGrow: 0, margin: '20px 0 0 10px' }}/>
|
||||
<Textfield
|
||||
name={`${name}_input`}
|
||||
style={{ width: '100%', flex: 1 }}
|
||||
floatingLabel
|
||||
label="Add list entry"
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur} />
|
||||
<IconButton name="add" raised style={{ flex: 1, flexGrow: 0, margin: '20px 0 0 10px' }} onClick={this.setValue} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
@ -8,9 +8,9 @@ const labelStyle = {
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
export default ({ field, value, onChange }) => (
|
||||
export default ({ name, value, onChange }) => (
|
||||
<div>
|
||||
<div style={labelStyle}>{field}: {value}%</div>
|
||||
<Slider min={0} max={100} defaultValue={value} value={value} onChange={onChange} label={field} />
|
||||
<div style={labelStyle}>{name}: {value}%</div>
|
||||
<Slider min={0} max={100} defaultValue={value} value={value} onChange={onChange} label={name} />
|
||||
</div>
|
||||
);
|
||||
|
@ -57,8 +57,8 @@ export function createActions ({ id, prepare = (v) => v }) {
|
||||
dispatch(createPop({ id: getId(id, ownProps), key, index }));
|
||||
},
|
||||
|
||||
updateInList (key, index, newValue) {
|
||||
dispatch(createUp({ id: getId(id, ownProps), key, index, newValue }));
|
||||
updateInList (key, index, newValue, merge = false) {
|
||||
dispatch(createUp({ id: getId(id, ownProps), key, index, newValue, merge }));
|
||||
},
|
||||
|
||||
incValue (key) {
|
||||
|
@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
||||
import { createMapper, createActions } from '../input-helpers';
|
||||
import { createStrategy } from '../../store/strategy/actions';
|
||||
|
||||
import AddStrategy, { PARAM_PREFIX, TYPE_PREFIX } from './add-strategy';
|
||||
import AddStrategy from './add-strategy';
|
||||
|
||||
const ID = 'add-strategy';
|
||||
|
||||
@ -12,15 +12,28 @@ const prepare = (methods, dispatch) => {
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const parametersTemplate = {};
|
||||
Object.keys(input).forEach(key => {
|
||||
if (key.startsWith(PARAM_PREFIX)) {
|
||||
parametersTemplate[input[key]] = input[key.replace(PARAM_PREFIX, TYPE_PREFIX)] || 'string';
|
||||
}
|
||||
});
|
||||
input.parametersTemplate = parametersTemplate;
|
||||
|
||||
createStrategy(input)(dispatch)
|
||||
|
||||
// clean
|
||||
const parameters = input.parameters
|
||||
.filter((name) => !!name)
|
||||
.map(({
|
||||
name,
|
||||
type = 'string',
|
||||
description = '',
|
||||
required = false,
|
||||
}) => ({
|
||||
name,
|
||||
type,
|
||||
description,
|
||||
required,
|
||||
}));
|
||||
|
||||
createStrategy({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
parameters,
|
||||
})(dispatch)
|
||||
.then(() => methods.clear())
|
||||
// somewhat quickfix / hacky to go back..
|
||||
.then(() => window.history.back());
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
|
||||
import { Textfield, IconButton, Menu, MenuItem } from 'react-mdl';
|
||||
import { Textfield, IconButton, Menu, MenuItem, Checkbox } from 'react-mdl';
|
||||
import { HeaderTitle, FormButtons } from '../common';
|
||||
|
||||
|
||||
@ -15,40 +15,60 @@ const trim = (value) => {
|
||||
function gerArrayWithEntries (num) {
|
||||
return Array.from(Array(num));
|
||||
}
|
||||
export const PARAM_PREFIX = 'param_';
|
||||
export const TYPE_PREFIX = 'type_';
|
||||
|
||||
const genParams = (input, num = 0, setValue) => (<div>{gerArrayWithEntries(num).map((v, i) => {
|
||||
const key = `${PARAM_PREFIX}${i + 1}`;
|
||||
const typeKey = `${TYPE_PREFIX}${i + 1}`;
|
||||
return (
|
||||
<div key={key}>
|
||||
<Textfield
|
||||
style={{ width: '50%' }}
|
||||
floatingLabel
|
||||
label={`Parameter name ${i + 1}`}
|
||||
name={key}
|
||||
onChange={({ target }) => setValue(key, target.value)}
|
||||
value={input[key]} />
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<span id={`${key}-type-menu`}>
|
||||
{input[typeKey] || 'string'}
|
||||
<IconButton name="arrow_drop_down" onClick={(evt) => evt.preventDefault()} />
|
||||
</span>
|
||||
<Menu target={`${key}-type-menu`} align="right">
|
||||
<MenuItem onClick={() => setValue(typeKey, 'string')}>String</MenuItem>
|
||||
<MenuItem onClick={() => setValue(typeKey, 'percentage')}>Percentage</MenuItem>
|
||||
<MenuItem onClick={() => setValue(typeKey, 'list')}>List of values</MenuItem>
|
||||
<MenuItem onClick={() => setValue(typeKey, 'number')}>Number</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
const Parameter = ({ set, input = {}, index }) => (
|
||||
<div style={{ background: '#f1f1f1', margin: '20px 0', padding: '10px' }}>
|
||||
<Textfield
|
||||
style={{ width: '50%' }}
|
||||
floatingLabel
|
||||
label={`Parameter name ${index + 1}`}
|
||||
onChange={({ target }) => set({ name: target.value }, true)}
|
||||
value={input.name} />
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<span id={`${index}-type-menu`}>
|
||||
{input.type || 'string'}
|
||||
<IconButton name="arrow_drop_down" onClick={(evt) => evt.preventDefault()} />
|
||||
</span>
|
||||
<Menu target={`${index}-type-menu`} align="right">
|
||||
<MenuItem onClick={() => set({ type: 'string' })}>String</MenuItem>
|
||||
<MenuItem onClick={() => set({ type: 'percentage' })}>Percentage</MenuItem>
|
||||
<MenuItem onClick={() => set({ type: 'list' })}>List of values</MenuItem>
|
||||
<MenuItem onClick={() => set({ type: 'number' })}>Number</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
})}</div>);
|
||||
<Textfield
|
||||
floatingLabel
|
||||
style={{ width: '100%' }}
|
||||
rows={2}
|
||||
label={`Parameter name ${index + 1} description`}
|
||||
onChange={({ target }) => set({ description: target.value })}
|
||||
value={input.description}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Required"
|
||||
checked={!!input.required}
|
||||
onChange={() => set({ required: !input.required })}
|
||||
ripple
|
||||
defaultChecked
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Parameters = ({ input = [], count = 0, updateInList }) => (
|
||||
<div>{
|
||||
gerArrayWithEntries(count)
|
||||
.map((v, i) => <Parameter
|
||||
key={i}
|
||||
set={(v) => updateInList('parameters', i, v, true)}
|
||||
index={i}
|
||||
input={input[i]}
|
||||
/>)
|
||||
}</div>);
|
||||
|
||||
const AddStrategy = ({
|
||||
input,
|
||||
setValue,
|
||||
updateInList,
|
||||
incValue,
|
||||
// clear,
|
||||
onCancel,
|
||||
@ -78,7 +98,7 @@ const AddStrategy = ({
|
||||
</section>
|
||||
|
||||
<section style={{ margin: '0 20px' }}>
|
||||
{genParams(input, input._params, setValue)}
|
||||
<Parameters input={input.parameters} count={input._params} updateInList={updateInList} />
|
||||
<IconButton raised name="add" title="Add parameter" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
incValue('_params');
|
||||
@ -97,6 +117,7 @@ const AddStrategy = ({
|
||||
AddStrategy.propTypes = {
|
||||
input: PropTypes.object,
|
||||
setValue: PropTypes.func,
|
||||
updateInList: PropTypes.func,
|
||||
incValue: PropTypes.func,
|
||||
clear: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { List, ListItem, ListItemContent, Chip, Icon, IconButton } from 'react-mdl';
|
||||
import { List, ListItem, ListItemContent, IconButton } from 'react-mdl';
|
||||
import { HeaderTitle } from '../common';
|
||||
|
||||
class StrategiesListComponent extends Component {
|
||||
@ -14,12 +14,6 @@ class StrategiesListComponent extends Component {
|
||||
this.props.fetchStrategies();
|
||||
}
|
||||
|
||||
getParameterMap ({ parametersTemplate }) {
|
||||
return Object.keys(parametersTemplate || {}).map(k => (
|
||||
<Chip key={k}><small>{k}</small></Chip>
|
||||
));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { strategies, removeStrategy } = this.props;
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Grid, Cell } from 'react-mdl';
|
||||
|
||||
import { Grid, Cell, List, ListItem, ListItemContent } from 'react-mdl';
|
||||
import { AppsLinkList, TogglesLinkList, HeaderTitle } from '../common';
|
||||
|
||||
class ShowStrategyComponent extends Component {
|
||||
@ -16,13 +15,17 @@ class ShowStrategyComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
renderParameters (parametersTemplate) {
|
||||
if (parametersTemplate) {
|
||||
return Object.keys(parametersTemplate).map((name, i) => (
|
||||
<li key={`${name}-${i}`}><strong>{name}</strong> ({parametersTemplate[name]})</li>
|
||||
renderParameters (params) {
|
||||
if (params) {
|
||||
return params.map(({ name, type, description, required }, i) => (
|
||||
<ListItem twoLine key={`${name}-${i}`} title={required ? 'Required' : ''}>
|
||||
<ListItemContent avatar={required ? 'add' : ' '} subtitle={description}>
|
||||
{name} <small>({type})</small>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
));
|
||||
} else {
|
||||
return <li>(no params)</li>;
|
||||
return <ListItem>(no params)</ListItem>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,28 +44,28 @@ class ShowStrategyComponent extends Component {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
parametersTemplate = {},
|
||||
parameters = [],
|
||||
} = strategy;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HeaderTitle title={name} subtitle={description} />
|
||||
<Grid>
|
||||
<Cell col={4}>
|
||||
<Cell col={12}>
|
||||
<h6>Parameters</h6>
|
||||
<hr />
|
||||
<ol className="demo-list-item mdl-list">
|
||||
{this.renderParameters(parametersTemplate)}
|
||||
</ol>
|
||||
<List>
|
||||
{this.renderParameters(parameters)}
|
||||
</List>
|
||||
</Cell>
|
||||
|
||||
<Cell col={4}>
|
||||
<Cell col={6}>
|
||||
<h6>Applications using this strategy</h6>
|
||||
<hr />
|
||||
<AppsLinkList apps={applications} />
|
||||
</Cell>
|
||||
|
||||
<Cell col={4}>
|
||||
<Cell col={6}>
|
||||
<h6>Toggles using this strategy</h6>
|
||||
<hr />
|
||||
<TogglesLinkList toggles={toggles} />
|
||||
|
@ -13,7 +13,7 @@ export const createInc = ({ id, key }) => ({ type: actions.INCREMENT_VALUE, id,
|
||||
export const createSet = ({ id, key, value }) => ({ type: actions.SET_VALUE, id, key, value });
|
||||
export const createPush = ({ id, key, value }) => ({ type: actions.LIST_PUSH, id, key, value });
|
||||
export const createPop = ({ id, key, index }) => ({ type: actions.LIST_POP, id, key, index });
|
||||
export const createUp = ({ id, key, index, newValue }) => ({ type: actions.LIST_UP, id, key, index, newValue });
|
||||
export const createUp = ({ id, key, index, newValue, merge }) => ({ type: actions.LIST_UP, id, key, index, newValue, merge });
|
||||
export const createClear = ({ id }) => ({ type: actions.CLEAR, id });
|
||||
|
||||
export default actions;
|
||||
|
@ -48,11 +48,18 @@ function addToList (state, { id, key, value }) {
|
||||
return state.updateIn(id.concat([key]), (list) => list.push(value));
|
||||
}
|
||||
|
||||
function updateInList (state, { id, key, index, newValue }) {
|
||||
function updateInList (state, { id, key, index, newValue, merge }) {
|
||||
state = assertId(state, id);
|
||||
state = assertList(state, id, key);
|
||||
|
||||
return state.updateIn(id.concat([key]), (list) => list.set(index, newValue));
|
||||
return state.updateIn(id.concat([key]), (list) => {
|
||||
if (merge && list.has(index)) {
|
||||
newValue = list.get(index).merge(new $Map(newValue));
|
||||
} else if (typeof newValue !== 'string' ) {
|
||||
newValue = fromJS(newValue);
|
||||
}
|
||||
return list.set(index, newValue);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFromList (state, { id, key, index }) {
|
||||
|
Loading…
Reference in New Issue
Block a user