Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions allterrain-forms.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@
define( 'ATF_STATUS_SPAM', 'atf-spam' );
define( 'ATF_STATUS_PARTIAL', 'atf-partial' );

/**
* The status an archived *form* holds.
*
* A real post status for the same reasons the entry statuses are: the post
* keeps its meta, its revisions and its trash behaviour, and nothing about it
* is copied anywhere to make it disappear from the pickers.
*/
define( 'ATF_STATUS_ARCHIVED', 'atf-archived' );

require_once ATF_DIR . 'includes/shell-api.php';
require_once ATF_DIR . 'includes/post-types.php';
require_once ATF_DIR . 'includes/fields.php';
Expand All @@ -184,6 +193,7 @@
require_once ATF_DIR . 'includes/confirmations.php';
require_once ATF_DIR . 'includes/actions.php';
require_once ATF_DIR . 'includes/entries.php';
require_once ATF_DIR . 'includes/archive.php';
require_once ATF_DIR . 'includes/analytics.php';
require_once ATF_DIR . 'includes/dev-mode.php';
require_once ATF_DIR . 'includes/demo-data.php';
Expand Down
45 changes: 45 additions & 0 deletions assets/css/builder.css
Original file line number Diff line number Diff line change
Expand Up @@ -3069,3 +3069,48 @@ os-checkbox-label.atfb-check {
border-radius: 4px;
background: var( --atfb-well );
}

/* -------------------------------------------------------------------------- */
/* The archive's door in the New form dialog */
/* -------------------------------------------------------------------------- */

.atfb-archived {
margin-block-start: 4px;
}

.atfb-archived__title {
margin: 14px 0 8px;
padding-block-start: 14px;
border-block-start: 1px solid var( --atfb-border );
font-size: 12px;
font-weight: 600;
color: var( --atfb-muted );
}

.atfb-archived__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var( --atfb-border );
border-radius: var( --atfb-radius );
background: var( --atfb-well );
}

.atfb-archived__row + .atfb-archived__row {
margin-block-start: 8px;
}

.atfb-archived__meta {
display: flex;
flex-direction: column;
gap: 2px;
min-inline-size: 0;
}

