feat: list, create and start snapshots
This commit is contained in:
parent
55811372fb
commit
8cdf8070e8
@ -229,7 +229,13 @@ const resolvers = {
|
|||||||
startMachineFromSnapshot: (root, { id, name }) =>
|
startMachineFromSnapshot: (root, { id, name }) =>
|
||||||
api.machines.snapshots
|
api.machines.snapshots
|
||||||
.startFromSnapshot({ id, name })
|
.startFromSnapshot({ id, name })
|
||||||
.then(() => resolvers.Query.machine(null, { id }))
|
.then(() => resolvers.Query.machine(null, { id })),
|
||||||
|
|
||||||
|
deleteMachineSnapshot: async (root, { id, snapshot: name }) => {
|
||||||
|
const snapshot = await api.machines.snapshots.get({ id, name });
|
||||||
|
await api.machines.snapshots.destroy({ id, name });
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
"lodash.isstring": "^4.0.1",
|
"lodash.isstring": "^4.0.1",
|
||||||
"lodash.sortby": "^4.7.0",
|
"lodash.sortby": "^4.7.0",
|
||||||
"lunr": "^2.1.3",
|
"lunr": "^2.1.3",
|
||||||
|
"moment": "^2.19.1",
|
||||||
"normalized-styled-components": "^1.0.17",
|
"normalized-styled-components": "^1.0.17",
|
||||||
"param-case": "^2.1.1",
|
"param-case": "^2.1.1",
|
||||||
"prop-types": "^15.6.0",
|
"prop-types": "^15.6.0",
|
||||||
|
@ -5,3 +5,5 @@ export { default as Network } from './network';
|
|||||||
export { default as FirewallRule } from './firewall-rule';
|
export { default as FirewallRule } from './firewall-rule';
|
||||||
export { default as Resize } from './resize';
|
export { default as Resize } from './resize';
|
||||||
export { default as CreateSnapshot } from './create-snapshot';
|
export { default as CreateSnapshot } from './create-snapshot';
|
||||||
|
export { default as Snapshots } from './snapshots';
|
||||||
|
export { default as Snapshot } from './snapshot';
|
||||||
|
56
packages/my-joy-beta/src/components/instances/snapshot.js
Normal file
56
packages/my-joy-beta/src/components/instances/snapshot.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import titleCase from 'title-case';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardMeta,
|
||||||
|
CardAction,
|
||||||
|
CardTitle,
|
||||||
|
CardLabel,
|
||||||
|
CardView,
|
||||||
|
Checkbox,
|
||||||
|
FormGroup,
|
||||||
|
QueryBreakpoints,
|
||||||
|
StatusLoader
|
||||||
|
} from 'joyent-ui-toolkit';
|
||||||
|
|
||||||
|
const { SmallOnly, Small } = QueryBreakpoints;
|
||||||
|
|
||||||
|
const stateColor = {
|
||||||
|
QUEUED: 'blue',
|
||||||
|
CANCELED: 'grey',
|
||||||
|
FAILED: 'red',
|
||||||
|
CREATED: 'green'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ({ name, state, created, loading, last, first }) => (
|
||||||
|
<Card collapsed flat={!last} topMargin={first} bottomless={!last} gapless>
|
||||||
|
<CardView>
|
||||||
|
<CardMeta>
|
||||||
|
<CardAction>
|
||||||
|
<FormGroup name={name} reduxForm>
|
||||||
|
<Checkbox />
|
||||||
|
</FormGroup>
|
||||||
|
</CardAction>
|
||||||
|
<CardTitle>{name}</CardTitle>
|
||||||
|
<CardLabel>{moment.unix(created).fromNow()}</CardLabel>
|
||||||
|
{loading && (
|
||||||
|
<CardLabel>
|
||||||
|
<StatusLoader small />
|
||||||
|
</CardLabel>
|
||||||
|
)}
|
||||||
|
{!loading && (
|
||||||
|
<Small>
|
||||||
|
<CardLabel color={stateColor[state]}>{titleCase(state)}</CardLabel>
|
||||||
|
</Small>
|
||||||
|
)}
|
||||||
|
{!loading && (
|
||||||
|
<SmallOnly>
|
||||||
|
<CardLabel color={stateColor[state]} />
|
||||||
|
</SmallOnly>
|
||||||
|
)}
|
||||||
|
</CardMeta>
|
||||||
|
</CardView>
|
||||||
|
</Card>
|
||||||
|
);
|
162
packages/my-joy-beta/src/components/instances/snapshots.js
Normal file
162
packages/my-joy-beta/src/components/instances/snapshots.js
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Row, Col } from 'react-styled-flexboxgrid';
|
||||||
|
import forceArray from 'force-array';
|
||||||
|
import find from 'lodash.find';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FormGroup,
|
||||||
|
Input,
|
||||||
|
FormLabel,
|
||||||
|
ViewContainer,
|
||||||
|
StatusLoader,
|
||||||
|
Select,
|
||||||
|
Message,
|
||||||
|
MessageTitle,
|
||||||
|
MessageDescription,
|
||||||
|
Button,
|
||||||
|
QueryBreakpoints
|
||||||
|
} from 'joyent-ui-toolkit';
|
||||||
|
|
||||||
|
import Item from './snapshot';
|
||||||
|
|
||||||
|
const { SmallOnly, Medium } = QueryBreakpoints;
|
||||||
|
|
||||||
|
export default ({
|
||||||
|
snapshots = [],
|
||||||
|
selected = [],
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
handleChange = () => null,
|
||||||
|
onAction = () => null,
|
||||||
|
handleSubmit,
|
||||||
|
submitting = false,
|
||||||
|
pristine = true,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const allowedActions = {
|
||||||
|
delete: selected.length > 0,
|
||||||
|
start: selected.length === 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActions = ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
onAction({
|
||||||
|
name: ev.target.value,
|
||||||
|
items: selected
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _snapshots = forceArray(snapshots);
|
||||||
|
|
||||||
|
const _loading = !_snapshots.length &&
|
||||||
|
loading && (
|
||||||
|
<ViewContainer center>
|
||||||
|
<StatusLoader />
|
||||||
|
</ViewContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = _snapshots.map((snapshot, i, all) => {
|
||||||
|
const { name } = snapshot;
|
||||||
|
|
||||||
|
const isSelected = Boolean(find(selected, ['name', name]));
|
||||||
|
const isSubmitting = isSelected && submitting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={name}
|
||||||
|
{...snapshot}
|
||||||
|
last={all.length - 1 === i}
|
||||||
|
first={!i}
|
||||||
|
loading={isSubmitting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const _error = error &&
|
||||||
|
!submitting && (
|
||||||
|
<Message error>
|
||||||
|
<MessageTitle>Ooops!</MessageTitle>
|
||||||
|
<MessageDescription>{error}</MessageDescription>
|
||||||
|
</Message>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onChange={() => handleSubmit(ctx => handleChange(ctx))}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<Row between="xs">
|
||||||
|
<Col xs={8} sm={8} lg={6}>
|
||||||
|
<Row>
|
||||||
|
<Col xs={7} sm={7} md={6} lg={6}>
|
||||||
|
<FormGroup name="filter" reduxForm>
|
||||||
|
<FormLabel>Filter snapshots</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="Search for name or state"
|
||||||
|
disabled={pristine && !items.length}
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Col>
|
||||||
|
<Col xs={5} sm={3} lg={3}>
|
||||||
|
<FormGroup name="sort" reduxForm>
|
||||||
|
<FormLabel>Sort</FormLabel>
|
||||||
|
<Select disabled={!items.length} fluid>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="state">State</option>
|
||||||
|
<option value="created">Created</option>
|
||||||
|
<option value="updated">Updated</option>
|
||||||
|
</Select>
|
||||||
|
</FormGroup>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<Col xs={4} sm={4} lg={6}>
|
||||||
|
<Row end="xs">
|
||||||
|
<Col xs={6} sm={4} md={3} lg={2}>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>⁣</FormLabel>
|
||||||
|
<Select
|
||||||
|
value="actions"
|
||||||
|
disabled={!items.length || !selected.length}
|
||||||
|
onChange={handleActions}
|
||||||
|
fluid
|
||||||
|
>
|
||||||
|
<option value="actions" selected disabled>
|
||||||
|
≡
|
||||||
|
</option>
|
||||||
|
<option value="delete" disabled={!allowedActions.delete}>
|
||||||
|
Delete
|
||||||
|
</option>
|
||||||
|
<option value="start" disabled={!allowedActions.start}>
|
||||||
|
Start
|
||||||
|
</option>
|
||||||
|
</Select>
|
||||||
|
</FormGroup>
|
||||||
|
</Col>
|
||||||
|
<Col xs={6} sm={6} md={5} lg={2}>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>⁣</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
small
|
||||||
|
icon
|
||||||
|
fluid
|
||||||
|
onClick={() => onAction({ name: 'create' })}
|
||||||
|
>
|
||||||
|
<SmallOnly>+</SmallOnly>
|
||||||
|
<Medium>Create</Medium>
|
||||||
|
</Button>
|
||||||
|
</FormGroup>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{_loading}
|
||||||
|
{_error}
|
||||||
|
{items}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
@ -1,31 +1,50 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactJson from 'react-json-view';
|
import moment from 'moment';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import forceArray from 'force-array';
|
import forceArray from 'force-array';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
import { compose, graphql } from 'react-apollo';
|
import { compose, graphql } from 'react-apollo';
|
||||||
import find from 'lodash.find';
|
import find from 'lodash.find';
|
||||||
|
import sortBy from 'lodash.sortby';
|
||||||
import get from 'lodash.get';
|
import get from 'lodash.get';
|
||||||
|
|
||||||
|
import { reduxForm, stopSubmit, startSubmit, change } from 'redux-form';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ViewContainer,
|
ViewContainer,
|
||||||
Title,
|
Title,
|
||||||
StatusLoader,
|
|
||||||
Message,
|
Message,
|
||||||
MessageTitle,
|
MessageTitle,
|
||||||
MessageDescription
|
MessageDescription
|
||||||
} from 'joyent-ui-toolkit';
|
} from 'joyent-ui-toolkit';
|
||||||
|
|
||||||
import GetSnapshots from '@graphql/list-snapshots.gql';
|
import GetSnapshots from '@graphql/list-snapshots.gql';
|
||||||
|
import StartSnapshot from '@graphql/start-from-snapshot.gql';
|
||||||
|
import RemoveSnapshot from '@graphql/remove-snapshot.gql';
|
||||||
|
import { Snapshots as SnapshotsList } from '@components/instances';
|
||||||
|
import GenIndex from '@state/gen-index';
|
||||||
|
|
||||||
const Snapshots = ({ snapshots = [], loading, error }) => {
|
const SnapshotsListForm = reduxForm({
|
||||||
|
form: `snapshots-list`,
|
||||||
|
initialValues: {
|
||||||
|
sort: 'name'
|
||||||
|
}
|
||||||
|
})(SnapshotsList);
|
||||||
|
|
||||||
|
const Snapshots = ({
|
||||||
|
snapshots = [],
|
||||||
|
selected = [],
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
handleAction
|
||||||
|
}) => {
|
||||||
const _title = <Title>Snapshots</Title>;
|
const _title = <Title>Snapshots</Title>;
|
||||||
const _loading = !(loading && !forceArray(snapshots).length) ? null : (
|
|
||||||
<StatusLoader />
|
|
||||||
);
|
|
||||||
|
|
||||||
const _summary = !_loading && <ReactJson src={snapshots} />;
|
const _values = forceArray(snapshots);
|
||||||
|
const _loading = !_values.length && loading;
|
||||||
|
|
||||||
const _error = !(error && !_loading) ? null : (
|
const _error = error &&
|
||||||
|
!_loading &&
|
||||||
|
!_values.length && (
|
||||||
<Message error>
|
<Message error>
|
||||||
<MessageTitle>Ooops!</MessageTitle>
|
<MessageTitle>Ooops!</MessageTitle>
|
||||||
<MessageDescription>
|
<MessageDescription>
|
||||||
@ -35,20 +54,22 @@ const Snapshots = ({ snapshots = [], loading, error }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ViewContainer center={Boolean(_loading)} main>
|
<ViewContainer main>
|
||||||
{_title}
|
{_title}
|
||||||
{_loading}
|
|
||||||
{_error}
|
{_error}
|
||||||
{_summary}
|
<SnapshotsListForm
|
||||||
|
snapshots={_values}
|
||||||
|
loading={_loading}
|
||||||
|
onAction={handleAction}
|
||||||
|
selected={selected}
|
||||||
|
/>
|
||||||
</ViewContainer>
|
</ViewContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Snapshots.propTypes = {
|
|
||||||
loading: PropTypes.bool
|
|
||||||
};
|
|
||||||
|
|
||||||
export default compose(
|
export default compose(
|
||||||
|
graphql(StartSnapshot, { name: 'start' }),
|
||||||
|
graphql(RemoveSnapshot, { name: 'remove' }),
|
||||||
graphql(GetSnapshots, {
|
graphql(GetSnapshots, {
|
||||||
options: ({ match }) => ({
|
options: ({ match }) => ({
|
||||||
pollInterval: 1000,
|
pollInterval: 1000,
|
||||||
@ -56,14 +77,108 @@ export default compose(
|
|||||||
name: get(match, 'params.instance')
|
name: get(match, 'params.instance')
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
props: ({ data: { loading, error, variables, ...rest } }) => ({
|
props: ({ data: { loading, error, variables, ...rest } }) => {
|
||||||
snapshots: get(
|
const { name } = variables;
|
||||||
find(get(rest, 'machines', []), ['name', variables.name]),
|
const instance = find(get(rest, 'machines', []), ['name', name]);
|
||||||
|
|
||||||
|
const snapshots = get(
|
||||||
|
instance,
|
||||||
'snapshots',
|
'snapshots',
|
||||||
[]
|
[]
|
||||||
),
|
).map(({ created, updated, ...rest }) => ({
|
||||||
loading,
|
...rest,
|
||||||
error
|
created: moment.utc(created).unix(),
|
||||||
|
updated: moment.utc(updated).unix()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const index = GenIndex(
|
||||||
|
snapshots.map(({ name, ...rest }) => ({ ...rest, id: name }))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
snapshots,
|
||||||
|
instance,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
connect(
|
||||||
|
(state, { index, snapshots = [], ...rest }) => {
|
||||||
|
const form = get(state, 'form.snapshots-list.values', {});
|
||||||
|
const filter = get(form, 'filter');
|
||||||
|
const sort = get(form, 'sort');
|
||||||
|
|
||||||
|
const values = filter
|
||||||
|
? index.search(filter).map(({ ref }) => find(snapshots, ['name', ref]))
|
||||||
|
: snapshots;
|
||||||
|
|
||||||
|
const selected = Object.keys(form)
|
||||||
|
.filter(key => Boolean(form[key]))
|
||||||
|
.map(name => find(values, ['name', name]))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(({ name }) => find(snapshots, ['name', name]))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
snapshots: sortBy(values, value => get(value, sort)),
|
||||||
|
selected
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(dispatch, { create, start, remove, instance, history, match }) => ({
|
||||||
|
handleAction: ({ name, items = [] }) => {
|
||||||
|
const form = 'snapshots-list';
|
||||||
|
|
||||||
|
const types = {
|
||||||
|
start: () =>
|
||||||
|
Promise.resolve(dispatch(startSubmit(form))).then(() =>
|
||||||
|
Promise.all(
|
||||||
|
items.map(({ name }) =>
|
||||||
|
start({ variables: { id: instance.id, snapshot: name } })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
delete: () =>
|
||||||
|
Promise.resolve(dispatch(startSubmit(form))).then(() =>
|
||||||
|
Promise.all(
|
||||||
|
items.map(({ name }) =>
|
||||||
|
remove({ variables: { id: instance.id, snapshot: name } })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
create: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
history.push(`/instances/${instance.name}/snapshots/~create`)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = error => {
|
||||||
|
dispatch(
|
||||||
|
stopSubmit(form, {
|
||||||
|
_error: error.graphQLErrors
|
||||||
|
.map(({ message }) => message)
|
||||||
|
.join('\n')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
dispatch(
|
||||||
|
items
|
||||||
|
.map(({ name: field }) => change(form, field, false))
|
||||||
|
.concat([stopSubmit(form)])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
types[name] &&
|
||||||
|
types[name]()
|
||||||
|
.then(handleSuccess)
|
||||||
|
.catch(handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
)
|
||||||
)(Snapshots);
|
)(Snapshots);
|
||||||
|
@ -5,6 +5,8 @@ query instance($name: String!) {
|
|||||||
snapshots {
|
snapshots {
|
||||||
name
|
name
|
||||||
state
|
state
|
||||||
|
created
|
||||||
|
updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
5
packages/my-joy-beta/src/graphql/remove-snapshot.gql
Normal file
5
packages/my-joy-beta/src/graphql/remove-snapshot.gql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mutation deleteMachineSnapshot($id: ID!, $snapshot: ID!) {
|
||||||
|
deleteMachineSnapshot(id: $id, snapshot: $snapshot) {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
mutation startInstanceFromSnapshot($id: ID!, $snapshot: ID!) {
|
mutation startInstanceFromSnapshot($id: ID!, $snapshot: ID!) {
|
||||||
startMachineFromSnapshot(id: $id, snapshot: $snapshot)
|
startMachineFromSnapshot(id: $id, snapshot: $snapshot) {
|
||||||
|
id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -112,7 +112,7 @@ export default () => (
|
|||||||
component={InstanceCreateSnapshot}
|
component={InstanceCreateSnapshot}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/instances/:instance/:section?/~create-snapshot"
|
path="/instances/:instance/snapshots/~create"
|
||||||
exact
|
exact
|
||||||
component={InstanceCreateSnapshot}
|
component={InstanceCreateSnapshot}
|
||||||
/>
|
/>
|
||||||
|
@ -6,18 +6,18 @@ import pascalCase from 'pascal-case';
|
|||||||
|
|
||||||
export const breakpoints = {
|
export const breakpoints = {
|
||||||
small: {
|
small: {
|
||||||
upper: 768
|
upper: 767
|
||||||
},
|
},
|
||||||
medium: {
|
medium: {
|
||||||
upper: 1024,
|
upper: 1023,
|
||||||
lower: 769
|
lower: 768
|
||||||
},
|
},
|
||||||
large: {
|
large: {
|
||||||
upper: 1200,
|
upper: 1199,
|
||||||
lower: 1025
|
lower: 1024
|
||||||
},
|
},
|
||||||
xlarge: {
|
xlarge: {
|
||||||
lower: 1201
|
lower: 1200
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -17,8 +17,9 @@ const style = css`
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
margin: 0;
|
margin-bottom: ${remcalc(8)};
|
||||||
padding: ${remcalc(15)} ${remcalc(18)};
|
margin-top: ${remcalc(8)};
|
||||||
|
padding: ${remcalc(13)} ${remcalc(18)};
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
${typography.normal};
|
${typography.normal};
|
||||||
@ -154,8 +155,7 @@ const style = css`
|
|||||||
`};
|
`};
|
||||||
|
|
||||||
${is('small')`
|
${is('small')`
|
||||||
padding: ${remcalc(9)} ${remcalc(18)};
|
padding: ${remcalc(13)} ${remcalc(18)};
|
||||||
font-weight: 600;
|
|
||||||
`};
|
`};
|
||||||
|
|
||||||
${is('icon')`
|
${is('icon')`
|
||||||
@ -167,6 +167,10 @@ const style = css`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
`};
|
`};
|
||||||
|
|
||||||
|
${is('marginless')`
|
||||||
|
margin: 0;
|
||||||
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledButton = NButton.extend`
|
const StyledButton = NButton.extend`
|
||||||
|
Loading…
Reference in New Issue
Block a user