@truedat/dd 5.2.8 → 5.3.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.3.0] 2023-03-08
4
+
5
+ ### Added
6
+
7
+ - [TD-4438] Reference dataset field selector
8
+
9
+ ## [5.2.9] 2023-02-28
10
+
11
+ ### Added
12
+
13
+ - [TD-5599] Support for Tasks
14
+
3
15
  ## [5.2.8] 2023-02-28
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/dd",
3
- "version": "5.2.8",
3
+ "version": "5.3.0",
4
4
  "description": "Truedat Web Data Dictionary",
5
5
  "sideEffects": false,
6
6
  "jsnext:main": "src/index.js",
@@ -87,9 +87,9 @@
87
87
  },
88
88
  "dependencies": {
89
89
  "@apollo/client": "^3.7.1",
90
- "@truedat/auth": "5.2.4",
91
- "@truedat/core": "5.2.4",
92
- "@truedat/df": "5.2.4",
90
+ "@truedat/auth": "5.3.0",
91
+ "@truedat/core": "5.3.0",
92
+ "@truedat/df": "5.3.0",
93
93
  "lodash": "^4.17.21",
94
94
  "moment": "^2.29.4",
95
95
  "path-to-regexp": "^1.7.0",
@@ -114,5 +114,5 @@
114
114
  "react-dom": ">= 16.8.6 < 17",
115
115
  "semantic-ui-react": ">= 2.0.3 < 2.2"
116
116
  },
117
- "gitHead": "562bc4667fa44eeb0e224a886c41849adbd69a18"
117
+ "gitHead": "4893c314084a4ea7ca48a4e251d4e47565f3f030"
118
118
  }
@@ -446,3 +446,34 @@ export const DATA_STRUCTURE_VERSION_QUERY = gql`
446
446
  }
447
447
  }
448
448
  `;
449
+
450
+ export const TASKS_QUERY = gql`
451
+ query Tasks {
452
+ tasks {
453
+ id
454
+ index
455
+ status
456
+ started_at
457
+ last_message_at
458
+ count
459
+ processed
460
+ elapsed
461
+ }
462
+ }
463
+ `;
464
+
465
+ export const TASK_QUERY = gql`
466
+ query Task($id: String) {
467
+ task(id: $id) {
468
+ id
469
+ index
470
+ status
471
+ started_at
472
+ last_message_at
473
+ count
474
+ processed
475
+ memory_trace
476
+ elapsed
477
+ }
478
+ }
479
+ `;
@@ -11,6 +11,7 @@ import {
11
11
  STRUCTURE_TAGS,
12
12
  STRUCTURE_TYPES,
13
13
  SYSTEMS,
14
+ TASKS,
14
15
  } from "@truedat/core/routes";
15
16
  import PendingStructureNotes from "./PendingStructureNotes";
16
17
  import ProfileExecution from "./ProfileExecution";
@@ -28,6 +29,7 @@ import StructureTypesRoutes from "./StructureTypesRoutes";
28
29
  import SystemsRoutes from "./SystemsRoutes";
29
30
  import SystemsLoader from "./SystemsLoader";
30
31
  import TagsRoutes from "./TagsRoutes";
32
+ import TasksRoutes from "./TasksRoutes";
31
33
 
32
34
  const TemplatesLoader = React.lazy(() =>
33
35
  import("@truedat/df/templates/components/TemplatesLoader")
@@ -124,6 +126,7 @@ export const DictionaryRoutes = () => {
124
126
  </>
125
127
  )}
126
128
  />
129
+ <Route path={TASKS} render={() => <TasksRoutes />} />
127
130
  </>
128
131
  );
129
132
  };
