@truedat/core 6.3.0 → 6.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/core",
3
- "version": "6.3.0",
3
+ "version": "6.3.2",
4
4
  "description": "Truedat Web Core",
5
5
  "sideEffects": false,
6
6
  "jsnext:main": "src/index.js",
@@ -34,8 +34,9 @@
34
34
  "@babel/preset-react": "^7.18.6",
35
35
  "@testing-library/jest-dom": "^5.16.5",
36
36
  "@testing-library/react": "^12.0.0",
37
+ "@testing-library/react-hooks": "^8.0.1",
37
38
  "@testing-library/user-event": "^13.2.1",
38
- "@truedat/test": "6.2.3",
39
+ "@truedat/test": "6.3.2",
39
40
  "babel-jest": "^28.1.0",
40
41
  "babel-plugin-dynamic-import-node": "^2.3.3",
41
42
  "babel-plugin-lodash": "^3.3.4",
@@ -117,5 +118,5 @@
117
118
  "react-dom": ">= 16.8.6 < 17",
118
119
  "semantic-ui-react": ">= 2.0.3 < 2.2"
119
120
  },
120
- "gitHead": "fadb5776768bcbaeac2ed35e6fdbef23c0a8263a"
121
+ "gitHead": "b9ccce69ebb729f62d28972e31b2865d74e9a0d6"
121
122
  }
package/src/api.js CHANGED
@@ -6,3 +6,5 @@ export const API_MESSAGE = "/api/messages/:id";
6
6
  export const API_MESSAGES = "/api/messages";
7
7
  export const API_REINDEX_GRANTS = "/api/grants/search/reindex_all";
8
8
  export const API_REINDEX_STRUCTURES = "/api/data_structures/search/reindex_all";
