feat: Add tags to nova service and so on

1.Lists tags, creates, replaces tags for a server
2.Fix input autocomplete in Chrome browser
3.Fix some e2e test bugs

Change-Id: I6236da8670d36c88978317d34a50cde3974b83d9
This commit is contained in:
xusongfu 2022-03-11 13:51:19 +08:00
parent c8fee87d63
commit 81c037de41
25 changed files with 505 additions and 211 deletions

View File

@ -127,6 +127,7 @@
/opt/stack/skyline-console/test/e2e/report: logs /opt/stack/skyline-console/test/e2e/report: logs
/opt/stack/skyline-console/test/e2e/screenshots: logs /opt/stack/skyline-console/test/e2e/screenshots: logs
/opt/stack/skyline-console/test/e2e/config: logs /opt/stack/skyline-console/test/e2e/config: logs
/opt/stack/skyline-console/test/e2e/videos: logs
group-vars: group-vars:
subnode: subnode:
devstack_services: devstack_services:
@ -290,6 +291,7 @@
/opt/stack/skyline-console/test/e2e/report: logs /opt/stack/skyline-console/test/e2e/report: logs
/opt/stack/skyline-console/test/e2e/screenshots: logs /opt/stack/skyline-console/test/e2e/screenshots: logs
/opt/stack/skyline-console/test/e2e/config: logs /opt/stack/skyline-console/test/e2e/config: logs
/opt/stack/skyline-console/test/e2e/videos: logs
# octavia # octavia
/var/log/dib-build/: logs /var/log/dib-build/: logs
/var/log/octavia-tenant-traffic.log: logs /var/log/octavia-tenant-traffic.log: logs
@ -391,6 +393,7 @@
/opt/stack/skyline-console/test/e2e/report: logs /opt/stack/skyline-console/test/e2e/report: logs
/opt/stack/skyline-console/test/e2e/screenshots: logs /opt/stack/skyline-console/test/e2e/screenshots: logs
/opt/stack/skyline-console/test/e2e/config: logs /opt/stack/skyline-console/test/e2e/config: logs
/opt/stack/skyline-console/test/e2e/videos: logs
- job: - job:
name: skyline-console-devstack-e2etests-storage name: skyline-console-devstack-e2etests-storage
@ -494,6 +497,7 @@
/opt/stack/skyline-console/test/e2e/report: logs /opt/stack/skyline-console/test/e2e/report: logs
/opt/stack/skyline-console/test/e2e/screenshots: logs /opt/stack/skyline-console/test/e2e/screenshots: logs
/opt/stack/skyline-console/test/e2e/config: logs /opt/stack/skyline-console/test/e2e/config: logs
/opt/stack/skyline-console/test/e2e/videos: logs
- job: - job:
name: skyline-nodejs14-run-lint-src name: skyline-nodejs14-run-lint-src

View File

