@truedat/core 6.3.1 → 6.3.3
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 +4 -3
- package/src/api.js +2 -0
- package/src/components/AddMemberForm.js +178 -0
- package/src/components/AddResourceMember.js +27 -0
- package/src/components/ResourceMember.js +203 -0
- package/src/components/ResourceMembers.js +105 -0
- package/src/components/ResourceMembersActions.js +34 -0
- package/src/components/__tests__/AddMemberForm.spec.js +92 -0
- package/src/components/__tests__/AddResourceMember.spec.js +16 -0
- package/src/components/__tests__/ResourceMembers.spec.js +117 -0
- package/src/components/__tests__/ResourceMembersAction.spec.js +37 -0
- package/src/components/__tests__/__snapshots__/AddMemberForm.spec.js.snap +187 -0
- package/src/components/__tests__/__snapshots__/AddResourceMember.spec.js.snap +160 -0
- package/src/components/__tests__/__snapshots__/ResourceMembers.spec.js.snap +151 -0
- package/src/components/__tests__/__snapshots__/ResourceMembersAction.spec.js.snap +17 -0
- package/src/hooks/__tests__/useAclEntries.spec.js +143 -0
- package/src/hooks/useAclEntries.js +44 -0
- package/src/routes.js +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@truedat/core",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.3",
|
|
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.
|
|
39
|
+
"@truedat/test": "6.3.3",
|
|
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": "
|
|
121
|
+
"gitHead": "ee40175932833d26f2d0ca297ce5a3a26fb71cb2"
|
|
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;
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "@truedat/test/render";
|
|
3
|
+
import { AddResourceMember } from "../AddResourceMember";
|
|
4
|
+
|
|
5
|
+
const props = {
|
|
6
|
+
type: "domain",
|
|
7
|
+
id: 1,
|
|
8
|
+
onSuccess: jest.fn(),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("<AddResourceMember />", () => {
|
|
12
|
+
it("matches the latest snapshot", () => {
|
|
13
|
+
const { container } = render(<AddResourceMember {...props} />);
|
|
14
|
+
expect(container).toMatchSnapshot();
|
|
15
|
+
});
|
|
16
|
+
});
|