@@ -0,0 +1,146 @@
1
+ import _ from "lodash/fp";
2
+ import PropTypes from "prop-types";
3
+ import React, { useEffect, useState } from "react";
4
+ import { useIntl } from "react-intl";
5
+ import { useQuery } from "@apollo/client";
6
+ import { Loading } from "@truedat/core/components";
7
+ import { Dropdown } from "semantic-ui-react";
8
+ import { REFERENCE_DATASETS_QUERY } from "@truedat/dd/api/queries";
9
+
10
+ const refDatasetsToOptions = (referenceDatasets) => {
11
+ return _.map(({ id, name }) => {
12
+ return { key: id, text: name, value: id };
13
+ })(referenceDatasets);
14
+ };
15
+
16
+ const headersToOptions = (refDatasetId) => (headers) => {
17
+ return _.map((header) => {
18
+ return {
19
+ key: `${refDatasetId}_${header}`,
20
+ text: header,
21
+ value: header,
22
+ };
23
+ })(headers);
24
+ };
25
+
26
+ const onReferenceDatasetChange =
27
+ (referenceDatasets, setHeaders, setSelectedRefDataset, onChange) =>
28
+ (_e, { value: refDatasetId }) => {
29
+ /* Only reference dataset selected, its field is still yet to be chosen.
30
+ * Therefore:
31
+ * Clear any previously selected field still showing in ConditionSummary
32
+ * Make validation fail using field nulled name so that "Next"
33
+ * RuleImplementationForm button is disabled.
34
+ */
35
+ onChange(
36
+ null,
37
+ {
38
+ type: "reference_dataset_field",
39
+ name: null,
40
+ referenceDataset: { id: refDatasetId },
41
+ },
42
+ null
43
+ );
44
+ setSelectedRefDataset(_.find({ id: refDatasetId })(referenceDatasets));
45
+ _.flow(
46
+ _.find({ id: refDatasetId }),
47
+ _.prop("headers"),
48
+ setHeaders
49
+ )(referenceDatasets);
50
+ };
51
+
52
+ export const ReferenceDatasetFieldSelector = ({
53
+ referenceDatasets,
54
+ referenceDataset,
55
+ header,
56
+ onChange,
57
+ }) => {
58
+ const { formatMessage } = useIntl();
59
+ const [headers, setHeaders] = useState(
60
+ _.find({ id: referenceDataset?.id })(referenceDatasets)?.headers || []
61
+ );
62
+ const [selectedRefDataset, setSelectedRefDataset] =
63
+ useState(referenceDataset);
64
+
65
+ const [selectedHeader, setSelectedHeader] = useState(header);
66
+
67
+ // if the operator dropdown changes, referenceDataset will come as undefined
68
+ useEffect(() => {
69
+ if (referenceDataset == null) {
70
+ setSelectedRefDataset(null);
71
+ setSelectedHeader(null);
72
+ setHeaders([]);
73
+ }
74
+ }, [referenceDataset]);
75
+
76
+ return (
77
+ <>
78
+ <label>
79
+ {formatMessage({
80
+ id: "referenceDatasetFields.dropdown.placeholder",
81
+ })}
82
+ </label>
83
+ <Dropdown
84
+ className="reference-dataset"
85
+ selection
86
+ placeholder={formatMessage({
87
+ id: "referenceDataset.dropdown.placeholder",
88
+ })}
89
+ options={refDatasetsToOptions(referenceDatasets)}
90
+ value={selectedRefDataset?.id || ""}
91
+ onChange={onReferenceDatasetChange(
92
+ referenceDatasets,
93
+ setHeaders,
94
+ setSelectedRefDataset,
95
+ onChange
96
+ )}
97
+ />
98
+ <Dropdown
99
+ selection
100
+ placeholder={formatMessage({
101
+ id: "referenceDatasetFields.dropdown.placeholder",
102
+ })}
103
+ disabled={!selectedRefDataset}
104
+ options={headersToOptions(selectedRefDataset?.id)(headers)}
105
+ value={selectedHeader || ""}
106
+ onChange={(e, { value: newHeader }) => {
107
+ setSelectedHeader(newHeader);
108
+ onChange(
109
+ null,
110
+ {
111
+ referenceDataset: _.pick(["id", "name"], selectedRefDataset),
112
+ name: newHeader,
113
+ type: "reference_dataset_field",
114
+ },
115
+ null
116
+ );
117
+ }}
118
+ />
119
+ </>
120
+ );
121
+ };
122
+
123
+ ReferenceDatasetFieldSelector.propTypes = {
124
+ header: PropTypes.string,
125
+ onChange: PropTypes.func,
126
+ referenceDatasets: PropTypes.array,
127
+ referenceDataset: PropTypes.object,
128
+ };
129
+
130
+ export const ReferenceDatasetFieldSelectorLoader = (props) => {
131
+ const { loading, error, data } = useQuery(REFERENCE_DATASETS_QUERY, {
132
+ fetchPolicy: "cache-and-network",
133
+ });
134
+ if (error) return null;
135
+ if (loading) return <Loading />;
136
+ const referenceDatasets = data?.referenceDatasets || [];
137
+ return (
138
+ <ReferenceDatasetFieldSelector
139
+ loading={loading}
140
+ referenceDatasets={referenceDatasets}
141
+ {...props}
142
+ />
143
+ );
144
+ };
145
+
146
+ export default ReferenceDatasetFieldSelectorLoader;
@@ -0,0 +1,149 @@
1
+ import _ from "lodash/fp";
2
+ import React from "react";
3
+ import { useParams, Link } from "react-router-dom";
4
+ import { useQuery } from "@apollo/client";
5
+ import { FormattedMessage } from "react-intl";
6
+ import {
7
+ Breadcrumb,
8
+ Button,
9
+ Header,
10
+ Icon,
11
+ Grid,
12
+ Item,
13
+ Segment,
14
+ Progress,
15
+ Divider,
16
+ } from "semantic-ui-react";
17
+ import { Dimmer, Loader } from "semantic-ui-react";
18
+ import Moment from "react-moment";
19
+ import { TASKS } from "@truedat/core/routes";
20
+ import { TASK_QUERY } from "../api/queries";
21
+
22
+ function Task() {
23
+ const { id } = useParams();
24
+ const { loading, data, refetch } = useQuery(TASK_QUERY, {
25
+ fetchPolicy: "cache-and-network",
26
+ variables: { id },
27
+ });
28
+
29
+ const task = _.get("task")(data);
30
+
31
+ const memoryStats = _.flow(
32
+ _.get("memory_trace"),
33
+ _.reduce(
34
+ (stats, [_ts, memory]) => ({
35
+ max: Math.max(stats.max || -Infinity, memory),
36
+ min: Math.min(stats.min || Infinity, memory),
37
+ last: memory,
38
+ }),
39
+ {}
40
+ )
41
+ )(task);
42
+
43
+ const parseMemory = (value) => `${Math.round(value / 1024 / 1024)}MB`;
44
+
45
+ const properties = [
46
+ { name: "task.index", content: task?.index },
47
+ { name: "task.status", content: task?.status },
48
+
49
+ {
50
+ name: "task.started_at",
51
+ content: <Moment date={task?.started_at} format="YYYY-MM-DD HH:mm" />,
52
+ },
53
+ {
54
+ name: "task.last_message_at",
55
+ content: (
56
+ <Moment date={task?.last_message_at} format="YYYY-MM-DD HH:mm" />
57
+ ),
58
+ },
59
+ { name: "task.elapsed", content: `${task?.elapsed / 1000}s` },
60
+ ];
61
+
62
+ const propertyElement = (name, content) => (
63
+ <Grid.Column key={name} width={3}>
64
+ <Item>
65
+ <Item.Content>
66
+ <Item.Header as="h5">
67
+ <FormattedMessage id={name} />
68
+ </Item.Header>
69
+ <Item.Description>{content}</Item.Description>
70
+ </Item.Content>
71
+ </Item>
72
+ </Grid.Column>
73
+ );
74
+
75
+ return (
76
+ <>
77
+ <Breadcrumb>
78
+ <Breadcrumb.Section as={Link} to={TASKS} active={false}>
79
+ <FormattedMessage id="tasks.header" />
80
+ </Breadcrumb.Section>
81
+ <>
82
+ <Breadcrumb.Divider icon="right angle" />
83
+ <Breadcrumb.Section active>{task?.index}</Breadcrumb.Section>
84
+ </>
85
+ </Breadcrumb>
86
+ <Segment>
87
+ <Header as="h2">
88
+ <Icon circular name="check" />
89
+ <Header.Content>
90
+ <FormattedMessage id="task.header" />
91
+ <Header.Subheader>
92
+ <FormattedMessage id="task.subheader" />
93
+ </Header.Subheader>
94
+ </Header.Content>
95
+ </Header>
96
+ <Segment basic>
97
+ <Button
98
+ primary
99
+ onClick={() => refetch()}
100
+ content={<FormattedMessage id="task.refresh" />}
101
+ />
102
+ </Segment>
103
+ <Dimmer.Dimmable dimmed={loading}>
104
+ <Dimmer active={loading} inverted>
105
+ <Loader />
106
+ </Dimmer>
107
+ {task ? (
108
+ <>
109
+ <Grid columns={4} divided>
110
+ <Grid.Row>
111
+ {properties.map(({ name, content }) =>
112
+ propertyElement(name, content)
113
+ )}
114
+ </Grid.Row>
115
+ </Grid>
116
+ <Divider />
117
+ <Progress
118
+ percent={Math.round((task.processed / task.count) * 100)}
119
+ progress
120
+ >
121
+ <FormattedMessage id="task.progress" />
122
+ </Progress>
123
+ <Divider />
124
+
125
+ <Grid columns={4} divided>
126
+ <Grid.Row>
127
+ {propertyElement(
128
+ "task.memory.max",
129
+ parseMemory(memoryStats?.max)
130
+ )}
131
+ {propertyElement(
132
+ "task.memory.min",
133
+ parseMemory(memoryStats?.min)
134
+ )}
135
+ {propertyElement(
136
+ "task.memory.last",
137
+ parseMemory(memoryStats?.last)
138
+ )}
139
+ </Grid.Row>
140
+ </Grid>
141
+ </>
142
+ ) : null}
143
+ </Dimmer.Dimmable>
144
+ </Segment>
145
+ </>
146
+ );
147
+ }
148
+
149
+ export default Task;
@@ -0,0 +1,138 @@
1
+ import _ from "lodash/fp";
2
+ import React from "react";
3
+ import PropTypes from "prop-types";
4
+ import { useHistory } from "react-router-dom";
5
+ import { useQuery } from "@apollo/client";
6
+ import { connect } from "react-redux";
7
+ import { FormattedMessage } from "react-intl";
8
+ import { Button, Header, Icon, Table, Segment } from "semantic-ui-react";
9
+ import { Dimmer, Loader } from "semantic-ui-react";
10
+ import Moment from "react-moment";
11
+ import { reindexAllGrants, reindexAllStructures } from "@truedat/core/routines";
12
+ import { linkTo } from "@truedat/core/routes";
13
+ import { TASKS_QUERY } from "../api/queries";
14
+
15
+ export const Tasks = ({ reindexAllGrants, reindexAllStructures }) => {
16
+ const { loading, data, refetch } = useQuery(TASKS_QUERY, {
17
+ fetchPolicy: "cache-and-network",
18
+ });
19
+ const history = useHistory();
20
+
21
+ const tasks = _.flow(
22
+ _.get("tasks"),
23
+ _.orderBy(["started_at"], ["desc"])
24
+ )(data);
25
+
26
+ return (
27
+ <Segment>
28
+ <Header as="h2">
29
+ <Icon circular name="check" />
30
+ <Header.Content>
31
+ <FormattedMessage id="tasks.header" />
32
+ <Header.Subheader>
33
+ <FormattedMessage id="tasks.subheader" />
34
+ </Header.Subheader>
35
+ </Header.Content>
36
+ </Header>
37
+ <Segment basic>
38
+ <Button
39
+ primary
40
+ onClick={() => refetch()}
41
+ content={<FormattedMessage id="task.refresh" />}
42
+ />
43
+ <Button
44
+ primary
45
+ floated="right"
46
+ onClick={() => {
47
+ reindexAllStructures();
48
+ refetch();
49
+ }}
50
+ content={<FormattedMessage id="task.reindex_all_structures" />}
51
+ />
52
+ <Button
53
+ primary
54
+ floated="right"
55
+ onClick={() => {
56
+ reindexAllGrants();
57
+ refetch();
58
+ }}
59
+ content={<FormattedMessage id="task.reindex_all_grants" />}
60
+ />
61
+ </Segment>
62
+ <Dimmer.Dimmable dimmed={loading}>
63
+ <Dimmer active={loading} inverted>
64
+ <Loader />
65
+ </Dimmer>
66
+ {data ? (
67
+ <Table selectable>
68
+ <Table.Header>
69
+ <Table.Row>
70
+ <Table.HeaderCell
71
+ content={<FormattedMessage id={`task.index`} />}
72
+ />
73
+ <Table.HeaderCell
74
+ content={<FormattedMessage id={`task.status`} />}
75
+ />
76
+ <Table.HeaderCell
77
+ content={<FormattedMessage id={`task.started_at`} />}
78
+ />
79
+ <Table.HeaderCell
80
+ content={<FormattedMessage id={`task.last_message_at`} />}
81
+ />
82
+ <Table.HeaderCell
83
+ content={<FormattedMessage id={`task.progress`} />}
84
+ />
85
+ <Table.HeaderCell
86
+ content={<FormattedMessage id={`task.elapsed`} />}
87
+ />
88
+ </Table.Row>
89
+ </Table.Header>
90
+ <Table.Body>
91
+ {tasks.map((task) => (
92
+ <Table.Row
93
+ key={task.started_at}
94
+ onClick={() => history.push(linkTo.TASK(task))}
95
+ >
96
+ <Table.Cell content={task.index} />
97
+ <Table.Cell content={task.status} />
98
+
99
+ <Table.Cell
100
+ content={
101
+ <Moment
102
+ date={task.started_at}
103
+ format="YYYY-MM-DD HH:mm"
104
+ />
105
+ }
106
+ />
107
+ <Table.Cell
108
+ content={
109
+ <Moment
110
+ date={task.last_message_at}
111
+ format="YYYY-MM-DD HH:mm"
112
+ />
113
+ }
114
+ />
115
+ <Table.Cell
116
+ content={
117
+ task.processed ? `${task.processed}/${task.count}` : "-"
118
+ }
119
+ />
120
+ <Table.Cell
121
+ content={task.elapsed ? `${task.elapsed / 1000}s` : "-"}
122
+ />
123
+ </Table.Row>
124
+ ))}
125
+ </Table.Body>
126
+ </Table>
127
+ ) : null}
128
+ </Dimmer.Dimmable>
129
+ </Segment>
130
+ );
131
+ };
132
+
133
+ Tasks.propTypes = {
134
+ reindexAllGrants: PropTypes.func,
135
+ reindexAllStructures: PropTypes.func,
136
+ };
137
+
138
+ export default connect(null, { reindexAllGrants, reindexAllStructures })(Tasks);
@@ -0,0 +1,27 @@
1
+ import { Route } from "react-router-dom";
2
+ import React from "react";
3
+ import { Unauthorized } from "@truedat/core/components";
4
+ import { useAuthorized } from "@truedat/core/hooks";
5
+ import { TASKS, TASK } from "@truedat/core/routes";
6
+ import Tasks from "./Tasks";
7
+ import Task from "./Task";
8
+
9
+ export const TasksRoutes = () => {
10
+ const authorized = useAuthorized();
11
+ return (
12
+ <>
13
+ <Route
14
+ exact
15
+ path={TASK}
16
+ render={() => (authorized ? <Task /> : <Unauthorized />)}
17
+ />
18
+ <Route
19
+ exact
20
+ path={TASKS}
21
+ render={() => (authorized ? <Tasks /> : <Unauthorized />)}
22
+ />
23
+ </>
24
+ );
25
+ };
26
+
27
+ export default TasksRoutes;
@@ -0,0 +1,86 @@
1
+ import React from "react";
2
+ import { waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { render } from "@truedat/test/render";
5
+ import { REFERENCE_DATASETS_QUERY } from "../../api/queries";
6
+ import ReferenceDatasetFieldSelector from "../ReferenceDatasetFieldSelector";
7
+
8
+ const referenceDatasets = [
9
+ {
10
+ __typename: "ReferenceDataset",
11
+ id: "1",
12
+ name: "countries",
13
+ headers: ["CODE", "DESC_ES", "DESC_EN"],
14
+ rowCount: 9,
15
+ updatedAt: "2023-03-04T12:10:53.245332Z",
16
+ },
17
+ {
18
+ __typename: "ReferenceDataset",
19
+ id: "2",
20
+ name: "currencies",
21
+ headers: ["ENTITY", "Currency", "Alphabetic Code", "Numeric Code"],
22
+ rowCount: 5,
23
+ updatedAt: "2023-03-05T09:57:59.765822Z",
24
+ },
25
+ ];
26
+
27
+ const referenceDatasetsMock = {
28
+ request: {
29
+ query: REFERENCE_DATASETS_QUERY,
30
+ },
31
+ result: { data: { referenceDatasets } },
32
+ };
33
+
34
+ describe("<ReferenceDatasetFieldSelector />", () => {
35
+ it("selecting reference dataset fills fields dropdown; clicking a field calls onChange", async () => {
36
+ const onChange = jest.fn();
37
+ const renderOpts = {
38
+ mocks: [referenceDatasetsMock],
39
+ };
40
+ const { queryAllByText, getByRole } = render(
41
+ <ReferenceDatasetFieldSelector onChange={onChange} />,
42
+ renderOpts
43
+ );
44
+ const countriesOptionQuery = () =>
45
+ getByRole("option", { name: /countries/i });
46
+ await waitFor(() => {
47
+ // also matches "reference dataset field", get first
48
+ expect(queryAllByText(/Reference dataset/i)[0]).toBeInTheDocument();
49
+ // same text for label and dropdown, match the second one, the dropdown
50
+ expect(queryAllByText(/Reference dataset field/i)[1]).toBeInTheDocument();
51
+ expect(countriesOptionQuery()).toBeInTheDocument();
52
+ expect(getByRole("option", { name: /currencies/i })).toBeInTheDocument();
53
+ });
54
+
55
+ userEvent.click(countriesOptionQuery());
56
+
57
+ const optionQuery = () => getByRole("option", { name: /CODE/i });
58
+ await waitFor(() => {
59
+ expect(optionQuery()).toBeInTheDocument();
60
+ });
61
+ userEvent.click(optionQuery());
62
+ expect(onChange).toHaveBeenNthCalledWith(
63
+ 1,
64
+ null,
65
+ {
66
+ name: null,
67
+ referenceDataset: { id: "1" },
68
+ type: "reference_dataset_field",
69
+ },
70
+ null
71
+ );
72
+ expect(onChange).toHaveBeenNthCalledWith(
73
+ 2,
74
+ null,
75
+ {
76
+ name: "CODE",
77
+ referenceDataset: {
78
+ id: "1",
79
+ name: "countries",
80
+ },
81
+ type: "reference_dataset_field",
82
+ },
83
+ null
84
+ );
85
+ });
86
+ });
@@ -0,0 +1,44 @@
1
+ import React from "react";
2
+ import { render } from "@truedat/test/render";
3
+ import Task from "../Task";
4
+ import { TASK_QUERY } from "../../api/queries";
5
+ import en from "../../messages/en";
6
+
7
+ jest.mock("@truedat/core/hooks", () => ({
8
+ useAuthorized: jest.fn(() => true),
9
+ }));
10
+
11
+ jest.mock("react-router-dom", () => ({
12
+ ...jest.requireActual("react-router-dom"),
13
+ useParams: () => ({ id: "123" }),
14
+ }));
15
+
16
+ const task = {
17
+ __typename: "Task",
18
+ id: "123",
19
+ started_at: "2020-01-01T00:00:00.000Z",
20
+ last_message_at: "2020-01-01T00:01:00.000Z",
21
+ index: "grants",
22
+ status: "processing",
23
+ count: 10000,
24
+ processed: 1000,
25
+ memory_trace: [["2020-01-01T00:01:00.000Z", 100000]],
26
+ elapsed: 10,
27
+ };
28
+
29
+ const taskMock = {
30
+ request: { query: TASK_QUERY, variables: { id: "123" } },
31
+ result: { data: { task } },
32
+ };
33
+
34
+ describe("<Task />", () => {
35
+ it("matches the latest snapshot", async () => {
36
+ const renderOpts = {
37
+ messages: { en },
38
+ mocks: [taskMock],
39
+ };
40
+ const { container, findByText } = render(<Task />, renderOpts);
41
+ await findByText("processing");
42
+ expect(container).toMatchSnapshot();
43
+ });
44
+ });