@ -2,7 +2,7 @@
"baseUrl": "http://localhost:8081", "baseUrl": "http://localhost:8081",
"viewportWidth": 1600, "viewportWidth": 1600,
"viewportHeight": 900, "viewportHeight": 900,
"video": false, "video": true,
"retries": 5, "retries": 5,
"env": { "env": {
"username": "administrator", "username": "administrator",

View File

@ -40,6 +40,11 @@ class NovaClient extends Base {
key: 'os-instance-actions', key: 'os-instance-actions',
responseKey: 'instanceAction', responseKey: 'instanceAction',
}, },
{
name: 'tags',
key: 'tags',
responseKey: 'tag',
},
], ],
extendOperations: [ extendOperations: [
{ {
@ -51,6 +56,11 @@ class NovaClient extends Base {
key: 'action', key: 'action',
method: 'post', method: 'post',
}, },
{
name: 'updateTags',
key: 'tags',
method: 'put',
},
], ],
}, },
{ {

View File

@ -602,6 +602,7 @@ export default class BaseForm extends React.Component {
onValuesChange={this.onValuesChangeForm} onValuesChange={this.onValuesChangeForm}
scrollToFirstError scrollToFirstError
> >
<input type="password" hidden autoComplete="new-password" />
<Row>{this.renderFormItems()}</Row> <Row>{this.renderFormItems()}</Row>
</Form> </Form>
); );

View File

@ -406,18 +406,27 @@ class ActionButton extends Component {
}; };
onClickModalActionCancel = (finish) => { onClickModalActionCancel = (finish) => {
if (!isBoolean(finish)) { const callback = () => {
this.formRef.current.wrappedInstance.onClickCancel(); if (!isBoolean(finish)) {
} this.formRef.current.wrappedInstance.onClickCancel();
const { onCancelAction } = this.props;
this.setState(
{
visible: false,
},
() => {
onCancelAction && onCancelAction();
} }
); const { onCancelAction } = this.props;
this.setState(
{
visible: false,
},
() => {
onCancelAction && onCancelAction();
}
);
};
const {
action: { beforeCancel },
} = this.props;
if (beforeCancel) {
return beforeCancel(callback);
}
callback();
}; };
getModalWidth = (size) => { getModalWidth = (size) => {

View File

@ -0,0 +1,173 @@
// Copyright 2021 99cloud
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React, { useEffect, useState } from 'react';
import { Col, Input, Row, Tag, Tooltip } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { projectTagsColors } from 'src/utils/constants';
import PropTypes from 'prop-types';
const Tags = ({ tags: source, onChange, maxLength, maxCount }) => {
const [tags, setTags] = useState(source);
const [inputVisible, setInputVisible] = useState(false);
const [inputValue, setInputValue] = useState('');
const [editInputIdx, setEditInputIdx] = useState(-1);
const [editInputValue, setEditInputValue] = useState('');
const tagLength = maxLength && maxLength > 0 ? { maxLength } : {};
const tagCount = (maxCount && maxCount > 0) || -1;
function handleClose(removedTag) {
setTags(tags.filter((tag) => tag !== removedTag));
}
let editInput = null;
let saveInput = null;
const saveEditInputRef = (input) => {
editInput = input;
};
const saveInputRef = (input) => {
saveInput = input;
};
function handleEditInputChange(e) {
setEditInputValue(e.target.value);
}
function handleEditInputConfirm() {
const newTags = [...tags];
newTags[editInputIdx] = editInputValue;
setTags(newTags);
setEditInputValue('');
setEditInputIdx(-1);
}
function handleInputChange(e) {
setInputValue(e.target.value);
}
function handleInputConfirm() {
const retVal = inputValue.toLocaleLowerCase();
if (inputValue && !tags.some((tag) => tag.toLowerCase() === retVal)) {
if (tagCount !== -1 && tags.length < maxCount) {
setTags([...tags, inputValue]);
} else if (tagCount === -1) {
setTags([...tags, inputValue]);
}
}
setInputVisible(false);
setInputValue('');
}
function showInput() {
setInputVisible(true);
}
useEffect(() => {
saveInput && saveInput.focus();
}, [inputVisible]);
useEffect(() => {
editInput && editInput.focus();
}, [editInputIdx]);
useEffect(() => {
onChange(tags);
}, [tags]);
return (
<Row gutter={[0, 8]}>
{tags.map((tag, index) => {
if (editInputIdx === index) {
return (
<Input
ref={saveEditInputRef}
style={{ width: 78, marginRight: 8, verticalAlign: 'top' }}
key={tag}
size="small"
value={editInputValue}
onChange={handleEditInputChange}
onBlur={handleEditInputConfirm}
onPressEnter={handleEditInputConfirm}
{...tagLength}
/>
);
}
const isLongTag = tag.length > 20;
const tagText = isLongTag ? `${tag.slice(0, 20)}...` : tag;
const tagEl = (
<Tag
key={tag}
closable
onClose={() => handleClose(tag)}
color={projectTagsColors[index % 10]}
>
<span
style={{ whiteSpace: 'pre-wrap' }}
onDoubleClick={(e) => {
setEditInputIdx(index);
setEditInputValue(tag);
e.preventDefault();
}}
>
{tagText}
</span>
</Tag>
);
return (
<Col span={24} key={tag}>
{isLongTag ? (
<Tooltip
title={<span style={{ whiteSpace: 'pre-wrap' }}>{tag}</span>}
>
{tagEl}
</Tooltip>
) : (
tagEl
)}
</Col>
);
})}
<Col span={24}>
{inputVisible && (
<Input
ref={saveInputRef}
style={{ width: 78, marginRight: 8, verticalAlign: 'top' }}
type="text"
size="small"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onPressEnter={handleInputConfirm}
{...tagLength}
/>
)}
{!inputVisible && (
<Tag onClick={showInput}>
<PlusOutlined /> New Tag
</Tag>
)}
</Col>
</Row>
);
};
Tags.propTypes = {
tags: PropTypes.array,
onChange: PropTypes.func,
maxLength: PropTypes.number,
maxCount: PropTypes.number,
};
export default Tags;

View File

@ -624,6 +624,7 @@
"ESP": "ESP", "ESP": "ESP",
"Each instance belongs to at least one security group, which needs to be specified when it is created. Instances in the same security group can communicate with each other on the network, and instances in different security groups are disconnected from the internal network by default.": "Each instance belongs to at least one security group, which needs to be specified when it is created. Instances in the same security group can communicate with each other on the network, and instances in different security groups are disconnected from the internal network by default.", "Each instance belongs to at least one security group, which needs to be specified when it is created. Instances in the same security group can communicate with each other on the network, and instances in different security groups are disconnected from the internal network by default.": "Each instance belongs to at least one security group, which needs to be specified when it is created. Instances in the same security group can communicate with each other on the network, and instances in different security groups are disconnected from the internal network by default.",
"Each new connection request is assigned to the next server in order, and all requests are finally divided equally among all servers. Commonly used for short connection services, such as HTTP services.": "Each new connection request is assigned to the next server in order, and all requests are finally divided equally among all servers. Commonly used for short connection services, such as HTTP services.", "Each new connection request is assigned to the next server in order, and all requests are finally divided equally among all servers. Commonly used for short connection services, such as HTTP services.": "Each new connection request is assigned to the next server in order, and all requests are finally divided equally among all servers. Commonly used for short connection services, such as HTTP services.",
"Each server can have up to 50 tags": "Each server can have up to 50 tags",
"East Timor": "East Timor", "East Timor": "East Timor",
"Ecuador": "Ecuador", "Ecuador": "Ecuador",
"Edit": "Edit", "Edit": "Edit",
@ -1203,6 +1204,7 @@
"Missing Port": "Missing Port", "Missing Port": "Missing Port",
"Missing Subnet": "Missing Subnet", "Missing Subnet": "Missing Subnet",
"Missing Weight": "Missing Weight", "Missing Weight": "Missing Weight",
"Modify Instance Tags": "Modify Instance Tags",
"Modify Project Tags": "Modify Project Tags", "Modify Project Tags": "Modify Project Tags",
"Modify QoS": "Modify QoS", "Modify QoS": "Modify QoS",
"Moldova": "Moldova", "Moldova": "Moldova",
@ -1849,7 +1851,10 @@
"System is error, please try again later.": "System is error, please try again later.", "System is error, please try again later.": "System is error, please try again later.",
"TCP": "TCP", "TCP": "TCP",
"TCP Connections": "TCP Connections", "TCP Connections": "TCP Connections",
"Tag Name is too long: {tag}": "Tag Name is too long: {tag}",
"Tag is no longer than 60 characters": "Tag is no longer than 60 characters",
"Tags": "Tags", "Tags": "Tags",
"Tags Info": "Tags Info",
"Tags are not case sensitive": "Tags are not case sensitive", "Tags are not case sensitive": "Tags are not case sensitive",
"Taiwan": "Taiwan", "Taiwan": "Taiwan",
"Tajikistan": "Tajikistan", "Tajikistan": "Tajikistan",
@ -2298,6 +2303,7 @@
"message.reason": "message.reason", "message.reason": "message.reason",
"metadata": "metadata", "metadata": "metadata",
"migrate": "migrate", "migrate": "migrate",
"modify instance tags": "modify instance tags",
"modify project tags": "modify project tags", "modify project tags": "modify project tags",
"network": "network", "network": "network",
"networks": "networks", "networks": "networks",

View File

@ -624,6 +624,7 @@
"ESP": "", "ESP": "",
"Each instance belongs to at least one security group, which needs to be specified when it is created. Instances in the same security group can communicate with each other on the network, and instances in different security groups are disconnected from the internal network by default.": "每个云主机至少属于一个安全组,在创建的时候就需要指定。同一安全组内的云主机之间网络互通,不同安全组的云主机之间默认内网不通。", "Each instance belongs to at least one security group, which needs to be specified when it is created. Instances in the same security group can communicate with each other on the network, and instances in different security groups are disconnected from the internal network by default.": "每个云主机至少属于一个安全组,在创建的时候就需要指定。同一安全组内的云主机之间网络互通,不同安全组的云主机之间默认内网不通。",
"Each new connection request is assigned to the next server in order, and all requests are finally divided equally among all servers. Commonly used for short connection services, such as HTTP services.": "按顺序把每个新的连接请求分配给下一个服务器最终把所有请求平分给所有的服务器。常用于短连接服务例如HTTP等服务。", "Each new connection request is assigned to the next server in order, and all requests are finally divided equally among all servers. Commonly used for short connection services, such as HTTP services.": "按顺序把每个新的连接请求分配给下一个服务器最终把所有请求平分给所有的服务器。常用于短连接服务例如HTTP等服务。",
"Each server can have up to 50 tags": "每台云主机最多绑定50个标签",
"East Timor": "东帝汶", "East Timor": "东帝汶",
"Ecuador": "厄瓜多尔", "Ecuador": "厄瓜多尔",
"Edit": "编辑", "Edit": "编辑",
@ -1203,6 +1204,7 @@
"Missing Port": "未填写端口号", "Missing Port": "未填写端口号",
"Missing Subnet": "未填写子网", "Missing Subnet": "未填写子网",
"Missing Weight": "未填写权重", "Missing Weight": "未填写权重",
"Modify Instance Tags": "修改云主机标签",
"Modify Project Tags": "修改项目标签", "Modify Project Tags": "修改项目标签",
"Modify QoS": "修改QoS", "Modify QoS": "修改QoS",
"Moldova": "摩尔多瓦", "Moldova": "摩尔多瓦",
@ -1849,7 +1851,10 @@
"System is error, please try again later.": "系统出错,请稍后再试。", "System is error, please try again later.": "系统出错,请稍后再试。",
"TCP": "", "TCP": "",
"TCP Connections": "TCP连接数", "TCP Connections": "TCP连接数",
"Tag Name is too long: {tag}": "标签名称太长: {tag}",
"Tag is no longer than 60 characters": "标签名长度不超过60个字符",
"Tags": "标签", "Tags": "标签",
"Tags Info": "标签信息",
"Tags are not case sensitive": "标签不区分大小写", "Tags are not case sensitive": "标签不区分大小写",
"Taiwan": "台湾", "Taiwan": "台湾",
"Tajikistan": "塔吉克", "Tajikistan": "塔吉克",
@ -2298,6 +2303,7 @@
"message.reason": "", "message.reason": "",
"metadata": "元数据", "metadata": "元数据",
"migrate": "迁移", "migrate": "迁移",
"modify instance tags": "修改云主机标签",
"modify project tags": "修改项目标签", "modify project tags": "修改项目标签",
"network": "网络", "network": "网络",
"networks": "网络", "networks": "网络",

View File

@ -31,7 +31,11 @@ import instanceIcon from 'asset/image/instance.svg';
import interfaceIcon from 'asset/image/interface.svg'; import interfaceIcon from 'asset/image/interface.svg';
import classnames from 'classnames'; import classnames from 'classnames';
import ImageType from 'components/ImageType'; import ImageType from 'components/ImageType';
import { instanceStatus, isIronicInstance } from 'resources/instance'; import {
instanceStatus,
isIronicInstance,
SimpleTag,
} from 'resources/instance';
import { generateId } from 'utils/index'; import { generateId } from 'utils/index';
import { getSinceTime, getLocalTimeStr } from 'utils/time'; import { getSinceTime, getLocalTimeStr } from 'utils/time';
import AttachVolume from 'pages/compute/containers/Instance/actions/AttachVolume'; import AttachVolume from 'pages/compute/containers/Instance/actions/AttachVolume';
@ -60,6 +64,7 @@ export class BaseDetail extends Base {
this.flavorCard, this.flavorCard,
this.imageCard, this.imageCard,
this.securityGroupCard, this.securityGroupCard,
this.tagsCard,
]; ];
if (!isIronicInstance(this.detailData)) { if (!isIronicInstance(this.detailData)) {
cards.push(this.serverGroupCard); cards.push(this.serverGroupCard);
@ -78,6 +83,23 @@ export class BaseDetail extends Base {
return ret; return ret;
} }
get tagsCard() {
const tags = toJS(this.detailData.tags) || [];
const content = !tags.length
? '-'
: tags.map((tag, index) => SimpleTag({ tag, index }));
const options = [
{
label: t('Tags'),
content,
},
];
return {
title: t('Tags Info'),
options,
};
}
get networkCard() { get networkCard() {
const addresses = toJS(this.detailData.addresses) || []; const addresses = toJS(this.detailData.addresses) || [];
const networks = []; const networks = [];

View File

@ -0,0 +1,113 @@
// Copyright 2021 99cloud
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from 'react';
import { inject, observer } from 'mobx-react';
import { ModalAction } from 'containers/Action';
import Tags from 'components/Tags';
import globalTagStore from 'stores/nova/tag';
import { isEqual } from 'lodash';
@inject('rootStore')
@observer
export default class ModifyTags extends ModalAction {
static id = 'modify-instance-tags';
static title = t('Modify Instance Tags');
static buttonText = t('Modify Instance Tags');
static policy = 'os_compute_api:os-server-tags:update_all';
static allowed = () => Promise.resolve(true);
get name() {
return t('modify instance tags');
}
init() {
this.state.tags = this.props.item.tags || [];
}
onSubmit = (values) => {
return globalTagStore.update({ serverId: this.props.item.id }, values);
};
get formItems() {
const { tags } = this.state;
return [
{
name: 'tags',
label: t('Tags'),
component: <Tags tags={tags} maxLength={60} maxCount={50} />,
validator: (rule, val) => {
const initialTags = this.props.item.tags || [];
// for init modal
if (isEqual(val, initialTags)) {
return Promise.resolve(true);
}
let errorTag = '';
// /
if (
val.some((tag) => {
const ret = tag.includes('/') || tag.includes(',');
ret && (errorTag = tag);
return ret;
})
) {
return Promise.reject(
new Error(t('Invalid Tag Value: {tag}', { tag: errorTag }))
);
}
//
if (initialTags.some(checkEqual)) {
return Promise.reject(
new Error(t('Duplicate tag name: {tag}', { tag: errorTag }))
);
}
return Promise.resolve(true);
function checkEqual(tag) {
return val.some((v) => {
//
const flag = tag !== v && v.toLowerCase() === tag.toLowerCase();
if (flag) {
errorTag = v;
}
return flag;
});
}
},
extra: (
<div>
<div>1. {t('Each server can have up to 50 tags')}</div>
<div>2. {t('Tags are not case sensitive')}</div>
<div>3. {t('Tag is no longer than 60 characters')}</div>
<div>
4. {t('Forward Slash / is not allowed to be in a tag name')}
</div>
<div>
5.{' '}
{t(
'Commas , are not allowed to be in a tag name in order to simplify requests that specify lists of tags'
)}
</div>
</div>
),
},
];
}
}

View File

@ -48,6 +48,7 @@ import ManageSecurityGroup from './ManageSecurityGroup';
import DeleteIronic from './DeleteIronic'; import DeleteIronic from './DeleteIronic';
import ConfirmResize from './ConfirmResize'; import ConfirmResize from './ConfirmResize';
import RevertResize from './RevertResize'; import RevertResize from './RevertResize';
import ModifyTags from './ModifyTags';
const statusActions = [ const statusActions = [
StartAction, StartAction,
@ -130,6 +131,9 @@ const actionConfigs = {
{ {
action: DeleteIronic, action: DeleteIronic,
}, },
{
action: ModifyTags,
},
], ],
}, },
batchActions, batchActions,

View File

@ -22,6 +22,7 @@ import {
lockRender, lockRender,
instanceStatusFilter, instanceStatusFilter,
isIronicInstance, isIronicInstance,
SimpleTag,
} from 'resources/instance'; } from 'resources/instance';
import globalServerStore, { ServerStore } from 'stores/nova/instance'; import globalServerStore, { ServerStore } from 'stores/nova/instance';
import { ServerGroupInstanceStore } from 'stores/skyline/server-group-instance'; import { ServerGroupInstanceStore } from 'stores/skyline/server-group-instance';
@ -205,6 +206,12 @@ export class Instance extends Base {
sorter: false, sorter: false,
render: (value) => instanceStatus[value && value.toLowerCase()] || '-', render: (value) => instanceStatus[value && value.toLowerCase()] || '-',
}, },
{
title: t('Tags'),
dataIndex: 'tags',
render: (tags) => tags.map((tag, index) => SimpleTag({ tag, index })),
isHideable: true,
},
{ {
title: t('Locked'), title: t('Locked'),
dataIndex: 'locked', dataIndex: 'locked',
@ -273,6 +280,10 @@ export class Instance extends Base {
] ]
: []), : []),
instanceStatusFilter, instanceStatusFilter,
{
label: t('Tags'),
name: 'tags',
},
]; ];
} }

