@k-int/stripes-kint-components 5.9.0 → 5.11.0

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,145 @@
1
+ import { useContext } from 'react';
2
+ import PropTypes from 'prop-types';
3
+
4
+ import { useQuery, useMutation, useQueryClient } from 'react-query';
5
+ import { CalloutContext, useOkapiKy } from '@folio/stripes/core';
6
+
7
+ import { uniqBy, sortBy, difference } from 'lodash';
8
+ import { Pane } from '@folio/stripes/components';
9
+
10
+ import { TagsForm } from '@folio/stripes/smart-components';
11
+
12
+ import { tagNamespaceArray } from './tagsConfig';
13
+ import { useTags } from './hooks';
14
+ import { useKintIntl } from '../hooks';
15
+
16
+ const Tags = ({
17
+ invalidateLinks = [], // If there are other queries that need invalidating, pass those here
18
+ labelOverrides = {},
19
+ link,
20
+ onToggle,
21
+ intlKey: passedIntlKey,
22
+ intlNS: passedIntlNS,
23
+ }) => {
24
+ const kintIntl = useKintIntl(passedIntlKey, passedIntlNS);
25
+
26
+ const ky = useOkapiKy();
27
+ const callout = useContext(CalloutContext);
28
+ const queryClient = useQueryClient();
29
+
30
+ // TAG GET/POST
31
+ const { data: { tags = [] } = {} } = useTags(
32
+ [...tagNamespaceArray, link]
33
+ );
34
+
35
+ // istanbul ignore next
36
+ const { mutateAsync: postTags } = useMutation(
37
+ ['tags', 'stripes-erm-components', 'Tags', 'postTags'],
38
+ (data) => ky.post('tags', { json: data }).then(() => {
39
+ queryClient.invalidateQueries('tags');
40
+ })
41
+ );
42
+
43
+ // ENTITY GET/PUT
44
+ const { data: entity } = useQuery(
45
+ [link, 'stripes-erm-components', 'Tags'],
46
+ () => ky.get(link).json()
47
+ );
48
+
49
+ // istanbul ignore next
50
+ const { mutateAsync: putEntity } = useMutation(
51
+ [link, 'stripes-erm-components', 'Tags', 'putEntity'],
52
+ (data) => ky.put(link, { json: data }).then(() => {
53
+ queryClient.invalidateQueries(link);
54
+ if (invalidateLinks?.length) {
55
+ invalidateLinks.forEach(il => queryClient.invalidateQueries(il));
56
+ }
57
+ })
58
+ );
59
+
60
+ // add tags to global list of tags
61
+ // istanbul ignore next
62
+ const saveTags = (tagsToSave) => {
63
+ const newTag = difference(tagsToSave.map(t => (t.value || t)), tags.map(t => t.label.toLowerCase()));
64
+ if (!newTag || !newTag.length) return;
65
+
66
+ postTags({
67
+ label: newTag[0],
68
+ description: newTag[0]
69
+ });
70
+
71
+ callout.sendCallout({
72
+ message: kintIntl.formatKintMessage({
73
+ id: 'newTagCreated',
74
+ overrideValue: labelOverrides.newTagCreated
75
+ })
76
+ });
77
+ };
78
+
79
+ // add tag to the list of entity tags
80
+ // istanbul ignore next
81
+ const saveEntityTags = (tagsToSave) => {
82
+ const tagListMap = (entity?.tags ?? []).map(tag => ({ 'value': tag.value }));
83
+ const tagsMap = tagsToSave.map(tag => ({ 'value': tag.value || tag }));
84
+
85
+ const newTags = sortBy(uniqBy([...tagListMap, ...tagsMap], 'value'));
86
+
87
+ putEntity({
88
+ tags: newTags
89
+ });
90
+ };
91
+
92
+ const onAdd = (addTags) => {
93
+ saveEntityTags(addTags);
94
+ saveTags(addTags);
95
+ };
96
+
97
+ const onRemove = (tag) => {
98
+ const tagToDelete = (entity?.tags ?? []).filter(t => t.value.toLowerCase() === tag.toLowerCase());
99
+
100
+ putEntity({
101
+ tags: [{ id: tagToDelete[0].id, _delete:true }]
102
+ });
103
+ };
104
+
105
+ const entityTags = (entity?.tags ?? []).map(tag => tag.value.toLowerCase());
106
+ return (
107
+ <Pane
108
+ defaultWidth="20%"
109
+ dismissible
110
+ id="tags-helper-pane"
111
+ onClose={onToggle}
112
+ paneSub={kintIntl.formatKintMessage(
113
+ {
114
+ id: 'numberOfTags',
115
+ overrideValue: labelOverrides.numberOfTags
116
+ },
117
+ { count: entity?.tags?.length ?? 0 }
118
+ )}
119
+ paneTitle={kintIntl.formatKintMessage(
120
+ {
121
+ id: 'tags',
122
+ overrideValue: labelOverrides.tags
123
+ }
124
+ )}
125
+ >
126
+ <TagsForm
127
+ entityTags={entityTags}
128
+ onAdd={onAdd}
129
+ onRemove={onRemove}
130
+ tags={tags}
131
+ />
132
+ </Pane>
133
+ );
134
+ };
135
+
136
+ Tags.propTypes = {
137
+ intlKey: PropTypes.string,
138
+ intlNS: PropTypes.string,
139
+ invalidateLinks: PropTypes.arrayOf(PropTypes.string),
140
+ labelOverrides: PropTypes.object,
141
+ link: PropTypes.string,
142
+ onToggle: PropTypes.func
143
+ };
144
+
145
+ export default Tags;
@@ -0,0 +1,77 @@
1
+ import React from 'react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+
4
+ import {
5
+ IconButton,
6
+ Pane,
7
+ PaneHeader
8
+ } from '@folio/stripes-erm-testing';
9
+
10
+ import Tags from './Tags';
11
+ import { renderWithKintHarness } from '../../../test/jest';
12
+
13
+ const onToggle = jest.fn();
14
+ const onAdd = jest.fn();
15
+ const link = 'erm/sas/14c16fc4-f986-4e60-aa59-4e627fcf160b';
16
+
17
+ describe('Tags', () => {
18
+ let renderComponent;
19
+ beforeEach(() => {
20
+ renderComponent = renderWithKintHarness(
21
+ <MemoryRouter>
22
+ <Tags
23
+ invalidateLinks={[]}
24
+ link={link}
25
+ onAdd={onAdd}
26
+ onToggle={onToggle}
27
+ />
28
+ </MemoryRouter>
29
+ );
30
+ });
31
+
32
+ test('renders the expected label', () => {
33
+ const { getByText } = renderComponent;
34
+ expect(getByText('0 Tags')).toBeInTheDocument();
35
+ });
36
+
37
+ test('renders expected pane dismiss button ', async () => {
38
+ await IconButton('Close Tags').exists();
39
+ });
40
+
41
+ test('renders expected open menu button ', () => {
42
+ const { getByRole } = renderComponent;
43
+ expect(
44
+ getByRole('button', {
45
+ name: 'stripes-components.multiSelection.dropdownTriggerLabel',
46
+ })
47
+ ).toBeInTheDocument();
48
+ });
49
+
50
+ test('renders tags heading ', () => {
51
+ const { getByRole } = renderComponent;
52
+ expect(getByRole('heading', { name: 'Tags' })).toBeInTheDocument();
53
+ });
54
+
55
+ test('renders expected region with zero tags', () => {
56
+ const { getByRole } = renderComponent;
57
+ expect(getByRole('region', { name: 'Tags 0 Tags' })).toBeInTheDocument();
58
+ });
59
+
60
+ test('renders the expected multiSelectDescription', () => {
61
+ const { getByText } = renderComponent;
62
+ expect(getByText('Contains a list of any selected values, followed by an autocomplete textfield for selecting additional values.')).toBeInTheDocument();
63
+ });
64
+
65
+ test('renders the expected label', () => {
66
+ const { getByText } = renderComponent;
67
+ expect(getByText('0 items selected')).toBeInTheDocument();
68
+ });
69
+
70
+ test('displays the tags pane', async () => {
71
+ await Pane('Tags').is({ visible: true });
72
+ });
73
+
74
+ test('displays the tags pane header', async () => {
75
+ await PaneHeader('Tags').is({ visible: true });
76
+ });
77
+ });
@@ -0,0 +1,2 @@
1
+ export { default as useTagsEnabled, tagsEnabledQueryKey } from './useTagsEnabled';
2
+ export { default as useTags } from './useTags';
@@ -0,0 +1,16 @@
1
+ import { useQuery } from 'react-query';
2
+ import { useOkapiKy } from '@folio/stripes/core';
3
+ import { defaultTagQuery, tagNamespaceArray } from '../tagsConfig';
4
+
5
+ const useTags = (namespaceArray, options) => {
6
+ const ky = useOkapiKy();
7
+ const nsArray = namespaceArray ?? tagNamespaceArray;
8
+
9
+ return useQuery(
10
+ nsArray,
11
+ () => ky.get(defaultTagQuery).json(),
12
+ options
13
+ );
14
+ };
15
+
16
+ export default useTags;
@@ -0,0 +1,19 @@
1
+ import { useOkapiKy } from '@folio/stripes/core';
2
+ import { useQuery } from 'react-query';
3
+ import { MOD_SETTINGS_ENDPOINT } from '../../constants/endpoints';
4
+
5
+ export const tagsEnabledQueryKey = [MOD_SETTINGS_ENDPOINT, 'query=(module==TAGS and configName==tags_enabled)', 'stripes-kint-components', 'useTagsEnabled'];
6
+
7
+ const useTagsEnabled = () => {
8
+ const ky = useOkapiKy();
9
+
10
+ const queryObject = useQuery(
11
+ tagsEnabledQueryKey,
12
+ () => ky.get(`${MOD_SETTINGS_ENDPOINT}?query=(module==TAGS and configName==tags_enabled)`).json()
13
+ );
14
+
15
+ const { data: { configs: { 0: { value } = {} } = [] } = {} } = queryObject;
16
+ return !value || value === 'true';
17
+ };
18
+
19
+ export default useTagsEnabled;
@@ -0,0 +1,4 @@
1
+ export { default as Tags } from './Tags';
2
+
3
+ export * from './tagsConfig';
4
+ export * from './hooks';
@@ -0,0 +1,16 @@
1
+ const tagNamespaceArray = ['tags', 'stripes-kint-components', 'Tags'];
2
+
3
+ const tagsPath = 'tags';
4
+ const defaultTagsParams = [
5
+ 'limit=1000',
6
+ 'query=cql.allRecords%3D1%20sortby%20label'
7
+ ];
8
+
9
+ const defaultTagQuery = `${tagsPath}?${defaultTagsParams?.join('&')}`;
10
+
11
+ export {
12
+ tagsPath,
13
+ defaultTagsParams,
14
+ defaultTagQuery,
15
+ tagNamespaceArray
16
+ };
@@ -24,22 +24,24 @@ import Typedown from '@k-int/stripes-kint-components';
24
24
 