9
+ export const API_ACL_ENTRY = "/api/acl_entries/:id";
10
+ export const API_ACL_RESOURCE_ENTRIES = "/api/acl_entries/:type/:id";
@@ -0,0 +1,178 @@
1
+ import _ from "lodash/fp";
2
+ import React, { useState } from "react";
3
+ import PropTypes from "prop-types";
4
+ import { Button, Form } from "semantic-ui-react";
5
+ import { useForm, Controller } from "react-hook-form";
6
+ import { useIntl } from "react-intl";
7
+ import { connect } from "react-redux";
8
+ import {
9
+ accentInsensitivePathOrder,
10
+ lowerDeburrTrim,
11
+ } from "@truedat/core/services/sort";
12
+ import { HistoryBackButton } from "@truedat/core/components";
13
+ import { getRecipients } from "@truedat/audit/selectors";
14
+ import { useAclEntryCreate } from "@truedat/core/hooks/useAclEntries";
15
+
16
+ const UsersSearchLoader = React.lazy(() =>
17
+ import("@truedat/auth/users/components/UsersSearchLoader")
18
+ );
19
+ const GroupsSearchLoader = React.lazy(() =>
20
+ import("@truedat/auth/groups/components/GroupsSearchLoader")
21
+ );
22
+
23
+ const MIN_SEARCH_CHARACTERS = 2;
24
+
25
+ const roleOption = ({ id, name }) => ({ key: id, text: name, value: name });
26
+
27
+ const roleOptions = (roles) =>
28
+ _.flow(
29
+ _.uniqBy("name"),
30
+ _.sortBy(accentInsensitivePathOrder("name")),
31
+ _.map(roleOption)
32
+ )(roles);
33
+
34
+ export const AddMemberForm = ({ resource, onSuccess, roles, options }) => {
35
+ const { trigger: addDomainMember, isMutating: isSubmitting } =
36
+ useAclEntryCreate(resource);
37
+ const { formatMessage } = useIntl();
38
+ const [usersGroupsQuery, setUsersGroupsQuery] = useState("");
39
+ const { handleSubmit, control, formState } = useForm({
40
+ mode: "all",
41
+ defaultValues: { id: null, role: null, description: "" },
42
+ });
43
+ const onSubmit = ({ id, role, description }) => {
44
+ const [principal_type, principal_id] = _.split("_")(id);
45
+ const acl_entry = {
46
+ role_name: role,
47
+ resource_type: resource.type,
48
+ principal_type,
49
+ principal_id: _.toNumber(principal_id),
50
+ description,
51
+ };
52
+ addDomainMember({ acl_entry }, { onSuccess });
53
+ };
54
+ const { isDirty, isValid } = formState;
55
+
56
+ const onSearch = (_currentOptions, { searchQuery }) =>
57
+ setUsersGroupsQuery(lowerDeburrTrim(searchQuery));
58
+
59
+ return (
60
+ <Form onSubmit={handleSubmit(onSubmit)}>
61
+ {_.size(usersGroupsQuery) >= MIN_SEARCH_CHARACTERS && (
62
+ <>
63
+ <UsersSearchLoader query={usersGroupsQuery} hideLoading />
64
+ <GroupsSearchLoader query={usersGroupsQuery} hideLoading />
65
+ </>
66
+ )}
67
+ <Controller
68
+ control={control}
69
+ name="id"
70
+ rules={{ required: true }}
71
+ render={({
72
+ field: { onBlur, onChange, value },
73
+ fieldState: { error },
74
+ }) => (
75
+ <Form.Dropdown
76
+ placeholder={formatMessage({
77
+ id: "domain.member",
78
+ })}
79
+ clearable
80
+ onChange={(_e, { value }) => onChange(value)}
81
+ deburr
82
+ minCharacters={MIN_SEARCH_CHARACTERS}
83
+ onSearchChange={onSearch}
84
+ search={_.identity}
85
+ selection
86
+ options={options}
87
+ required
88
+ label={{
89
+ children: formatMessage({ id: "domain.member" }),
90
+ htmlFor: "principal",
91
+ }}
92
+ error={!!error}
93
+ onBlur={onBlur}
94
+ value={value}
95
+ />
96
+ )}
97
+ />
98
+ <Controller
99
+ control={control}
100
+ name="role"
101
+ rules={{ required: true }}
102
+ render={({
103
+ field: { onBlur, onChange, value },
104
+ fieldState: { error },
105
+ }) => (
106
+ <Form.Select
107
+ basic
108
+ clearable
109
+ error={!!error}
110
+ label={{
111
+ children: formatMessage({ id: "domain.role" }),
112
+ htmlFor: "role",
113
+ }}
114
+ onBlur={onBlur}
115
+ onChange={(_e, { value }) => onChange(value)}
116
+ options={roles}
117
+ placeholder={formatMessage({ id: "domain.role" })}
118
+ required
119
+ search
120
+ searchInput={{ id: "role" }}
121
+ value={value}
122
+ />
123
+ )}
124
+ />
125
+ <Controller
126
+ control={control}
127
+ name="description"
128
+ render={({
129
+ field: { onBlur, onChange, value },
130
+ fieldState: { error },
131
+ }) => (
132
+ <Form.Input
133
+ autoComplete="off"
134
+ error={!!error}
135
+ id="description"
136
+ label={formatMessage({ id: "domain.role.member.description" })}
137
+ maxLength="120"
138
+ onBlur={onBlur}
139
+ onChange={(_e, { value }) => onChange(value)}
140
+ placeholder={formatMessage({
141
+ id: "domain.role.member.description",
142
+ })}
143
+ value={value}
144
+ />
145
+ )}
146
+ />
147
+ <div className="actions">
148
+ <Button
149
+ floated="right"
150
+ type="submit"
151
+ primary
152
+ loading={isSubmitting}
153
+ disabled={isSubmitting || !isDirty || !isValid}
154
+ >
155
+ {formatMessage({ id: "domain.actions.add_member" })}
156
+ </Button>
157
+ <HistoryBackButton
158
+ content={formatMessage({ id: "actions.cancel" })}
159
+ disabled={isSubmitting}
160
+ />
161
+ </div>
162
+ </Form>
163
+ );
164
+ };
165
+
166
+ AddMemberForm.propTypes = {
167
+ resource: PropTypes.object,
168
+ onSuccess: PropTypes.func,
169
+ roles: PropTypes.array,
170
+ options: PropTypes.array,
171
+ };
172
+
173
+ const mapStateToProps = (state) => ({
174
+ roles: roleOptions(state.roles),
175
+ options: getRecipients(state),
176
+ });
177
+
178
+ export default connect(mapStateToProps)(AddMemberForm);
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import PropTypes from "prop-types";
3
+
4
+ import { Container, Header, Icon, Segment } from "semantic-ui-react";
5
+ import { FormattedMessage } from "react-intl";
6
+
7
+ import AddMemberForm from "./AddMemberForm";
8
+
9
+ export const AddResourceMember = ({ type, id, onSuccess }) => (
10
+ <Container as={Segment} text>
11
+ <Header as="h2">
12
+ <Icon name="id card outline" />
13
+ <Header.Content>
14
+ <FormattedMessage id="domain.actions.add_member" />
15
+ </Header.Content>
16
+ </Header>
17
+ <AddMemberForm resource={{ type, id }} onSuccess={onSuccess} />
18
+ </Container>
19
+ );
20
+
21
+ AddResourceMember.propTypes = {
22
+ type: PropTypes.string,
23
+ onSuccess: PropTypes.func,
24
+ id: PropTypes.string,
25
+ };
26
+
27
+ export default AddResourceMember;
@@ -1,11 +1,13 @@
1
1
  import React from "react";