View File

@ -12,13 +12,11 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import React, { useEffect, useState } from 'react'; import React from 'react';
import { inject, observer } from 'mobx-react'; import { inject, observer } from 'mobx-react';
import { ModalAction } from 'containers/Action'; import { ModalAction } from 'containers/Action';
import { Col, Input, Row, Tag, Tooltip } from 'antd'; import Tags from 'components/Tags';
import globalTagStore from 'stores/keystone/tag'; import globalTagStore from 'stores/keystone/tag';
import { PlusOutlined } from '@ant-design/icons';
import { projectTagsColors } from 'src/utils/constants';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
@inject('rootStore') @inject('rootStore')
@ -113,139 +111,3 @@ export default class ModifyTags extends ModalAction {
]; ];
} }
} }
const Tags = ({ tags: source, onChange }) => {
const [tags, setTags] = useState(source);
const [inputVisible, setInputVisible] = useState(false);
const [inputValue, setInputValue] = useState('');
const [editInputIdx, setEditInputIdx] = useState(-1);
const [editInputValue, setEditInputValue] = useState('');
function handleClose(removedTag) {
setTags(tags.filter((tag) => tag !== removedTag));
}
let editInput = null;
let saveInput = null;
const saveEditInputRef = (input) => {
editInput = input;
};
const saveInputRef = (input) => {
saveInput = input;
};
function handleEditInputChange(e) {
setEditInputValue(e.target.value);
}
function handleEditInputConfirm() {
const newTags = [...tags];
newTags[editInputIdx] = editInputValue;
setTags(newTags);
setEditInputValue('');
setEditInputIdx(-1);
}
function handleInputChange(e) {
setInputValue(e.target.value);
}
function handleInputConfirm() {
const retVal = inputValue.toLocaleLowerCase();
if (inputValue && !tags.some((tag) => tag.toLowerCase() === retVal)) {
setTags([...tags, inputValue]);
}
setInputVisible(false);
setInputValue('');
}
function showInput() {
setInputVisible(true);
}
useEffect(() => {
saveInput && saveInput.focus();
}, [inputVisible]);
useEffect(() => {
editInput && editInput.focus();
}, [editInputIdx]);
useEffect(() => {
onChange(tags);
}, [tags]);
return (
<Row gutter={[0, 8]}>
{tags.map((tag, index) => {
if (editInputIdx === index) {
return (
<Input
ref={saveEditInputRef}
style={{ width: 78, marginRight: 8, verticalAlign: 'top' }}
key={tag}
size="small"
value={editInputValue}
onChange={handleEditInputChange}
onBlur={handleEditInputConfirm}
onPressEnter={handleEditInputConfirm}
/>
);
}
const isLongTag = tag.length > 20;
const tagEl = (
<Tag
key={tag}
closable
onClose={() => handleClose(tag)}
color={projectTagsColors[index % 10]}
>
<span
style={{ whiteSpace: 'pre-wrap' }}
onDoubleClick={(e) => {
setEditInputIdx(index);
setEditInputValue(tag);
e.preventDefault();
}}
>
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
</span>
</Tag>
);
return (
<Col span={24} key={tag}>
{isLongTag ? (
<Tooltip
title={<span style={{ whiteSpace: 'pre-wrap' }}>{tag}</span>}
>
{tagEl}
</Tooltip>
) : (
tagEl
)}
</Col>
);
})}
<Col span={24}>
{inputVisible && (
<Input
ref={saveInputRef}
style={{ width: 78, marginRight: 8, verticalAlign: 'top' }}
type="text"
size="small"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onPressEnter={handleInputConfirm}
/>
)}
{!inputVisible && (
<Tag onClick={showInput}>
<PlusOutlined /> New Tag
</Tag>
)}
</Col>
</Row>
);
};

