Skip to content

Separate forms app#1608

Open
garethbowen wants to merge 67 commits into
getodk:masterfrom
garethbowen:separate-forms-app
Open

Separate forms app#1608
garethbowen wants to merge 67 commits into
getodk:masterfrom
garethbowen:separate-forms-app

Conversation

@garethbowen

@garethbowen garethbowen commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Things left to do...

  • The asset bundles are still combined so users of either app end up downloading all the code for both. I think I have a way forward but this doesn't impact the majority of this review so I thought it was worth a first look
  • The unit tests are being difficult, because they rely on a lot of the infrastructure of central to run. I got some running on vitest but the solution might be to go back to karma and do the bare minimum.
  • Depends on associated central patch: Separate forms app central#1960

getodk/web-forms#535

What has been done to verify that this works as intended?

Why is this the best possible solution? Were any other approaches considered?

How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?

Does this change require updates to user documentation? If so, please file an issue here and include the link below.

Before submitting this PR, please make sure you have:

  • run npm run test and npm run lint and confirmed all checks still pass OR confirm CircleCI build passes
  • verified that any code or assets from external sources are properly credited in comments or that everything is internally sourced
  • run npm run changeset to generate a changeset file for changes that should be included in the release notes

@garethbowen garethbowen requested a review from latin-panda June 8, 2026 06:06
@changeset-bot

changeset-bot Bot commented Jun 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10d4efc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@getodk/central-frontend Minor
@getodk/forms Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@latin-panda latin-panda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's great to see this coming along! I did an initial review, and tomorrow I'll take another deep look.