25
25
  ### Props
26
26
 
27
- | Name | Type | Description | Default | Required |
28
- |---|---|---|---|---|
29
- | className | string | CSS class name to apply to the component's outer div. | | ✕ |
30
- | dataOptions | array | An array of objects representing the dropdown options. Each object should have a property (specified by `uniqueIdentificationPath`) that serves as a unique identifier. | | ✓ |
31
- | displayClearItem | bool | Whether to display a clear icon when an item is selected. | `true` | ✕ |
32
- | endOfList | node/func | Component or function to render when the dropdown list is empty. Defaults to `<EndOfList />` from `@folio/stripes/components`. | `<EndOfList />` | ✕ |
33
- | filterPath | string | Path to the property in `dataOptions` objects to use for filtering. If not provided, filtering is done on the property specified by `uniqueIdentificationPath`. | | ✕ |
34
- | id | string | Id to apply to the component's outer div. | | ✕ |
35
- | input | object | An object containing the input props typically provided by a form library like `react-final-form` or `redux-form`. Should include `name`, `value`, and `onChange`. | | |
36
- | isSelected | func | A function `(inputValue, dataOption)` that determines if a given `dataOption` is currently selected. Useful when selected values are complex objects. If not provided, selection is determined by comparing the value of the `uniqueIdentificationPath` property of the `input.value` and the `dataOption`. | | ✕ |
37
- | label | string/element | Label for the input field. | | |
38
- | meta | object | Meta information about the field, typically provided by a form library. Useful for displaying error messages etc. | | ✕ |
39
- | onChange | func | A callback function `(value)` that is called when a value is selected from the dropdown. This is in addition to the `input.onChange` provided. | | ✕ |
40
- | onType | func | A callback function `(event)` that is called when the user types in the search field. Allows for custom filtering or data fetching based on user input. | | ✕ |
41
- | renderFooter | func | A function `(displayData, currentlyTyped, exactMatch)` that renders a footer below the dropdown list. Receives the currently filtered `displayData`, what the user has typed, and whether it is an exact match. | | ✕ |
42
- | renderListItem | func | A function `(option, currentlyTyped, exactMatch, optionIsSelected)` that renders each item in the dropdown list. Receives the current option, what the user has typed, whether there is an exact match, and if the option is selected. If not provided, the value of the `uniqueIdentificationPath` property of the option is displayed. | | ✕ |
43
- | required | bool | Whether the input is required. | | ✕ |
44
- | selectedStyles | string | CSS class name to apply to the selected item display. | | ✕ |
45
- | uniqueIdentificationPath | string | Path to the property in `dataOptions` objects that serves as a unique identifier for each option and is used to determine if an option is selected. | `'id'` | ✕ |
27
+ | Name | Type | Description | Default | Required |
28
+ |--------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|---|
29
+ | className | string | CSS class name to apply to the component's outer div. | | ✕ |
30
+ | dataOptions | array | An array of objects representing the dropdown options. Each object should have a property (specified by `uniqueIdentificationPath`) that serves as a unique identifier. | | ✓ |
31
+ | displayClearItem | bool | Whether to display a clear icon when an item is selected. | `true` | ✕ |
32
+ | displayValueWhileOpen | bool | Whether or not to display the "value" underneath the typedown search while the dropdown is open. Defaults to true to avoid size changing onClick. | `true` | ✕ |
33
+ | endOfList | node/func | Component or function to render when the dropdown list is empty. Defaults to `<EndOfList />` from `@folio/stripes/components`. | `<EndOfList />` | ✕ |
34
+ | filterPath | string | Path to the property in `dataOptions` objects to use for filtering. If not provided, filtering is done on the property specified by `uniqueIdentificationPath`. | | ✕ |
35
+ | id | string | Id to apply to the component's outer div. | | |
36
+ | initialTimeoutDelay | number | Delay applied to `open` occurring on first render. Set to 800ms to avoid any stripes animations, such as being the first element focused in an opening modal. | 800 | ✕ |
37
+ | input | object | An object containing the input props typically provided by a form library like `react-final-form` or `redux-form`. Should include `name`, `value`, and `onChange`. | | |
38
+ | isSelected | func | A function `(inputValue, dataOption)` that determines if a given `dataOption` is currently selected. Useful when selected values are complex objects. If not provided, selection is determined by comparing the value of the `uniqueIdentificationPath` property of the `input.value` and the `dataOption`. | | ✕ |
39
+ | label | string/element | Label for the input field. | | ✕ |
40
+ | meta | object | Meta information about the field, typically provided by a form library. Useful for displaying error messages etc. | | ✕ |
41
+ | onChange | func | A callback function `(value)` that is called when a value is selected from the dropdown. This is in addition to the `input.onChange` provided. | | ✕ |
42
+ | onType | func | A callback function `(event)` that is called when the user types in the search field. Allows for custom filtering or data fetching based on user input. | | ✕ |
43
+ | renderFooter | func | A function `(displayData, currentlyTyped, exactMatch)` that renders a footer below the dropdown list. Receives the currently filtered `displayData`, what the user has typed, and whether it is an exact match. | | ✕ |
44
+ | renderListItem | func | A function `(option, currentlyTyped, exactMatch, optionIsSelected)` that renders each item in the dropdown list. Receives the current option, what the user has typed, whether there is an exact match, and if the option is selected. If not provided, the value of the `uniqueIdentificationPath` property of the option is displayed. | | ✕ |
45
+ | required | bool | Whether the input is required. | | ✕ |
46
+ | selectedStyles | string | CSS class name to apply to the selected item display. | | ✕ |
47
+ | uniqueIdentificationPath | string | Path to the property in `dataOptions` objects that serves as a unique identifier for each option and is used to determine if an option is selected. | `'id'` | ✕ |
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import classnames from 'classnames';
4
4
 
