@truedat/qx 8.10.2 → 8.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +3 -3
  2. package/src/components/QxRoutes.js +8 -7
  3. package/src/components/__tests__/QxRoutes.spec.js +2 -1
  4. package/src/components/__tests__/__fixtures__/helper.js +13 -0
  5. package/src/components/__tests__/__snapshots__/QxRoutes.spec.js.snap +1 -1
  6. package/src/components/common/ClauseViewer.js +52 -8
  7. package/src/components/common/DescriptionInput.js +9 -34
  8. package/src/components/common/TypeSelector.js +20 -10
  9. package/src/components/common/__tests__/ClauseViewer.spec.js +150 -0
  10. package/src/components/common/__tests__/DescriptionInput.spec.js +2 -5
  11. package/src/components/common/__tests__/__snapshots__/DescriptionInput.spec.js.snap +7 -10
  12. package/src/components/common/expressions/Clauses.js +22 -17
  13. package/src/components/common/expressions/Condition.js +201 -102
  14. package/src/components/common/expressions/Expression.js +17 -23
  15. package/src/components/common/expressions/FunctionSelector.js +3 -3
  16. package/src/components/common/expressions/ShapeSelector.js +1 -1
  17. package/src/components/common/expressions/__tests__/Clauses.spec.js +1 -1
  18. package/src/components/common/expressions/__tests__/Condition.spec.js +83 -26
  19. package/src/components/common/expressions/__tests__/ParamSelector.spec.js +1 -1
  20. package/src/components/common/expressions/__tests__/__snapshots__/Clauses.spec.js.snap +18 -22
  21. package/src/components/common/expressions/__tests__/__snapshots__/Condition.spec.js.snap +215 -216
  22. package/src/components/common/expressions/__tests__/__snapshots__/ConstantSelector.spec.js.snap +6 -10
  23. package/src/components/common/expressions/__tests__/__snapshots__/Expression.spec.js.snap +630 -654
  24. package/src/components/common/expressions/__tests__/__snapshots__/FunctionArgs.spec.js.snap +193 -201
  25. package/src/components/common/expressions/__tests__/__snapshots__/FunctionSelector.spec.js.snap +450 -466
  26. package/src/components/common/expressions/__tests__/__snapshots__/ShapeSelector.spec.js.snap +34 -34
  27. package/src/components/common/expressions/constantInputs/AnySelector.js +1 -0
  28. package/src/components/common/expressions/constantInputs/__tests__/__snapshots__/AnySelector.spec.js.snap +15 -15
  29. package/src/components/dataViews/__tests__/__snapshots__/AdvancedDataViewEditor.spec.js.snap +378 -406
  30. package/src/components/dataViews/__tests__/__snapshots__/DataViewSummary.spec.js.snap +6 -6
  31. package/src/components/dataViews/__tests__/__snapshots__/Queryable.spec.js.snap +489 -577
  32. package/src/components/dataViews/__tests__/__snapshots__/Queryables.spec.js.snap +400 -476
  33. package/src/components/dataViews/advancedForm/Queryable.js +56 -64
  34. package/src/components/dataViews/advancedForm/Queryables.js +11 -2
  35. package/src/components/dataViews/queryableProperties/Join.js +16 -6
  36. package/src/components/dataViews/queryableProperties/__tests__/__snapshots__/Join.spec.js.snap +0 -28
  37. package/src/components/dataViews/queryableProperties/__tests__/__snapshots__/Select.spec.js.snap +270 -282
  38. package/src/components/dataViews/queryableProperties/__tests__/__snapshots__/SelectField.spec.js.snap +172 -180
  39. package/src/components/dataViews/simpleForm/DatasetForm.js +4 -0
  40. package/src/components/dataViews/simpleForm/FormQueryable.js +49 -53
  41. package/src/components/dataViews/summary/Where.js +3 -2
  42. package/src/components/functions/FunctionEdit.js +92 -0
  43. package/src/components/functions/FunctionEditor.js +32 -10
  44. package/src/components/functions/FunctionParams.js +54 -61
  45. package/src/components/functions/FunctionsList.js +213 -0
  46. package/src/components/functions/__tests__/FunctionEdit.spec.js +168 -0
  47. package/src/components/functions/__tests__/FunctionParams.spec.js +1 -8
  48. package/src/components/functions/__tests__/FunctionsList.spec.js +62 -0
  49. package/src/components/functions/__tests__/__snapshots__/FunctionEditor.spec.js.snap +553 -469
  50. package/src/components/functions/__tests__/__snapshots__/FunctionParams.spec.js.snap +139 -161
  51. package/src/components/functions/__tests__/__snapshots__/FunctionsList.spec.js.snap +588 -0
  52. package/src/components/qualityControls/__tests__/NewQualityControl.spec.js +4 -0
  53. package/src/components/qualityControls/__tests__/QualityControlEditor.spec.js +8 -1
  54. package/src/components/qualityControls/__tests__/__snapshots__/EditQualityControl.spec.js.snap +109 -118
  55. package/src/components/qualityControls/__tests__/__snapshots__/NewDraftQualityControl.spec.js.snap +109 -118
  56. package/src/components/qualityControls/__tests__/__snapshots__/QualityControlEditor.spec.js.snap +471 -468
  57. package/src/hooks/useFunctions.js +6 -0
  58. package/src/styles/Expression.less +39 -6
  59. package/src/components/functions/Functions.js +0 -143
  60. package/src/components/functions/__tests__/Functions.spec.js +0 -56
  61. package/src/components/functions/__tests__/__snapshots__/Functions.spec.js.snap +0 -85