Comment thread apps/forms/src/components/submission.vue
Comment thread nginx-conf/nginx.conf
Comment thread vite.config.js Outdated
configureServer(server) {
server.middlewares.use((req, res, next) => {
// must match the regex paths defined in Nginx
const newSubmissionUrlRegex = /^\/projects\/\d+\/forms\/[^/]+(?:\/draft)?(?:\/preview|\/submissions\/new(?:\/offline)?\/?|\/submissions\/[^/]+\/edit)\/?/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same feedback from here

Is there a way to define this in one place and share it in the different configs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if that'll be possible between nginx and vue, but I'm sure we can at least get the nginx using the same config. There's an ongoing convo about this because the mismatch between dev and prod is risky. I think the docker work will help us move in that direction.

I've added comments and renamed the regexes to be more clear.

instanceId: String
});

const emit = defineEmits(['loaded']);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who calls this emit? Is it dead code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! Thank you.

const { status, statusText, headers } = response;

const fetchHeaders = new Headers();
for (const [key, value] of Object.entries(headers)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response comes from the native fetch, so the headers are a Headers type that should be iterated using its entries function.

Suggested change
for (const [key, value] of Object.entries(headers)) {
for (const [key, value] of headers.entries()) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread apps/central/src/router.js Outdated
router.beforeEach(to => {
if (to.fullPath.startsWith('/#/')) {
// This must do a full page refresh because the page may end up in a different vue app
window.location.href = to.fullPath.substring(2);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user's back button takes them to the hash URL, which redirects again. The replace swaps the entry.
Also, it can return false here to cancel any forward loading in central (if any callback is reacting to navigation events)

Suggested change
window.location.href = to.fullPath.substring(2);
window.location.replace(to.fullPath.slice(2));
return false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Thanks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out returning false cancels the request so which caused playwright to bail. Because this isn't a security issue I've left it continuing on which might be a little bit of a waste but means the request completes cleanly.


#web-form-renderer-submission-modal pre {
white-space: pre-wrap;
if (isEdit.value) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preferably wrap in an init function or onMount hook

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread apps/forms/index.html Outdated
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<!--
Copyright 2017 ODK Central Developers

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There have been conversations in Slack about this (maybe in the team channel?). I think we resolved to drop this for new development, since this is a new app. It could be dropped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you decide to leave it here and remove it from the other files? Or perhaps it has not been pushed yet? 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! Removed that one too.

@garethbowen

Copy link
Copy Markdown
Contributor Author

@latin-panda I've resolved all your comments. I've also...

  • fixed the bundling so the central app no longer downloads web-forms
  • added error handling so if your session times out you get redirected to the login page

@latin-panda latin-panda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't finished yet, but I'm submitting the first part of today's review

let body;
const contentType = headers.get('content-type');
if (contentType && (contentType.includes('application/json') || contentType.includes('application/geo+json'))) {
body = await response.json();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In L254, this body is passed to the Response constructor, but it doesn't accept the data type that .json() produces and ends up converting it to a string ("[object Object]"). That's probably why it initially used JSON.stringify(body).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow... looking at this now I don't think we need this function at all. It was used to transform an axios response into a native response, but because it's all native that's not necessary.

Removed!

url = `/v1/projects/${props.form.projectId}/forms/${props.form.xmlFormId}/submissions/${props.instanceId}`;
method = 'PUT';
} else {
url = `/v1/projects/${props.form.projectId}/forms/${props.form.xmlFormId}${draftPath}/submissions`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When does it append a st query parameter for public-link?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question for L59, L151, L262, 268, 279.

What do you think about having a URL builder and unit testing it? Looking to centralize it

@garethbowen garethbowen Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell it's only used for fetching the formXml which is handled in the api. After that the st isn't needed.

Regarding centralising it, I think that's a good idea. At the moment these are only used once each so I just inlined them.

clearFormCallback({ next: POST_SUBMIT__NEW_INSTANCE });
};
const submissionPath = () => {
return `/v1/projects/${props.form.projectId}/forms/${props.form.xmlFormId}/submissions/${props.instanceId}`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suffix is used only for api calls, for L116 we need it without /v1/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Amazing that it worked at all... removed now.

if (response.ok) {
const data = await response.json();
result = { success: true, data };
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should assign result when response.ok is false to ensure consistent handling in subsequent flows after this code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! Done.

submissionModal.hide();
const getAttachment = (requestUrl: URL) => {
const encodedName = encodeURIComponent(requestUrl.pathname.split('/').pop()!);
const draftPath = '';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In L64, there's a check for props.form.draft that we could use here as well.

Or does it even need draftPath? What happens if there are attachments for form drafts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right! It is expected by the API. Fixed.

} catch (e) {
if (e instanceof RequestError && e.statusCode >= 401 && e.statusCode < 404) {
// not logged in
window.location.href = '/login?next=/wf' + window.location.pathname;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it needs to preserve window.location.search for the navigation after login

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Done.

xform.value = await getFormXml(formConfig.projectId, formConfig.xmlFormId, formConfig.draft, st)
webFormsEnabled.value = true;
} else {
if (offline) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Little comment to not lose track.

Suggested change
if (offline) {
if (offline) {
// TODO: Update once Web Forms has support for offline

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +181 to +186
<template v-else-if="webFormsEnabled">
<WebFormRenderer :form="form!" :xform="xform!" :instance-id="instanceId" :action-type="props.actionType ?? 'new'"/>
</template>
<template v-else>
<EnketoIframe :form="form!" :enketo-id="enketoId" :action-type="props.actionType ?? 'new'"/>
</template>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem right to default to new here. Instead of silently prompting the user to create a new submission, it would be better to display an error message and an option to close the page. For example, if editing isn't supported by Enketo, it could show: Editing is only supported for Web Forms. Please contact the administrator.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think defaulting to 'new' is correct in the case when no action type is provided.

However you're right that we should catch the case where they're editing in Enketo. I've caught that out and given a non-specific error above (404). A specific error is new functionality - I'm happy to raise an issue if you think it's worth that.

Comment thread apps/forms/package.json
"vue-i18n": "^10.0.7",
"vue-router": "^4.6.3"
},
"devDependencies": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way we can declare primevue and the local packages (web-forms, xforms-engine) as direct dependencies here? It helps a lot with readability so we know exactly what the app consumes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not... 🤔
Leaving the comment open just in case there's an idea

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added primevue because that one we should definitely have.

I'm unsure whether we should include monorepo packages in the dependencies...

Comment thread apps/forms/src/utils/api.ts Outdated
const url = `/v1/projects/${projectId}/forms/${formId}${draftPath}.xml${qs}`;
const response = await fetch(url);
if (!response.ok) {
const result = await response.json();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If response.json() breaks (response.ok is falsy, maybe corrupted), is there any other catch to handle it nicely?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. We could catch it here and wrap it up with a 500 error or something? But I think letting it go and be handled by the caller is ok too.

@latin-panda latin-panda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the second part of today's review.
Mainly small safe feedback that improves readability

Comment on lines 37 to 39

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (returnUrlPascalCase && typeof returnUrlPascalCase === 'string') {
return returnUrlPascalCase;
}
if (returnUrl && typeof returnUrl === 'string') {
return returnUrl;
}
return null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

<script setup lang="ts">
import { ref, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { type Form, getFormByEnketoId, getFormByFormId, getFormXml, getProject, type Project, queryString, RequestError } from '../utils/api.ts';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is lint or prettify running? This is very long. It should have been caught and fixed automatically

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment it's using central's config for eslint, and no prettify at all. As most of the code came from central I took the path of least resistance. I've manually broken the line here.

I'm looking forward to getting web-forms, forms, and central on the same setting.

});
if (inst) {
inst.appContext.app.use(PrimeVue, { theme: { preset: odkThemePreset, options: { darkModeSelector: false } } });
// TODO this should work instead of the above, but haven't got it working yet...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this planned for this round or for a follow-up PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment it's working so I'm ok leaving it in. If you have any ideas please let me know!

Comment on lines +214 to +217
const handleSubmit = async (
payload: MonolithicInstancePayload,
clearFormCallback: Function
) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spacing looks off to me

Suggested change
const handleSubmit = async (
payload: MonolithicInstancePayload,
clearFormCallback: Function
) => {
const handleSubmit = async (
payload: MonolithicInstancePayload,
clearFormCallback: Function
) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

if (isEdit.value) {
fetchSubmissionAttachments().then(() => {
loading.value = false;
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think lint catches these too

Suggested change
})
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linting is a mess... I'm using the old central eslint config but now because I'm using typescript it's not happy. I'm going to look at how hard it would be to conform with web-forms style...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I've switched to using the same config as web-forms packages...

Comment thread apps/forms/src/i18n.js
export const i18n = createI18n({
locale: fallbackLocale,
fallbackLocale,
messages: { },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it correct that messages is empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the messages required are inline in the vue files. This will change if/when we modernise the i18n.

Comment thread apps/forms/src/main.ts
import { i18n } from './i18n'

const app = createApp(Forms as Component);
app.use(webFormsPlugin);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why defining Primevue in web-form-renderer.vue L35 instead of here main.ts? 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because primevue isn't needed for enketo rendering so I wanted to leave it out if possible.

Comment thread apps/forms/index.html Outdated
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<!--
Copyright 2017 ODK Central Developers

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you decide to leave it here and remove it from the other files? Or perhaps it has not been pushed yet? 😅

Comment thread apps/forms/package.json
"vue-i18n": "^10.0.7",
"vue-router": "^4.6.3"
},
"devDependencies": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not... 🤔
Leaving the comment open just in case there's an idea

Comment thread bin/check-bundle-size.js Outdated
Comment on lines +62 to +63
case 'forms.js': return size > 2_000_000;
case 'web-form.js': return size > 2_000_000; // Matches web-form-renderer.js

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this L63 the web forms plugin or the web-forms renderer of app/forms? If the latter, it can be lowered. If the former, we need to update the comment.

When running the script:
//:build:web: dist/assets/web-form-renderer-CycNBHBr.js 985.19 kB │ gzip: 291.12 kB
//:build:web: dist/forms/forms-fTznoukg.js 1,848.76 kB │ gzip: 779.64 kB

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I reduced the size to 1MB.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Funny side note: The reason it matches web-form-renderer.js is because the word "renderer" is 8 letters long which exactly matches the regex which is designed to strip off the hash suffix.

@garethbowen garethbowen requested a review from latin-panda June 10, 2026 04:18
@garethbowen garethbowen marked this pull request as ready for review June 10, 2026 04:18
@garethbowen

Copy link
Copy Markdown
Contributor Author

@latin-panda Thank you for your continued reviews! I've responded to your comments, and this is good to go again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants