فهرست منبع

feat: some fixes and loading spinners while loading albums

    * refactor: don't expose expanded prop to artist row
    * feat: use <FixedSizeList /> instead of react-vtree
    * fix: deselect album when closing parent artist
    * feat: loading spinner next to artist while loading its albums
    * fix: encode artist in albums query
Fela Maslen 5 سال پیش
والد
کامیت
26c2cacb8d

+ 3 - 1
gmus/package.json

@@ -64,6 +64,8 @@
     "@types/pluralize": "^0.0.29",
     "@types/react": "^16.9.53",
     "@types/react-dom": "^16.9.8",
+    "@types/react-virtualized-auto-sizer": "^1.0.0",
+    "@types/react-window": "^1.8.2",
     "@types/styled-components": "^5.1.5",
     "axios": "^0.21.0",
     "eslint-config-airbnb-base": "^14.2.1",
@@ -83,7 +85,7 @@
     "react-scripts": "4.0.1",
     "react-spinners": "^0.9.0",
     "react-storage-hooks": "^4.0.1",
-    "react-vtree": "^3.0.0-beta.0",
+    "react-virtualized-auto-sizer": "^1.0.2",
     "react-window": "^1.8.6",
     "styled-components": "^5.2.1",
     "styled-reset": "^4.3.3",

+ 22 - 0
gmus/src/components/ui/cmus/reducer.spec.ts

@@ -423,6 +423,28 @@ describe(cmusUIReducer.name, () => {
               expect(result).toStrictEqual(stateNoActive);
             });
           });
+
+          describe('when the active album will disappear', () => {
+            const stateWithActiveAlbum: CmusUIState = {
+              ...initialCmusUIState,
+              artistAlbums: {
+                'Artist A': ['Album A', 'Album B', 'Album C'],
+              },
+              library: {
+                ...initialCmusUIState.library,
+                activeArtist: 'Artist A',
+                expandedArtists: ['Artist A'],
+                activeAlbum: 'Album B',
+              },
+            };
+
+            it('should set the active album to null', () => {
+              expect.assertions(2);
+              const result = cmusUIReducer(stateWithActiveAlbum, action);
+              expect(result.library.activeArtist).toBe('Artist A');
+              expect(result.library.activeAlbum).toBeNull();
+            });
+          });
         });
       });
     });

+ 1 - 0
gmus/src/components/ui/cmus/reducer.ts

@@ -117,6 +117,7 @@ function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['libra
       expandedArtists: library.expandedArtists.filter(
         (compare) => compare !== library.activeArtist,
       ),
+      activeAlbum: null,
     };
   }
   return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };

+ 6 - 6
gmus/src/components/ui/cmus/styled/layout.ts

@@ -11,6 +11,12 @@ export const FlexColumn = styled(FlexRow)`
   flex-flow: column;
 `;
 
+export const NoWrapFill = styled.span`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: 100%;
+`;
+
 export const NoWrap = styled.div`
   white-space: nowrap;
 `;