View File

@ -17,7 +17,7 @@ import { inject, observer } from 'mobx-react';
import { Select } from 'antd'; import { Select } from 'antd';
import globalProjectStore from 'stores/keystone/project'; import globalProjectStore from 'stores/keystone/project';
import { UserStore } from 'stores/keystone/user'; import { UserStore } from 'stores/keystone/user';
import globalRoleStore from 'stores/keystone/role'; import { RoleStore } from 'stores/keystone/role';
import { ModalAction } from 'containers/Action'; import { ModalAction } from 'containers/Action';
import globalDomainStore from 'stores/keystone/domain'; import globalDomainStore from 'stores/keystone/domain';
@ -34,7 +34,7 @@ export class UserManager extends ModalAction {
const projectRole = JSON.stringify(this.item.userMapProjectRoles); const projectRole = JSON.stringify(this.item.userMapProjectRoles);
this.state.domainDefault = this.item.domain_id; this.state.domainDefault = this.item.domain_id;
this.state.userRoles = JSON.parse(projectRole); this.state.userRoles = JSON.parse(projectRole);
this.store = globalRoleStore; this.store = new RoleStore();
this.domainStore = globalDomainStore; this.domainStore = globalDomainStore;
this.userStore = new UserStore(); this.userStore = new UserStore();
this.getRoleList(); this.getRoleList();

View File

@ -14,14 +14,11 @@
import React from 'react'; import React from 'react';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import { Divider, Badge, Tag, Tooltip } from 'antd'; import { Divider, Badge } from 'antd';
import Base from 'containers/List'; import Base from 'containers/List';
import globalProjectStore, { ProjectStore } from 'stores/keystone/project'; import globalProjectStore, { ProjectStore } from 'stores/keystone/project';
import { import { yesNoOptions, emptyActionConfig } from 'utils/constants';
yesNoOptions, import { SimpleTag } from 'resources/instance';
projectTagsColors,
emptyActionConfig,
} from 'utils/constants';
import actionConfigs from './actions'; import actionConfigs from './actions';
import styles from './index.less'; import styles from './index.less';
@ -124,31 +121,7 @@ export class Projects extends Base {
{ {
title: t('Tags'), title: t('Tags'),
dataIndex: 'tags', dataIndex: 'tags',
render: (tags) => render: (tags) => tags.map((tag, index) => SimpleTag({ tag, index })),
tags.map((tag, index) => {
const isLongTag = tag.length > 20;
const tagEl = (
<Tag
key={tag}
color={projectTagsColors[index % 10]}
style={{ marginTop: 8 }}
>
<span style={{ whiteSpace: 'pre-wrap' }}>
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
</span>
</Tag>
);
return isLongTag ? (
<Tooltip
key={tag}
title={<span style={{ whiteSpace: 'pre-wrap' }}>{tag}</span>}
>
{tagEl}
</Tooltip>
) : (
tagEl
);
}),
isHideable: true, isHideable: true,
}, },
{ {

View File

@ -17,7 +17,7 @@ import { inject, observer } from 'mobx-react';
import { Select } from 'antd'; import { Select } from 'antd';
import globalProjectStore from 'stores/keystone/project'; import globalProjectStore from 'stores/keystone/project';
import globalUserStore from 'stores/keystone/user'; import globalUserStore from 'stores/keystone/user';
import globalRoleStore from 'stores/keystone/role'; import { RoleStore } from 'stores/keystone/role';
import { ModalAction } from 'containers/Action'; import { ModalAction } from 'containers/Action';
import globalDomainStore from 'stores/keystone/domain'; import globalDomainStore from 'stores/keystone/domain';
@ -34,7 +34,7 @@ export class SystemRole extends ModalAction {
const systemRole = JSON.stringify(this.item.projectMapSystemRole); const systemRole = JSON.stringify(this.item.projectMapSystemRole);
this.state.domainDefault = this.item.domain_id; this.state.domainDefault = this.item.domain_id;
this.state.projectRoles = JSON.parse(systemRole); this.state.projectRoles = JSON.parse(systemRole);
this.store = globalRoleStore; this.store = new RoleStore();
this.domainStore = globalDomainStore; this.domainStore = globalDomainStore;
this.userStore = globalUserStore; this.userStore = globalUserStore;
this.getRoleList(); this.getRoleList();
@ -199,6 +199,10 @@ export class SystemRole extends ModalAction {
(it) => it === this.adminRoleId (it) => it === this.adminRoleId
)[0]; )[0];
} }
// for test e2e, will delete by next patch
localStorage.setItem('test-project-role', this.projectRolesList(id));
localStorage.setItem('test-total-role', this.systemRoleList);
localStorage.setItem('test-actual', 'can get localstorage');
return ( return (
<Select <Select
size="small" size="small"

View File

@ -15,9 +15,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import ImageType from 'components/ImageType'; import ImageType from 'components/ImageType';
import { getLocalTimeStr } from 'utils/time'; import { getLocalTimeStr } from 'utils/time';
import { Table, Popover } from 'antd'; import { Table, Popover, Tag, Tooltip } from 'antd';
import globalActionLogStore from 'stores/nova/action-log'; import globalActionLogStore from 'stores/nova/action-log';
import { ironicOriginEndpoint } from 'client/client/constants'; import { ironicOriginEndpoint } from 'client/client/constants';
import { projectTagsColors } from 'src/utils/constants';
import lockSvg from 'asset/image/lock.svg'; import lockSvg from 'asset/image/lock.svg';
import unlockSvg from 'asset/image/unlock.svg'; import unlockSvg from 'asset/image/unlock.svg';
@ -612,4 +613,28 @@ export const actionColumn = (self) => {
]; ];
}; };
export const SimpleTag = ({ tag, index }) => {
const isLongTag = tag.length > 20;
const tagText = isLongTag ? `${tag.slice(0, 20)}...` : tag;
const tagEl = (
<Tag
key={tag}
color={projectTagsColors[index % 10]}
style={{ marginTop: 2, marginBottom: 2 }}
>
<span style={{ whiteSpace: 'pre-wrap' }}>{tagText}</span>
</Tag>
);
return isLongTag ? (
<Tooltip
key={tag}
title={<span style={{ whiteSpace: 'pre-wrap' }}>{tag}</span>}
>
{tagEl}
</Tooltip>
) : (
tagEl
);
};
export const allowAttachInterfaceStatus = ['active', 'paused', 'stopped']; export const allowAttachInterfaceStatus = ['active', 'paused', 'stopped'];

View File

@ -148,7 +148,10 @@ export class ServerStore extends Base {
if (host) { if (host) {
return newData.filter((it) => it.host === host); return newData.filter((it) => it.host === host);
} }
return newData; return newData.map((it) => ({
...it,
tags: (it.origin_data || {}).tags || [],
}));
} }
@action @action

