@backstage/plugin-catalog-react 1.6.0-next.2 → 1.7.0-next.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # @backstage/plugin-catalog-react
2
2
 
3
+ ## 1.7.0-next.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cb4c15989b6b: The `EntityOwnerPicker` component has undergone improvements to enhance its performance.
8
+
9
+ The component now loads entities asynchronously, resulting in improved performance and responsiveness. Instead of loading all entities upfront, they are now loaded in batches as the user scrolls.
10
+ The previous implementation inferred users and groups displayed by the `EntityOwnerPicker` component based on the entities available in the `EntityListContext`. The updated version no longer relies on the `EntityListContext` for inference, allowing for better decoupling and improved performance.
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies
15
+ - @backstage/catalog-client@1.4.2-next.0
16
+ - @backstage/theme@0.4.0-next.0
17
+ - @backstage/integration@1.4.5
18
+ - @backstage/core-components@0.13.2-next.0
19
+ - @backstage/core-plugin-api@1.5.1
20
+ - @backstage/plugin-permission-react@0.4.12
21
+ - @backstage/catalog-model@1.3.0
22
+ - @backstage/errors@1.1.5
23
+ - @backstage/types@1.0.2
24
+ - @backstage/version-bridge@1.0.4
25
+ - @backstage/plugin-catalog-common@1.0.13
26
+ - @backstage/plugin-permission-common@0.7.5
27
+
28
+ ## 1.6.0
29
+
30
+ ### Minor Changes
31
+
32
+ - 2258dcae970: Added an entity namespace filter and column on the default catalog page.
33
+
34
+ If you have a custom version of the catalog page, you can add this filter in your CatalogPage code:
35
+
36
+ ```ts
37
+ <CatalogFilterLayout>
38
+ <CatalogFilterLayout.Filters>
39
+ <EntityTypePicker />
40
+ <UserListPicker initialFilter={initiallySelectedFilter} />
41
+ <EntityTagPicker />
42
+ /* if you want namespace picker */
43
+ <EntityNamespacePicker />
44
+ </CatalogFilterLayout.Filters>
45
+ <CatalogFilterLayout.Content>
46
+ <CatalogTable columns={columns} actions={actions} />
47
+ </CatalogFilterLayout.Content>
48
+ </CatalogFilterLayout>
49
+ ```
50
+
51
+ The namespace column can be added using `createNamespaceColumn();`. This is only needed if you customized the columns for CatalogTable.
52
+
53
+ ### Patch Changes
54
+
55
+ - Updated dependencies
56
+ - @backstage/theme@0.3.0
57
+ - @backstage/integration@1.4.5
58
+ - @backstage/core-components@0.13.1
59
+ - @backstage/catalog-client@1.4.1
60
+ - @backstage/catalog-model@1.3.0
61
+ - @backstage/core-plugin-api@1.5.1
62
+ - @backstage/errors@1.1.5
63
+ - @backstage/types@1.0.2
64
+ - @backstage/version-bridge@1.0.4
65
+ - @backstage/plugin-catalog-common@1.0.13
66
+ - @backstage/plugin-permission-common@0.7.5
67
+ - @backstage/plugin-permission-react@0.4.12
68
+
3
69
  ## 1.6.0-next.2
4
70
 
5
71
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backstage/plugin-catalog-react",
3
- "version": "1.6.0-next.2",
3
+ "version": "1.7.0-next.0",
4
4
  "main": "../dist/alpha.esm.js",
5
5
  "module": "../dist/alpha.esm.js",
6
6
  "types": "../dist/alpha.d.ts"
package/dist/index.d.ts CHANGED
@@ -143,7 +143,8 @@ declare function EntityRefLinks<TRef extends string | CompoundEntityRef | Entity
143
143
  * @param defaultNamespace - if set to false then namespace is never omitted,
144
144
  * if set to string which matches namespace of entity then omitted
145
145
  *