@@ -0,0 +1,92 @@
1
+ import { useParams, useNavigate } from "react-router";
2
+ import { Segment } from "semantic-ui-react";
3
+ import { FUNCTIONS } from "@truedat/core/routes";
4
+ import {
5
+ useFunctionFetch,
6
+ useFunctions,
7
+ useFunctionCreate,
8
+ useFunctionUpdate,
9
+ useFunctionDelete,
10
+ } from "@truedat/qx/hooks/useFunctions";
11
+ import FunctionEditor from "./FunctionEditor";
12
+
13
+ const NEW_FUNCTION = {
14
+ name: "",
15
+ description: "",
16
+ type: "boolean",
17
+ params: [],
18
+ expression: { shape: "function", value: null },
19
+ };
20
+
21
+ function FunctionEditWithId({ id }) {
22
+ const navigate = useNavigate();
23
+ const { data: functionData, loading, error } = useFunctionFetch(id);
24
+ const { data: functionsData } = useFunctions();
25
+ const functions = functionsData?.data;
26
+ const func = functionData?.data;
27
+
28
+ const { trigger: updateFunction, isMutating: isUpdating } =
29
+ useFunctionUpdate(func);
30
+ const { trigger: deleteFunction, isMutating: isDeleting } =
31
+ useFunctionDelete(func);
32
+
33
+ const isSubmitting = isUpdating || isDeleting;
34
+
35
+ const onSubmit = (f) => {
36
+ updateFunction({ function: f }).then(() => navigate(FUNCTIONS));
37
+ };
38
+
39
+ const onDelete = (f) => {
40
+ deleteFunction({ function: f }).then(() => navigate(FUNCTIONS));
41
+ };
42
+
43
+ if (error) return null;
44
+
45
+ return (
46
+ <Segment loading={loading}>
47
+ <FunctionEditor
48
+ key={func?.id}
49
+ selectedFunction={func}
50
+ functions={functions}
51
+ onCancel={() => navigate(FUNCTIONS)}
52
+ onSubmit={onSubmit}
53
+ // Seeded functions are recreated from their JSON file on every reboot,
54
+ // so deleting one would only hide it until the next restart.
55
+ onDelete={func?.seeded ? null : onDelete}
56
+ isSubmitting={isSubmitting}
57
+ />
58
+ </Segment>
59
+ );
60
+ }
61
+
62
+ function FunctionNew() {
63
+ const navigate = useNavigate();
64
+ const { data: functionsData } = useFunctions();
65
+ const functions = functionsData?.data;
66
+
67
+ const { trigger: createFunction, isMutating: isCreating } =
68
+ useFunctionCreate();
69
+
70
+ const onSubmit = (f) => {
71
+ createFunction({ function: f }).then(() => navigate(FUNCTIONS));
72
+ };
73
+
74
+ return (
75
+ <Segment>
76
+ <FunctionEditor
77
+ key="new"
78
+ selectedFunction={NEW_FUNCTION}
79
+ functions={functions}
80
+ onCancel={() => navigate(FUNCTIONS)}
81
+ onSubmit={onSubmit}
82
+ onDelete={null}
83
+ isSubmitting={isCreating}
84
+ />
85
+ </Segment>
86
+ );
87
+ }
88
+
89
+ export default function FunctionEdit() {
90
+ const { id } = useParams();
91
+ return id ? <FunctionEditWithId id={id} /> : <FunctionNew />;
92
+ }
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect } from "react";
1
+ import { Fragment } from "react";
2
2
  import PropTypes from "prop-types";
