ecamp3 icon indicating copy to clipboard operation
ecamp3 copied to clipboard

Proposal for ContentNode refactor

Open usu opened this issue 3 years ago • 3 comments

  • Avoid class/table inheritance
  • One ContentNode class, structured as proper tree (as-is now) or as nested set (feasability to be evaluated)
  • contentType property defines the type (as-is today)
  • data property of type jsonb holds the actual content
  • Some validation possible via schema validation (similar as already implemented for ColumnLayout->columns)

Limitations/Notes

  • Validation might be possible to a limited extent only (schema validation) - more responsibility to the frontend.
  • CleanHTML probably still required --> needs manual validation inside DataPersister
  • Concurrency when editing might be an issue, when data is completely replaced with each write (could be mitigated by implementing partial updates - either by merging the json or by using Postgres specific json functions)

Variation

  • Sub data like Storyboard->sections could be separate content nodes instead of integrated into the data json property (this could mitigate concurrency issues)
  • In this case, some control mechanism is needed to define allowed tree structures (e.g. StoryboardSection can be a child of Storyboard only)

Other notes

  • https://github.com/dunglas/doctrine-json-odm might be interesting to look into, but probably not needed
  • Evaluate implementation as nested set https://github.com/blt04/doctrine2-nestedset

Doctrine entity

class ContentNode extends BaseEntity implements BelongsToCampInterface, CopyFromPrototypeInterface {
    /**
     * The camp to which this contet node belongs. May not be changed once the content node is created.
     */
    #[ORM\ManyToOne(targetEntity: Camp::class)]
    #[ORM\JoinColumn(nullable: false, onDelete: 'cascade')]
    public ?Camp $camp = null;

    /**
     * The content node that is the root of the content node tree. Refers to itself in case this
     * content node is the root.
     */
    #[ORM\ManyToOne(targetEntity: ContentNode::class, inversedBy: 'rootDescendants')]
    #[ORM\JoinColumn(nullable: true)]
    public ContentNode $root;

    /**
     * All content nodes that are part of this content node tree.
     */
    #[ApiProperty(readable: false, writable: false)]
    #[ORM\OneToMany(targetEntity: ContentNode::class, mappedBy: 'root')]
    public Collection $rootDescendants;

    /**
     * The parent to which this content node belongs. Is null in case this content node is the
     * root of a content node tree. For non-root content nodes, the parent can be changed, as long
     * as the new parent is in the same camp as the old one. A content node is defined as root when
     * it has an owner.
     */
    #[ORM\ManyToOne(targetEntity: ContentNode::class, inversedBy: 'children')]
    #[ORM\JoinColumn(onDelete: 'CASCADE')]
    public ?ContentNode $parent = null;

    /**
     * All content nodes that are direct children of this content node.
     */
    #[ORM\OneToMany(targetEntity: ContentNode::class, mappedBy: 'parent', cascade: ['persist'])]
    public Collection $children;


    /**
     * Holds the actual data of the content node
     */
    #[ORM\Column(type: 'json', nullable: true, options: ["jsonb" => true])]
    public ?array $data = null;

    /**
     * The name of the slot in the parent in which this content node resides. The valid slot names
     * are defined by the content type of the parent.
     */
    #[ORM\Column(type: 'text', nullable: true)]
    public ?string $slot = null;

    /**
     * A whole number used for ordering multiple content nodes that are in the same slot of the
     * same parent. The API does not guarantee the uniqueness of parent+slot+position.
     */
    #[ORM\Column(type: 'integer', nullable: false)]
    public int $position = -1;

    /**
     * An optional name for this content node. This is useful when planning e.g. an alternative
     * version of the programme suited for bad weather, in addition to the normal version.
     */
    #[ORM\Column(type: 'text', nullable: true)]
    public ?string $instanceName = null;

