feat: assertions implementation in UI

This commit is contained in:
Anoop M D 2023-02-21 14:04:05 +05:30
parent 925af1f26f
commit d58e92205b
15 changed files with 290 additions and 11 deletions

View File

@ -0,0 +1,56 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
&:nth-child(1) {
width: 30%;
}
&:nth-child(3) {
width: 70px;
}
}
}
.btn-add-assertion {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
background-color: inherit;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@ -0,0 +1,129 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addAssertion, updateAssertion, deleteAssertion } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
const handleAddAssertion = () => {
dispatch(
addAssertion({
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleAssertionChange = (e, _assertion, type) => {
const assertion = cloneDeep(_assertion);
switch (type) {
case 'name': {
assertion.name = e.target.value;
break;
}
case 'value': {
assertion.value = e.target.value;
break;
}
case 'enabled': {
assertion.enabled = e.target.checked;
break;
}
}
dispatch(
updateAssertion({
assertion: assertion,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveAssertion = (assertion) => {
dispatch(
deleteAssertion({
assertUid: assertion.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>Expr</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{assertions && assertions.length
? assertions.map((assertion, index) => {
return (
<tr key={assertion.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={assertion.name}
className="mousetrap"
onChange={(e) => handleAssertionChange(e, assertion, 'name')}
/>
</td>
<td>
<SingleLineEditor
value={assertion.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) => handleAssertionChange({
target: {
value: newValue
}
}, assertion, 'value')}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={assertion.enabled}
className="mr-3 mousetrap"
onChange={(e) => handleAssertionChange(e, assertion, 'enabled')}
/>
<button onClick={() => handleRemoveAssertion(assertion)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<button className="btn-add-assertion text-link pr-2 py-3 mt-2 select-none" onClick={handleAddAssertion}>
+ Add Assertion
</button>
</StyledWrapper>
);
};
export default Assertions;

View File

@ -8,6 +8,7 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody'; import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode'; import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Vars from 'components/RequestPane/Vars'; import Vars from 'components/RequestPane/Vars';
import Assert from 'components/RequestPane/Assert';
import Script from 'components/RequestPane/Script'; import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests'; import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -40,6 +41,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
case 'vars': { case 'vars': {
return <Vars item={item} collection={collection} />; return <Vars item={item} collection={collection} />;
} }
case 'assert': {
return <Assert item={item} collection={collection} />;
}
case 'script': { case 'script': {
return <Script item={item} collection={collection} />; return <Script item={item} collection={collection} />;
} }
@ -71,7 +75,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<StyledWrapper className="flex flex-col h-full relative"> <StyledWrapper className="flex flex-col h-full relative">
<div className="flex items-center tabs" role="tablist"> <div className="flex items-center tabs" role="tablist">
<div className={getTabClassname('params')} role="tab" onClick={() => selectTab('params')}> <div className={getTabClassname('params')} role="tab" onClick={() => selectTab('params')}>
Params Query
</div> </div>
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}> <div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Body Body
@ -85,6 +89,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}> <div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script Script
</div> </div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}> <div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests Tests
</div> </div>

View File

@ -6,6 +6,7 @@ import { useTheme } from 'providers/Theme';
import { addVar, updateVar, deleteVar } from 'providers/ReduxStore/slices/collections'; import { addVar, updateVar, deleteVar } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor'; import SingleLineEditor from 'components/SingleLineEditor';
import Tooltip from 'components/Tooltip';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const VarsTable = ({ item, collection, vars, varType }) => { const VarsTable = ({ item, collection, vars, varType }) => {
@ -67,7 +68,21 @@ const VarsTable = ({ item, collection, vars, varType }) => {
<thead> <thead>
<tr> <tr>
<td>Name</td> <td>Name</td>
<td>Value</td> { varType === 'request' ? (
<td>
<div className='flex items-center'>
<span>Value</span>
<Tooltip text="You can write any valid JS Template Literal here" tooltipId="request-var"/>
</div>
</td>
) : (
<td>
<div className='flex items-center'>
<span>Expr</span>
<Tooltip text="You can write any valid JS expression here" tooltipId="response-var"/>
</div>
</td>
)}
<td></td> <td></td>
</tr> </tr>
</thead> </thead>

View File

@ -45,6 +45,8 @@ const StyledWrapper = styled.div`
.CodeMirror-line { .CodeMirror-line {
color: ${(props) => props.theme.text}; color: ${(props) => props.theme.text};
padding-left: 0;
padding-right: 0;
} }
} }

View File

@ -707,6 +707,59 @@ export const collectionsSlice = createSlice({
} }
} }
}, },
addAssertion: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.assertions = item.draft.request.assertions || [];
item.draft.request.assertions.push({
uid: uuid(),
name: '',
value: '',
enabled: true
});
}
}
},
updateAssertion: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
const assertion = item.draft.request.assertions.find((a) => a.uid === action.payload.assertion.uid);
if (assertion) {
assertion.name = action.payload.assertion.name;
assertion.value = action.payload.assertion.value;
assertion.enabled = action.payload.assertion.enabled;
}
}
}
},
deleteAssertion: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.assertions = item.draft.request.assertions.filter((a) => a.uid !== action.payload.assertUid);
}
}
},
addVar: (state, action) => { addVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type; const type = action.payload.type;
@ -1122,6 +1175,9 @@ export const {
updateResponseScript, updateResponseScript,
updateRequestTests, updateRequestTests,
updateRequestMethod, updateRequestMethod,
addAssertion,
updateAssertion,
deleteAssertion,
addVar, addVar,
updateVar, updateVar,
deleteVar, deleteVar,

View File

@ -9,6 +9,9 @@ const deleteUidsInItems = (items) => {
if (['http-request', 'graphql-request'].includes(item.type)) { if (['http-request', 'graphql-request'].includes(item.type)) {
each(get(item, 'request.headers'), (header) => delete header.uid); each(get(item, 'request.headers'), (header) => delete header.uid);
each(get(item, 'request.params'), (param) => delete param.uid); each(get(item, 'request.params'), (param) => delete param.uid);
each(get(item, 'request.vars.req'), (v) => delete v.uid);
each(get(item, 'request.vars.res'), (v) => delete v.uid);
each(get(item, 'request.vars.assertions'), (a) => delete a.uid);
each(get(item, 'request.body.multipartForm'), (param) => delete param.uid); each(get(item, 'request.body.multipartForm'), (param) => delete param.uid);
each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid); each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid);
} }

View File

@ -282,6 +282,8 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
multipartForm: copyMultipartFormParams(si.draft.request.body.multipartForm) multipartForm: copyMultipartFormParams(si.draft.request.body.multipartForm)
}, },
script: si.draft.request.script, script: si.draft.request.script,
vars: si.draft.request.vars,
assertions: si.draft.request.assertions,
tests: si.draft.request.tests tests: si.draft.request.tests
}; };
} }
@ -302,6 +304,8 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
multipartForm: copyMultipartFormParams(si.request.body.multipartForm) multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
}, },
script: si.request.script, script: si.request.script,
vars: si.request.vars,
assertions: si.request.assertions,
tests: si.request.tests tests: si.request.tests
}; };
} }
@ -350,6 +354,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
body: _item.request.body, body: _item.request.body,
script: _item.request.script, script: _item.request.script,
vars: _item.request.vars, vars: _item.request.vars,
assertions: _item.request.assertions,
tests: _item.request.tests tests: _item.request.tests
} }
}; };

View File

@ -32,6 +32,9 @@ export const updateUidsInCollection = (_collection) => {
each(get(item, 'request.headers'), (header) => (header.uid = uuid())); each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
each(get(item, 'request.query'), (param) => (param.uid = uuid())); each(get(item, 'request.query'), (param) => (param.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid())); each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.vars.req'), (v) => (v.uid = uuid()));
each(get(item, 'request.vars.res'), (v) => (v.uid = uuid()));
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid())); each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid())); each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));

View File

@ -69,6 +69,7 @@ const bruToJson = (bru) => {
"body": _.get(json, "body", {}), "body": _.get(json, "body", {}),
"script": _.get(json, "script", {}), "script": _.get(json, "script", {}),
"vars": _.get(json, "vars", {}), "vars": _.get(json, "vars", {}),
"assertions": _.get(json, "assertions", []),
"tests": _.get(json, "tests", "") "tests": _.get(json, "tests", "")
} }
}; };
@ -118,6 +119,7 @@ const jsonToBru = (json) => {
req: _.get(json, 'request.vars.req', []), req: _.get(json, 'request.vars.req', []),
res: _.get(json, 'request.vars.res', []) res: _.get(json, 'request.vars.res', [])
}, },
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''), tests: _.get(json, 'request.tests', ''),
}; };

View File

@ -381,7 +381,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}, },
assert(_1, dictionary) { assert(_1, dictionary) {
return { return {
assert: mapPairListToKeyValPairs(dictionary.ast) assertions: mapPairListToKeyValPairs(dictionary.ast)
}; };
}, },
scriptreq(_1, _2, _3, _4, textblock, _5) { scriptreq(_1, _2, _3, _4, textblock, _5) {

View File

@ -24,7 +24,7 @@ const jsonToBru = (json) => {
script, script,
tests, tests,
vars, vars,
assert, assertions,
docs docs
} = json; } = json;
@ -196,15 +196,15 @@ ${indentString(body.xml)}
bru += '\n}\n\n'; bru += '\n}\n\n';
} }
if(assert && assert.length) { if(assertions && assertions.length) {
bru += `assert {`; bru += `assert {`;
if(enabled(assert).length) { if(enabled(assertions).length) {
bru += `\n${indentString(enabled(assert).map(item => `${item.name}: ${item.value}`).join('\n'))}`; bru += `\n${indentString(enabled(assertions).map(item => `${item.name}: ${item.value}`).join('\n'))}`;
} }
if(disabled(assert).length) { if(disabled(assertions).length) {
bru += `\n${indentString(disabled(assert).map(item => `~${item.name}: ${item.value}`).join('\n'))}`; bru += `\n${indentString(disabled(assertions).map(item => `~${item.name}: ${item.value}`).join('\n'))}`;
} }
bru += '\n}\n\n'; bru += '\n}\n\n';

View File

@ -13,7 +13,7 @@ assert {
const output = parser(input); const output = parser(input);
const expected = { const expected = {
"assert": [{ "assertions": [{
name: "res(\"data.airports\").filter(a => a.code ===\"BLR\").name", name: "res(\"data.airports\").filter(a => a.code ===\"BLR\").name",
value: '"Bangalore International Airport"', value: '"Bangalore International Airport"',
enabled: true enabled: true

View File

@ -124,7 +124,7 @@
} }
] ]
}, },
"assert": [ "assertions": [
{ {
"name": "$res.status", "name": "$res.status",
"value": "200", "value": "200",

View File

@ -70,6 +70,7 @@ const requestSchema = Yup.object({
req: Yup.array().of(varsSchema).nullable(), req: Yup.array().of(varsSchema).nullable(),
res: Yup.array().of(varsSchema).nullable() res: Yup.array().of(varsSchema).nullable()
}).noUnknown(true).strict().nullable(), }).noUnknown(true).strict().nullable(),
assertions: Yup.array().of(keyValueSchema).nullable(),
tests: Yup.string().nullable() tests: Yup.string().nullable()
}).noUnknown(true).strict(); }).noUnknown(true).strict();