.atfb-archived__meta strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
6 changes: 6 additions & 0 deletions assets/js/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ var allTerrainFormsAnalytics = function(exports) {
const api = {
config: () => get("/config"),
listForms: () => get("/forms"),
/** The other side of the archive: the retired forms, same shape. */
listArchivedForms: () => get("/forms?archived=1"),
/** Retires a form — it leaves every picker, its entries leave every list, its stats go with it. */
archiveForm: (id) => post(`/forms/${id}/archive`, {}),
/** Brings an archived form back, entries and stats included, in its pre-archive status. */
unarchiveForm: (id) => post(`/forms/${id}/unarchive`, {}),
getForm: (id) => get(`/forms/${id}`).then(withObjectOverrides),
createForm: (body) => post("/forms", body).then(withObjectOverrides),
updateForm: (id, body) => post(`/forms/${id}`, body).then(withObjectOverrides),
Expand Down
2 changes: 1 addition & 1 deletion assets/js/analytics.min.js

Large diffs are not rendered by default.

123 changes: 123 additions & 0 deletions assets/js/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,12 @@ var allTerrainFormsBuilder = function(exports) {
const api = {
config: () => get("/config"),
listForms: () => get("/forms"),
/** The other side of the archive: the retired forms, same shape. */
listArchivedForms: () => get("/forms?archived=1"),
/** Retires a form — it leaves every picker, its entries leave every list, its stats go with it. */
archiveForm: (id) => post(`/forms/${id}/archive`, {}),
/** Brings an archived form back, entries and stats included, in its pre-archive status. */
unarchiveForm: (id) => post(`/forms/${id}/unarchive`, {}),
getForm: (id) => get(`/forms/${id}`).then(withObjectOverrides),
createForm: (body) => post("/forms", body).then(withObjectOverrides),
updateForm: (id, body) => post(`/forms/${id}`, body).then(withObjectOverrides),
Expand Down Expand Up @@ -4863,6 +4869,7 @@ var allTerrainFormsBuilder = function(exports) {
children: [
el("h2", { text: "Start a new form" }),
grid,
this.archivedFormsSection(close),
el("div", { class: "atfb-modal__actions", children: [button("Cancel", close)] })
]
})
Expand All @@ -4876,6 +4883,68 @@ var allTerrainFormsBuilder = function(exports) {
this.root.append(overlay);
grid.querySelector("button")?.focus();
}
/**
* The archive's door, inside the "Start a new form" dialog.
*
* Restoring a retired form is a way of getting a form, so it lives where
* getting a form lives — not behind a settings tab on a form you would
* have to already have open. The list arrives asynchronously and the
* section simply is not there when the archive is empty, so the dialog
* costs nothing on the sites that never archive anything.
*
* @param close Closes the dialog this section sits in.
* @return The section, filled in when the archive answers.
*/
archivedFormsSection(close) {
const section = el("div", { class: "atfb-archived" });
void api.listArchivedForms().then((archived) => {
if (!archived.length) {
return;
}
section.append(
el("h3", { class: "atfb-archived__title", text: "Or bring one back from the archive" }),
...archived.map(
(form) => el("div", {
class: "atfb-archived__row",
children: [
el("div", {
class: "atfb-archived__meta",
children: [
el("strong", { text: form.title || "(untitled)" }),
el("span", {
class: "atfb-hint",
text: `${form.entries} ${form.entries === 1 ? "entry" : "entries"} · ${form.submissions} submissions · ${form.views} views`
})
]
}),
button(
"Restore",
async () => {
try {
const restored = await api.unarchiveForm(form.id);
close();
this.forms.unshift(restored);
notify("Form restored", `${restored.title || "(untitled)"} is back, with its entries and stats.`);
await this.open(restored.id);
} catch (error) {
notify(
"Could not restore the form",
error instanceof Error ? error.message : "",
"error"
);
}
},
"secondary",
"undo"
)
]
})
)
);
}).catch(() => {
});
return section;
}
/** Shown when the site has no forms at all. */
renderFormsList() {
clear(this.canvas);
Expand Down Expand Up @@ -7159,10 +7228,64 @@ var allTerrainFormsBuilder = function(exports) {
text: "The link this creates is the only key to those answers — anyone holding it can read them. For genuinely sensitive questions, require login instead."
})
]
}),
el("section", {
children: [
el("h3", { text: "Archive" }),
el("p", {
class: "atfb-hint",
text: "Retire the form when its moment has passed. It stops accepting responses and leaves every list, and its entries and stats go with it — nothing is deleted, and restoring it brings all of it back exactly as it was."
}),
button("Archive this form", () => void this.archiveCurrentForm(), "secondary", "archive")
]
})
]
});
}
/**
* Archives the open form, entries and stats included.
*
* Unsaved edits are saved first: the archive keeps whatever the form is at
* the moment it goes in, and losing the last half hour of edits because
* archiving skipped the save would be a quiet little disaster.
*/
async archiveCurrentForm() {
if (!this.form) {
return;
}
const title = this.form.title || "(untitled)";
const entries = this.forms.find((form) => form.id === this.form.id)?.entries ?? 0;
const confirmed = await confirmAction(
`Archive “${title}”? It stops accepting responses and leaves every list — its ${entries} ${entries === 1 ? "entry" : "entries"} and its stats go with it. Nothing is deleted: restore it any time from “Start a new form”.`,
"Archive form"
);
if (!confirmed) {
return;
}
try {
if (this.dirty) {
await this.save(true);
}
await api.archiveForm(this.form.id);
notify("Form archived", `${title} — restore it any time from the New form dialog.`);
this.forms = this.forms.filter((form) => form.id !== this.form.id);
this.form = null;
this.schema = null;
this.selected = null;
this.dirty = false;
this.history = [];
this.historyAt = -1;
if (this.forms.length) {
await this.open(this.forms[0].id);
return;
}
this.renderBar();
this.renderCanvas();
this.renderInspector();
} catch (error) {
notify("Could not archive the form", error instanceof Error ? error.message : "", "error");
}
}
/** A one-line input that understands merge tags. */
taggableInput(value, onChange, placeholder = "") {
return taggable(textInput(value, onChange, placeholder), { formId: this.form.id });
Expand Down
6 changes: 3 additions & 3 deletions assets/js/builder.min.js

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions assets/js/dock.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ var allTerrainFormsDock = function(exports) {
const api = {
config: () => get("/config"),
listForms: () => get("/forms"),
/** The other side of the archive: the retired forms, same shape. */
listArchivedForms: () => get("/forms?archived=1"),
/** Retires a form — it leaves every picker, its entries leave every list, its stats go with it. */
archiveForm: (id) => post(`/forms/${id}/archive`, {}),
/** Brings an archived form back, entries and stats included, in its pre-archive status. */
unarchiveForm: (id) => post(`/forms/${id}/unarchive`, {}),
getForm: (id) => get(`/forms/${id}`).then(withObjectOverrides),
createForm: (body) => post("/forms", body).then(withObjectOverrides),
updateForm: (id, body) => post(`/forms/${id}`, body).then(withObjectOverrides),
Expand Down
Loading
Loading