@truedat/auth 8.14.1 → 8.14.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 (65) hide show
  1. package/package.json +4 -4
  2. package/src/groups/components/EditGroup.js +64 -20
  3. package/src/groups/components/Group.js +39 -41
  4. package/src/groups/components/GroupCard.js +51 -54
  5. package/src/groups/components/GroupCards.js +60 -25
  6. package/src/groups/components/GroupDeleteModal.js +98 -0
  7. package/src/groups/components/GroupDropdown.js +92 -0
  8. package/src/groups/components/GroupForm.js +165 -84
  9. package/src/groups/components/GroupFormModal.js +137 -0
  10. package/src/groups/components/GroupInfoModal.js +114 -0
  11. package/src/groups/components/GroupMemberPicker.js +364 -0
  12. package/src/groups/components/GroupRouteModal.js +46 -0
  13. package/src/groups/components/GroupUsers.js +31 -14
  14. package/src/groups/components/GroupUsersTable.js +136 -83
  15. package/src/groups/components/NewGroup.js +38 -10
  16. package/src/groups/components/__tests__/EditGroup.spec.js +105 -0
  17. package/src/groups/components/__tests__/Group.spec.js +29 -47
  18. package/src/groups/components/__tests__/GroupCard.spec.js +8 -20
  19. package/src/groups/components/__tests__/GroupDropdown.spec.js +27 -0
  20. package/src/groups/components/__tests__/GroupForm.spec.js +32 -5
  21. package/src/groups/components/__tests__/GroupMemberPicker.spec.js +378 -0
  22. package/src/groups/components/__tests__/GroupModals.spec.js +210 -0
  23. package/src/groups/components/__tests__/GroupUsers.spec.js +17 -2
  24. package/src/groups/components/__tests__/GroupUsersTable.spec.js +34 -2
  25. package/src/groups/components/__tests__/__snapshots__/Group.spec.js.snap +1 -0
  26. package/src/groups/components/__tests__/__snapshots__/GroupCard.spec.js.snap +32 -30
  27. package/src/groups/components/__tests__/__snapshots__/GroupCards.spec.js.snap +90 -77
  28. package/src/groups/components/__tests__/__snapshots__/GroupUsers.spec.js.snap +75 -11
  29. package/src/groups/components/__tests__/__snapshots__/GroupUsersTable.spec.js.snap +70 -13
  30. package/src/groups/components/index.js +0 -4
  31. package/src/groups/hooks/__tests__/useGroupOperation.spec.js +167 -0
  32. package/src/groups/hooks/__tests__/useGroups.spec.js +90 -0
  33. package/src/groups/hooks/useGroupOperation.js +83 -0
  34. package/src/groups/hooks/useGroups.js +64 -0
  35. package/src/groups/reducers/index.js +0 -4
  36. package/src/groups/routines.js +0 -7
  37. package/src/groups/sagas/index.js +2 -24
  38. package/src/hooks/useRoles.js +6 -3
  39. package/src/styles/Groups.less +1009 -0
  40. package/src/users/components/AdminUserRoutes.js +1 -14
  41. package/src/users/components/GroupUserCrumbs.js +7 -12
  42. package/src/users/components/UserRoutes.js +12 -35
  43. package/src/users/components/UsersAndGroups.js +3 -6
  44. package/src/users/components/__tests__/GroupUserCrumbs.spec.js +26 -30
  45. package/src/users/components/__tests__/UserRoutes.spec.js +4 -6
  46. package/src/users/components/__tests__/UsersAndGroups.spec.js +5 -7
  47. package/src/users/components/__tests__/__snapshots__/UserRoutes.spec.js.snap +9 -55
  48. package/src/groups/components/GroupLoader.js +0 -27
  49. package/src/groups/components/GroupsLoader.js +0 -30
  50. package/src/groups/components/__tests__/GroupLoader.spec.js +0 -71
  51. package/src/groups/components/__tests__/__snapshots__/GroupLoader.spec.js.snap +0 -11
  52. package/src/groups/reducers/__tests__/groupRedirect.spec.js +0 -51
  53. package/src/groups/reducers/__tests__/groupUsers.spec.js +0 -60
  54. package/src/groups/reducers/group.js +0 -56
  55. package/src/groups/reducers/groupRedirect.js +0 -20
  56. package/src/groups/reducers/groupUsers.js +0 -16
  57. package/src/groups/reducers/groups.js +0 -29
  58. package/src/groups/sagas/__tests__/deleteGroup.spec.js +0 -93
  59. package/src/groups/sagas/__tests__/deleteGroupUser.spec.js +0 -80
  60. package/src/groups/sagas/createGroup.js +0 -27
  61. package/src/groups/sagas/deleteGroup.js +0 -31
  62. package/src/groups/sagas/deleteGroupUser.js +0 -35
  63. package/src/groups/sagas/fetchGroup.js +0 -30
  64. package/src/groups/sagas/fetchGroups.js +0 -26
  65. package/src/groups/sagas/updateGroup.js +0 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/auth",
