diff --git a/wegas-app/src/main/node/wegas-lobby/src/API/api.ts b/wegas-app/src/main/node/wegas-lobby/src/API/api.ts index e76a8e5210..c59a55ed5e 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/API/api.ts +++ b/wegas-app/src/main/node/wegas-lobby/src/API/api.ts @@ -686,6 +686,13 @@ export const getGameModels = createAsyncThunk( }, ); +export const getGameModelsPaginated = createAsyncThunk( + 'gameModel/getGameModelsPaginated', + async (payload: { type: IGameModelWithId['type']; status: IGameModelWithId['status']; mine: boolean; permissions: string[]; page: number; size: number; query: string }) => { + return await restClient.GameModelController.getGameModelsPaginated(payload.type, payload.status, payload.mine, payload.permissions, payload.page, payload.size, payload.query); + } +) + export const duplicateGameModel = createAsyncThunk( 'gameModel/duplicate', async (gameModelId: number) => { diff --git a/wegas-app/src/main/node/wegas-lobby/src/API/restClient.ts b/wegas-app/src/main/node/wegas-lobby/src/API/restClient.ts index d1d2a0540a..1b36eb21b4 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/API/restClient.ts +++ b/wegas-app/src/main/node/wegas-lobby/src/API/restClient.ts @@ -736,12 +736,12 @@ export const WegasLobbyRestClient = function ( mine: boolean, ) => { const path = `${baseUrl}/Lobby/GameModel/Game/status/${status}/Paginated?page=${page}&size=${size}&query=${query}&mine=${mine}`; - return sendJsonRequest>( - 'GET', - path, - undefined, - errorHandler - ) + return sendJsonRequest>( + 'GET', + path, + undefined, + errorHandler, + ); }, changeStatus: (gameId: number, status: IGameWithId['status']) => { const path = `${baseUrl}/Lobby/GameModel/Game/${gameId}/status/${status}`; @@ -808,6 +808,18 @@ export const WegasLobbyRestClient = function ( const path = `${baseUrl}/Lobby/GameModel/type/${gmType}/status/${status}`; return sendJsonRequest('GET', path, undefined, errorHandler); }, + getGameModelsPaginated: ( + gmType: IGameModelWithId['type'], + status: IGameModelWithId['status'], + mine: boolean, + permissions: string[], + page: number, + size: number, + query: string, + ) => { + const path = `${baseUrl}/Lobby/GameModel/type/${gmType}/status/${status}/Paginated?page=${page}&size=${size}&query=${query}&mine=${mine}&perm=${permissions.join(',')}`; + return sendJsonRequest>('GET', path, undefined, errorHandler); + }, changeStatus: (gmid: number, status: IGameModelWithId['status']) => { const path = `${baseUrl}/Lobby/GameModel/${gmid}/status/${status}`; return sendJsonRequest('PUT', path, undefined, errorHandler); diff --git a/wegas-app/src/main/node/wegas-lobby/src/components/scenarist/ScenaristTab.tsx b/wegas-app/src/main/node/wegas-lobby/src/components/scenarist/ScenaristTab.tsx index d679ff3c20..4d285d34d8 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/components/scenarist/ScenaristTab.tsx +++ b/wegas-app/src/main/node/wegas-lobby/src/components/scenarist/ScenaristTab.tsx @@ -11,13 +11,12 @@ import { faPlusCircle } from '@fortawesome/free-solid-svg-icons'; import { uniq } from 'lodash'; import * as React from 'react'; import { useMatch, useResolvedPath } from 'react-router-dom'; -import { IAbstractAccount, IGameModelWithId } from 'wegas-ts-api'; -import { getGameModels, getShadowUserByIds } from '../../API/api'; -import { getDisplayName, mapByKey, match } from '../../helper'; +import { IGameModelWithId } from 'wegas-ts-api'; +import {getGameModelsPaginated, getShadowUserByIds} from '../../API/api'; import useTranslations from '../../i18n/I18nContext'; import { useLocalStorageState } from '../../preferences'; import { useAccountsByUserIds, useCurrentUser } from '../../selectors/userSelector'; -import { MINE_OR_ALL, useEditableGameModels } from '../../selectors/wegasSelector'; +import { MINE_OR_ALL, useGameModelsById, useGameModelStoreNoticeableChangesCount } from '../../selectors/wegasSelector'; import { useAppDispatch } from '../../store/hooks'; import { WindowedContainer } from '../common/CardContainer'; import DebouncedInput from '../common/DebouncedInput'; @@ -27,35 +26,14 @@ import FitSpace from '../common/FitSpace'; import Flex from '../common/Flex'; import IconButton from '../common/IconButton'; import InlineLoading from '../common/InlineLoading'; -import SortBy, { SortByOption } from '../common/SortBy'; import { successColor } from '../styling/color'; import { panelPadding } from '../styling/style'; import CreateModel from './CreateModel'; import CreateScenario from './CreateScenario'; import GameModelCard from './GameModelCard'; import InferModel from './InferModel'; - -interface SortBy { - createdByName: string; - name: string; - createdTime: number; -} - -const matchSearch = - (accountMap: Record, search: string) => - (gameModel: IGameModelWithId | 'LOADING') => { - return match(search, regex => { - if (gameModel != 'LOADING') { - const username = - gameModel.createdById != null ? getDisplayName(accountMap[gameModel.createdById]) : ''; - return ( - (gameModel.name && gameModel.name.match(regex) != null) || username.match(regex) != null - ); - } else { - return false; - } - }); - }; +import Checkbox from "../common/Checkbox"; +import {IPage} from "../../API/restClient"; export interface ScenaristTabProps { gameModelType: IGameModelWithId['type']; @@ -64,10 +42,16 @@ export interface ScenaristTabProps { export default function ScenaristTab({ gameModelType }: ScenaristTabProps): JSX.Element { const i18n = useTranslations(); const dispatch = useAppDispatch(); - const { currentUser, isAdmin } = useCurrentUser(); - const currentUserId = currentUser != null ? currentUser.id : undefined; + const { isAdmin } = useCurrentUser(); + + const [createPanelViewMode, setCreatePanelViewMode] = React.useState<'EXPANDED' | 'COLLAPSED'>( + 'COLLAPSED', + ); + const [inferModelViewMode, setInferModelViewMode] = React.useState<'EXPANDED' | 'COLLAPSED'>( + 'COLLAPSED', + ); - const [statusFilter, setStatusFilter] = useLocalStorageState( + const [gameStatusFilter, setGameStatusFilter] = useLocalStorageState( 'scenarist-status', 'LIVE', ); @@ -76,71 +60,80 @@ export default function ScenaristTab({ gameModelType }: ScenaristTabProps): JSX. isAdmin ? 'MINE' : 'ALL', ); - const gamemodels = useEditableGameModels( - currentUserId, - gameModelType, - !isAdmin && statusFilter === 'DELETE' ? 'BIN' : statusFilter, - isAdmin ? mineFilter : 'MINE', - ); + const [filter, setFilter] = React.useState(''); - React.useEffect(() => { - if (!isAdmin && statusFilter === 'DELETE') { - setStatusFilter('BIN'); - } - }, [isAdmin, statusFilter, setStatusFilter]); + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(20); - const [createPanelViewMode, setCreatePanelViewMode] = React.useState<'EXPANDED' | 'COLLAPSED'>( - 'COLLAPSED', - ); - const [inferModelViewMode, setInferModelViewMode] = React.useState<'EXPANDED' | 'COLLAPSED'>( - 'COLLAPSED', - ); + const [isDataReady, setIsDataReady] = React.useState(false); + const [renderedGameModelsIds, setRenderedGameModelsIds] = React.useState([]); + const [totalResults, setTotalResults] = React.useState(0); - const [sortBy, setSortBy] = useLocalStorageState<{ key: keyof SortBy; asc: boolean }>( - 'scenarist-sortby', - { - key: 'createdTime', - asc: false, - }, - ); + /** + * used as a trigger to refresh the list of paginated game models + * otherwise the list would not change if game models are added or removed + */ + const nbGameModelStoreChanges = useGameModelStoreNoticeableChangesCount(); - // const onSortChange = React.useCallback(({ key, asc }: { key: keyof SortBy; asc: boolean }) => { - // setSortBy({ key, asc }); - // }, []); + const gamemodels = useGameModelsById(renderedGameModelsIds); - const [filter, setFilter] = React.useState(''); + //non-admin should never see deleted + React.useEffect(() => { + if (!isAdmin && gameStatusFilter === 'DELETE') { + setGameStatusFilter('BIN'); + } + }, [isAdmin, gameStatusFilter, setGameStatusFilter]); const onFilterChange = React.useCallback((filter: string) => { setFilter(filter); }, []); - const sortOptions: SortByOption[] = [ - { key: 'createdTime', label: i18n.date }, - { key: 'name', label: i18n.name }, - ]; - if (isAdmin) { - sortOptions.push({ key: 'createdByName', label: i18n.createdBy }); - } - - const status = gamemodels.status[gameModelType][statusFilter]; + const onNextPage = () => setPage(page < totalResults / pageSize ? page + 1 : page); + const onPreviousPage = () => setPage(page > 1 ? page - 1 : 1); + // if we change any filter or display choice, come back to first page React.useEffect(() => { - if (status === 'NOT_INITIALIZED') { - dispatch(getGameModels({ status: statusFilter, type: gameModelType })); + if (page !== 1) { + setPage(1) } - }, [statusFilter, gameModelType, status, dispatch]); + // page must not be set as dependency + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameStatusFilter, gameModelType, pageSize, filter, mineFilter]); - const userIds = uniq( - gamemodels.gamemodels.flatMap(gm => (gm.createdById != null ? [gm.createdById] : [])), - ); - const accountsState = useAccountsByUserIds(userIds); - const accounts = mapByKey(accountsState.accounts, 'parentId'); + // launch the game models fetch + // when there is any change on any filter or display choice + // OR if game models were added or changed status or deleted in the store (nbGameModelStoreChanges) + React.useEffect(() => { + setIsDataReady(false); + dispatch(getGameModelsPaginated({ + type: gameModelType, + status: gameStatusFilter, + mine: isAdmin ? mineFilter === 'MINE' : true, + permissions: ['Edit', 'Translate'], + page: page, + size: pageSize, + query: filter, + })) + .then(action => { + const payload = (action.payload as IPage); + setRenderedGameModelsIds(payload.pageContent.map((gameModel: IGameModelWithId) => gameModel.id)); + setTotalResults(payload.total); + setIsDataReady(true); + }); + }, [dispatch, isAdmin, gameStatusFilter, gameModelType, page, pageSize, filter, mineFilter, nbGameModelStoreChanges]); const buildCardCb = React.useCallback( - (gameModel: IGameModelWithId) => , - [], + (gameModel: IGameModelWithId) => , + [], + ); + + const userIds: number[] = uniq( + gamemodels.gamemodels.flatMap(gm => (gm.createdById != null ? [gm.createdById] : [])), ); + const accountsState = useAccountsByUserIds(userIds); + + // This is done to fetch the name of the creators of the games React.useEffect(() => { if (isAdmin && accountsState.unknownUsers.length > 0) { dispatch(getShadowUserByIds(accountsState.unknownUsers)); @@ -153,194 +146,201 @@ export default function ScenaristTab({ gameModelType }: ScenaristTabProps): JSX. const match = useMatch<'id', string>(`${resolvedPath.pathname}:id/*`); const selectedId = Number(match?.params.id) || undefined; - if (gamemodels.status[gameModelType][statusFilter] === 'NOT_INITIALIZED') { - return ; - } else { - const selected = gamemodels.gamemodels.find(gm => gm.id === selectedId); - const filtered = filter - ? gamemodels.gamemodels.filter(matchSearch(accounts, filter)) - : gamemodels.gamemodels; - - const sorted = filtered.sort((a, b) => { - const reverse = sortBy.asc ? 1 : -1; - if (sortBy.key === 'createdTime') { - return reverse * (a.createdTime! - b.createdTime!); - } else if (sortBy.key === 'name') { - return reverse * (a.name || '').localeCompare(b.name || ''); - } else if (sortBy.key === 'createdByName') { - const aName = a.createdById != null ? getDisplayName(accounts[a.createdById]) : ''; - const bName = b.createdById != null ? getDisplayName(accounts[b.createdById]) : ''; - - return reverse * aName.localeCompare(bName); - } - return 0; + const selected = gamemodels.gamemodels.find(gm => gm.id === selectedId); + + const statusFilterEntries: { value: IGameModelWithId['status']; label: React.ReactNode }[] = [ + { + value: 'LIVE', + label:
{i18n.liveGameModels}
, + }, + { + value: 'BIN', + label:
{i18n.archivedGameModels}
, + }, + ]; + + if (isAdmin) { + statusFilterEntries.push({ + value: 'DELETE', + label:
{i18n.deletedGameModels}
, }); + } + const dropDownStatus = ( + + ); - const statusFilterEntries: { value: IGameModelWithId['status']; label: React.ReactNode }[] = [ - { - value: 'LIVE', - label:
{i18n.liveGameModels}
, - }, - { - value: 'BIN', - label:
{i18n.archivedGameModels}
, - }, - ]; - - if (isAdmin) { - statusFilterEntries.push({ - value: 'DELETE', - label:
{i18n.deletedGameModels}
, - }); - } - const dropDownStatus = ( - - ); - - const mineFilterEntries: { value: MINE_OR_ALL; label: React.ReactNode }[] = [ - { - value: 'MINE', - label:
{i18n.mine}
, - }, - { - value: 'ALL', - label:
{i18n.all}
, - }, - ]; - - const dropDownMine = isAdmin ? ( - - ) : null; - - return ( - + const mineFilterEntries: { value: MINE_OR_ALL; label: React.ReactNode }[] = [ + { + value: 'MINE', + label:
{i18n.mine}
, + }, + { + value: 'ALL', + label:
{i18n.all}
, + }, + ]; + + const dropDownMine = isAdmin ? ( + + ) : null; + + return ( + + { + setCreatePanelViewMode('COLLAPSED'); + }} + > + {createPanelViewMode === 'EXPANDED' ? ( + gameModelType === 'SCENARIO' ? ( + { + setCreatePanelViewMode('COLLAPSED'); + }} + /> + ) : ( + { + setCreatePanelViewMode('COLLAPSED'); + }} + /> + ) + ) : null} + + + {gameModelType === 'MODEL' ? ( { - setCreatePanelViewMode('COLLAPSED'); + setInferModelViewMode('COLLAPSED'); }} > - {createPanelViewMode === 'EXPANDED' ? ( - gameModelType === 'SCENARIO' ? ( - { - setCreatePanelViewMode('COLLAPSED'); - }} - /> - ) : ( - { - setCreatePanelViewMode('COLLAPSED'); - }} - /> - ) + {inferModelViewMode === 'EXPANDED' ? ( + { + setInferModelViewMode('COLLAPSED'); + }} + /> ) : null} - - {gameModelType === 'MODEL' ? ( - { - setInferModelViewMode('COLLAPSED'); + ) : null} + + + + { + setCreatePanelViewMode('EXPANDED'); }} + title={gameModelType === 'SCENARIO' ? i18n.createGameModel : i18n.createModel} > - {inferModelViewMode === 'EXPANDED' ? ( - { - setInferModelViewMode('COLLAPSED'); - }} - /> - ) : null} - - ) : null} + {gameModelType === 'SCENARIO' ? i18n.createGameModel : i18n.createModel} + - - + {gameModelType === 'MODEL' ? ( { - setCreatePanelViewMode('EXPANDED'); + setInferModelViewMode('EXPANDED'); }} - title={gameModelType === 'SCENARIO' ? i18n.createGameModel : i18n.createModel} + title={i18n.inferModel} > - {gameModelType === 'SCENARIO' ? i18n.createGameModel : i18n.createModel} + {i18n.inferModel} + ) : null} - {gameModelType === 'MODEL' ? ( - { - setInferModelViewMode('EXPANDED'); - }} - title={i18n.inferModel} - > - {i18n.inferModel} - - ) : null} - - - - {dropDownStatus} - {dropDownMine} - - + {dropDownStatus} + {dropDownMine} + + + + + +

{`${totalResults} ${i18n.gameModels}`}

- {status === 'READY' ? ( + {totalResults > pageSize /* show pagination tools only if needed */ && ( <> - - {filter - ? gameModelType === 'SCENARIO' - ? i18n.noScenariosFound - : i18n.noModelsFound - : gameModelType === 'SCENARIO' - ? i18n.noScenarios - : i18n.noModels} - - } - > - {buildCardCb} - + +

+ + {page}/{totalResults > 0 ? Math.ceil(totalResults / pageSize) : 1} + +

+
+ + setPageSize(newValue ? 20 : pageSize)} + /> + setPageSize(newValue ? 50 : pageSize)} + /> + setPageSize(newValue ? 100 : pageSize)} + /> + - ) : ( - )} -
+ + {isDataReady ? ( + + {filter + ? gameModelType === 'SCENARIO' + ? i18n.noScenariosFound + : i18n.noModelsFound + : gameModelType === 'SCENARIO' + ? i18n.noScenarios + : i18n.noModels} + + } + > + {buildCardCb} + + ) : ( + + )}
- ); - } -} - -// -// -// {sorted.map(gameModel => ( -// -// ))} -// +
+ ); +} \ No newline at end of file diff --git a/wegas-app/src/main/node/wegas-lobby/src/components/trainer/CreateGame.tsx b/wegas-app/src/main/node/wegas-lobby/src/components/trainer/CreateGame.tsx index 3db8101cb4..63c9647564 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/components/trainer/CreateGame.tsx +++ b/wegas-app/src/main/node/wegas-lobby/src/components/trainer/CreateGame.tsx @@ -25,10 +25,9 @@ import { defaultSelectStyles, mainButtonStyle } from '../styling/style'; interface CreateGameProps { close: () => void; - callback: () => void; } -export default function CreateGame({ close, callback }: CreateGameProps): JSX.Element { +export default function CreateGame({ close }: CreateGameProps): JSX.Element { const dispatch = useAppDispatch(); const i18n = useTranslations(); const { currentUser } = useCurrentUser(); @@ -49,7 +48,6 @@ export default function CreateGame({ close, callback }: CreateGameProps): JSX.El if (a.meta.requestStatus === 'fulfilled') { close(); } - callback(); }); } }, [dispatch, gameModelId, name, close]); diff --git a/wegas-app/src/main/node/wegas-lobby/src/components/trainer/TrainerTab.tsx b/wegas-app/src/main/node/wegas-lobby/src/components/trainer/TrainerTab.tsx index 8a12cebba8..71c4be2a07 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/components/trainer/TrainerTab.tsx +++ b/wegas-app/src/main/node/wegas-lobby/src/components/trainer/TrainerTab.tsx @@ -16,7 +16,7 @@ import { getGamesPaginated, getShadowUserByIds } from '../../API/api'; import useTranslations from '../../i18n/I18nContext'; import { useLocalStorageState } from '../../preferences'; import { useAccountsByUserIds, useCurrentUser } from '../../selectors/userSelector'; -import { MINE_OR_ALL, useGames } from '../../selectors/wegasSelector'; +import { MINE_OR_ALL, useGamesByIds, useGameStoreNoticeableChangesCount } from '../../selectors/wegasSelector'; import { useAppDispatch } from '../../store/hooks'; import { WindowedContainer } from '../common/CardContainer'; import DebouncedInput from '../common/DebouncedInput'; @@ -31,13 +31,16 @@ import { panelPadding } from '../styling/style'; import CreateGame from './CreateGame'; import GameCard from './GameCard'; import Checkbox from '../common/Checkbox'; +import { IPage } from '../../API/restClient'; export default function TrainerTab(): JSX.Element { const i18n = useTranslations(); const dispatch = useAppDispatch(); const { currentUser, isAdmin } = useCurrentUser(); - const [statusFilter, setStatusFilter] = useLocalStorageState( + const [gameCreationPanelMode, setGameCreationPanelMode] = React.useState<'EXPANDED' | 'COLLAPSED'>('COLLAPSED'); + + const [gameStatusFilter, setGameStatusFilter] = useLocalStorageState( 'trainer.status', 'LIVE', ); @@ -46,62 +49,71 @@ export default function TrainerTab(): JSX.Element { 'MINE', ); - React.useEffect(() => { - if (!isAdmin && statusFilter === 'DELETE') { - setStatusFilter('BIN'); - } - }, [isAdmin, statusFilter, setStatusFilter]); - - const games = useGames( - !isAdmin && statusFilter === 'DELETE' ? 'BIN' : statusFilter, //non-admin should never see deleted - currentUser != null ? currentUser.id : undefined, - isAdmin ? mineFilter : 'MINE', // non-admin only see theirs - ); - - const [viewMode, setViewMode] = React.useState<'EXPANDED' | 'COLLAPSED'>('COLLAPSED'); - const [filter, setFilter] = React.useState(''); + const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(20); - const onNextPage = () => setPage(page < games.totalResults / pageSize ? page + 1 : page); - const onPreviousPage = () => setPage(page > 1 ? page - 1 : 1); + const [isDataReady, setIsDataReady] = React.useState(false); + const [renderedGamesIds, setRenderedGamesIds] = React.useState([]); + const [totalResults, setTotalResults] = React.useState(0); - const onFilterChange = React.useCallback((filter: string) => { - setFilter(filter); - }, []); + /** + * used as a trigger to refresh the list of paginated games + * otherwise the list would not change if games are added or removed + */ + const nbGameStoreChanges = useGameStoreNoticeableChangesCount(); - const status = games.status[statusFilter]; + const games = useGamesByIds( + gameStatusFilter, + currentUser != null ? currentUser.id : undefined, + isAdmin ? mineFilter : 'MINE', + renderedGamesIds + ); + //non-admin should never see deleted React.useEffect(() => { - if (status === 'NOT_INITIALIZED') { - dispatch( - getGamesPaginated({ status: statusFilter, page: page, size: pageSize, query: filter, mine: mineFilter === 'MINE' }), - ); + if (!isAdmin && gameStatusFilter === 'DELETE') { + setGameStatusFilter('BIN'); } - }, [status, dispatch, statusFilter]); + }, [isAdmin, gameStatusFilter, setGameStatusFilter]); + + const onFilterChange = React.useCallback((filter: string) => { + setFilter(filter); + }, []); + + const onNextPage = () => setPage(page < totalResults / pageSize ? page + 1 : page); + const onPreviousPage = () => setPage(page > 1 ? page - 1 : 1); + // if we change any filter or display choice, come back to first page React.useEffect(() => { if (page !== 1) { setPage(1); - } else { - dispatch( - getGamesPaginated({ status: statusFilter, page: page, size: pageSize, query: filter, mine: mineFilter === 'MINE' }), - ); } - }, [filter, pageSize, statusFilter, mineFilter]); + // page must not be set as dependency + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameStatusFilter, pageSize, filter, mineFilter]); + // launch the games fetch + // when there is any change on any filter or display choice + // OR if games were added or changed status or deleted in the store (nbGameStoreChanges) React.useEffect(() => { + setIsDataReady(false); dispatch( - getGamesPaginated({ status: statusFilter, page: page, size: pageSize, query: filter, mine: mineFilter === 'MINE' }), - ); - }, [page]); - - const userIds = uniq( - games.gamesAndGameModels.flatMap(data => - data.game.createdById != null ? [data.game.createdById] : [], - ), - ); + getGamesPaginated({ + status: gameStatusFilter, + page: page, + size: pageSize, + query: filter, + mine: isAdmin ? mineFilter === 'MINE' : true, + }), + ).then((action) => { + const payload = action.payload as IPage; + setRenderedGamesIds(payload.pageContent.map((game: IGameWithId) => game.id)); + setTotalResults(payload.total); + setIsDataReady(true); + }); + }, [dispatch, isAdmin, gameStatusFilter, page, pageSize, filter, mineFilter, nbGameStoreChanges]); const buildCardCb = React.useCallback( (gameAndGameModel: { game: IGameWithId; gameModel: IGameModelWithId }) => ( @@ -114,8 +126,15 @@ export default function TrainerTab(): JSX.Element { [], ); + const userIds: number[] = uniq( + games.gamesAndGameModels.flatMap(data => + data.game.createdById != null ? [data.game.createdById] : [], + ), + ); + const accountsState = useAccountsByUserIds(userIds); + // This is done to fetch the name of the creators of the games React.useEffect(() => { if (isAdmin && accountsState.unknownUsers.length > 0) { dispatch(getShadowUserByIds(accountsState.unknownUsers)); @@ -128,144 +147,123 @@ export default function TrainerTab(): JSX.Element { const match = useMatch<'id', string>(`${resolvedPath.pathname}:id/*`); const selectedId = Number(match?.params.id) || undefined; - if (games.status[statusFilter] === 'NOT_INITIALIZED') { - return ; - } else { - const selected = games.gamesAndGameModels.find(ggm => ggm.gameModel.id === selectedId); - - const statusFilterEntries: { value: IGameWithId['status']; label: React.ReactNode }[] = [ - { - value: 'LIVE', - label:
{i18n.liveGames}
, - }, - { - value: 'BIN', - label:
{i18n.archivedGames}
, - }, - ]; + const selected = games.gamesAndGameModels.find(ggm => ggm.gameModel.id === selectedId); - if (isAdmin) { - statusFilterEntries.push({ - value: 'DELETE', - label:
{i18n.deletedGames}
, - }); - } - const dropDownStatus = ( - - ); + const statusFilterEntries: { value: IGameWithId['status']; label: React.ReactNode }[] = [ + { + value: 'LIVE', + label:
{i18n.liveGames}
, + }, + { + value: 'BIN', + label:
{i18n.archivedGames}
, + }, + ]; + if (isAdmin) { + statusFilterEntries.push({ + value: 'DELETE', + label:
{i18n.deletedGames}
, + }); + } + const dropDownGameStatus = ( + + ); - const mineFilterEntries: { value: MINE_OR_ALL; label: React.ReactNode }[] = [ - { - value: 'MINE', - label:
{i18n.mine}
, - }, - { - value: 'ALL', - label:
{i18n.all}
, - }, - ]; + const mineFilterEntries: { value: MINE_OR_ALL; label: React.ReactNode }[] = [ + { + value: 'MINE', + label:
{i18n.mine}
, + }, + { + value: 'ALL', + label:
{i18n.all}
, + }, + ]; + const dropDownMineOrAll = isAdmin ? ( + + ) : null; - const dropDownMine = isAdmin ? ( - - ) : null; + return ( + + { + setGameCreationPanelMode('COLLAPSED'); + }} + > + {gameCreationPanelMode === 'EXPANDED' && ( + { + setGameCreationPanelMode('COLLAPSED'); + }} + /> + )} + - return ( - - { - setViewMode('COLLAPSED'); - }} + + - {viewMode === 'EXPANDED' ? ( - { - setViewMode('COLLAPSED'); - }} - callback={() => - dispatch( - getGamesPaginated({ - status: statusFilter, - page: page, - size: pageSize, - query: filter, - mine: mineFilter === 'MINE', - }), - ) - } - /> - ) : null} - - - - { + setGameCreationPanelMode('EXPANDED'); + }} > - { - setViewMode('EXPANDED'); - }} - > - {i18n.createGame} - + {i18n.createGame} + - {dropDownStatus} - {dropDownMine} + {dropDownGameStatus} + {dropDownMineOrAll} - - + + + -
-

{`${games.totalResults} ${i18n.games}`}

-
-
+

{`${totalResults} ${i18n.games}`}

+ + {totalResults > pageSize /* show pagination tools only if needed */ && + (<> +

- {page}/{games.totalResults > 0 ? Math.ceil(games.totalResults / pageSize) : 1} + {page}/{totalResults > 0 ? Math.ceil(totalResults / pageSize) : 1}

-
-
+ setPageSize(newValue ? 100 : pageSize)} /> -
-
+
+ ) + } + - {status === 'READY' ? ( - <> - {filter ? i18n.noGamesFound : i18n.noGames}} - > - {buildCardCb} - - - ) : ( - - )} -
+ {isDataReady ? + ({filter ? i18n.noGamesFound : i18n.noGames}} + > + {buildCardCb} + ) + : () + }
- ); - } +
+ ); } diff --git a/wegas-app/src/main/node/wegas-lobby/src/selectors/wegasSelector.ts b/wegas-app/src/main/node/wegas-lobby/src/selectors/wegasSelector.ts index a12dcdaa83..ba2aea77eb 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/selectors/wegasSelector.ts +++ b/wegas-app/src/main/node/wegas-lobby/src/selectors/wegasSelector.ts @@ -9,7 +9,6 @@ import { isEqual, uniq } from 'lodash'; import { IGame, - IGameModel, IGameModelWithId, IGameWithId, IPermission, @@ -120,7 +119,7 @@ export type IGameStoreInfo = IGameWithId | 'LOADING' | undefined; // TODO 5.x syntax //export const useGame = (gameId?: number, compareFunc = customStateEquals) => { -const gameStateEquals = (a: IGameStoreInfo,b : IGameStoreInfo) => customStateEquals(a,b); +const gameStateEquals = (a: IGameStoreInfo, b: IGameStoreInfo) => customStateEquals(a,b); export const useGame = (gameId?: number, compareFunc = gameStateEquals) => { return useAppSelector(state => { @@ -250,7 +249,7 @@ function getGameModels( permissionTypes: PermissionType[], gmType: IGameModelWithId['type'][], gmStatus: IGameModelWithId['status'][], -) { +): IGameModelWithId[] { let gameModels: IGameModelWithId[] = []; const regex = new RegExp(`GameModel:(.*(${permissionTypes.join('|')}).*|\\*):(gm\\d+|\\*)`); @@ -293,6 +292,12 @@ function getGameModels( ); } +function getGameModelsById(state: WegasLobbyState, gameModelsIds: number[]): IGameModelWithId[] { + return Object.values(state.gameModels.gameModels) + .flatMap(gm => entityIs(gm, 'GameModel') ? [gm] : []) + .filter(gm => gameModelsIds.includes(gm.id)); +} + export const useInstantiableGameModels = (userId: number | undefined) => { return useAppSelector( state => { @@ -323,39 +328,36 @@ export const useInstantiableGameModels = (userId: number | undefined) => { ); }; -export const useEditableGameModels = ( - userId: number | undefined, - gameModelType: IGameModel['type'], - gameModelStatus: IGameModel['status'], - mine: MINE_OR_ALL, -) => { +export const useGameModelsById = (gameModelsIds: number[]): { + gamemodels: IGameModelWithId[] +} => { return useAppSelector( state => { - if (userId != null) { - return { - gamemodels: getGameModels( - state, - userId, - mine, - ['Edit', 'Translate'], - [gameModelType], - [gameModelStatus], - ), - status: state.gameModels.status, - }; - } - return { - gamemodels: [], - status: state.gameModels.status, + gamemodels: getGameModelsById( + state, + gameModelsIds, + ).sort((a: IGameModelWithId, b: IGameModelWithId) => b.createdTime - a.createdTime), }; }, (a, b) => { - return shallowEqual(a.gamemodels, b.gamemodels) && isEqual(a.status, b.status); + return shallowEqual(a.gamemodels, b.gamemodels); }, ); }; +/** + * To detect changes that automatically occurred on the game models in the store. + * It can be used to force to refresh a list of paginated game models + * otherwise the list would not change if game models are added or removed + */ +export const useGameModelStoreNoticeableChangesCount = (): number => { + return useAppSelector( + state => { + return state.gameModels.nbGameModelsChanges; + }); +}; + export const useDuplicatableGameModels = (userId: number | undefined) => { return useAppSelector( state => { @@ -439,13 +441,11 @@ export const useGames = ( return { gamesAndGameModels: gamesAndGameModels, status: gStatus, - totalResults: state.games.totalResults, }; } else { return { gamesAndGameModels: [], status: gStatus, - totalResults: state.games.totalResults, }; } }, @@ -469,6 +469,29 @@ export const useGames = ( ); }; +export const useGamesByIds = ( + status: IGame['status'], + userId: number | undefined, + mine: MINE_OR_ALL, + gamesIds: number[], +) => { + const games = useGames(status, userId, mine); + + return {gamesAndGameModels: games.gamesAndGameModels.filter(ggm => gamesIds.includes(ggm.game.id))}; +}; + +/** + * To detect changes that automatically occurred on the games in the store. + * It can be used to force to refresh a list of paginated games + * otherwise the list would not change if games are added or removed + */ +export const useGameStoreNoticeableChangesCount = (): number => { + return useAppSelector( + state => { + return state.games.nbGameChanges; + }); +} + export const useModelInstances = (userId: number | undefined, modelId: number) => { return useAppSelector( state => { diff --git a/wegas-app/src/main/node/wegas-lobby/src/store/slices/game.ts b/wegas-app/src/main/node/wegas-lobby/src/store/slices/game.ts index 1fffeff326..e905f8270b 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/store/slices/game.ts +++ b/wegas-app/src/main/node/wegas-lobby/src/store/slices/game.ts @@ -10,19 +10,20 @@ import { IGameWithId } from 'wegas-ts-api'; import * as API from '../../API/api'; import { mapById } from '../../helper'; import { processDeletedEntities, processUpdatedEntities } from '../../websocket/websocket'; -import { LoadingStatus } from './../store'; +import { LoadingStatus } from '../store'; +import { entityIs } from '../../API/entityHelper'; export interface GameState { - currentUserId: number | undefined; status: Record; games: Record; teams: Record; joinStatus: Record; - totalResults: number; + /** Just a stupid data that changes when a game model is added, its status is changed or is deleted. + * Its aim is to trigger data reloading (only needed for pagination purposes) */ + nbGameChanges: number; } const initialState: GameState = { - currentUserId: undefined, status: { LIVE: 'NOT_INITIALIZED', BIN: 'NOT_INITIALIZED', @@ -32,7 +33,7 @@ const initialState: GameState = { games: {}, teams: {}, joinStatus: {}, - totalResults: 0, + nbGameChanges: 0, }; const slice = createSlice({ @@ -42,7 +43,22 @@ const slice = createSlice({ extraReducers: builder => builder .addCase(processUpdatedEntities.fulfilled, (state, action) => { + action.payload.games.forEach((g: IGameWithId) => { + if (state.games[g.id]) { + // add to noticeable changes the number of created games + state.nbGameChanges++; + } else { + const game = state.games[g.id]; + // trigger change only when status changes. If no condition on what changed, it would be updated a lot, really + if (entityIs(game, 'Game') && game.status !== g.status) { + // add to noticeable changes the number of games that had a status change + state.nbGameChanges++; + } + } + }) + state.games = { ...state.games, ...mapById(action.payload.games) }; + action.payload.teams.forEach(t => { const parentId = t.parentId; if (parentId != null) { @@ -56,7 +72,12 @@ const slice = createSlice({ }); }) .addCase(processDeletedEntities.fulfilled, (state, action) => { - action.payload.games.forEach(id => delete state.games[id]); + action.payload.games.forEach(id => { + delete state.games[id]; + // add to noticeable changes the number of deleted games + state.nbGameChanges++; + }); + action.payload.teams.forEach(id => delete state.teams[id]); if (action.payload.teams.length > 0) { Object.entries(state.teams).forEach(([key, list]) => { @@ -66,12 +87,6 @@ const slice = createSlice({ }); } }) - .addCase(API.reloadCurrentUser.fulfilled, (state, action) => { - // hack: to build state.mine projects, currentUserId must be known - state.currentUserId = action.payload.currentUser - ? action.payload.currentUser.id || undefined - : undefined; - }) .addCase(API.findGameByToken.fulfilled, (state, action) => { const id = action.payload.game.id; state.games[id] = action.payload.game; @@ -102,26 +117,20 @@ const slice = createSlice({ ...mapById( action.payload.map(game => { const g = { ...game }; + // we remove the gameModel because it would not be updated. If the gameModel is needed, fetch it from gameModel slice delete g.gameModel; return g; }), ), }; }) - .addCase(API.getGamesPaginated.pending, (state, action) => { - const status = action.meta.arg.status; - state.status[status] = 'LOADING'; - }) .addCase(API.getGamesPaginated.fulfilled, (state, action) => { - const status = action.meta.arg.status; - state.status[status] = 'READY'; - - state.totalResults = action.payload.total; - state.games = { + ...state.games, ...mapById( action.payload.pageContent.map(game => { const g = { ...game }; + // we remove the gameModel because it would not be updated. If the gameModel is needed, fetch it from gameModel slice delete g.gameModel; return g; }), diff --git a/wegas-app/src/main/node/wegas-lobby/src/store/slices/gameModel.ts b/wegas-app/src/main/node/wegas-lobby/src/store/slices/gameModel.ts index 64702b9923..aaa1f35cfe 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/store/slices/gameModel.ts +++ b/wegas-app/src/main/node/wegas-lobby/src/store/slices/gameModel.ts @@ -10,17 +10,18 @@ import { IGameModelWithId } from 'wegas-ts-api'; import * as API from '../../API/api'; import { mapById } from '../../helper'; import { processDeletedEntities, processUpdatedEntities } from '../../websocket/websocket'; -import { LoadingStatus } from './../store'; +import { LoadingStatus } from '../store'; +import { entityIs } from '../../API/entityHelper'; export interface GameModelState { - currentUserId: number | undefined; status: Record>; gameModels: Record; - games: Record; + /** Just a stupid data that changes when a game model is added, its status is changed or is deleted. + * Its aim is to trigger data reloading (only needed for pagination purposes) */ + nbGameModelsChanges: number; } const initialState: GameModelState = { - currentUserId: undefined, status: { MODEL: { LIVE: 'NOT_INITIALIZED', @@ -48,13 +49,9 @@ const initialState: GameModelState = { }, }, gameModels: {}, - games: {}, + nbGameModelsChanges: 0, }; -function updateParent(state: GameModelState, gameModelId: number, gameId: number) { - state.games[gameModelId] = [gameId]; -} - const slice = createSlice({ name: 'gameModels', initialState, @@ -62,30 +59,28 @@ const slice = createSlice({ extraReducers: builder => builder .addCase(processUpdatedEntities.fulfilled, (state, action) => { - state.gameModels = { ...state.gameModels, ...mapById(action.payload.gameModels) }; - action.payload.games.forEach(g => { - const parentId = g.parentId; - if (parentId != null) { - updateParent(state, parentId, g.id); + action.payload.gameModels.forEach((g: IGameModelWithId) => { + if (state.gameModels[g.id] == undefined) { + // add to noticeable changes the number of created game models + state.nbGameModelsChanges++; + } else { + const gameModel = state.gameModels[g.id]; + // trigger change only when status changes. If no condition on what changed, it would be updated a lot, really + if (entityIs(gameModel, 'GameModel') && gameModel.status != g.status) { + // add to noticeable changes the number of game models that had a status change + state.nbGameModelsChanges++; + } } - }); + }) + + state.gameModels = { ...state.gameModels, ...mapById(action.payload.gameModels) }; }) .addCase(processDeletedEntities.fulfilled, (state, action) => { - action.payload.gameModels.forEach(id => delete state.gameModels[id]); - action.payload.games.forEach(id => delete state.games[id]); - if (action.payload.games.length > 0) { - Object.entries(state.games).forEach(([key, list]) => { - if (typeof list != 'string') { - state.games[+key] = list.filter(item => action.payload.games.indexOf(item) < 0); - } - }); - } - }) - .addCase(API.reloadCurrentUser.fulfilled, (state, action) => { - // hack: to build state.mine projects, currentUserId must be known - state.currentUserId = action.payload.currentUser - ? action.payload.currentUser.id || undefined - : undefined; + action.payload.gameModels.forEach(id => { + delete state.gameModels[id]; + // add to noticeable changes the number of deleted game models + state.nbGameModelsChanges++; + }); }) .addCase(API.getGameModelById.pending, (state, action) => { state.gameModels[action.meta.arg.id] = 'LOADING'; @@ -108,17 +103,13 @@ const slice = createSlice({ action.payload.forEach(game => { if (game.gameModel != null) { state.gameModels[game.gameModel.id] = game.gameModel; - updateParent(state, game.gameModel.id, game.id); } }); }) .addCase(API.getGamesPaginated.fulfilled, (state, action) => { - state.games = []; - state.gameModels = []; action.payload.pageContent.forEach(game => { if (game.gameModel != null) { state.gameModels[game.gameModel.id] = game.gameModel; - updateParent(state, game.gameModel.id, game.id); } }); }) @@ -126,9 +117,6 @@ const slice = createSlice({ action.payload.forEach(data => { if (data.gameModel != null) { state.gameModels[data.gameModel.id] = data.gameModel; - if (data.game != null) { - updateParent(state, data.gameModel.id, data.game.id); - } } }); @@ -171,6 +159,17 @@ const slice = createSlice({ ), }; }) + .addCase(API.getGameModelsPaginated.fulfilled, (state, action) => { + state.gameModels = { + ...state.gameModels, + ...mapById( + action.payload.pageContent.map(gameModel => { + return { ...gameModel }; + }), + ), + }; + + }) .addCase(API.runAs.fulfilled, () => { return initialState; }) diff --git a/wegas-app/src/main/node/wegas-lobby/src/store/slices/player.ts b/wegas-app/src/main/node/wegas-lobby/src/store/slices/player.ts index 154cea5f31..59f1abb832 100644 --- a/wegas-app/src/main/node/wegas-lobby/src/store/slices/player.ts +++ b/wegas-app/src/main/node/wegas-lobby/src/store/slices/player.ts @@ -14,17 +14,15 @@ import { processDeletedEntities, processUpdatedEntities, } from '../../websocket/websocket'; -import { LoadingStatus } from './../store'; +import { LoadingStatus } from '../store'; export interface PlayerState { - currentUserId: number | undefined; status: LoadingStatus; // players owned by the current user players: Record; } const initialState: PlayerState = { - currentUserId: undefined, status: 'NOT_INITIALIZED', players: {}, }; @@ -46,12 +44,6 @@ const playerSlice = createSlice({ if (existing == null || action.payload.version >= existing.version) state.players[action.payload.id] = action.payload; }) - .addCase(API.reloadCurrentUser.fulfilled, (state, action) => { - // hack: to build state.mine projects, currentUserId must be known - state.currentUserId = action.payload.currentUser - ? action.payload.currentUser.id || undefined - : undefined; - }) .addCase(API.getPlayers.pending, state => { state.status = 'LOADING'; }) diff --git a/wegas-core/src/main/java/com/wegas/core/ejb/GameFacade.java b/wegas-core/src/main/java/com/wegas/core/ejb/GameFacade.java index aa0cda8635..2d14743c44 100644 --- a/wegas-core/src/main/java/com/wegas/core/ejb/GameFacade.java +++ b/wegas-core/src/main/java/com/wegas/core/ejb/GameFacade.java @@ -413,7 +413,6 @@ public Page findByStatusAndUserPaginated(Game.Status status, boolean mine, for (String param : pageable.getSplitQuery()) { - ParameterExpression queryParameter = criteriaBuilder.parameter(String.class); if (!param.isEmpty()) { Join gameModelJoin = gameRoot.join("gameModel", JoinType.INNER); diff --git a/wegas-core/src/main/java/com/wegas/core/ejb/GameModelFacade.java b/wegas-core/src/main/java/com/wegas/core/ejb/GameModelFacade.java index 8d95dec185..c66eecb363 100644 --- a/wegas-core/src/main/java/com/wegas/core/ejb/GameModelFacade.java +++ b/wegas-core/src/main/java/com/wegas/core/ejb/GameModelFacade.java @@ -1,7 +1,7 @@ /** * Wegas * http://wegas.albasim.ch - * + *

* Copyright (c) 2013-2021 School of Management and Engineering Vaud, Comem, MEI * Licensed under the MIT License */ @@ -45,9 +45,11 @@ import com.wegas.core.persistence.game.Game; import com.wegas.core.persistence.game.GameModel; import com.wegas.core.persistence.game.GameModel.GmType; + import static com.wegas.core.persistence.game.GameModel.GmType.MODEL; import static com.wegas.core.persistence.game.GameModel.GmType.PLAY; import static com.wegas.core.persistence.game.GameModel.GmType.SCENARIO; + import com.wegas.core.persistence.game.GameModel.Status; import com.wegas.core.persistence.game.GameModelContent; import com.wegas.core.persistence.game.GameModelLanguage; @@ -59,14 +61,18 @@ import com.wegas.core.persistence.variable.scope.AbstractScope; import com.wegas.core.rest.util.JacksonMapperProvider; import com.wegas.core.rest.util.Views; +import com.wegas.core.rest.util.pagination.Page; +import com.wegas.core.rest.util.pagination.Pageable; import com.wegas.core.security.ejb.UserFacade; import com.wegas.core.security.guest.GuestJpaAccount; +import com.wegas.core.security.persistence.AbstractAccount; import com.wegas.core.security.persistence.Permission; import com.wegas.core.security.persistence.User; import com.wegas.core.security.util.ActAsPlayer; import com.wegas.core.tools.FindAndReplacePayload; import com.wegas.core.tools.FindAndReplaceVisitor; import com.wegas.core.tools.RegexExtractorVisitor; + import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -77,9 +83,11 @@ import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; + import jakarta.ejb.Asynchronous; import jakarta.ejb.LocalBean; import jakarta.ejb.Stateless; @@ -87,10 +95,13 @@ import jakarta.ejb.TransactionAttributeType; import jakarta.enterprise.event.Event; import jakarta.inject.Inject; + import javax.jcr.RepositoryException; import javax.naming.NamingException; + import jakarta.persistence.NoResultException; import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.*; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.StreamingOutput; import org.apache.commons.io.IOUtils; @@ -383,7 +394,7 @@ public Player addTestPlayer(GameModel gameModel) { /* Make sure the game model is a scenario or a model */ if (gameModel.getType() != GmType.SCENARIO - && gameModel.getType() != GmType.SCENARIO) { + && gameModel.getType() != GmType.SCENARIO) { throw WegasErrorMessage.error("GameModel is null"); } @@ -420,7 +431,7 @@ public Player addTestPlayer(GameModel gameModel) { this.getEntityManager().persist(player); // make sure to create all missing variable isntances - try ( ActAsPlayer a = requestManager.actAsPlayer(player)) { + try (ActAsPlayer a = requestManager.actAsPlayer(player)) { this.propagateAndReviveDefaultInstances(gameModel, player, true); // One-step team create (internal use) } @@ -594,7 +605,7 @@ public String findUniqueName(String oName, GmType type) { */ public List findDistinctLogIds() { TypedQuery query = this.getEntityManager() - .createNamedQuery("GameModel.findDistinctLogIds", String.class); + .createNamedQuery("GameModel.findDistinctLogIds", String.class); return query.getResultList(); } @@ -710,7 +721,7 @@ public StreamingOutput archiveAsWGZ(Long gameModelId) throws RepositoryException out = new StreamingOutput() { @Override public void write(OutputStream output) throws IOException, WebApplicationException { - try ( ZipOutputStream zipOutputStream = new ZipOutputStream(output, StandardCharsets.UTF_8)) { + try (ZipOutputStream zipOutputStream = new ZipOutputStream(output, StandardCharsets.UTF_8)) { // serialise the json ZipEntry gameModelEntry = new ZipEntry("gamemodel.json"); @@ -786,13 +797,13 @@ private String extensionToMimeType(String ext) { private static final String GM_DOT_JSON_NAME = GM_PREFIX + "gamemodel.json"; private static final Pattern LIB_PATTERN = Pattern.compile(LIBS_PREFIX - + NO_SLASH_GROUP// libType - + "/" + ANY_LAZY_GROUP // libName / libPath - + "\\." + NO_SLASH_GROUP); //libExtension + + NO_SLASH_GROUP// libType + + "/" + ANY_LAZY_GROUP // libName / libPath + + "\\." + NO_SLASH_GROUP); //libExtension private static final Pattern PAGE_PATTERN = Pattern.compile(PAGES_PREFIX - + NO_SLASH_GROUP// pageId - + "\\.json"); //libExtension + + NO_SLASH_GROUP// pageId + + "\\.json"); //libExtension private static final Pattern FILE_PATTERN = Pattern.compile(FILES_PREFIX + "(.*)"); //libExtension @@ -829,7 +840,7 @@ public StreamingOutput archiveAsExplodedZip(Long gameModelId) throws CloneNotSup out = new StreamingOutput() { @Override public void write(OutputStream output) throws IOException, WebApplicationException { - try ( ZipOutputStream zipOutputStream = new ZipOutputStream(output, StandardCharsets.UTF_8)) { + try (ZipOutputStream zipOutputStream = new ZipOutputStream(output, StandardCharsets.UTF_8)) { ObjectMapper mapper = JacksonMapperProvider.getMapper(); mapper.enable(SerializationFeature.INDENT_OUTPUT); mapper.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); @@ -1109,11 +1120,11 @@ public RecombinedGameModel extractFromExplodedZip(ZipInputStream zip) throws IOE List gmLibs = gameModel.getLibraries(); libs.forEach(lib -> { Optional find = gmLibs.stream() - .filter(l -> { - return l.getLibraryType().equals(lib.getLibraryType()) - && l.getContentKey().equals(lib.getContentKey()); - }) - .findFirst(); + .filter(l -> { + return l.getLibraryType().equals(lib.getLibraryType()) + && l.getContentKey().equals(lib.getContentKey()); + }) + .findFirst(); if (find.isPresent()) { // restore content find.get().setContent(lib.getContent()); @@ -1489,7 +1500,7 @@ public List findByTypeAndStatus(final GmType gmType, final GameModel. * @return all gameModel matching the given status */ public List findByTypesAndStatuses(List gmTypes, - final List statuses) { + final List statuses) { final TypedQuery query = getEntityManager().createNamedQuery("GameModel.findByTypesAndStatuses", GameModel.class); query.setParameter("types", gmTypes); @@ -1589,7 +1600,7 @@ public void liveUpdate(String channel, AbstractEntity entity) { * @return */ public Collection findByTypeStatusAndUser(GmType type, - GameModel.Status status) { + GameModel.Status status) { ArrayList gameModels = new ArrayList<>(); Map> pMatrix = this.getPermissionMatrix(type, status); @@ -1605,6 +1616,86 @@ public Collection findByTypeStatusAndUser(GmType type, return gameModels; } + /** + * + * @param type type {@link GameModel.GmType#MODEL} {@link GameModel.GmType#REFERENCE} {@link GameModel.GmType#SCENARIO} {@link GameModel.GmType#PLAY} + * @param status status {@link Game.Status#LIVE} {@link Game.Status#BIN} {@link Game.Status#DELETE} + * @param mine boolean return currentUser's or all games (admin only) + * @param permissions The requested permissions to filter out others game models + * @param pageable + * @return + */ + public Page findByTypeStatusPermissionAndUserPaginated(GmType type, GameModel.Status status, boolean mine, List permissions, Pageable pageable) { + List gameModelIdsPartiallyMatching = getGameModelIdsByUserPermissionsTypeAndStatus(type, status, permissions); + + final CriteriaBuilder criteriaBuilder = getEntityManager().getCriteriaBuilder(); + final CriteriaQuery query = criteriaBuilder.createQuery(GameModel.class); + Root gameModelRoot = query.from(GameModel.class); + query.select(gameModelRoot); + + Predicate whereClause = criteriaBuilder.and( + criteriaBuilder.equal(gameModelRoot.get("status"), status), + criteriaBuilder.equal(gameModelRoot.get("type"), type), + // Maximum in values for psql: 32767 + gameModelRoot.get("id").in(gameModelIdsPartiallyMatching) + ); + + for (String param: pageable.getSplitQuery()) { + if (!param.isEmpty()) { + Join userJoin = gameModelRoot.join("createdBy", JoinType.INNER); + Join abstractAccountJoin = userJoin.join("accounts", JoinType.INNER); + Expression exp = criteriaBuilder.concat(criteriaBuilder.lower(criteriaBuilder.coalesce(abstractAccountJoin.get("firstname"), "")), " "); + exp = criteriaBuilder.concat(exp, criteriaBuilder.lower(criteriaBuilder.coalesce(abstractAccountJoin.get("lastname"), ""))); + + whereClause = criteriaBuilder.and(whereClause, criteriaBuilder.or( + criteriaBuilder.like(criteriaBuilder.lower(gameModelRoot.get("name")), "%" + param.toLowerCase() + "%"), + criteriaBuilder.like(criteriaBuilder.lower(exp), "%" + param.toLowerCase() + "%") + )); + } + } + + if (mine && requestManager.isAdmin()) { + User user = userFacade.getCurrentUser(); + whereClause = criteriaBuilder.and(whereClause, criteriaBuilder.and( + criteriaBuilder.equal(gameModelRoot.get("createdBy"), user) + )); + } + + query.where(whereClause); + query.orderBy(criteriaBuilder.desc(gameModelRoot.get("createdTime"))); + + int total = getEntityManager().createQuery(query).getResultList().size(); + TypedQuery listQuery = pageable.paginateQuery(getEntityManager().createQuery(query)); + + return new Page(total, pageable.getPage(), pageable.getSize(), listQuery.getResultList()); + + } + + private List getGameModelIdsByUserPermissionsTypeAndStatus(GmType type, GameModel.Status status, List permissions) { + List gameModelIdsMatchingPermissions = new ArrayList<>(); + + Map> permissionMatrix = this.getPermissionMatrix(type, status); + for (Map.Entry> entry : permissionMatrix.entrySet()) { + boolean hasAccess = false; + if (entry.getValue().contains("*")) { + hasAccess = true; + } else { + for (String perm : entry.getValue()) { + if (permissions.contains(perm)) { + // at least one match between wanted permissions and user's effective permissions + hasAccess = true; + break; + } + } + } + if (hasAccess) { + gameModelIdsMatchingPermissions.add(entry.getKey()); + } + } + + return gameModelIdsMatchingPermissions; + } + /** * Get the list of gameModels id the current user has access to. * @@ -1614,7 +1705,7 @@ public Collection findByTypeStatusAndUser(GmType type, * @return list of gameModel id mapped with the permission the user has */ public Map> getPermissionMatrix(GmType type, - GameModel.Status status) { + GameModel.Status status) { List gmTypes = new ArrayList<>(); List gmStatuses = new ArrayList<>(); @@ -1634,13 +1725,13 @@ public Map> getPermissionMatrix(GmType type, * @return list of gameModel id mapped with the permission the user has */ public Map> getPermissionMatrix(List types, - List statuses) { + List statuses) { Map> pMatrix = new HashMap<>(); String roleQuery = "SELECT p FROM Permission p WHERE " - + "(p.role.id in " - + " (SELECT r.id FROM User u JOIN u.roles r WHERE u.id = :userId)" - + ")"; + + "(p.role.id in " + + " (SELECT r.id FROM User u JOIN u.roles r WHERE u.id = :userId)" + + ")"; String userQuery = "SELECT p FROM Permission p WHERE p.user.id = :userId "; @@ -1662,9 +1753,9 @@ public void processQuery(String sqlQuery, Map> gmMatrix, Map< } private void processPermission(String permission, Map> gmMatrix, - Map> gMatrix, - List gmTypes, List gmStatuses, - List gStatuses) { + Map> gMatrix, + List gmTypes, List gmStatuses, + List gStatuses) { if (permission != null && !permission.isEmpty()) { String[] split = permission.split(":"); if (split.length == 3) { @@ -1687,6 +1778,7 @@ private void processPermission(String permission, Map> gmMatr String pId = split[2].replaceAll(idPrefix, ""); ArrayList ids = new ArrayList<>(); if (!pId.isEmpty()) { + // Wildcard, has access to all if (pId.equals("*")) { if (type.equals("GameModel")) { for (GameModel gm : this.findByTypesAndStatuses(gmTypes, gmStatuses)) { @@ -1703,8 +1795,8 @@ private void processPermission(String permission, Map> gmMatr if (type.equals("GameModel")) { GameModel gm = this.find(id); if (gm == null - || !gmTypes.contains(gm.getType()) - || !gmStatuses.contains(gm.getStatus())) { + || !gmTypes.contains(gm.getType()) + || !gmStatuses.contains(gm.getStatus())) { return; } } else { @@ -1909,7 +2001,7 @@ public Set findAllRefToFiles(Long gameModelId, Long vdId) { * @return */ public Set findAllRefToFiles(GameModel gameModel, - VariableDescriptor root) { + VariableDescriptor root) { FindAndReplacePayload payload = new FindAndReplacePayload(); payload.setLangsFromGameModel(gameModel); diff --git a/wegas-core/src/main/java/com/wegas/core/rest/GameModelController.java b/wegas-core/src/main/java/com/wegas/core/rest/GameModelController.java index 380f09dfb9..9adfd232ed 100644 --- a/wegas-core/src/main/java/com/wegas/core/rest/GameModelController.java +++ b/wegas-core/src/main/java/com/wegas/core/rest/GameModelController.java @@ -25,6 +25,8 @@ import com.wegas.core.persistence.game.GameModel.Status; import com.wegas.core.persistence.game.Player; import com.wegas.core.rest.util.JacksonMapperProvider; +import com.wegas.core.rest.util.pagination.Page; +import com.wegas.core.rest.util.pagination.Pageable; import com.wegas.core.security.persistence.Permission; import com.wegas.core.tools.FindAndReplacePayload; import java.io.IOException; @@ -32,24 +34,14 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import java.util.zip.ZipInputStream; import jakarta.ejb.Stateless; import jakarta.inject.Inject; import javax.jcr.RepositoryException; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DELETE; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.PUT; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; + +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.StreamingOutput; @@ -643,6 +635,32 @@ public Collection findByTypeAndStatus( return gameModelFacade.findByTypeStatusAndUser(type, status); } + /** + * Get all gameModel of given type with given status paginated + * + * @param type + * @param status + * @param mine + * @param permissions The requested permissions + * @param page + * @param size + * @param query + * @return given size of gameModel having type, status and matching query + */ + @GET + @Path("type/{type: [A-Z]*}/status/{status: [A-Z]*}/Paginated") + public Page paginatedGameModels( + @PathParam("type") final GameModel.GmType type, + @PathParam("status") final GameModel.Status status, + @QueryParam("mine") boolean mine, + @QueryParam("perm") String permissions, + @QueryParam("page") int page, + @QueryParam("size") int size, + @QueryParam("query") String query + ) { + return gameModelFacade.findByTypeStatusPermissionAndUserPaginated(type, status, mine, Arrays.asList(permissions.split(",")), new Pageable(page, size, query)); + } + /** * count gameModel with given status *