Add isActive field to challenge chains and update localization; implement functionality to toggle chain status in the UI, enhancing task management and user experience.
This commit is contained in:
@@ -74,6 +74,8 @@
|
|||||||
"challenge.admin.chains.button.add": "+ Add",
|
"challenge.admin.chains.button.add": "+ Add",
|
||||||
"challenge.admin.chains.button.save": "Save changes",
|
"challenge.admin.chains.button.save": "Save changes",
|
||||||
"challenge.admin.chains.button.create": "Create chain",
|
"challenge.admin.chains.button.create": "Create chain",
|
||||||
|
"challenge.admin.chains.field.isActive": "Active for students",
|
||||||
|
"challenge.admin.chains.field.isActive.helper": "If disabled, the chain will not appear in the user-facing list.",
|
||||||
"challenge.admin.chains.list.title": "Task Chains",
|
"challenge.admin.chains.list.title": "Task Chains",
|
||||||
"challenge.admin.chains.list.create.button": "+ Create Chain",
|
"challenge.admin.chains.list.create.button": "+ Create Chain",
|
||||||
"challenge.admin.chains.list.search.placeholder": "Search by name...",
|
"challenge.admin.chains.list.search.placeholder": "Search by name...",
|
||||||
@@ -84,8 +86,11 @@
|
|||||||
"challenge.admin.chains.list.table.name": "Name",
|
"challenge.admin.chains.list.table.name": "Name",
|
||||||
"challenge.admin.chains.list.table.tasks.count": "Number of tasks",
|
"challenge.admin.chains.list.table.tasks.count": "Number of tasks",
|
||||||
"challenge.admin.chains.list.table.created": "Created date",
|
"challenge.admin.chains.list.table.created": "Created date",
|
||||||
|
"challenge.admin.chains.list.table.status": "Status",
|
||||||
"challenge.admin.chains.list.table.actions": "Actions",
|
"challenge.admin.chains.list.table.actions": "Actions",
|
||||||
"challenge.admin.chains.list.badge.tasks": "tasks",
|
"challenge.admin.chains.list.badge.tasks": "tasks",
|
||||||
|
"challenge.admin.chains.list.status.active": "Active",
|
||||||
|
"challenge.admin.chains.list.status.inactive": "Inactive",
|
||||||
"challenge.admin.chains.list.button.edit": "Edit",
|
"challenge.admin.chains.list.button.edit": "Edit",
|
||||||
"challenge.admin.chains.list.button.delete": "Delete",
|
"challenge.admin.chains.list.button.delete": "Delete",
|
||||||
"challenge.admin.chains.deleted": "Chain deleted",
|
"challenge.admin.chains.deleted": "Chain deleted",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@
|
|||||||
"challenge.admin.chains.button.add": "+ Добавить",
|
"challenge.admin.chains.button.add": "+ Добавить",
|
||||||
"challenge.admin.chains.button.save": "Сохранить изменения",
|
"challenge.admin.chains.button.save": "Сохранить изменения",
|
||||||
"challenge.admin.chains.button.create": "Создать цепочку",
|
"challenge.admin.chains.button.create": "Создать цепочку",
|
||||||
|
"challenge.admin.chains.field.isActive": "Активна для студентов",
|
||||||
|
"challenge.admin.chains.field.isActive.helper": "Если выключить, цепочка не будет отображаться в пользовательском списке.",
|
||||||
"challenge.admin.chains.list.title": "Цепочки заданий",
|
"challenge.admin.chains.list.title": "Цепочки заданий",
|
||||||
"challenge.admin.chains.list.create.button": "+ Создать цепочку",
|
"challenge.admin.chains.list.create.button": "+ Создать цепочку",
|
||||||
"challenge.admin.chains.list.search.placeholder": "Поиск по названию...",
|
"challenge.admin.chains.list.search.placeholder": "Поиск по названию...",
|
||||||
@@ -83,8 +85,11 @@
|
|||||||
"challenge.admin.chains.list.table.name": "Название",
|
"challenge.admin.chains.list.table.name": "Название",
|
||||||
"challenge.admin.chains.list.table.tasks.count": "Количество заданий",
|
"challenge.admin.chains.list.table.tasks.count": "Количество заданий",
|
||||||
"challenge.admin.chains.list.table.created": "Дата создания",
|
"challenge.admin.chains.list.table.created": "Дата создания",
|
||||||
|
"challenge.admin.chains.list.table.status": "Статус",
|
||||||
"challenge.admin.chains.list.table.actions": "Действия",
|
"challenge.admin.chains.list.table.actions": "Действия",
|
||||||
"challenge.admin.chains.list.badge.tasks": "заданий",
|
"challenge.admin.chains.list.badge.tasks": "заданий",
|
||||||
|
"challenge.admin.chains.list.status.active": "Включена",
|
||||||
|
"challenge.admin.chains.list.status.inactive": "Выключена",
|
||||||
"challenge.admin.chains.list.button.edit": "Редактировать",
|
"challenge.admin.chains.list.button.edit": "Редактировать",
|
||||||
"challenge.admin.chains.list.button.delete": "Удалить",
|
"challenge.admin.chains.list.button.delete": "Удалить",
|
||||||
"challenge.admin.chains.deleted": "Цепочка удалена",
|
"challenge.admin.chains.deleted": "Цепочка удалена",
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const api = createApi({
|
|||||||
|
|
||||||
// Chains
|
// Chains
|
||||||
getChains: builder.query<ChallengeChain[], void>({
|
getChains: builder.query<ChallengeChain[], void>({
|
||||||
query: () => '/challenge/chains',
|
query: () => '/challenge/chains/admin',
|
||||||
transformResponse: (response: { body: ChallengeChain[] }) => response.body,
|
transformResponse: (response: { body: ChallengeChain[] }) => response.body,
|
||||||
providesTags: ['Chain'],
|
providesTags: ['Chain'],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ export const ChainFormPage: React.FC = () => {
|
|||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
|
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [isActive, setIsActive] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chain) {
|
if (chain) {
|
||||||
setName(chain.name)
|
setName(chain.name)
|
||||||
setSelectedTasks(chain.tasks)
|
setSelectedTasks(chain.tasks)
|
||||||
|
setIsActive(chain.isActive !== false)
|
||||||
}
|
}
|
||||||
}, [chain])
|
}, [chain])
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ export const ChainFormPage: React.FC = () => {
|
|||||||
data: {
|
data: {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
taskIds: taskIds,
|
taskIds: taskIds,
|
||||||
|
isActive,
|
||||||
},
|
},
|
||||||
}).unwrap()
|
}).unwrap()
|
||||||
toaster.create({
|
toaster.create({
|
||||||
@@ -91,6 +94,7 @@ export const ChainFormPage: React.FC = () => {
|
|||||||
await createChain({
|
await createChain({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
taskIds: taskIds,
|
taskIds: taskIds,
|
||||||
|
isActive,
|
||||||
}).unwrap()
|
}).unwrap()
|
||||||
toaster.create({
|
toaster.create({
|
||||||
title: t('challenge.admin.common.success'),
|
title: t('challenge.admin.common.success'),
|
||||||
@@ -191,6 +195,25 @@ export const ChainFormPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Active flag */}
|
||||||
|
<Field.Root>
|
||||||
|
<HStack justify="space-between" align="flex-start">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }}
|
||||||
|
/>
|
||||||
|
<Text>{t('challenge.admin.chains.field.isActive')}</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="gray.500" maxW="md">
|
||||||
|
{t('challenge.admin.chains.field.isActive.helper')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
{/* Selected Tasks */}
|
{/* Selected Tasks */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontWeight="bold" mb={3}>
|
<Text fontWeight="bold" mb={3}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Badge,
|
Badge,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useGetChainsQuery, useDeleteChainMutation } from '../../__data__/api/api'
|
import { useGetChainsQuery, useDeleteChainMutation, useUpdateChainMutation } from '../../__data__/api/api'
|
||||||
import { URLs } from '../../__data__/urls'
|
import { URLs } from '../../__data__/urls'
|
||||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
@@ -29,6 +29,8 @@ export const ChainsListPage: React.FC = () => {
|
|||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
|
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
|
||||||
|
const [updatingChainId, setUpdatingChainId] = useState<string | null>(null)
|
||||||
|
const [updateChain] = useUpdateChainMutation()
|
||||||
|
|
||||||
const handleDeleteChain = async () => {
|
const handleDeleteChain = async () => {
|
||||||
if (!chainToDelete) return
|
if (!chainToDelete) return
|
||||||
@@ -50,6 +52,30 @@ export const ChainsListPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleActive = async (chain: ChallengeChain, nextValue: boolean) => {
|
||||||
|
setUpdatingChainId(chain.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateChain({
|
||||||
|
id: chain.id,
|
||||||
|
data: { isActive: nextValue },
|
||||||
|
}).unwrap()
|
||||||
|
toaster.create({
|
||||||
|
title: t('challenge.admin.common.success'),
|
||||||
|
description: t('challenge.admin.chains.updated'),
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toaster.create({
|
||||||
|
title: t('challenge.admin.common.error'),
|
||||||
|
description: t('challenge.admin.chains.save.error'),
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setUpdatingChainId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} />
|
return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} />
|
||||||
}
|
}
|
||||||
@@ -110,6 +136,7 @@ export const ChainsListPage: React.FC = () => {
|
|||||||
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.status')}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader>
|
<Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
@@ -127,6 +154,25 @@ export const ChainsListPage: React.FC = () => {
|
|||||||
{formatDate(chain.createdAt)}
|
{formatDate(chain.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<HStack gap={3} justify="flex-start">
|
||||||
|
<Badge colorPalette={chain.isActive ? 'green' : 'gray'} variant="subtle">
|
||||||
|
{chain.isActive
|
||||||
|
? t('challenge.admin.chains.list.status.active')
|
||||||
|
: t('challenge.admin.chains.list.status.inactive')}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleToggleActive(chain, !chain.isActive)}
|
||||||
|
isDisabled={updatingChainId === chain.id}
|
||||||
|
>
|
||||||
|
{chain.isActive
|
||||||
|
? t('challenge.admin.chains.list.status.inactive')
|
||||||
|
: t('challenge.admin.chains.list.status.active')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell textAlign="right">
|
<Table.Cell textAlign="right">
|
||||||
<HStack gap={2} justify="flex-end">
|
<HStack gap={2} justify="flex-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface ChallengeChain {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
tasks: ChallengeTask[] // Populated
|
tasks: ChallengeTask[] // Populated
|
||||||
|
isActive: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -132,11 +133,13 @@ export interface UpdateTaskRequest {
|
|||||||
export interface CreateChainRequest {
|
export interface CreateChainRequest {
|
||||||
name: string
|
name: string
|
||||||
taskIds: string[] // Array of task IDs
|
taskIds: string[] // Array of task IDs
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateChainRequest {
|
export interface UpdateChainRequest {
|
||||||
name?: string
|
name?: string
|
||||||
taskIds?: string[]
|
taskIds?: string[]
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Stats v2 Types ==========
|
// ========== Stats v2 Types ==========
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"updatedAt": "2024-11-05T11:00:00.000Z"
|
"updatedAt": "2024-11-05T11:00:00.000Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"isActive": true,
|
||||||
"createdAt": "2024-11-01T09:00:00.000Z",
|
"createdAt": "2024-11-01T09:00:00.000Z",
|
||||||
"updatedAt": "2024-11-05T12:00:00.000Z"
|
"updatedAt": "2024-11-05T12:00:00.000Z"
|
||||||
},
|
},
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"updatedAt": "2024-11-03T09:15:00.000Z"
|
"updatedAt": "2024-11-03T09:15:00.000Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"isActive": false,
|
||||||
"createdAt": "2024-11-03T08:00:00.000Z",
|
"createdAt": "2024-11-03T08:00:00.000Z",
|
||||||
"updatedAt": "2024-11-03T09:30:00.000Z"
|
"updatedAt": "2024-11-03T09:30:00.000Z"
|
||||||
},
|
},
|
||||||
@@ -63,6 +65,7 @@
|
|||||||
"updatedAt": "2024-11-04T14:20:00.000Z"
|
"updatedAt": "2024-11-04T14:20:00.000Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"isActive": true,
|
||||||
"createdAt": "2024-11-02T11:00:00.000Z",
|
"createdAt": "2024-11-02T11:00:00.000Z",
|
||||||
"updatedAt": "2024-11-04T15:00:00.000Z"
|
"updatedAt": "2024-11-04T15:00:00.000Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,8 +184,15 @@ router.delete('/challenge/task/:id', (req, res) => {
|
|||||||
|
|
||||||
// ============= CHAINS =============
|
// ============= CHAINS =============
|
||||||
|
|
||||||
// GET /api/challenge/chains
|
// GET /api/challenge/chains (user-facing list: only active chains)
|
||||||
router.get('/challenge/chains', (req, res) => {
|
router.get('/challenge/chains', (req, res) => {
|
||||||
|
const chains = getChains();
|
||||||
|
const activeChains = chains.filter(c => c.isActive !== false);
|
||||||
|
respond(res, activeChains);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/challenge/chains/admin (admin list: all chains)
|
||||||
|
router.get('/challenge/chains/admin', (req, res) => {
|
||||||
const chains = getChains();
|
const chains = getChains();
|
||||||
respond(res, chains);
|
respond(res, chains);
|
||||||
});
|
});
|
||||||
@@ -204,17 +211,17 @@ router.get('/challenge/chain/:id', (req, res) => {
|
|||||||
|
|
||||||
// POST /api/challenge/chain
|
// POST /api/challenge/chain
|
||||||
router.post('/challenge/chain', (req, res) => {
|
router.post('/challenge/chain', (req, res) => {
|
||||||
const { name, tasks } = req.body;
|
const { name, taskIds, isActive } = req.body;
|
||||||
|
|
||||||
if (!name || !tasks || !Array.isArray(tasks)) {
|
if (!name || !taskIds || !Array.isArray(taskIds)) {
|
||||||
return respondError(res, 'Name and tasks array are required');
|
return respondError(res, 'Name and taskIds array are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const chains = getChains();
|
const chains = getChains();
|
||||||
const allTasks = getTasks();
|
const allTasks = getTasks();
|
||||||
|
|
||||||
// Populate tasks
|
// Populate tasks
|
||||||
const populatedTasks = tasks.map(taskId => {
|
const populatedTasks = taskIds.map(taskId => {
|
||||||
const task = allTasks.find(t => t.id === taskId);
|
const task = allTasks.find(t => t.id === taskId);
|
||||||
return task ? {
|
return task ? {
|
||||||
_id: task._id,
|
_id: task._id,
|
||||||
@@ -231,6 +238,7 @@ router.post('/challenge/chain', (req, res) => {
|
|||||||
id: `chain_${Date.now()}`,
|
id: `chain_${Date.now()}`,
|
||||||
name,
|
name,
|
||||||
tasks: populatedTasks,
|
tasks: populatedTasks,
|
||||||
|
isActive: isActive !== undefined ? !!isActive : true,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
@@ -253,14 +261,16 @@ router.put('/challenge/chain/:id', (req, res) => {
|
|||||||
return respondError(res, 'Chain not found', 404);
|
return respondError(res, 'Chain not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, tasks } = req.body;
|
const { name, taskIds, tasks, isActive } = req.body;
|
||||||
const chain = chains[chainIndex];
|
const chain = chains[chainIndex];
|
||||||
|
|
||||||
if (name) chain.name = name;
|
if (name) chain.name = name;
|
||||||
|
|
||||||
if (tasks && Array.isArray(tasks)) {
|
const effectiveTaskIds = Array.isArray(taskIds) ? taskIds : (Array.isArray(tasks) ? tasks : null);
|
||||||
|
|
||||||
|
if (effectiveTaskIds) {
|
||||||
const allTasks = getTasks();
|
const allTasks = getTasks();
|
||||||
const populatedTasks = tasks.map(taskId => {
|
const populatedTasks = effectiveTaskIds.map(taskId => {
|
||||||
const task = allTasks.find(t => t.id === taskId);
|
const task = allTasks.find(t => t.id === taskId);
|
||||||
return task ? {
|
return task ? {
|
||||||
_id: task._id,
|
_id: task._id,
|
||||||
@@ -275,6 +285,10 @@ router.put('/challenge/chain/:id', (req, res) => {
|
|||||||
chain.tasks = populatedTasks;
|
chain.tasks = populatedTasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
chain.isActive = !!isActive;
|
||||||
|
}
|
||||||
|
|
||||||
chain.updatedAt = new Date().toISOString();
|
chain.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
respond(res, chain);
|
respond(res, chain);
|
||||||
|
|||||||
Reference in New Issue
Block a user