3
- "version": "8.14.1",
3
+ "version": "8.14.2",
4
4
  "description": "Truedat Web Auth",
5
5
  "sideEffects": false,
6
6
  "jsnext:main": "src/index.js",
@@ -54,14 +54,14 @@
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.14.1",
57
+ "@truedat/test": "8.14.2",
58
58
  "identity-obj-proxy": "^3.0.0",
59
59
  "jest": "^29.7.0",
60
60
  "redux-saga-test-plan": "^4.0.6"
61
61
  },
62
62
  "dependencies": {
63
63
  "@apollo/client": "^3.13.8",
64
- "@truedat/core": "8.14.1",
64
+ "@truedat/core": "8.14.2",
65
65
  "auth0-js": "^10.2.0",
66
66
  "axios": "^1.20.0",
67
67
  "graphql": "^16.11.0",
@@ -90,5 +90,5 @@
90
90
  "swr": "^2.3.3"
91
91
  },
92
92
  "overrides": {},
93
- "gitHead": "391e4e51eebb33b37b9676e541cda5f6f262e5e7"
93
+ "gitHead": "3814d239579315d817ba0e3467771e6a128e5788"
94
94
  }
@@ -1,14 +1,48 @@
1
- import _ from "lodash/fp";
2
- import PropTypes from "prop-types";
1
+ import { useNavigate, useParams } from "react-router";
3
2
  import { Header, Icon, Container, Segment } from "semantic-ui-react";
4
- import { connect } from "react-redux";
5
3
  import { FormattedMessage } from "react-intl";
6
- import { updateGroup } from "../routines";
4
+ import { GROUPS } from "@truedat/core/routes";
5
+ import { useGroup, useGroupUsers } from "../hooks/useGroups";
6
+ import { useGroupOperation } from "../hooks/useGroupOperation";
7
7
  import GroupBreadcrumbs from "./GroupBreadcrumbs";
8
8
  import GroupForm from "./GroupForm";
9
9
 