2
2
  import { useAuthorized } from "../hooks";
3
- import { RESOURCE_MAPPINGS, PROMPTS } from "../routes";
3
+ import { AI_SANDBOX, RESOURCE_MAPPINGS, PROMPTS, PROVIDERS } from "../routes";
4
4
  import Submenu from "./Submenu";
5
5
 
6
6
  export const ITEMS = [
7
7
  { name: "resource_mappings", routes: [RESOURCE_MAPPINGS] },
8
8
  { name: "prompts", routes: [PROMPTS] },
9
+ { name: "providers", routes: [PROVIDERS] },
10
+ { name: "ai_sandbox", routes: [AI_SANDBOX] },
9
11
  ];
10
12
 
11
13
  export default function AiMenu() {
@@ -0,0 +1,203 @@
1
+ import _ from "lodash/fp";
2
+ import React, { useState } from "react";
3
+ import { useIntl } from "react-intl";
4
+ import { Card, Form, Icon, Button, Popup } from "semantic-ui-react";
5
+ import PropTypes from "prop-types";
6
+ import { useAclEntryUpdate, useAclEntryDelete } from "../hooks/useAclEntries";
7
+
8
+ import { ConfirmModal } from "./ConfirmModal";
9
+
10
+ const memberTypeToIcon = {
11
+ group: "users",
12
+ user: "user",
13
+ };
14
+
15
+ const memberTypeToPopup = {
16
+ group: "Group",
17
+ user: "User",
18
+ };
19
+
20
+ const retrieveNameFromMember = (principal_type, principal) => {
21
+ switch (principal_type) {
22
+ case "group":
23
+ return null;
24
+ case "user":
25
+ return principal.user_name;
26
+ }
27
+ };
28
+
29
+ const retrieveFullNameFromMember = (principal_type, principal) => {
30
+ switch (principal_type) {
31
+ case "group":
32
+ return principal.name;
33
+ case "user":
34
+ return principal.full_name;
35
+ }
36
+ };
37
+
38
+ const DescriptionInput = ({
39
+ canUpdate,
40
+ description: currentDescription,
41
+ updateDescription,
42
+ }) => {
43
+ const { formatMessage } = useIntl();
44
+
45
+ const [description, setDescription] = useState(currentDescription || "");
46
+ const [editing, setEditing] = useState(false);
47
+
48
+ const handleChange = (_, { value }) => setDescription(value);
49
+
50
+ const handleSubmit = () => {
51
+ setEditing(false);
52
+ updateDescription(description);
53
+ };
54
+
55
+ const handleKeyDown = (e) => {
56
+ if (e.key === "Escape") {
57
+ setEditing(false);
58
+ setDescription(currentDescription);
59
+ }
60
+ };
61
+
62
+ return editing ? (
63
+ <Form onSubmit={handleSubmit}>
64
+ <Form.Group>
65
+ <Form.Input
66
+ autoComplete="off"
67
+ name="description"
68
+ value={description}
69
+ onKeyDown={handleKeyDown}
70
+ onChange={handleChange}
71
+ focus
72
+ inline
73
+ action={{
74
+ icon: "check",
75
+ type: "submit",
76
+ }}
77
+ />
78
+ </Form.Group>
79
+ </Form>
80
+ ) : (
81
+ <>
82
+ <span>{description}</span>{" "}
83
+ {canUpdate && (
84
+ <Popup
85
+ content={formatMessage({
86
+ id: "domain.member.update_description.tooltip",
87
+ })}
88
+ trigger={
89
+ <Icon
90
+ name="pencil alternate"
91
+ link
92
+ onClick={() => setEditing(true)}
93
+ />
94
+ }
95
+ />
96
+ )}
97
+ </>
98
+ );
99
+ };
100
+
101
+ DescriptionInput.propTypes = {
102
+ canUpdate: PropTypes.bool,
103
+ description: PropTypes.string,
104
+ updateDescription: PropTypes.func,
105
+ };
106
+
107
+ export const ResourceMember = ({
108
+ description,
109
+ role_name,
110
+ principal,
111
+ principal_type,
112
+ acl_entry_id,
113
+ onUpdate,
114
+ _links,
115
+ }) => {
116
+ const { formatMessage } = useIntl();
117
+ const { trigger: updateDescription } = useAclEntryUpdate(acl_entry_id);
118
+ const { trigger: deleteMember, isMutating: isMemberDeleting } =
119
+ useAclEntryDelete(acl_entry_id);
120
+ const memberName = retrieveNameFromMember(principal_type, principal);
121
+ const fullName = retrieveFullNameFromMember(principal_type, principal);
122
+ const canDelete = _.flow(
123
+ _.path("self.methods"),
124
+ _.includes("DELETE")
125
+ )(_links);
126
+ const canUpdate = _.flow(
127
+ _.path("self.methods"),
128
+ _.includes("UPDATE")
129
+ )(_links);
130
+ return (
131
+ <Card className="domain-member">
132
+ <Card.Content className="domain-member__content">
133
+ <Card.Header>
134
+ <Popup
135
+ key={role_name}
136
+ trigger={<Icon name={memberTypeToIcon[principal_type]} />}
137
+ content={memberTypeToPopup[principal_type]}
138
+ />
139
+ {fullName}
140
+ </Card.Header>
141
+ <Card.Description>{memberName}</Card.Description>
142
+ </Card.Content>
143
+ {canDelete && (
144
+ <Card.Content
145
+ extra
146
+ className={
147
+ description
148
+ ? "domain-member__content-with-description"
149
+ : "domain-member__content-without-description"
150
+ }
151
+ >
152
+ <Card.Description className="domain-member__description">
153
+ <DescriptionInput
154
+ canUpdate={canUpdate}
155
+ description={description}
156
+ updateDescription={(description) => {
157
+ updateDescription(
158
+ {
159
+ id: acl_entry_id,
160
+ acl_entry: { description },
161
+ },
162
+ { onSuccess: onUpdate }
163
+ );
164
+ }}
165
+ />
166
+ </Card.Description>
167
+
168
+ <ConfirmModal
169
+ header={formatMessage({ id: "domain.members.delete" })}
170
+ content={formatMessage({ id: "domain.members.delete.question" })}
171
+ onConfirm={() => deleteMember({}, { onSuccess: onUpdate })}
172
+ trigger={
173
+ <Button
174
+ className={
175
+ description
176
+ ? "button-domain_delete_description"
177
+ : "button-domain_delete_without_description"
178
+ }
179
+ basic
180
+ floated="right"
181
+ icon="trash"
182
+ negative
183
+ loading={isMemberDeleting}
184
+ />
185
+ }
186
+ />
187
+ </Card.Content>
188
+ )}
189
+ </Card>
190
+ );
191
+ };
192
+
193
+ ResourceMember.propTypes = {
194
+ _links: PropTypes.object,
195
+ acl_entry_id: PropTypes.number,
196
+ description: PropTypes.string,
197
+ principal: PropTypes.object,
198
+ principal_type: PropTypes.string,
199
+ role_name: PropTypes.string,
200
+ onUpdate: PropTypes.func,
201
+ };
202
+
203
+ export default ResourceMember;
@@ -0,0 +1,105 @@
1
+ import _ from "lodash/fp";
2
+ import React, { useState } from "react";
3
+ import { useParams } from "react-router-dom";
4
+ import PropTypes from "prop-types";
5
+ import { Card, Header, Input, Message, Grid } from "semantic-ui-react";
6
+ import { useIntl } from "react-intl";
7
+ import { accentInsensitivePathOrder } from "../services/sort";
8
+ import { useAclEntries } from "../hooks/useAclEntries";
9
+ import { Loading } from "./Loading";
10
+
11
+ import ResourceMember from "./ResourceMember";
12
+ import ResourceMembersActions from "./ResourceMembersActions";
13
+
14
+ const toSearchable = _.flow(_.deburr, _.toLower, _.trim);
15
+
16
+ const matchesFilter = (filter) =>
17
+ _.flow(
18
+ _.at([
19
+ "principal.user_name",
20
+ "principal.full_name",
21
+ "role_name",
22
+ "principal.name",
23
+ ]),
24
+ _.map(toSearchable),
25
+ _.some(_.includes(toSearchable(filter)))
26
+ );
27
+
28
+ const filterData = (filter, data) => {
29
+ return _.filter(matchesFilter(filter))(data);
30
+ };
31
+
32
+ export const ResourceMembers = ({ type }) => {
33
+ const { id } = useParams();
34
+ const {
35
+ data,
36
+ loading,
37
+ mutate: refreshAclEntries,
38
+ } = useAclEntries({ type, id });
39
+ const { aclEntries, actions } = data;
40
+ // debugger
41
+ const { formatMessage } = useIntl();
42
+ const [searchFilter, setSearchFilter] = useState("");
43
+
44
+ const filteredResourceMembers = filterData(searchFilter, aclEntries);
45
+ const groupedResourceMembers = _.flow(
46
+ _.sortBy(accentInsensitivePathOrder("role_name")),
47
+ _.groupBy("role_name"),
48
+ _.toPairs
49
+ )(filteredResourceMembers);
50
+
51
+ const handleFilterUsers = (_e, { value }) => {
52
+ setSearchFilter(value);
53
+ };
54
+ return (
55
+ <>
56
+ {!loading && (
57
+ <Grid centered columns={1}>
58
+ <Grid.Column>
59
+ <Input
60
+ onChange={handleFilterUsers}
61
+ value={searchFilter}
62
+ icon={{ name: "search", link: true }}
63
+ placeholder={formatMessage({ id: "user.search.placeholder" })}
64
+ />
65
+ <ResourceMembersActions resource={{ type, id }} actions={actions} />
66
+ </Grid.Column>
67
+ </Grid>
68
+ )}
69
+ {loading && <Loading inline="centered" />}
70
+ {_.isEmpty(filteredResourceMembers) && !loading && (
71
+ <Message
72
+ icon="warning"
73
+ header={formatMessage({ id: "domain.members.empty" })}
74
+ />
75
+ )}
76
+ {groupedResourceMembers.map(([groupName, members], groupIndex) => {
77
+ return (
78
+ <React.Fragment key={`fragment_${groupIndex}`}>
79
+ <Header as="h3" key={`group_${groupIndex}`} dividing>
80
+ {groupName}
81
+ </Header>
82
+ <Card.Group key={`card_group_${groupIndex}`}>
83
+ {_.sortBy(["principal.name", "principal.user_name"])(members).map(
84
+ (m) => (
85
+ <ResourceMember
86
+ key={`group_${groupIndex}.${m.acl_entry_id}`}
87
+ resourceId={id}
88
+ onUpdate={() => refreshAclEntries()}
89
+ {...m}
90
+ />
91
+ )
92
+ )}
93
+ </Card.Group>
94
+ </React.Fragment>
95
+ );
96
+ })}
97
+ </>
98
+ );
99
+ };
100
+
101
+ ResourceMembers.propTypes = {
102
+ type: PropTypes.string,
103
+ };
104
+
105
+ export default ResourceMembers;
@@ -0,0 +1,34 @@
1
+ import React from "react";
2
+ import PropTypes from "prop-types";
3
+ import { Link } from "react-router-dom";
4
+ import { Button } from "semantic-ui-react";
5
+ import { FormattedMessage } from "react-intl";
6
+ import { linkTo } from "../routes";
7
+
8
+ const newMemberLinks = {
9
+ domain: linkTo.DOMAIN_MEMBERS_NEW,
10
+ structure: linkTo.STRUCTURE_MEMBERS_NEW,
11
+ };
12
+
13
+ export const ResourceMembersActions = ({ resource: { type, id }, actions }) => {
14
+ const to = newMemberLinks[type]({ id });
15
+ return to && actions.canCreate ? (
16
+ <Button
17
+ floated="right"
18
+ primary
19
+ content={<FormattedMessage id="domain.actions.add_member" />}
20
+ icon="add user"
21
+ as={Link}
22
+ to={to}
23
+ />
24
+ ) : null;
25
+ };
26
+
27
+ ResourceMembersActions.propTypes = {
28
+ resource: PropTypes.object,
29
+ actions: PropTypes.shape({
30
+ canCreate: PropTypes.bool,
31
+ }),
32
+ };
33
+
34
+ export default ResourceMembersActions;
@@ -0,0 +1,92 @@
1
+ import React from "react";
2
+ import { MemoryRouter } from "react-router-dom";
3
+ import { intl } from "@truedat/test/intl-stub";
4
+ import { waitFor } from "@testing-library/react";
5
+ import { render } from "@truedat/test/render";
6
+ import userEvent from "@testing-library/user-event";
7
+ import { apiJsonPost } from "@truedat/core/services/api";
8
+ import { AddMemberForm } from "../AddMemberForm";
9
+
10
+ // workaround for enzyme issue with React.useContext
11
+ // see https://github.com/airbnb/enzyme/issues/2176#issuecomment-532361526
12
+ jest.spyOn(React, "useContext").mockImplementation(() => intl);
13
+
14
+ jest.mock("@truedat/core/services/api", () => {
15
+ const originalModule = jest.requireActual("@truedat/core/services/api");
16
+
17
+ return {
18
+ __esModule: true,
19
+ ...originalModule,
20
+ apiJsonPost: jest.fn(),
21
+ };
22
+ });
23
+
24
+ describe("<AddMemberForm />", () => {
25
+ const onSuccess = jest.fn();
26
+ const resource = {
27
+ type: "domain",
28
+ id: 1,
29
+ };
30
+ const options = [
31
+ { key: 1, text: "john", value: "user_1", id: 1 },
32
+ { key: 2, text: "mambo", value: "group_2", id: 2 },
33
+ ];
34
+ const roles = [
35
+ { key: 1, text: "role1", value: "role1" },
36
+ { key: 2, text: "role2", value: "role2" },
37
+ ];
38
+ const props = { onSuccess, resource, options, roles };
39
+
40
+ it("matches the latest snapshot", async () => {
41
+ const { container, findByRole } = render(
42
+ <MemoryRouter>
43
+ <AddMemberForm {...props} />
44
+ </MemoryRouter>
45
+ );
46
+ await findByRole("button", { name: /add_member/ });
47
+ expect(container).toMatchSnapshot();
48
+ });
49
+
50
+ it("calls onSuccess with acl data on submit correctly", async () => {
51
+ const { findByRole, getByRole, findByText } = render(
52
+ <MemoryRouter>
53
+ <AddMemberForm {...props} />
54
+ </MemoryRouter>
55
+ );
56
+
57
+ // Submit button should initially be disabled
58
+ await waitFor(() =>
59
+ expect(getByRole("button", { name: /add_member/ })).toBeDisabled()
60
+ );
61
+
62
+ // Select principal
63
+ userEvent.click(await findByText(/john/));
64
+
65
+ // Select role
66
+ userEvent.click(await findByRole("textbox", { name: "domain.role" }));
67
+ userEvent.click(await findByRole("option", { name: "role2" }));
68
+
69
+ // Submit button should now be enabled
70
+ await waitFor(() =>
71
+ expect(getByRole("button", { name: /add_member/ })).not.toBeDisabled()
72
+ );
73
+
74
+ // Description
75
+ userEvent.type(await findByRole("textbox", { name: /description/ }), "foo");
76
+
77
+ // Submit
78
+ userEvent.click(await findByRole("button", { name: /add_member/ }));
79
+
80
+ await waitFor(() =>
81
+ expect(apiJsonPost).toHaveBeenCalledWith("/api/acl_entries/domain/1", {
82
+ acl_entry: {
83
+ role_name: "role2",
84
+ resource_type: "domain",
85
+ principal_type: "user",
86
+ principal_id: 1,
87
+ description: "foo",
88
+ },
89
+ })
90
+ );
91
+ });
92
+ });