diff --git a/api/migrations/Version20250306152729.php b/api/migrations/Version20250306152729.php
new file mode 100644
index 00000000..fd915fce
--- /dev/null
+++ b/api/migrations/Version20250306152729.php
@@ -0,0 +1,37 @@
+addSql('CREATE TABLE book (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, isbn VARCHAR(255) DEFAULT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, author VARCHAR(255) NOT NULL, publication_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
+ $this->addSql('CREATE TABLE review (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, rating SMALLINT NOT NULL, body TEXT NOT NULL, author VARCHAR(255) NOT NULL, publication_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, book_id INT DEFAULT NULL, PRIMARY KEY(id))');
+ $this->addSql('CREATE INDEX IDX_794381C616A2B381 ON review (book_id)');
+ $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C616A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('CREATE SCHEMA public');
+ $this->addSql('ALTER TABLE review DROP CONSTRAINT FK_794381C616A2B381');
+ $this->addSql('DROP TABLE book');
+ $this->addSql('DROP TABLE review');
+ }
+}
diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php
new file mode 100644
index 00000000..e51d68a8
--- /dev/null
+++ b/api/src/Entity/Book.php
@@ -0,0 +1,77 @@
+ 'ASC',
+ 'isbn' => 'ASC',
+ 'title' => 'ASC',
+ 'author' => 'ASC',
+ 'publicationDate' => 'DESC'
+])]
+#[ApiFilter(SearchFilter::class, properties: [
+ 'id' => 'exact',
+ 'title' => 'ipartial',
+ 'author' => 'ipartial'
+])]
+#[ApiFilter(DateFilter::class, properties: ['publicationDate'])]
+class Book
+{
+ /** The ID of this book */
+ #[ORM\Id]
+ #[ORM\Column]
+ #[ORM\GeneratedValue]
+ private ?int $id = null;
+
+ /** The ISBN of this book (or null if doesn't have one) */
+ #[ORM\Column(nullable: true)]
+ public ?string $isbn = null;
+
+ /** The title of this book */
+ #[ORM\Column]
+ #[Assert\NotBlank]
+ #[ApiProperty(iris: ['http://schema.org/name'])]
+ public string $title = '';
+
+ /** The description of this book */
+ #[ORM\Column(type: 'text')]
+ #[Assert\NotBlank]
+ public string $description = '';
+
+ /** The author of this book */
+ #[ORM\Column]
+ #[Assert\NotBlank]
+ public string $author = '';
+
+ /** The publication date of this book */
+ #[ORM\Column]
+ #[Assert\NotNull]
+ public ?\DateTimeImmutable $publicationDate = null;
+
+ /** @var Review[] Available reviews for this book */
+ #[ORM\OneToMany(mappedBy: 'book', targetEntity: Review::class, cascade: ['persist', 'remove'])]
+ public iterable $reviews;
+
+ public function __construct()
+ {
+ $this->reviews = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+}
diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php
new file mode 100644
index 00000000..dc7de3f2
--- /dev/null
+++ b/api/src/Entity/Review.php
@@ -0,0 +1,69 @@
+ 'ASC',
+ 'rating' => 'ASC',
+ 'author' => 'ASC',
+ 'publicationDate' => 'DESC'
+])]
+#[ApiFilter(SearchFilter::class, properties: [
+ 'id' => 'exact',
+ 'body' => 'ipartial',
+ 'author' => 'ipartial'
+])]
+#[ApiFilter(NumericFilter::class, properties: ['rating'])]
+#[ApiFilter(DateFilter::class, properties: ['publicationDate'])]
+class Review
+{
+ /** The ID of this review */
+ #[ORM\Id]
+ #[ORM\Column]
+ #[ORM\GeneratedValue]
+ private ?int $id = null;
+
+ /** The rating of this review (between 0 and 5) */
+ #[ORM\Column(type: 'smallint')]
+ #[Assert\Range(min: 0, max: 5)]
+ public int $rating = 0;
+
+ /** The body of this review */
+ #[ORM\Column(type: 'text')]
+ #[Assert\NotBlank]
+ public string $body = '';
+
+ /** The author of this review */
+ #[ORM\Column]
+ #[Assert\NotBlank]
+ public string $author = '';
+
+ /** The publication date of this review */
+ #[ORM\Column]
+ #[Assert\NotNull]
+ #[ApiProperty(iris: ['http://schema.org/name'])]
+ public ?\DateTimeImmutable $publicationDate = null;
+
+ /** The book this review is about */
+ #[ORM\ManyToOne(inversedBy: 'reviews')]
+ #[Assert\NotNull]
+ public ?Book $book = null;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+}
diff --git a/src/stories/Custom.stories.tsx b/src/stories/Custom.stories.tsx
deleted file mode 100644
index a68b6281..00000000
--- a/src/stories/Custom.stories.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react';
-import { HydraAdmin } from '../hydra';
-import ResourceGuesser from '../core/ResourceGuesser';
-import ListGuesser from '../list/ListGuesser';
-import ShowGuesser from '../show/ShowGuesser';
-import FieldGuesser from '../field/FieldGuesser';
-import EditGuesser from '../edit/EditGuesser';
-import InputGuesser from '../input/InputGuesser';
-import CreateGuesser from '../create/CreateGuesser';
-
-export default {
- title: 'Admin/Custom',
- parameters: {
- layout: 'fullscreen',
- },
-};
-
-const GreetingList = () => (
-
-
-
-);
-
-const GreetingShow = () => (
-
-
-
-);
-
-const GreetingEdit = () => (
-
-
-
-);
-
-const GreetingCreate = () => (
-
-
-
-);
-
-export const Custom = () => (
-
-
-
-);
diff --git a/src/stories/custom/AdvancedCustomization.stories.tsx b/src/stories/custom/AdvancedCustomization.stories.tsx
new file mode 100644
index 00000000..c3c90b84
--- /dev/null
+++ b/src/stories/custom/AdvancedCustomization.stories.tsx
@@ -0,0 +1,247 @@
+import AutoStoriesIcon from '@mui/icons-material/AutoStories';
+import ReviewsIcon from '@mui/icons-material/Reviews';
+import { Rating, Stack } from '@mui/material';
+import React from 'react';
+import type { InputProps } from 'react-admin';
+import {
+ AutocompleteInput,
+ Create,
+ Datagrid,
+ DateField,
+ Edit,
+ Labeled,
+ Layout,
+ List,
+ NumberField,
+ ReferenceArrayField,
+ ReferenceField,
+ ReferenceInput,
+ Show,
+ SimpleForm,
+ SimpleList,
+ SimpleShowLayout,
+ TabbedShowLayout,
+ TextField,
+ TextInput,
+ WithRecord,
+ WrapperField,
+ defaultDarkTheme,
+ defaultLightTheme,
+ required,
+ useInput,
+} from 'react-admin';
+import ResourceGuesser from '../../core/ResourceGuesser';
+import FieldGuesser from '../../field/FieldGuesser';
+import { HydraAdmin } from '../../hydra';
+import InputGuesser from '../../input/InputGuesser';
+
+export default {
+ title: 'Admin/Custom/AdvancedCustomization',
+ parameters: {
+ layout: 'fullscreen',
+ },
+};
+
+const BookCreate = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const BookEdit = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const BookShow = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ review.author
+ .split(' ')
+ .map((name: string) => name[0])
+ .join('')
+ }
+ // eslint-disable-next-line react/no-unstable-nested-components
+ tertiaryText={(review) => (
+
+ )}
+ />
+
+
+
+
+);
+
+const BookList = () => (
+
+
+
+
+
+
+
+
+
+);
+
+const RatingInput = (props: InputProps) => {
+ const { field } = useInput(props);
+ return (
+ {
+ field.onChange(value);
+ }}
+ />
+ );
+};
+
+const filterToBookQuery = (searchText: string) => ({
+ title: `%${searchText}%`,
+});
+
+const ReviewCreate = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const ReviewEdit = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const ReviewShow = () => (
+
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+);
+
+const ReviewList = () => (
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+);
+
+export const AdvancedCustomization = () => (
+
+
+
+
+);
diff --git a/src/stories/custom/UsingGuessers.stories.tsx b/src/stories/custom/UsingGuessers.stories.tsx
new file mode 100644
index 00000000..79a59bee
--- /dev/null
+++ b/src/stories/custom/UsingGuessers.stories.tsx
@@ -0,0 +1,119 @@
+import AutoStoriesIcon from '@mui/icons-material/AutoStories';
+import ReviewsIcon from '@mui/icons-material/Reviews';
+import React from 'react';
+import { HydraAdmin } from '../../hydra';
+import ResourceGuesser from '../../core/ResourceGuesser';
+import ListGuesser from '../../list/ListGuesser';
+import ShowGuesser from '../../show/ShowGuesser';
+import FieldGuesser from '../../field/FieldGuesser';
+import EditGuesser from '../../edit/EditGuesser';
+import InputGuesser from '../../input/InputGuesser';
+import CreateGuesser from '../../create/CreateGuesser';
+
+export default {
+ title: 'Admin/Custom/UsingGuessers',
+ parameters: {
+ layout: 'fullscreen',
+ },
+};
+
+const BookCreate = () => (
+
+
+
+
+
+
+
+);
+
+const BookEdit = () => (
+
+
+
+
+
+
+
+);
+
+const BookShow = () => (
+
+
+
+
+
+
+
+
+);
+
+const BookList = () => (
+
+
+
+
+
+
+
+);
+
+const ReviewCreate = () => (
+
+
+
+
+
+
+
+);
+
+const ReviewEdit = () => (
+
+
+
+
+
+
+
+);
+
+const ReviewShow = () => (
+
+
+
+
+
+
+
+);
+
+const ReviewList = () => (
+
+
+
+
+
+
+);
+
+export const UsingGuessers = () => (
+
+
+
+
+);