@truedat/ai 6.3.0 → 6.3.1

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.
@@ -0,0 +1,199 @@
1
+ import _ from "lodash/fp";
2
+ import React, { Fragment, useEffect } from "react";
3
+ import PropTypes from "prop-types";
4
+ import { useIntl } from "react-intl";
5
+ import {
6
+ Button,
7
+ Container,
8
+ Divider,
9
+ Header,
10
+ Form,
11
+ Dropdown,
12
+ } from "semantic-ui-react";
13
+ import { FormProvider, useForm, Controller } from "react-hook-form";
14
+ import { ConfirmModal } from "@truedat/core/components";
15
+ import { useProviderTypeOptions } from "../constants";
16
+ import { Openai, AzureOpenai, BedrockClaude } from "./providerProperties";
17
+
18
+ const providerPropertiesComponents = {
19
+ openai: <Openai />,
20
+ azure_openai: <AzureOpenai />,
21
+ bedrock_claude: <BedrockClaude />,
22
+ };
23
+
24
+ export default function ProviderEditor({
25
+ selectedProvider,
26
+ onSubmit,
27
+ onCancel,
28
+ onDelete,
29
+ isSubmitting,
30
+ setDirty,
31
+ }) {
32
+ const { formatMessage } = useIntl();
33
+ const typeOptions = useProviderTypeOptions();
34
+
35
+ const form = useForm({
36
+ mode: "onTouched",
37
+ defaultValues: selectedProvider,
38
+ });
39
+ const { control, handleSubmit, watch, formState } = form;
40
+ const { isDirty, isValid } = formState;
41
+
42
+ useEffect(() => {
43
+ setDirty(isDirty);
44
+ }, [setDirty, isDirty]);
45
+
46
+ if (!selectedProvider) return null;
47
+
48
+ const name =
49
+ watch("name") ||
50
+ formatMessage({
51
+ id: "providers.form.name.new",
52
+ });
53
+
54
+ const type = watch("type");
55
+
56
+ return (
57
+ <Fragment>
58
+ <FormProvider {...form}>
59
+ <Form>
60
+ <Header as="h3" dividing>
61
+ {name}
62
+ </Header>
63
+
64
+ <Controller
65
+ control={control}
66
+ name="name"
67
+ rules={{
68
+ required: formatMessage(
69
+ { id: "form.validation.required" },
70
+ {
71
+ prop: formatMessage({
72
+ id: "providers.form.name",
73
+ }),
74
+ }
75
+ ),
76
+ }}
77
+ render={({
78
+ field: { onBlur, onChange, value },
79
+ fieldState: { error },
80
+ }) => (
81
+ <Form.Input
82
+ autoComplete="off"
83
+ placeholder={formatMessage({
84
+ id: "providers.form.name",
85
+ })}
86
+ error={error?.message}
87
+ label={formatMessage({
88
+ id: "providers.form.name",
89
+ })}
90
+ onBlur={onBlur}
91
+ onChange={(_e, { value }) => onChange(value)}
92
+ value={value}
93
+ required
94
+ />
95
+ )}
96
+ />
97
+
98
+ <Controller
99
+ control={control}
100
+ name="type"
101
+ rules={{
102
+ required: formatMessage(
103
+ { id: "form.validation.required" },
104
+ {
105
+ prop: formatMessage({
106
+ id: "providers.form.type",
107
+ }),
108
+ }
109
+ ),
110
+ }}
111
+ render={({ field: { onBlur, onChange, value } }) => (
112
+ <Form.Field required>
113
+ <label>
114
+ {formatMessage({
115
+ id: "providers.form.type",
116
+ })}
117
+ </label>
118
+ <Dropdown
119
+ selection
120
+ onBlur={onBlur}
121
+ options={typeOptions}
122
+ onChange={(_e, { value }) => onChange(value)}
123
+ value={value}
124
+ />
125
+ </Form.Field>
126
+ )}
127
+ />
128
+
129
+ {providerPropertiesComponents[type]}
130
+
131
+ <Divider hidden />
132
+ <Container textAlign="right">
133
+ <Button
134
+ onClick={handleSubmit(onSubmit)}
135
+ primary
136
+ loading={isSubmitting}
137
+ disabled={!isValid || !isDirty}
138
+ content={formatMessage({ id: "actions.save" })}
139
+ />
140
+ {isDirty ? (
141
+ <ConfirmModal
142
+ trigger={
143
+ <Button
144
+ content={formatMessage({ id: "actions.cancel" })}
145
+ disabled={isSubmitting}
146
+ />
147
+ }
148
+ header={formatMessage({
149
+ id: "actions.discard.confirmation.header",
150
+ })}
151
+ content={formatMessage({
152
+ id: "actions.discard.confirmation.content",
153
+ })}
154
+ onConfirm={onCancel}
155
+ onOpen={(e) => e.stopPropagation()}
156
+ onClose={(e) => e.stopPropagation()}
157
+ />
158
+ ) : (
159
+ <Button
160
+ content={formatMessage({ id: "actions.cancel" })}
161
+ disabled={isSubmitting}
162
+ onClick={onCancel}
163
+ />
164
+ )}
165
+ {onDelete ? (
166
+ <ConfirmModal
167
+ trigger={
168
+ <Button
169
+ color="red"
170
+ content={formatMessage({ id: "actions.delete" })}
171
+ disabled={isSubmitting}
172
+ />
173
+ }
174
+ header={formatMessage({
175
+ id: "functions.action.delete.header",
176
+ })}
177
+ content={formatMessage({
178
+ id: "functions.action.delete.content",
179
+ })}
180
+ onConfirm={onDelete}
181
+ onOpen={(e) => e.stopPropagation()}
182
+ onClose={(e) => e.stopPropagation()}
183
+ />
184
+ ) : null}
185
+ </Container>
186
+ </Form>
187
+ </FormProvider>
188
+ </Fragment>
189
+ );
190
+ }
191
+
192
+ ProviderEditor.propTypes = {
193
+ selectedProvider: PropTypes.object,
194
+ onSubmit: PropTypes.func,
195
+ onCancel: PropTypes.func,
196
+ onDelete: PropTypes.func,
197
+ isSubmitting: PropTypes.bool,
198
+ setDirty: PropTypes.func,
199
+ };
@@ -0,0 +1,136 @@
1
+ import _ from "lodash/fp";
2
+ import React, { useState } from "react";
3
+ import { useIntl, FormattedMessage } from "react-intl";
4
+ import {
5
+ Button,
6
+ Grid,
7
+ GridColumn,
8
+ Header,
9
+ Icon,
10
+ List,
11
+ Segment,
12
+ } from "semantic-ui-react";
13
+
14
+ import {
15
+ useProviders,
16
+ useProviderCreate,
17
+ useProviderDelete,
18
+ useProviderUpdate,
19
+ } from "@truedat/ai/hooks/useProviders";
20
+ import ProviderEditor from "./ProviderEditor";
21
+
22
+ const NEW_PROVIDER = {
23
+ name: "",
24
+ type: "openai",
25
+ properties: {},
26
+ };
27
+
28
+ export default function Providers() {
29
+ const { formatMessage } = useIntl();
30
+ const { data, loading, mutate } = useProviders();
31
+ const [selectedProvider, setSelectedProvider] = useState();
32
+ const [isDirty, setDirty] = useState(false);
33
+
34
+ const providers = _.flow(_.prop("data"), _.orderBy(["id"], ["asc"]))(data);
35
+
36
+ const setStateNewProvider = () => setSelectedProvider(NEW_PROVIDER);
37
+ const clearForm = () => {
38
+ setSelectedProvider(null);
39
+ setDirty(false);
40
+ };
41
+
42
+ const { trigger: createProvider, isMutating: isCreating } =
43
+ useProviderCreate();
44
+
45
+ const { trigger: updateProvider, isMutating: isUpdating } =
46
+ useProviderUpdate(selectedProvider);
47
+
48
+ const { trigger: deleteProvider, isMutating: isDeleting } =
49
+ useProviderDelete(selectedProvider);
50
+
51
+ const isSubmitting = isCreating || isUpdating || isDeleting;
52
+ const onProviderCreate = async (provider) => {
53
+ const mutateProvider = selectedProvider?.id
54
+ ? updateProvider
55
+ : createProvider;
56
+ await mutateProvider({ provider });
57
+ clearForm();
58
+ mutate();
59
+ };
60
+
61
+ const onProviderDelete = async (provider) => {
62
+ await deleteProvider({ provider });
63
+ clearForm();
64
+ mutate();
65
+ };
66
+
67
+ return (
68
+ <Segment loading={loading}>
69
+ <Header as="h2">
70
+ <Icon circular name="map signs" />
71
+ <Header.Content>
72
+ <FormattedMessage id="providers.header" />
73
+ <Header.Subheader>
74
+ <FormattedMessage id="providers.subheader" />
75
+ </Header.Subheader>
76
+ </Header.Content>
77
+ </Header>
78
+
79
+ <Grid>
80
+ <GridColumn width={4}>
81
+ <Button fluid onClick={setStateNewProvider} disabled={isDirty}>
82
+ {formatMessage({ id: "providers.action.new" })}
83
+ </Button>
84
+ <List divided selection={!isDirty}>
85
+ {!_.isEmpty(providers) ? (
86
+ providers.map((provider, key) => (
87
+ <List.Item
88
+ key={key}
89
+ onClick={() => !isDirty && setSelectedProvider(provider)}
90
+ >
91
+ <List.Content>
92
+ <List.Header>
93
+ {provider.name ||
94
+ formatMessage({
95
+ id: "providers.form.name.new",
96
+ })}
97
+ </List.Header>
98
+ </List.Content>
99
+ </List.Item>
100
+ ))
101
+ ) : (
102
+ <List.Item>
103
+ <List.Content>
104
+ <List.Header>
105
+ {formatMessage({ id: "providers.empty_list" })}
106
+ </List.Header>
107
+ </List.Content>
108
+ </List.Item>
109
+ )}
110
+ </List>
111
+ </GridColumn>
112
+ <GridColumn width={11}>
113
+ {selectedProvider ? (
114
+ <ProviderEditor
115
+ key={selectedProvider?.id || "new"}
116
+ selectedProvider={selectedProvider}
117
+ providers={providers}
118
+ onCancel={clearForm}
119
+ onSubmit={onProviderCreate}
120
+ onDelete={selectedProvider?.id ? onProviderDelete : null}
121
+ isSubmitting={isSubmitting}
122
+ setDirty={setDirty}
123
+ />
124
+ ) : (
125
+ <Header as="h2" icon textAlign="center">
126
+ <Icon name="hand pointer outline" />
127
+ <Header.Subheader>
128
+ {formatMessage({ id: "providers.no_selection" })}
129
+ </Header.Subheader>
130
+ </Header>
131
+ )}
132
+ </GridColumn>
133
+ </Grid>
134
+ </Segment>
135
+ );
136
+ }
@@ -0,0 +1,191 @@
1
+ import React from "react";
2
+ import { act } from "react-dom/test-utils";
3
+ import { waitFor } from "@testing-library/react";
4
+ import { render } from "@truedat/test/render";
5
+ import userEvent from "@testing-library/user-event";
6
+ import ProviderEditor from "../ProviderEditor";
7
+
8
+ jest.mock("@truedat/core/hooks", () => ({
9
+ useLocales: jest.fn(() => ({
10
+ loading: false,
11
+ locales: [{ lang: "es", id: 1 }],
12
+ })),
13
+ }));
14
+
15
+ const renderOpts = {
16
+ messages: {
17
+ en: {
18
+ "form.validation.required": "form.validation.required",
19
+ "actions.save": "actions.save",
20
+ "actions.cancel": "actions.cancel",
21
+ "actions.delete": "actions.delete",
22
+ "functions.action.delete.header": "functions.action.delete.header",
23
+ "functions.action.delete.content": "functions.action.delete.content",
24
+ "providers.form.name": "providers.form.name",
25
+ "providers.form.name": "providers.form.name",
26
+ "providers.form.type": "providers.form.type",
27
+ "form.validation.required": "form.validation.required",
28
+ "actions.discard.confirmation.content":
29
+ "actions.discard.confirmation.content",
30
+ "actions.discard.confirmation.header":
31
+ "actions.discard.confirmation.header",
32
+ "confirmation.yes": "confirmation.yes",
33
+ "confirmation.no": "confirmation.no",
34
+ "providers.type.openai": "providers.type.openai",
35
+ "providers.type.azure_openai": "providers.type.azure_openai",
36
+ "providers.type.bedrock_claude": "providers.type.bedrock_claude",
37
+ "providerProperties.form.model": "providerProperties.form.model",
38
+ "providerProperties.form.organizationKey":
39
+ "providerProperties.form.organizationKey",
40
+ "providerProperties.form.apiKey": "providerProperties.form.apiKey",
41
+ },
42
+ },
43
+ fallback: "lazy",
44
+ };
45
+
46
+ const props = {
47
+ selectedProvider: {
48
+ id: 1,
49
+ name: "provider1",
50
+ type: "openai",
51
+ properties: { model: "model1", organization_key: "ok1" },
52
+ },
53
+ providers: [
54
+ {
55
+ id: 1,
56
+ name: "provider1",
57
+ type: "openai",
58
+ properties: { model: "model1", organization_key: "ok1" },
59
+ },
60
+ ],
61
+ onSubmit: jest.fn(),
62
+ onCancel: jest.fn(),
63
+ onDelete: jest.fn(),
64
+ isSubmitting: false,
65
+ setDirty: jest.fn(),
66
+ };
67
+
68
+ describe("<ProviderEditor />", () => {
69
+ it("matches the latest snapshot", async () => {
70
+ const { container } = render(<ProviderEditor {...props} />, renderOpts);
71
+ await act(async () => {
72
+ expect(container).toMatchSnapshot();
73
+ });
74
+ });
75
+
76
+ it("matches snapshot without selected resource mapping", async () => {
77
+ const props = { setDirty: jest.fn() };
78
+ const { container, queryByText } = render(
79
+ <ProviderEditor {...props} />,
80
+ renderOpts
81
+ );
82
+ await waitFor(() => expect(queryByText(/lazy/i)).not.toBeInTheDocument());
83
+ expect(container).toMatchSnapshot();
84
+ });
85
+
86
+ it("matches snapshot without onDelete", async () => {
87
+ const thisProps = {
88
+ ...props,
89
+ onDelete: null,
90
+ };
91
+ const { container } = render(<ProviderEditor {...thisProps} />, renderOpts);
92
+
93
+ await waitFor(() => expect(container).toMatchSnapshot());
94
+ });
95
+
96
+ it("test cancel button", async () => {
97
+ const onCancel = jest.fn();
98
+ const thisProps = { ...props, onCancel };
99
+ const { getByRole } = render(<ProviderEditor {...thisProps} />, renderOpts);
100
+
101
+ userEvent.click(getByRole("button", { name: /cancel/i }));
102
+
103
+ await waitFor(() => expect(onCancel).toHaveBeenCalled());
104
+ });
105
+
106
+ it("test cancel button with confirm", async () => {
107
+ const onCancel = jest.fn();
108
+ const thisProps = { ...props, onCancel };
109
+ const { container, getByRole, getAllByRole } = render(
110
+ <ProviderEditor {...thisProps} />,
111
+ renderOpts
112
+ );
113
+
114
+ userEvent.type(getAllByRole("textbox")[0], "name");
115
+
116
+ await waitFor(() =>
117
+ expect(getByRole("button", { name: /save/i })).toBeEnabled()
118
+ );
119
+
120
+ userEvent.click(getByRole("button", { name: /cancel/i }));
121
+ expect(onCancel).toHaveBeenCalledTimes(0);
122
+
123
+ userEvent.click(getByRole("button", { name: /modal-negative-action/i }));
124
+ expect(onCancel).toHaveBeenCalledTimes(0);
125
+
126
+ userEvent.click(getByRole("button", { name: /cancel/i }));
127
+ expect(onCancel).toHaveBeenCalledTimes(0);
128
+
129
+ expect(container).toMatchSnapshot();
130
+
131
+ userEvent.click(getByRole("button", { name: /modal-affirmative-action/i }));
132
+ expect(onCancel).toHaveBeenCalledTimes(1);
133
+ });
134
+
135
+ it("test delete button", async () => {
136
+ const onDelete = jest.fn();
137
+ const thisProps = { ...props, onDelete };
138
+ const { container, getByRole } = render(
139
+ <ProviderEditor {...thisProps} />,
140
+ renderOpts
141
+ );
142
+
143
+ userEvent.click(getByRole("button", { name: /delete/i }));
144
+ expect(onDelete).toHaveBeenCalledTimes(0);
145
+
146
+ userEvent.click(getByRole("button", { name: /modal-negative-action/i }));
147
+ expect(onDelete).toHaveBeenCalledTimes(0);
148
+
149
+ userEvent.click(getByRole("button", { name: /delete/i }));
150
+ expect(onDelete).toHaveBeenCalledTimes(0);
151
+
152
+ await waitFor(() => expect(container).toMatchSnapshot());
153
+
154
+ userEvent.click(getByRole("button", { name: /modal-affirmative-action/i }));
155
+ expect(onDelete).toHaveBeenCalledTimes(1);
156
+ });
157
+
158
+ it("test submit", async () => {
159
+ const onSubmit = jest.fn();
160
+ const thisProps = { ...props, onSubmit };
161
+ const { getByRole, getAllByRole } = render(
162
+ <ProviderEditor {...thisProps} />,
163
+ renderOpts
164
+ );
165
+
166
+ expect(getByRole("button", { name: /save/i })).toBeDisabled();
167
+
168
+ userEvent.type(getAllByRole("textbox")[0], "name");
169
+
170
+ await waitFor(() =>
171
+ expect(getByRole("button", { name: /save/i })).toBeEnabled()
172
+ );
173
+
174
+ userEvent.click(getByRole("button", { name: /save/i }));
175
+
176
+ await waitFor(() =>
177
+ expect(getByRole("button", { name: /save/i })).toBeEnabled()
178
+ );
179
+
180
+ expect(onSubmit.mock.calls[0][0]).toEqual({
181
+ id: 1,
182
+ name: "provider1name",
183
+ type: "openai",
184
+ properties: {
185
+ model: "model1",
186
+ organization_key: "ok1",
187
+ api_key: undefined,
188
+ },
189
+ });
190
+ });
191
+ });
@@ -0,0 +1,77 @@
1
+ import React from "react";
2
+ import { waitFor } from "@testing-library/react";
3
+ import { render } from "@truedat/test/render";
4
+ import Providers from "../Providers";
5
+
6
+ jest.mock("@truedat/ai/hooks/useProviders", () => {
7
+ const originalModule = jest.requireActual("@truedat/ai/hooks/useProviders");
8
+
9
+ return {
10
+ __esModule: true,
11
+ ...originalModule,
12
+ useProviders: jest.fn(() => ({
13
+ data: {
14
+ data: [
15
+ {
16
+ name: "rm1",
17
+ resource_type: "boolean",
18
+ fields: [{ source: "s1", target: "t1" }],
19
+ selector: { system_external_id: 1 },
20
+ },
21
+ ],
22
+ },
23
+ loading: false,
24
+ })),
25
+ useProviderCreate: jest.fn(() => ({
26
+ trigger: jest.fn(() => new Promise(() => {})),
27
+ isMutating: false,
28
+ })),
29
+ useProviderDelete: jest.fn(() => ({
30
+ trigger: jest.fn(() => new Promise(() => {})),
31
+ isMutating: false,
32
+ })),
33
+ useProviderUpdate: jest.fn(() => ({
34
+ trigger: jest.fn(() => new Promise(() => {})),
35
+ isMutating: false,
36
+ })),
37
+ };
38
+ });
39
+
40
+ describe("<Providers />", () => {
41
+ const renderOpts = {
42
+ messages: {
43
+ en: {
44
+ "providers.header": "providers.header",
45
+ "providers.subheader": "providers.subheader",
46
+ "providers.no_selection": "providers.no_selection",
47
+ "providers.empty_list": "providers.empty_list",
48
+ "providers.action.new": "providers.action.new",
49
+ "group.props.name": "name",
50
+ "providers.form.name": "name",
51
+ "providers.form.add_description": "add_description",
52
+ "providers.form.name": "name",
53
+ "providers.form.add_param": "add_param",
54
+ "actions.save": "save",
55
+ "actions.cancel": "cancel",
56
+ "actions.delete": "delete",
57
+ "providers.action.delete.header": "delete_header",
58
+ "providers.action.delete.content": "delete_content",
59
+ "providers.form.output": "output",
60
+ "providers.form.params": "params",
61
+ "form.validation.required": "required",
62
+ "confirmation.yes": "confirm_yes",
63
+ "confirmation.no": "confirm_no",
64
+ "actions.discard.confirmation.header": "confirmation_header",
65
+ "actions.discard.confirmation.content": "confirmation_content",
66
+ "providers.action.new": "providers.action.new",
67
+ },
68
+ },
69
+ fallback: "lazy",
70
+ };
71
+
72
+ it("matches the latest snapshot", async () => {
73
+ const { container, queryByText } = render(<Providers />, renderOpts);
74
+ await waitFor(() => expect(queryByText(/lazy/i)).not.toBeInTheDocument());
75
+ expect(container).toMatchSnapshot();
76
+ });
77
+ });