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 871c7e2489..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,6 +408,36 @@ func grantsLoadUnawarded() { } } +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 @@ -463,11 +507,32 @@ 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, + }, } result[template.ProjectId] = existing @@ -537,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 @@ -607,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, ` @@ -619,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, }, ) @@ -932,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 { @@ -946,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)) @@ -963,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 ( @@ -975,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, @@ -983,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 @@ -998,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 941d49da41..8da453fda3 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"; @@ -97,7 +96,7 @@ interface EditorState { possibleTransfers: Allocators[]; allocators: Allocators[]; - application: ApplicationSection[]; + application: Grants.FormField[]; applicationDocument: Record; resources: Record; @@ -107,7 +106,7 @@ interface Allocators { id: string; title: string; description: string; - template: string; + template: Grants.FormField[]; checked: boolean; } @@ -241,9 +240,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"; @@ -271,14 +270,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 === 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[templateKey], checked: false + template: forms, checked: false, }); - } else if (existing.template !== allocator.templates[templateKey]) { - newAllocators[i] = {...existing, template: allocator.templates[templateKey]}; + } else if (!sameForm) { + newAllocators[i] = {...existing, template: forms}; } + for (const category of allocator.categories) { let sectionForProvider = newResources[category.provider]; @@ -287,7 +289,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)) { @@ -308,18 +309,14 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { 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]) - ); - + const forms = action.allocators.filter(it => newAllocators.find(existing => existing.id === it.id)?.checked === true).flatMap(it => it.templates.structured[templateKey]) + return { ...state, possibleTransfers: newAllocators, allocators: newAllocators, resources: newResources, - application: allSections, + application: forms, }; } @@ -497,17 +494,12 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { return it; } }); - - const allSections = calculateNewApplication( - newAllocators - .filter(it => it.checked) - .map(it => it.template) - ); + const forms = newAllocators.filter(it => it.checked).flatMap(it => it.template); return { ...state, allocators: newAllocators, - application: allSections, + application: forms, } } @@ -699,17 +691,6 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { // Scoped utility functions // ----------------------------------------------------------------------------------------------------------------- - 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; @@ -732,9 +713,9 @@ function stateReducer(state: EditorState, action: EditorAction): EditorState { newAllocators.push({ title: breakdown.projectTitle, id: breakdown.projectId, - template: "", + template: [], description: "", - checked: true + checked: true, }); } } @@ -774,27 +755,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 templates = isGrantGiverInitiated ? [grantGiverInitiatedTemplate] : newAllocators.map(it => it.template); - const newApplication = calculateNewApplication(templates); - + const forms = isGrantGiverInitiated ? [grantGiverInitiatedForm] : newAllocators.flatMap(it => it.template); + 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()) @@ -979,7 +969,12 @@ 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, @@ -1644,7 +1639,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 @@ -2220,9 +2215,9 @@ export function Editor(): React.ReactNode { // NOTE(Dan): Empty placeholder is a quick work-around for fields having error // immediately on load. return -