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
79 changes: 65 additions & 14 deletions assets/js/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -8127,6 +8127,16 @@ ${decls}
}),
button("Archive this form", () => void this.archiveCurrentForm(), "secondary", "archive")
]
}),
el("section", {
children: [
el("h3", { text: "Delete" }),
el("p", {
class: "atfb-hint",
text: "Deleting is for a form that should never have existed. It moves to the desktop’s Trash, disappears from every list, and any page still carrying its shortcode shows nothing at all. Its entries stay in Entries, and until the Trash is emptied you can bring it back from there. If the form simply had its day, archive it instead."
}),
button("Delete this form", () => void this.deleteCurrentForm(), "danger", "trash")
]
})
]
});
Expand Down Expand Up @@ -8157,24 +8167,65 @@ ${decls}
}
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();
await this.releaseCurrentForm();
} catch (error) {
notify("Could not archive the form", error instanceof Error ? error.message : "", "error");
}
}
/**
* Deletes the open form.
*
* No save-first, unlike the archive: saving a form on its way to the trash
* would only preserve edits nobody will ever see. The entries deliberately
* stay — they are the visitors' words, not the form's — and the server
* uses the trash rather than a hard delete, so a wrong click ends in the
* desktop's Trash window, not in a loss.
*/
async deleteCurrentForm() {
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(
`Delete “${title}”? Every page showing it goes blank. ${entries ? `Its ${entries} ${entries === 1 ? "entry stays" : "entries stay"} in Entries. ` : ""}It moves to the desktop’s Trash — restore it from there if you change your mind.`,
"Delete form"
);
if (!confirmed) {
return;
}
try {
const formId = this.form.id;
await api.deleteForm(formId);
const shell2 = window.wp?.os;
shell2?.announceContentChange?.("alltfo_form", "trashed", formId, "allterrain-forms");
notify("Form deleted", `${title} moved to the desktop’s Trash.`);
await this.releaseCurrentForm();
} catch (error) {
notify("Could not delete the form", error instanceof Error ? error.message : "", "error");
}
}
/**
* Lets go of the open form after it left the working set — archived or
* deleted — and lands the builder somewhere sensible: the next form if
* there is one, the empty state if there is not.
*/
async releaseCurrentForm() {
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();
}
/** A one-line input that understands merge tags. */
taggableInput(value, onChange, placeholder = "") {
return taggable(textInput(value, onChange, placeholder), { formId: this.form.id });
Expand Down
8 changes: 4 additions & 4 deletions assets/js/builder.min.js

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions includes/openstation.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ function alltfo_maybe_init_openstation() {

add_action( 'init', 'alltfo_register_shell_surfaces', 20 );

add_filter( 'openstation_recycle_bin_capture_post_types', 'alltfo_recycle_bin_post_types' );

// Registered against both spellings of the hook. Which one fires depends on
// the shell's version, and a listener for a hook that never fires costs
// nothing -- far less than deciding at boot which shell is present, since
Expand All @@ -76,6 +78,27 @@ function alltfo_maybe_init_openstation() {
add_action( 'admin_enqueue_scripts', 'alltfo_enqueue_shell_styles', 20 );
}

/**
* Opts forms into the desktop's Recycle Bin.
*
* The form post type is headless (`show_ui => false`), so the bin does not
* track it by itself — without this, a deleted form is in the trash with no
* door back out of it. Forms only, deliberately: entries are bulk-trashed by
* the retention policy every day, and hundreds of expired entries would bury
* the two things in the bin somebody actually wants back. Entries live and
* die in the Entries window, which is where somebody who lost one will look.
*
* @since 0.2.0
*
* @param string[] $types Post types the recycle bin tracks.
* @return string[]
*/
function alltfo_recycle_bin_post_types( $types ) {
$types[] = ALLTFO_FORM_TYPE;

return $types;
}

/**
* Registers the windows, the icon, the widget and the commands.
*
Expand Down
13 changes: 13 additions & 0 deletions includes/post-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,25 @@ function alltfo_add_capabilities() {
'read_private_alltfo_forms',
'delete_alltfo_forms',
'delete_others_alltfo_forms',
// The `_published_` pair is easy to believe unnecessary — our own
// REST routes check `alltfo_edit_forms` and never ask WordPress's
// `edit_post`. But anything *outside* the plugin asks the standard
// question: `map_meta_cap( 'edit_post' )` on a published form — or a
// trashed one that was published, which is every form in the
// desktop's Recycle Bin — resolves to `edit_published_alltfo_forms`,
// and without it the bin silently hid trashed forms from the very
// administrators who put them there. `delete_published_` is the same
// story for restore and purge, which check `delete_post`.
'edit_published_alltfo_forms',
'delete_published_alltfo_forms',
'edit_alltfo_entries',
'edit_others_alltfo_entries',
'publish_alltfo_entries',
'read_private_alltfo_entries',
'delete_alltfo_entries',
'delete_others_alltfo_entries',
'edit_published_alltfo_entries',
'delete_published_alltfo_entries',
);

foreach ( alltfo_capability_map() as $role_slug => $caps ) {
Expand Down
6 changes: 5 additions & 1 deletion includes/render.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ function alltfo_render_form( $form_id, $args = array() ) {
$form_id = absint( $form_id );
$form = $form_id ? get_post( $form_id ) : null;

if ( ! $form || ALLTFO_FORM_TYPE !== $form->post_type ) {
// A trashed form is a deleted form: `get_post()` still returns it, but as
// far as any page is concerned it no longer exists. Without the status
// check a deleted form kept rendering — and accepting answers — through
// every shortcode that ever mentioned it.
if ( ! $form || ALLTFO_FORM_TYPE !== $form->post_type || 'trash' === $form->post_status ) {
// Silent for visitors, loud for the person who can fix it. A broken
// shortcode id should never print an error into a published page.
if ( alltfo_can_edit_forms() ) {
Expand Down
5 changes: 4 additions & 1 deletion includes/submission.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ function alltfo_process_submission( $form_id, $request, $files = array() ) {
$form_id = absint( $form_id );
$form = $form_id ? get_post( $form_id ) : null;

if ( ! $form || ALLTFO_FORM_TYPE !== $form->post_type ) {
// The same three-way gate as the renderer, trash included: a deleted form
// must refuse a POST as surely as it refuses to render, or a browser tab
// opened before the deletion could keep feeding it entries afterwards.
if ( ! $form || ALLTFO_FORM_TYPE !== $form->post_type || 'trash' === $form->post_status ) {
return alltfo_submission_failure( __( 'That form does not exist.', 'allterrain-forms' ) );
}

Expand Down
118 changes: 103 additions & 15 deletions src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4927,6 +4927,22 @@ export class Builder {
button( 'Archive this form', () => void this.archiveCurrentForm(), 'secondary', 'archive' ),
],
} ),

el( 'section', {
children: [
el( 'h3', { text: 'Delete' } ),
el( 'p', {
class: 'atfb-hint',
text:
'Deleting is for a form that should never have existed. It moves to the desktop’s '
+ 'Trash, disappears from every list, and any page still carrying its shortcode '
+ 'shows nothing at all. Its entries stay in Entries, and until the Trash is '
+ 'emptied you can bring it back from there. If the form simply had its day, '
+ 'archive it instead.',
} ),
button( 'Delete this form', () => void this.deleteCurrentForm(), 'danger', 'trash' ),
],
} ),
],
} );
}
Expand Down Expand Up @@ -4965,28 +4981,100 @@ export class Builder {
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;
await this.releaseCurrentForm();
} catch ( error ) {
notify( 'Could not archive the form', error instanceof Error ? error.message : '', 'error' );
}
}

if ( this.forms.length ) {
await this.open( this.forms[ 0 ].id );
/**
* Deletes the open form.
*
* No save-first, unlike the archive: saving a form on its way to the trash
* would only preserve edits nobody will ever see. The entries deliberately
* stay — they are the visitors' words, not the form's — and the server
* uses the trash rather than a hard delete, so a wrong click ends in the
* desktop's Trash window, not in a loss.
*/
private async deleteCurrentForm(): Promise< void > {
if ( ! this.form ) {
return;
}

return;
}
const title = this.form.title || '(untitled)';
const entries = this.forms.find( ( form ) => form.id === this.form!.id )?.entries ?? 0;

this.renderBar();
this.renderCanvas();
this.renderInspector();
const confirmed = await confirmAction(
`Delete “${ title }”? Every page showing it goes blank. ${
entries
? `Its ${ entries } ${ entries === 1 ? 'entry stays' : 'entries stay' } in Entries. `
: ''
}It moves to the desktop’s Trash — restore it from there if you change your mind.`,
'Delete form'
);

if ( ! confirmed ) {
return;
}

try {
const formId = this.form.id;

await api.deleteForm( formId );

// Tell the rest of the desktop *now*. The open Trash window and
// its dock badge subscribe to `os.alltfo_form.changed`; the shell
// cannot see a mutation made through this plugin's own REST, so
// without the announcement they only learn about the delete when
// the Heartbeat catch-all drips it in, up to a minute later.
const shell = ( window as unknown as {
wp?: {
os?: {
announceContentChange?: (
type: string,
action: string,
ids: number | number[],
source?: string
) => void;
};
};
} ).wp?.os;

shell?.announceContentChange?.( 'alltfo_form', 'trashed', formId, 'allterrain-forms' );

notify( 'Form deleted', `${ title } moved to the desktop’s Trash.` );

await this.releaseCurrentForm();
} catch ( error ) {
notify( 'Could not archive the form', error instanceof Error ? error.message : '', 'error' );
notify( 'Could not delete the form', error instanceof Error ? error.message : '', 'error' );
}
}

/**
* Lets go of the open form after it left the working set — archived or
* deleted — and lands the builder somewhere sensible: the next form if
* there is one, the empty state if there is not.
*/
private async releaseCurrentForm(): Promise< void > {
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();
}

/** A one-line input that understands merge tags. */
private taggableInput(
value: string,
Expand Down
Loading
Loading