146
- * @public */
146
+ * @public
147
+ **/
147
148
  declare function humanizeEntityRef(entityRef: Entity | CompoundEntityRef, opts?: {
148
149
  defaultKind?: string;
149
150
  defaultNamespace?: string | false;
package/dist/index.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
2
- import { createApiRef, useApi, identityApiRef, alertApiRef, errorApiRef, createRouteRef, useRouteRef, useApiHolder, useApp, configApiRef } from '@backstage/core-plugin-api';
2
+ import { createApiRef, useApi, identityApiRef, alertApiRef, createRouteRef, useRouteRef, useApiHolder, useApp, configApiRef } from '@backstage/core-plugin-api';
3
3
  import ObservableImpl from 'zen-observable';
4
4
  import React, { useState, createContext, useMemo, useCallback, useContext, useEffect, useRef, forwardRef, memo, useLayoutEffect, Fragment } from 'react';
5
5
  import { Grid, useMediaQuery, useTheme, Button, Drawer, Box, Typography, makeStyles, FormControlLabel, Checkbox, TextField, Tooltip, IconButton, Card, CardContent, Chip, CardActions, Toolbar, FormControl, Input, InputAdornment, withStyles, DialogContentText, ListItemText as ListItemText$1, ListSubheader as ListSubheader$1, ListItem, ListItemIcon, List, Dialog, DialogTitle, DialogContent, Tabs, Tab, DialogActions, Divider, MenuItem, ListItemSecondaryAction } from '@material-ui/core';
@@ -22,6 +22,9 @@ import CheckBoxIcon from '@material-ui/icons/CheckBox';
22
22
  import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
23
23
  import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
24
24
  import { Autocomplete, Alert } from '@material-ui/lab';
25
+ import { useDebouncedEffect } from '@react-hookz/web';
26
+ import PersonIcon from '@material-ui/icons/Person';
27
+ import GroupIcon from '@material-ui/icons/Group';
25
28
  import get from 'lodash/get';
26
29
  import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
27
30
  import HoverPopover from 'material-ui-popup-state/HoverPopover';
@@ -838,14 +841,14 @@ function humanizeEntityRef(entityRef, opts) {
838
841
  kind = defaultKind && defaultKind.toLocaleLowerCase("en-US") === kind ? void 0 : kind;
839
842
  return `${kind ? `${kind}:` : ""}${namespace ? `${namespace}/` : ""}${name}`;
840
843
  }
841
- function humanizeEntity(entity, opts) {
844
+ function humanizeEntity(entity, defaultName) {
842
845
  for (const path of ["spec.profile.displayName", "metadata.title"]) {
843
846
  const value = get(entity, path);
844
847
  if (value && typeof value === "string") {
845
848
  return value;
846
849
  }
847
850
  }
848
- return humanizeEntityRef(entity, opts);
851
+ return defaultName;
849
852
  }
850
853
 
851
854
  const useStyles$e = makeStyles(
@@ -863,75 +866,53 @@ const EntityOwnerPicker = () => {
863
866
  const classes = useStyles$e();
864
867
  const {
865
868
  updateFilters,
866
- backendEntities,
867
869
  filters,
868
870
  queryParameters: { owners: ownersParameter }
869
871
  } = useEntityList();
870
872
  const catalogApi = useApi(catalogApiRef);
871
- const errorApi = useApi(errorApiRef);
873
+ const [text, setText] = useState("");
874
+ const [{ value, loading }, handleFetch] = useAsyncFn(
875
+ async (request) => {
876
+ const initialRequest = request;
877
+ const cursorRequest = request;
878
+ const limit = 20;
879
+ if (cursorRequest.cursor) {
880
+ const response = await catalogApi.queryEntities({
881
+ cursor: cursorRequest.cursor,
882
+ limit
883
+ });
884
+ return {
885
+ ...response,
886
+ items: [...cursorRequest.prev, ...response.items]
887
+ };
888
+ }
889
+ return catalogApi.queryEntities({
890
+ fullTextFilter: {
891
+ term: initialRequest.text || "",
892
+ fields: [
893
+ "metadata.name",
894
+ "kind",
895
+ "spec.profile.displayname",
896
+ "metadata.title"
897
+ ]
898
+ },
899
+ filter: { kind: ["User", "Group"] },
900
+ orderFields: [{ field: "metadata.name", order: "asc" }],
901
+ limit
902
+ });
903
+ },
904
+ [text]
905
+ );
906
+ useDebouncedEffect(() => handleFetch({ text }), [text], 250);
907
+ const availableOwners = (value == null ? void 0 : value.items) || [];
872
908
  const queryParamOwners = useMemo(
873
909
  () => [ownersParameter].flat().filter(Boolean),
874
910
  [ownersParameter]
875
911
  );
876
912
  const [selectedOwners, setSelectedOwners] = useState(
877
- queryParamOwners.length ? new EntityOwnerFilter(queryParamOwners).values : (_b = (_a = filters.owners) == null ? void 0 : _a.values) != null ? _b : []
913
+ queryParamOwners.length ? queryParamOwners : (_b = (_a = filters.owners) == null ? void 0 : _a.values) != null ? _b : []
878
914
  );
879
- const {
880
- loading,
881
- error,
882
- value: ownerEntities
883
- } = useAsync(async () => {
884
- const ownerEntityRefs = [
885
- ...new Set(
886
- backendEntities.flatMap(
887
- (e) => getEntityRelations(e, RELATION_OWNED_BY).map(
888
- (o) => stringifyEntityRef(o)
889
- )
890
- ).filter(Boolean)
891
- )
892
- ];
893
- const { items: ownerEntitiesOrNull } = await catalogApi.getEntitiesByRefs({
894
- entityRefs: ownerEntityRefs,
895
- fields: [
896
- "kind",
897
- "metadata.name",
898
- "metadata.title",
899
- "metadata.namespace",
900
- "spec.profile.displayName"
901
- ]
902
- });
903
- const owners = ownerEntitiesOrNull.map((entity, index) => {
904
- if (entity) {
905
- return {
906
- label: humanizeEntity(entity, { defaultKind: "Group" }),
907
- entityRef: stringifyEntityRef(entity)
908
- };
909
- }
910
- return {
911
- label: humanizeEntityRef(parseEntityRef(ownerEntityRefs[index]), {
912
- defaultKind: "group"
913
- }),
914
- entityRef: ownerEntityRefs[index]
915
- };
916
- });
917
- return owners.sort(
918
- (a, b) => a.label.localeCompare(b.label, "en-US", {
919
- ignorePunctuation: true,
920
- caseFirst: "upper"
921
- })
922
- );
923
- }, [backendEntities]);
924
- useEffect(() => {
925
- if (error) {
926
- errorApi.post(
927
- {
928
- ...error,
929
- message: `EntityOwnerPicker failed to initialize: ${error.message}`
930
- },
931
- {}
932
- );
933
- }
934
- }, [error, errorApi]);
915
+ const { getEntity, setEntity } = useSelectedOwners(selectedOwners);
935
916
  useEffect(() => {
936
917
  if (queryParamOwners.length) {
937
918
  const filter = new EntityOwnerFilter(queryParamOwners);
@@ -939,41 +920,64 @@ const EntityOwnerPicker = () => {
939
920
  }
940
921
  }, [queryParamOwners]);
941
922
  useEffect(() => {
942
- if (!loading && ownerEntities) {
943
- updateFilters({
944
- owners: selectedOwners.length && ownerEntities.length ? new EntityOwnerFilter(selectedOwners) : void 0
945
- });
946
- }
947
- }, [selectedOwners, updateFilters, ownerEntities, loading]);
948
- if (!loading && !(ownerEntities == null ? void 0 : ownerEntities.length))
923
+ updateFilters({
924
+ owners: selectedOwners.length ? new EntityOwnerFilter(selectedOwners) : void 0
925
+ });
926
+ }, [selectedOwners, updateFilters]);
927
+ if (["user", "group"].includes(
928
+ ((_c = filters.kind) == null ? void 0 : _c.value.toLocaleLowerCase("en-US")) || ""
929
+ )) {
949
930
  return null;
931
+ }
950
932
  return /* @__PURE__ */ React.createElement(Box, { pb: 1, pt: 1 }, /* @__PURE__ */ React.createElement(Typography, { variant: "button", component: "label" }, "Owner", /* @__PURE__ */ React.createElement(
951
933
  Autocomplete,
952
934
  {
953
935
  multiple: true,
954
936
  disableCloseOnSelect: true,
955
937
  loading,
956
- options: ownerEntities || [],
957
- value: (_c = ownerEntities == null ? void 0 : ownerEntities.filter(
958
- (e) => selectedOwners.some((f) => f === e.entityRef)
959
- )) != null ? _c : [],
960
- onChange: (_, value) => setSelectedOwners(value.map((e) => e.entityRef)),
961
- getOptionLabel: (option) => option.label,
962
- renderOption: (option, { selected }) => /* @__PURE__ */ React.createElement(
963
- FormControlLabel,
964
- {
965
- control: /* @__PURE__ */ React.createElement(
966
- Checkbox,
967
- {
968
- icon: icon$2,
969
- checkedIcon: checkedIcon$2,
970
- checked: selected
971
- }
972
- ),
973
- onClick: (event) => event.preventDefault(),
974
- label: option.label
938
+ options: availableOwners,
939
+ value: selectedOwners,
940
+ getOptionSelected: (o, v) => {
941
+ if (typeof v === "string") {
942
+ return stringifyEntityRef(o) === v;
975
943
  }
976
- ),
944
+ return o === v;
945
+ },
946
+ getOptionLabel: (o) => {
947
+ const entity = typeof o === "string" ? getEntity(o) || o : o;
948
+ return typeof entity === "string" ? entity : humanizeEntity(entity, entity.metadata.name);
949
+ },
950
+ onChange: (_, owners) => {
951
+ setText("");
952
+ setSelectedOwners(
953
+ owners.map((e) => {
954
+ const entityRef = typeof e === "string" ? e : stringifyEntityRef(e);
955
+ if (typeof e !== "string") {
956
+ setEntity(e);
957
+ }
958
+ return entityRef;
959
+ })
960
+ );
961
+ },
962
+ filterOptions: (x) => x,
963
+ renderOption: (entity, { selected }) => {
964
+ const isGroup = entity.kind === "Group";
965
+ return /* @__PURE__ */ React.createElement(
966
+ FormControlLabel,
967
+ {
968
+ control: /* @__PURE__ */ React.createElement(
969
+ Checkbox,
970
+ {
971
+ icon: icon$2,
972
+ checkedIcon: checkedIcon$2,
973
+ checked: selected
974
+ }
975
+ ),
976
+ onClick: (event) => event.preventDefault(),
977
+ label: /* @__PURE__ */ React.createElement(Box, { display: "flex", flexWrap: "wrap", alignItems: "center" }, isGroup ? /* @__PURE__ */ React.createElement(GroupIcon, { fontSize: "small" }) : /* @__PURE__ */ React.createElement(PersonIcon, { fontSize: "small" }), "\xA0", humanizeEntity(entity, entity.metadata.name))
978
+ }
979
+ );
980
+ },
977
981
  size: "small",
978
982
  popupIcon: /* @__PURE__ */ React.createElement(ExpandMoreIcon, { "data-testid": "owner-picker-expand" }),
979
983
  renderInput: (params) => /* @__PURE__ */ React.createElement(
@@ -981,12 +985,53 @@ const EntityOwnerPicker = () => {
981
985
  {
982
986
  ...params,
983
987
  className: classes.input,
988
+ onChange: (e) => {
989
+ setText(e.currentTarget.value);
990
+ },
984
991
  variant: "outlined"
985
992
  }
986
- )
993
+ ),
994
+ ListboxProps: {
995
+ onScroll: (e) => {
996
+ const element = e.currentTarget;
997
+ const hasReachedEnd = Math.abs(
998
+ element.scrollHeight - element.clientHeight - element.scrollTop
999
+ ) < 1;
1000
+ if (hasReachedEnd && (value == null ? void 0 : value.pageInfo.nextCursor)) {
1001
+ handleFetch({
1002
+ cursor: value.pageInfo.nextCursor,
1003
+ prev: value.items
1004
+ });
1005
+ }
1006
+ },
1007
+ "data-testid": "owner-picker-listbox"
1008
+ }
987
1009
  }
988
1010
  )));
989
1011
  };
1012
+ function useSelectedOwners(initialSelectedOwnersRefs) {
1013
+ const allEntities = useRef({});
1014
+ const catalogApi = useApi(catalogApiRef);
1015
+ useAsync(async () => {
1016
+ if (initialSelectedOwnersRefs.length === 0) {
1017
+ return;
1018
+ }
1019
+ const initialSelectedEntities = await catalogApi.getEntitiesByRefs({
1020
+ entityRefs: initialSelectedOwnersRefs
1021
+ });
1022
+ initialSelectedEntities.items.forEach((e) => {
1023
+ if (e) {
1024
+ allEntities.current[stringifyEntityRef(e)] = e;
1025
+ }
1026
+ });
1027
+ }, []);
1028
+ return {
1029
+ getEntity: (entityRef) => allEntities.current[entityRef],
1030
+ setEntity: (entity) => {
1031
+ allEntities.current[stringifyEntityRef(entity)] = entity;
1032
+ }
1033
+ };
1034
+ }
990
1035
 
991
1036
  const entityRouteRef = getOrCreateGlobalSingleton(
992
1037
  "catalog:entity-route-ref",