@@ -16,8 +16,10 @@ const Typedown = ({
16
16
  className,
17
17
  dataOptions,
18
18
  displayClearItem = true,
19
+ displayValueWhileOpen = true,
19
20
  endOfList,
20
21
  id,
22
+ initialOpenDelay = 800, // Initial opening delay of 800ms (handles any stripes animations)
21
23
  input,
22
24
  isSelected,
23
25
  filterPath,
@@ -85,7 +87,12 @@ const Typedown = ({
85
87
  resizeRef,
86
88
  searchWidth
87
89
  }
88
- } = useTypedown(input.name);
90
+ } = useTypedown(
91
+ input.name,
92
+ {
93
+ timeout: initialOpenDelay
94
+ }
95
+ );
89
96
 
90
97
  const renderItem = useCallback((option, optionIsSelected = false) => (
91
98
  <div
@@ -197,6 +204,10 @@ const Typedown = ({
197
204
  );
198
205
  };
199
206
 
207
+ const displayValue = useMemo(() => {
208
+ return !!selectedUniqueId && (!open || displayValueWhileOpen);
209
+ }, [displayValueWhileOpen, open, selectedUniqueId]);
210
+
200
211
  return (
201
212
  <div
202
213
  ref={resizeRef}
@@ -229,7 +240,7 @@ const Typedown = ({
229
240
  >
230
241
  {dropDown()}
231
242
  </Popper>
232
- {selectedUniqueId && !open &&
243
+ {displayValue &&
233
244
  <div
234
245
  className={classnames(
235
246
  css.selectedDisplay
@@ -257,6 +268,7 @@ Typedown.propTypes = {
257
268
  className: PropTypes.string,
258
269
  dataOptions: PropTypes.arrayOf(PropTypes.object),
259
270
  displayClearItem: PropTypes.bool,
271
+ displayValueWhileOpen: PropTypes.bool,
260
272
  endOfList: PropTypes.oneOfType([
261
273
  PropTypes.func,
262
274
  PropTypes.node,
@@ -264,6 +276,7 @@ Typedown.propTypes = {
264
276
  ]),
265
277
  filterPath: PropTypes.string,
266
278
  id: PropTypes.string,
279
+ initialOpenDelay: PropTypes.number,
267
280
  input: PropTypes.object,
268
281
  isSelected: PropTypes.func,
269
282
  label: PropTypes.oneOfType([
@@ -1,4 +1,4 @@
1
- import { useRef } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
  import { useResizeDetector } from 'react-resize-detector';
3
3
 
4
4
  import {
@@ -18,7 +18,10 @@ import selectorSafe from '../../utils/selectorSafe';
18
18
 
19
19
  import useTypedownToggle from './useTypedownToggle';
20
20
 
21
- const useTypedown = (name) => {
21
+ const useTypedown = (
22
+ name,
23
+ { timeout = 800 } = {}
24
+ ) => {
22
25
  // SEARCHFIELD COMPONENT
23
26
  const searchFieldComponent = document.getElementById(`typedown-searchField-${selectorSafe(name)}`);
24
27
 
@@ -114,6 +117,17 @@ const useTypedown = (name) => {
114
117
 
115
118
  // SET UP VARIABLES
116
119
  const { open } = useTypedownToggle(name);
120
+ const [useOpen, setUseOpen] = useState(false);
121
+
122
+ useEffect(() => {
123
+ // Use setTimeout to update the message after 2000 milliseconds (2 seconds)
124
+ const timeoutId = setTimeout(() => {
125
+ setUseOpen(true);
126
+ }, timeout); // Wait 0.8 seconds for open prop to get used
127
+
128
+ // Cleanup function to clear the timeout if the component unmounts
129
+ return () => clearTimeout(timeoutId);
130
+ }, [timeout]);
117
131
 
118
132
  // RESIZE STUFF
119
133
  const { width: searchWidth, ref: resizeRef } = useResizeDetector();
@@ -134,7 +148,7 @@ const useTypedown = (name) => {
134
148
  searchFieldKeyDownHandler
135
149
  },
136
150
  variables: {
137
- open,
151
+ open: useOpen ? open : false,
138
152
  portal,
139
153
  resizeRef,
140
154
  searchWidth
@@ -1,15 +1,16 @@
1
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useState } from 'react';
2
2
  import { useHistory, useLocation } from 'react-router-dom';
3
3
 
4
4
  import queryString from 'query-string';
5
5
  import isEqual from 'lodash/isEqual';
6
6
 
7
- let helperObject = {};
8
-
9
7
  const useHelperApp = (helpers) => {
10
8
  const history = useHistory();
11
9
  const location = useLocation();
12
10
 
11
+ const [helperObject, setHelperObject] = useState({});
12
+ const [helperToggleFunctions, setHelperToggleFunctions] = useState({});
13
+
13
14
  const query = queryString.parse(location.search);
14
15
 
15
16
  const [currentHelper, setCurrentHelper] = useState(query?.helper);
@@ -22,9 +23,18 @@ const useHelperApp = (helpers) => {
22
23
  };
23
24
 
24
25
  useEffect(() => {
25
- // Keep object outside of hook to avoid redraw, oncly change when keys change
26
26
  if (!isEqual(Object.keys(helperObject), Object.keys(helpers))) {
27
- helperObject = helpers;
27
+ setHelperObject(helpers);
28
+ }
29
+
30
+ const newHelperToggleFunctions = {};
31
+ Object.keys(helperObject).forEach(h => {
32
+ newHelperToggleFunctions[h] = () => handleToggleHelper(h);
33
+ });
34
+
35
+ if (!isEqual(Object.keys(helperToggleFunctions), Object.keys(newHelperToggleFunctions))) {
36
+ // This makes sure adding/removing helpers changes the functions
37
+ setHelperToggleFunctions(newHelperToggleFunctions);
28
38
  }
29
39
 
30
40
  if (currentHelper !== query?.helper) {
@@ -37,11 +47,14 @@ const useHelperApp = (helpers) => {
37
47
  pathname: location.pathname,
38
48
  search: `?${queryString.stringify(newQuery)}`
39
49
  });
50
+
51
+ // When helper changes, reset helperToggleFunctions
52
+ setHelperToggleFunctions(newHelperToggleFunctions);
40
53
  }
41
- }, [currentHelper, helpers, history, location, query]);
54
+ }, [currentHelper, handleToggleHelper, helperObject, helperToggleFunctions, helpers, history, location, query]);
42
55
 
43
56
  // Set the HelperComponent
44
- const HelperComponent = useMemo(() => ((props) => {
57
+ const HelperComponent = useCallback((props) => {
45
58
  if (!query?.helper) return null;
46
59
 
47
60
  let Component = null;
@@ -56,13 +69,8 @@ const useHelperApp = (helpers) => {
56
69
  {...props}
57
70
  />
58
71
  );
59
- }), [handleToggleHelper, query.helper]);
72
+ }, [handleToggleHelper, helperObject, query?.helper]);
60
73
 
61
- // Set up the helperToggleFunctions
62
- const helperToggleFunctions = {};
63
- Object.keys(helperObject).forEach(h => {
64
- helperToggleFunctions[h] = () => handleToggleHelper(h);
65
- });
66
74
 
67
75
  return { currentHelper, HelperComponent, helperToggleFunctions, isOpen };
68
76
  };