Skip to content

Commit

Permalink
cont work on entities
Browse files Browse the repository at this point in the history
  • Loading branch information
yiqu committed Jun 4, 2023
1 parent 8986314 commit c480a77
Show file tree
Hide file tree
Showing 9 changed files with 305 additions and 18 deletions.
53 changes: 53 additions & 0 deletions src/core/store/entities/entities.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createApi, fetchBaseQuery, TagDescription } from '@reduxjs/toolkit/query/react';
import { BASE_SW_API } from 'src/shared/api/endpoints';
import { HttpResponse, StarwarsContent } from 'src/shared/models/starwars.model';
import { EntityFetchArg } from './entities.state';
import { current } from 'immer';

export const speciesTag = "Species";
export const starshipsTag = "Starships";
export const vehiclesTag = "Vehicles";
export const planetsTag = "Planets";
export const peopleTag = "People";

export const starwarsEntitiesApi = createApi({
reducerPath: 'swEntities',
baseQuery: fetchBaseQuery({
baseUrl: BASE_SW_API
}),
tagTypes: [speciesTag, starshipsTag, vehiclesTag, planetsTag, peopleTag],
endpoints: (builder) => ({

fetchEntitiesInfinite: builder.query<HttpResponse<StarwarsContent>, EntityFetchArg>({
query: (args: EntityFetchArg) => {
return {
url: `${args.url}`,
method: 'GET'
};
},
transformResponse: (response: HttpResponse<StarwarsContent>, meta, args: EntityFetchArg) => {
return response;
},
serializeQueryArgs: ({ endpointName, endpointDefinition, queryArgs }) => {
return queryArgs.entityId; // each infinite scroll cache correspond to their own unique id
},
merge: (currentCache: HttpResponse<StarwarsContent>, newItems: HttpResponse<StarwarsContent>) => {
currentCache.results.push(...newItems.results);
},
forceRefetch({ currentArg, previousArg }) {
return currentArg?.url !== previousArg?.url; // if provided url has changed
},
providesTags: (result, error, args: EntityFetchArg, meta) => {
const tags: TagDescription<"Species" | "Starships" | "Vehicles" | "Planets" | "People">[] = [];
result?.results.forEach((res: StarwarsContent) => {
tags.push({ type: args.entityId, id: res.uid });
});
tags.push(peopleTag);
return tags;
}
}),

})
});

export const { useFetchEntitiesInfiniteQuery, useLazyFetchEntitiesInfiniteQuery } = starwarsEntitiesApi;
49 changes: 49 additions & 0 deletions src/core/store/entities/entities.reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { EntityHttpParams } from 'src/shared/models/http.model';
import { ContentPagination, SwEntitiesState } from './entities.state';
import { starwarsEntitiesApi } from './entities.api';
import { HttpResponse, StarwarsContent } from 'src/shared/models/starwars.model';

const initialState: SwEntitiesState = {
pagination: {}
};

export const swEntitiesConfigSlice = createSlice({
name: 'swEntitiesConfig',
initialState,
reducers: {
dispatchPaging: (state, action: PayloadAction<ContentPagination>) => {
state.pagination[action.payload.entityId] = {
...state.pagination[action.payload.entityId],
pagination: {
...state.pagination[action.payload.entityId].pagination,
fetchUrl: action.payload.pagination.fetchUrl
}
};
}
},
extraReducers: (builder) => {
builder.addMatcher(starwarsEntitiesApi.endpoints.fetchEntitiesInfinite.matchFulfilled, (state, action) => {
const payload: HttpResponse<StarwarsContent> = action.payload;
const entityId = action.meta.arg.originalArgs.entityId;
state.pagination = {
...state.pagination,
[entityId]: {
entityId: entityId,
pagination: {
...state.pagination[entityId]?.pagination,
total_pages: payload.total_pages,
total_records: payload.total_records,
next: payload.next,
previous: payload.previous
}
}
};

});
}
});

export const { dispatchPaging } = swEntitiesConfigSlice.actions;
export default swEntitiesConfigSlice.reducer;
23 changes: 23 additions & 0 deletions src/core/store/entities/entities.selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createSelector } from "@reduxjs/toolkit";
import { BASE_SW_API } from "src/shared/api/endpoints";
import { RootState } from "src/store/appStore";
import { EntityType } from "./entities.state";

const swapiConfigSlice = (state: RootState) => {
return state.swEntitiesConfig;
};


export const selectNextPageUrl = (entityId: EntityType) => createSelector(
swapiConfigSlice,
(slice): string | null | undefined=> {
return slice.pagination[entityId]?.pagination.next;
}
);

export const selectFetchPageUrl = (entityId: EntityType) => createSelector(
swapiConfigSlice,
(slice): string => {
return slice.pagination[entityId]?.pagination.fetchUrl ?? `${BASE_SW_API}${entityId.toLowerCase()}?page=1&limit=10`;
}
);
29 changes: 29 additions & 0 deletions src/core/store/entities/entities.state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Pagination } from "src/shared/models/http.model";

