From 56652095af915f8017bcaf8587e567d1736c7a0d Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:09:44 +0300 Subject: [PATCH] F: Implementing PromptEdit pt2 --- .../apps/prompt/serializers/data_access.py | 5 +- .../ai/backend/use-create-prompt-template.tsx | 4 +- .../ai/backend/use-delete-prompt-template.tsx | 6 +- .../ai/backend/use-mutating-prompts.tsx | 10 ++ .../ai/backend/use-update-prompt-template.tsx | 13 +- .../ai/components/icon-shared-template.tsx | 2 +- .../ai/dialogs/dlg-create-prompt-template.tsx | 1 + .../prompt-templates-page.tsx | 32 ++++- .../form-prompt-template.tsx | 112 ++++++++++++------ .../tab-edit-template/tab-edit-template.tsx | 72 +++++------ .../tab-edit-template/toolbar-template.tsx | 38 ++++-- .../tab-list-templates.tsx | 5 + .../oss-page/editor-oss-card/form-oss.tsx | 12 +- .../editor-constituenta/form-constituenta.tsx | 12 +- .../editor-rsform-card/form-rsform.tsx | 12 +- rsconcept/frontend/src/utils/constants.ts | 1 + 16 files changed, 220 insertions(+), 117 deletions(-) create mode 100644 rsconcept/frontend/src/features/ai/backend/use-mutating-prompts.tsx diff --git a/rsconcept/backend/apps/prompt/serializers/data_access.py b/rsconcept/backend/apps/prompt/serializers/data_access.py index 603229b5..b4627f4f 100644 --- a/rsconcept/backend/apps/prompt/serializers/data_access.py +++ b/rsconcept/backend/apps/prompt/serializers/data_access.py @@ -17,7 +17,10 @@ class PromptTemplateSerializer(serializers.ModelSerializer): def validate_label(self, value): user = self.context['request'].user - if PromptTemplate.objects.filter(owner=user, label=value).exists(): + queryset = PromptTemplate.objects.filter(owner=user, label=value) + if self.instance is not None: + queryset = queryset.exclude(pk=self.instance.pk) + if queryset.exists(): raise serializers.ValidationError(msg.promptLabelTaken(value)) return value diff --git a/rsconcept/frontend/src/features/ai/backend/use-create-prompt-template.tsx b/rsconcept/frontend/src/features/ai/backend/use-create-prompt-template.tsx index 2fe39e82..bc382abe 100644 --- a/rsconcept/frontend/src/features/ai/backend/use-create-prompt-template.tsx +++ b/rsconcept/frontend/src/features/ai/backend/use-create-prompt-template.tsx @@ -1,11 +1,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { KEYS } from '@/backend/configuration'; + import { promptsApi } from './api'; export function useCreatePromptTemplate() { const client = useQueryClient(); const mutation = useMutation({ - mutationKey: [promptsApi.baseKey, 'create'], + mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'create'], mutationFn: promptsApi.createPromptTemplate, onSuccess: () => { void client.invalidateQueries({ queryKey: [promptsApi.baseKey] }); diff --git a/rsconcept/frontend/src/features/ai/backend/use-delete-prompt-template.tsx b/rsconcept/frontend/src/features/ai/backend/use-delete-prompt-template.tsx index 06d764e9..f04b57e3 100644 --- a/rsconcept/frontend/src/features/ai/backend/use-delete-prompt-template.tsx +++ b/rsconcept/frontend/src/features/ai/backend/use-delete-prompt-template.tsx @@ -1,13 +1,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { KEYS } from '@/backend/configuration'; + import { promptsApi } from './api'; export function useDeletePromptTemplate() { const client = useQueryClient(); const mutation = useMutation({ - mutationKey: [promptsApi.baseKey, 'delete'], + mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'delete'], mutationFn: promptsApi.deletePromptTemplate, - onSuccess: (_data, id) => { + onSuccess: (_, id) => { void client.invalidateQueries({ queryKey: [promptsApi.baseKey] }); void client.invalidateQueries({ queryKey: [promptsApi.baseKey, id] }); } diff --git a/rsconcept/frontend/src/features/ai/backend/use-mutating-prompts.tsx b/rsconcept/frontend/src/features/ai/backend/use-mutating-prompts.tsx new file mode 100644 index 00000000..bf181adc --- /dev/null +++ b/rsconcept/frontend/src/features/ai/backend/use-mutating-prompts.tsx @@ -0,0 +1,10 @@ +import { useIsMutating } from '@tanstack/react-query'; + +import { KEYS } from '@/backend/configuration'; + +import { promptsApi } from './api'; + +export const useMutatingPrompts = () => { + const countMutations = useIsMutating({ mutationKey: [KEYS.global_mutation, promptsApi.baseKey] }); + return countMutations !== 0; +}; diff --git a/rsconcept/frontend/src/features/ai/backend/use-update-prompt-template.tsx b/rsconcept/frontend/src/features/ai/backend/use-update-prompt-template.tsx index 2a18fe8a..17a89545 100644 --- a/rsconcept/frontend/src/features/ai/backend/use-update-prompt-template.tsx +++ b/rsconcept/frontend/src/features/ai/backend/use-update-prompt-template.tsx @@ -1,16 +1,21 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { KEYS } from '@/backend/configuration'; + import { promptsApi } from './api'; -import { type IUpdatePromptTemplateDTO } from './types'; +import { type IPromptTemplateDTO, type IUpdatePromptTemplateDTO } from './types'; export function useUpdatePromptTemplate() { const client = useQueryClient(); const mutation = useMutation({ - mutationKey: [promptsApi.baseKey, 'update'], + mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'update'], mutationFn: ({ id, data }: { id: number; data: IUpdatePromptTemplateDTO }) => promptsApi.updatePromptTemplate(id, data), - onSuccess: (_, variables) => { - void client.invalidateQueries({ queryKey: [promptsApi.baseKey, variables.id] }); + onSuccess: (data: IPromptTemplateDTO) => { + client.setQueryData(promptsApi.getPromptTemplateQueryOptions(data.id).queryKey, data); + client.setQueryData(promptsApi.getAvailableTemplatesQueryOptions().queryKey, prev => + prev?.map(item => (item.id === data.id ? data : item)) + ); } }); return { diff --git a/rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx b/rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx index 02206b6c..7aa41e99 100644 --- a/rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx +++ b/rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx @@ -3,7 +3,7 @@ import { type DomIconProps, IconPrivate, IconPublic } from '@/components/icons'; /** Icon for shared template flag. */ export function IconSharedTemplate({ value, size = '1.25rem', className }: DomIconProps) { if (value) { - return ; + return ; } else { return ; } diff --git a/rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx b/rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx index ab883b9d..954e748d 100644 --- a/rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx +++ b/rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx @@ -44,6 +44,7 @@ export function DlgCreatePromptTemplate() { submitText='Создать' canSubmit={isValid} onSubmit={event => void handleSubmit(onSubmit)(event)} + submitInvalidTooltip='Введите уникальное название шаблона' className='cc-column w-140 max-h-120 py-2 px-6' > diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx index 3fe1e820..5ab64b06 100644 --- a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx +++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx @@ -1,8 +1,13 @@ +import { ErrorBoundary } from 'react-error-boundary'; +import { isAxiosError } from 'axios'; import { z } from 'zod'; import { useBlockNavigation } from '@/app'; +import { routes } from '@/app/urls'; import { RequireAuth } from '@/features/auth/components/require-auth'; +import { TextURL } from '@/components/control'; +import { type ErrorData } from '@/components/info-error'; import { useQueryStrings } from '@/hooks/use-query-strings'; import { useModificationStore } from '@/stores/modification'; @@ -25,10 +30,29 @@ export function PromptTemplatesPage() { useBlockNavigation(isModified); return ( - - - + } + > + + + + ); } -export default PromptTemplatesPage; +// ====== Internals ========= +function ProcessError({ error, itemID }: { error: ErrorData; itemID?: number | null }): React.ReactElement | null { + if (isAxiosError(error) && error.response) { + if (error.response.status === 404) { + return ( +
+

{`Шаблон запроса с указанным идентификатором ${itemID} отсутствует`}

+
+ +
+
+ ); + } + } + throw error as Error; +} diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/form-prompt-template.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/form-prompt-template.tsx index c4218706..22961121 100644 --- a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/form-prompt-template.tsx +++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/form-prompt-template.tsx @@ -1,13 +1,18 @@ +'use no memo'; // TODO: remove when react hook forms are compliant with react compiler 'use client'; -import { useEffect } from 'react'; +import { useRef } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutatingPrompts } from '@/features/ai/backend/use-mutating-prompts'; +import { useUpdatePromptTemplate } from '@/features/ai/backend/use-update-prompt-template'; import { useAuthSuspense } from '@/features/auth'; import { Checkbox, TextArea, TextInput } from '@/components/input'; import { cn } from '@/components/utils'; +import { useModificationStore } from '@/stores/modification'; +import { globalIDs } from '@/utils/constants'; import { type IPromptTemplate, @@ -17,31 +22,24 @@ import { interface FormPromptTemplateProps { promptTemplate: IPromptTemplate; - disabled: boolean; - onSubmit: (data: IUpdatePromptTemplateDTO) => void; - onReset: () => void; + isMutable: boolean; className?: string; + toggleReset: boolean; } /** Form for editing a prompt template. */ -export function FormPromptTemplate({ - promptTemplate, - disabled, - className, - onSubmit, - onReset: _onReset -}: FormPromptTemplateProps) { +export function FormPromptTemplate({ promptTemplate, className, isMutable, toggleReset }: FormPromptTemplateProps) { const { user } = useAuthSuspense(); + const isProcessing = useMutatingPrompts(); + const setIsModified = useModificationStore(state => state.setIsModified); + const { updatePromptTemplate } = useUpdatePromptTemplate(); const { control, handleSubmit, reset, register, - formState: { - /* isDirty, */ - /* errors */ - } + formState: { isDirty, errors } } = useForm({ resolver: zodResolver(schemaUpdatePromptTemplate), defaultValues: { @@ -53,7 +51,11 @@ export function FormPromptTemplate({ } }); - useEffect(() => { + const prevReset = useRef(toggleReset); + const prevTemplate = useRef(promptTemplate); + if (prevTemplate.current !== promptTemplate || prevReset.current !== toggleReset) { + prevTemplate.current = promptTemplate; + prevReset.current = toggleReset; reset({ owner: promptTemplate.owner, label: promptTemplate.label, @@ -61,30 +63,64 @@ export function FormPromptTemplate({ text: promptTemplate.text, is_shared: promptTemplate.is_shared }); - }, [promptTemplate, reset]); + } + + const prevDirty = useRef(isDirty); + if (prevDirty.current !== isDirty) { + prevDirty.current = isDirty; + setIsModified(isDirty); + } + + function onSubmit(data: IUpdatePromptTemplateDTO) { + return updatePromptTemplate({ id: promptTemplate.id, data }).then(() => { + setIsModified(false); + reset({ ...data }); + }); + } return ( -
void handleSubmit(onSubmit)(event)}> - -