diff --git a/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql b/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql new file mode 100644 index 0000000000..92d71c1933 --- /dev/null +++ b/apps/backend/db_patches/0209_addBaseulrtodynamiccs.sql @@ -0,0 +1,16 @@ +DO +$$ +BEGIN + IF register_patch('addBaseulrtodynamiccs.sql', 'Zachary Hankin', 'Adds an option to the dynamic HTTP question type allowing use of base domain', '2026-5-21') THEN + UPDATE questions + SET default_config = jsonb_set( + default_config::jsonb, + '{useBaseDomain}', + 'false'::jsonb, + true + ) + WHERE data_type = 'DYNAMIC_MULTIPLE_CHOICE'; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql b/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql index ac01b60896..00d33f8448 100644 --- a/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql +++ b/apps/backend/db_patches/db_seeds/0005_ProposalQuestions.sql @@ -376,7 +376,7 @@ VALUES ( 'dynamic_multiple_choice_question', 'DYNAMIC_MULTIPLE_CHOICE', 'Dynamic multiple choice question from seeds', - '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[], "readPermissions":[]}', + '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[], "readPermissions":[], "useBaseDomain":false}', '2023-02-08 10:23:10.285415+00', '2023-02-08 10:23:10.285415+00', 'dynamic_multiple_choice_question', @@ -389,7 +389,7 @@ INSERT INTO templates_has_questions( VALUES ( 'dynamic_multiple_choice_question', - proposal_template_id_var, proposal_topic_id_var, 10, '{"variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[],"readPermissions":[]}' + proposal_template_id_var, proposal_topic_id_var, 10, '{"useBaseDomain":false, "variant":"dropdown", "url":"", "jsonPath":"","isMultipleSelect":true, "apiCallRequestHeaders":[],"readPermissions":[]}' ); INSERT INTO answers( questionary_id, question_id, answer diff --git a/apps/backend/example.development.env b/apps/backend/example.development.env index dde1b439b0..60b944e96f 100644 --- a/apps/backend/example.development.env +++ b/apps/backend/example.development.env @@ -44,7 +44,7 @@ NODE_ENV=development # DEPENDENCY_CONFIG= PING_PUBLIC_CRT=dummypingsecret DATABASE_URL=postgres://duouser:duopassword@127.0.0.1:5432/duo -BASE_URL=localhost:3000 +BASE_URL=http://localhost:3000 JWT_TOKEN_LIFE=7d JWT_SECRET=qMyLZALzs229ybdQXNyzYRdju7X784TH # SPARKPOST_TOKEN=insertokenhere diff --git a/apps/backend/src/datasources/mockups/TemplateDataSource.ts b/apps/backend/src/datasources/mockups/TemplateDataSource.ts index c65f132d48..025ff05186 100644 --- a/apps/backend/src/datasources/mockups/TemplateDataSource.ts +++ b/apps/backend/src/datasources/mockups/TemplateDataSource.ts @@ -126,6 +126,20 @@ const dummyTemplateStepsFactory = () => { config: { url: '', jsonPath: '', + useBaseDomain: false, + } as DynamicMultipleChoiceConfig, + }), + }); + + const dmcQuestionWithBaseDomain = dummyQuestionTemplateRelationFactory({ + question: dummyQuestionFactory({ + id: 'dmcQuestionWithBaseDomain', + naturalKey: 'dmcQuestionWithBaseDomain', + dataType: DataType.DYNAMIC_MULTIPLE_CHOICE, + config: { + url: 'getListOfCountries', + useBaseDomain: true, + jsonPath: '', } as DynamicMultipleChoiceConfig, }), }); @@ -138,6 +152,7 @@ const dummyTemplateStepsFactory = () => { config: { url: 'api-url', jsonPath: '', + useBaseDomain: false, apiCallRequestHeaders: [ { name: 'header1', @@ -160,6 +175,7 @@ const dummyTemplateStepsFactory = () => { config: { url: 'api-url', jsonPath: '$..option', + useBaseDomain: false, } as DynamicMultipleChoiceConfig, }), }); @@ -175,6 +191,7 @@ const dummyTemplateStepsFactory = () => { dmcQuestionEmptyUrl, dmcQuestionEmptyJsonPath, dmcQuestionWithUrlAndJsonPath, + dmcQuestionWithBaseDomain, ]), ]; }; diff --git a/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts b/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts index 4d4ed3d602..f51d72f950 100644 --- a/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts +++ b/apps/backend/src/models/questionTypes/DynamicMultipleChoice.ts @@ -29,6 +29,7 @@ export const dynamicMultipleChoiceDefinition: Question { expect(options).toEqual(['option1', 'option2']); }); + it('Should return options if selected useBaseDomain', async () => { + process.env = { + ...process.env, + BASE_URL: 'http://mocked.example.com', + }; + + jest + .spyOn(global, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url === 'http://mocked.example.com/getListOfCountries') { + return Promise.resolve({ + json: () => Promise.resolve(['option1', 'option2']), + ok: true, + } as Response); + } else { + return Promise.reject(new Error('Unknown URL')); + } + }); + + const options = await templateQueries.getDynamicMultipleChoiceOptions( + dummyUserWithRole, + 'dmcQuestionWithBaseDomain' + ); + + expect(options).toEqual(['option1', 'option2']); + }); it('should use the question template relation if a template ID is supplied', async () => { const templateDataSource = container.resolve( Tokens.TemplateDataSource diff --git a/apps/backend/src/queries/TemplateQueries.ts b/apps/backend/src/queries/TemplateQueries.ts index b054ee4da5..d93abdfd7f 100644 --- a/apps/backend/src/queries/TemplateQueries.ts +++ b/apps/backend/src/queries/TemplateQueries.ts @@ -104,10 +104,11 @@ export default class TemplateQueries { if (!question) return []; const config = question.config as DynamicMultipleChoiceConfig; - if (config.url === '') return []; + const dynamicURL = constructDynamicURL(config.url, config.useBaseDomain); + if (dynamicURL === '') return []; try { - const response = await fetch(config.url, { + const response = await fetch(dynamicURL, { headers: config.apiCallRequestHeaders?.reduce( (acc, header) => ({ ...acc, @@ -166,3 +167,11 @@ export default class TemplateQueries { return this.dataSource.getQuestion(questionId); } } + +function constructDynamicURL(url: string, useBaseURL: boolean): string { + if (useBaseURL) { + return `${process.env.BASE_URL}/${url}`; + } + + return url; +} diff --git a/apps/backend/src/resolvers/queries/ServerConfigQuery.ts b/apps/backend/src/resolvers/queries/ServerConfigQuery.ts new file mode 100644 index 0000000000..8b83ba2fc0 --- /dev/null +++ b/apps/backend/src/resolvers/queries/ServerConfigQuery.ts @@ -0,0 +1,11 @@ +import { Query, Resolver } from 'type-graphql'; + +import { ServerConfig } from '../types/ServerConfig'; + +@Resolver() +export class ServerConfigQuery { + @Query(() => ServerConfig, { nullable: false }) + ServerConfig() { + return { baseURL: process.env.BASE_URL }; + } +} diff --git a/apps/backend/src/resolvers/types/FieldConfig.ts b/apps/backend/src/resolvers/types/FieldConfig.ts index 044b609d38..dd845a0bbe 100644 --- a/apps/backend/src/resolvers/types/FieldConfig.ts +++ b/apps/backend/src/resolvers/types/FieldConfig.ts @@ -156,6 +156,9 @@ export class DynamicMultipleChoiceConfig extends ConfigBase { @Field(() => String) jsonPath: string; + @Field(() => Boolean) + useBaseDomain: boolean; + @Field(() => Boolean) isMultipleSelect: boolean; diff --git a/apps/backend/src/resolvers/types/ServerConfig.ts b/apps/backend/src/resolvers/types/ServerConfig.ts new file mode 100644 index 0000000000..2ea09058e9 --- /dev/null +++ b/apps/backend/src/resolvers/types/ServerConfig.ts @@ -0,0 +1,7 @@ +import { ObjectType, Field } from 'type-graphql'; + +@ObjectType() +export class ServerConfig { + @Field(() => String) + public baseURL: string; +} diff --git a/apps/e2e/cypress/e2e/templateContext.ts b/apps/e2e/cypress/e2e/templateContext.ts index 95f08ec547..2095f43550 100644 --- a/apps/e2e/cypress/e2e/templateContext.ts +++ b/apps/e2e/cypress/e2e/templateContext.ts @@ -66,6 +66,7 @@ const dynamicMultipleChoiceQuestion = { title: faker.lorem.words(2), url: 'http://localhost:9000', jsonPath: '$.*.item', + useBaseDomain: false, answers: { arrayString: [ faker.lorem.words(3), diff --git a/apps/e2e/cypress/e2e/templatesBasic.cy.ts b/apps/e2e/cypress/e2e/templatesBasic.cy.ts index f8e65633ba..ae9c77d252 100644 --- a/apps/e2e/cypress/e2e/templatesBasic.cy.ts +++ b/apps/e2e/cypress/e2e/templatesBasic.cy.ts @@ -323,6 +323,7 @@ context('Template Basic tests', () => { { url: dynamicMultipleChoiceQuestion.url, isMultipleSelect: true, + useBaseDomain: false, } ); @@ -1547,6 +1548,7 @@ context('Template Basic tests', () => { jsonPath: '$.[*].item', isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); @@ -1567,6 +1569,7 @@ context('Template Basic tests', () => { jsonPath: dynamicMultipleChoiceQuestion.jsonPath, isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); createProposalAndClickDropdownBehavior(); @@ -1589,6 +1592,7 @@ context('Template Basic tests', () => { url: dynamicMultipleChoiceQuestion.url, isMultipleSelect: true, firstTopic: true, + useBaseDomain: false, } ); createProposalAndClickDropdownBehavior(); @@ -1614,6 +1618,7 @@ context('Template Basic tests', () => { isMultipleSelect: true, firstTopic: true, headers: { Authorization: 'Bearer 1234', 'Content-Type': 'text/' }, + useBaseDomain: false, } ); diff --git a/apps/e2e/cypress/fixtures/template_export.json b/apps/e2e/cypress/fixtures/template_export.json index 3d1957f07f..9363048cc5 100644 --- a/apps/e2e/cypress/fixtures/template_export.json +++ b/apps/e2e/cypress/fixtures/template_export.json @@ -458,6 +458,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -473,6 +474,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -785,6 +787,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1171,6 +1174,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1186,6 +1190,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] @@ -1403,6 +1408,7 @@ "variant": "dropdown", "url": "", "jsonPath": "", + "useBaseDomain": false, "isMultipleSelect": true, "apiCallRequestHeaders": [], "readPermissions": [] diff --git a/apps/e2e/cypress/types/template.d.ts b/apps/e2e/cypress/types/template.d.ts index e1b461ec46..6f4d024054 100644 --- a/apps/e2e/cypress/types/template.d.ts +++ b/apps/e2e/cypress/types/template.d.ts @@ -187,6 +187,7 @@ declare global { options?: { url?: string; jsonPath?: string; + useBaseDomain: boolean; isMultipleSelect?: boolean; type?: 'radio' | 'dropdown'; firstTopic?: boolean; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx index a453e0c2ee..68222bb28b 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/DynamicMultipleChoice/QuestionDynamicMultipleChoiceForm.tsx @@ -21,7 +21,7 @@ import InputLabel from '@mui/material/InputLabel'; import { SelectChangeEvent } from '@mui/material/Select'; import { styled } from '@mui/material/styles'; import { Field } from 'formik'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import * as Yup from 'yup'; import CheckboxWithLabel from 'components/common/FormikUICheckboxWithLabel'; @@ -34,6 +34,7 @@ import { ApiCallRequestHeader, DynamicMultipleChoiceConfig, } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; import { urlValidationSchema } from 'utils/helperFunctions'; import { useNaturalKeySchema } from 'utils/userFieldValidationSchema'; @@ -83,6 +84,14 @@ const jsonPathFieldsDocRows = [ }, ]; +const pathNameValidationSchema = Yup.string() + .matches( + /^(?!http|www)/i, + 'Provide a valid pathname, the base domain is already provided' + ) + .matches(/^(?!\/)/, 'Leading slash should not be included') + .required('Pathname is required'); + const CustomizedTableCell = styled(TableCell)(({ theme }) => ({ [`&.${tableCellClasses.head}`]: { background: theme.palette.common.black, @@ -110,6 +119,9 @@ export const QuestionDynamicMultipleChoiceForm = (props: QuestionFormProps) => { const urlValidation = urlValidationSchema(); const [showIsMultipleSelectCheckbox, setShowIsMultipleSelectCheckbox] = useState(config.variant === 'dropdown'); + const [useBaseDomain, setUseBaseDomain] = useState( + config.useBaseDomain ?? false + ); const availableVariantOptions = [ { label: 'Radio', value: 'radio' }, @@ -119,6 +131,25 @@ export const QuestionDynamicMultipleChoiceForm = (props: QuestionFormProps) => { const [isJsonPathFieldDocPopupOpen, setIsJsonPathFieldDocPopupOpen] = useState(false); + const [isBaseURLCheckBoxPopupOpen, setIsBaseURLCheckBoxPopupOpen] = + useState(false); + + const api = useDataApi(); + const [serverConfig, setServerConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const { ServerConfig } = await api().getServerConfig(); + setServerConfig(ServerConfig.baseURL); + } catch (err) { + console.error('Failed to fetch server config', err); + } + }; + + fetchConfig(); + }, [api]); + return ( { config: Yup.object({ required: Yup.bool(), variant: Yup.string().required('Variant is required'), - url: urlValidation, + + url: Yup.string().when('useBaseDomain', { + is: true, + then: (schema) => schema.concat(pathNameValidationSchema), + otherwise: (schema) => schema.concat(urlValidation), + }), + + useBaseDomain: Yup.bool(), jsonPath: Yup.string(), apiRequestHeaders: Yup.array(), }), })} > - {() => ( + {({ setFieldValue }) => ( <> { Link - - - - +
+ {useBaseDomain && ( + + {`${serverConfig}/`} + + )} + + +
+
+ ) => { + const checked = e.target.checked; + setUseBaseDomain(checked); + setFieldValue('config.useBaseDomain', checked); + }} + /> + + setIsBaseURLCheckBoxPopupOpen(true)} + > + + + setIsBaseURLCheckBoxPopupOpen(false)} + aria-labelledby="customized-dialog-title" + > + +
+ Instead of providing a full URL to retrieve a list for + your question, this option allows you to use the current + server's domain and specify only the relative path. +
+
+ This is particularly useful for GraphQL queries that + access the database. Please note that while the domain + displayed in the UI reflects the current deployment, it + is not stored. At runtime, the system will automatically + resolve and apply the current servers domain. +
+
+ + + +
+
+
+
+ + JsonPath { const config = props.questionRel.config as DynamicMultipleChoiceConfig; const [showIsMultipleSelectCheckbox, setShowIsMultipleSelectCheckbox] = useState(config.variant === 'dropdown'); + const [useBaseDomain, setUseBaseDomain] = useState( + config.useBaseDomain ?? false + ); + const [isBaseURLCheckBoxPopupOpen, setIsBaseURLCheckBoxPopupOpen] = + useState(false); const availableVariantOptions = [ { label: 'Radio', value: 'radio' }, @@ -39,6 +62,22 @@ export const QuestionTemplateRelationDynamicMultipleChoiceForm = ( const urlValidation = urlValidationSchema(); + const api = useDataApi(); + const [serverConfig, setServerConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const { ServerConfig } = await api().getServerConfig(); + setServerConfig(ServerConfig.baseURL); + } catch (err) { + console.error('Failed to fetch server config', err); + } + }; + + fetchConfig(); + }, [api]); + return ( schema.concat(pathNameValidationSchema), + otherwise: (schema) => schema.concat(urlValidation), + }), }), })} > - {() => ( + {({ setFieldValue }) => ( <> @@ -103,17 +146,94 @@ export const QuestionTemplateRelationDynamicMultipleChoiceForm = ( Link - + +
+ {useBaseDomain && ( + + {`${serverConfig}/`} + + )} + + +
+ +
+ ) => { + const checked = e.target.checked; + setUseBaseDomain(checked); + setFieldValue('config.useBaseDomain', checked); + }} + /> + + setIsBaseURLCheckBoxPopupOpen(true)} + > + + + setIsBaseURLCheckBoxPopupOpen(false)} + aria-labelledby="customized-dialog-title" + > + +
+ Instead of providing a full URL to retrieve a list for + your question, this option allows you to use the current + server's domain and specify only the relative path. +
+
+ This is particularly useful for GraphQL queries that + access the database. Please note that while the domain + displayed in the UI reflects the current deployment, it + is not stored. At runtime, the system will automatically + resolve and apply the current servers domain. +
+
+ + + +
+
+
- - + + + JsonPath