@truedat/dd 8.11.0 → 8.11.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.
Files changed (33) hide show
  1. package/package.json +3 -3
  2. package/src/api/queries.js +4 -0
  3. package/src/components/GrantRequest.js +74 -8
  4. package/src/components/GrantRequestCancel.js +3 -0
  5. package/src/components/GrantRequestHeader.js +45 -14
  6. package/src/components/GrantRequestsDownloadButton.js +9 -1
  7. package/src/components/GrantRequestsTable.js +4 -1
  8. package/src/components/StructureGrantCartCheckout.js +9 -4
  9. package/src/components/StructureGrantCartUserSelector.js +19 -4
  10. package/src/components/StructureGrantDropdown.js +29 -3
  11. package/src/components/StructureGrantListButton.js +3 -1
  12. package/src/components/StructureGrantSummaryButton.js +8 -4
  13. package/src/components/__tests__/GrantRequest.spec.js +437 -0
  14. package/src/components/__tests__/GrantRequestHeader.spec.js +89 -2
  15. package/src/components/__tests__/GrantRequestsDownloadButton.spec.js +52 -0
  16. package/src/components/__tests__/GrantRequestsTable.spec.js +18 -0
  17. package/src/components/__tests__/StructureGrantCartCheckout.spec.js +41 -0
  18. package/src/components/__tests__/StructureGrantCartUserSelector.spec.js +105 -0
  19. package/src/components/__tests__/StructureGrantDropdown.spec.js +126 -0
  20. package/src/components/__tests__/StructureGrantListButton.spec.js +36 -1
  21. package/src/components/__tests__/StructureGrantSummaryButton.spec.js +89 -1
  22. package/src/components/__tests__/__snapshots__/GrantRequest.spec.js.snap +28 -0
  23. package/src/components/__tests__/__snapshots__/GrantRequestHeader.spec.js.snap +5 -0
  24. package/src/messages/en.js +5 -0
  25. package/src/messages/es.js +5 -0
  26. package/src/reducers/__tests__/grantRequestsCart.spec.js +12 -0
  27. package/src/reducers/grantRequestsCart.js +12 -0
  28. package/src/sagas/__tests__/createGrantRequestStatus.spec.js +67 -1
  29. package/src/sagas/__tests__/fetchGrantRequest.spec.js +26 -0
  30. package/src/sagas/createGrantRequestStatus.js +5 -0
  31. package/src/sagas/fetchGrantRequest.js +1 -1
  32. package/src/selectors/__tests__/getGrantRequestsSearchColumns.spec.js +82 -0
  33. package/src/selectors/getGrantRequestsSearchColumns.js +51 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/dd",
3
- "version": "8.11.0",
3
+ "version": "8.11.2",
4
4
  "description": "Truedat Web Data Dictionary",
5
5
  "sideEffects": false,
6
6
  "module": "src/index.js",
@@ -54,7 +54,7 @@
54
54
  "@testing-library/jest-dom": "^6.6.3",
55
55
  "@testing-library/react": "^16.3.0",
56
56
  "@testing-library/user-event": "^14.6.1",
57
- "@truedat/test": "8.11.0",
57
+ "@truedat/test": "8.11.2",
58
58
  "identity-obj-proxy": "^3.0.0",
59
59
  "jest": "^29.7.0",
60
60
  "redux-saga-test-plan": "^4.0.6"
@@ -89,5 +89,5 @@
89
89
  "svg-pan-zoom": "^3.6.2",
90
90
  "swr": "^2.3.3"
91
91
  },
92
- "gitHead": "89b12f5fa7ff2d0fb790d88830938ccff9cb3d91"
92
+ "gitHead": "5c0ce3c555de4c9e89021b4d0ab785ed27a87305"
93
93
  }
