Переглянути джерело

Let the wizard reopen a post type instead of only listing them

The list of existing post types is a dropdown now, defaulting to "A new post
type".  Pick one and the form fills in with what that type was made from -- the
name, the canned-field checkboxes, the placeholder, the datasource, the body
form, every custom field with its type, label, required, editors-only and
indexed flags, and the display template.  Overwrite is ticked for you, since
saving it back under the same name is the point; change the name and you get a
copy instead.

Each type also carries a description of what it is for, shown under the dropdown
when you pick one, with a collapsed textarea to edit it.

All of it lives under x-tcms-post-type in the sidecar, which is where it belongs
and where you guessed: schema_for() keeps properties and required and drops
everything else before validation, so none of this can affect whether a post
saves.  There is a test that says so, because that is the property the whole
arrangement rests on.

Two things had to be recovered rather than merely read.  A relation which pulls
in every post of its target type stores nothing, so it has no property to be
found by and exists only as an x-tcms-relations entry -- reconstructing the form
from properties alone would silently lose it.  And the display template was not
recorded anywhere before now, so for types generated earlier it is read back out
of the template between the two lines the generator puts around it; a
hand-written type yields nothing there, which is correct, since no wizard form
produces one.

The types reach the page as JSON in a script element rather than as JavaScript,
with every '<' escaped -- still valid JSON, and it means a display template full
of markup cannot end the element it is sitting in.

Also describe the four types that had no description: dirindex, guests,
provisioned_guests and hypervisor.  The hand-written ones already had one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6Q1gRJ2Rr85S8FQ47EjnQ
George Baugh 1 місяць тому
батько
коміт
2fb1090c7f

+ 5 - 0
Readme.md

@@ -65,6 +65,11 @@ Our post data storage being JSON allows us the flexibility to have any kind of m
 You describe the type of data provided in a sidecar JSON file alongside the form's template.
 There's a wizard you can use to build these!
 
+The wizard's "Start from" dropdown reopens any type that already exists, filling the
+form in from its sidecar so you can change one thing and save it back.  Each type
+carries a description saying what it is for, which the dropdown shows and a
+collapsed textarea lets you edit.
+
 From there you make Series of the content type you want; tag the series with 'topbar' if you want it to show up in the top links.
 
 Currently supported:

+ 9 - 1
design.md

@@ -166,7 +166,7 @@ is one file to keep in sync rather than two:
 | `x-tcms-relation-form` | this field holds another post's UUID |
 | `x-tcms-relations` | what to resolve onto the post at render time |
 | `x-tcms-datasource` | these posts are built by a module, not stored |
-| `x-tcms-post-type` | the type's own name and description |
+| `x-tcms-post-type` | the type's own name, description, and everything the wizard was told to build it |
 
 You do not have to write either file by hand. **The Post Type Wizard**
 (`/admin/wyzzerdd`) generates both from a form: name the fields, pick their
@@ -180,6 +180,14 @@ The generated template is ordinary Kolon, so hand-editing it afterwards is
 expected and fine. Regenerating the type overwrites it, which is why the wizard
 says so at the top of what it writes.
 
+The wizard's "Start from" dropdown reopens a type that already exists: it fills
+the form back in from the sidecar, shows the type's description, and ticks
+Overwrite, so changing one thing about a type is a two-minute job rather than
+filling the form in again from memory. That works because `x-tcms-post-type`
+records everything the wizard was told -- the description, the display template,
+the checkboxes, the placeholder -- and `schema_for` strips that whole key before
+validation, so none of it can affect whether a post saves.
+
 Series, and how content is organised
 ------------------------------------
 

+ 230 - 7
lib/Trog/Routes/HTML.pm

