feat(10-04): search, link and unlink related records through the relation manager

- relation-manager registered in the field registry; renders only on an
  existing record, never on create, and is never part of the save body
- RelationManager: relation schema label and comment, debounced search,
  selectable linked list (DataTable relation variant), toolbar buttons in
  declared order, confirmed unlink with plural messages and toasts
- RelationPickerModal: Reka Dialog (aria-modal, focus trap, Esc) over the
  candidates endpoint five per page, selection kept across pages, Dodaj (N)
  POSTs link, focus returns to the opener
- admin OpenAPI documents search, sort, dir, page and per_page on the linked
  and candidate relation routes so the SPA sends them typed
- neutral acme.demo.widgets members fixtures and relation smoke tests
This commit is contained in:
Jakub Zych
2026-09-27 17:18:14 +02:00
parent 6e1b6dd5bf
commit f4e97cccad
26 changed files with 1509 additions and 33 deletions

View File

@@ -309,6 +309,12 @@
"backend::lang.relation.link": {
"other": "Dołącz"
},
"backend::lang.relation.load_failed": {
"other": "Nie udało się wczytać powiązanych rekordów."
},
"backend::lang.relation.no_candidates": {
"other": "Brak rekordów do dodania."
},
"backend::lang.relation.remove": {
"other": "Usuń"
},

View File

@@ -115,12 +115,12 @@
]
},
{
"name": "parts",
"name": "members",
"type": "relation-manager",
"relation": "parts",
"relation": "members",
"span": "full",
"tab": "Parts",
"context": "update"
"tab": "Members",
"comment": "People who may edit this widget"
}
],
"messages": {

View File

@@ -0,0 +1,81 @@
{
"page1": {
"data": [
{
"id": 11,
"name": "Alan Turing",
"email": "alan.turing@example.test"
},
{
"id": 12,
"name": "Grace Hopper",
"email": "grace.hopper@example.test"
},
{
"id": 13,
"name": "Emmy Noether",
"email": "emmy.noether@example.test"
},
{
"id": 14,
"name": "Niels Bohr",
"email": "niels.bohr@example.test"
},
{
"id": 15,
"name": "Marie Curie",
"email": "marie.curie@example.test"
}
],
"meta": {
"page": 1,
"per_page": 5,
"total": 7,
"last_page": 2
}
},
"page2": {
"data": [
{
"id": 16,
"name": "Carl Gauss",
"email": "carl.gauss@example.test"
},
{
"id": 17,
"name": "Rosalind Franklin",
"email": "rosalind.franklin@example.test"
}
],
"meta": {
"page": 2,
"per_page": 5,
"total": 7,
"last_page": 2
}
},
"search": {
"data": [
{
"id": 13,
"name": "Emmy Noether",
"email": "emmy.noether@example.test"
}
],
"meta": {
"page": 1,
"per_page": 5,
"total": 1,
"last_page": 1
}
},
"empty": {
"data": [],
"meta": {
"page": 1,
"per_page": 5,
"total": 0,
"last_page": 0
}
}
}

View File

@@ -0,0 +1,51 @@
{
"all": {
"data": [
{
"id": 21,
"name": "Ada Byron",
"email": "ada.byron@example.test"
},
{
"id": 22,
"name": "Max Planck",
"email": "max.planck@example.test"
},
{
"id": 23,
"name": "Lise Meitner",
"email": "lise.meitner@example.test"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 3,
"last_page": 1
}
},
"search": {
"data": [
{
"id": 22,
"name": "Max Planck",
"email": "max.planck@example.test"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"last_page": 1
}
},
"empty": {
"data": [],
"meta": {
"page": 1,
"per_page": 20,
"total": 0,
"last_page": 0
}
}
}

View File

@@ -0,0 +1,81 @@
{
"data": {
"name": "members",
"label": "Members",
"view": {
"list": {
"columns": [
{
"key": "name",
"label": "Name",
"searchable": true,
"sortable": true
},
{
"key": "email",
"label": "E-mail",
"searchable": true,
"sortable": true
}
]
},
"toolbarButtons": [
"link",
"unlink"
],
"showSearch": true
},
"manage": {
"list": {
"columns": [
{
"key": "name",
"label": "Name",
"searchable": true,
"sortable": true
},
{
"key": "email",
"label": "E-mail",
"searchable": true,
"sortable": true
}
]
},
"toolbarButtons": [],
"showSearch": true
},
"messages": {
"link": {
"other": "Add member"
},
"linkHint": {
"other": "Pick people who are not members yet. The owner is not listed."
},
"candidateSearch": {
"other": "Search by name or email…"
},
"linked": {
"one": "Added :count member",
"other": "Added :count members"
},
"unlinkSelected": {
"other": "Remove selected"
},
"unlinkConfirm": {
"one": "Remove :count member?",
"other": "Remove :count members?"
},
"unlinked": {
"one": "Removed :count member",
"other": "Removed :count members"
},
"empty": {
"other": "This widget has no members."
}
}
},
"meta": {
"locale": "en"
}
}