    /**
     * Defines the type of this content node. There is a fixed list of types that are implemented
     * in eCamp. Depending on the type, different content data and different slots may be allowed
     * in a content node. The content type may not be changed once the content node is created.
     */
    #[ORM\ManyToOne(targetEntity: ContentType::class)]
    #[ORM\JoinColumn(nullable: false)]
    public ?ContentType $contentType = null;
}

Example API response (without variation)

{
  "_links": {
    "self": {
      "href": "/content_nodes/8e2119b66e8f"
    },
    "root": {
      "href": "/content_nodes/d81a9dae4316"
    },
    "parent": {
      "href": "/content_nodes/945b99f32295"
    },
    "children": [],
    "contentType": {
      "href": "/content_types/cfccaecd4bad"
    },
    "camp": {
      "href": "/camps/c4cca3a51342"
    }
  },
  "slot": "1",
  "position": 0,
  "instanceName": null,
  "id": "8e2119b66e8f",
  "contentTypeName": "Storyboard",
  "data": {
    "sections": [
      {
        "column1": null,
        "column2": "<p>Wir Treffen uns vor der Küche und alle Kinder werfen ihren Anonymen Brief in denn Pfaditopf. Danach wird nachmals der ganze Ablauf erklärt. </p><p>1. Still zum Bi-Pi Feuer laufen.</p><p>2. Feuer anzünden</p><p>3. Brief</p><p>4. Abteilungslied singen</p><p>5. Private Zettel vorlessen</p>",
        "column3": null,
        "position": 0
      },
      {
        "column1": null,
        "column2": "<p>Zusammen Laufen wir zum Bi-Pi Feuer und geniessen unser Ritual.</p><p>1. Der Feuermeister macht Feuer</p><p>2. Zera liest den Letzten Brief von Bi-Pi vor</p><p>3. Wir Singen das Abteilungslied</p><p>4. Wir geben denn Kessel durch und jeder zieht einen Zettel der er vorliest</p>",
        "column3": null,
        "position": 1
      },
      {
        "column1": null,
        "column2": "<p>laufen die Kinder (vereinzelt) zurück zum Lagerplatz wenn sie alles überdenkt haben.</p><p> Danach gibt es das Lagerfeuer-feeling in der Küche mit Schoggi-Bananen und Marshmallows.</p>",
        "column3": null,
        "position": 2
      },
    ]
  }
}

Example API response (with variation)

{
  "_links": {
    "self": {
      "href": "/content_nodes/8e2119b66e8f"
    },
    "root": {
      "href": "/content_nodes/d81a9dae4316"
    },
    "parent": {
      "href": "/content_nodes/945b99f32295"
    },
    "children": [
        "/content_nodes/f7310f31c767",
        "/content_nodes/6f1975b376c1",
        "/content_nodes/ed357d92cc86"
    ],
    "contentType": {
      "href": "/content_types/cfccaecd4bad"
    },
    "camp": {
      "href": "/camps/c4cca3a51342"
    }
  },
  "slot": "1",
  "position": 0,
  "instanceName": null,
  "id": "8e2119b66e8f",
  "contentTypeName": "Storyboard",
  "data": null
}
{
    "_links": {
      "self": {
        "href": "/content_nodes/f7310f31c767"
      },
      "root": {
        "href": "/content_nodes/d81a9dae4316"
      },
      "parent": {
        "href": "/content_nodes/8e2119b66e8f"
      },
      "children": [],
      "contentType": {
        "href": "/content_types/e8c03e4285cb"
      },
      "camp": {
        "href": "/camps/c4cca3a51342"
      }
    },
    "slot": null,
    "position": 0,
    "instanceName": null,
    "id": "f7310f31c767",
    "contentTypeName": "StoryboardSection",
    "data": {
        "column1": null,
        "column2": "<p>Wir Treffen uns vor der Küche und alle Kinder werfen ihren Anonymen Brief in denn Pfaditopf. Danach wird nachmals der ganze Ablauf erklärt. </p><p>1. Still zum Bi-Pi Feuer laufen.</p><p>2. Feuer anzünden</p><p>3. Brief</p><p>4. Abteilungslied singen</p><p>5. Private Zettel vorlessen</p>",
        "column3": null
    }
}