@@ -102,6 +102,10 @@ export const LATEST_GRANT_REQUEST_BY_DS_QUERY = gql`
102
102
  grant {
103
103
  id
104
104
  }
105
+ userGroupId
106
+ createdBy {
107
+ userName
108
+ }
105
109
  }
106
110
  requestType
107
111
  status {
@@ -1,14 +1,22 @@
1
1
  import _ from "lodash/fp";
2
- import { lazy } from "react";
2
+ import { lazy, useState } from "react";
3
3
  import PropTypes from "prop-types";
4
4
  import { connect } from "react-redux";
5
5
  import { Link } from "react-router";
6
6
  import { linkTo } from "@truedat/core/routes";
7
7
  import { useIntl } from "react-intl";
8
- import { Grid, Segment, List, Label } from "semantic-ui-react";
8
+ import {
9
+ Grid,
10
+ Segment,
11
+ List,
12
+ Label,
13
+ Icon,
14
+ Accordion,
15
+ Table,
16
+ } from "semantic-ui-react";
9
17
  import GenericCrumbs from "@truedat/core/components/GenericCrumbs";
10
18
  import { getGrantRequestTemplate } from "../selectors";
11
- import GrantRequestHeader from "./GrantRequestHeader";
19
+ import { GrantRequestHeader } from "./GrantRequestHeader";
12
20
  import GrantRequestSummary from "./GrantRequestSummary";
13
21
 
14
22
  const DynamicFormViewer = lazy(
@@ -21,6 +29,7 @@ const ConditionSummary = lazy(
21
29
 
22
30
  export const GrantRequest = ({ grantRequest, template }) => {
23
31
  const { formatMessage } = useIntl();
32
+ const [membersOpen, setMembersOpen] = useState(false);
24
33
  const dynamic_content = grantRequest?.dynamic_content;
25
34
  const data_structure = grantRequest?._embedded?.data_structure;
26
35
  const path = _.flow(
@@ -29,7 +38,24 @@ export const GrantRequest = ({ grantRequest, template }) => {
29
38
  _.join(" › "),
30
39
  )(data_structure?.path);
31
40
  const rows = grantRequest?.filters?.rows;
32
- const user = grantRequest?._embedded?.group?._embedded?.user?.user_name;
41
+ const group = grantRequest?._embedded?.group;
42
+ const user = group?._embedded?.user?.user_name;
43
+ const userGroup = group?._embedded?.user_group;
44
+ const userGroupId = group?.user_group_id;
45
+ const userGroupText = userGroup
46
+ ? _.isEmpty(userGroup.alias)
47
+ ? userGroup.name
48
+ : userGroup.alias
49
+ : undefined;
50
+ const requestFor =
51
+ user ||
52
+ userGroupText ||
53
+ (userGroupId
54
+ ? formatMessage({ id: "grantRequest.deleted.group" })
55
+ : group
56
+ ? formatMessage({ id: "grantRequest.deleted.user" })
57
+ : undefined);
58
+ const members = _.isArray(userGroup?.users) ? userGroup.users : [];
33
59
  const createdBy =
34
60
  grantRequest?._embedded?.group?._embedded?.created_by?.user_name;
35
61
 
@@ -46,7 +72,7 @@ export const GrantRequest = ({ grantRequest, template }) => {
46
72
  <Grid columns="equal" style={{ marginTop: 0 }}>
47
73
  <Grid.Column width={12}>
48
74
  <Segment>
49
- <GrantRequestHeader />
75
+ <GrantRequestHeader grantRequest={grantRequest} />
50
76
  <List size="big" relaxed="very">
51
77
  {data_structure ? (
52
78
  <List.Item>
@@ -73,13 +99,53 @@ export const GrantRequest = ({ grantRequest, template }) => {
73
99
  ) : null}
74
100
  </List.Item>
75
101
  ) : null}
76
- {user && (
102
+ {requestFor && (
77
103
  <List.Item>
78
104
  <List.Header>
79
- {formatMessage({ id: "grantRequest.header.user" })}
105
+ {formatMessage({
106
+ id:
107
+ userGroup || userGroupId
108
+ ? "grantRequest.header.group"
109
+ : "grantRequest.header.user",
110
+ })}
80
111
  </List.Header>
81
112
  <List.Description>
82
- <Label>{user}</Label>
113
+ {userGroup ? (
114
+ <Accordion>
115
+ <Accordion.Title
116
+ active={membersOpen}
117
+ icon={false}
118
+ onClick={() => setMembersOpen(!membersOpen)}
119
+ >
120
+ <Label>{requestFor}</Label>
121
+ <Icon
122
+ name={membersOpen ? "angle down" : "angle right"}
123
+ />
124
+ </Accordion.Title>
125
+ <Accordion.Content active={membersOpen}>
126
+ <Table compact collapsing size="small">
127
+ <Table.Header>
128
+ <Table.Row>
129
+ <Table.HeaderCell>
130
+ {formatMessage({
131
+ id: "grantRequests.props.user_full_name",
132
+ })}
133
+ </Table.HeaderCell>
134
+ </Table.Row>
135
+ </Table.Header>
136
+ <Table.Body>
137
+ {members.map((member) => (
138
+ <Table.Row key={member.id ?? member.user_name}>
139
+ <Table.Cell>{member.full_name}</Table.Cell>
140
+ </Table.Row>
141
+ ))}
142
+ </Table.Body>
143
+ </Table>
144
+ </Accordion.Content>
145
+ </Accordion>
146
+ ) : (
147
+ <Label>{requestFor}</Label>
148
+ )}
83
149
  </List.Description>
84
150
  </List.Item>
85
151
  )}
@@ -11,6 +11,7 @@ export const GrantRequestCancel = ({
11
11
  createGrantRequestStatus,
12
12
  children,
13
13
  previousStructureQuery,
14
+ onSuccess,
14
15
  }) => {
15
16
  const { formatMessage } = useIntl();
16
17
  if (!grantRequest) return;
@@ -31,6 +32,7 @@ export const GrantRequestCancel = ({
31
32
  setLatestGrantRequest,
32
33
  status: "cancelled",
33
34
  previousStructureQuery,
35
+ onSuccess,
34
36
  })
35
37
  }
36
38
  onOpen={(e) => e.stopPropagation()}
@@ -46,6 +48,7 @@ GrantRequestCancel.propTypes = {
46
48
  children: PropTypes.node,
47
49
  previousStructureQuery: PropTypes.object,
48
50
  setLatestGrantRequest: PropTypes.func,
51
+ onSuccess: PropTypes.func,
49
52
  };
50
53
 
51
54
  export default connect(null, { createGrantRequestStatus })(GrantRequestCancel);
@@ -1,9 +1,11 @@
1
1
  import _ from "lodash/fp";
2
2
  import PropTypes from "prop-types";
3
- import { connect } from "react-redux";
3
+ import { connect, useDispatch, useSelector } from "react-redux";
4
4
  import { useIntl } from "react-intl";
5
- import { Header, Grid, Icon } from "semantic-ui-react";
5
+ import { Button, Grid, Header, Icon } from "semantic-ui-react";
6
+ import { fetchGrantRequest } from "../routines";
6
7
  import GrantRequestApprovalPopup from "./GrantRequestApprovalPopup";
8
+ import GrantRequestCancel from "./GrantRequestCancel";
7
9
 
8
10
  const selectGrantTypeOptions = (grantRequest) => {
9
11
  const [color, translationId] =
@@ -17,11 +19,23 @@ const selectGrantTypeOptions = (grantRequest) => {
17
19
 
18
20
  export const GrantRequestHeader = ({ grantRequest }) => {
19
21
  const { formatMessage } = useIntl();
22
+ const dispatch = useDispatch();
23
+ const userName = useSelector((state) => state.authentication?.user_name);
20
24
  const { translationId: grantTypeTranslationId } =
21
25
  selectGrantTypeOptions(grantRequest);
22
26
  const canApprove =
23
27
  grantRequest?.status === "pending" &&
24
28
  !_.isEmpty(grantRequest?.pending_roles);
29
+ const isGroupRequest = !_.isNil(
30
+ grantRequest?._embedded?.group?._embedded?.user_group,
31
+ );
32
+ const createdBy =
33
+ grantRequest?._embedded?.group?._embedded?.created_by?.user_name;
34
+ const canCancel =
35
+ (grantRequest?.status === "pending" ||
36
+ grantRequest?.status === "approved") &&
37
+ (!isGroupRequest || (createdBy && userName === createdBy));
38
+ const structureId = grantRequest?._embedded?.data_structure?.id;
25
39
  return (
26
40
  <Grid>
27
41
  <Grid.Column width={12}>
@@ -40,19 +54,36 @@ export const GrantRequestHeader = ({ grantRequest }) => {
40
54
  </Header.Content>
41
55
  </Header>
42
56
  </Grid.Column>
43
- {canApprove ? (
57
+ {canApprove || canCancel ? (
44
58
  <Grid.Column width={4} textAlign="right">
45
- <GrantRequestApprovalPopup
46
- content={formatMessage({ id: "grantRequest.actions.approve" })}
47
- primary
48
- grantRequest={grantRequest}
49
- />
50
- <GrantRequestApprovalPopup
51
- content={formatMessage({ id: "grantRequest.actions.reject" })}
52
- secondary
53
- isRejection
54
- grantRequest={grantRequest}
55
- />
59
+ {canApprove ? (
60
+ <>
61
+ <GrantRequestApprovalPopup
62
+ content={formatMessage({ id: "grantRequest.actions.approve" })}
63
+ primary
64
+ grantRequest={grantRequest}
65
+ />
66
+ <GrantRequestApprovalPopup
67
+ content={formatMessage({ id: "grantRequest.actions.reject" })}
68
+ secondary
69
+ isRejection
70
+ grantRequest={grantRequest}
71
+ />
72
+ </>
73
+ ) : null}
74
+ {canCancel ? (
75
+ <GrantRequestCancel
76
+ structureId={structureId}
77
+ grantRequest={grantRequest}
78
+ onSuccess={() =>
79
+ dispatch(fetchGrantRequest({ id: grantRequest?.id }))
80
+ }
81
+ >
82
+ <Button>
83
+ {formatMessage({ id: "structure.grant.cancel_grant_request" })}
84
+ </Button>
85
+ </GrantRequestCancel>
86
+ ) : null}
56
87
  </Grid.Column>
57
88
  ) : null}
58
89
  </Grid>
@@ -40,7 +40,15 @@ export const GrantRequestsDownloadButton = ({
40
40
  const grantRequests = searchData?.data;
41
41
 
42
42
  const headerLabels = _.flow(
43
- _.map((l) => [l, formatMessage({ id: `grantRequests.props.${l}` })]),
43
+ _.map((l) => [
44
+ l,
45
+ formatMessage({
46
+ id:
47
+ l === "user_full_name"
48
+ ? "grantRequests.props.user_or_group"
49
+ : `grantRequests.props.${l}`,
50
+ }),
51
+ ]),
44
52
  _.fromPairs,
45
53
  )(staticLabels);
46
54
 
@@ -46,7 +46,10 @@ export const GrantRequestsTable = ({
46
46
  key={key}
47
47
  width={column.width}
48
48
  content={formatMessage({
49
- id: `grantRequest.header.${column.name}`,
49
+ id:
50
+ column.name === "user"
51
+ ? "grantRequests.props.user_or_group"
52
+ : `grantRequest.header.${column.name}`,
50
53
  defaultMessage: column.name,
51
54
  })}
52
55
  sorted={
@@ -132,6 +132,7 @@ export const StructureGrantCartCheckout = ({
132
132
  modificationGrant,
133
133
  validCart,
134
134
  user,
135
+ user_group_id,
135
136
  structures = [],
136
137
  groupTemplates = {},
137
138
  } = grantRequestsCart;
@@ -293,6 +294,12 @@ export const StructureGrantCartCheckout = ({
293
294
 
294
295
  const canSubmit = isMultiGroup ? validCartMulti : validCart && user?.valid;
295
296
 
297
+ const grantRequestGroupIds = _.omitBy(_.isNil)({
298
+ user_id: _.isNil(user_group_id) ? user?.id : null,
299
+ user_group_id,
300
+ created_by_id: user?.id,
301
+ });
302
+
296
303
  const domainNamesByKey = useMemo(() => {
297
304
  const domainIdToName = {};
298
305
  (structures ?? []).forEach((structure) => {
@@ -344,8 +351,7 @@ export const StructureGrantCartCheckout = ({
344
351
  requests,
345
352
  type: templateName,
346
353
  modification_grant_id: modificationGrant?.id ?? null,
347
- user_id: user?.id,
348
- created_by_id: user?.id,
354
+ ...grantRequestGroupIds,
349
355
  });
350
356
  });
351
357
  // eslint-disable-next-line fp/no-mutation
@@ -362,8 +368,7 @@ export const StructureGrantCartCheckout = ({
362
368
  requests,
363
369
  type: template,
364
370
  modification_grant_id: modificationGrant?.id ?? null,
365
- user_id: user?.id,
366
- created_by_id: user?.id,
371
+ ...grantRequestGroupIds,
367
372
  }),
368
373
  };
369
374
  }
@@ -24,7 +24,7 @@ export const StructureGrantCartUserSelector = ({
24
24
  const [selectedRoles, setSelectedRoles] = useState([]);
25
25
  const [selectedRoleIds, setSelectedRoleIds] = useState([]);
26
26
  const [userId, setUserId] = useState(null);
27
- const { users, trigger } = useGrantRequestableUsers();
27
+ const { users, groups, trigger } = useGrantRequestableUsers();
28
28
 
29
29
  const structuresDomainIds = useMemo(
30
30
  () =>
@@ -38,12 +38,19 @@ export const StructureGrantCartUserSelector = ({
38
38
  const { modificationGrant } = grantRequestsCart;
39
39
  const updateGrant = !_.isEmpty(modificationGrant);
40
40
  const domainActions = ["publishGrantRequest"];
41
- const options = _.map(({ full_name, id }) => ({
41
+ const userOptions = _.map(({ full_name, id }) => ({
42
42
  key: id,
43
43
  text: full_name,
44
44
  value: id,
45
45
  icon: "user",
46
46
  }))(users);
47
+ const groupOptions = _.map(({ name, alias, id }) => ({
48
+ key: `group:${id}`,
49
+ text: _.isEmpty(alias) ? name : alias,
50
+ value: `group:${id}`,
51
+ icon: "users",
52
+ }))(groups || []);
53
+ const options = [...(userOptions || []), ...groupOptions];
47
54
 
48
55
  const { data: roles } = useRoles();
49
56
 
@@ -62,10 +69,18 @@ export const StructureGrantCartUserSelector = ({
62
69
  }, [grantRequestsCart?.user?.id, modificationGrant?.user_id, options]);
63
70
 
64
71
  useEffect(() => {
72
+ const valid =
73
+ !thirdParty || (thirdParty && !(_.isNil(userId) && _.isEmpty(userId)));
74
+
75
+ if (_.isString(userId) && _.startsWith("group:")(userId)) {
76
+ const user_group_id = Number(userId.slice("group:".length));
77
+ updateGrantRequestUser({ user_group_id, valid });
78
+ return;
79
+ }
80
+
65
81
  updateGrantRequestUser({
66
82
  id: userId,
67
- valid:
68
- !thirdParty || (thirdParty && !(_.isNil(userId) && _.isEmpty(userId))),
83
+ valid,
69
84
  });
70
85
  }, [thirdParty, updateGrantRequestUser, userId]);
71
86
 
@@ -1,7 +1,8 @@
1
1
  import _ from "lodash/fp";
2
2
  import PropTypes from "prop-types";
3
- import { Button, Dropdown, Popup, Header } from "semantic-ui-react";
3
+ import { Button, Dropdown, Popup, Header, Icon } from "semantic-ui-react";
4
4
  import { FormattedMessage, useIntl } from "react-intl";
5
+ import { useSelector } from "react-redux";
5
6
  import { useNavigate } from "react-router";
6
7
  import { linkTo } from "@truedat/core/routes";
7
8
  import { Date } from "@truedat/core/components";
@@ -22,10 +23,26 @@ export const StructureGrantDropdown = ({
22
23
  authorized,
23
24
  requested,
24
25
  previousStructureQuery,
26
+ onSuccess,
25
27
  size,
26
28
  }) => {
27
29
  const navigate = useNavigate();
28
30
  const { formatMessage } = useIntl();
31
+ const userName = useSelector((state) => state.authentication?.user_name);
32
+ const isGroupRequest = !_.isNil(
33
+ grantRequest?.group?.user_group_id ??
34
+ grantRequest?.group?.userGroupId ??
35
+ grantRequest?.group?._embedded?.user_group ??
36
+ grantRequest?._embedded?.group?.user_group_id ??
37
+ grantRequest?._embedded?.group?.userGroupId ??
38
+ grantRequest?._embedded?.group?._embedded?.user_group,
39
+ );
40
+ const createdBy =
41
+ grantRequest?.group?._embedded?.created_by?.user_name ??
42
+ grantRequest?.group?.createdBy?.userName ??
43
+ grantRequest?._embedded?.group?._embedded?.created_by?.user_name ??
44
+ grantRequest?._embedded?.group?.createdBy?.userName;
45
+ const canCancel = !isGroupRequest || (createdBy && userName === createdBy);
29
46
 
30
47
  const hasPendingGrantRequest = _.includes(grantRequestStatus)([
31
48
  "pending",
@@ -77,6 +94,7 @@ export const StructureGrantDropdown = ({
77
94
  structureId={structure.id}
78
95
  grantRequest={grantRequest}
79
96
  previousStructureQuery={previousStructureQuery}
97
+ onSuccess={onSuccess}
80
98
  >
81
99
  <Dropdown.Item
82
100
  icon="delete"
@@ -91,7 +109,9 @@ export const StructureGrantDropdown = ({
91
109
  const dropdownStates = {
92
110
  pendingRequest: {
93
111
  color: "yellow",
94
- options: [actions["viewRequest"], actions["cancelRequest"]],
112
+ options: canCancel
113
+ ? [actions["viewRequest"], actions["cancelRequest"]]
114
+ : [actions["viewRequest"]],
95
115
 
96
116
  header: isModification
97
117
  ? "structure.grant.requested_modification"
@@ -145,8 +165,13 @@ export const StructureGrantDropdown = ({
145
165
  const popupContent = (
146
166
  <>
147
167
  {stateProps?.header ? (
148
- <Header as="h6">{formatMessage({ id: stateProps.header })}</Header>
168
+ <Header as="h6">
169
+ <Header.Content>
170
+ {formatMessage({ id: stateProps.header })}
171
+ </Header.Content>
172
+ </Header>
149
173
  ) : null}
174
+ {isGroupRequest ? <Icon name="users" /> : null}
150
175
  {grantRequest ? grantRequestDetails : grantDetails}
151
176
  </>
152
177
  );
@@ -200,6 +225,7 @@ StructureGrantDropdown.propTypes = {
200
225
  authorized: PropTypes.bool,
201
226
  requested: PropTypes.bool,
202
227
  previousStructureQuery: PropTypes.object,
228
+ onSuccess: PropTypes.func,
203
229
  size: PropTypes.string,
204
230
  };
205
231
 
@@ -12,7 +12,8 @@ export const StructureGrantListButton = ({
12
12
  authorized_request,
13
13
  requested,
14
14
  }) => {
15
- const { filterParams: previousStructureQuery } = useSearchContext();
15
+ const { filterParams: previousStructureQuery, refreshSearch } =
16
+ useSearchContext();
16
17
 
17
18
  const grant = _.flow(_.prop("my_grants"), _.first)(structure);
18
19
  const grantRequest = _.prop("my_grant_request")(structure);
@@ -34,6 +35,7 @@ export const StructureGrantListButton = ({
34
35
  grantRequest={isActiveRequest ? grantRequest : null}
35
36
  grantRequestStatus={grantRequestStatus}
36
37
  previousStructureQuery={previousStructureQuery}
38
+ onSuccess={refreshSearch}
37
39
  size="mini"
38
40
  />
39
41
  ) : (
@@ -16,10 +16,13 @@ export const StructureGrantSummaryButton = ({
16
16
  }) => {
17
17
  const { grant, id } = structure;
18
18
 
19
- const { loading, error, data } = useQuery(LATEST_GRANT_REQUEST_BY_DS_QUERY, {
20
- fetchPolicy: "cache-and-network",
21
- variables: { id },
22
- });
19
+ const { loading, error, data, refetch } = useQuery(
20
+ LATEST_GRANT_REQUEST_BY_DS_QUERY,
21
+ {
22
+ fetchPolicy: "cache-and-network",
23
+ variables: { id },
24
+ },
25
+ );
23
26
  if (error) return null;
24
27
  if (loading) return <Loading />;
25
28
 
@@ -40,6 +43,7 @@ export const StructureGrantSummaryButton = ({
40
43
  requested={requested}
41
44
  grantRequest={isActiveRequest ? grantRequest : null}
42
45
  grantRequestStatus={grantRequestStatus}
46
+ onSuccess={refetch}
43
47
  />
44
48
  ) : (
45
49
  <StructureGrantRequestButton