35
src/stores/nova/tag.js Normal file
View File

@ -0,0 +1,35 @@
// Copyright 2021 99cloud
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { action } from 'mobx';
import client from 'client';
import Base from 'stores/base';
export class TagStore extends Base {
get client() {
return client.nova.servers;
}
get paramsFunc() {
return () => null;
}
@action
update({ serverId }, newObject) {
return this.submitting(this.client.updateTags(serverId, newObject));
}
}
const globalTagStore = new TagStore();
export default globalTagStore;

View File

@ -49,6 +49,16 @@ export class ServerGroupInstanceStore extends Base {
}); });
return servers; return servers;
} }
async listDidFetch(items) {
if (items.length === 0) {
return items;
}
return items.map((it) => ({
...it,
tags: (it.origin_data || {}).tags || [],
}));
}
} }
const globalServerGroupInstanceStore = new ServerGroupInstanceStore(); const globalServerGroupInstanceStore = new ServerGroupInstanceStore();

View File

@ -120,7 +120,7 @@ describe('The System Info Page', () => {
.goToDetail(0) .goToDetail(0)
.clickDetailTab('Router') .clickDetailTab('Router')
.clickHeaderButton(1) .clickHeaderButton(1)
.wait(5000) .wait(10000)
.formTableSelectBySearch('router', routerName) .formTableSelectBySearch('router', routerName)
.clickModalActionSubmitButton(); .clickModalActionSubmitButton();
}); });

