Skip to content

feat: allow to specify opening date - #3810

Open
susnux wants to merge 3 commits into
mainfrom
feat/form-opening-date
Open

susnux wants to merge 3 commits into
mainfrom
feat/form-opening-date

Conversation

@susnux

@susnux susnux commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Allow to configure a date when the form will be opened, similar to the expiry date.

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI (N/A)

Add an `opens` timestamp to forms, the counterpart to `expires`.
Before that time a form does not accept responses and the public
link shows a "not yet open" page instead of the questions.
An opening date must be before the expiration date.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Add an opening date picker to the form settings. Forms that are not
yet open show when they will open, both in the navigation and in
place of the questions. The opening and expiration date pickers
prevent selecting an opening date after the expiration date.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
@susnux susnux added this to the 5.5 milestone Oct 30, 2026
@susnux
susnux requested a review from Chartman123 October 30, 2026 12:17
@susnux susnux added enhancement New feature or request 3. to review Waiting for reviews labels Oct 30, 2026
@codecov

codecov Bot commented Oct 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.05263% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/Migration/Version050500Date20261030000000.php 0.00% 11 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Chartman123 Chartman123 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.

Two comments and one typo/incorrect wording

Comment thread lib/Controller/ApiController.php Resolved
/**
* Throws if the opening date is invalid or not before the expiration date
*/
private function checkOpeningDateUpdate(Form $form, array $keyValuePairs): void {

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.

Do we need the same check for expiration date?

.fromNow()
return t('forms', 'Opens {relativeDate}', { relativeDate })
}
if (props.form.expires) {

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.

Only show expiration for open forms?


const opens = moment().add(1, 'hour')
if (formExpires.value && opens.unix() >= props.form.expires) {
emit('update:formProp', 'opens', moment().unix())

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

✅ Verified live at dd588c85: on an expired form the PATCH returns 400, an "Error while saving form" toast appears, and the switch stays on, reading "Opened on …". ❌ The days in the picker are not disabled: disabledDate is not a prop of NcDateTimePicker 9 (see the SettingsSidebarTab.vue:613 thread).

When an expiration date is already set and now + 1h >= expires, this falls back to opens = now. If the form has already expired (expires < now), that value is still >= expires, so the PATCH fails with 400 Opening date must be before expiration date. The switch flips on locally anyway, because the error is only toasted and not rolled back and every day in the picker is disabled by isInvalidOpeningDay. When it does succeed, opens = now is a no-op opening date that immediately reads "Opened on …". It would be better to refuse to enable the switch, or show a hint, when there is no future window before expires.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One more case on the same branch: when the expiration date is less than an hour away (but not yet passed), this falls back to opens = now. The PATCH succeeds, so the switch shows "on" and the picker reads "Opened on {date}", but the form is already open and responses are accepted immediately. The owner believes an opening date is in force when it has no effect. It is probably better to not enable the switch (or to show a hint) when there is no valid future opening time before expires.

Comment thread src/views/Submit.vue
return props.form.expires > 0 && moment().unix() > props.form.expires
})

