mirror of
https://github.com/kopia/kopia.git
synced 2026-03-13 11:46:55 -04:00
htmlui: added first version of UI policy editor
This commit is contained in:
@@ -32,4 +32,21 @@
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.tab-body {
|
||||
padding: 10px;
|
||||
border-left: 1px solid #ccc;
|
||||
border-right: 1px solid #ccc;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.policy-help {
|
||||
font-size: 80%;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.new-policy-panel {
|
||||
background-color: #f0f0f0;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import React, { Component } from 'react';
|
||||
import Badge from 'react-bootstrap/Badge';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import { handleChange, OptionalField, RequiredField, validateRequiredFields } from './forms';
|
||||
import { PolicyEditor } from './PolicyEditor';
|
||||
import MyTable from './Table';
|
||||
import { intervalDisplayName, sourceDisplayName, timesOfDayDisplayName } from './uiutil';
|
||||
|
||||
export class PoliciesTable extends Component {
|
||||
constructor() {
|
||||
@@ -10,9 +14,21 @@ export class PoliciesTable extends Component {
|
||||
items: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
editorTarget: null,
|
||||
selectedRowIsNew: false,
|
||||
};
|
||||
|
||||
this.editorClosed = this.editorClosed.bind(this);
|
||||
this.setNewPolicy = this.setNewPolicy.bind(this);
|
||||
this.handleChange = handleChange.bind(this);
|
||||
this.fetchPolicies = this.fetchPolicies.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchPolicies();
|
||||
}
|
||||
|
||||
fetchPolicies() {
|
||||
axios.get('/api/v1/policies').then(result => {
|
||||
this.setState({ "items": result.data.policies });
|
||||
}).catch(error => this.setState({
|
||||
@@ -20,6 +36,72 @@ export class PoliciesTable extends Component {
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
|
||||
editorClosed() {
|
||||
this.setState({
|
||||
editorTarget: null,
|
||||
});
|
||||
this.fetchPolicies();
|
||||
}
|
||||
|
||||
setNewPolicy() {
|
||||
if (!validateRequiredFields(this, ["targetHost"])) {
|
||||
return;
|
||||
}
|
||||
|
||||
function fixInput(a) {
|
||||
if (!a) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// allow both * and empty string to indicate "all"
|
||||
if (a === "*") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return a;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
editorTarget: {
|
||||
userName: fixInput(this.state.targetUsername),
|
||||
host: this.state.targetHost,
|
||||
path: fixInput(this.state.targetPath),
|
||||
},
|
||||
selectedRowIsNew: true,
|
||||
})
|
||||
}
|
||||
|
||||
policySummary(p) {
|
||||
function isEmpty(obj) {
|
||||
for(var key in obj) {
|
||||
if(obj.hasOwnProperty(key))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
let bits = [];
|
||||
if (!isEmpty(p.policy.retention)) {
|
||||
bits.push(<><Badge variant="success">retention</Badge>{' '}</>);
|
||||
}
|
||||
if (!isEmpty(p.policy.files)) {
|
||||
bits.push(<><Badge variant="primary">files</Badge>{' '}</>);
|
||||
}
|
||||
if (!isEmpty(p.policy.errorHandling)) {
|
||||
bits.push(<><Badge variant="danger">errors</Badge>{' '}</>);
|
||||
}
|
||||
if (!isEmpty(p.policy.compression)) {
|
||||
bits.push(<><Badge variant="secondary">compression</Badge>{' '}</>);
|
||||
}
|
||||
if (!isEmpty(p.policy.scheduling)) {
|
||||
bits.push(<><Badge variant="warning">scheduling</Badge>{' '}</>);
|
||||
}
|
||||
|
||||
return bits;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { items, isLoading, error } = this.state;
|
||||
if (error) {
|
||||
@@ -29,39 +111,48 @@ export class PoliciesTable extends Component {
|
||||
return <p>Loading ...</p>;
|
||||
}
|
||||
const columns = [{
|
||||
id: 'target',
|
||||
Header: 'Target',
|
||||
accessor: x => sourceDisplayName(x.target),
|
||||
Header: 'Username',
|
||||
accessor: x => x.target.userName || "*",
|
||||
}, {
|
||||
Header: 'Latest',
|
||||
accessor: 'policy.retention.keepLatest'
|
||||
Header: 'Host',
|
||||
accessor: x => x.target.host || "*",
|
||||
}, {
|
||||
Header: 'Hourly',
|
||||
accessor: 'policy.retention.keepHourly'
|
||||
Header: 'Path',
|
||||
accessor: x => x.target.path || "*",
|
||||
}, {
|
||||
Header: 'Daily',
|
||||
accessor: 'policy.retention.keepDaily'
|
||||
Header: 'Defined',
|
||||
width: 300,
|
||||
accessor: x => this.policySummary(x),
|
||||
}, {
|
||||
Header: 'Weekly',
|
||||
accessor: 'policy.retention.keepWeekly'
|
||||
}, {
|
||||
Header: 'Monthly',
|
||||
accessor: 'policy.retention.keepMonthly'
|
||||
}, {
|
||||
Header: 'Annual',
|
||||
accessor: 'policy.retention.keepAnnual'
|
||||
}, {
|
||||
id: 'interval',
|
||||
Header: 'Interval',
|
||||
accessor: x => intervalDisplayName(x.policy.scheduling.interval),
|
||||
}, {
|
||||
id: 'timesOfDay',
|
||||
Header: 'Times of Day',
|
||||
accessor: x => timesOfDayDisplayName(x.policy.scheduling.timesOfDay),
|
||||
id: 'edit',
|
||||
Header: '',
|
||||
width: 50,
|
||||
Cell: x => <Button size="sm" onClick={() => {
|
||||
this.setState({
|
||||
editorTarget: x.row.original.target,
|
||||
selectedRowIsNew: false,
|
||||
})
|
||||
}}>Edit</Button>,
|
||||
}]
|
||||
|
||||
return <div className="padded">
|
||||
<MyTable data={items} columns={columns} />
|
||||
<div className={this.state.editorTarget ? "hidden" : "normal"}>
|
||||
<MyTable data={items} columns={columns} />
|
||||
</div>
|
||||
|
||||
<div className={this.state.editorTarget ? "hidden" : "normal"}>
|
||||
<hr/>
|
||||
<div className="new-policy-panel"><Form>
|
||||
<Form.Row>
|
||||
{OptionalField(this, "Target Username", "targetUsername", {}, "Specify * all empty to target all users")}
|
||||
{RequiredField(this, "Target Host", "targetHost", {})}
|
||||
{OptionalField(this, "Target Path", "targetPath", {}, "Specify * all empty to target all filesystem paths")}
|
||||
</Form.Row>
|
||||
<Button variant="primary" onClick={this.setNewPolicy}>Set New Policy</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.editorTarget && <PolicyEditor host={this.state.editorTarget.host} userName={this.state.editorTarget.userName} path={this.state.editorTarget.path} close={this.editorClosed} isNew={this.state.selectedRowIsNew} />}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
241
htmlui/src/PolicyEditor.js
Normal file
241
htmlui/src/PolicyEditor.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import axios from 'axios';
|
||||
import React, { Component } from 'react';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import Tab from 'react-bootstrap/Tab';
|
||||
import Tabs from 'react-bootstrap/Tabs';
|
||||
import { handleChange, OptionalBoolean, OptionalNumberField, RequiredBoolean, stateProperty, StringList } from './forms';
|
||||
|
||||
function sourceDisplayName(s) {
|
||||
if (!s.host && !s.userName) {
|
||||
return "global"
|
||||
}
|
||||
|
||||
if (!s.userName) {
|
||||
return "host " + s.host;
|
||||
}
|
||||
|
||||
if (!s.path) {
|
||||
return "user " + s.userName + "@" + s.host;
|
||||
}
|
||||
|
||||
return "user " + s.userName + "@" + s.host + " path '" + s.path + "'";
|
||||
}
|
||||
|
||||
export class PolicyEditor extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
items: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
this.fetchPolicy = this.fetchPolicy.bind(this);
|
||||
this.handleChange = handleChange.bind(this);
|
||||
this.saveChanges = this.saveChanges.bind(this);
|
||||
this.isGlobal = this.isGlobal.bind(this);
|
||||
this.deletePolicy = this.deletePolicy.bind(this);
|
||||
this.snapshotURL = this.snapshotURL.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
axios.get('/api/v1/repo/algorithms').then(result => {
|
||||
this.setState({
|
||||
algorithms: result.data,
|
||||
});
|
||||
|
||||
this.fetchPolicy(this.props);
|
||||
});
|
||||
}
|
||||
|
||||
componentWillReceiveProps(props) {
|
||||
this.fetchPolicy(props);
|
||||
}
|
||||
|
||||
fetchPolicy(props) {
|
||||
if (props.isNew) {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
policy: {},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
axios.get(this.snapshotURL(props)).then(result => {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
policy: result.data,
|
||||
});
|
||||
}).catch(error => this.setState({
|
||||
error: error,
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
|
||||
saveChanges() {
|
||||
function removeEmpty(l) {
|
||||
if (!l) {
|
||||
return l;
|
||||
}
|
||||
|
||||
let result = [];
|
||||
for (let i = 0; i < l.length; i++) {
|
||||
const s = l[i];
|
||||
if (s === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(s);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// clean up policy before saving
|
||||
let policy = JSON.parse(JSON.stringify(this.state.policy));
|
||||
if (policy.files) {
|
||||
if (policy.files.ignore) {
|
||||
policy.files.ignore = removeEmpty(policy.files.ignore)
|
||||
}
|
||||
if (policy.files.ignoreDotFiles) {
|
||||
policy.files.ignoreDotFiles = removeEmpty(policy.files.ignoreDotFiles)
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.compression) {
|
||||
if (policy.compression.onlyCompress) {
|
||||
policy.compression.onlyCompress = removeEmpty(policy.compression.onlyCompress)
|
||||
}
|
||||
if (policy.compression.neverCompress) {
|
||||
policy.compression.neverCompress = removeEmpty(policy.compression.neverCompress)
|
||||
}
|
||||
}
|
||||
|
||||
axios.put(this.snapshotURL(this.props), policy).then(result => {
|
||||
this.props.close();
|
||||
}).catch(error => {
|
||||
alert('Error saving snapshot: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
deletePolicy() {
|
||||
if (window.confirm('Are you sure you want to delete this policy?')) {
|
||||
axios.delete(this.snapshotURL(this.props)).then(result => {
|
||||
this.props.close();
|
||||
}).catch(error => {
|
||||
alert('Delete error: ' + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
snapshotURL(props) {
|
||||
return '/api/v1/policy?host=' + props.host + '&userName=' + props.userName + '&path=' + props.path;
|
||||
}
|
||||
|
||||
isGlobal() {
|
||||
return !this.props.host && !this.props.userName && !this.props.path;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, error } = this.state;
|
||||
if (error) {
|
||||
return <p>{error.message}</p>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p>Loading ...</p>;
|
||||
}
|
||||
|
||||
return <div className="padded">
|
||||
<h3>{this.props.isNew && "New "}Policy: {sourceDisplayName(this.props)}</h3>
|
||||
|
||||
<Tabs defaultActiveKey="retention">
|
||||
<Tab eventKey="retention" title="Retention">
|
||||
<div className="tab-body">
|
||||
<p className="policy-help">Controls how many latest snapshots to keep per source directory</p>
|
||||
<Form.Row>
|
||||
{OptionalNumberField(this, "Latest", "policy.retention.keepLatest", { placeholder: "# of latest snapshots" })}
|
||||
{OptionalNumberField(this, "Hourly", "policy.retention.keepHourly", { placeholder: "# of hourly snapshots" })}
|
||||
{OptionalNumberField(this, "Daily", "policy.retention.keepDaily", { placeholder: "# of daily snapshots" })}
|
||||
</Form.Row>
|
||||
<Form.Row>
|
||||
{OptionalNumberField(this, "Weekly", "policy.retention.keepWeekly", { placeholder: "# of weekly snapshots" })}
|
||||
{OptionalNumberField(this, "Monthly", "policy.retention.keepMonthly", { placeholder: "# of monthly snapshots" })}
|
||||
{OptionalNumberField(this, "Annual", "policy.retention.keepAnnual", { placeholder: "# of annual snapshots" })}
|
||||
</Form.Row>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="files" title="Files">
|
||||
<div className="tab-body">
|
||||
<p className="policy-help">Controls which files should be included and excluded when snapshotting. Use <a href="https://git-scm.com/docs/gitignore">.gitignore</a> syntax.</p>
|
||||
<Form.Row>
|
||||
{StringList(this, "Ignore Rules", "policy.files.ignore", "List of file name patterns to ignore.")}
|
||||
{StringList(this, "Ignore Rule Files", "policy.files.ignoreDotFiles", "List of additional files containing ignore rules. Each file configures ignore rules for the directory and its subdirectories.")}
|
||||
</Form.Row>
|
||||
<Form.Row>
|
||||
{RequiredBoolean(this, "Ignore Parent Rules", "policy.files.noParentIgnore")}
|
||||
{RequiredBoolean(this, "Ignore Parent Rule Files", "policy.files.noParentDotFiles")}
|
||||
</Form.Row>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="errors" title="Errors">
|
||||
<div class="tab-body">
|
||||
<p className="policy-help">Controls how errors detected while snapshotting are handled.</p>
|
||||
<Form.Row>
|
||||
{OptionalBoolean(this, "Ignore Directory Errors", "policy.errorHandling.ignoreDirectoryErrors", "inherit from parent")}
|
||||
{OptionalBoolean(this, "Ignore File Errors", "policy.errorHandling.ignoreFileErrors", "inherit from parent")}
|
||||
</Form.Row>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="compression" title="Compression">
|
||||
<div class="tab-body">
|
||||
<p className="policy-help">Controls which files are compressed.</p>
|
||||
<Form.Row>
|
||||
<Form.Group as={Col}>
|
||||
<Form.Label className="required">Compression</Form.Label>
|
||||
<Form.Control as="select"
|
||||
name="policy.compression.compressorName"
|
||||
onChange={this.handleChange}
|
||||
value={stateProperty(this, "policy.compression.compressorName")}>
|
||||
<option value="">(none)</option>
|
||||
{this.state.algorithms && this.state.algorithms.compression.map(x => <option key={x} value={x}>{x}</option>)}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
{OptionalNumberField(this, "Min File Size", "policy.compression.minSize", { placeholder: "minimum file size to compress" })}
|
||||
{OptionalNumberField(this, "Max File Size", "policy.compression.maxSize", { placeholder: "maximum file size to compress" })}
|
||||
</Form.Row>
|
||||
<Form.Row>
|
||||
{StringList(this, "Only Compress Extensions", "policy.compression.onlyCompress", "Only compress files with the above file extensions (one extension per line)")}
|
||||
{StringList(this, "Never Compress Extensions", "policy.compression.neverCompress", "Never compress the above file extensions (one extension per line)")}
|
||||
</Form.Row>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="scheduling" title="Scheduling">
|
||||
<div class="tab-body">
|
||||
<p className="policy-help">Controls when snapshots are automatically created.</p>
|
||||
<Form.Row>
|
||||
{OptionalNumberField(this, "Snapshot Interval", "policy.scheduling.intervalSeconds", { placeholder: "seconds" })}
|
||||
</Form.Row>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Form.Row>
|
||||
{RequiredBoolean(this, "Disable Parent Policy Evaluation (prevents any parent policies from affecting this directory and subdirectories)", "policy.noParent")}
|
||||
</Form.Row>
|
||||
|
||||
<Button variant="success" onClick={this.saveChanges}>Save Policy</Button>
|
||||
{!this.props.isNew && <>
|
||||
<Button variant="danger" disabled={this.isGlobal()} onClick={this.deletePolicy}>Delete Policy</Button>
|
||||
</>}
|
||||
|
||||
<Button variant="dark" onClick={this.props.close}>Back To List</Button>
|
||||
<hr />
|
||||
<h5>JSON representation</h5>
|
||||
<pre className="debug-json">{JSON.stringify(this.state.policy, null, 4)}
|
||||
</pre>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
@@ -24,32 +24,181 @@ export function validateRequiredFields(component, fields) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleChange(event) {
|
||||
this.setState({
|
||||
[event.target.name]: event.target.value,
|
||||
});
|
||||
export function handleChange(event, valueGetter=x=>x.value) {
|
||||
let newState = { ...this.state };
|
||||
let st = newState;
|
||||
|
||||
const parts = event.target.name.split(/\./);
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
|
||||
if (st[part] === undefined) {
|
||||
st[part] = {}
|
||||
}
|
||||
|
||||
st = st[part]
|
||||
}
|
||||
|
||||
const part = parts[parts.length - 1]
|
||||
const v = valueGetter(event.target);
|
||||
st[part] = v;
|
||||
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
export function RequiredField(component, label, name, props={}) {
|
||||
export function stateProperty(component, name) {
|
||||
let st = component.state;
|
||||
const parts = name.split(/\./);
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (st === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
st = st[part];
|
||||
}
|
||||
|
||||
return st;
|
||||
}
|
||||
|
||||
export function RequiredField(component, label, name, props = {}, helpText = null) {
|
||||
return <Form.Group as={Col}>
|
||||
<Form.Label className="required">{label}</Form.Label>
|
||||
<Form.Control
|
||||
isInvalid={component.state[name] === ''}
|
||||
isInvalid={stateProperty(component, name) === ''}
|
||||
name={name}
|
||||
value={component.state[name]}
|
||||
onChange={component.handleChange}
|
||||
value={stateProperty(component, name)}
|
||||
onChange={component.handleChange}
|
||||
{...props} />
|
||||
{helpText && <Form.Text className="text-muted">{helpText}</Form.Text>}
|
||||
<Form.Control.Feedback type="invalid">{label} Is Required</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
}
|
||||
|
||||
export function OptionalField(component, label, name, props={}) {
|
||||
export function OptionalField(component, label, name, props = {}, helpText = null) {
|
||||
return <Form.Group as={Col}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control
|
||||
name={name}
|
||||
value={component.state[name]}
|
||||
onChange={component.handleChange}
|
||||
value={stateProperty(component, name)}
|
||||
onChange={component.handleChange}
|
||||
{...props} />
|
||||
{helpText && <Form.Text className="text-muted">{helpText}</Form.Text>}
|
||||
</Form.Group>
|
||||
}
|
||||
|
||||
function valueToNumber(t) {
|
||||
if (t.value === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const v = Number.parseInt(t.value);
|
||||
if (isNaN(v)) {
|
||||
return t.value + '';
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
function isInvalidNumber(v) {
|
||||
if (v === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isNaN(Number.parseInt(v))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function OptionalNumberField(component, label, name, props = {}) {
|
||||
return <Form.Group as={Col}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control
|
||||
name={name}
|
||||
isInvalid={isInvalidNumber(stateProperty(component, name))}
|
||||
value={stateProperty(component, name)}
|
||||
onChange={e => component.handleChange(e, valueToNumber)}
|
||||
{...props} />
|
||||
<Form.Control.Feedback type="invalid">Must be a valid number or empty</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
}
|
||||
|
||||
function checkedToBool(t) {
|
||||
if (t.checked) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function RequiredBoolean(component, label, name, defaultLabel) {
|
||||
return <Form.Group as={Col}>
|
||||
<Form.Check
|
||||
label={label}
|
||||
name={name}
|
||||
checked={stateProperty(component, name)}
|
||||
onChange={e => component.handleChange(e, checkedToBool)}
|
||||
type="checkbox" />
|
||||
</Form.Group>
|
||||
}
|
||||
|
||||
function optionalBooleanValue(target) {
|
||||
if (target.value === "true") {
|
||||
return true;
|
||||
}
|
||||
if (target.value === "false") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function OptionalBoolean(component, label, name, defaultLabel) {
|
||||
return <Form.Group as={Col}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control
|
||||
name={name}
|
||||
value={stateProperty(component, name)}
|
||||
onChange={e => component.handleChange(e, optionalBooleanValue)}
|
||||
as="select">
|
||||
<option value="">{defaultLabel}</option>
|
||||
<option value="true">yes</option>
|
||||
<option value="false">no</option>
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
}
|
||||
|
||||
function listToMultilineString(v) {
|
||||
if (v) {
|
||||
return v.join("\n");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function multilineStringToList(target) {
|
||||
const v = target.value;
|
||||
if (v === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return v.split(/\n/);
|
||||
}
|
||||
|
||||
export function StringList(component, label, name, helpText) {
|
||||
return <Form.Group as={Col}>
|
||||
<Form.Label>{label}</Form.Label>
|
||||
<Form.Control
|
||||
name={name}
|
||||
value={listToMultilineString(stateProperty(component, name))}
|
||||
onChange={e => component.handleChange(e, multilineStringToList)}
|
||||
as="textarea"
|
||||
rows="5">
|
||||
</Form.Control>
|
||||
<Form.Text className="text-muted">{helpText}</Form.Text>
|
||||
</Form.Group>
|
||||
}
|
||||
@@ -1,15 +1,3 @@
|
||||
export function sourceDisplayName(s) {
|
||||
if (!s.host && !s.userName) {
|
||||
return "(all)"
|
||||
}
|
||||
|
||||
if (!s.userName) {
|
||||
return "(all)@" + s.host;
|
||||
}
|
||||
|
||||
return s.userName + "@" + s.host + ":" + s.path;
|
||||
}
|
||||
|
||||
const base10UnitPrefixes = ["", "K", "M", "G", "T"];
|
||||
|
||||
function niceNumber(f) {
|
||||
|
||||
Reference in New Issue
Block a user