Repository navigation
Conversation
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>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Chartman123
left a comment
There was a problem hiding this comment.
Two comments and one typo/incorrect wording
| /** | ||
| * Throws if the opening date is invalid or not before the expiration date | ||
| */ | ||
| private function checkOpeningDateUpdate(Form $form, array $keyValuePairs): void { |
There was a problem hiding this comment.
Do we need the same check for expiration date?
| .fromNow() | ||
| return t('forms', 'Opens {relativeDate}', { relativeDate }) | ||
| } | ||
| if (props.form.expires) { |
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
✅ 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:disabledDateis not a prop ofNcDateTimePicker9 (see theSettingsSidebarTab.vue:613thread).
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 . When it does succeed, isInvalidOpeningDayopens = 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.
There was a problem hiding this comment.
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.
| return props.form.expires > 0 && moment().unix() > props.form.expires | ||
| }) | ||
|
|
||
| const isNotYetOpen = computed<boolean>(() => { |
There was a problem hiding this comment.
✅ 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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| </template> | ||
| </NcEmptyContent> | ||
| <NcEmptyContent | ||
| v-else-if="isNotYetOpen" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Also the same for expired forms...
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
⚠️ Partly corrected after a live check (see the reply below): theopens/expiresasymmetry is confirmed; theexpires: nullpart is not, because it is stored as0.
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. Either validate both keys the same way, or cast {"expires": null} passes this check (null falls back to the stored value through ??), and then reaches setExpires.opens the way the entity addType(integer) already does.
There was a problem hiding this comment.
Partial correction after a live check (test instance built from dd588c85):
- confirmed:
openssent as a numeric string ("1793384000") gets 400 "Invalid opening date", whileexpires: -5is accepted and stored as-5; - not reproduced:
{opens: <now+1h>, expires: null}returns 200, but the storedexpiresis0, notnull, 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, [ |
There was a problem hiding this comment.
✅ Verified live at
dd588c85(MariaDB 10.6): a 2039 opening date returns 500 withSQLSTATE[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.
There was a problem hiding this comment.
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 => |
There was a problem hiding this comment.
❌ 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).
| return t('forms', 'Expires {relativeDate}.', { relativeDate }) | ||
| }) | ||
|
|
||
| const openingMessage = computed<string>(() => { |
There was a problem hiding this comment.
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.
| $form->setOwnerId($user->getUID()); | ||
| $form->setAccess($formData['access']); | ||
| $form->setExpires($formData['expires']); | ||
| $form->setOpens($formData['opens'] ?? 0); |
There was a problem hiding this comment.
ℹ️ Not verified live: the test instance has no
user_migrationapp, 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.
| <NcDateTimePicker | ||
| id="opensDatetimePicker" |
There was a problem hiding this comment.
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:
| <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)) { |
There was a problem hiding this comment.
✅ Verified live at
dd588c85: before the opening date, a respondent the form is shared with gets 200 with all questions fromGET /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)) { |
There was a problem hiding this comment.
✅ Verified live at
dd588c85: after a future opening date was set, the respondent's/{hash}/submit/{submissionId}page shows "Form not yet open", andPUT /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()) |
There was a problem hiding this comment.
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.
Review summary: live verification at
|
| 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) |
Allow to configure a date when the form will be opened, similar to the expiry date.
🤖 AI (if applicable)