API response today (as reference)

{
  "_links": {
    "self": {
      "href": "/content_node/storyboards/8e2119b66e8f"
    },
    "sections": {
      "href": "/content_node/storyboard_sections?storyboard=/content_node/storyboards/8e2119b66e8f"
    },
    "root": {
      "href": "/content_node/column_layouts/d81a9dae4316"
    },
    "parent": {
      "href": "/content_node/column_layouts/945b99f32295"
    },
    "children": [],
    "contentType": {
      "href": "/content_types/cfccaecd4bad"
    },
    "owner": {
      "href": "/activities/b790b08a6b47"
    },
    "ownerCategory": {
      "href": "/categories/505e3fdf9e90"
    }
  },
  "_embedded": {
    "sections": [
      {
        "_links": {
          "self": {
            "href": "/content_node/storyboard_sections/ed357d92cc86"
          },
          "storyboard": {
            "href": "/content_node/storyboards/8e2119b66e8f"
          }
        },
        "column1": null,
        "column2": "<p>laufen die Kinder (vereinzelt) zurück zum Lagerplatz wenn sie alles überdenkt haben.</p><p> Danach gibt es das Lagerfeuer-feeling in der Küche mit Schoggi-Bananen und Marshmallows.</p>",
        "column3": null,
        "id": "ed357d92cc86",
        "position": 2
      },
      {
        "_links": {
          "self": {
            "href": "/content_node/storyboard_sections/f7310f31c767"
          },
          "storyboard": {
            "href": "/content_node/storyboards/8e2119b66e8f"
          }
        },
        "column1": null,
        "column2": "<p>Wir Treffen uns vor der Küche und alle Kinder werfen ihren Anonymen Brief in denn Pfaditopf. Danach wird nachmals der ganze Ablauf erklärt. </p><p>1. Still zum Bi-Pi Feuer laufen.</p><p>2. Feuer anzünden</p><p>3. Brief</p><p>4. Abteilungslied singen</p><p>5. Private Zettel vorlessen</p>",
        "column3": null,
        "id": "f7310f31c767",
        "position": 0
      },
      {
        "_links": {
          "self": {
            "href": "/content_node/storyboard_sections/6f1975b376c1"
          },
          "storyboard": {
            "href": "/content_node/storyboards/8e2119b66e8f"
          }
        },
        "column1": null,
        "column2": "<p>Zusammen Laufen wir zum Bi-Pi Feuer und geniessen unser Ritual.</p><p>1. Der Feuermeister macht Feuer</p><p>2. Zera liest den Letzten Brief von Bi-Pi vor</p><p>3. Wir Singen das Abteilungslied</p><p>4. Wir geben denn Kessel durch und jeder zieht einen Zettel der er vorliest</p>",
        "column3": null,
        "id": "6f1975b376c1",
        "position": 1
      }
    ]
  },
  "slot": "1",
  "position": 0,
  "instanceName": null,
  "id": "8e2119b66e8f",
  "contentTypeName": "Storyboard"
}

usu avatar Apr 20 '22 05:04 usu

So for you https://github.com/usu/ecamp3/pull/7 is not an option?

BacLuc avatar Apr 24 '22 13:04 BacLuc

So for you usu#7 is not an option?

usu#7 oder ähnlich wäre für mich das Minimum. Der Vorschlag hier geht halt ein gutes Stück weiter mit verschiedenen Pros/Cons. Wahrscheinlich ersparen wir uns halt einige Headaches, wenn wir versuchen die Inheritance zu umgehen. Auf der anderen Seite halt schon ein wenig ein Konzeptwechsel.

usu avatar Apr 24 '22 14:04 usu

Current state of affairs: https://github.com/usu/ecamp3/pull/8 (still very experimental)

usu avatar May 11 '22 20:05 usu

Implemented in #2825

usu avatar Oct 01 '22 14:10 usu