@@ -45,12 +51,6 @@ export const ActiveHighlightRow = styled(FlexRow)<{
 
   white-space: nowrap;
   width: 100%;
-
-  span {
-    overflow: hidden;
-    text-overflow: ellipsis;
-    width: 100%;
-  }
 `;
 
 export const FlexList = styled(FlexColumn)`

+ 28 - 0
gmus/src/components/ui/cmus/styled/spinner.tsx

@@ -0,0 +1,28 @@
+import { rem } from 'polished';
+import styled, { keyframes } from 'styled-components';
+
+const spin = keyframes`
+  0% { content: '⣾' }
+  14.2857% { content: '⣽' }
+  28.5714% { content: '⣻' }
+  42.8571% { content: '⢿' }
+  57.1429% { content: '⡿' }
+  71.4286% { content: '⣟' }
+  85.7143% { content: '⣯' }
+  100% { content: '⣷' }
+`;
+
+export const AsciiSpinner = styled.span`
+  &::after {
+    animation: 1s ${spin} infinite;
+    animation-timing-function: step-end;
+    color: inherit;
+    content: '';
+    display: inline;
+    font-family: Hack, monospace;
+    height: ${rem(16)};
+    line-height: ${rem(16)};
+    margin-right: ${rem(8)};
+    width: auto;
+  }
+`;

+ 1 - 1
gmus/src/components/ui/cmus/views/artists.styles.ts

@@ -11,6 +11,6 @@ export const ArtistTitle = styled(ActiveHighlightRow)``;
 
 export const AlbumTitle = styled(ActiveHighlightRow)`
   span {
-    padding-left: ${rem(16)};
+    padding-left: ${rem(32)};
   }
 `;

+ 93 - 114
gmus/src/components/ui/cmus/views/artists.tsx

@@ -8,13 +8,15 @@ import React, {
   useRef,
   useState,
 } from 'react';
-import { FixedSizeNodeData, FixedSizeTree as Tree, TreeWalker, TreeWalkerValue } from 'react-vtree';
-import { NodeComponentProps, NodePublicState } from 'react-vtree/dist/es/Tree';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import { FixedSizeList as List } from 'react-window';
 
 import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
+import { namedMemo } from '../../../../utils/component';
 import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
-import { CmusUIState } from '../types';
+import { NoWrapFill } from '../styled/layout';
+import { AsciiSpinner } from '../styled/spinner';
 import { getScrollIndex } from '../utils/scroll';
 
 import * as Styled from './artists.styles';
@@ -23,122 +25,67 @@ type Props = {
   active: boolean;
 };
 
-type TreeNode = {
-  name: string;
+type ArtistData = {
   id: string;
-  focused: boolean;
+  artist: string;
+  loading: boolean;
   active: boolean;
-  shouldBeOpen?: boolean;
-  children?: TreeNode[];
+  parentActive: boolean;
 };
 
-type TreeMeta = {
-  node: TreeNode;
+type AlbumData = {
+  id: string;
+  album: string;
+  active: boolean;
+  parentActive: boolean;
 };
 
-type TreeData = FixedSizeNodeData &
-  Omit<TreeNode, 'children'> & {
-    isArtist: boolean;
-  };
-
-function useTreeWalker(
-  { artists, artistAlbums, library: { activeArtist, activeAlbum, expandedArtists } }: CmusUIState,
-  focused: boolean,
-): { treeWalker: TreeWalker<TreeData, TreeMeta>; haveData: boolean } {
-  const treeNodes = useMemo<TreeNode[]>(
-    () =>
-      artists.map<TreeNode>((artist) => ({
-        name: artist,
-        id: artist,
-        focused,
-        active: activeArtist === artist && activeAlbum === null,
-        shouldBeOpen: expandedArtists.includes(artist),
-        children:
-          artistAlbums[artist]?.map<TreeNode>((album) => ({
-            name: album,
-            id: `${artist}-${album}`,
-            focused,
-            active: activeArtist === artist && activeAlbum === album,
-          })) ?? undefined,
-      })),
-    [artists, artistAlbums, focused, activeArtist, activeAlbum, expandedArtists],
-  );
-
-  const getNodeData = useCallback(
-    (node: TreeNode, isArtist: boolean): TreeWalkerValue<TreeData, TreeMeta> => ({
-      data: {
-        id: node.id,
-        name: node.name,
-        focused: node.focused,
-        active: node.active,
-        shouldBeOpen: node.shouldBeOpen,
-        isOpenByDefault: !!node.shouldBeOpen,
-        isArtist,
-      },
-      node,
-    }),
-    [],
-  );
-
-  const treeWalker = useMemo<TreeWalker<TreeData, TreeMeta>>(
-    () =>
-      // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-      function* treeWalkerGenerator() {
-        for (let i = 0; i < treeNodes.length; i += 1) {
-          yield getNodeData(treeNodes[i], true);
-        }
-
-        while (true) {
-          const parent = yield;
-
-          if (parent?.node.children) {
-            for (let i = 0; i < parent.node.children.length; i += 1) {
-              yield getNodeData(parent.node.children[i], false);
-            }
-          }
-        }
-      },
-    [treeNodes, getNodeData],
-  );
+type RowData = ArtistData | AlbumData;
 
-  return { treeWalker, haveData: treeNodes.length > 0 };
-}
+const isArtist = (data: RowData): data is ArtistData => Reflect.has(data, 'artist');
 
-const Node: React.FC<NodeComponentProps<TreeData, NodePublicState<TreeData>>> = ({
-  data: { name, isArtist, focused, active, shouldBeOpen },
-  isOpen,
-  setOpen,
-  style,
-}) => {
-  useEffect(() => {
-    if (!!isOpen !== !!shouldBeOpen) {
-      setOpen(!!shouldBeOpen);
-    }
-  }, [isOpen, shouldBeOpen, setOpen]);
+const itemKey = (index: number, data: RowData[]): string => data[index].id;
 
-  if (isArtist) {
-    return (
-      <Styled.ArtistTitle active={active} parentActive={focused} style={style as CSSProperties}>
-        <span>{name || 'Unknown Artist'}</span>
-      </Styled.ArtistTitle>
-    );
-  }
+const Artist = namedMemo<{ row: ArtistData; style: CSSProperties }>(
+  'Artist',
+  ({ row: { artist, loading, active, parentActive }, style }) => (
+    <Styled.ArtistTitle active={active} parentActive={parentActive} style={style}>
+      {loading ? <AsciiSpinner /> : <>&nbsp;&nbsp;</>}
+      <NoWrapFill>{artist || 'Unknown Artist'}</NoWrapFill>
+    </Styled.ArtistTitle>
+  ),
+);
 
-  return (
-    <Styled.AlbumTitle active={active} parentActive={focused} style={style as CSSProperties}>
-      <span>{name || 'Unknown Album'}</span>
+const Album = namedMemo<{ row: AlbumData; style: CSSProperties }>(
+  'Album',
+  ({ row: { album, active, parentActive }, style }) => (
+    <Styled.AlbumTitle active={active} parentActive={parentActive} style={style}>
+      <NoWrapFill>{album || 'Unknown Album'}</NoWrapFill>
     </Styled.AlbumTitle>
-  );
-};
+  ),
+);
+
+const Row = namedMemo<{ index: number; data: RowData[]; style: CSSProperties }>(
+  'ArtistListRow',
+  ({ index, data, style }) => {
+    const row = data[index];
+    if (isArtist(row)) {
+      return <Artist row={row} style={style} />;
+    }
+    return <Album row={row} style={style} />;
+  },
+);
 
 const lineHeight = 16;
 const scrollThresholdLines = 4;
 
-export const Artists: React.FC<Props> = ({ active }) => {
+export const Artists: React.FC<Props> = ({ active: parentActive }) => {
   const dispatchUI = useContext(CmusUIDispatchContext);
   const state = useContext(CmusUIStateContext);
   const {
-    library: { activeArtist, expandedArtists },
+    artists,
+    artistAlbums,
+    library: { activeArtist, activeAlbum, expandedArtists },
   } = state;
 
   const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
@@ -162,6 +109,36 @@ export const Artists: React.FC<Props> = ({ active }) => {
     }
   }, [dispatchUI, songs]);
 
+  const itemData = useMemo<RowData[]>(
+    () =>
+      artists.reduce<RowData[]>((last, artist) => {
+        const expanded = expandedArtists.includes(artist);
+        const artistRow: ArtistData = {
+          id: artist,
+          artist,
+          loading: !(artist in artistAlbums) && expandedArtists.includes(artist),
+          active: activeArtist === artist && activeAlbum === null,
+          parentActive,
+        };
+
+        if (!expanded) {
+          return [...last, artistRow];
+        }
+
+        return [
+          ...last,
+          artistRow,
+          ...(artistAlbums[artist] ?? []).map<AlbumData>((album) => ({
+            id: `${artist}-${album}`,
+            album,
+            active: activeArtist === artist && activeAlbum === album,
+            parentActive: parentActive && activeArtist === artist && activeAlbum === album,
+          })),
+        ];
+      }, []),
+    [parentActive, artists, artistAlbums, activeArtist, activeAlbum, expandedArtists],
+  );
+
   const ref = useRef<HTMLDivElement>(null);
   const [windowDimensions, setWindowDimensions] = useState<{ width: number; height: number }>({
     width: 0,
@@ -213,21 +190,23 @@ export const Artists: React.FC<Props> = ({ active }) => {
     }
   }, [windowDimensions.height, scrollIndex]);
 
-  const { treeWalker, haveData } = useTreeWalker(state, active);
-
   return (
     <Styled.Container ref={ref}>
-      {haveData && (
-        <Tree
-          outerRef={windowRef}
-          treeWalker={treeWalker}
-          itemSize={lineHeight}
-          width={windowDimensions.width}
-          height={windowDimensions.height}
-        >
-          {Node}
-        </Tree>
-      )}
+      <AutoSizer>
+        {({ height, width }): React.ReactElement => (
+          <List
+            outerRef={windowRef}
+            height={height}
+            width={width}
+            itemCount={artists.length}
+            itemSize={lineHeight}
+            itemKey={itemKey}
+            itemData={itemData}
+          >
+            {Row}
+          </List>
+        )}
+      </AutoSizer>
     </Styled.Container>
   );
 };

+ 1 - 1
gmus/src/hooks/fetch/artists.ts

@@ -25,7 +25,7 @@ const sendAlbumsRequest = (
   axios: AxiosInstance,
   query: AlbumsQuery,
 ): Promise<AxiosResponse<AlbumsResponse>> =>
-  axios.get(`${getApiUrl()}/albums?artist=${query.artist}`);
+  axios.get(`${getApiUrl()}/albums?artist=${encodeURIComponent(query.artist)}`);
 
 type SongsResponse = {
   artist: string;

+ 19 - 13
gmus/yarn.lock

@@ -1106,7 +1106,7 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
   version "7.12.5"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
   integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==
@@ -1959,6 +1959,20 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-virtualized-auto-sizer@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz#fc32f30a8dab527b5816f3a757e1e1d040c8f272"
+  integrity sha512-NMErdIdSnm2j/7IqMteRiRvRulpjoELnXWUwdbucYCz84xG9PHcoOrr7QfXwB/ku7wd6egiKFrzt/+QK4Imeeg==
+  dependencies:
+    "@types/react" "*"
+
+"@types/react-window@^1.8.2":
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe"
+  integrity sha512-gP1xam68Wc4ZTAee++zx6pTdDAH08rAkQrWm4B4F/y6hhmlT9Mgx2q8lTCXnrPHXsr15XjRN9+K2DLKcz44qEQ==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*":
   version "17.0.0"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8"
@@ -9465,11 +9479,6 @@ react-is@^17.0.1:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
   integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
 
-react-merge-refs@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz#73d88b892c6c68cbb7a66e0800faa374f4c38b06"
-  integrity sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==
-
 react-refresh@^0.8.3:
   version "0.8.3"
   resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
@@ -9553,13 +9562,10 @@ react-storage-hooks@^4.0.1:
   resolved "https://registry.yarnpkg.com/react-storage-hooks/-/react-storage-hooks-4.0.1.tgz#e30ed5cda48c77c431ecc02ec3824bd615f5b7fb"
   integrity sha512-fetDkT5RDHGruc2NrdD1iqqoLuXgbx6AUpQSQLLkrCiJf8i97EtwJNXNTy3+GRfsATLG8TZgNc9lGRZOaU5yQA==
 
-react-vtree@^3.0.0-beta.0:
-  version "3.0.0-beta.0"
-  resolved "https://registry.yarnpkg.com/react-vtree/-/react-vtree-3.0.0-beta.0.tgz#55425d367d7f66b11c375a080b770bc61ae88c9f"
-  integrity sha512-S7L19EPMkx7CNkqJqy34oACUoPJar0AkMhRTw54VbvJqcytVYJh1nI4W0rqFhbLfdBU1qJL6XPchKm7MMnZafw==
-  dependencies:
-    "@babel/runtime" "^7.11.0"
-    react-merge-refs "^1.1.0"
+react-virtualized-auto-sizer@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"
+  integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==
 
 react-window@^1.8.6:
   version "1.8.6"