10
- const EditGroup = ({ updateGroup, group, groupUsers }) =>
11
- _.isEmpty(group) ? null : (
10
+ const EditGroup = () => {
11
+ const { id } = useParams();
12
+ const navigate = useNavigate();
13
+ const {
14
+ group,
15
+ members,
16
+ hasData: hasGroup,
17
+ loading,
18
+ error,
19
+ retry,
20
+ } = useGroup(id);
21
+ const {
22
+ users,
23
+ hasData: hasUsers,
24
+ loading: usersLoading,
25
+ error: usersError,
26
+ retry: retryUsers,
27
+ } = useGroupUsers();
28
+ const { submit, pending, failed } = useGroupOperation("update", id, () =>
29
+ navigate(GROUPS),
30
+ );
31
+ if ((loading && !hasGroup) || (usersLoading && !hasUsers))
32
+ return <p role="status">Cargando grupo…</p>;
33
+ if (!hasGroup || !hasUsers || !group)
34
+ return (
35
+ <div role="alert">
36
+ No se ha podido cargar el grupo o sus usuarios.
37
+ <button
38
+ type="button"
39
+ onClick={() => Promise.allSettled([retry(), retryUsers()])}
40
+ >
41
+ Reintentar
42
+ </button>
43
+ </div>
44
+ );
45
+ return (
12
46
  <>
13
47
  <GroupBreadcrumbs name="groups.actions.edit" />
14
48
  <Container text as={Segment}>
@@ -18,23 +52,33 @@ const EditGroup = ({ updateGroup, group, groupUsers }) =>
18
52
  <FormattedMessage id="groups.actions.edit" />
19
53
  </Header.Content>
20
54
  </Header>
21
- {group && (
22
- <GroupForm
23
- onSubmit={(props) => updateGroup({ ...props, id: group.id })}
24
- group={group}
25
- groupUsers={groupUsers}
26
- />
55
+ {(error || usersError) && (
56
+ <div role="alert">
57
+ No se ha podido actualizar el grupo o sus usuarios.
58
+ <button
59
+ type="button"
60
+ onClick={() => Promise.allSettled([retry(), retryUsers()])}
61
+ >
62
+ Reintentar
63
+ </button>
64
+ </div>
65
+ )}
66
+ {failed && (
67
+ <p role="alert">
68
+ No se ha podido guardar el grupo. Inténtalo de nuevo.
69
+ </p>
27
70
  )}
71
+ <GroupForm
72
+ key={group.id}
73
+ onSubmit={(values) => submit({ ...values, id: group.id })}
74
+ group={group}
75
+ groupUsers={members}
76
+ users={users}
77
+ isSubmitting={pending}
78
+ />
28
79
  </Container>
29
80
  </>
30
81
  );
31
-
32
- EditGroup.propTypes = {
33
- group: PropTypes.object,
34
- groupUsers: PropTypes.array,
35
- updateGroup: PropTypes.func,
36
82
  };
37
83
 
38
- const mapStateToProps = ({ group, groupUsers }) => ({ group, groupUsers });
39
-
40
- export default connect(mapStateToProps, { updateGroup })(EditGroup);
84
+ export default EditGroup;
@@ -1,50 +1,59 @@
1
1
  import PropTypes from "prop-types";
2
- import { connect } from "react-redux";
2
+ import { useCallback, useState } from "react";
3
+ import { useNavigate, useParams } from "react-router";
3
4
  import { Header, Button, Icon, Grid } from "semantic-ui-react";
4
5
  import { FormattedMessage, useIntl } from "react-intl";
5
- import ConfirmModal from "@truedat/core/components/ConfirmModal";
6
+ import { GROUPS } from "@truedat/core/routes";
6
7
  import GroupActions from "@truedat/core/components/GroupActions";
7
- import { deleteGroup } from "../routines";
8
+ import { useGroup } from "../hooks/useGroups";
9
+ import GroupDeleteModal from "./GroupDeleteModal";
8
10
 
9
- const ModalDelete = ({ group: { id, name }, deleteGroup }) => {
11
+ const ModalDelete = ({ group }) => {
12
+ const [open, setOpen] = useState(false);
13
+ const navigate = useNavigate();
14
+ const onDeleted = useCallback(() => navigate(GROUPS), [navigate]);
10
15
  return (
11
- <ConfirmModal
12
- icon="trash"
13
- trigger={
14
- <Button
15
- icon={<Icon name="trash alternate outline" color="red" />}
16
- content={<FormattedMessage id="actions.delete" />}
16
+ <>
17
+ <Button
18
+ type="button"
19
+ icon={<Icon name="trash alternate outline" color="red" />}
20
+ content={<FormattedMessage id="actions.delete" />}
21
+ onClick={() => setOpen(true)}
22
+ />
23
+ {open && (
24
+ <GroupDeleteModal
25
+ group={group}
26
+ onClose={() => setOpen(false)}
27
+ onDeleted={onDeleted}
17
28
  />
18
- }
19
- header={
20
- <FormattedMessage id="group.actions.delete.confirmation.header" />
21
- }
22
- content={
23
- <FormattedMessage
24
- id="group.actions.delete.confirmation.content"
25
- values={{ name: <i>{name}</i> }}
26
- />
27
- }
28
- onConfirm={() => deleteGroup({ id })}
29
- />
29
+ )}
30
+ </>
30
31
  );
31
32
  };
32
33
 
33
- ModalDelete.propTypes = {
34
- group: PropTypes.object,
35
- deleteGroup: PropTypes.func,
36
- };
34
+ ModalDelete.propTypes = { group: PropTypes.object.isRequired };
37
35
 
38
- export const Group = ({ group, groupLoading, deleteGroup }) => {
36
+ export const Group = () => {
37
+ const { id } = useParams();
38
+ const { group, loading, error, retry } = useGroup(id);
39
39
  const { formatMessage } = useIntl();
40
- if (groupLoading || !group.id) return null;
40
+ if (loading) return <p role="status">Cargando grupo…</p>;
41
+ if (error || !group) {
42
+ return (
43
+ <div role="alert">
44
+ No se ha podido cargar el grupo.
45
+ <Button type="button" onClick={() => retry().catch(() => {})}>
46
+ Reintentar
47
+ </Button>
48
+ </div>
49
+ );
50
+ }
41
51
  const actions = [
42
52
  {
43
53
  key: "delete",
44
54
  icon: "trash alternate outline",
45
55
  value: "delete",
46
56
  as: ModalDelete,
47
- deleteGroup,
48
57
  group,
49
58
  selected: false,
50
59
  active: false,
@@ -78,15 +87,4 @@ export const Group = ({ group, groupLoading, deleteGroup }) => {
78
87
  );
79
88
  };
80
89
 
81
- Group.propTypes = {
82
- group: PropTypes.object,
83
- groupLoading: PropTypes.bool,
84
- deleteGroup: PropTypes.func,
85
- };
86
-
87
- const mapStateToProps = (state) => ({
88
- group: state.group,
89
- groupLoading: state.groupLoading,
90
- });
91
-
92
- export default connect(mapStateToProps, { deleteGroup })(Group);
90
+ export default Group;
@@ -1,61 +1,58 @@
1
1
  import PropTypes from "prop-types";
2
- import { connect } from "react-redux";
3
- import { Button, Card, Icon } from "semantic-ui-react";
2
+ import { Icon } from "semantic-ui-react";
4
3
  import { Link } from "react-router";
5
- import { FormattedMessage } from "react-intl";
6
- import ConfirmModal from "@truedat/core/components/ConfirmModal";
4
+ import { useIntl } from "react-intl";
7
5
  import { linkTo } from "@truedat/core/routes";
8
- import { deleteGroup } from "../routines";
9
6
 
10
- export const GroupCard = ({
11
- group: { name, description, alias, id },
12
- deleteGroup,
13
- }) => (
14
- <Card key={id}>
15
- <Card.Content>
16
- <Card.Header as={Link} to={linkTo.GROUP({ id })}>
17
- <Icon name="group" />
18
- {alias || name}
19
- </Card.Header>
20
- <Card.Description>{description}</Card.Description>
21
- </Card.Content>
22
- <Card.Content extra>
23
- <div className="ui two buttons">
24
- <Button
25
- basic
26
- color="green"
27
- as={Link}
7
+ export const GroupCard = ({ group }) => {
8
+ const { formatMessage } = useIntl();
9
+ const { id, name, alias, description, users } = group;
10
+ const displayName = alias || name;
11
+ const count = Array.isArray(users) ? users.length : null;
12
+ const isEmpty = count === 0;
13
+ return (
14
+ <article className="td-group-card">
15
+ <Link className="gc-content" to={linkTo.GROUP({ id })}>
16
+ <span className="gc-avatar" aria-hidden="true">
17
+ <Icon name="users" />
18
+ </span>
19
+ <h3>{displayName}</h3>
20
+ {alias && alias !== name && <span className="gc-name">{name}</span>}
21
+ {description && <p className="gc-description">{description}</p>}
22
+ </Link>
23
+ <div className="gc-actions">
24
+ <Link
28
25
  to={linkTo.GROUP_EDIT({ id })}
29
- content={<FormattedMessage id="actions.edit" />}
30
- />
31
- <ConfirmModal
32
- icon="trash"
33
- trigger={
34
- <Button
35
- basic
36
- negative
37
- content={<FormattedMessage id="actions.delete" />}
38
- />
39
- }
40
- header={
41
- <FormattedMessage id="group.actions.delete.confirmation.header" />
42
- }
43
- content={
44
- <FormattedMessage
45
- id="group.actions.delete.confirmation.content"
46
- values={{ name: <i>{alias || name}</i> }}
47
- />
48
- }
49
- onConfirm={() => deleteGroup({ id })}
50
- />
26
+ aria-label={formatMessage({ id: "actions.edit" })}
27
+ title={formatMessage({ id: "actions.edit" })}
28
+ >
29
+ <Icon name="pencil alternate" />
30
+ <span className="gc-action-label">
31
+ {formatMessage({ id: "actions.edit" })}
32
+ </span>
33
+ </Link>
51
34
  </div>
52
- </Card.Content>
53
- </Card>
54
- );
55
-
56
- GroupCard.propTypes = {
57
- group: PropTypes.object,
58
- deleteGroup: PropTypes.func,
35
+ {count !== null && (
36
+ <span className={`gc-count${isEmpty ? " gc-count--empty" : ""}`}>
37
+ <Icon name="users" />
38
+ {isEmpty ? (
39
+ <span className="gc-count-empty-marker" aria-hidden="true">
40
+ -
41
+ </span>
42
+ ) : (
43
+ count
44
+ )}
45
+ <span className="gc-count-label">
46
+ {formatMessage(
47
+ isEmpty
48
+ ? { id: "group.members.none", defaultMessage: "Sin miembros" }
49
+ : { id: "navigation.members", defaultMessage: "miembros" },
50
+ )}
51
+ </span>
52
+ </span>
53
+ )}
54
+ </article>
55
+ );
59
56
  };
60
-
61
- export default connect(null, { deleteGroup })(GroupCard);
57
+ GroupCard.propTypes = { group: PropTypes.object.isRequired };
58
+ export default GroupCard;
@@ -1,33 +1,68 @@
1
1
  import PropTypes from "prop-types";
2
2
  import { Link } from "react-router";
3
- import { connect } from "react-redux";
4
- import { Icon, Card } from "semantic-ui-react";
3
+ import { Button, Icon } from "semantic-ui-react";
5
4
  import { FormattedMessage } from "react-intl";
6
5
  import { GROUP_CREATE } from "@truedat/core/routes";
6
+ import { useGroups } from "../hooks/useGroups";
7
7
  import GroupCard from "./GroupCard";
8
8
 
9
- export const GroupCards = ({ groups }) => (
10
- <Card.Group>
11
- <Card link as={Link} to={GROUP_CREATE} className="create group">
12
- <Card.Content textAlign="center">
13
- <Card.Header>
14
- <FormattedMessage id="groups.actions.create" />
15
- </Card.Header>
16
- <Card.Description>
17
- <Icon floated="center" name="add" size="massive" />
18
- </Card.Description>
19
- </Card.Content>
20
- </Card>
21
- {groups.map((group, i) => (
22
- <GroupCard key={i} group={group} />
23
- ))}
24
- </Card.Group>
9
+ export const GroupCards = ({ groups = [] }) => (
10
+ <>
11
+ <div className="groups-toolbar">
12
+ <Button
13
+ primary
14
+ as={Link}
15
+ to={GROUP_CREATE}
16
+ className="group-create-button"
17
+ >
18
+ <span className="group-create-icon" aria-hidden="true">
19
+ <Icon name="users" />
20
+ <svg viewBox="0 0 16 16" focusable="false">
21
+ <path
22
+ d="M8 3v10M3 8h10"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ strokeWidth="2"
26
+ strokeLinecap="round"
27
+ />
28
+ </svg>
29
+ </span>
30
+ <FormattedMessage id="groups.actions.create" />
31
+ </Button>
32
+ </div>
33
+ <div className="groups-card-list">
34
+ {groups.map((group) => (
35
+ <GroupCard key={group.id} group={group} />
36
+ ))}
37
+ </div>
38
+ </>
25
39
  );
26
-
27
- GroupCards.propTypes = {
28
- groups: PropTypes.array,
40
+ GroupCards.propTypes = { groups: PropTypes.array };
41
+ export const GroupsList = () => {
42
+ const { groups, error, loading, retry } = useGroups();
43
+ return (
44
+ <>
45
+ {loading && (
46
+ <p role="status">
47
+ <FormattedMessage
48
+ id="group.loading"
49
+ defaultMessage="Cargando grupo…"
50
+ />
51
+ </p>
52
+ )}
53
+ {error && (
54
+ <div role="alert">
55
+ <FormattedMessage id="group.unavailable" />
56
+ <Button onClick={() => retry().catch(() => {})}>
57
+ <FormattedMessage
58
+ id="group.members.retry"
59
+ defaultMessage="Reintentar"
60
+ />
61
+ </Button>
62
+ </div>
63
+ )}
64
+ <GroupCards groups={groups} />
65
+ </>
66
+ );
29
67
  };
30
-
31
- const mapStateToProps = ({ groups }) => ({ groups });
32
-
33
- export default connect(mapStateToProps)(GroupCards);
68
+ export default GroupsList;
@@ -0,0 +1,98 @@
1
+ import PropTypes from "prop-types";
2
+ import { useIntl } from "react-intl";
3
+ import { TrueModal } from "@truedat/core/components/TrueModal";
4
+ import { useGroupOperation } from "../hooks/useGroupOperation";
5
+
6
+ export const GroupDeleteModal = ({ group, onClose, onDeleted, member }) => {
7
+ const { formatMessage } = useIntl();
8
+ const { submit, pending, failed } = useGroupOperation(
9
+ member ? "removeMember" : "delete",
10
+ group?.id,
11
+ () => {
12
+ onClose();
13
+ onDeleted?.();
14
+ },
15
+ );
16
+ const message = (id, defaultMessage) => formatMessage({ id, defaultMessage });
17
+ return (
18
+ <TrueModal
19
+ open
20
+ size="mini"
21
+ headerColor="#ffffff"
22
+ headerTextColor="var(--td-danger, #b91c1c)"
23
+ aria-busy={pending}
24
+ className="group-modal group-modal--delete"
25
+ title={message(
26
+ member
27
+ ? "groupUsers.actions.delete.confirmation.header"
28
+ : "group.actions.delete.confirmation.header",
29
+ )}
30
+ icon="warning sign"
31
+ onClose={() => !pending && onClose()}
32
+ closeDisabled={pending}
33
+ closeOnEscape={!pending}
34
+ closeOnDimmerClick={!pending}
35
+ banner={
36
+ failed
37
+ ? message(
38
+ "group.operation.error",
39
+ "No se ha podido completar la operación. Inténtalo de nuevo.",
40
+ )
41
+ : null
42
+ }
43
+ bannerType="danger"
44
+ actions={[
45
+ {
46
+ key: "cancel",
47
+ content: message("actions.cancel"),
48
+ secondary: true,
49
+ disabled: pending,
50
+ onClick: onClose,
51
+ },
52
+ {
53
+ key: "delete",
54
+ content: message("actions.delete"),
55
+ negative: true,
56
+ loading: pending,
57
+ disabled: pending,
58
+ onClick: () => submit(member ? { id: member.id } : {}),
59
+ },
60
+ ]}
61
+ >
62
+ <p>
63
+ {formatMessage(
64
+ {
65
+ id: member
66
+ ? "groupUsers.actions.delete.confirmation.content"
67
+ : "group.delete.description",
68
+ defaultMessage: member
69
+ ? "Se va a eliminar al usuario seleccionado del grupo, ¿estás seguro?"
70
+ : "Se va a eliminar el grupo <strong>{name}</strong>. <danger>Esta acción no se puede deshacer.</danger>",
71
+ },
72
+ {
73
+ name: group?.alias || group?.name,
74
+ strong: (chunks) => <strong>{chunks}</strong>,
75
+ danger: (chunks) => (
76
+ <span className="group-danger-text">{chunks}</span>
77
+ ),
78
+ },
79
+ )}
80
+ </p>
81
+ {!member && (
82
+ <p>
83
+ {message(
84
+ "group.delete.confirm",
85
+ "¿Seguro que quieres eliminar este grupo?",
86
+ )}
87
+ </p>
88
+ )}
89
+ </TrueModal>
90
+ );
91
+ };
92
+ GroupDeleteModal.propTypes = {
93
+ group: PropTypes.object.isRequired,
94
+ onClose: PropTypes.func.isRequired,
95
+ onDeleted: PropTypes.func,
96
+ member: PropTypes.object,
97
+ };
98
+ export default GroupDeleteModal;
@@ -0,0 +1,92 @@
1
+ import { useLayoutEffect, useRef, useState } from "react";
2
+ import PropTypes from "prop-types";
3
+ import { Form } from "semantic-ui-react";
4
+
5
+ const GroupDropdown = ({ onOpen, onClose, ...props }) => {
6
+ const anchor = useRef(null);
7
+ const [open, setOpen] = useState(false);
8
+ useLayoutEffect(() => {
9
+ if (!open) return undefined;
10
+ const trigger = anchor.current?.querySelector(".ui.dropdown");
11
+ const menu = trigger?.querySelector(":scope > .menu");
12
+ if (!menu) return undefined;
13
+ const previousStyle = menu.getAttribute("style");
14
+ const frame = { current: null };
15
+ const position = () => {
16
+ const rect = trigger.getBoundingClientRect();
17
+ const below = window.innerHeight - rect.bottom - 8;
18
+ const above = rect.top - 8;
19
+ const upward = below < 180 && above > below;
20
+ const maxHeight = Math.max(0, Math.min(180, upward ? above : below));
21
+ const width = Math.min(rect.width, window.innerWidth - 16);
22
+ const styles = {
23
+ position: "fixed",
24
+ margin: "0",
25
+ inset: "auto",
26
+ left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`,
27
+ width: `${width}px`,
28
+ minWidth: "0",
29
+ maxHeight: `${maxHeight}px`,
30
+ top: upward ? "auto" : `${rect.bottom}px`,
31
+ bottom: upward ? `${window.innerHeight - rect.top}px` : "auto",
32
+ };
33
+ Object.assign(menu.style, styles);
34
+ };
35
+ const schedule = (event) => {
36
+ if (event?.target === menu) return;
37
+ cancelAnimationFrame(frame.current);
38
+ frame.current = requestAnimationFrame(position);
39
+ };
40
+ menu.classList.add("group-dropdown-layer");
41
+ menu.setAttribute("popover", "manual");
42
+ position();
43
+ menu.showPopover?.();
44
+ const handleEscape = (event) => {
45
+ if (event.key !== "Escape") return;
46
+ event.preventDefault();
47
+ event.stopImmediatePropagation();
48
+ setOpen(false);
49
+ };
50
+ window.addEventListener("keydown", handleEscape, true);
51
+ window.addEventListener("resize", schedule);
52
+ document.addEventListener("scroll", schedule, true);
53
+ const observer =
54
+ typeof ResizeObserver === "undefined"
55
+ ? null
56
+ : new ResizeObserver(schedule);
57
+ observer?.observe(trigger);
58
+ return () => {
59
+ cancelAnimationFrame(frame.current);
60
+ observer?.disconnect();
61
+ window.removeEventListener("keydown", handleEscape, true);
62
+ window.removeEventListener("resize", schedule);
63
+ document.removeEventListener("scroll", schedule, true);
64
+ if (menu.hidePopover && menu.matches(":popover-open")) menu.hidePopover();
65
+ menu.removeAttribute("popover");
66
+ menu.classList.remove("group-dropdown-layer");
67
+ if (previousStyle === null) menu.removeAttribute("style");
68
+ else menu.setAttribute("style", previousStyle);
69
+ };
70
+ }, [open]);
71
+ return (
72
+ <div className="group-dropdown-anchor" ref={anchor}>
73
+ <Form.Dropdown
74
+ {...props}
75
+ open={open}
76
+ onOpen={(event, data) => {
77
+ anchor.current
78
+ ?.querySelector("input.search")
79
+ ?.focus({ preventScroll: true });
80
+ setOpen(true);
81
+ onOpen?.(event, data);
82
+ }}
83
+ onClose={(event, data) => {
84
+ setOpen(false);
85
+ onClose?.(event, data);
86
+ }}
87
+ />
88
+ </div>
89
+ );
90
+ };
91
+ GroupDropdown.propTypes = { onOpen: PropTypes.func, onClose: PropTypes.func };
92
+ export default GroupDropdown;