const isNotYetOpen = computed<boolean>(() => {

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

✅ Verified live at dd588c85: a logged-in respondent and an anonymous visitor on the public link both still saw "Form not yet open" 20–25 s after the opening time. A reload showed the form.

computed caches moment().unix(). It only re-evaluates when props.form.opens changes, not when time passes. A respondent who loads the page shortly before the opening time keeps seeing "Form not yet open" until they reload manually, even though the server is already accepting submissions. The same holds for the server-rendered public notyetopen empty-content page. A cheap fix is a timer, or a reload scheduled at opens.

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 same for the expiration I guess? So somenone could currently be on a Form that is about to expire and then can't send it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, you could also look at it the other way around in the opposite scenario which has the same effect, true.

So can be fine as is or cosmetic for sure.

Comment thread src/views/Submit.vue
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="isNotYetOpen"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This branch also applies to the owner or editors opening the form via "View". Together with loadFormForSubmission throwing This form is not yet taking answers, nobody can preview or test-submit a scheduled form before it opens, and preparing a form before its opening time is the main use case for this feature. Consider skipping the not-yet-open state, or showing it only as an info banner, for users with edit permission.

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.

Also the same for expired forms...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

True

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So keep as is consistency reason or change in both cases (also for consistency reason)? Same for the above I guess

}

if (array_key_exists('opens', $keyValuePairs)
&& (!is_int($keyValuePairs['opens']) || $keyValuePairs['opens'] < 0)) {

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

⚠️ Partly corrected after a live check (see the reply below): the opens/expires asymmetry is confirmed; the expires: null part is not, because it is stored as 0.

The validation is lopsided. opens is rejected unless it is a strict PHP int, so a numeric string from a form-encoded client gets a 400. expires gets no type or sign check at all: {"expires": -5} passes this check and is stored as -5. {"expires": null} passes this check (null falls back to the stored value through ??), and then reaches setExpires. Either validate both keys the same way, or cast opens the way the entity addType(integer) already does.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Partial correction after a live check (test instance built from dd588c85):

  • confirmed: opens sent as a numeric string ("1793384000") gets 400 "Invalid opening date", while expires: -5 is accepted and stored as -5;
  • not reproduced: {opens: <now+1h>, expires: null} returns 200, but the stored expires is 0, not null, so the form is not treated as expired. Please ignore that part of the comment.

The point that stands is the asymmetry: expires gets no type or sign check, while opens does.

return null;
}

$table->addColumn('opens', Types::INTEGER, [

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

✅ Verified live at dd588c85 (MariaDB 10.6): a 2039 opening date returns 500 with SQLSTATE[22003] (see the reply below).

Types::INTEGER is a 32-bit signed column, and the date picker allows any future date. Choosing an opening date after 2038-01-19 overflows the column (on MariaDB 10.6 this is a 500 with an out-of-range error; other databases were not tested). expires has the same limit, but a new column is the cheap moment to use Types::BIGINT.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I checked this on a running instance (MariaDB 10.6). It is worse than a silent clamp: PATCH {opens: 2200000000} (2039-09-17) returns 500 Internal Server Error with SQLSTATE[22003]: Numeric value out of range: 1264 Out of range value for column 'opens', and the date picker lets the owner pick that date. Using Types::BIGINT for the new column avoids it. Pre-existing expires has the same limit, but there's no reason to add a second such column.

*
* @param datetime the datepicker Date
*/
const isInvalidOpeningDay = (datetime: Date): boolean =>

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

❌ Corrected after a live check: these validators are never called, so the point below about them running on every calendar cell is wrong (struck through).

disabledDate and disabledTime are not props of NcDateTimePicker 9. Its declared props include min and max (both Date), but no disabledDate/disabledTime (checked in @nextcloud/vue 9.13.1's NcDateTimePicker.vue.d.ts). So isInvalidOpeningDay, isInvalidOpeningTime, isInvalidExpirationDay and isInvalidExpirationTime have no effect. On a running instance built from dd588c85, the opening picker of an expired form showed no disabled day at all, past days included. The commit's promise that the pickers "prevent selecting an opening date after the expiration date" therefore doesn't hold in the UI. Only the server check in checkOpeningDateUpdate catches it, with an "Error while saving form" toast.

The expiration picker passes the same unsupported props (notBeforeToday/notBeforeNow) on main, so the gap is older than this PR, but the new cross-checks between the two pickers depend on it. Suggested fix: express the limits through :min / :max (e.g. :min="new Date()" and :max="expirationDate" on the opening picker), which also makes the per-cell callbacks unnecessary.


Original comment:

The four new validators rebuild moment(props.form.expires, "X") and moment(props.form.opens, "X") on every calendar cell and time-slot callback. The expirationDate and openingDate computeds already hold exactly these Dates, so datetime > expirationDate.value and similar comparisons would do the same job with less code (the day variants still need startOf/endOf).

Comment thread src/views/Submit.vue
return t('forms', 'Expires {relativeDate}.', { relativeDate })
})

const openingMessage = computed<string>(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The "not yet open" logic is now duplicated three times (Submit.vue isNotYetOpen, AppNavigationForm formSubtitle, SettingsSidebarTab isOpened), and this message string is duplicated from the FormsEmptyContent notyetopen model. A shared helper or composable next to the existing expiry checks would keep them in sync, especially if the reactive-time fix above is applied.

Comment thread lib/FormsMigrator.php
$form->setOwnerId($user->getUID());
$form->setAccess($formData['access']);
$form->setExpires($formData['expires']);
$form->setOpens($formData['opens'] ?? 0);

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ℹ️ Not verified live: the test instance has no user_migration app, so this comes from the code only.

The new invariant (opens >= 0 and opens < expires when both are set) is enforced only in ApiController::checkOpeningDateUpdate. Import goes through here unchecked, so an export with "opens": 1800000000, "expires": 1700000000 creates a form that can never open. Moving the invariant into a shared helper (for example in FormsService), and calling it from both the update and import paths, would close this gap.

Comment on lines +68 to +69
<NcDateTimePicker
id="opensDatetimePicker"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Accessibility: with this PR the settings sidebar contains two date pickers, and both have the same accessible name. Neither the opening picker nor the expiration picker passes ariaLabel, so each input falls back to the library default ("Datepicker input", or "Eingabe Datumsauswahl" in German), and the "Set opening date" / "Set expiration date" switch above it is not programmatically associated with the input. A screen-reader user tabbing through the sidebar hears two identical "Datepicker input" text boxes and can only tell them apart by their current value text (WCAG 2.4.6 Headings and Labels, 1.3.1 Info and Relationships).

I checked this on a running instance built from dd588c85. The input's accessibility tree was textbox "Eingabe Datumsauswahl", with no aria-labelledby and no <label>. axe does not flag it, because each input technically has a name.

NcDateTimePicker (9.13.1) already has the props for this, so the fix is two lines per picker:

Suggested change
<NcDateTimePicker
id="opensDatetimePicker"
<NcDateTimePicker
id="opensDatetimePicker"
:ariaLabel="t('forms', 'Opening date')"
:ariaLabelMenu="t('forms', 'Choose opening date')"

The same goes for expiresDatetimePicker at line 90 ('Expiration date' / 'Choose expiration date'). That picker had this gap before this PR, but it only becomes ambiguous now that there are two of them.

return $this->provideEmptyContent(Constants::EMPTY_EXPIRED, $form);
}

if (!$this->formsService->hasFormOpened($form)) {

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

✅ Verified live at dd588c85: before the opening date, a respondent the form is shared with gets 200 with all questions from GET /forms/{id} (submitting returns 403). The Submit view's own request carries the question text too.

This check only protects the rendered page (and loadFormForSubmission protects the submit endpoints). ApiController::getForm (GET /api/v3/forms/{formId}, line 275) only calls getFormIfAllowed($formId, Constants::PERMISSION_SUBMIT) and never looks at opens, so before the opening date anyone the form is shared with still gets the full form, including all questions and options.

Scenario: an owner shares a quiz or exam form directly with users and sets it to open tomorrow. A shared user calls GET /ocs/v2.php/apps/forms/api/v3/forms/{id} (or just looks at the Submit view's network request) and reads every question before the form opens. The "Form not yet open" empty content only hides them in the UI.

If keeping questions confidential until the opening date is part of the feature's promise, getForm should not return the questions to users with only submit permission while !hasFormOpened($form). Owners and users with edit or results permission still need them. Otherwise it's worth stating explicitly that the opening date only gates submissions, not visibility.

throw new OCSForbiddenException('This form is no longer taking answers');
}

if (!$this->hasFormOpened($form)) {

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

✅ Verified live at dd588c85: after a future opening date was set, the respondent's /{hash}/submit/{submissionId} page shows "Form not yet open", and PUT /submissions/{id} returns 403.

loadFormForSubmission is used by newSubmission, updateSubmission (ApiController.php:1546) and uploadFiles (:1742), so this check also blocks editing existing submissions while a form is not yet open.

Scenario: a live form with allowEditSubmissions = true has already received responses. The owner sets opens to next Monday to pause it. Respondents opening /{hash}/submit/{submissionId} now see "Form not yet open", and a PUT to update their submission returns 403 "This form is not yet taking answers".

That may be intended ("paused means paused"), but it is a behaviour change for existing respondents and isn't covered by a test either way. If it's not intended, the check should apply only to new submissions (and their uploads), not to updateSubmission.

const expirationDate = computed(() =>
moment(props.form.expires, 'X').toDate(),
)
const isOpened = computed(() => props.form.opens <= moment().unix())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

isOpened is a computed over moment().unix(), so it is cached and only re-evaluates when props.form.opens changes, not when time passes. If the sidebar stays open across the opening time, the picker label keeps saying "Opens on {date}" after the form has opened, until something else changes opens or the component remounts.

Same root cause as isNotYetOpen in Submit.vue. A shared reactive "now" (e.g. a ref updated by a timer, or useNow if already available) would fix both, or this could be a plain function evaluated in stringifyOpeningDate, since the picker calls format on render anyway.

@AndyScherzinger

AndyScherzinger commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Review summary: live verification at dd588c85

I deployed this branch on a Nextcloud dev instance with a clean Forms install. I tested the inline findings with real requests and browser runs: an owner, a separately shared respondent and an anonymous public-link visitor, with the edit sidebar and submit view driven with Playwright. Rows marked code-level were not tested live. Each inline comment now starts with its verification status.

Finding Live result
Questions readable through the API before the opening date (PageController.php:206 thread) ✅ a shared respondent's GET /forms/{id} returns 200 with all questions, and the Submit view's own request carries them; submitting returns 403
Owner/editors can't preview or test before opening ✅ the owner's View shows "Form not yet open" with no questions; the owner's submit returns 403
Pausing with a future opening date blocks editing existing submissions ✅ /{hash}/submit/{submissionId} shows "Form not yet open"; PUT /submissions/{id} returns 403
"Not yet open" view doesn't update at the opening time ✅ for a logged-in respondent and on the public link, still shown 20–25 s after opening; fixed by a reload
Sidebar label keeps "Opens on" after opening ✅ the navigation entry also kept "Opens in a few seconds"
New: the date validators are never called ✅ disabledDate/disabledTime aren't NcDateTimePicker 9 props; no day is disabled, past days included (SettingsSidebarTab.vue:613 thread, which replaces the earlier performance remark)
Enabling the opening date on an expired form ✅ PATCH 400, "Error while saving form" toast, the switch stays on reading "Opened on …"; ❌ the days are not disabled (see the previous row)
Enabling it less than an hour before expiry ✅ opens is saved as "now", the switch is on, the form is already open
32-bit opens column ✅ worse than described: a 2039 date returns 500 (SQLSTATE[22003], MariaDB 10.6)
opens/expires validation asymmetry ✅ strict int vs. expires: -5; ❌ the expires: null part is withdrawn (stored as 0)
Date pickers share one accessible name ✅ seen in the accessibility tree
Import bypasses the opens < expires rule (FormsMigrator.php) code-level: user_migration isn't installed on the test instance
"Not yet open" logic copied three times code-level (maintainability)

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

Labels

3. to review Waiting for reviews enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants