From eb81176ee156746a051e8db332f1ecd3a82e89de Mon Sep 17 00:00:00 2001 From: Spencer Spenst Date: Wed, 4 Oct 2023 14:53:05 -0700 Subject: [PATCH] multiSelectLevel (from #1008) --- components/page/multiSelectLevel.tsx | 125 ++++++++++++++++++++ models/schemas/levelSchema.ts | 3 +- pages/admin/index.tsx | 165 +++++++++++++++++++++------ 3 files changed, 257 insertions(+), 36 deletions(-) create mode 100644 components/page/multiSelectLevel.tsx diff --git a/components/page/multiSelectLevel.tsx b/components/page/multiSelectLevel.tsx new file mode 100644 index 000000000..dbb738e2a --- /dev/null +++ b/components/page/multiSelectLevel.tsx @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Level from '@root/models/db/level'; +import React, { useState } from 'react'; +import AsyncSelect from 'react-select/async'; +import { debounce } from 'throttle-debounce'; + +interface MultiSelectLevelProps { + controlStyles?: any; + defaultValue?: Level | null; + onSelect?: (selectedList: any, selectedItem: any) => void; + placeholder?: string +} + +export default function MultiSelectLevel({ controlStyles, defaultValue, onSelect, placeholder }: MultiSelectLevelProps) { + const [options, setOptions] = useState([]); + const [value, setValue] = useState(defaultValue); + + const doSearch = async (searchText: any, callback: any) => { + const search = encodeURI(searchText) || ''; + + const res = await fetch('/api/search?search=' + search, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }); + + const data = await res.json(); + + setOptions(data?.levels); + callback(data?.levels); + }; + const debounceDoSearch = debounce(500, doSearch); + + return ( + <>{option.name} + )} + getOptionLabel={(option: any) => option.name} + getOptionValue={(option: any) => option._id.toString()} + id='search-author-input-async-select' + instanceId='search-author-input-async-select' + isClearable={true} + loadOptions={debounceDoSearch} + noOptionsMessage={() => 'No levels found'} + onChange={(selectedOption: any, selectedAction: any) => { + setValue(selectedOption); + + if (!selectedAction) { + return; + } + + if (onSelect) { + if (selectedAction.action === 'clear') { + onSelect('', selectedAction); + } else { + onSelect(selectedOption, selectedAction); + } + } + }} + options={options} // Options to display in the dropdown + placeholder={placeholder ? placeholder : 'Search levels...'} + // https://react-select.com/styles + styles={{ + control: (provided: any, state: any) => ({ + ...provided, + backgroundColor: 'white', + borderColor: state.isFocused ? 'rgb(37 99 235)' : 'rgb(209 213 219)', + borderRadius: '0.375rem', + borderWidth: '1px', + boxShadow: 'none', + cursor: 'text', + height: '2.5rem', + width: '13rem', + ...controlStyles, + }), + dropdownIndicator: (provided: any) => ({ + ...provided, + color: 'black', + // change to search icon + '&:hover': { + color: 'gray', + }, + }), + input: (provided: any) => ({ + ...provided, + color: 'rgb(55 65 81)', + }), + menu: (provided: any) => ({ + ...provided, + borderColor: 'rgb(209 213 219)', + borderRadius: '0.375rem', + borderWidth: '1px', + boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + marginTop: '2px', + }), + option: (provided: any, state: any) => ({ + ...provided, + backgroundColor: state.isSelected ? '#e2e8f0' : 'white', + color: 'black', + '&:hover': { + backgroundColor: '#e2e8f0', + }, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }), + placeholder: (provided: any) => ({ + ...provided, + color: 'rgb(156 163 175)', + }), + singleValue: (provided: any) => ({ + ...provided, + color: 'black', + }), + }} + value={value} + />; +} diff --git a/models/schemas/levelSchema.ts b/models/schemas/levelSchema.ts index 5cca32b40..ce272e3a6 100644 --- a/models/schemas/levelSchema.ts +++ b/models/schemas/levelSchema.ts @@ -11,7 +11,6 @@ export const LEVEL_SEARCH_DEFAULT_PROJECTION = { _id: 1, ts: 1, name: 1, slug: 1 const LevelSchema = new mongoose.Schema( { - archivedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', @@ -114,7 +113,7 @@ const LevelSchema = new mongoose.Schema( locale: 'en_US', strength: 2, }, - } + }, ); LevelSchema.index({ slug: 1 }, { name: 'slug_index', unique: true }); diff --git a/pages/admin/index.tsx b/pages/admin/index.tsx index 4b3c86337..eff96779d 100644 --- a/pages/admin/index.tsx +++ b/pages/admin/index.tsx @@ -1,12 +1,17 @@ import { Menu } from '@headlessui/react'; import FormattedUser from '@root/components/formatted/formattedUser'; +import RecommendedLevel from '@root/components/homepage/recommendedLevel'; +import MultiSelectLevel from '@root/components/page/multiSelectLevel'; import MultiSelectUser from '@root/components/page/multiSelectUser'; import Page from '@root/components/page/page'; import Role from '@root/constants/role'; +import useLevelBySlug from '@root/hooks/useLevelBySlug'; import dbConnect from '@root/lib/dbConnect'; import { getUserFromToken } from '@root/lib/withAuth'; +import Level from '@root/models/db/level'; import User from '@root/models/db/user'; -import { UserModel } from '@root/models/mongoose'; +import { LevelModel, UserModel } from '@root/models/mongoose'; +import { Types } from 'mongoose'; import { GetServerSidePropsContext, NextApiRequest } from 'next'; import Router from 'next/router'; import React, { useEffect, useState } from 'react'; @@ -24,56 +29,56 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; } - const { queryUser, queryCommand } = context.query; + const { queryUser, queryLevel, queryUserCommand, queryLevelCommand } = context.query; return { props: { - queryUser: queryUser ? JSON.parse(JSON.stringify(await UserModel.findOne({ name: queryUser }))) : null, - queryCommand: queryCommand || null, + queryLevel: queryLevel && queryLevel !== 'undefined' ? JSON.parse(JSON.stringify(await LevelModel.findById(new Types.ObjectId(queryLevel as string)))) : null, + queryUser: queryUser && queryUser !== 'undefiend' ? JSON.parse(JSON.stringify(await UserModel.findOne({ name: queryUser }))) : null, + queryUserCommand: queryUserCommand || null, + queryLevelCommand: queryLevelCommand || null, }, }; } -export default function AdminPage({ queryUser, queryCommand }: {queryUser: User | undefined; queryCommand: string | null}) { +export default function AdminPage({ queryUser, queryLevel, queryUserCommand, queryLevelCommand }: {queryUser: User | undefined; queryLevel: Level, queryUserCommand: string | null, queryLevelCommand: string | null}) { const [selectedUser, setSelectedUser] = useState(queryUser); + const [selectedLevel, setSelectedLevel] = useState(queryLevel); // TODO: [refactor] [minor const [runningCommand, setRunningCommand] = useState(false); - const commands = [ + const commandsUser = [ { label: 'Refresh Achievements', command: 'refreshAchievements' }, { label: 'Delete Achievements', command: 'deleteAchievements', confirm: true }, - { label: 'Refresh Play Attempts', command: 'calcPlayAttempts' }, - { label: 'Refresh Index Calculations', command: 'refreshIndexCalcs' }, // Add more commands here ]; - const selectedCommandFromQuery = commands.find((cmd) => cmd.command === queryCommand); - const [selectedCommand, setSelectedCommand] = useState<{ label: string; command: string; confirm?: boolean } | null>(selectedCommandFromQuery || null); + const commandsLevel = [ + { label: 'Refresh Play Attempts', command: 'calcPlayAttempts' }, + { label: 'Refresh Index Calculations', command: 'refreshIndexCalcs' }, + ]; + const selectedUserCommandFromQuery = commandsUser.find((cmd) => cmd.command === queryUserCommand); - useEffect(() => { - if (queryUser && queryUser !== selectedUser) { - setSelectedUser(queryUser); - } + const [selectedUserCommand, setSelectedUserCommand] = useState<{ label: string; command: string; confirm?: boolean } | null>(selectedUserCommandFromQuery || null); - if (queryCommand && queryCommand !== selectedCommand?.command) { - if (selectedCommandFromQuery) { - setSelectedCommand(selectedCommandFromQuery); - } - } - }, [queryCommand, queryUser, selectedCommand?.command, selectedCommandFromQuery, selectedUser]); + const selectedLevelCommandFromQuery = commandsLevel.find((cmd) => cmd.command === queryLevelCommand); + + const [selectedLevelCommand, setSelectedLevelCommand] = useState<{ label: string; command: string; confirm?: boolean } | null>(selectedLevelCommandFromQuery || null); + + const { level: levelPreview } = useLevelBySlug(selectedLevel?.slug); useEffect(() => { - const newUrl = `${window.location.pathname}?queryUser=${selectedUser?.name}&queryCommand=${selectedCommand?.command}`; + const newUrl = `${window.location.pathname}?queryLevel=${selectedLevel?._id}&queryLevelCommand=${selectedLevelCommand?.command}&queryUser=${selectedUser?.name}&queryUserCommand=${selectedUserCommand?.command}`; const currentFullURL = `${window.location.pathname}${window.location.search}`; if (currentFullURL !== newUrl) { Router.push(newUrl); } - }, [selectedUser, selectedCommand]); + }, [selectedUser, selectedUserCommand, selectedLevel?._id, selectedLevelCommand?.command]); - async function runCommand() { - if (selectedCommand?.confirm && !window.confirm('Are you sure you want to proceed?')) return; + async function runCommandUser() { + if (selectedUserCommand?.confirm && !window.confirm('Are you sure you want to proceed?')) return; setRunningCommand(true); toast.dismiss(); @@ -85,7 +90,7 @@ export default function AdminPage({ queryUser, queryCommand }: {queryUser: User 'Content-Type': 'application/json', }, body: JSON.stringify({ - command: selectedCommand?.command, + command: selectedUserCommand?.command, targetId: selectedUser?._id, }), }); @@ -100,14 +105,45 @@ export default function AdminPage({ queryUser, queryCommand }: {queryUser: User } } - function display(title: string, obj: User) { + async function runCommandLevel() { + if (selectedLevelCommand?.confirm && !window.confirm('Are you sure you want to proceed?')) return; + + setRunningCommand(true); + toast.dismiss(); + toast.loading('Running command...'); + const resp = await fetch('/api/admin', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + command: selectedLevelCommand?.command, + targetId: selectedLevel?._id, + }), + }); + + setRunningCommand(false); + const json = await resp.json(); + + if (json.error) { + toast.error(json.error); + } else { + toast.success('Command ran successfully'); + } + + Router.reload(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function display(title: string, obj: any) { return (

{title}

{obj && (
{Object.keys(obj).map((value) => { - const key = value as keyof User; + const key = value; const str = obj[key]?.toString() ?? ''; return ( @@ -138,9 +174,14 @@ export default function AdminPage({ queryUser, queryCommand }: {queryUser: User return ( +
-

Admin Page

-
+

Admin Page

+

+ User +

+
+

Run command on user:

{ setSelectedUser(selected); @@ -148,16 +189,16 @@ export default function AdminPage({ queryUser, queryCommand }: {queryUser: User
- {selectedCommand?.label || 'Select Command'} + {selectedUserCommand?.label || 'Select Command'}
- {commands.map((cmd) => ( + {commandsUser.map((cmd) => ( {({ active }) => ( { - setSelectedCommand(cmd); + setSelectedUserCommand(cmd); }} className={`${active ? 'bg-blue-600 text-white' : 'text-gray-900'} block px-4 py-2 text-sm`} > @@ -171,20 +212,76 @@ export default function AdminPage({ queryUser, queryCommand }: {queryUser: User
+
+ +
{selectedUser && (
- + {display('User', selectedUser)}
)}
+ +

+ Level

+
+ +
+ +
+
+ {selectedLevel && ( +
+ + {display('Level', selectedLevel)} +
+ )} +
+ ); }