export type EntityType = 'Species' | 'Starships' | 'Vehicles' | 'Planets' | 'People';

export interface EntityFetchArg {
url: string;
entityId: EntityType;
}

export interface SwEntitiesState {
pagination: {
[entityId: string]: ContentPagination;
};
}

export interface SearchContentQuery {
entity: string;
name: string;
}

export interface StarwarsSpecieEditable<T> {
entityId: EntityType;
editable: T;
}

export interface ContentPagination {
pagination: Pagination;
entityId: EntityType;
}
4 changes: 3 additions & 1 deletion src/core/store/sw-entities-config/swapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export const starwarsContentApi = createApi({
transformResponse: (response: HttpResponse<StarwarsContent>, meta, args: string) => {
return response;
},
serializeQueryArgs: ({ endpointName }) => {
serializeQueryArgs: ({ endpointName,endpointDefinition,queryArgs }) => {
return endpointName;
},
merge: (currentCache: HttpResponse<StarwarsContent>, newItems: HttpResponse<StarwarsContent>) => {
Expand All @@ -245,6 +245,8 @@ export const starwarsContentApi = createApi({
}
}),



// General Search
searchContent: builder.query<ResultProperty<any>[], SearchContentQuery>({
query: (args: SearchContentQuery) => {
Expand Down
149 changes: 137 additions & 12 deletions src/personal-movies/new-film/NewPersonalFilmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { Dialog, DialogActions, Button, DialogTitle, IconButton, Stack, Divider, DialogContent } from "@mui/material";
import { Dialog, DialogActions, Button, DialogTitle, IconButton, Stack, Divider, DialogContent, FormControl, InputLabel, Select,
OutlinedInput, Box, Chip, SelectChangeEvent, Typography, Checkbox, ListItemText } from "@mui/material";
import MenuItem from '@mui/material/MenuItem';
import SaveIcon from '@mui/icons-material/Save';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import CloudDownloadIcon from '@mui/icons-material/CloudDownload';
import CloseIcon from '@mui/icons-material/Close';
import { Refresh } from "@mui/icons-material";
import { Form, Formik, useFormikContext } from "formik";
import { useRef, useState } from "react";
import { useFetchCharactersInfiniteQuery } from "src/core/store/sw-entities-config/swapi";
import { useAppDispatch, useAppSelector } from "src/store/appHook";
import { selectFetchPageUrl, selectNextPageUrl } from "src/core/store/entities/entities.selectors";
import { skipToken } from "@reduxjs/toolkit/dist/query/react";
import InfiniteScroll from "react-infinite-scroll-component";
import { dispatchPaging } from "src/core/store/entities/entities.reducer";
import { useFetchEntitiesInfiniteQuery, useLazyFetchEntitiesInfiniteQuery } from "src/core/store/entities/entities.api";
import { EntityType } from "src/core/store/entities/entities.state";
import { StarwarsContent } from "src/shared/models/starwars.model";
import ProgressCircle from "src/shared/components/progress/CircleProgress";
import { getFilledIcon } from "src/shared/utils/left-nav.utils";


export interface NewPersonalFilmDialogProps {
open?: boolean;
Expand All @@ -14,7 +29,9 @@ export interface NewPersonalFilmDialogProps {
function NewPersonalFilmDialog({ open, onDialogClose }: NewPersonalFilmDialogProps) {

const handleDialogClose = (event: object, reason: string) => {
onDialogClose();
if (reason !== 'backdropClick') {
onDialogClose();
}
};

const handleRefreshData = () => {
Expand All @@ -26,8 +43,8 @@ function NewPersonalFilmDialog({ open, onDialogClose }: NewPersonalFilmDialogPro
};

return (
<Dialog onClose={ handleDialogClose } open={ !!open } maxWidth="lg">
<DialogTitle minWidth={ '30rem' } sx={ {bgcolor: (theme) => theme.palette.mode === 'light' ? 'primary.main' : null,
<Dialog onClose={ handleDialogClose } open={ !!open } maxWidth="md">
<DialogTitle minWidth={ '900px' } sx={ {bgcolor: (theme) => theme.palette.mode === 'light' ? 'primary.main' : null,
color: (theme) => theme.palette.mode === 'light' ? '#fff' : null} }>
<Stack direction={ 'row' } justifyContent="space-between" alignItems="center">
<Stack direction="row" justifyContent="start" alignItems="center">
Expand Down Expand Up @@ -62,25 +79,26 @@ export default NewPersonalFilmDialog;
function NewFilmForm() {

const { values, submitForm, dirty, errors, isValid } = useFormikContext();

console.log(values);


const handleReset = () => {

};

const handleSave = () => {

};

return (
<Form>
<DialogContent>

Form
<Stack direction="column" justifyContent="start" alignItems="center" width="100%">
<RemoteSelect entityId="People" />
<RemoteSelect entityId="Planets" />
<RemoteSelect entityId="Vehicles" />
<RemoteSelect entityId="Starships" />
<RemoteSelect entityId="Species" />
</Stack>
</DialogContent>


<Divider flexItem variant="fullWidth" />
<DialogActions>
<Button variant="text" startIcon={ <RestartAltIcon /> } onClick={ handleReset }>
Expand All @@ -96,4 +114,111 @@ function NewFilmForm() {

export const DEFAULT_NEW_FORM_VALUE = {
title: ''
};
};

export interface RemoteSelectProps {
entityId: EntityType;
}

function RemoteSelect({ entityId }: RemoteSelectProps) {
const dispatch = useAppDispatch();
const [ selectedEntities, setSelectedEntities ] = useState<StarwarsContent[]>([]);

const fetchUrl: string | undefined = useAppSelector(selectFetchPageUrl(entityId));
const nextPage: string | null | undefined = useAppSelector(selectNextPageUrl(entityId));
const { data, isLoading, isFetching }= useFetchEntitiesInfiniteQuery({entityId, url: fetchUrl});

const selectedObject = useRef({} as {[uid: string]: boolean}); // used to highlight selections

const onPageHandler = () => {
dispatch(dispatchPaging({entityId: entityId, pagination: { page: -1, fetchUrl: nextPage }}));
};

/**
* Using a wrapper (infinite scroll) div will not trigger default Select's onChange. So we have to
* manually calculate select and de-select
*
* @param content
* @returns
*/
const handleOptionClick = (content: StarwarsContent) => (event: any) => {
const isAlreadySelected: boolean = !!selectedObject.current[content.uid];

selectedObject.current[`${content.uid}`] = (isAlreadySelected ? false : true);
setSelectedEntities((current) => {
if (isAlreadySelected) {
const dup = [...current];
const indexToRemove = current.findIndex((c) => c.uid === content.uid) ?? -1;
if (indexToRemove > -1) {
dup.splice(indexToRemove, 1);
}
return [...dup];
}
return [...current, content];
});
};


return (
<Stack direction="row" justifyContent="start" alignItems="center" width="100%" spacing={ 2 } mb={ 2 }>
<Box flexBasis="20%">
<Box>
{ getFilledIcon(entityId.toLowerCase()) }
</Box>
<Typography variant="h6">{ entityId } {selectedEntities.length > 0 ? `(${selectedEntities.length})` : ''}</Typography>
</Box>
<FormControl sx={ { width: '100%' } }>
<InputLabel> { entityId } </InputLabel>
<Select
multiple
value={ selectedEntities }
input={ <OutlinedInput label={ entityId } /> }
renderValue={ (selected: StarwarsContent[]) => {
return (
<Box sx={ { display: 'flex', flexWrap: 'wrap', gap: 0.5 } }>
{
selected.map((value: StarwarsContent) => (
<Chip key={ value.uid } label={ value.name } color="info" size="small" sx={ {height: '20px'} } />
))
}
</Box>
);
} }
MenuProps={ {
PaperProps: {
style: {
maxHeight: 300 // limit height to trigger scrollable
},
id: 'menuParentDiv' // set the div for infinite scroll
}
} }
>
{
isLoading ? <MenuItem disabled><ProgressCircle size={ 15 } styleProps={ {marginRight: 2} } /><em>Loading { entityId }...</em></MenuItem> : (
<InfiniteScroll
dataLength={ data?.results.length ?? 0 }
next={ onPageHandler }
hasMore={ !!nextPage }
loader={ <MenuItem disabled><ProgressCircle size={ 15 } styleProps={ {marginRight: 2} } /><em>Loading more { entityId }...</em></MenuItem> }
endMessage={ <MenuItem disabled><em>End of { entityId }</em></MenuItem> }
className="scroller-parent"
scrollableTarget="menuParentDiv">
{
data?.results.map((content: StarwarsContent) => (
<MenuItem key={ content.uid } onClick={ handleOptionClick(content) } selected={ selectedObject.current[content.uid] === true }>
<Checkbox checked={ selectedObject.current[content.uid] === true } />
<ListItemText primary={ content.name } />
</MenuItem>
))
}
</InfiniteScroll>
)
}
</Select>
</FormControl>
<Box width={ 30 }>
{ isFetching && <ProgressCircle size={ 15 } /> }
</Box>
</Stack>
);
}
3 changes: 1 addition & 2 deletions src/shared/models/starwars.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ export interface HttpResponse<T> {
total_records: number;
total_pages: number;
previous: string;
count?: number;
next: string;
results: T[];
//
count?: number;
result?: ResultProperty<T>[];
}

Expand Down
3 changes: 2 additions & 1 deletion src/shared/utils/left-nav.utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export const getFilledIcon = (pathName: string): JSX.Element => {
case 'movies': {
return <MovieIcon color='primary' />;
}
case 'characters': {
case 'characters':
case 'people': {
return <Person4Icon color='primary' />;
}
case 'planets': {
Expand Down
Loading

0 comments on commit c480a77

Please sign in to comment.