@@ -2445,6 +2445,9 @@ sub post_wizard ($query) {
             is_admin          => 1,
             forms             => $forms,
             forms_dir         => Trog::Themes::forms_dir(),
+            types             => _wizard_types_json($forms),
+            type_names        => [ map { ( $_ =~ s/\.tx$//r ) } @$forms ],
+            description       => _wizard_description( $query->{description} ),
             datasources       => _get_datasources(),
             datasource        => _wizard_scalar( $query->{datasource} ),
             checked           => \%checked,
@@ -2520,7 +2523,15 @@ sub post_wizard_save ($query) {
     my @includes  = grep { $query->{$_} } @wizard_include_order;
     my $body_form = _wizard_scalar( $query->{body_form} ) eq 'form_multi.tx' ? 'form_multi.tx' : 'form_common.tx';
 
-    my $spec = _wizard_sidecar( $name, $fields, \@includes, $body_form, $datasource );
+    my $spec = _wizard_sidecar(
+        $name, $fields, \@includes, $body_form, $datasource,
+        {
+            description       => _wizard_description( $query->{description} ),
+            display           => _wizard_scalar( $query->{display} ),
+            title_placeholder => _wizard_scalar( $query->{title_placeholder} ),
+            map { $_ => $query->{$_} ? 1 : 0 } qw{wrapper inc_post_title inc_post_tags},
+        }
+    );
 
     local $@;
     eval {
@@ -2625,6 +2636,205 @@ sub _wizard_reindex ( $name, $fields ) {
     return;
 }
 
+=head2 _wizard_types($forms) = HASHREF
+
+Every post type on disk, in the shape the wizard's form is in.
+
+This is the other direction from _wizard_sidecar: it reads a type back out and
+says what the wizard would have had to be told to produce it, so that picking one
+from the dropdown can put the form into that state.
+
+Everything but the display template comes out of the sidecar.  The display comes
+out of the sidecar too for anything written since it started being recorded
+there, and is read back out of the template itself otherwise -- see
+_wizard_display_from_template, and note that a hand-written type will often
+yield nothing, which is honest: there is no wizard form that produces it.
+
+=cut
+
+sub _wizard_types ($forms) {
+    my %types;
+
+    foreach my $form (@$forms) {
+        my $schema = Trog::DataModule::schema_for($form);
+        my $meta   = Trog::DataModule::type_meta_for($form);
+        my $type   = $meta->{'x-tcms-post-type'};
+        $type = {} unless Ref::Util::is_hashref($type);
+
+        my %includes = map { $_ => 1 } @{ Ref::Util::is_arrayref( $type->{includes} ) ? $type->{includes} : [] };
+
+        my $display = $type->{display};
+        $display = _wizard_display_from_template($form) unless length( $display // '' );
+
+        $types{$form} = {
+            name              => ( $form =~ s/\.tx$//r ),
+            description       => $type->{description}         // '',
+            display           => $display                     // '',
+            body_form         => $type->{body_form}           // 'form_common.tx',
+            datasource        => $meta->{'x-tcms-datasource'} // '',
+            title_placeholder => $type->{title_placeholder}   // '',
+            generated         => $type->{generated} ? 1 : 0,
+
+            # The wizard's own checkboxes.  The includes are a list in the
+            # sidecar; the other three are recorded individually because nothing
+            # else would say whether they were ticked.
+            wrapper        => $type->{wrapper}        ? 1 : 0,
+            inc_post_title => $type->{inc_post_title} ? 1 : 0,
+            inc_post_tags  => $type->{inc_post_tags}  ? 1 : 0,
+            ( map { $_ => $includes{ $wizard_includes{$_} } ? 1 : 0 } @wizard_optional_includes ),
+
+            fields => _wizard_fields_of( $schema, $meta ),
+        };
+    }
+
+    return \%types;
+}
+
+# The custom fields of a type, as rows the wizard would have submitted.
+sub _wizard_fields_of ( $schema, $meta ) {
+    my $properties = $schema->{properties} // {};
+    my %required   = map { $_ => 1 } @{ $schema->{required} // [] };
+    my $relations  = $meta->{'x-tcms-relations'};
+    $relations = {} unless Ref::Util::is_hashref($relations);
+
+    my @fields;
+    foreach my $name ( sort keys(%$properties) ) {
+
+        # Only what this type added.  Everything in the base post schema belongs
+        # to every post and was never a wizard field.
+        next if exists $Trog::DataModule::post_schema{properties}{$name};
+
+        my $property = $properties->{$name};
+        next unless Ref::Util::is_hashref($property);
+
+        push(
+            @fields,
+            {
+                name          => $name,
+                type          => $property->{'x-tcms-input'}       // 'text',
+                label         => $property->{'x-tcms-label'}       // '',
+                placeholder   => $property->{'x-tcms-placeholder'} // '',
+                required      => $required{$name}              ? 1 : 0,
+                private       => $property->{'x-tcms-private'} ? 1 : 0,
+                indexed       => $property->{'x-tcms-indexed'} ? 1 : 0,
+                relation_form => $property->{'x-tcms-relation-form'} // '',
+                relation_mode => 'one',
+            }
+        );
+    }
+
+    # A relation pulling in every post of its target type stores nothing, so it
+    # has no property to have been found above -- it exists only as a relations
+    # entry with no 'from'.
+    foreach my $name ( sort keys(%$relations) ) {
+        my $relation = $relations->{$name};
+        next unless Ref::Util::is_hashref($relation);
+        next if $relation->{from};
+
+        push(
+            @fields,
+            {
+                name          => $name,
+                type          => 'relation',
+                label         => '',
+                placeholder   => '',
+                required      => 0,
+                private       => 0,
+                indexed       => 0,
+                relation_form => $relation->{form} // '',
+                relation_mode => 'all',
+            }
+        );
+    }
+
+    return \@fields;
+}
+
+=head2 _wizard_display_from_template($form)
+
+Recover a generated type's display template from the template itself.
+
+For types written before the wizard started recording it in the sidecar.  Only
+attempted for a file carrying the generated marker, and only between the two
+lines _wizard_template puts around it, so this either finds exactly what was
+submitted or finds nothing.  A hand-written type finds nothing, which is correct:
+there is no wizard form that produces one.
+
+=cut
+
+sub _wizard_display_from_template ($form) {
+    my $path = Trog::Themes::themed_file_in_dir( 'forms', $form, 'text/html', 1 );
+    return '' unless $path;
+
+    my $template = eval { File::Slurper::read_text($path) };
+    return '' unless defined $template;
+
+    my @lines = split( "\n", $template );
+
+    # CORE::index because this package has an index() of its own -- the route
+    # that renders every page.
+    return '' unless @lines && CORE::index( $lines[0], 'Generated by the tCMS Post Type Wizard' ) != -1;
+
+    my ( @display, $inside );
+    foreach my $line (@lines) {
+        if ( !$inside ) {
+            $inside = 1 if $line =~ m/^\s*:\s*if\s*\(\s*!\$post\.addpost\s*\)\s*\{\s*$/;
+            next;
+        }
+
+        last if $line =~ m/^\s*:\s*\}\s*$/;
+
+        # The includes the wizard emitted, which are checkboxes rather than
+        # anything the admin typed into the display box.
+        next if $line =~ m/^\s*:\s*include\s+"/;
+
+        push( @display, $line );
+    }
+
+    my $display = join( "\n", @display );
+    $display =~ s/^\s+|\s+$//g;
+    return $display;
+}
+
+=head2 _wizard_types_json($forms) = STRING
+
+_wizard_types() as JSON, safe to drop into a script element.
+
+Every '<' is escaped, which is still valid JSON and means the string cannot
+close the element it is sitting in -- a display template is full of markup, and
+one containing the characters that end a script tag would otherwise end it.
+
+=cut
+
+sub _wizard_types_json ($forms) {
+    my $json = eval { JSON::MaybeXS->new( canonical => 1 )->encode( _wizard_types($forms) ) };
+    if ( !defined $json ) {
+        WARN("Could not describe the existing post types: $@");
+        return '{}';
+    }
+
+    $json =~ s/</\\u003c/g;
+    return $json;
+}
+
+=head2 _wizard_description($description)
+
+A post type's description, as it goes into the sidecar.
+
+Free text rather than template source: it is shown to whoever picks the type in
+the wizard, and rendered as text, so the only thing to do to it is keep it a
+sane length and stop it being something other than a string.
+
+=cut
+
+our $wizard_description_max = 2048;
+
+sub _wizard_description ( $description = '' ) {
+    $description = _wizard_scalar($description);
+    $description = substr( $description, 0, $wizard_description_max ) if length($description) > $wizard_description_max;
+    return $description;
+}
+
 # Repeated params arrive as arrayrefs; anywhere we want one string, insist on one string.
 sub _wizard_scalar ( $value = '' ) {
     return '' if !defined $value || ref $value;
@@ -2729,7 +2939,7 @@ Trog::DataModule::schema_for() merges this over the base post schema.
 
 =cut
 
-sub _wizard_sidecar ( $name, $fields, $includes, $body_form, $datasource = '' ) {
+sub _wizard_sidecar ( $name, $fields, $includes, $body_form, $datasource = '', $extra = {} ) {
     my %properties;
     my @required;
 
@@ -2769,13 +2979,26 @@ sub _wizard_sidecar ( $name, $fields, $includes, $body_form, $datasource = '' )
         push( @required, $field->{name} ) if $field->{required};
     }
 
+    # Everything the wizard was told, so that picking this type again puts the
+    # form back the way it was.  It all lives under x-tcms-post-type, which
+    # schema_for() strips before validation -- the validator sees properties and
+    # required and nothing else, so none of this can affect whether a post saves.
     my %spec = (
         'x-tcms-post-type' => {
-            name      => $name,
-            generated => JSON::MaybeXS::true(),
-            generator => 'Trog::Routes::HTML::post_wizard_save',
-            body_form => $body_form,
-            includes  => [ map { $wizard_includes{$_} } @$includes ],
+            name        => $name,
+            generated   => JSON::MaybeXS::true(),
+            generator   => 'Trog::Routes::HTML::post_wizard_save',
+            body_form   => $body_form,
+            includes    => [ map { $wizard_includes{$_} } @$includes ],
+            description => $extra->{description} // '',
+            display     => $extra->{display}     // '',
+
+            # The checkboxes which are not includes, and so are not recoverable
+            # from the list above.
+            wrapper           => $extra->{wrapper}        ? JSON::MaybeXS::true() : JSON::MaybeXS::false(),
+            inc_post_title    => $extra->{inc_post_title} ? JSON::MaybeXS::true() : JSON::MaybeXS::false(),
+            inc_post_tags     => $extra->{inc_post_tags}  ? JSON::MaybeXS::true() : JSON::MaybeXS::false(),
+            title_placeholder => $extra->{title_placeholder} // '',
         },
         type       => 'object',
         properties => \%properties,

+ 138 - 0
t/Trog-DataModule-schema.t

@@ -411,6 +411,144 @@ subtest '_wizard_sidecar emits a usable OpenAPIv3 schema' => sub {
     is_deeply( $spec->{'x-tcms-post-type'}{includes}, ['tags.tx'], "the includes are recorded for regeneration" );
 };
 
+subtest 'a type records what the wizard was told, where the validator cannot see it' => sub {
+    my $fields = [
+        {
+            name     => 'cook_time', type => 'text', label => 'Cook Time', placeholder => '45 minutes',
+            required => 1, private => 0, indexed => 1, relation_form => '', relation_mode => 'one',
+        },
+        {
+            name     => 'everything', type => 'relation', label => '', placeholder => '',
+            required => 0, private => 0, indexed => 0, relation_form => 'blog.tx', relation_mode => 'all',
+        },
+    ];
+
+    my $spec = Trog::Routes::HTML::_wizard_sidecar(
+        'recipe', $fields, ['inc_tags'], 'form_common.tx', '',
+        {
+            description       => 'A recipe, with its cook time.',
+            display           => '<div class="r"><: $post.cook_time :></div>',
+            title_placeholder => 'Bread',
+            wrapper           => 1,
+            inc_post_title    => 1,
+            inc_post_tags     => 0,
+        }
+    );
+
+    my $type = $spec->{'x-tcms-post-type'};
+    is( $type->{description},       'A recipe, with its cook time.',              'the description is recorded' );
+    is( $type->{display},           '<div class="r"><: $post.cook_time :></div>', 'and the display template' );
+    is( $type->{title_placeholder}, 'Bread',                                      'and the title placeholder' );
+    ok( $type->{wrapper},        'and the checkboxes which are not includes' );
+    ok( $type->{inc_post_title}, 'all of them' );
+    ok( !$type->{inc_post_tags}, 'including the ones that were clear' );
+
+    # The point of putting it there: schema_for() keeps properties and required
+    # and nothing else, so none of this can affect whether a post validates.
+    set_sidecars( 'recipe.json' => JSON::MaybeXS::encode_json($spec) );
+    my $schema = Trog::DataModule::schema_for('recipe.tx');
+    ok( !exists $schema->{'x-tcms-post-type'},   'and the validator never sees any of it' );
+    ok( exists $schema->{properties}{cook_time}, 'while the fields it does care about are there' );
+
+    is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', title => 'x', cook_time => '45 minutes' } ) ], [], 'so a post of the type still saves' );
+};
+
+subtest 'a type reads back as the form that made it' => sub {
+    my $spec = Trog::Routes::HTML::_wizard_sidecar(
+        'recipe',
+        [
+            {
+                name     => 'cook_time', type => 'number', label => 'Cook Time', placeholder => '45',
+                required => 1, private => 0, indexed => 1, relation_form => '', relation_mode => 'one',
+            },
+            {
+                name     => 'secret_note', type => 'textarea', label => 'Note', placeholder => '',
+                required => 0, private => 1, indexed => 0, relation_form => '', relation_mode => 'one',
+            },
+            {
+                name     => 'picked', type => 'relation', label => 'Picked', placeholder => '',
+                required => 0, private => 0, indexed => 0, relation_form => 'blog.tx', relation_mode => 'one',
+            },
+            {
+                name     => 'everything', type => 'relation', label => '', placeholder => '',
+                required => 0, private => 0, indexed => 0, relation_form => 'entities.tx', relation_mode => 'all',
+            },
+        ],
+        [qw{inc_preview inc_tags}],
+        'form_multi.tx',
+        'Trog::DataSource::Virt',
+        {
+            description       => 'What it is for.',
+            display           => '<div>x</div>',
+            title_placeholder => 'Bread',
+            wrapper           => 1,
+            inc_post_title    => 0,
+            inc_post_tags     => 1,
+        }
+    );
+    set_sidecars( 'recipe.json' => JSON::MaybeXS::encode_json($spec) );
+
+    my $types = Trog::Routes::HTML::_wizard_types( ['recipe.tx'] );
+    my $back  = $types->{'recipe.tx'};
+
+    is( $back->{name},              'recipe',                 'the name comes back without the extension' );
+    is( $back->{description},       'What it is for.',        'the description comes back' );
+    is( $back->{display},           '<div>x</div>',           'and the display' );
+    is( $back->{body_form},         'form_multi.tx',          'and which body form' );
+    is( $back->{datasource},        'Trog::DataSource::Virt', 'and the datasource' );
+    is( $back->{title_placeholder}, 'Bread',                  'and the placeholder' );
+    is( $back->{wrapper},           1,                        'the ticked boxes come back ticked' );
+    is( $back->{inc_post_title},    0,                        'the clear ones clear' );
+    is( $back->{inc_post_tags},     1,                        'individually' );
+    is( $back->{inc_preview},       1,                        'and the includes are recovered from the list' );
+    is( $back->{inc_tags},          1,                        'all of them' );
+    is( $back->{inc_aliases},       0,                        'and only them' );
+
+    my %by_name = map { $_->{name} => $_ } @{ $back->{fields} };
+    is( scalar( keys %by_name ), 4, 'every custom field comes back' );
+
+    is( $by_name{cook_time}{type},       'number',    'with the input it was given' );
+    is( $by_name{cook_time}{label},      'Cook Time', 'its label' );
+    is( $by_name{cook_time}{required},   1,           'whether it was required' );
+    is( $by_name{cook_time}{indexed},    1,           'and whether it was indexed' );
+    is( $by_name{secret_note}{private},  1,           'a private field says so' );
+    is( $by_name{picked}{type},          'relation',  'a relation says so' );
+    is( $by_name{picked}{relation_form}, 'blog.tx',   'naming its target' );
+    is( $by_name{picked}{relation_mode}, 'one',       'and that it picks one' );
+
+    # This one stores nothing, so it has no property to be found by -- it exists
+    # only as a relations entry, and would be lost without looking there.
+    is( $by_name{everything}{relation_mode}, 'all',         'a pick-everything relation comes back too' );
+    is( $by_name{everything}{relation_form}, 'entities.tx', 'naming its target' );
+
+    # And nothing from the base post schema, which was never a wizard field.
+    ok( !exists $by_name{title},      'the base schema fields are not offered as custom ones' );
+    ok( !exists $by_name{visibility}, 'any of them' );
+};
+
+subtest 'the types are handed to the page as JSON it cannot break out of' => sub {
+    no warnings qw{once};
+
+    my $spec = Trog::Routes::HTML::_wizard_sidecar(
+        'nasty', [], [], 'form_common.tx', '',
+        { description => 'ends a script </script><script>alert(1)</script>', display => '<div>markup</div>' }
+    );
+    set_sidecars( 'nasty.json' => JSON::MaybeXS::encode_json($spec) );
+
+    my $json = Trog::Routes::HTML::_wizard_types_json( ['nasty.tx'] );
+    unlike( $json, qr/</, 'no bare < survives, so the script element cannot be closed early' );
+
+    my $back = JSON::MaybeXS::decode_json($json);
+    like( $back->{'nasty.tx'}{description}, qr{</script>}, 'while the text itself is intact once parsed' );
+    is( $back->{'nasty.tx'}{display}, '<div>markup</div>', 'and so is the display template' );
+
+    # A description is free text, but it still goes in a file.
+    my $long = Trog::Routes::HTML::_wizard_description( 'x' x 9000 );
+    ok( length($long) <= $Trog::Routes::HTML::wizard_description_max, 'a very long description is capped' );
+    is( Trog::Routes::HTML::_wizard_description(),                    '', 'and no description at all is empty rather than undef' );
+    is( Trog::Routes::HTML::_wizard_description( [ 'an', 'array' ] ), '', 'as is one that is not a string' );
+};
+
 subtest '_kolon_safe defuses template syntax' => sub {
     my $safe = Trog::Routes::HTML::_kolon_safe(qq{<: \$post.id :>\n: include "evil.tx";});
     unlike( $safe, qr/</,      "no angle brackets survive" );

+ 74 - 1
t/Trog-Routes-HTML-series-lifecycle.t

@@ -452,7 +452,9 @@ subtest 'the post wizard invents a new post type' => sub {
 
     ok( -f 'www/templates/html/components/forms/spec_widget.tx',   'the template was written' );
     ok( -f 'www/templates/html/components/forms/spec_widget.json', 'the schema sidecar was written' );
-    like( $body, qr{<li>spec_widget\.tx</li>}, 'and the new type is listed back to us' );
+
+    # The existing types are a dropdown now, not a list.
+    like( $body, qr{<option value="spec_widget\.tx">}, 'and the new type is listed back to us' );
 
     # This is the assertion that the sandbox is real: a symlinked template tree
     # would have put these in the developer's actual checkout.
@@ -769,6 +771,77 @@ subtest 'a directory index series' => sub {
     is_deeply( $tpsgi->{invalidated}, ['html'], 'and a change to the directory invalidates the renders' );
 };
 
+subtest 'the wizard offers the types that exist, and can reopen one' => sub {
+
+    # Make a type with everything the form can carry, then check the wizard
+    # hands it back as the form that would have produced it.
+    my ( $code, $body, $err ) = _render(
+        _admin(
+            route               => '/admin/wyzzerdd/save',
+            method              => 'POST',
+            name                => 'spec_reopen',
+            body_form           => 'form_common.tx',
+            wrapper             => 1,
+            inc_post_title      => 1,
+            inc_tags            => 1,
+            title_placeholder   => 'A Widget',
+            description         => 'A widget, with the size it comes in.',
+            param_name          => ['widget_size'],
+            param_type          => ['number'],
+            param_label         => ['Size'],
+            param_placeholder   => ['12'],
+            param_required      => [1],
+            param_private       => [0],
+            param_indexed       => [1],
+            param_relation_form => ['blog.tx'],
+            param_relation_mode => ['one'],
+            display             => q{<div class="w"><: $post.widget_size :></div>},
+        ),
+        \&Trog::Routes::HTML::post_wizard_save,
+    );
+    is( $code, 200, 'the type was created' ) or diag($err);
+
+    # The description goes in the sidecar, under the key the validator strips.
+    my $sidecar = JSON::MaybeXS::decode_json( Path::Tiny->new('www/templates/html/components/forms/spec_reopen.json')->slurp_utf8 );
+    is( $sidecar->{'x-tcms-post-type'}{description}, 'A widget, with the size it comes in.', 'the description is stored' );
+    like( $sidecar->{'x-tcms-post-type'}{display}, qr/widget_size/, 'and so is the display template' );
+
+    # Now the wizard page itself.
+    ( $code, $body, $err ) = _render( _admin( route => '/admin/wyzzerdd' ), \&Trog::Routes::HTML::post_wizard );
+    is( $code, 200, 'the wizard renders' ) or diag($err);
+
+    like( $body, qr/id="wizard-existing"/,             'the existing types are a dropdown' );
+    like( $body, qr/<option value="">A new post type/, 'defaulting to a new one' );
+    like( $body, qr/<option value="spec_reopen\.tx">/, 'and offering the one we just made' );
+    like( $body, qr/id="wizard-description-editor"/,   'with a collapsed editor for the description' );
+
+    my ($json) = $body =~ m{id="wizard-types"[^>]*>(.*?)</script>}s;
+    ok( $json, 'the page carries what each type was made from' ) or return;
+    unlike( $json, qr/</, 'as JSON with no bare < in it, so it cannot end its own script element' );
+
+    my $types = JSON::MaybeXS::decode_json($json);
+    my $mine  = $types->{'spec_reopen.tx'};
+    ok( $mine, 'including the new type' ) or return;
+
+    is( $mine->{name},        'spec_reopen',                          'named without the extension' );
+    is( $mine->{description}, 'A widget, with the size it comes in.', 'with its description' );
+    like( $mine->{display}, qr/widget_size/, 'and its display template' );
+    is( $mine->{title_placeholder}, 'A Widget', 'and its placeholder' );
+    is( $mine->{wrapper},           1,          'the boxes that were ticked' );
+    is( $mine->{inc_tags},          1,          'includes and all' );
+    is( $mine->{inc_aliases},       0,          'and only those' );
+
+    is( scalar( @{ $mine->{fields} } ), 1, 'and its custom field' );
+    my $field = $mine->{fields}[0];
+    is( $field->{name},     'widget_size', 'by name' );
+    is( $field->{type},     'number',      'with the input it was given' );
+    is( $field->{required}, 1,             'whether it was required' );
+    is( $field->{indexed},  1,             'and whether it was indexed' );
+
+    # Which is to say: reopening it and saving it back is a round trip, not a
+    # form somebody has to fill in again from memory.
+};
+
 subtest 'a field can be declared indexed' => sub {
     my ( $code, $body, $err ) = _render(
         _admin(

+ 129 - 0
www/scripts/post_wizard.js

@@ -61,6 +61,135 @@ function syncIndexedRow(checkbox) {
     return false;
 }
 
+// Everything the wizard knows about the types which already exist, written into
+// the page by post_wizard() as JSON rather than as JS, so that a display
+// template full of markup cannot become code.
+function wizardTypes() {
+    var el = document.getElementById('wizard-types');
+    if (el === null) {
+        return {};
+    }
+    try {
+        return JSON.parse(el.textContent) || {};
+    } catch (e) {
+        console.log('could not read the existing post types: ' + e);
+        return {};
+    }
+}
+
+function setField(name, value) {
+    var el = document.querySelector('#postTypeWizard [name="' + name + '"]');
+    if (!el) {
+        return;
+    }
+    if (el.type === 'checkbox') {
+        el.checked = value ? true : false;
+        return;
+    }
+    el.value = value === null || value === undefined ? '' : value;
+}
+
+// Put the form into the state that produced the picked type, or back to blank
+// for 'A new post type'.
+function fillFromType(select) {
+    var types = wizardTypes();
+    var type  = types[select.value];
+    var host  = document.getElementById('wizard-params');
+    var about = document.getElementById('wizard-type-description');
+
+    if (host) {
+        host.innerHTML = '';
+    }
+
+    if (!type) {
+        // Back to blank, but leave the canned-field checkboxes as the server
+        // rendered them -- those are the defaults for a new type.
+        setField('name', '');
+        setField('display', '');
+        setField('title_placeholder', '');
+        setField('datasource', '');
+        setField('description', '');
+        setField('overwrite', false);
+        if (about) {
+            about.textContent = '';
+        }
+        addParam();
+        return false;
+    }
+
+    setField('name', type.name);
+    setField('display', type.display);
+    setField('title_placeholder', type.title_placeholder);
+    setField('datasource', type.datasource);
+    setField('body_form', type.body_form);
+    setField('description', type.description);
+
+    var boxes = ['wrapper', 'inc_post_title', 'inc_post_tags', 'inc_preview',
+                 'inc_tags', 'inc_aliases', 'inc_attachments'];
+    for (var i = 0; i < boxes.length; i++) {
+        setField(boxes[i], type[boxes[i]]);
+    }
+
+    // Saving it under the same name is the whole point of picking it, and
+    // without this the save is refused with a message about ticking this box.
+    setField('overwrite', true);
+
+    if (about) {
+        about.textContent = type.description ||
+            (type.generated ? 'No description recorded for this type yet.'
+                            : 'Written by hand rather than by the wizard, so there may be more to it than this form can show.');
+    }
+
+    var fields = type.fields || [];
+    for (var f = 0; f < fields.length; f++) {
+        addParam();
+        fillParamRow(fields[f]);
+    }
+    if (!fields.length) {
+        addParam();
+    }
+
+    return false;
+}
+
+// The row addParam() just appended is the last one.
+function fillParamRow(field) {
+    var rows = document.querySelectorAll('#wizard-params .wizard-param-row');
+    if (!rows.length) {
+        return;
+    }
+    var row = rows[rows.length - 1];
+
+    var set = function (name, value) {
+        var el = row.querySelector('[name="' + name + '"]');
+        if (el) {
+            el.value = value === null || value === undefined ? '' : value;
+        }
+    };
+
+    set('param_name', field.name);
+    set('param_type', field.type);
+    set('param_label', field.label);
+    set('param_placeholder', field.placeholder);
+    set('param_required', field.required ? '1' : '0');
+    set('param_private', field.private ? '1' : '0');
+    set('param_indexed', field.indexed ? '1' : '0');
+    set('param_relation_form', field.relation_form);
+    set('param_relation_mode', field.relation_mode);
+
+    // The checkbox has no name -- the hidden input beside it is what submits --
+    // so it has to be brought into line by hand.
+    var box = row.querySelector('.wizard-index input[type="checkbox"]');
+    if (box) {
+        box.checked = field.indexed ? true : false;
+    }
+
+    var type = row.querySelector('.param-type');
+    if (type) {
+        syncRelationRow(type);
+    }
+}
+
 function delParam(button) {
     var row = button.closest('.wizard-param-row');
     if (row) {

+ 14 - 13
www/templates/html/components/forms/dirindex.json

@@ -1,24 +1,25 @@
 {
-   "properties" : {
-      "directory" : {
-         "type" : "string",
-         "x-tcms-input" : "text",
-         "x-tcms-label" : "Directory to index"
+   "properties": {
+      "directory": {
+         "type": "string",
+         "x-tcms-input": "text",
+         "x-tcms-label": "Directory to index"
       }
    },
-   "type" : "object",
-   "x-tcms-datasource" : "Trog::DataSource::DirIndex",
-   "x-tcms-post-type" : {
-      "body_form" : "form_common.tx",
-      "generated" : true,
-      "generator" : "Trog::Routes::HTML::post_wizard_save",
-      "includes" : [
+   "type": "object",
+   "x-tcms-datasource": "Trog::DataSource::DirIndex",
+   "x-tcms-post-type": {
+      "body_form": "form_common.tx",
+      "description": "A listing of a directory on disk.  Its posts are built by Trog::DataSource::DirIndex rather than written, so put files in the directory instead of using an editor.",
+      "generated": true,
+      "generator": "Trog::Routes::HTML::post_wizard_save",
+      "includes": [
          "preview.tx",
          "visibility.tx",
          "acls.tx",
          "tags.tx",
          "aliases.tx"
       ],
-      "name" : "dirindex"
+      "name": "dirindex"
    }
 }

+ 18 - 17
www/templates/html/components/forms/guests.json

@@ -1,27 +1,28 @@
 {
-   "properties" : {
-      "preview" : {
-         "type" : "string",
-         "x-tcms-input" : "text",
-         "x-tcms-label" : "Console capture",
-         "x-tcms-private" : true
+   "properties": {
+      "preview": {
+         "type": "string",
+         "x-tcms-input": "text",
+         "x-tcms-label": "Console capture",
+         "x-tcms-private": true
       }
    },
-   "type" : "object",
-   "x-tcms-datasource" : "Trog::DataSource::Virt",
-   "x-tcms-post-type" : {
-      "body_form" : "form_common.tx",
-      "generated" : true,
-      "generator" : "Trog::Routes::HTML::post_wizard_save",
-      "includes" : [
+   "type": "object",
+   "x-tcms-datasource": "Trog::DataSource::Virt",
+   "x-tcms-post-type": {
+      "body_form": "form_common.tx",
+      "description": "A libvirt guest.  Built by Trog::DataSource::Virt every time the page is drawn, with controls to power it on and off.",
+      "generated": true,
+      "generator": "Trog::Routes::HTML::post_wizard_save",
+      "includes": [
          "visibility.tx",
          "acls.tx"
       ],
-      "name" : "guests"
+      "name": "guests"
    },
-   "x-tcms-relations" : {
-      "hypervisors" : {
-         "form" : "hypervisor.tx"
+   "x-tcms-relations": {
+      "hypervisors": {
+         "form": "hypervisor.tx"
       }
    }
 }

+ 16 - 15
www/templates/html/components/forms/hypervisor.json

@@ -1,27 +1,28 @@
 {
-   "properties" : {
-      "conn_uri" : {
-         "type" : "string",
-         "x-tcms-input" : "url",
-         "x-tcms-label" : "Libvirt Connection URI",
-         "x-tcms-placeholder" : "libvirt://localhost",
-         "x-tcms-private" : true
+   "properties": {
+      "conn_uri": {
+         "type": "string",
+         "x-tcms-input": "url",
+         "x-tcms-label": "Libvirt Connection URI",
+         "x-tcms-placeholder": "libvirt://localhost",
+         "x-tcms-private": true
       }
    },
-   "required" : [
+   "required": [
       "conn_uri"
    ],
-   "type" : "object",
-   "x-tcms-post-type" : {
-      "body_form" : "form_common.tx",
-      "generated" : true,
-      "generator" : "Trog::Routes::HTML::post_wizard_save",
-      "includes" : [
+   "type": "object",
+   "x-tcms-post-type": {
+      "body_form": "form_common.tx",
+      "description": "A libvirt hypervisor to draw guests from.  Its connection URI is editors-only.",
+      "generated": true,
+      "generator": "Trog::Routes::HTML::post_wizard_save",
+      "includes": [
          "visibility.tx",
          "acls.tx",
          "tags.tx",
          "aliases.tx"
       ],
-      "name" : "hypervisor"
+      "name": "hypervisor"
    }
 }

+ 1 - 0
www/templates/html/components/forms/provisioned_guests.json

@@ -23,6 +23,7 @@
    "x-tcms-datasource": "Trog::DataSource::ProvisionedVirt",
    "x-tcms-post-type": {
       "body_form": "form_common.tx",
+      "description": "A libvirt guest, plus the recipe it was provisioned from and a button to build it again.  Built by Trog::DataSource::ProvisionedVirt.",
       "generated": true,
       "generator": "Trog::Routes::HTML::post_wizard_save",
       "includes": [

+ 20 - 3
www/templates/html/components/post_wizard.tx

@@ -1,4 +1,5 @@
 <script type="text/javascript" src="/scripts/post_wizard.js"></script>
+<script type="application/json" id="wizard-types"><: $types | mark_raw :></script>
 <div id="backoffice">
 <p class="title">
  Existing Post Types:
@@ -7,15 +8,31 @@ A "post type" is just a template living in <code>templates/html/components/forms
 Every post picks exactly one of them &mdash; it's what the <em>Content Template</em> dropdown
 selects when you edit a Series.
 <hr />
-<ul id="existing-post-types">
+<label for="wizard-existing">Start from</label>
+<select class="cooltext" id="wizard-existing" name="wizard_existing" onchange="fillFromType(this);">
+    <option value="">A new post type</option>
 : for $forms -> $form {
-    <li><: $form :></li>
+    <option value="<: $form :>"><: $form :></option>
 : }
-</ul>
+</select>
+<p id="wizard-type-description" class="wizard-type-description"></p>
+<details id="wizard-description-editor">
+    <summary>Description &mdash; what this post type is for</summary>
+    Shown here when somebody picks this type.  It is stored in the sidecar under
+    x-tcms-post-type, which the validator never sees, so it can say whatever is useful.
+    <br />
+    <textarea class="cooltext" name="description" rows="3" form="postTypeWizard"
+              placeholder="A recipe, with its cook time and servings."><: $description :></textarea>
+</details>
 <hr />
 <p class="title">
  Create a new Post Type:
 </p>
+Picking an existing type above fills this form in with what that type was made from,
+so you can change one thing and save it back.  Overwrite is ticked for you when you do,
+since saving it under the same name is the point &mdash; change the name and you get a
+copy instead.
+<br />
 This writes a new template into the forms directory currently in use
 (<code><: $forms_dir :></code>) along with a matching <code>.json</code> sidecar describing
 any custom fields, so that the data model knows to keep them when a post is saved.