View File

@ -57,14 +57,15 @@ describe('The Project Page', () => {
.clickModalActionSubmitButton(); .clickModalActionSubmitButton();
}); });
// it('successfully manage user', () => { it('successfully manage user', () => {
// cy.tableSearchText(name) cy.tableSearchText(name)
// .clickActionInMore('Manage User') .clickActionInMore('Manage User')
// .formTransfer('select_user', username) .formTransfer('select_user', username)
// .formTransferRight('select_user', username) .formTransferRight('select_user', username)
// .formSelect('select_user', 'admin') .wait(10000)
// .clickModalActionSubmitButton(); .formSelect('select_user', 'admin')
// }); .clickModalActionSubmitButton();
});
it('successfully manage user group', () => { it('successfully manage user group', () => {
cy.tableSearchText(name) cy.tableSearchText(name)

View File

@ -75,14 +75,19 @@ describe('The User Page', () => {
cy.goBackToList(listUrl); cy.goBackToList(listUrl);
}); });
// it('successfully edit system permission', () => { it('successfully edit system permission', () => {
// cy.tableSearchText(name) cy.tableSearchText(name)
// .clickActionInMore('Edit System Permission') .clickActionInMore('Edit System Permission')
// .formTransfer('select_project', projectName2) .formTransfer('select_project', projectName2)
// .formTransferRight('select_project', projectName2) .formTransferRight('select_project', projectName2)
// .formSelect('select_project', 'admin') .wait(10000)
// .clickModalActionSubmitButton(); .log('test-project-role', localStorage.getItem('test-project-role'))
// }); .log('test-total-role', localStorage.getItem('test-total-role'))
.log('test-actual', localStorage.getItem('test-actual'))
.wait(2000)
.formSelect('select_project', 'admin')
.clickModalActionSubmitButton();
});
it('successfully forbidden user', () => { it('successfully forbidden user', () => {
cy.tableSearchText(name).clickConfirmActionInMore('Forbidden'); cy.tableSearchText(name).clickConfirmActionInMore('Forbidden');

View File

@ -39,7 +39,7 @@ import 'cypress-file-upload';
require('cypress-downloadfile/lib/downloadFileCommand'); require('cypress-downloadfile/lib/downloadFileCommand');
Cypress.Cookies.defaults({ Cypress.Cookies.defaults({
preserve: ['session', 'X-Auth-Token'], preserve: ['session', 'X-Auth-Token', 'shouldSkip'],
}); });
Cypress.on( Cypress.on(

View File

@ -344,15 +344,17 @@ Cypress.Commands.add('selectAll', () => {
Cypress.Commands.add( Cypress.Commands.add(
'getStatusLength', 'getStatusLength',
(hasLengthCallback, noLengthCallback) => { (hasLengthCallback, noLengthCallback, timeoutCallback, index) => {
cy.log(`Current index is: ${index}`);
if ( if (
Cypress.$('.ant-badge-status-success').length > 0 || Cypress.$('.ant-badge-status-success').length > 0 ||
Cypress.$('.ant-badge-status-error').length > 0 Cypress.$('.ant-badge-status-error').length > 0
) { ) {
hasLengthCallback(); hasLengthCallback();
} else if (index >= 100) {
timeoutCallback();
} else { } else {
noLengthCallback(); noLengthCallback();
cy.getStatusLength(hasLengthCallback, noLengthCallback);
} }
} }
); );
@ -369,8 +371,23 @@ Cypress.Commands.add('waitStatusActiveByRefresh', () => {
cy.freshTable(); cy.freshTable();
index += 1; index += 1;
cy.wait(5000); cy.wait(5000);
cy.getStatusLength(
hasLengthCallback,
noLengthCallback,
timeoutCallback,
index
);
}; };
cy.getStatusLength(hasLengthCallback, noLengthCallback); const timeoutCallback = () => {
// eslint-disable-next-line no-console
console.log('not active and timeout', index);
};
cy.getStatusLength(
hasLengthCallback,
noLengthCallback,
timeoutCallback,
index
);
}); });
Cypress.Commands.add('waitStatusActive', (index) => { Cypress.Commands.add('waitStatusActive', (index) => {