From 1ecd0d46f1a723f5329826c9f54e48657443d540 Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Tue, 12 May 2026 09:36:24 +0200 Subject: [PATCH 1/8] Parsing template for formfield --- core2/pkg/accounting/grants_persistence.go | 13 +- .../shared/pkg/accounting/grants.go | 224 +++++++++++++++++- 2 files changed, 231 insertions(+), 6 deletions(-) diff --git a/core2/pkg/accounting/grants_persistence.go b/core2/pkg/accounting/grants_persistence.go index 871c7e2489..d63eaf81ab 100644 --- a/core2/pkg/accounting/grants_persistence.go +++ b/core2/pkg/accounting/grants_persistence.go @@ -394,6 +394,13 @@ func grantsLoadUnawarded() { } } +func parseStructuredFormFields(templateString string) []accapi.FormField { + if templateString == "" { + return make([]accapi.FormField, 0) + } + return accapi.ParseFormFields(templateString) +} + func grantsLoadSettings() { if grantGlobals.Testing.Enabled { return @@ -469,7 +476,11 @@ func grantsLoadSettings() { NewProject: template.NewProject, ExistingProject: template.ExistingProject, } - + existing.Templates.Structured = accapi.TemplatesStructured{ + PersonalProject: parseStructuredFormFields(template.PersonalProject), + NewProject: parseStructuredFormFields(template.NewProject), + ExistingProject: parseStructuredFormFields(template.ExistingProject), + } result[template.ProjectId] = existing } diff --git a/provider-integration/shared/pkg/accounting/grants.go b/provider-integration/shared/pkg/accounting/grants.go index 827587699d..1080529824 100644 --- a/provider-integration/shared/pkg/accounting/grants.go +++ b/provider-integration/shared/pkg/accounting/grants.go @@ -5,6 +5,9 @@ import ( "fmt" "io" "net/http" + "regexp" + "strconv" + "strings" fnd "ucloud.dk/shared/pkg/foundation" "ucloud.dk/shared/pkg/rpc" @@ -73,7 +76,7 @@ func (s GrantRequestSettings) MarshalJSON() ([]byte, error) { type GrantGiver struct { Id string `json:"id"` Description string `json:"description"` - Templates Templates `json:"templates"` + Templates Templates `json:"templates"` // This needs to be removed for another structure Categories []ProductCategory `json:"categories"` } @@ -121,13 +124,31 @@ type TemplatesType string const ( TemplatesTypePlainText TemplatesType = "plain_text" + //TemplatesTypeStructured TemplatesType = "structured" ) +type TemplatesStructured struct { + PersonalProject []FormField `json:"personalProject"` + NewProject []FormField `json:"newProject"` + ExistingProject []FormField `json:"existingProject"` +} type Templates struct { - Type TemplatesType `json:"type"` - PersonalProject string `json:"personalProject"` // plain_text - NewProject string `json:"newProject"` // plain_text - ExistingProject string `json:"existingProject"` // plain_text + Type TemplatesType `json:"type"` + Structured TemplatesStructured `json:"structured"` + + // Legacy compatibility + PersonalProject string `json:"personalProject"` // plain_text + NewProject string `json:"newProject"` // plain_text + ExistingProject string `json:"existingProject"` // plain_text +} + +type FormField struct { + Name string + Title string + Description string // allows markdown + Optional bool + MaxLength util.Option[int] + Rows util.Option[int] } type GrantApplication struct { @@ -161,6 +182,7 @@ type FormType string const ( FormTypePlainText FormType = "plain_text" FormTypeGrantGiverInitiated FormType = "grant_giver_initiated" + FormTypeStructured FormType = "structured" // New ) func (f FormType) Valid() bool { @@ -174,6 +196,198 @@ func (f FormType) Valid() bool { } } +func ParseFormFields(text string) []FormField { + normalizeTitle := func(title string) string { + words := strings.Split(title, " ") + if len(words) == 0 { + return title + } + + builder := words[0] + + for i := 1; i < len(words); i++ { + word := words[i] + + builder += " " + + if word == strings.ToUpper(word) || word == strings.ToLower(word) { + builder += word + } else { + builder += strings.ToLower(word) + } + } + + return builder + } + + lines := strings.Split(text, "\n") + + var sectionSeparators []int + for i, line := range lines { + if strings.HasPrefix(line, "---") { + allDashes := true + for _, r := range line { + if r != '-' { + allDashes = false + break + } + } + + if allDashes { + sectionSeparators = append(sectionSeparators, i) + } + } + } + var titles []string + for _, lineIdx := range sectionSeparators { + if lineIdx > 0 { + titles = append(titles, lines[lineIdx-1]) + } + } + + foundDescriptionBeforeFirstTitle := false + + var descriptions []string + currentStartLine := 0 + + for i := 0; i <= len(sectionSeparators); i++ { + end := len(lines) + + if i < len(sectionSeparators) { + end = sectionSeparators[i] - 1 + } + + var builder strings.Builder + + for row := currentStartLine; row < end; row++ { + builder.WriteString(lines[row]) + builder.WriteString("\n") + } + + description := strings.TrimSpace(builder.String()) + + if description != "" { + if i == 0 { + foundDescriptionBeforeFirstTitle = true + } + + descriptions = append(descriptions, description) + } else { + if i != 0 { + descriptions = append(descriptions, "") + } + } + + currentStartLine = end + 2 + } + + if foundDescriptionBeforeFirstTitle { + if len(titles) > 0 { + titles = append([]string{"Introduction"}, titles...) + } else { + titles = []string{"Application"} + } + } + + prefixesWhichSoundMandatory := []string{ + "Add a ", + "Describe the ", + "Provide a ", + "Please describe the reason for applying", + "Required:", + } + + limitRegex := regexp.MustCompile(`max (\d+) ch`) + + var result []FormField + + for i := 0; i < len(titles); i++ { + description := "" + if i < len(descriptions) { + description = descriptions[i] + } + + title := normalizeTitle(titles[i]) + + optional := true + for _, prefix := range prefixesWhichSoundMandatory { + if strings.HasPrefix(description, prefix) { + optional = false + break + } + } + + field := FormField{ + Name: title, + Title: title, + Description: description, + Optional: optional, + } + + matches := limitRegex.FindAllStringSubmatch(description, -1) + for _, match := range matches { + if len(match) >= 2 { + if limit, err := strconv.Atoi(match[1]); err == nil { + field.MaxLength = util.OptValue(limit) + } + } + } + + if strings.ToLower(field.Title) == "application" { + field.MaxLength = util.OptValue(4000) + } + + limit := 250 + if field.MaxLength.Present { + limit = field.MaxLength.Value + } + + rows := min(15, max(2, limit/50)) + + if strings.Contains(strings.ToLower(field.Title), "project title") { + rows = 2 + } + + field.Rows = util.OptValue(rows) + + result = append(result, field) + } + + // Move large sections to the end + smallFields := make([]FormField, 0, len(result)) + largeFields := make([]FormField, 0) + + for _, field := range result { + maxLength := 0 + if field.MaxLength.Present { + maxLength = field.MaxLength.Value + } + + if maxLength > 1000 { + largeFields = append(largeFields, field) + } else { + smallFields = append(smallFields, field) + } + } + result = append(smallFields, largeFields...) + + return result +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + type Form struct { Type FormType `json:"type"` Text string `json:"text"` // plain_text, grant_giver_initiated From 503a10175c6fbb8449d56c63f5443facf84ea19d Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Tue, 12 May 2026 12:01:40 +0200 Subject: [PATCH 2/8] Extending frontend grant form --- frontend-web/webclient/app/Grants/index.ts | 26 ++++++++++++++++++- .../webclient/app/Project/ProjectSettings.tsx | 13 +++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/frontend-web/webclient/app/Grants/index.ts b/frontend-web/webclient/app/Grants/index.ts index 412d0f8524..cc3338ba0d 100644 --- a/frontend-web/webclient/app/Grants/index.ts +++ b/frontend-web/webclient/app/Grants/index.ts @@ -201,7 +201,7 @@ export interface Doc { allocationPeriod?: Period | null } -type Form = PlainTextForm | GrantGiverInitiatedForm; +type Form = PlainTextForm | GrantGiverInitiatedForm | StructuredForm; interface PlainTextForm { type: "plain_text"; @@ -214,6 +214,14 @@ interface GrantGiverInitiatedForm { subAllocator: boolean; } +interface StructuredForm { + type: "structured"; + text: string; + subAllocator: boolean; + fields: Record; +} + + export type Recipient = {type: "existingProject", id: string;} | {type: "newProject", title: string;} | @@ -301,8 +309,24 @@ export interface GrantGiver { categories: Accounting.ProductCategoryV2[]; } +export interface FormField { + name: string; + title: string + description: string; + optional: boolean; + maxLength?: number; + rows?: number; +} + +export interface TemplateStructured { + personalProject: FormField[]; + newProject: FormField[]; + existingProject: FormField[]; +} + export interface Templates { type: "plain_text"; + structured: TemplateStructured; personalProject: string; newProject: string; existingProject: string; diff --git a/frontend-web/webclient/app/Project/ProjectSettings.tsx b/frontend-web/webclient/app/Project/ProjectSettings.tsx index 89d753acc7..26ce79a528 100644 --- a/frontend-web/webclient/app/Project/ProjectSettings.tsx +++ b/frontend-web/webclient/app/Project/ProjectSettings.tsx @@ -113,7 +113,13 @@ export const ProjectSettings: React.FunctionComponent = () => { personalProject: "No template", newProject: "No template", existingProject: "No template", - } + structured: { + personalProject: [{description: "", name: "", optional: true, title: ""}], + existingProject: [{description: "", name: "", optional: true, title: ""}], + newProject: [{description: "", name: "", optional: true, title: ""}] + } + + } }); const templatePersonal = useRef(null); @@ -214,6 +220,11 @@ export const ProjectSettings: React.FunctionComponent = () => { personalProject: templatePersonal.current!.value, existingProject: templateExisting.current!.value, newProject: templateNew.current!.value, + structured: { + personalProject: [{description: templateExisting.current!.value, name: "", optional: true, title: ""}], + existingProject: [{description: templateExisting.current!.value, name: "", optional: true, title: ""}], + newProject: [{description: templateExisting.current!.value, name: "", optional: true, title: ""}] + } } }) ); From 07f38c50040b27d2f3fbe4c5c86a7d931883700c Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Tue, 12 May 2026 14:46:58 +0200 Subject: [PATCH 3/8] Initial frontend changes --- frontend-web/webclient/app/Grants/Editor.tsx | 133 ++++++++++++++----- frontend-web/webclient/app/Grants/index.ts | 2 + 2 files changed, 100 insertions(+), 35 deletions(-) diff --git a/frontend-web/webclient/app/Grants/Editor.tsx b/frontend-web/webclient/app/Grants/Editor.tsx index 941d49da41..eb4d3ab4a9 100644 --- a/frontend-web/webclient/app/Grants/Editor.tsx +++ b/frontend-web/webclient/app/Grants/Editor.tsx @@ -107,7 +107,7 @@ interface Allocators { id: string; title: string; description: string; - template: string; + template: Grants.Templates; checked: boolean; } @@ -196,6 +196,27 @@ type EditorAction = | {type: "Reset"} ; +function extractFormFields(templateKey: string, templates: Grants.Templates[]): Grants.FormField[] { + let formSections: Grants.FormField[] = []; + if (templateKey === "newProject") { + formSections = templates.flatMap((it) => it.structured.newProject); + } + if (templateKey === "existingProject") { + formSections = templates.flatMap((it) => it.structured.existingProject); + } + if (templateKey === "personalProject") { + formSections = templates.flatMap((it) => it.structured.personalProject); + } + return formSections +} + +function extractAllFormFields(templates: Grants.Templates[]): Grants.FormField[] { + let newProjects = templates.flatMap((it) => it.structured.newProject); + let existingProjects = templates.flatMap((it) => it.structured.existingProject); + let personalProjects = templates.flatMap((it) => it.structured.personalProject); + return [...newProjects, ...existingProjects, ...personalProjects]; +} + function stateReducer(state: EditorState, action: EditorAction): EditorState { switch (action.type) { // Loading and error state @@ -274,10 +295,10 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { if (!existing) { newAllocators.push({ id: allocator.id, title: allocator.title, description: allocator.description, - template: allocator.templates[templateKey], checked: false + template: allocator.templates, checked: false }); - } else if (existing.template !== allocator.templates[templateKey]) { - newAllocators[i] = {...existing, template: allocator.templates[templateKey]}; + } else if (existing.template !== allocator.templates) { + newAllocators[i] = {...existing, template: allocator.templates}; } for (const category of allocator.categories) { @@ -307,12 +328,12 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { for (const arr of Object.values(newResources)) { arr.sort((a, b) => Accounting.categoryComparator(a.category, b.category)); } - - const allSections = calculateNewApplication( - action.allocators - .filter(it => newAllocators.find(existing => existing.id === it.id)?.checked === true) - .map(it => it.templates[templateKey]) - ); + // we should filter the type === templateKey + const templates = action.allocators.filter(it => newAllocators.find(existing => existing.id === it.id)?.checked === true) + .map(it => it.templates) + + // list of grant givers action.allocators + const allSections = calculateNewApplicationV2(extractFormFields(templateKey, templates)); return { ...state, @@ -497,12 +518,8 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { return it; } }); - - const allSections = calculateNewApplication( - newAllocators - .filter(it => it.checked) - .map(it => it.template) - ); + const templates = newAllocators.filter(it => it.checked).map(it => it.template); + const allSections = calculateNewApplicationV2(extractAllFormFields(templates)) return { ...state, @@ -699,6 +716,20 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { // Scoped utility functions // ----------------------------------------------------------------------------------------------------------------- + + function calculateNewApplicationV2(forms: Grants.FormField[]): ApplicationSection[] { + if (forms === null) { + return []; + } + return forms.map(f => ({ + title: f.title ?? "NO TITLE", + description: f.description ?? "NO DESCRIPTION", + rows: f.rows ?? 0, + mandatory: !f.optional, + limit: f.maxLength + })); + } + function calculateNewApplication(templates: string[]): ApplicationSection[] { const allSections: ApplicationSection[] = []; for (const template of templates) { @@ -732,7 +763,17 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { newAllocators.push({ title: breakdown.projectTitle, id: breakdown.projectId, - template: "", + template: { + type: "plain_text", + existingProject: "", + newProject: "", + personalProject: "", + structured: { + existingProject: [], + newProject: [], + personalProject: [] + } + }, description: "", checked: true }); @@ -775,27 +816,44 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { const isGrantGiverInitiated = app && app.status.overallState == "APPROVED" && app.status.revisions.length === 1 && docText.startsWith(grantGiverInitiatedPrefix); const docSections = parseIntoSections(docText); - const templates = isGrantGiverInitiated ? [grantGiverInitiatedTemplate] : newAllocators.map(it => it.template); - const newApplication = calculateNewApplication(templates); + const grantTemplate: Grants.FormField = { + description: grantGiverInitiatedTemplate, + name: "grantGiverInitiatedTemplate", + optional: false, + maxLength: 1000, + title: "grantGiverStuff", + }; + const foundTemplates = isGrantGiverInitiated ? [grantTemplate] : newAllocators.map(it => it.template); + + /////// ALRIGHJT FIX HREE + const allSections = [ + foundTemplates["newProject"], + foundTemplates["existingProject"], + foundTemplates["personalProject"] + ] + + console.log("NOTHING ", newAllocators); + console.log("NOTHING ", allSections); + const newApplication = calculateNewApplicationV2(allSections); const newApplicationDocument: EditorState["applicationDocument"] = {}; let otherSection = ""; - for (const section of docSections) { - const hasSection = newApplication.some(it => it.title === section.title); - if (hasSection) { - newApplicationDocument[section.title] = section.description; - } else { - otherSection += section.title; - otherSection += ":\n\n"; - otherSection += section.description; - otherSection += "\n\n"; - } - } - - if (otherSection) { - newApplication.push({title: "Other", rows: 6, mandatory: false, description: ""}); - newApplicationDocument["Other"] = otherSection; - } + // for (const section of docSections) { + // const hasSection = newApplication.some(it => it.title === section.title); + // if (hasSection) { + // newApplicationDocument[section.title] = section.description; + // } else { + // otherSection += section.title; + // otherSection += ":\n\n"; + // otherSection += section.description; + // otherSection += "\n\n"; + // } + // } + + // if (otherSection) { + // newApplication.push({title: "Other", rows: 6, mandatory: false, description: ""}); + // newApplicationDocument["Other"] = otherSection; + // } let startDate = new Date(Date.now()) if (doc.allocationPeriod?.start != null) { @@ -983,6 +1041,11 @@ function useStateReducerMiddleware( newProject: grantGiverInitiatedTemplate, existingProject: grantGiverInitiatedTemplate, personalProject: grantGiverInitiatedTemplate, + structured: { + newProject: [{name: "", description: "", optional: false, title: ""}], + existingProject: [{name: "", description: "", optional: false, title: ""}], + personalProject: [{name: "", description: "", optional: false, title: ""}], + } } }] }); diff --git a/frontend-web/webclient/app/Grants/index.ts b/frontend-web/webclient/app/Grants/index.ts index cc3338ba0d..51757b0473 100644 --- a/frontend-web/webclient/app/Grants/index.ts +++ b/frontend-web/webclient/app/Grants/index.ts @@ -324,6 +324,8 @@ export interface TemplateStructured { existingProject: FormField[]; } +export type TemplateKey = "personalProject" | "newProject" | "existingProject"; + export interface Templates { type: "plain_text"; structured: TemplateStructured; From f4eee81c75ad5ae76c12773a626e8086601b3bc9 Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Wed, 13 May 2026 14:46:40 +0200 Subject: [PATCH 4/8] Implementing new structured template for editor --- frontend-web/webclient/app/Grants/Editor.tsx | 101 ++++++++---------- frontend-web/webclient/app/Grants/index.ts | 1 - .../shared/pkg/accounting/grants.go | 12 +-- 3 files changed, 49 insertions(+), 65 deletions(-) diff --git a/frontend-web/webclient/app/Grants/Editor.tsx b/frontend-web/webclient/app/Grants/Editor.tsx index eb4d3ab4a9..5238e98c63 100644 --- a/frontend-web/webclient/app/Grants/Editor.tsx +++ b/frontend-web/webclient/app/Grants/Editor.tsx @@ -44,7 +44,6 @@ import {useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef} fr import {useLocation, useNavigate} from "react-router-dom"; import * as Grants from "."; import {ChangeOrganizationDetails, OptionalInfo, optionalInfoRequest, optionalInfoUpdate} from "@/UserSettings/ChangeUserDetails"; -import {ProviderBranding, ProviderBrandingProductDescription, ProviderBrandingResponse} from "@/UCloud/ProviderBrandingApi"; import {useSelector} from "react-redux"; import {sendFailureNotification, sendSuccessNotification} from "@/Notifications"; @@ -107,7 +106,7 @@ interface Allocators { id: string; title: string; description: string; - template: Grants.Templates; + template: Grants.FormField[]; checked: boolean; } @@ -292,14 +291,17 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { let i = 0; for (const allocator of action.allocators) { const existing = newAllocators.find(it => it.id === allocator.id); + const forms = allocator.templates.structured[templateKey]; + const sameForm = existing?.template.length === forms && existing?.template.every((val, i) => val === forms[i]); if (!existing) { newAllocators.push({ id: allocator.id, title: allocator.title, description: allocator.description, - template: allocator.templates, checked: false + template: forms, checked: false, }); - } else if (existing.template !== allocator.templates) { - newAllocators[i] = {...existing, template: allocator.templates}; + } else if (!sameForm) { + newAllocators[i] = {...existing, template: forms}; } + for (const category of allocator.categories) { let sectionForProvider = newResources[category.provider]; @@ -308,7 +310,6 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { sectionForProvider = newResources[category.provider]!; } - const existing = sectionForProvider.find(it => it.category.name === category.name); if (existing) { if ([...existing.allocators.values()].find(grantGiver => grantGiver.grantGiverId == allocator.id && grantGiver.grantGiverTitle === allocator.title)) { @@ -328,12 +329,11 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { for (const arr of Object.values(newResources)) { arr.sort((a, b) => Accounting.categoryComparator(a.category, b.category)); } - // we should filter the type === templateKey - const templates = action.allocators.filter(it => newAllocators.find(existing => existing.id === it.id)?.checked === true) - .map(it => it.templates) + + const forms = action.allocators.filter(it => newAllocators.find(existing => existing.id === it.id)?.checked === true).flatMap(it => it.templates.structured[templateKey]) // list of grant givers action.allocators - const allSections = calculateNewApplicationV2(extractFormFields(templateKey, templates)); + const allSections = calculateNewApplicationV2(forms); return { ...state, @@ -518,8 +518,8 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { return it; } }); - const templates = newAllocators.filter(it => it.checked).map(it => it.template); - const allSections = calculateNewApplicationV2(extractAllFormFields(templates)) + const forms = newAllocators.filter(it => it.checked).flatMap(it => it.template); + const allSections = calculateNewApplicationV2(forms); return { ...state, @@ -763,19 +763,9 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { newAllocators.push({ title: breakdown.projectTitle, id: breakdown.projectId, - template: { - type: "plain_text", - existingProject: "", - newProject: "", - personalProject: "", - structured: { - existingProject: [], - newProject: [], - personalProject: [] - } - }, + template: [], description: "", - checked: true + checked: true, }); } } @@ -816,44 +806,28 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { const isGrantGiverInitiated = app && app.status.overallState == "APPROVED" && app.status.revisions.length === 1 && docText.startsWith(grantGiverInitiatedPrefix); const docSections = parseIntoSections(docText); - const grantTemplate: Grants.FormField = { - description: grantGiverInitiatedTemplate, - name: "grantGiverInitiatedTemplate", - optional: false, - maxLength: 1000, - title: "grantGiverStuff", - }; - const foundTemplates = isGrantGiverInitiated ? [grantTemplate] : newAllocators.map(it => it.template); - - /////// ALRIGHJT FIX HREE - const allSections = [ - foundTemplates["newProject"], - foundTemplates["existingProject"], - foundTemplates["personalProject"] - ] - console.log("NOTHING ", newAllocators); - console.log("NOTHING ", allSections); - const newApplication = calculateNewApplicationV2(allSections); + const forms = isGrantGiverInitiated ? [grantGiverInitiatedForm] : newAllocators.flatMap(it => it.template); + const newApplication = calculateNewApplicationV2(forms); const newApplicationDocument: EditorState["applicationDocument"] = {}; let otherSection = ""; - // for (const section of docSections) { - // const hasSection = newApplication.some(it => it.title === section.title); - // if (hasSection) { - // newApplicationDocument[section.title] = section.description; - // } else { - // otherSection += section.title; - // otherSection += ":\n\n"; - // otherSection += section.description; - // otherSection += "\n\n"; - // } - // } - - // if (otherSection) { - // newApplication.push({title: "Other", rows: 6, mandatory: false, description: ""}); - // newApplicationDocument["Other"] = otherSection; - // } + for (const section of docSections) { + const hasSection = newApplication.some(it => it.title === section.title); + if (hasSection) { + newApplicationDocument[section.title] = section.description; + } else { + otherSection += section.title; + otherSection += ":\n\n"; + otherSection += section.description; + otherSection += "\n\n"; + } + } + + if (otherSection) { + newApplication.push({title: "Other", rows: 6, mandatory: false, description: ""}); + newApplicationDocument["Other"] = otherSection; + } let startDate = new Date(Date.now()) if (doc.allocationPeriod?.start != null) { @@ -3148,4 +3122,15 @@ const grantGiverInitiatedTemplate = `${grantGiverInitiatedPrefix} Describe the reason for creating this sub-allocation (max 4000 ch).`; +const grantGiverInitiatedForm: Grants.FormField = { + description: ` + -------------------------------------------------- + Describe the reason for creating this sub-allocation`, + name: grantGiverInitiatedPrefix, + optional: false, + title: grantGiverInitiatedPrefix, + maxLength: 4000, + rows: 100 +}; + export default Editor; diff --git a/frontend-web/webclient/app/Grants/index.ts b/frontend-web/webclient/app/Grants/index.ts index 51757b0473..1ce4e055e0 100644 --- a/frontend-web/webclient/app/Grants/index.ts +++ b/frontend-web/webclient/app/Grants/index.ts @@ -3,7 +3,6 @@ import * as Accounting from "@/Accounting"; import {FindByStringId, PageV2, PaginationRequestV2} from "@/UCloud"; import {IconName} from "@/ui-components/Icon"; import {ThemeColor} from "@/ui-components/theme"; -import {ProductCategoryId} from "@/Accounting"; import {OptionalInfo} from "@/UserSettings/ChangeUserDetails"; const baseContext = "/api/grants/v2"; diff --git a/provider-integration/shared/pkg/accounting/grants.go b/provider-integration/shared/pkg/accounting/grants.go index 1080529824..7b46b326ee 100644 --- a/provider-integration/shared/pkg/accounting/grants.go +++ b/provider-integration/shared/pkg/accounting/grants.go @@ -143,12 +143,12 @@ type Templates struct { } type FormField struct { - Name string - Title string - Description string // allows markdown - Optional bool - MaxLength util.Option[int] - Rows util.Option[int] + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Optional bool `json:"optional"` + MaxLength util.Option[int] `json:"maxLength"` + Rows util.Option[int] `json:"rows"` } type GrantApplication struct { From 9250811d0327d6e9beb64a7f2e792bd7ddd34281 Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Thu, 21 May 2026 14:55:12 +0200 Subject: [PATCH 5/8] Added new structured template to core and frontend --- core2/pkg/accounting/grants.go | 15 ++- core2/pkg/accounting/grants_persistence.go | 126 +++++++++++++++--- core2/pkg/migrations/00_module.go | 1 + core2/pkg/migrations/grant.go | 20 +++ frontend-web/webclient/app/Grants/Editor.tsx | 112 +++++++--------- frontend-web/webclient/app/Grants/index.ts | 2 +- .../webclient/app/Project/ProjectSettings.tsx | 39 +++--- .../shared/pkg/accounting/grants.go | 9 +- 8 files changed, 220 insertions(+), 104 deletions(-) diff --git a/core2/pkg/accounting/grants.go b/core2/pkg/accounting/grants.go index 0c14d40f8c..f5bfcb08af 100644 --- a/core2/pkg/accounting/grants.go +++ b/core2/pkg/accounting/grants.go @@ -1673,12 +1673,25 @@ func GrantsRetrieveGrantGivers(actor rpc.Actor, req accapi.RetrieveGrantGiversRe gg.Templates = sWrapper.Settings.Templates sWrapper.Mu.RUnlock() } else { + defaultForm := accapi.FormField{ + Name: "Default Template", + Description: defaultTemplate, + MaxLength: util.OptValue(4000), + Title: "Default Template", + Rows: util.OptValue(100), + Optional: false, + } gg.Description = "" gg.Templates = accapi.Templates{ Type: accapi.TemplatesTypePlainText, PersonalProject: defaultTemplate, NewProject: defaultTemplate, ExistingProject: defaultTemplate, + Structured: accapi.TemplatesStructured{ + PersonalProject: []accapi.FormField{defaultForm}, + NewProject: []accapi.FormField{defaultForm}, + ExistingProject: []accapi.FormField{defaultForm}, + }, } } result = append(result, gg) @@ -1777,7 +1790,7 @@ func GrantsRetrieveSettings(actor rpc.Actor) (accapi.GrantRequestSettings, *util AllowRequestsFrom: []accapi.UserCriteria{}, ExcludeRequestsFrom: []accapi.UserCriteria{}, Templates: accapi.Templates{ - Type: accapi.TemplatesTypePlainText, + Type: accapi.TemplatesTypeStructured, PersonalProject: defaultTemplate, NewProject: defaultTemplate, ExistingProject: defaultTemplate, diff --git a/core2/pkg/accounting/grants_persistence.go b/core2/pkg/accounting/grants_persistence.go index d63eaf81ab..35dc917bac 100644 --- a/core2/pkg/accounting/grants_persistence.go +++ b/core2/pkg/accounting/grants_persistence.go @@ -85,6 +85,7 @@ func grantsLoad(id accGrantId, prefetchHint []accGrantId) { revisionsPromise := db.BatchSelect[struct { ApplicationId int Form string + FormType accapi.FormType ParentProjectId sql.Null[string] Recipient string RecipientType string @@ -106,6 +107,7 @@ func grantsLoad(id accGrantId, prefetchHint []accGrantId) { f.parent_project_id, f.recipient, f.recipient_type, + f.form_type, f.reference_ids, f.revision_number, r.created_at, @@ -218,7 +220,7 @@ func grantsLoad(id accGrantId, prefetchHint []accGrantId) { continue } - app.Status.Revisions = append(app.Status.Revisions, accapi.GrantRevision{ + currentRevision := accapi.GrantRevision{ CreatedAt: fndapi.Timestamp(revision.CreatedAt), UpdatedBy: revision.UpdatedBy, RevisionNumber: revision.RevisionNumber, @@ -226,7 +228,7 @@ func grantsLoad(id accGrantId, prefetchHint []accGrantId) { Recipient: accapi.RecipientFromReference(accapi.RecipientType(revision.RecipientType), revision.Recipient), AllocationRequests: nil, Form: accapi.Form{ - Type: accapi.FormTypePlainText, + Type: revision.FormType, Text: revision.Form, }, ReferenceIds: util.OptValue(revision.ReferenceIds), @@ -236,7 +238,19 @@ func grantsLoad(id accGrantId, prefetchHint []accGrantId) { End: util.OptValue[fndapi.Timestamp](fndapi.Timestamp(revision.GrantEnd)), }), }, - }) + } + // Handling structured form + if currentRevision.Document.Form.Type == accapi.FormTypeStructured { + + jsonStr := currentRevision.Document.Form.Text + fields := make(map[string]string) + err := json.Unmarshal([]byte(jsonStr), &fields) + if err != nil { + log.Warn("failed to parse structured form: %s", err) + } + currentRevision.Document.Form.Fields = fields + } + app.Status.Revisions = append(app.Status.Revisions, currentRevision) if revision.ProjectTitle.Valid { app.Status.ProjectTitle.Set(revision.ProjectTitle.V) @@ -394,13 +408,36 @@ func grantsLoadUnawarded() { } } -func parseStructuredFormFields(templateString string) []accapi.FormField { +func parseToStructuredFormFields(templateString string) []accapi.FormField { if templateString == "" { return make([]accapi.FormField, 0) } return accapi.ParseFormFields(templateString) } +func formFieldsToJsonString(fields []accapi.FormField) string { + b, err := json.Marshal(fields) + if err != nil { + log.Warn("Failed to serialize form fields: %s", err) + return "" + } + return string(b) +} + +func deserializeFormFields(raw string) ([]accapi.FormField, error) { + if raw == "" { + return make([]accapi.FormField, 0), nil + } + + var fields []accapi.FormField + err := json.Unmarshal([]byte(raw), &fields) + if err != nil { + return make([]accapi.FormField, 0), err + } + + return util.NonNilSlice(fields), nil +} + func grantsLoadSettings() { if grantGlobals.Testing.Enabled { return @@ -470,17 +507,34 @@ func grantsLoadSettings() { result := map[string]accapi.GrantRequestSettings{} for _, template := range templates { existing := result[template.ProjectId] + var personalProject, newProject, existingProject []accapi.FormField + + // Trying to deserialize form fields + personalProject, err := deserializeFormFields(template.PersonalProject) + if err == nil { + newProject, _ = deserializeFormFields(template.NewProject) + existingProject, _ = deserializeFormFields(template.ExistingProject) + } else { + // If we failed to deserialize, we are going to try to parse it as structured form fields + personalProject = parseToStructuredFormFields(template.PersonalProject) + newProject = parseToStructuredFormFields(template.NewProject) + existingProject = parseToStructuredFormFields(template.ExistingProject) + } + existing.Templates = accapi.Templates{ - Type: accapi.TemplatesTypePlainText, + Type: accapi.TemplatesTypeStructured, + // For legacy purposes PersonalProject: template.PersonalProject, NewProject: template.NewProject, ExistingProject: template.ExistingProject, + // Structured template + Structured: accapi.TemplatesStructured{ + PersonalProject: personalProject, + NewProject: newProject, + ExistingProject: existingProject, + }, } - existing.Templates.Structured = accapi.TemplatesStructured{ - PersonalProject: parseStructuredFormFields(template.PersonalProject), - NewProject: parseStructuredFormFields(template.NewProject), - ExistingProject: parseStructuredFormFields(template.ExistingProject), - } + result[template.ProjectId] = existing } @@ -548,10 +602,15 @@ func grantsLoadSettings() { for projectId, settings := range result { if settings.Templates.Type == "" { settings.Templates = accapi.Templates{ - Type: accapi.TemplatesTypePlainText, + Type: accapi.TemplatesTypeStructured, PersonalProject: defaultTemplate, NewProject: defaultTemplate, ExistingProject: defaultTemplate, + Structured: accapi.TemplatesStructured{ + PersonalProject: parseToStructuredFormFields(defaultTemplate), + NewProject: parseToStructuredFormFields(defaultTemplate), + ExistingProject: parseToStructuredFormFields(defaultTemplate), + }, } result[projectId] = settings @@ -618,6 +677,21 @@ func lGrantsPersistSettings(settings *grantSettings) { }, ) + var personalProject string + var existingProject string + var newProject string + if len(s.Templates.Structured.PersonalProject) > 0 { + // If we have structured templates, we need to serialize them to JSON + personalProject = formFieldsToJsonString(s.Templates.Structured.PersonalProject) + newProject = formFieldsToJsonString(s.Templates.Structured.NewProject) + existingProject = formFieldsToJsonString(s.Templates.Structured.ExistingProject) + } else { + // converting to structured form fields + personalProject = formFieldsToJsonString(parseToStructuredFormFields(s.Templates.PersonalProject)) + newProject = formFieldsToJsonString(parseToStructuredFormFields(s.Templates.NewProject)) + existingProject = formFieldsToJsonString(parseToStructuredFormFields(s.Templates.ExistingProject)) + } + db.Exec( tx, ` @@ -630,9 +704,9 @@ func lGrantsPersistSettings(settings *grantSettings) { `, db.Params{ "project": settings.ProjectId, - "personal": s.Templates.PersonalProject, - "new": s.Templates.NewProject, - "existing": s.Templates.ExistingProject, + "personal": personalProject, + "new": newProject, + "existing": existingProject, }, ) @@ -943,6 +1017,7 @@ func lGrantsPersist(app *grantApplication) { var formsRecipient []string var formsRecipientType []string var form []string + var formType []accapi.FormType var formsReferences []string // json array for _, rev := range appl.Status.Revisions { @@ -957,8 +1032,24 @@ func lGrantsPersist(app *grantApplication) { case accapi.RecipientTypePersonalWorkspace: sqlRecipientType = "personal" } + formsRecipientType = append(formsRecipientType, sqlRecipientType) - form = append(form, rev.Document.Form.Text) + + if rev.Document.Form.Type == accapi.FormTypeStructured { + b, err := json.Marshal(rev.Document.Form.Fields) + if err == nil { + form = append(form, string(b)) + } else { + form = append(form, "Failed to marshal") + log.Error("Failed to marshal form fields for application %d", app.lId()) + } + } + + if rev.Document.Form.Type == accapi.FormTypePlainText { + form = append(form, rev.Document.Form.Text) + } + + formType = append(formType, rev.Document.Form.Type) jsonArr, _ := json.Marshal(util.NonNilSlice(rev.Document.ReferenceIds.GetOrDefault([]string{}))) formsReferences = append(formsReferences, string(jsonArr)) @@ -974,6 +1065,7 @@ func lGrantsPersist(app *grantApplication) { unnest(cast(:recipients as text[])) as recipient, unnest(cast(:recipient_types as text[])) as recipient_type, unnest(cast(:form as text[])) as form, + unnest(cast(:form_types as text[])) as form_type, unnest(cast(:refs as text[])) as refs ), refs_unwrapped as ( @@ -986,7 +1078,7 @@ func lGrantsPersist(app *grantApplication) { group by rev ) insert into "grant".forms(application_id, revision_number, parent_project_id, recipient, - recipient_type, form, reference_ids) + recipient_type, form, form_type, reference_ids) select :app_id, d.rev, @@ -994,6 +1086,7 @@ func lGrantsPersist(app *grantApplication) { d.recipient, d.recipient_type, d.form, + d.form_type::form_type, coalesce(r.refs, cast(array[] as text[])) from data d @@ -1009,6 +1102,7 @@ func lGrantsPersist(app *grantApplication) { "recipients": formsRecipient, "recipient_types": formsRecipientType, "form": form, + "form_types": formType, "refs": formsReferences, }, ) diff --git a/core2/pkg/migrations/00_module.go b/core2/pkg/migrations/00_module.go index 4f95718ffe..fb4a2ec3f0 100644 --- a/core2/pkg/migrations/00_module.go +++ b/core2/pkg/migrations/00_module.go @@ -35,4 +35,5 @@ func Init() { db.AddMigration(resourcesV1()) db.AddMigration(stacksV1()) db.AddMigration(accountingV5()) + db.AddMigration(grantV3()) } diff --git a/core2/pkg/migrations/grant.go b/core2/pkg/migrations/grant.go index cd167215a1..54b4186931 100644 --- a/core2/pkg/migrations/grant.go +++ b/core2/pkg/migrations/grant.go @@ -43,3 +43,23 @@ func grantV2() db.MigrationScript { }, } } + +func grantV3() db.MigrationScript { + return db.MigrationScript{ + Id: "grantsV3", + Execute: func(tx *db.Transaction) { + statements := []string{ + ` + create type form_type as enum ('plain_text', 'structured'); + `, + ` + alter table "grant".forms + add column form_type form_type not null default 'plain_text'; + `, + } + for _, statement := range statements { + db.Exec(tx, statement, db.Params{}) + } + }, + } +} diff --git a/frontend-web/webclient/app/Grants/Editor.tsx b/frontend-web/webclient/app/Grants/Editor.tsx index 5238e98c63..5ecb46cbeb 100644 --- a/frontend-web/webclient/app/Grants/Editor.tsx +++ b/frontend-web/webclient/app/Grants/Editor.tsx @@ -96,7 +96,7 @@ interface EditorState { possibleTransfers: Allocators[]; allocators: Allocators[]; - application: ApplicationSection[]; + application: Grants.FormField[]; applicationDocument: Record; resources: Record; @@ -261,9 +261,9 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { const newResources: EditorState["resources"] = {...state.resources}; - let templateKey: keyof Grants.Templates = "newProject"; + let templateKey: keyof Grants.TemplateStructured = "newProject"; - function templateKeyFromRecipientType(type: Grants.Recipient["type"]): keyof Grants.Templates { + function templateKeyFromRecipientType(type: Grants.Recipient["type"]): keyof Grants.TemplateStructured { switch (type) { case "personalWorkspace": return "personalProject"; @@ -292,7 +292,7 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { for (const allocator of action.allocators) { const existing = newAllocators.find(it => it.id === allocator.id); const forms = allocator.templates.structured[templateKey]; - const sameForm = existing?.template.length === forms && existing?.template.every((val, i) => val === forms[i]); + const sameForm = existing?.template === forms && existing?.template.every((val, i) => val === forms[i]); if (!existing) { newAllocators.push({ id: allocator.id, title: allocator.title, description: allocator.description, @@ -332,15 +332,12 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { const forms = action.allocators.filter(it => newAllocators.find(existing => existing.id === it.id)?.checked === true).flatMap(it => it.templates.structured[templateKey]) - // list of grant givers action.allocators - const allSections = calculateNewApplicationV2(forms); - return { ...state, possibleTransfers: newAllocators, allocators: newAllocators, resources: newResources, - application: allSections, + application: forms, }; } @@ -519,12 +516,11 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { } }); const forms = newAllocators.filter(it => it.checked).flatMap(it => it.template); - const allSections = calculateNewApplicationV2(forms); return { ...state, allocators: newAllocators, - application: allSections, + application: forms, } } @@ -717,31 +713,6 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { // Scoped utility functions // ----------------------------------------------------------------------------------------------------------------- - function calculateNewApplicationV2(forms: Grants.FormField[]): ApplicationSection[] { - if (forms === null) { - return []; - } - return forms.map(f => ({ - title: f.title ?? "NO TITLE", - description: f.description ?? "NO DESCRIPTION", - rows: f.rows ?? 0, - mandatory: !f.optional, - limit: f.maxLength - })); - } - - function calculateNewApplication(templates: string[]): ApplicationSection[] { - const allSections: ApplicationSection[] = []; - for (const template of templates) { - const theseSections = parseIntoSections(template) - for (const section of theseSections) { - if (allSections.some(it => it.title === section.title)) continue - allSections.push(section); - } - } - return allSections; - } - function loadRevision(state: EditorState): EditorState { if (!state.stateDuringEdit) return state; const newEditState = {...state.stateDuringEdit}; @@ -805,28 +776,36 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { const isGrantGiverInitiated = app && app.status.overallState == "APPROVED" && app.status.revisions.length === 1 && docText.startsWith(grantGiverInitiatedPrefix); - const docSections = parseIntoSections(docText); - const forms = isGrantGiverInitiated ? [grantGiverInitiatedForm] : newAllocators.flatMap(it => it.template); - const newApplication = calculateNewApplicationV2(forms); - + const newApplication = forms const newApplicationDocument: EditorState["applicationDocument"] = {}; - let otherSection = ""; - for (const section of docSections) { - const hasSection = newApplication.some(it => it.title === section.title); - if (hasSection) { - newApplicationDocument[section.title] = section.description; - } else { - otherSection += section.title; - otherSection += ":\n\n"; - otherSection += section.description; - otherSection += "\n\n"; - } + + if (doc.form.type == "structured") { + Object.entries(doc.form.fields).forEach(([fieldName, value]) => { + newApplicationDocument[fieldName] = value; + }); } + else { + // ****************** Legacy way of parsing the text **************************** + const docSections = parseIntoSections(docText); + + let otherSection = ""; + for (const section of docSections) { + const hasSection = newApplication.some(it => it.title === section.title); + if (hasSection) { + newApplicationDocument[section.title] = section.description; + } else { + otherSection += section.title; + otherSection += ":\n\n"; + otherSection += section.description; + otherSection += "\n\n"; + } + } - if (otherSection) { - newApplication.push({title: "Other", rows: 6, mandatory: false, description: ""}); - newApplicationDocument["Other"] = otherSection; + if (otherSection) { + newApplication.push({title: "Other", rows: 6, optional: true, description: "", name: "Other" }); + newApplicationDocument["Other"] = otherSection; + } } let startDate = new Date(Date.now()) @@ -1011,15 +990,15 @@ function useStateReducerMiddleware( description: "Your project", categories: wallets.map(w => w.paysFor), templates: { - type: "plain_text", + type: "structured", + structured: { + newProject: [grantGiverInitiatedForm], + existingProject: [grantGiverInitiatedForm], + personalProject: [grantGiverInitiatedForm], + }, newProject: grantGiverInitiatedTemplate, existingProject: grantGiverInitiatedTemplate, personalProject: grantGiverInitiatedTemplate, - structured: { - newProject: [{name: "", description: "", optional: false, title: ""}], - existingProject: [{name: "", description: "", optional: false, title: ""}], - personalProject: [{name: "", description: "", optional: false, title: ""}], - } } }] }); @@ -1681,7 +1660,7 @@ export function Editor(): React.ReactNode { parentProjectId: currentDoc.parentProjectId, allocationPeriod: period }; - + doc.form["fields"] = state.applicationDocument if (isGrantGiverInitiated) { doc.form.type = "grant_giver_initiated"; doc.form["subAllocator"] = isForSubAllocator @@ -2257,9 +2236,9 @@ export function Editor(): React.ReactNode { // NOTE(Dan): Empty placeholder is a quick work-around for fields having error // immediately on load. return -