summaryrefslogtreecommitdiffstats
path: root/client
diff options
context:
space:
mode:
authorValerij Fredriksen <valerijf@yahooinc.com>2022-08-14 09:52:34 +0200
committerValerij Fredriksen <valerijf@yahooinc.com>2022-08-14 12:10:08 +0200
commit560950be64c4e8de5bd2ae870af173febb3524d3 (patch)
tree6c656c2e701728ca0d2858eb0b75a7c66a09b6c0 /client
parent2c34a3bf55c1f3d4abb84020fa78e46614daa2fa (diff)
Make query input editable, remove modal
Diffstat (limited to 'client')
-rw-r--r--client/js/app/src/app/pages/querybuilder/context/__test__/query-builder-provider.test.jsx190
-rw-r--r--client/js/app/src/app/pages/querybuilder/context/query-builder-provider.jsx148
-rw-r--r--client/js/app/src/app/pages/querybuilder/query-derived/paste-modal.jsx81
-rw-r--r--client/js/app/src/app/pages/querybuilder/query-derived/query-derived.jsx16
-rw-r--r--client/js/app/src/app/pages/querybuilder/query-filters/query-filters.jsx9
5 files changed, 294 insertions, 150 deletions
diff --git a/client/js/app/src/app/pages/querybuilder/context/__test__/query-builder-provider.test.jsx b/client/js/app/src/app/pages/querybuilder/context/__test__/query-builder-provider.test.jsx
new file mode 100644
index 00000000000..d37347a5580
--- /dev/null
+++ b/client/js/app/src/app/pages/querybuilder/context/__test__/query-builder-provider.test.jsx
@@ -0,0 +1,190 @@
+import {
+ ACTION,
+ reducer,
+} from 'app/pages/querybuilder/context/query-builder-provider';
+import parameters from 'app/pages/querybuilder/context/parameters';
+import { cloneDeep, omitBy } from 'lodash';
+
+const state = reducer();
+
+test('default state', () => {
+ const fixed = { ...state, params: hideTypes(state.params) };
+ expect(fixed).toEqual({
+ http: {},
+ params: {
+ children: [{ id: '0', input: '', type: 'yql' }],
+ },
+ query: {
+ input: '{\n "yql": ""\n}',
+ },
+ request: {
+ body: '{\n "yql": ""\n}',
+ fullUrl: 'http://localhost:8080/search/',
+ method: 'POST',
+ url: 'http://localhost:8080/search/',
+ },
+ });
+});
+
+test('manipulates inputs', () => {
+ function assert(state, queryJson, querySearchParams, params) {
+ expect(hideTypes(state.params).children).toEqual(params);
+ expect({ ...state.query, input: JSON.parse(state.query.input) }).toEqual(
+ queryJson
+ );
+
+ const spState = reducer(state, { action: ACTION.SET_METHOD, data: 'GET' });
+ expect(hideTypes(spState.params).children).toEqual(params);
+ expect(spState.query).toEqual(querySearchParams);
+ }
+
+ const s1 = reduce(state, [[ACTION.INPUT_ADD, { type: 'hits' }]]);
+ assert(s1, { input: { yql: '', hits: null } }, { input: 'yql=&hits=' }, [
+ { id: '0', input: '', type: 'yql' },
+ { id: '1', input: '', type: 'hits' },
+ ]);
+
+ const s2 = reduce(s1, [
+ [ACTION.INPUT_UPDATE, { id: '1', input: '12' }],
+ [ACTION.INPUT_ADD, { type: 'ranking' }],
+ [ACTION.INPUT_UPDATE, { id: '1', type: 'offset' }],
+ [ACTION.INPUT_REMOVE, '0'],
+ [ACTION.INPUT_ADD, { id: '2', type: 'location' }],
+ [ACTION.INPUT_ADD, { id: '2', type: 'matchPhase' }],
+ [ACTION.INPUT_UPDATE, { id: '2.0', input: 'us' }],
+ ]);
+ assert(
+ s2,
+ { input: { offset: 12, ranking: { location: 'us', matchPhase: {} } } },
+ { input: 'offset=12&ranking.location=us' },
+ [
+ { id: '1', input: '12', type: 'offset' },
+ {
+ id: '2',
+ input: '',
+ type: 'ranking',
+ children: [
+ { id: '2.0', input: 'us', type: 'location' },
+ { id: '2.1', input: '', type: 'matchPhase', children: [] },
+ ],
+ },
+ ]
+ );
+
+ assert(
+ reduce(s2, [[ACTION.INPUT_UPDATE, { id: '2', type: 'noCache' }]]),
+ { input: { offset: 12, noCache: false } },
+ { input: 'offset=12&noCache=' },
+ [
+ { id: '1', input: '12', type: 'offset' },
+ { id: '2', input: '', type: 'noCache' },
+ ]
+ );
+
+ assert(
+ reduce(s2, [[ACTION.INPUT_REMOVE, '2']]),
+ { input: { offset: 12 } },
+ { input: 'offset=12' },
+ [{ id: '1', input: '12', type: 'offset' }]
+ );
+});
+
+test('set query', () => {
+ const query = (method, input) =>
+ reduce(state, [
+ [ACTION.INPUT_REMOVE, '0'],
+ [ACTION.SET_METHOD, method],
+ [ACTION.SET_QUERY, input],
+ ]);
+ function assert(inputJson, inputSearchParams, params) {
+ const s2 = query('POST', inputJson);
+ expect(hideTypes(s2.params).children).toEqual(params);
+ expect(s2.query.input).toEqual(inputJson);
+ expect(s2.query.error).toBeUndefined();
+
+ if (inputSearchParams == null) return;
+ const s3 = query('GET', inputSearchParams);
+ expect(hideTypes(s3.params).children).toEqual(params);
+ expect(s3.query.input).toEqual(inputSearchParams);
+ expect(s3.query.error).toBeUndefined();
+ }
+
+ function error(method, input, error) {
+ const s = query(method, input);
+ expect(s.params.children).toEqual([]);
+ expect(s.query.input).toEqual(input);
+ expect(s.query.error).toEqual(error);
+ }
+
+ assert('{"yql":"abc"}', '?yql=abc', [{ id: '0', input: 'abc', type: 'yql' }]);
+
+ assert(
+ '{"hits":12,"ranking":{"location":"us","matchPhase":{"attribute":"[\\"a b\\"]"}},"noCache":true,"offset":""}',
+ 'hits=12&ranking.location=us&noCache=true&ranking.matchPhase.attribute=%5B%22a+b%22%5D&offset',
+ [
+ { id: '0', input: '12', type: 'hits' },
+ {
+ id: '1',
+ input: '',
+ type: 'ranking',
+ children: [
+ { id: '1.0', input: 'us', type: 'location' },
+ {
+ id: '1.1',
+ input: '',
+ type: 'matchPhase',
+ children: [{ id: '1.1.0', input: '["a b"]', type: 'attribute' }],
+ },
+ ],
+ },
+ { id: '2', input: 'true', type: 'noCache' },
+ { id: '3', input: '', type: 'offset' },
+ ]
+ );
+
+ assert('{"ranking":{"matchPhase":{}}}', null, [
+ {
+ id: '0',
+ input: '',
+ type: 'ranking',
+ children: [
+ {
+ id: '0.0',
+ input: '',
+ type: 'matchPhase',
+ children: [],
+ },
+ ],
+ },
+ ]);
+
+ let msg = "Unknown property 'asd' on root level";
+ error('POST', '{"asd":123}', msg);
+ error('GET', 'asd=123', msg);
+
+ msg = "Unknown property 'asd' under 'matchPhase'";
+ error('POST', '{"ranking":{"matchPhase":{"asd":123}}}', msg);
+ error('GET', 'ranking.matchPhase.asd=123', msg);
+
+ error('POST', '{"yql":"test}', 'Unexpected end of JSON input');
+
+ msg =
+ "Property 'ranking' cannot have value, supported children: features,freshness,listFeatures,location,matchPhase,profile,properties,queryCache,sorting";
+ error('POST', '{"ranking":123}', msg);
+ error('GET', 'ranking=123', msg);
+
+ error('POST', '{"yql":{}}', "Expected property 'yql' to be String");
+});
+
+function hideTypes({ type, children, ...copy }) {
+ if (type.name) copy.type = type.name;
+ if (children) copy.children = children.map(hideTypes);
+ return copy;
+}
+
+function reduce(state, operations) {
+ return operations.reduce(
+ (acc, [action, data]) => reducer(acc, { action, data }),
+ state
+ );
+}
diff --git a/client/js/app/src/app/pages/querybuilder/context/query-builder-provider.jsx b/client/js/app/src/app/pages/querybuilder/context/query-builder-provider.jsx
index 98a040159da..1b1faa39e59 100644
--- a/client/js/app/src/app/pages/querybuilder/context/query-builder-provider.jsx
+++ b/client/js/app/src/app/pages/querybuilder/context/query-builder-provider.jsx
@@ -1,4 +1,4 @@
-import { cloneDeep, last } from 'lodash';
+import { last, set } from 'lodash';
import React, { useReducer } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
import parameters from 'app/pages/querybuilder/context/parameters';
@@ -18,6 +18,11 @@ export const ACTION = Object.freeze({
INPUT_REMOVE: 12,
});
+function cloneParams(params) {
+ if (!params.children) return { ...params };
+ return { ...params, children: params.children.map(cloneParams) };
+}
+
function inputsToSearchParams(inputs, parent) {
return inputs.reduce((acc, { input, type: { name }, children }) => {
const key = parent ? `${parent}.${name}` : name;
@@ -28,6 +33,14 @@ function inputsToSearchParams(inputs, parent) {
}, {});
}
+function searchParamsToInputs(search) {
+ const json = [...new URLSearchParams(search).entries()].reduce(
+ (acc, [key, value]) => set(acc, key, value),
+ {}
+ );
+ return jsonToInputs(json);
+}
+
function inputsToJson(inputs) {
return Object.fromEntries(
inputs.map(({ children, input, type: { name, type } }) => [
@@ -37,23 +50,32 @@ function inputsToJson(inputs) {
);
}
-export function jsonToInputs(json, parent = root) {
+function jsonToInputs(json, parent = root) {
return Object.entries(json).map(([key, value], i) => {
const node = {
id: parent.id ? `${parent.id}.${i}` : i.toString(),
type: parent.type.children[key],
- parent,
};
if (!node.type) {
const location = parent.type.name
- ? `under ${parent.type.name}`
+ ? `under '${parent.type.name}'`
: 'on root level';
throw new Error(`Unknown property '${key}' ${location}`);
}
- if (typeof value === 'object') {
+ if (value != null && typeof value === 'object') {
+ if (!node.type.children)
+ throw new Error(`Expected property '${key}' to be ${node.type.type}`);
node.input = '';
node.children = jsonToInputs(value, node);
- } else node.input = value.toString();
+ } else {
+ if (node.type.children)
+ throw new Error(
+ `Property '${key}' cannot have value, supported children: ${Object.keys(
+ node.type.children
+ ).sort()}`
+ );
+ node.input = value?.toString();
+ }
return node;
});
}
@@ -66,8 +88,8 @@ function parseInput(input, type) {
}
function inputAdd(params, { id: parentId, type: typeName }) {
- const inputs = cloneDeep(params.children);
- const parent = parentId ? findInput(inputs, parentId) : params;
+ const cloned = cloneParams(params);
+ const parent = findInput(cloned, parentId);
const nextId =
parseInt(last(last(parent.children)?.id?.split('.')) ?? -1) + 1;
@@ -75,58 +97,73 @@ function inputAdd(params, { id: parentId, type: typeName }) {
const type = parent.type.children[typeName];
parent.children.push(
- Object.assign(
- { id, input: '', type, parent },
- type.children && { children: [] }
- )
+ Object.assign({ id, input: '', type }, type.children && { children: [] })
);
- return { ...params, children: inputs };
+
+ return cloned;
}
-function inputUpdate(params, { id, ...props }) {
- const keys = Object.keys(props);
- if (keys.length !== 1)
- throw new Error(`Expected to update exactly 1 input prop, got: ${keys}`);
- if (!['input', 'type'].includes(keys[0]))
- throw new Error(`Cannot update key ${keys[0]}`);
+function inputUpdate(params, { id, type, input }) {
+ const cloned = cloneParams(params);
+ const node = findInput(cloned, id);
+ if (type) {
+ const parent = findInput(cloned, id.substring(0, id.lastIndexOf('.')));
+ node.type = parent.type.children[type];
+ }
+ if (input) node.input = input;
- const inputs = cloneDeep(params.children);
- const node = Object.assign(findInput(inputs, id), props);
if (node.type.children) node.children = [];
else delete node.children;
- return { ...params, children: inputs };
+ return cloned;
}
-function findInput(inputs, id, Delete = false) {
+function findInput(params, id, Delete = false) {
+ if (!id) return params;
let end = -1;
while ((end = id.indexOf('.', end + 1)) > 0)
- inputs = inputs.find((input) => input.id === id.substring(0, end)).children;
- const index = inputs.findIndex((input) => input.id === id);
- return Delete ? inputs.splice(index, 1)[0] : inputs[index];
+ params = params.children.find((input) => input.id === id.substring(0, end));
+ const index = params.children.findIndex((input) => input.id === id);
+ return Delete ? params.children.splice(index, 1)[0] : params.children[index];
}
-function reducer(state, action) {
- const result = preReducer(state, action);
- const { request: sr, params: sp } = state;
- const { request: rr, params: rp } = result;
- if (sp.children !== rp.children || sr.method !== rr.method) {
- result.query =
- rr.method === 'POST'
- ? JSON.stringify(inputsToJson(rp.children), null, 4)
- : new URLSearchParams(inputsToSearchParams(rp.children)).toString();
+export function reducer(state, action) {
+ if (state == null) {
+ state = { http: {}, params: {}, query: {}, request: {} };
+
+ return [
+ [ACTION.SET_URL, 'http://localhost:8080/search/'],
+ [ACTION.SET_QUERY, 'yql='],
+ [ACTION.SET_METHOD, 'POST'],
+ ].reduce((s, [action, data]) => reducer(s, { action, data }), state);
}
- if (sr.url !== rr.url || state.query !== result.query) {
+ const result = preReducer(state, action);
+ const { request: sr, params: sp, query: sq } = state;
+ const { request: rr, params: rp, query: rq } = result;
+
+ if ((sp.children !== rp.children && sq === rq) || sr.method !== rr.method)
+ result.query = {
+ input:
+ rr.method === 'POST'
+ ? JSON.stringify(inputsToJson(rp.children), null, 4)
+ : new URLSearchParams(inputsToSearchParams(rp.children)).toString(),
+ };
+
+ const input = result.query.input;
+ if (sr.url !== rr.url || sq.input !== input || sr.method !== rr.method) {
if (rr.method === 'POST') {
- rr.fullUrl = rr.url;
- rr.body = result.query;
+ result.request = { ...result.request, fullUrl: rr.url, body: input };
} else {
const url = new URL(rr.url);
- url.search = result.query;
- rr.fullUrl = url.toString();
- rr.body = null;
+ url.search = input;
+ result.request = {
+ ...result.request,
+ fullUrl: url.toString(),
+ body: null,
+ };
}
}
+
return result;
}
@@ -134,10 +171,17 @@ function preReducer(state, { action, data }) {
switch (action) {
case ACTION.SET_QUERY: {
try {
- const children = jsonToInputs(JSON.parse(data));
- return { ...state, params: { ...root, children } };
+ const children =
+ state.request.method === 'POST'
+ ? jsonToInputs(JSON.parse(data))
+ : searchParamsToInputs(data);
+ return {
+ ...state,
+ params: { ...root, children },
+ query: { input: data },
+ };
} catch (error) {
- return state;
+ return { ...state, query: { input: data, error: error.message } };
}
}
case ACTION.SET_HTTP:
@@ -152,9 +196,9 @@ function preReducer(state, { action, data }) {
case ACTION.INPUT_UPDATE:
return { ...state, params: inputUpdate(state.params, data) };
case ACTION.INPUT_REMOVE: {
- const inputs = cloneDeep(state.params.children);
- findInput(inputs, data, true);
- return { ...state, params: { ...state.params, children: inputs } };
+ const cloned = cloneParams(state.params);
+ findInput(cloned, data, true);
+ return { ...state, params: cloned };
}
default:
@@ -163,15 +207,7 @@ function preReducer(state, { action, data }) {
}
export function QueryBuilderProvider({ children }) {
- const [value, dispatch] = useReducer(
- reducer,
- {
- request: { url: 'http://localhost:8080/search/', method: 'POST' },
- http: {},
- params: { ...root, children: [] },
- },
- (s) => reducer(s, { action: ACTION.SET_QUERY, data: '{"yql":""}' })
- );
+ const [value, dispatch] = useReducer(reducer, null, reducer);
_dispatch = dispatch;
return <context.Provider value={value}>{children}</context.Provider>;
}
diff --git a/client/js/app/src/app/pages/querybuilder/query-derived/paste-modal.jsx b/client/js/app/src/app/pages/querybuilder/query-derived/paste-modal.jsx
deleted file mode 100644
index 90fe6a51eb6..00000000000
--- a/client/js/app/src/app/pages/querybuilder/query-derived/paste-modal.jsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { Button, Modal, Stack, Textarea } from '@mantine/core';
-import React, { useState } from 'react';
-import { Icon } from 'app/components';
-import {
- ACTION,
- dispatch,
- jsonToInputs,
-} from 'app/pages/querybuilder/context/query-builder-provider';
-
-function PasteForm({ close }) {
- const [query, setQuery] = useState('');
- const [error, setError] = useState('');
-
- function onSubmit() {
- try {
- const json = JSON.parse(query);
- try {
- jsonToInputs(json);
- dispatch(ACTION.SET_QUERY, query);
- close();
- } catch (error) {
- setError(`Invalid Vespa query: ${error.message}`);
- }
- } catch (error) {
- setError(`Invalid JSON: ${error.message}`);
- }
- }
-
- return (
- <Stack>
- <Textarea
- styles={(theme) => ({
- input: {
- fontFamily: theme.fontFamilyMonospace,
- fontSize: theme.fontSizes.xs,
- },
- })}
- placeholder="Your Vespa query JSON"
- error={error}
- minRows={21}
- value={query}
- onChange={({ target }) => setQuery(target.value)}
- autosize
- />
- <Button leftIcon={<Icon name="paste" />} onClick={onSubmit}>
- Save
- </Button>
- </Stack>
- );
-}
-
-export function PasteModal() {
- const [opened, setOpened] = useState(false);
- const close = () => setOpened(false);
-
- return (
- <>
- <Modal
- opened={opened}
- onClose={close}
- title="Vespa Query"
- overlayColor="white"
- shadow="1px 3px 10px 2px rgb(0 0 0 / 20%)"
- padding="xl"
- size="xl"
- centered
- >
- <PasteForm close={close} />
- </Modal>
- <Button
- onClick={() => setOpened(true)}
- leftIcon={<Icon name="paste" />}
- variant="outline"
- size="xs"
- compact
- >
- Paste JSON
- </Button>
- </>
- );
-}
diff --git a/client/js/app/src/app/pages/querybuilder/query-derived/query-derived.jsx b/client/js/app/src/app/pages/querybuilder/query-derived/query-derived.jsx
index e4626d194c5..fca06defc5d 100644
--- a/client/js/app/src/app/pages/querybuilder/query-derived/query-derived.jsx
+++ b/client/js/app/src/app/pages/querybuilder/query-derived/query-derived.jsx
@@ -7,19 +7,22 @@ import {
CopyButton,
Textarea,
} from '@mantine/core';
-import { useQueryBuilderContext } from 'app/pages/querybuilder/context/query-builder-provider';
+import {
+ ACTION,
+ dispatch,
+ useQueryBuilderContext,
+} from 'app/pages/querybuilder/context/query-builder-provider';
import { Icon } from 'app/components';
-import { PasteModal } from 'app/pages/querybuilder/query-derived/paste-modal';
export function QueryDerived() {
- const query = useQueryBuilderContext('query');
+ const { input, error } = useQueryBuilderContext('query');
return (
<Stack>
<Group position="apart">
<Badge variant="filled">Query</Badge>
<Group spacing="xs">
- <CopyButton value={query}>
+ <CopyButton value={input}>
{({ copied, copy }) => (
<Button
leftIcon={<Icon name={copied ? 'check' : 'copy'} />}
@@ -33,7 +36,6 @@ export function QueryDerived() {
</Button>
)}
</CopyButton>
- <PasteModal />
</Group>
</Group>
<Textarea
@@ -43,7 +45,9 @@ export function QueryDerived() {
fontSize: theme.fontSizes.xs,
},
})}
- value={query}
+ value={input}
+ error={error}
+ onChange={({ target }) => dispatch(ACTION.SET_QUERY, target.value)}
variant="unstyled"
minRows={21}
autosize
diff --git a/client/js/app/src/app/pages/querybuilder/query-filters/query-filters.jsx b/client/js/app/src/app/pages/querybuilder/query-filters/query-filters.jsx
index 2f59b0af86e..82e9fc6c8c1 100644
--- a/client/js/app/src/app/pages/querybuilder/query-filters/query-filters.jsx
+++ b/client/js/app/src/app/pages/querybuilder/query-filters/query-filters.jsx
@@ -33,12 +33,7 @@ function Input({ id, input, types, type, children }) {
<Select
sx={{ flex: 1 }}
data={Object.values(options).map(({ name }) => name)}
- onChange={(value) =>
- dispatch(ACTION.INPUT_UPDATE, {
- id,
- type: types[value],
- })
- }
+ onChange={(type) => dispatch(ACTION.INPUT_UPDATE, { id, type })}
value={type.name}
searchable
/>
@@ -52,7 +47,7 @@ function Input({ id, input, types, type, children }) {
})
}
placeholder={type.type}
- value={input}
+ value={input ?? ''}
/>
)}
<ActionIcon onClick={() => dispatch(ACTION.INPUT_REMOVE, id)}>