From d1dd37cc06664d496db41af454d65c239b7a1e8d Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:03:50 +0300 Subject: [PATCH] F: Implement prompt editing pt1 --- .../apps/prompt/serializers/data_access.py | 5 +- rsconcept/frontend/src/app/global-dialogs.tsx | 7 + rsconcept/frontend/src/app/router.tsx | 6 + rsconcept/frontend/src/app/urls.ts | 2 + .../ai/backend/use-available-templates.tsx | 10 +- .../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-prompt-template.tsx | 10 +- .../ai/backend/use-update-prompt-template.tsx | 13 +- .../ai/components/badge-shared-template.tsx | 18 +++ .../ai/components/icon-shared-template.tsx | 10 ++ .../ai/dialogs/dlg-create-prompt-template.tsx | 70 ++++++++++ .../src/features/ai/dialogs/index.tsx | 1 - .../ai/pages/prompt-templates-page/index.tsx | 1 + .../prompt-templates-page/menu-templates.tsx | 31 +++++ .../prompt-templates-page.tsx | 58 ++++++++ .../form-prompt-template.tsx | 126 ++++++++++++++++++ .../tab-edit-template/index.tsx | 1 + .../tab-edit-template/tab-edit-template.tsx | 69 ++++++++++ .../tab-edit-template/toolbar-template.tsx | 61 +++++++++ .../tab-list-templates.tsx | 116 ++++++++++++++++ .../tab-view-variables.tsx | 3 + .../prompt-templates-page/templates-tabs.tsx | 83 ++++++++++++ .../library-page/use-library-columns.tsx | 2 +- .../oss-page/editor-oss-card/form-oss.tsx | 12 +- .../editor-constituenta/form-constituenta.tsx | 14 +- .../editor-rsform-card/form-rsform.tsx | 12 +- rsconcept/frontend/src/stores/dialogs.ts | 9 +- rsconcept/frontend/src/styling/overrides.css | 1 - rsconcept/frontend/src/utils/constants.ts | 1 + rsconcept/frontend/src/utils/labels.ts | 1 + 32 files changed, 734 insertions(+), 39 deletions(-) create mode 100644 rsconcept/frontend/src/features/ai/backend/use-mutating-prompts.tsx create mode 100644 rsconcept/frontend/src/features/ai/components/badge-shared-template.tsx create mode 100644 rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx create mode 100644 rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx delete mode 100644 rsconcept/frontend/src/features/ai/dialogs/index.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/index.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/menu-templates.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/form-prompt-template.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/index.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/tab-edit-template.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/toolbar-template.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-list-templates.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-view-variables.tsx create mode 100644 rsconcept/frontend/src/features/ai/pages/prompt-templates-page/templates-tabs.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/app/global-dialogs.tsx b/rsconcept/frontend/src/app/global-dialogs.tsx index ffc79178..b61c4de6 100644 --- a/rsconcept/frontend/src/app/global-dialogs.tsx +++ b/rsconcept/frontend/src/app/global-dialogs.tsx @@ -143,6 +143,11 @@ const DlgImportSchema = React.lazy(() => const DlgAIPromptDialog = React.lazy(() => import('@/features/ai/dialogs/dlg-ai-prompt').then(module => ({ default: module.DlgAIPromptDialog })) ); +const DlgCreatePromptTemplate = React.lazy(() => + import('@/features/ai/dialogs/dlg-create-prompt-template').then(module => ({ + default: module.DlgCreatePromptTemplate + })) +); export const GlobalDialogs = () => { const active = useDialogsStore(state => state.active); @@ -213,5 +218,7 @@ export const GlobalDialogs = () => { return ; case DialogType.AI_PROMPT: return ; + case DialogType.CREATE_PROMPT_TEMPLATE: + return ; } }; diff --git a/rsconcept/frontend/src/app/router.tsx b/rsconcept/frontend/src/app/router.tsx index b4e408ed..f2cef55f 100644 --- a/rsconcept/frontend/src/app/router.tsx +++ b/rsconcept/frontend/src/app/router.tsx @@ -1,5 +1,6 @@ import { createBrowserRouter } from 'react-router'; +import { prefetchAvailableTemplates } from '@/features/ai/backend/use-available-templates'; import { prefetchAuth } from '@/features/auth/backend/use-auth'; import { LoginPage } from '@/features/auth/pages/login-page'; import { HomePage } from '@/features/home/home-page'; @@ -85,6 +86,11 @@ export const Router = createBrowserRouter([ path: `${routes.database_schema}`, lazy: () => import('@/features/home/database-schema-page') }, + { + path: routes.prompt_templates, + loader: prefetchAvailableTemplates, + lazy: () => import('@/features/ai/pages/prompt-templates-page') + }, { path: '*', element: diff --git a/rsconcept/frontend/src/app/urls.ts b/rsconcept/frontend/src/app/urls.ts index 52452fc7..20198fbc 100644 --- a/rsconcept/frontend/src/app/urls.ts +++ b/rsconcept/frontend/src/app/urls.ts @@ -39,6 +39,8 @@ export const urls = { library_filter: (strategy: string) => `/library?filter=${strategy}`, create_schema: `/${routes.create_schema}`, prompt_templates: `/${routes.prompt_templates}`, + prompt_template: (active: number | null, tab: number) => + `/prompt-templates?tab=${tab}${active ? `&active=${active}` : ''}`, manuals: `/${routes.manuals}`, help_topic: (topic: string) => `/manuals?topic=${topic}`, schema: (id: number | string, version?: number | string) => diff --git a/rsconcept/frontend/src/features/ai/backend/use-available-templates.tsx b/rsconcept/frontend/src/features/ai/backend/use-available-templates.tsx index 0767f58e..deb37c1b 100644 --- a/rsconcept/frontend/src/features/ai/backend/use-available-templates.tsx +++ b/rsconcept/frontend/src/features/ai/backend/use-available-templates.tsx @@ -1,17 +1,23 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { queryClient } from '@/backend/query-client'; + import { promptsApi } from './api'; export function useAvailableTemplates() { const { data, isLoading, error } = useQuery({ ...promptsApi.getAvailableTemplatesQueryOptions() }); - return { data, isLoading, error }; + return { items: data, isLoading, error }; } export function useAvailableTemplatesSuspense() { const { data } = useSuspenseQuery({ ...promptsApi.getAvailableTemplatesQueryOptions() }); - return { data }; + return { items: data }; +} + +export function prefetchAvailableTemplates() { + return queryClient.prefetchQuery(promptsApi.getAvailableTemplatesQueryOptions()); } 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-prompt-template.tsx b/rsconcept/frontend/src/features/ai/backend/use-prompt-template.tsx index 73f18b29..ceb96820 100644 --- a/rsconcept/frontend/src/features/ai/backend/use-prompt-template.tsx +++ b/rsconcept/frontend/src/features/ai/backend/use-prompt-template.tsx @@ -1,17 +1,23 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { queryClient } from '@/backend/query-client'; + import { promptsApi } from './api'; export function usePromptTemplate(id: number) { const { data, isLoading, error } = useQuery({ ...promptsApi.getPromptTemplateQueryOptions(id) }); - return { data, isLoading, error }; + return { promptTemplate: data, isLoading, error }; } export function usePromptTemplateSuspense(id: number) { const { data } = useSuspenseQuery({ ...promptsApi.getPromptTemplateQueryOptions(id) }); - return { data }; + return { promptTemplate: data }; +} + +export function prefetchPromptTemplate({ itemID }: { itemID: number }) { + return queryClient.prefetchQuery(promptsApi.getPromptTemplateQueryOptions(itemID)); } 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/badge-shared-template.tsx b/rsconcept/frontend/src/features/ai/components/badge-shared-template.tsx new file mode 100644 index 00000000..d6da3bdb --- /dev/null +++ b/rsconcept/frontend/src/features/ai/components/badge-shared-template.tsx @@ -0,0 +1,18 @@ +import { globalIDs } from '@/utils/constants'; + +import { IconSharedTemplate } from './icon-shared-template'; + +interface BadgeSharedTemplateProps { + value: boolean; +} + +/** + * Displays location icon with a full text tooltip. + */ +export function BadgeSharedTemplate({ value }: BadgeSharedTemplateProps) { + 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 new file mode 100644 index 00000000..7aa41e99 --- /dev/null +++ b/rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx @@ -0,0 +1,10 @@ +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 ; + } 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 new file mode 100644 index 00000000..954e748d --- /dev/null +++ b/rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx @@ -0,0 +1,70 @@ +import { Controller, useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import { useAuthSuspense } from '@/features/auth'; + +import { Checkbox, TextArea, TextInput } from '@/components/input'; +import { ModalForm } from '@/components/modal'; +import { useDialogsStore } from '@/stores/dialogs'; +import { type RO } from '@/utils/meta'; + +import { type ICreatePromptTemplateDTO, type IPromptTemplateDTO, schemaCreatePromptTemplate } from '../backend/types'; +import { useAvailableTemplatesSuspense } from '../backend/use-available-templates'; +import { useCreatePromptTemplate } from '../backend/use-create-prompt-template'; + +export interface DlgCreatePromptTemplateProps { + onCreate?: (data: RO) => void; +} + +export function DlgCreatePromptTemplate() { + const { onCreate } = useDialogsStore(state => state.props as DlgCreatePromptTemplateProps); + const { createPromptTemplate } = useCreatePromptTemplate(); + const { items: templates } = useAvailableTemplatesSuspense(); + const { user } = useAuthSuspense(); + + const { handleSubmit, control, register } = useForm({ + resolver: zodResolver(schemaCreatePromptTemplate), + defaultValues: { + label: '', + description: '', + text: '', + is_shared: false + } + }); + const label = useWatch({ control, name: 'label' }); + const isValid = label !== '' && !templates.find(template => template.label === label); + + function onSubmit(data: ICreatePromptTemplateDTO) { + void createPromptTemplate(data).then(onCreate); + } + + return ( + void handleSubmit(onSubmit)(event)} + submitInvalidTooltip='Введите уникальное название шаблона' + className='cc-column w-140 max-h-120 py-2 px-6' + > + +