3
3
  import { useIntl } from "react-intl";
4
4
  import {
@@ -27,7 +27,6 @@ export default function FunctionEditor({
27
27
  onCancel,
28
28
  onDelete,
29
29
  isSubmitting,
30
- setDirty,
31
30
  }) {
32
31
  const { formatMessage } = useIntl();
33
32
  const form = useForm({
@@ -37,10 +36,6 @@ export default function FunctionEditor({
37
36
  const { control, handleSubmit, watch, formState, setValue } = form;
38
37
  const { isDirty, isValid } = formState;
39
38
 
40
- useEffect(() => {
41
- setDirty(isDirty);
42
- }, [setDirty, isDirty]);
43
-
44
39
  if (!selectedFunction) return null;
45
40
 
46
41
  const type = watch("type");
@@ -105,17 +100,44 @@ export default function FunctionEditor({
105
100
  </Grid.Row>
106
101
  <Grid.Row>
107
102
  <Controller
108
- name="description"
109
103
  control={control}
110
- render={({ field: { onBlur, onChange, value } }) => (
111
- <DescriptionInput
104
+ name="label"
105
+ render={({
106
+ field: { onBlur, onChange, value },
107
+ fieldState: { error },
108
+ }) => (
109
+ <Form.Input
110
+ label={formatMessage({ id: "functions.form.label" })}
111
+ placeholder={formatMessage({
112
+ id: "functions.form.label",
113
+ })}
114
+ autoComplete="off"
112
115
  onBlur={onBlur}
113
- value={value}
114
116
  onChange={(_e, { value }) => onChange(value)}
117
+ value={value ?? ""}
118
+ error={error?.message}
115
119
  />
116
120
  )}
117
121
  />
118
122
  </Grid.Row>
123
+ <Grid.Row>
124
+ <Controller
125
+ name="description"
126
+ control={control}
127
+ render={({ field: { onBlur, onChange, value } }) => (
128
+ <Form.Field>
129
+ <label>
130
+ {formatMessage({ id: "functions.form.description" })}
131
+ </label>
132
+ <DescriptionInput
133
+ onBlur={onBlur}
134
+ value={value}
135
+ onChange={(_e, { value }) => onChange(value)}
136
+ />
137
+ </Form.Field>
138
+ )}
139
+ />
140
+ </Grid.Row>
119
141
  </GridColumn>
120
142
  </Grid>
121
143
  <Header as="h5" dividing>
@@ -4,7 +4,7 @@ import PropTypes from "prop-types";
4
4
  import { useIntl } from "react-intl";
5
5
  import { Controller, useFormContext, useFieldArray } from "react-hook-form";
6
6
  import { Button, Grid, Form, List } from "semantic-ui-react";
7
- import { DescriptionInput, TypeSelector } from "@truedat/qx/components/common";
7
+ import { TypeSelector } from "@truedat/qx/components/common";
8
8
 
9
9
  export default function FunctionParams() {
10
10
  const { formatMessage } = useIntl();
@@ -45,73 +45,66 @@ const ParamItem = ({ index, onDelete }) => {
45
45
  onMouseEnter={() => setIsShownDelete(true)}
46
46
  onMouseLeave={() => setIsShownDelete(false)}
47
47
  >
48
- <Grid>
49
- <Grid.Row>
50
- <Grid.Column />
51
- <Grid.Column width={15}>
52
- <Grid.Row>
53
- <Form.Group inline className="no-margin">
48
+ <Form.Group inline className="no-margin">
49
+ <Controller
50
+ control={control}
51
+ name={`params[${index}].name`}
52
+ rules={{
53
+ required: formatMessage({ id: "functions.form.required" }),
54
+ }}
55
+ render={({
56
+ field: { onBlur, onChange, value },
57
+ fieldState: { error },
58
+ }) => (
59
+ <Form.Input
60
+ autoComplete="off"
61
+ action={
54
62
  <Controller
63
+ name={`params[${index}].type`}
55
64
  control={control}
56
- name={`params[${index}].name`}
57
- rules={{
58
- required: formatMessage({ id: "functions.form.required" }),
59
- }}
60
- render={({
61
- field: { onBlur, onChange, value },
62
- fieldState: { error },
63
- }) => (
64
- <Form.Input
65
- autoComplete="off"
66
- action={
67
- <Controller
68
- name={`params[${index}].type`}
69
- control={control}
70
- render={({ field: { onBlur, onChange, value } }) => (
71
- <TypeSelector
72
- onBlur={onBlur}
73
- value={value}
74
- onChange={(_e, { value }) => onChange(value)}
75
- />
76
- )}
77
- />
78
- }
79
- placeholder={formatMessage({ id: "functions.form.name" })}
80
- error={error?.message}
65
+ render={({ field: { onBlur, onChange, value } }) => (
66
+ <TypeSelector
81
67
  onBlur={onBlur}
82
- onChange={(_e, { value }) => onChange(value)}
83
68
  value={value}
69
+ onChange={(_e, { value }) => onChange(value)}
84
70
  />
85
71
  )}
86
72
  />
87
- {isShownDelete ? (
88
- <Button
89
- onClick={onDelete}
90
- icon="trash alternate outline"
91
- basic
92
- color="red"
93
- size="mini"
94
- aria-label="delete"
95
- />
96
- ) : null}
97
- </Form.Group>
98
- </Grid.Row>
99
- <Grid.Row>
100
- <Controller
101
- name={`params[${index}].description`}
102
- control={control}
103
- render={({ field: { onBlur, onChange, value } }) => (
104
- <DescriptionInput
105
- onBlur={onBlur}
106
- value={value}
107
- onChange={(_e, { value }) => onChange(value)}
108
- />
109
- )}
110
- />
111
- </Grid.Row>
112
- </Grid.Column>
113
- </Grid.Row>
114
- </Grid>
73
+ }
74
+ placeholder={formatMessage({ id: "functions.form.name" })}
75
+ error={error?.message}
76
+ onBlur={onBlur}
77
+ onChange={(_e, { value }) => onChange(value)}
78
+ value={value}
79
+ />
80
+ )}
81
+ />
82
+ <Controller
83
+ name={`params[${index}].description`}
84
+ control={control}
85
+ render={({ field: { onBlur, onChange, value } }) => (
86
+ <Form.Input
87
+ autoComplete="off"
88
+ placeholder={formatMessage({
89
+ id: "functions.form.description",
90
+ })}
91
+ onBlur={onBlur}
92
+ onChange={(_e, { value }) => onChange(value)}
93
+ value={value ?? ""}
94
+ />
95
+ )}
96
+ />
97
+ {isShownDelete ? (
98
+ <Button
99
+ onClick={onDelete}
100
+ icon="trash alternate outline"
101
+ basic
102
+ color="red"
103
+ size="mini"
104
+ aria-label="delete"
105
+ />
106
+ ) : null}
107
+ </Form.Group>
115
108
  </div>
116
109
  );
117
110
  };
@@ -0,0 +1,213 @@
1
+ import _ from "lodash/fp";
2
+ import { useMemo, useState } from "react";
3
+ import { compile } from "path-to-regexp";
4
+ import { Link } from "react-router";
5
+ import { useIntl, FormattedMessage } from "react-intl";
6
+ import {
7
+ Button,
8
+ Divider,
9
+ Header,
10
+ Icon,
11
+ Input,
12
+ Label,
13
+ Segment,
14
+ Table,
15
+ } from "semantic-ui-react";
16
+ import { Pagination } from "@truedat/core/components";
17
+ import { columnDecorator } from "@truedat/core/services";
18
+ import { FUNCTION, FUNCTIONS_NEW } from "@truedat/core/routes";
19
+ import { useFunctions } from "@truedat/qx/hooks/useFunctions";
20
+ import { typeLabel } from "@truedat/qx/types";
21
+
22
+ const PAGE_SIZE = 10;
23
+
24
+ const columns = [
25
+ {
26
+ name: "type",
27
+ sort: "type",
28
+ width: 2,
29
+ fieldSelector: ({ type }) => type,
30
+ fieldDecorator: (type) => typeLabel(type),
31
+ },
32
+ {
33
+ name: "name",
34
+ sort: "name",
35
+ fieldSelector: _.pick(["id", "name"]),
36
+ fieldDecorator: ({ id, name }) => (
37
+ <Link to={compile(FUNCTION)({ id: `${id}` })}>{name || "Function"}</Link>
38
+ ),
39
+ },
40
+ {
41
+ name: "label",
42
+ sort: "label",
43
+ },
44
+ {
45
+ name: "class",
46
+ sort: "class",
47
+ width: 3,
48
+ },
49
+ {
50
+ name: "description",
51
+ sort: "description",
52
+ },
53
+ {
54
+ name: "params",
55
+ width: 2,
56
+ textAlign: "center",
57
+ fieldSelector: ({ params }) => params?.length ?? 0,
58
+ fieldDecorator: (count) => count,
59
+ },
60
+ ];
61
+
62
+ function FunctionRow({ func }) {
63
+ return _.isEmpty(func) ? null : (
64
+ <Table.Row>
65
+ {columns.map((column, i) => (
66
+ <Table.Cell
67
+ key={i}
68
+ textAlign={column.textAlign}
69
+ content={columnDecorator(column)(func)}
70
+ />
71
+ ))}
72
+ </Table.Row>
73
+ );
74
+ }
75
+
76
+ export default function FunctionsList() {
77
+ const { formatMessage } = useIntl();
78
+ const { data, loading } = useFunctions();
79
+ const [searchInput, setSearchInput] = useState("");
80
+ const [sortColumn, setSortColumn] = useState(null);
81
+ const [sortDirection, setSortDirection] = useState("ascending");
82
+ const [activePage, setActivePage] = useState(1);
83
+
84
+ const userFunctions = _.reject(({ expression }) => _.isNil(expression))(
85
+ data?.data || [],
86
+ );
87
+
88
+ const handleSortSelection = (column) => {
89
+ if (sortColumn === column) {
90
+ setSortDirection(
91
+ sortDirection === "ascending" ? "descending" : "ascending",
92
+ );
93
+ } else {
94
+ setSortColumn(column);
95
+ setSortDirection("ascending");
96
+ }
97
+ setActivePage(1);
98
+ };
99
+
100
+ const handleSearch = (_e, { value }) => {
101
+ setSearchInput(value);
102
+ setActivePage(1);
103
+ };
104
+
105
+ const filtered = useMemo(() => {
106
+ const lower = searchInput.trim().toLowerCase();
107
+ return lower
108
+ ? userFunctions.filter(
109
+ ({ name, description }) =>
110
+ (name && name.toLowerCase().includes(lower)) ||
111
+ (description && description.toLowerCase().includes(lower)),
112
+ )
113
+ : userFunctions;
114
+ }, [userFunctions, searchInput]);
115
+
116
+ const sorted = useMemo(() => {
117
+ if (!sortColumn) return filtered;
118
+ const order = sortDirection === "ascending" ? "asc" : "desc";
119
+ return _.orderBy([sortColumn], [order])(filtered);
120
+ }, [filtered, sortColumn, sortDirection]);
121
+
122
+ const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
123
+ const paginated = sorted.slice(
124
+ (activePage - 1) * PAGE_SIZE,
125
+ activePage * PAGE_SIZE,
126
+ );
127
+
128
+ return (
129
+ <Segment loading={loading}>
130
+ <Header as="h2">
131
+ <Icon circular name="archive" />
132
+ <Header.Content>
133
+ <FormattedMessage id="functions.header" />
134
+ <Header.Subheader>
135
+ <FormattedMessage id="functions.subheader" />
136
+ </Header.Subheader>
137
+ </Header.Content>
138
+ </Header>
139
+ <Button
140
+ floated="right"
141
+ primary
142
+ as={Link}
143
+ to={FUNCTIONS_NEW}
144
+ content={formatMessage({ id: "functions.action.new" })}
145
+ />
146
+ <Divider hidden />
147
+ {_.isEmpty(userFunctions) ? (
148
+ <Header as="h4">
149
+ <Icon name="search" />
150
+ <Header.Content>
151
+ {formatMessage({ id: "functions.empty_list" })}
152
+ </Header.Content>
153
+ </Header>
154
+ ) : (
155
+ <>
156
+ <div>
157
+ <Input
158
+ icon
159
+ placeholder={formatMessage({ id: "search.placeholder" })}
160
+ onChange={handleSearch}
161
+ >
162
+ <input />
163
+ <Icon name="search" />
164
+ </Input>
165
+ </div>
166
+ <Label style={{ float: "right" }}>
167
+ <FormattedMessage
168
+ id="functions.list.count"
169
+ values={{ count: filtered.length }}
170
+ />
171
+ </Label>
172
+ <Divider hidden />
173
+ <Table sortable>
174
+ <Table.Header>
175
+ <Table.Row>
176
+ {columns.map((column, i) => (
177
+ <Table.HeaderCell
178
+ key={i}
179
+ width={column.width}
180
+ textAlign={column.textAlign}
181
+ sorted={
182
+ column.sort && sortColumn === column.sort
183
+ ? sortDirection
184
+ : null
185
+ }
186
+ className={column.sort ? "" : "disabled"}
187
+ onClick={() =>
188
+ column.sort && handleSortSelection(column.sort)
189
+ }
190
+ content={formatMessage({
191
+ id: `functions.table.${column.name}`,
192
+ defaultMessage: column.name,
193
+ })}
194
+ />
195
+ ))}
196
+ </Table.Row>
197
+ </Table.Header>
198
+ <Table.Body>
199
+ {paginated.map((func, i) => (
200
+ <FunctionRow key={i} func={func} />
201
+ ))}
202
+ </Table.Body>
203
+ </Table>
204
+ <Pagination
205
+ activePage={activePage}
206
+ totalPages={totalPages}
207
+ selectPage={({ activePage }) => setActivePage(activePage)}
208
+ />
209
+ </>
210
+ )}
211
+ </Segment>
212
+ );
213
+ }
@@ -0,0 +1,168 @@
1
+ import { render, waitForLoad } from "@truedat/test/render";
2
+ import { useParams } from "react-router";
3
+ import FunctionEdit from "../FunctionEdit";
4
+
5
+ jest.mock("react-router", () => ({
6
+ ...jest.requireActual("react-router"),
7
+ useParams: jest.fn(),
8
+ useNavigate: jest.fn(() => jest.fn()),
9
+ }));
10
+
11
+ jest.mock("@truedat/core/components", () => ({
12
+ Loading: () => <div data-testid="loading">loading…</div>,
13
+ }));
14
+
15
+ jest.mock(
16
+ "../FunctionEditor",
17
+ () =>
18
+ function MockFunctionEditor({ selectedFunction, onDelete }) {
19
+ return (
20
+ <div data-testid="function-editor">
21
+ <span data-testid="function-name">{selectedFunction?.name}</span>
22
+ <span data-testid="has-delete">
23
+ {onDelete ? "with-delete" : "no-delete"}
24
+ </span>
25
+ </div>
26
+ );
27
+ },
28
+ );
29
+
30
+ const mockUseFunctionFetch = jest.fn();
31
+
32
+ jest.mock("@truedat/qx/hooks/useFunctions", () => ({
33
+ useFunctions: jest.fn(() => ({
34
+ data: { data: [{ id: 1, name: "eq", type: "boolean", params: [] }] },
35
+ loading: false,
36
+ })),
37
+ useFunctionFetch: (...args) => mockUseFunctionFetch(...args),
38
+ useFunctionCreate: jest.fn(() => ({
39
+ trigger: jest.fn(() => Promise.resolve()),
40
+ isMutating: false,
41
+ })),
42
+ useFunctionUpdate: jest.fn(() => ({
43
+ trigger: jest.fn(() => Promise.resolve()),
44
+ isMutating: false,
45
+ })),
46
+ useFunctionDelete: jest.fn(() => ({
47
+ trigger: jest.fn(() => Promise.resolve()),
48
+ isMutating: false,
49
+ })),
50
+ }));
51
+
52
+ beforeEach(() => {
53
+ jest.clearAllMocks();
54
+ useParams.mockReturnValue({});
55
+ mockUseFunctionFetch.mockReturnValue({
56
+ func: null,
57
+ loading: false,
58
+ error: undefined,
59
+ });
60
+ });
61
+
62
+ describe("<FunctionEdit /> — new function", () => {
63
+ it("renders the editor with empty NEW_FUNCTION defaults when no id param", async () => {
64
+ const rendered = render(<FunctionEdit />);
65
+ await waitForLoad(rendered);
66
+
67
+ expect(rendered.getByTestId("function-editor")).toBeInTheDocument();
68
+ expect(rendered.getByTestId("function-name")).toHaveTextContent("");
69
+ expect(rendered.getByTestId("has-delete")).toHaveTextContent("no-delete");
70
+ });
71
+ });
72
+
73
+ describe("<FunctionEdit /> — edit existing function", () => {
74
+ it("shows Loading while fetching function", () => {
75
+ useParams.mockReturnValue({ id: "42" });
76
+ mockUseFunctionFetch.mockReturnValue({
77
+ data: null,
78
+ loading: true,
79
+ error: undefined,
80
+ });
81
+
82
+ const rendered = render(<FunctionEdit />);
83
+
84
+ expect(
85
+ rendered.container.querySelector(".ui.segment.loading"),
86
+ ).toBeInTheDocument();
87
+ });
88
+
89
+ it("renders nothing when fetch returns an error", async () => {
90
+ useParams.mockReturnValue({ id: "42" });
91
+ mockUseFunctionFetch.mockReturnValue({
92
+ func: null,
93
+ loading: false,
94
+ error: { message: "Not found" },
95
+ });
96
+
97
+ const rendered = render(<FunctionEdit />);
98
+ await waitForLoad(rendered);
99
+
100
+ expect(rendered.queryByTestId("function-editor")).not.toBeInTheDocument();
101
+ expect(
102
+ rendered.container.querySelector(".ui.segment.loading"),
103
+ ).not.toBeInTheDocument();
104
+ });
105
+
106
+ it("renders editor with the fetched function and a delete handler", async () => {
107
+ useParams.mockReturnValue({ id: "42" });
108
+ mockUseFunctionFetch.mockReturnValue({
109
+ data: {
110
+ data: {
111
+ id: 42,
112
+ name: "my_func",
113
+ type: "boolean",
114
+ params: [],
115
+ expression: { shape: "function", value: null },
116
+ },
117
+ loading: false,
118
+ error: undefined,
119
+ },
120
+ });
121
+
122
+ const rendered = render(<FunctionEdit />);
123
+ await waitForLoad(rendered);
124
+
125
+ expect(rendered.getByTestId("function-editor")).toBeInTheDocument();
126
+ expect(rendered.getByTestId("function-name")).toHaveTextContent("my_func");
127
+ expect(rendered.getByTestId("has-delete")).toHaveTextContent("with-delete");
128
+ });
129
+
130
+ it("hides delete for seeded functions, which the loader recreates on reboot", async () => {
131
+ useParams.mockReturnValue({ id: "42" });
132
+ mockUseFunctionFetch.mockReturnValue({
133
+ data: {
134
+ data: {
135
+ id: 42,
136
+ name: "length_gt",
137
+ type: "boolean",
138
+ seeded: true,
139
+ params: [],
140
+ expression: { shape: "function", value: null },
141
+ },
142
+ loading: false,
143
+ error: undefined,
144
+ },
145
+ });
146
+
147
+ const rendered = render(<FunctionEdit />);
148
+ await waitForLoad(rendered);
149
+
150
+ expect(rendered.getByTestId("function-name")).toHaveTextContent(
151
+ "length_gt",
152
+ );
153
+ expect(rendered.getByTestId("has-delete")).toHaveTextContent("no-delete");
154
+ });
155
+
156
+ it("passes id to useFunctionFetch", async () => {
157
+ useParams.mockReturnValue({ id: "7" });
158
+ mockUseFunctionFetch.mockReturnValue({
159
+ func: { id: 7, name: "fn", type: "string", params: [], expression: {} },
160
+ loading: false,
161
+ error: undefined,
162
+ });
163
+
164
+ render(<FunctionEdit />);
165
+
166
+ expect(mockUseFunctionFetch).toHaveBeenCalledWith("7");
167
+ });
168
+ });