From f22adbb06ae4910cd0ad473c0292212e798f4d35 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:18:23 +0200 Subject: [PATCH 01/48] test(features): pin repeater entries kept one at a time --- .../features/features/fields-repeater.feature | 284 ++++++++++++++++++ test/features/features/fields-tenants.feature | 33 ++ 2 files changed, 317 insertions(+) diff --git a/test/features/features/fields-repeater.feature b/test/features/features/fields-repeater.feature index 67b8b45..8a6de09 100644 --- a/test/features/features/fields-repeater.feature +++ b/test/features/features/fields-repeater.feature @@ -140,3 +140,287 @@ Feature: A repeater keeps a list of entries on a contact | comment | Comment | LONGTEXT | Then the catalogue lists "history" with label "History" and kind REPEATER And the mapping registry does not list "history" + + @wip + Scenario: A sub field named id is refused + When the operator defines the repeater "history" labelled "History" with sub fields: + | name | label | kind | + | id | ID | TEXT | + Then the definition is refused with the reason "field_sub_field_name_reserved" + + @wip + Scenario: Adding an entry stores it with an id + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + When the operator adds an entry to "history" of the contact: + | date | comment | + | 2026-09-01 | First call | + Then the add answers the entry with an id + And the contact's "history" answers the entries, newest first: + | date | comment | + | 2026-09-01 | First call | + + @wip + Scenario: Entries answer newest first + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + When the operator adds the entries to "history" of the contact, oldest first: + | date | comment | + | 2026-09-01 | First call | + | 2026-09-10 | Sent the offer | + Then the contact's "history" answers the entries, newest first: + | date | comment | + | 2026-09-10 | Sent the offer | + | 2026-09-01 | First call | + And every entry keeps the id its add answered + + @wip + Scenario: An entry naming an unknown sub field is refused + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + When the operator adds an entry to "history" of the contact: + | date | mood | + | 2026-09-01 | happy | + Then the write is refused naming "history.mood" as the bad key + + @wip + Scenario: An entry naming its own id on an add is refused + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + When the operator adds the raw entry {"id": "0199a3c4-0000-7000-8000-000000000001", "comment": "First call"} to "history" of the contact + Then the write is refused naming "history.id" as the bad key + + @wip + Scenario: An entry holding a value of the wrong kind is refused + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + When the operator adds an entry to "history" of the contact: + | date | comment | + | not a date | First call | + Then the write is refused for a value of the wrong kind + + @wip + Scenario Outline: An entry with every cell blank is refused + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + When the operator adds the raw entry to "history" of the contact + Then the change is refused with the reason "field_entry_empty" + + Examples: + | entry | + | {} | + | {"date": null, "comment": ""} | + | {"comment": " "} | + + @wip + Scenario: Adding to a field that is not a repeater is refused + Given the field "birthDate" labelled "Birth date" of kind DATE is defined + When the operator adds an entry to "birthDate" of the contact: + | date | + | 2026-09-01 | + Then the change is refused with the reason "field_not_a_repeater" + + @wip + Scenario: Adding to an archived repeater is refused + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator archives the field "history" + When the operator adds an entry to "history" of the contact: + | comment | + | First call | + Then the write is refused naming "history" as the bad key + + @wip + Scenario: Editing an entry changes only its cells and keeps its id and its place + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | date | comment | + | 2026-09-01 | First call | + | 2026-09-10 | Sent the offer | + When the operator edits the entry "First call" of "history" to: + | date | comment | + | 2026-09-02 | First call, moved out | + Then the contact's "history" answers the entries, newest first: + | date | comment | + | 2026-09-10 | Sent the offer | + | 2026-09-02 | First call, moved out | + And every entry keeps the id its add answered + + @wip + Scenario: Editing an entry after another add still changes that entry + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | Sent the offer | + When the operator edits the entry "First call" of "history" to: + | comment | + | First call, amended | + Then the contact's "history" answers the entries, newest first: + | comment | + | Sent the offer | + | First call, amended | + + @wip + Scenario: An edit that sends the entry's own id back is accepted + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + When the operator edits the entry "First call" of "history" sending its own id with: + | comment | + | First call, amended | + Then the contact's "history" answers the entries, newest first: + | comment | + | First call, amended | + And every entry keeps the id its add answered + + @wip + Scenario: An edit of the wrong kind is refused and leaves the entry unchanged + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | date | comment | + | 2026-09-01 | First call | + When the operator edits the entry "First call" of "history" to: + | date | comment | + | not a date | First call | + Then the write is refused for a value of the wrong kind + And the contact's "history" answers the entries, newest first: + | date | comment | + | 2026-09-01 | First call | + + @wip + Scenario: An edit with every cell blank is refused and leaves the entry unchanged + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + When the operator edits the entry "First call" of "history" to the raw entry {"comment": " "} + Then the change is refused with the reason "field_entry_empty" + And the contact's "history" answers the entries, newest first: + | comment | + | First call | + + @wip + Scenario: Editing an entry that was removed is refused + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + And the operator removes the entry "First call" from "history" + When the operator edits the entry "First call" of "history" to: + | comment | + | First call, amended | + Then the change is refused with the reason "field_entry_not_found" + + @wip + Scenario: Deleting an entry drops only that entry + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + | Sent the offer | + | Signed | + When the operator removes the entry "Sent the offer" from "history" + Then the contact's "history" answers the entries, newest first: + | comment | + | Signed | + | First call | + And every entry keeps the id its add answered + + @wip + Scenario: Deleting the last entry clears the repeater + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + When the operator removes the entry "First call" from "history" + Then the contact "Maria Perez" answers null for the field "history" + + @wip + Scenario: Deleting an unknown entry is refused + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + When the operator removes an unknown entry from "history" + Then the change is refused with the reason "field_entry_not_found" + And the contact's "history" answers the entries, newest first: + | comment | + | First call | + + @wip + Scenario: Entry ids survive archiving the repeater and defining it again + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + When the operator archives the field "history" + And the operator defines the repeater "history" labelled "History" with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + Then the contact's "history" answers the entries, newest first: + | comment | + | First call | + And every entry keeps the id its add answered + + @wip + Scenario: Saving other fields keeps the entries + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the field "birthDate" labelled "Birth date" of kind DATE is defined + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + When the operator writes "1990-04-17" into "birthDate" of the contact + Then the contact's "history" answers the entries, newest first: + | comment | + | First call | + And every entry keeps the id its add answered + + @wip + Scenario: Writing a repeater through the field values is refused + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + When the operator writes the rows into "history" of the contact: + | comment | + | First call | + Then the change is refused with the reason "field_repeater_entries_only" diff --git a/test/features/features/fields-tenants.feature b/test/features/features/fields-tenants.feature index 01f4b08..3a1a63d 100644 --- a/test/features/features/fields-tenants.feature +++ b/test/features/features/fields-tenants.feature @@ -57,3 +57,36 @@ Feature: Each tenant keeps its own contact fields When the import is committed Then the commit answers 1 imported And the contact "Maria Perez" answers "1990-04-17" for the field "birthDate" + + @wip + Scenario: A tenant cannot add an entry to another tenant's contact + Given a contact named "Maria Perez" + And the tenant "Acme" defines the repeater "history" labelled "History" with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + When the tenant "Acme" adds an entry to "history" of the contact: + | comment | + | First call | + Then the change is refused with the reason "contact_not_found" + + @wip + Scenario: A tenant cannot edit or remove another tenant's entry + Given a contact named "Maria Perez" + And the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + And the operator added the entries to "history" of the contact, oldest first: + | comment | + | First call | + And the tenant "Acme" defines the repeater "history" labelled "History" with sub fields: + | name | label | kind | + | comment | Comment | LONGTEXT | + When the tenant "Acme" edits the entry "First call" of "history" to: + | comment | + | Changed | + Then the change is refused with the reason "field_entry_not_found" + When the tenant "Acme" removes the entry "First call" from "history" + Then the change is refused with the reason "field_entry_not_found" + And the contact's "history" answers the entries, newest first: + | comment | + | First call | From b2873789093fbc2999564079080da1218e5eca57 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:22:01 +0200 Subject: [PATCH 02/48] feat(fields): reserve id as a sub field name --- plugins/fields/field.go | 8 ++++++++ plugins/fields/field_internal_test.go | 2 ++ plugins/fields/frontend/errorTemplates.ts | 1 + plugins/fields/frontend/languages/es-ES.json | 2 +- plugins/fields/graphql.go | 1 + plugins/fields/graphql_internal_test.go | 11 ++++++----- plugins/fields/languages/alphone-fields.pot | 3 +++ plugins/fields/languages/es-ES.po | 4 ++++ test/features/features/fields-repeater.feature | 1 - 9 files changed, 26 insertions(+), 7 deletions(-) diff --git a/plugins/fields/field.go b/plugins/fields/field.go index 861040a..002bbcf 100644 --- a/plugins/fields/field.go +++ b/plugins/fields/field.go @@ -26,8 +26,13 @@ var ( errSubFieldNested = errors.New("fields: a sub field cannot be a repeater") errSubFieldNameInvalid = errors.New("fields: a sub field name is camelCase, starting with a lowercase letter") errSubFieldNameTaken = errors.New("fields: two sub fields of one repeater hold the same name") + + errSubFieldNameReserved = fmt.Errorf("fields: a sub field cannot be named %s", entryIDKey) ) +// entryIDKey is the key a repeater entry keeps its id under. +const entryIDKey = "id" + // labelMax caps how long a human label runs. const labelMax = 120 @@ -155,6 +160,9 @@ func checkSubField(column SubField, taken map[string]bool) (SubField, error) { if !namePattern.MatchString(column.Name) { return SubField{}, errSubFieldNameInvalid } + if column.Name == entryIDKey { + return SubField{}, errSubFieldNameReserved + } if taken[column.Name] { return SubField{}, errSubFieldNameTaken } diff --git a/plugins/fields/field_internal_test.go b/plugins/fields/field_internal_test.go index 427def3..fe42ffb 100644 --- a/plugins/fields/field_internal_test.go +++ b/plugins/fields/field_internal_test.go @@ -179,6 +179,8 @@ func TestNewDefinitionRefusesSubFieldsItCannotHold(t *testing.T) { "a name held twice": {"REPEATER", []SubField{ {Name: "note", Label: "Note", Kind: kindText}, {Name: "note", Label: "Other", Kind: kindText}, }, errSubFieldNameTaken}, + "the name an entry keeps its id under": {"REPEATER", + []SubField{{Name: "id", Label: "ID", Kind: kindText}}, errSubFieldNameReserved}, "an unknown kind": {"REPEATER", []SubField{{Name: "due", Label: "Due", Kind: "TIMESTAMP"}}, errUnknownKind}, "a blank label": {"REPEATER", diff --git a/plugins/fields/frontend/errorTemplates.ts b/plugins/fields/frontend/errorTemplates.ts index e131510..4672d3b 100644 --- a/plugins/fields/frontend/errorTemplates.ts +++ b/plugins/fields/frontend/errorTemplates.ts @@ -22,6 +22,7 @@ export function errorTemplates(): Record { field_sub_field_nested: __('A repeater cannot hold another repeater.', DOMAIN), field_sub_field_name_invalid: __('A sub field name starts lowercase and runs together, like followUp.', DOMAIN), field_sub_field_name_taken: __('Two sub fields of this repeater hold the same name.', DOMAIN), + field_sub_field_name_reserved: __('A sub field cannot be named id. Each entry keeps that name for itself.', DOMAIN), field_name_taken: __('Another field already holds that name.', DOMAIN), field_kind_locked: __('An archived field of that name holds another kind or other sub fields.', DOMAIN), field_not_found: __('That field no longer exists.', DOMAIN), diff --git a/plugins/fields/frontend/languages/es-ES.json b/plugins/fields/frontend/languages/es-ES.json index d50c1d8..5b70aae 100644 --- a/plugins/fields/frontend/languages/es-ES.json +++ b/plugins/fields/frontend/languages/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":""},"%(label)s %(number)d":["%(label)s %(number)d"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Fields":["Campos"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move entry down":["Bajar la entrada"],"Move entry up":["Subir la entrada"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove entry":["Quitar la entrada"],"Remove sub field":["Quitar el subcampo"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} +{"":{"lang":"es","plural-forms":""},"%(label)s %(number)d":["%(label)s %(number)d"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Fields":["Campos"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move entry down":["Bajar la entrada"],"Move entry up":["Subir la entrada"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove entry":["Quitar la entrada"],"Remove sub field":["Quitar el subcampo"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} diff --git a/plugins/fields/graphql.go b/plugins/fields/graphql.go index fe5743e..8cb6e30 100644 --- a/plugins/fields/graphql.go +++ b/plugins/fields/graphql.go @@ -170,6 +170,7 @@ var fieldReasons = []struct { {errSubFieldNested, "field_sub_field_nested"}, {errSubFieldNameInvalid, "field_sub_field_name_invalid"}, {errSubFieldNameTaken, "field_sub_field_name_taken"}, + {errSubFieldNameReserved, "field_sub_field_name_reserved"}, {errNameTaken, "field_name_taken"}, {errKindLocked, "field_kind_locked"}, {errNoDefinition, "field_not_found"}, diff --git a/plugins/fields/graphql_internal_test.go b/plugins/fields/graphql_internal_test.go index 4bb8cfc..149d87d 100644 --- a/plugins/fields/graphql_internal_test.go +++ b/plugins/fields/graphql_internal_test.go @@ -78,11 +78,12 @@ func TestEverySubFieldSentinelNamesItsReason(t *testing.T) { t.Parallel() want := map[error]string{ - errSubFieldsRequired: "field_sub_fields_required", - errSubFieldsUnexpected: "field_sub_fields_unexpected", - errSubFieldNested: "field_sub_field_nested", - errSubFieldNameInvalid: "field_sub_field_name_invalid", - errSubFieldNameTaken: "field_sub_field_name_taken", + errSubFieldsRequired: "field_sub_fields_required", + errSubFieldsUnexpected: "field_sub_fields_unexpected", + errSubFieldNested: "field_sub_field_nested", + errSubFieldNameInvalid: "field_sub_field_name_invalid", + errSubFieldNameTaken: "field_sub_field_name_taken", + errSubFieldNameReserved: "field_sub_field_name_reserved", } for sentinel, reason := range want { diff --git a/plugins/fields/languages/alphone-fields.pot b/plugins/fields/languages/alphone-fields.pot index 766bd8f..4708363 100644 --- a/plugins/fields/languages/alphone-fields.pot +++ b/plugins/fields/languages/alphone-fields.pot @@ -22,6 +22,9 @@ msgstr "" msgid "A repeater needs at least one sub field." msgstr "" +msgid "A sub field cannot be named id. Each entry keeps that name for itself." +msgstr "" + msgid "A sub field name starts lowercase and runs together, like followUp." msgstr "" diff --git a/plugins/fields/languages/es-ES.po b/plugins/fields/languages/es-ES.po index 147b84d..a697835 100644 --- a/plugins/fields/languages/es-ES.po +++ b/plugins/fields/languages/es-ES.po @@ -28,6 +28,10 @@ msgstr "Un repetidor no puede contener otro repetidor." msgid "A repeater needs at least one sub field." msgstr "Un repetidor necesita al menos un subcampo." +#, fuzzy +msgid "A sub field cannot be named id. Each entry keeps that name for itself." +msgstr "Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma." + #, fuzzy msgid "A sub field name starts lowercase and runs together, like followUp." msgstr "El nombre de un subcampo empieza por minúscula y va todo junto, como followUp." diff --git a/test/features/features/fields-repeater.feature b/test/features/features/fields-repeater.feature index 8a6de09..ca06470 100644 --- a/test/features/features/fields-repeater.feature +++ b/test/features/features/fields-repeater.feature @@ -141,7 +141,6 @@ Feature: A repeater keeps a list of entries on a contact Then the catalogue lists "history" with label "History" and kind REPEATER And the mapping registry does not list "history" - @wip Scenario: A sub field named id is refused When the operator defines the repeater "history" labelled "History" with sub fields: | name | label | kind | From 20d18a367fb66009c9715aed92af6c1ef404f115 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:22:01 +0200 Subject: [PATCH 03/48] docs(fields): say a sub field cannot be named id --- docs/src/content/docs/guides/fields.md | 3 ++- docs/src/content/docs/reference/graphql-api.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/guides/fields.md b/docs/src/content/docs/guides/fields.md index 261786e..bbbf49a 100644 --- a/docs/src/content/docs/guides/fields.md +++ b/docs/src/content/docs/guides/fields.md @@ -202,7 +202,8 @@ mutation { ``` The API does not make sub field names for you. Send a camelCase name for each -one, unique inside the repeater. +one, unique inside the repeater. `id` is refused, because each entry keeps its +own id under that name. ## What stays fixed diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index cf13603..24971d5 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -419,6 +419,7 @@ The stock plugins add their own: | `field_sub_field_nested` | | a sub field cannot be a repeater | | `field_sub_field_name_invalid` | | a sub field name is camelCase | | `field_sub_field_name_taken` | | two sub fields of one repeater share a name | +| `field_sub_field_name_reserved` | | a sub field is named `id`, which each entry keeps for itself | | `field_name_taken` | | another definition holds the name | | `field_kind_locked` | | an archived definition pins the kind and sub fields | | `field_not_found` | | the id names no live definition | From 93e6073cf9461db746aeceaf65553ac04b209bf3 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:30:22 +0200 Subject: [PATCH 04/48] feat(fields): give every stored repeater entry an id --- plugins/fields/migration_internal_test.go | 238 ++++++++++++++++++ .../00006_add_repeater_entry_ids.sql | 43 ++++ 2 files changed, 281 insertions(+) create mode 100644 plugins/fields/migration_internal_test.go create mode 100644 plugins/fields/migrations/00006_add_repeater_entry_ids.sql diff --git a/plugins/fields/migration_internal_test.go b/plugins/fields/migration_internal_test.go new file mode 100644 index 0000000..f3cbb90 --- /dev/null +++ b/plugins/fields/migration_internal_test.go @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package fields + +import ( + "database/sql" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/database" + + "github.com/gopherium/alphone/sdk" +) + +// entryIDsVersion is the migration that gives every stored repeater entry an id. +const entryIDsVersion = 6 + +// fieldsProvider returns a goose provider over the plugin's migrations and its own version table. +func fieldsProvider(t *testing.T, db *sql.DB) *goose.Provider { + t.Helper() + store, err := database.NewStore(database.DialectPostgres, "plugin_fields.goose_db_version") + if err != nil { + t.Fatalf("building the version store: %v", err) + } + provider, err := goose.NewProvider("", db, migrationSource, goose.WithStore(store)) + if err != nil { + t.Fatalf("building the provider: %v", err) + } + return provider +} + +// beforeEntryIDs returns a plugin and its database rolled back to the schema before entry ids. +func beforeEntryIDs(t *testing.T) (*Plugin, *sql.DB, *goose.Provider) { + t.Helper() + p := newMigratedPlugin(t) + db := stdlib.OpenDBFromPool(p.pool) + t.Cleanup(func() { _ = db.Close() }) + provider := fieldsProvider(t, db) + if _, err := provider.DownTo(t.Context(), entryIDsVersion-1); err != nil { + t.Fatalf("rolling back to the schema before entry ids: %v", err) + } + return p, db, provider +} + +// storedDefinition stores one definition row in a tenant as the schema before entry ids holds it. +func storedDefinition(t *testing.T, db *sql.DB, tenant uuid.UUID, name, kind, subFields string, archived bool) { + t.Helper() + if _, err := db.ExecContext(t.Context(), + `INSERT INTO plugin_fields.definitions (id, name, label, kind, sub_fields, archived_at, created_at, tenant_id) + VALUES ($1, $2, $2, $3, $4::jsonb, CASE WHEN $5 THEN now() END, now(), $6)`, + uuid.Must(uuid.NewV7()), name, kind, subFields, archived, tenant); err != nil { + t.Fatalf("storing the definition %s: %v", name, err) + } +} + +// contactHolding stores one contact in a tenant holding the given values. +func contactHolding(t *testing.T, db *sql.DB, tenant uuid.UUID, values string) uuid.UUID { + t.Helper() + id := uuid.Must(uuid.NewV7()) + if _, err := db.ExecContext(t.Context(), + "INSERT INTO core.contacts (id, name, created_at, tenant_id) VALUES ($1, 'Maria Perez', now(), $2)", + id, tenant); err != nil { + t.Fatalf("storing the contact: %v", err) + } + if _, err := db.ExecContext(t.Context(), + "INSERT INTO plugin_fields.contact_values (contact_id, values, tenant_id) VALUES ($1, $2::jsonb, $3)", + id, values, tenant); err != nil { + t.Fatalf("storing the values: %v", err) + } + return id +} + +// heldValues reads the values one contact holds. +func heldValues(t *testing.T, db *sql.DB, contactID uuid.UUID) map[string]any { + t.Helper() + var raw []byte + if err := db.QueryRowContext(t.Context(), + "SELECT values FROM plugin_fields.contact_values WHERE contact_id = $1", contactID).Scan(&raw); err != nil { + t.Fatalf("reading the values: %v", err) + } + var values map[string]any + if err := json.Unmarshal(raw, &values); err != nil { + t.Fatalf("decoding %s: %v", raw, err) + } + return values +} + +// decoded turns a JSON literal into the value the database answers for it. +func decoded(t *testing.T, literal string) map[string]any { + t.Helper() + var values map[string]any + if err := json.Unmarshal([]byte(literal), &values); err != nil { + t.Fatalf("decoding %s: %v", literal, err) + } + return values +} + +// splitIDs returns the ids of a stored list and its entries with their ids dropped. +func splitIDs(t *testing.T, list any) ([]string, []any) { + t.Helper() + entries, isList := list.([]any) + if !isList { + t.Fatalf("stored list = %#v, want a list", list) + } + ids := make([]string, 0, len(entries)) + cells := make([]any, 0, len(entries)) + for _, entry := range entries { + held, isObject := entry.(map[string]any) + if !isObject { + t.Fatalf("entry = %#v, want an object", entry) + } + id, _ := held[entryIDKey].(string) + if _, err := uuid.Parse(id); err != nil { + t.Errorf("entry %#v holds no id, want one minted", held) + } + ids = append(ids, id) + rest := map[string]any{} + for key, value := range held { + if key != entryIDKey { + rest[key] = value + } + } + cells = append(cells, rest) + } + return ids, cells +} + +const ( + commentColumns = `[{"name": "comment", "label": "Comment", "kind": "LONGTEXT"}]` + storedHistory = `{"history": [{"comment": "First call"}, {"comment": "Sent the offer"}], + "calls": [{"comment": "Rang back"}], "nickname": "Mari"}` +) + +func TestEntryIDsMigrationGivesEveryEntryAnIDNewestFirst(t *testing.T) { + t.Parallel() + + _, db, provider := beforeEntryIDs(t) + home := sdk.TenantOrDefault(t.Context()) + storedDefinition(t, db, home, "history", "REPEATER", commentColumns, false) + storedDefinition(t, db, home, "calls", "REPEATER", commentColumns, true) + storedDefinition(t, db, home, "nickname", "TEXT", "[]", false) + maria := contactHolding(t, db, home, storedHistory) + + if _, err := provider.UpTo(t.Context(), entryIDsVersion); err != nil { + t.Fatalf("applying entry ids: %v", err) + } + + held := heldValues(t, db, maria) + ids, cells := splitIDs(t, held["history"]) + want := []any{map[string]any{"comment": "Sent the offer"}, map[string]any{"comment": "First call"}} + if !reflect.DeepEqual(cells, want) { + t.Errorf("history = %#v, want the stored entries newest first", cells) + } + if len(ids) != 2 || ids[0] == ids[1] { + t.Errorf("ids = %v, want two distinct ids", ids) + } + if _, archived := splitIDs(t, held["calls"]); len(archived) != 1 { + t.Errorf("calls = %#v, want the archived repeater's entry given an id too", held["calls"]) + } + if held["nickname"] != "Mari" { + t.Errorf("nickname = %#v, want the plain value untouched", held["nickname"]) + } +} + +func TestEntryIDsMigrationLeavesEveryOtherValueAlone(t *testing.T) { + t.Parallel() + + p, db, provider := beforeEntryIDs(t) + home := sdk.TenantOrDefault(t.Context()) + acme := sdk.TenantOrDefault(inTenant(t, p)) + storedDefinition(t, db, home, "history", "REPEATER", commentColumns, false) + storedDefinition(t, db, home, "nickname", "TEXT", "[]", false) + storedDefinition(t, db, acme, "history", "TEXT", "[]", false) + literals := []struct { + tenant uuid.UUID + values string + }{ + {home, `{"nickname": "Rosa"}`}, + {home, `{"history": []}`}, + {acme, `{"history": "Called twice"}`}, + {home, `{"history": [{"id": "kept", "comment": "Kept"}]}`}, + } + stored := make(map[uuid.UUID]string, len(literals)) + for _, held := range literals { + stored[contactHolding(t, db, held.tenant, held.values)] = held.values + } + + if _, err := provider.UpTo(t.Context(), entryIDsVersion); err != nil { + t.Fatalf("applying entry ids: %v", err) + } + + for contactID, literal := range stored { + if held := heldValues(t, db, contactID); !reflect.DeepEqual(held, decoded(t, literal)) { + t.Errorf("values = %#v, want %s left as stored", held, literal) + } + } +} + +func TestEntryIDsMigrationRestoresTheStoredListsGoingDown(t *testing.T) { + t.Parallel() + + _, db, provider := beforeEntryIDs(t) + home := sdk.TenantOrDefault(t.Context()) + storedDefinition(t, db, home, "history", "REPEATER", commentColumns, false) + storedDefinition(t, db, home, "calls", "REPEATER", commentColumns, true) + storedDefinition(t, db, home, "nickname", "TEXT", "[]", false) + maria := contactHolding(t, db, home, storedHistory) + if _, err := provider.UpTo(t.Context(), entryIDsVersion); err != nil { + t.Fatalf("applying entry ids: %v", err) + } + + if _, err := provider.DownTo(t.Context(), entryIDsVersion-1); err != nil { + t.Fatalf("rolling entry ids back: %v", err) + } + + if held := heldValues(t, db, maria); !reflect.DeepEqual(held, decoded(t, storedHistory)) { + t.Errorf("values = %#v, want the lists back oldest first with no ids", held) + } +} + +func TestEntryIDsMigrationStopsAtASubFieldNamedID(t *testing.T) { + t.Parallel() + + _, db, provider := beforeEntryIDs(t) + storedDefinition(t, db, sdk.TenantOrDefault(t.Context()), "history", "REPEATER", + `[{"name": "id", "label": "ID", "kind": "TEXT"}]`, true) + + _, err := provider.UpTo(t.Context(), entryIDsVersion) + + if err == nil || !strings.Contains(err.Error(), "definitions_sub_fields_no_id") { + t.Errorf("UpTo() error = %v, want the sub field named id to stop the migration", err) + } +} diff --git a/plugins/fields/migrations/00006_add_repeater_entry_ids.sql b/plugins/fields/migrations/00006_add_repeater_entry_ids.sql new file mode 100644 index 0000000..6642ae2 --- /dev/null +++ b/plugins/fields/migrations/00006_add_repeater_entry_ids.sql @@ -0,0 +1,43 @@ +-- SPDX-License-Identifier: Elastic-2.0 + +-- +goose Up +ALTER TABLE plugin_fields.definitions ADD CONSTRAINT definitions_sub_fields_no_id + CHECK (NOT jsonb_path_exists(sub_fields, '$[*] ? (@.name == "id")')); + +UPDATE plugin_fields.contact_values AS held +SET values = held.values || rebuilt.lists +FROM ( + SELECT v.tenant_id, v.contact_id, jsonb_object_agg(d.name, ( + SELECT jsonb_agg(listed.entry || jsonb_build_object('id', uuidv7()) ORDER BY listed.at DESC) + FROM jsonb_array_elements(v.values -> d.name) WITH ORDINALITY AS listed (entry, at) + )) AS lists + FROM plugin_fields.contact_values AS v + JOIN plugin_fields.definitions AS d + ON d.tenant_id = v.tenant_id + AND d.kind = 'REPEATER' + AND jsonb_typeof(v.values -> d.name) = 'array' + AND jsonb_array_length(v.values -> d.name) > 0 + AND NOT jsonb_path_exists(v.values -> d.name, '$[*].id') + GROUP BY v.tenant_id, v.contact_id +) AS rebuilt +WHERE held.tenant_id = rebuilt.tenant_id AND held.contact_id = rebuilt.contact_id; + +-- +goose Down +UPDATE plugin_fields.contact_values AS held +SET values = held.values || rebuilt.lists +FROM ( + SELECT v.tenant_id, v.contact_id, jsonb_object_agg(d.name, ( + SELECT jsonb_agg(listed.entry - 'id' ORDER BY listed.at DESC) + FROM jsonb_array_elements(v.values -> d.name) WITH ORDINALITY AS listed (entry, at) + )) AS lists + FROM plugin_fields.contact_values AS v + JOIN plugin_fields.definitions AS d + ON d.tenant_id = v.tenant_id + AND d.kind = 'REPEATER' + AND jsonb_typeof(v.values -> d.name) = 'array' + AND jsonb_path_exists(v.values -> d.name, '$[*].id') + GROUP BY v.tenant_id, v.contact_id +) AS rebuilt +WHERE held.tenant_id = rebuilt.tenant_id AND held.contact_id = rebuilt.contact_id; + +ALTER TABLE plugin_fields.definitions DROP CONSTRAINT definitions_sub_fields_no_id; From 1fa749d372fb8b08bb332a46d59bdb7bd9a377b9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:38:49 +0200 Subject: [PATCH 05/48] feat(fields): check one repeater entry --- plugins/fields/value.go | 56 ++++++++++- plugins/fields/value_internal_test.go | 140 ++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/plugins/fields/value.go b/plugins/fields/value.go index 9f6b34a..e44d274 100644 --- a/plugins/fields/value.go +++ b/plugins/fields/value.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "math" "sort" "strings" @@ -17,8 +18,58 @@ var ( errWrongKind = errors.New("fields: the value does not match the kind its definition declares") errNoField = errors.New("fields: no live definition holds that name") errValuesNotAnObject = errors.New("fields: values is an object of field names to values") + errEntryEmpty = errors.New("fields: an entry holds at least one filled cell") ) +// checkEntry returns the storable cells of one repeater entry, refusing unknown keys, wrong kinds and a blank entry. +func checkEntry(name string, columns []SubField, given any, own string) (map[string]any, error) { + cells, ok := given.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: %s expects an entry", errWrongKind, name) + } + entry, unknown, err := checkRow(name, columnKinds(columns), withoutOwnID(cells, own)) + if err != nil { + return nil, err + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, fmt.Errorf("%w: %s", errNoField, strings.Join(unknown, ", ")) + } + dropBlankText(entry) + if len(entry) == 0 { + return nil, fmt.Errorf("%w: %s", errEntryEmpty, name) + } + return entry, nil +} + +// withoutOwnID returns the cells with an id cell naming the entry's own id left out. +func withoutOwnID(cells map[string]any, own string) map[string]any { + if own == "" || cells[entryIDKey] != own { + return cells + } + kept := maps.Clone(cells) + delete(kept, entryIDKey) + return kept +} + +// dropBlankText removes every text cell holding nothing but white space. +func dropBlankText(entry map[string]any) { + for key, value := range entry { + if text, isText := value.(string); isText && strings.TrimSpace(text) == "" { + delete(entry, key) + } + } +} + +// columnKinds maps each sub field name to the kind it declares. +func columnKinds(columns []SubField) map[string]kind { + kindsOf := make(map[string]kind, len(columns)) + for _, column := range columns { + kindsOf[column.Name] = column.Kind + } + return kindsOf +} + // coerce returns the storable form of a value, refusing one of another kind. func coerce(held kind, given any) (any, error) { if given == nil { @@ -134,10 +185,7 @@ func checkRows(name string, columns []SubField, given any) (any, []string, error if !ok { return nil, nil, fmt.Errorf("%w: %s expects %s", errWrongKind, name, kindRepeater) } - kindsOf := make(map[string]kind, len(columns)) - for _, column := range columns { - kindsOf[column.Name] = column.Kind - } + kindsOf := columnKinds(columns) var unknown []string rows := make([]map[string]any, 0, len(listed)) for at, entry := range listed { diff --git a/plugins/fields/value_internal_test.go b/plugins/fields/value_internal_test.go index cb10763..036ce0d 100644 --- a/plugins/fields/value_internal_test.go +++ b/plugins/fields/value_internal_test.go @@ -330,6 +330,146 @@ func TestCheckValuesClearsARepeaterWrittenWithoutRows(t *testing.T) { } } +// historyColumnsOf returns the sub fields of the history repeater the value tests hold. +func historyColumnsOf() []SubField { + return historyView().columns["history"] +} + +func TestCheckEntryKeepsItsCellsCoerced(t *testing.T) { + t.Parallel() + + checked, err := checkEntry("history", historyColumnsOf(), + map[string]any{"date": "2026-09-10", "comment": "Sent the offer", "count": float64(2)}, "") + + if err != nil { + t.Fatalf("checkEntry() error = %v, want nil", err) + } + want := map[string]any{"date": "2026-09-10", "comment": "Sent the offer", "count": int64(2)} + if !reflect.DeepEqual(checked, want) { + t.Errorf("entry = %#v, want every cell coerced", checked) + } +} + +func TestCheckEntryNamesEveryUnknownKeyWithoutAnIndex(t *testing.T) { + t.Parallel() + + _, err := checkEntry("history", historyColumnsOf(), + map[string]any{"date": "2026-09-01", "size": "large", "mood": "happy"}, "") + + if !errors.Is(err, errNoField) || !strings.HasSuffix(err.Error(), "history.mood, history.size") { + t.Errorf("checkEntry() error = %v, want every bad key named by its path, sorted", err) + } +} + +func TestCheckEntryNamesTheCellItRefused(t *testing.T) { + t.Parallel() + + _, err := checkEntry("visits", historyColumnsOf(), map[string]any{"date": "not a date"}, "") + + if !errors.Is(err, errWrongKind) || !strings.Contains(err.Error(), "visits.date expects DATE") { + t.Errorf("checkEntry() error = %v, want the cell named by its field and its kind", err) + } +} + +func TestCheckEntryRefusesAValueThatIsNotAnEntry(t *testing.T) { + t.Parallel() + + for name, given := range map[string]any{"a text": "a text", "a list": []any{}, "null": nil} { + _, err := checkEntry("history", historyColumnsOf(), given, "") + + if !errors.Is(err, errWrongKind) || !strings.Contains(err.Error(), "history expects an entry") { + t.Errorf("%s: checkEntry() error = %v, want the shape refused", name, err) + } + } +} + +func TestCheckEntryRefusesAnEntryWithEveryCellBlank(t *testing.T) { + t.Parallel() + + for name, given := range map[string]map[string]any{ + "no cells": {}, + "null cells": {"date": nil, "count": nil}, + "an empty text": {"date": nil, "comment": ""}, + "only spaces": {"comment": " "}, + "only line breaks": {"comment": "\n\t\n"}, + } { + _, err := checkEntry("history", historyColumnsOf(), given, "") + + if !errors.Is(err, errEntryEmpty) { + t.Errorf("%s: checkEntry() error = %v, want errEntryEmpty", name, err) + } + } +} + +func TestCheckEntryDropsBlankTextBesideAFilledCell(t *testing.T) { + t.Parallel() + + checked, err := checkEntry("history", historyColumnsOf(), + map[string]any{"date": "2026-09-01", "comment": " "}, "") + + if err != nil { + t.Fatalf("checkEntry() error = %v, want nil", err) + } + if want := map[string]any{"date": "2026-09-01"}; !reflect.DeepEqual(checked, want) { + t.Errorf("entry = %#v, want the blank comment left out", checked) + } +} + +func TestCheckEntryKeepsTextSurroundedBySpaceAsWritten(t *testing.T) { + t.Parallel() + + checked, err := checkEntry("history", historyColumnsOf(), + map[string]any{"comment": " Follow-up call.\nAsked for a second quote. "}, "") + + if err != nil { + t.Fatalf("checkEntry() error = %v, want nil", err) + } + if checked["comment"] != " Follow-up call.\nAsked for a second quote. " { + t.Errorf("comment = %#v, want the text kept as written", checked["comment"]) + } +} + +func TestCheckEntryRefusesAnIDCellOnAnAdd(t *testing.T) { + t.Parallel() + + _, err := checkEntry("history", historyColumnsOf(), + map[string]any{"id": "0199a3c4-0000-7000-8000-000000000001", "comment": "First call"}, "") + + if !errors.Is(err, errNoField) || !strings.HasSuffix(err.Error(), "history.id") { + t.Errorf("checkEntry() error = %v, want the id cell named as unknown", err) + } +} + +func TestCheckEntryAcceptsTheEntrysOwnIDBack(t *testing.T) { + t.Parallel() + own := "0199a3c4-0000-7000-8000-000000000001" + given := map[string]any{"id": own, "comment": "First call"} + + checked, err := checkEntry("history", historyColumnsOf(), given, own) + + if err != nil { + t.Fatalf("checkEntry() error = %v, want the entry's own id accepted", err) + } + if want := map[string]any{"comment": "First call"}; !reflect.DeepEqual(checked, want) { + t.Errorf("entry = %#v, want the cells without the id", checked) + } + if given["id"] != own { + t.Errorf("given = %#v, want the caller's entry left as sent", given) + } +} + +func TestCheckEntryRefusesAnotherEntrysID(t *testing.T) { + t.Parallel() + + _, err := checkEntry("history", historyColumnsOf(), + map[string]any{"id": "0199a3c4-0000-7000-8000-000000000002", "comment": "First call"}, + "0199a3c4-0000-7000-8000-000000000001") + + if !errors.Is(err, errNoField) || !strings.HasSuffix(err.Error(), "history.id") { + t.Errorf("checkEntry() error = %v, want another entry's id named as unknown", err) + } +} + func TestCheckValuesRefusesRowsOfAnotherShape(t *testing.T) { t.Parallel() From 368b99728eb1ad81ebf88e4560bb6cf59c353a1f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:45:11 +0200 Subject: [PATCH 06/48] feat(fields): keep a sub field key clear of id on the Fields screen --- plugins/fields/frontend/FieldsScreen.tsx | 6 +++--- plugins/fields/frontend/kind.ts | 3 +++ plugins/fields/frontend/test/fields.test.tsx | 13 +++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/plugins/fields/frontend/FieldsScreen.tsx b/plugins/fields/frontend/FieldsScreen.tsx index 338fa64..e5b2d79 100644 --- a/plugins/fields/frontend/FieldsScreen.tsx +++ b/plugins/fields/frontend/FieldsScreen.tsx @@ -26,7 +26,7 @@ import { useState } from 'react' import { fieldsIcon } from './icon' import type { FieldKind } from './gql/graphql' -import { kindItems, kindOf, subKindItems } from './kind' +import { ENTRY_ID_KEY, kindItems, kindOf, subKindItems } from './kind' import { archiveFieldMutation, defineFieldMutation, fieldsQuery } from './operations' const catalogueOperation = 'Fields' @@ -215,10 +215,10 @@ function labelList(subFields: SubFieldRow[]) { /** * Returns the sub fields as the define mutation takes them, each named from its label. * @param drafts - The sub fields the operator listed, in order. - * @returns The sub fields, each under a name no sibling repeats. + * @returns The sub fields, each under a name no sibling repeats and entries do not keep their id under. */ function namedSubFields(drafts: DraftSubField[]) { - const taken: string[] = [] + const taken: string[] = [ENTRY_ID_KEY] return drafts.map((draft) => { const name = keyFromLabel(draft.label, { style: 'camel', taken }) taken.push(name) diff --git a/plugins/fields/frontend/kind.ts b/plugins/fields/frontend/kind.ts index db19db4..8570ba0 100644 --- a/plugins/fields/frontend/kind.ts +++ b/plugins/fields/frontend/kind.ts @@ -4,6 +4,9 @@ import { _x } from '@alphone/frontend-sdk' import type { FieldKind } from './gql/graphql' +/** ENTRY_ID_KEY is the key a repeater entry keeps its id under. */ +export const ENTRY_ID_KEY = 'id' + /** * Returns the kind options, read fresh so the loaded catalogue answers. * @returns The options, in menu order. diff --git a/plugins/fields/frontend/test/fields.test.tsx b/plugins/fields/frontend/test/fields.test.tsx index f65e941..9c37d96 100644 --- a/plugins/fields/frontend/test/fields.test.tsx +++ b/plugins/fields/frontend/test/fields.test.tsx @@ -351,6 +351,19 @@ test('two sub fields sharing a label are sent under distinct names', async () => ]) }) +test('a sub field labelled ID is sent under a name other than the one entries keep their id under', async () => { + serveFields([]) + const defined = captureDefine() + + renderScreen() + await startRepeater() + await addSubField('ID', 'Text') + await userEvent.click(screen.getByRole('button', { name: 'Add field' })) + + await waitFor(() => expect(defined).toHaveBeenCalledTimes(1)) + expect(defined.mock.calls[0][0].subFields.map((column: { name: string }) => column.name)).toEqual(['id2']) +}) + test('the sub field kind menu leaves the repeater out', async () => { serveFields([]) From 7bb85686d469db1a30ddc168bc897797f76eae10 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:45:12 +0200 Subject: [PATCH 07/48] docs(fields): say a sub field labelled ID becomes id2 --- docs/src/content/docs/guides/fields.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/guides/fields.md b/docs/src/content/docs/guides/fields.md index bbbf49a..4a0129c 100644 --- a/docs/src/content/docs/guides/fields.md +++ b/docs/src/content/docs/guides/fields.md @@ -60,7 +60,8 @@ Long text. A sub field can be any kind except Repeater, so a repeater never holds another repeater. You do not type a name for a sub field. AlphOne makes one from its label, so `Follow-up comment` becomes `followUpComment`. Two sub fields with -the same label get two names, such as `note` and `note2`. +the same label get two names, such as `note` and `note2`. A sub field labelled +`ID` becomes `id2`, because each entry keeps its own id under `id`. The sub fields cannot be changed once the repeater exists. The reason is the same as for the kind: old entries would no longer fit. From ae8de24583671b0cb9b6fd9649b31535a655862a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:53:23 +0200 Subject: [PATCH 08/48] chore(deps): move to godmin 0.11.0 --- frontend/package.json | 2 +- pnpm-lock.yaml | 14 +++++++------- sdk/frontend/package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 7b86c4f..ac14f83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ "@alphone/plugin-fields": "workspace:*", "@alphone/plugin-importer": "workspace:*", "@alphone/plugin-whatsapp": "workspace:*", - "@gopherium/godmin": "0.10.0", + "@gopherium/godmin": "0.11.0", "@gopherium/gottext": "0.5.0", "@gopherium/react-auth": "0.9.0", "@tanstack/react-query": "^5.102.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeb37a4..104a4d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,8 +156,8 @@ importers: specifier: workspace:* version: link:../plugins/whatsapp/frontend '@gopherium/godmin': - specifier: 0.10.0 - version: 0.10.0(dcf93bb2df0535b72c5d2768136b7099) + specifier: 0.11.0 + version: 0.11.0(dcf93bb2df0535b72c5d2768136b7099) '@gopherium/gottext': specifier: 0.5.0 version: 0.5.0(@wordpress/i18n@6.28.0)(gettext-extractor@4.0.6) @@ -346,8 +346,8 @@ importers: sdk/frontend: dependencies: '@gopherium/godmin': - specifier: 0.10.0 - version: 0.10.0(dcf93bb2df0535b72c5d2768136b7099) + specifier: 0.11.0 + version: 0.11.0(dcf93bb2df0535b72c5d2768136b7099) '@gopherium/gottext': specifier: 0.5.0 version: 0.5.0(@wordpress/i18n@6.28.0)(gettext-extractor@4.0.6) @@ -1028,8 +1028,8 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@gopherium/godmin@0.10.0': - resolution: {integrity: sha512-9OqbHwd40wdOd4lUKow1Jn4DOA4g/vHw11roILCj74sdVJvG3uaZxb++8H82pst7RpFOmYolyIdwQFNW37csqA==} + '@gopherium/godmin@0.11.0': + resolution: {integrity: sha512-vSlqfHLahbjPArW1uKOb0rh8nMwD7jUrEo7i+boxkXAI0KiNXh/8kBMisgBUdTPkNHM3hIe3dQ3VeZ4VBQUp0Q==} peerDependencies: '@tanstack/react-router': ^1.170.0 '@testing-library/react': ^16.3.2 @@ -5690,7 +5690,7 @@ snapshots: '@floating-ui/utils@0.2.12': {} - '@gopherium/godmin@0.10.0(dcf93bb2df0535b72c5d2768136b7099)': + '@gopherium/godmin@0.11.0(dcf93bb2df0535b72c5d2768136b7099)': dependencies: '@wordpress/style-runtime': 0.11.0 '@wordpress/theme': 2.1.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(vite@8.2.2(@types/node@26.5.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) diff --git a/sdk/frontend/package.json b/sdk/frontend/package.json index c40d9f5..3a861e5 100644 --- a/sdk/frontend/package.json +++ b/sdk/frontend/package.json @@ -9,7 +9,7 @@ "./testing": "./testing.tsx" }, "dependencies": { - "@gopherium/godmin": "0.10.0", + "@gopherium/godmin": "0.11.0", "@gopherium/gottext": "0.5.0", "@gopherium/react-auth": "0.9.0", "@tanstack/react-query": "^5.102.8", From 869695372387d4cb301c15cde7a26ba9ff4a35d9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:53:23 +0200 Subject: [PATCH 09/48] feat(sdk): export the log list --- sdk/frontend/index.ts | 3 +++ sdk/frontend/test/logList.test.tsx | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 sdk/frontend/test/logList.test.tsx diff --git a/sdk/frontend/index.ts b/sdk/frontend/index.ts index fd2c099..c5a28ba 100644 --- a/sdk/frontend/index.ts +++ b/sdk/frontend/index.ts @@ -72,6 +72,9 @@ export { LoadingRows, LoadingScreen, LoadMore, + LogItem, + LogList, + LogTime, Page as PageScreen, PageTitle, RepeatRows, diff --git a/sdk/frontend/test/logList.test.tsx b/sdk/frontend/test/logList.test.tsx new file mode 100644 index 0000000..0f427d2 --- /dev/null +++ b/sdk/frontend/test/logList.test.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { render, screen, within } from '@testing-library/react' +import { expect, test } from 'vitest' + +import { LogItem, LogList, LogTime } from '../index' + +test('hands plugins a log list of dated items', () => { + render( + <> +

Notes

+ + Sep 1} + body="First call" + /> + + , + ) + + const list = screen.getByRole('list', { name: 'Notes' }) + const item = within(list).getByRole('listitem', { name: 'Note from Sep 1' }) + expect(within(item).getByText('Sep 1').closest('time')).toHaveAttribute('datetime', '2026-09-01') + expect(within(item).getByText('First call')).toBeInTheDocument() +}) From 2961454fd29f74517fc39cf594cea7575e1597bd Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 26 Sep 2026 22:55:35 +0200 Subject: [PATCH 10/48] refactor(fields): move the cell inputs into their own module --- .../fields/frontend/ContactFieldsPanel.tsx | 127 +----------------- plugins/fields/frontend/cellText.ts | 62 +++++++++ plugins/fields/frontend/cells.tsx | 63 +++++++++ 3 files changed, 128 insertions(+), 124 deletions(-) create mode 100644 plugins/fields/frontend/cellText.ts create mode 100644 plugins/fields/frontend/cells.tsx diff --git a/plugins/fields/frontend/ContactFieldsPanel.tsx b/plugins/fields/frontend/ContactFieldsPanel.tsx index ff39268..4944fae 100644 --- a/plugins/fields/frontend/ContactFieldsPanel.tsx +++ b/plugins/fields/frontend/ContactFieldsPanel.tsx @@ -3,14 +3,11 @@ import { __, Button, - Checkbox, ErrorNotice, - InputControl, LoadingRows, RepeatRows, Stack, Text, - TextareaControl, graphError, sprintf, useGraph, @@ -20,6 +17,9 @@ import { } from '@alphone/frontend-sdk' import { useId, useMemo, useState } from 'react' +import { entryText, textOf, typedValue } from './cellText' +import type { EntryText, SubFieldRow } from './cellText' +import { FieldInput } from './cells' import { contactValuesDocument } from './document' import { fieldsQuery, writeContactFieldsMutation } from './operations' @@ -34,16 +34,6 @@ interface FieldRow { subFields: SubFieldRow[] } -/** SubFieldRow is one sub field of a repeater, edited as a cell of every entry. */ -interface SubFieldRow { - name: string - label: string - kind: string -} - -/** EntryText is the text of one repeater entry, keyed by sub field name. */ -type EntryText = Record - /** * Renders the runtime defined fields of one contact. * @param props - The contact whose values the panel reads and writes. @@ -150,16 +140,6 @@ function FieldsForm({ ) } -/** - * Returns the value an entry holds under one of its own keys, never an inherited one. - * @param entry - The stored cells, keyed by sub field name. - * @param key - The sub field name to read. - * @returns The value, or undefined when the entry holds no such key of its own. - */ -function own(entry: Record, key: string) { - return Object.hasOwn(entry, key) ? entry[key] : undefined -} - /** * Renders the entries of one repeater, each a group of its sub field inputs. * @param props - The repeater, its stored entries, the edited ones and the change handler. @@ -213,78 +193,6 @@ function EntriesInput({ ) } -/** - * Renders one field's input, matched to the kind its definition declares. - * @param props - The field, its current text and the change handler. - * @returns The field input. - */ -function FieldInput({ - field, - value, - onChange, -}: { - field: { label: string; kind: string } - value: string - onChange: (next: string) => void -}) { - if (field.kind === 'BOOLEAN') { - return ( - - onChange(checked ? 'true' : 'false')} - /> - {field.label} - - ) - } - if (field.kind === 'LONGTEXT') { - return ( - onChange(event.target.value)} - /> - ) - } - return ( - onChange(event.target.value)} - /> - ) -} - -/** - * Returns the HTML input type one field kind is edited with. - * @param kind - The kind the definition declares. - * @returns The input type. - */ -function inputType(kind: string) { - if (kind === 'NUMBER') { - return 'number' - } - if (kind === 'DATE') { - return 'date' - } - return 'text' -} - -/** - * Returns the text form of a stored value. - * @param stored - The value the graph answered. - * @returns The text the input renders. - */ -function textOf(stored: unknown) { - if (stored === null || stored === undefined) { - return '' - } - return String(stored) -} - /** * Returns the text of every stored entry of one repeater. * @param subFields - The sub fields every entry holds. @@ -296,16 +204,6 @@ function entriesOf(subFields: SubFieldRow[], stored: unknown) { return held.map((entry) => entryText(subFields, entry)) } -/** - * Returns the text of one entry, one cell per sub field. - * @param subFields - The sub fields the entry holds. - * @param entry - The stored cells, keyed by sub field name. - * @returns The text of every cell, empty where the entry holds none. - */ -function entryText(subFields: SubFieldRow[], entry: Record): EntryText { - return Object.fromEntries(subFields.map((column) => [column.name, textOf(own(entry, column.name))])) -} - /** * Returns the edited values in the form the write mutation takes. * @param fields - The catalogue the panel renders. @@ -362,22 +260,3 @@ function typedEntries(subFields: SubFieldRow[], changed: EntryText[]) { Object.fromEntries(subFields.map((column) => [column.name, typedValue(column.kind, entry[column.name])])), ) } - -/** - * Returns one typed value the graph accepts for the given kind. - * @param kind - The kind the definition declares. - * @param text - The text the operator typed. - * @returns The typed value, or null when the text is blank. - */ -function typedValue(kind: string, text: string) { - if (text === '') { - return null - } - if (kind === 'NUMBER') { - return Number(text) - } - if (kind === 'BOOLEAN') { - return text === 'true' - } - return text -} diff --git a/plugins/fields/frontend/cellText.ts b/plugins/fields/frontend/cellText.ts new file mode 100644 index 0000000..87e54a1 --- /dev/null +++ b/plugins/fields/frontend/cellText.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** SubFieldRow is one sub field of a repeater, edited as a cell of every entry. */ +export interface SubFieldRow { + name: string + label: string + kind: string +} + +/** EntryText is the text of one repeater entry, keyed by sub field name. */ +export type EntryText = Record + +/** + * Returns the value an entry holds under one of its own keys, never an inherited one. + * @param entry - The stored cells, keyed by sub field name. + * @param key - The sub field name to read. + * @returns The value, or undefined when the entry holds no such key of its own. + */ +function own(entry: Record, key: string) { + return Object.hasOwn(entry, key) ? entry[key] : undefined +} + +/** + * Returns the text form of a stored value. + * @param stored - The value the graph answered. + * @returns The text the input renders. + */ +export function textOf(stored: unknown) { + if (stored === null || stored === undefined) { + return '' + } + return String(stored) +} + +/** + * Returns the text of one entry, one cell per sub field. + * @param subFields - The sub fields the entry holds. + * @param entry - The stored cells, keyed by sub field name. + * @returns The text of every cell, empty where the entry holds none. + */ +export function entryText(subFields: SubFieldRow[], entry: Record): EntryText { + return Object.fromEntries(subFields.map((column) => [column.name, textOf(own(entry, column.name))])) +} + +/** + * Returns one typed value the graph accepts for the given kind. + * @param kind - The kind the definition declares. + * @param text - The text the operator typed. + * @returns The typed value, or null when the text is blank. + */ +export function typedValue(kind: string, text: string) { + if (text === '') { + return null + } + if (kind === 'NUMBER') { + return Number(text) + } + if (kind === 'BOOLEAN') { + return text === 'true' + } + return text +} diff --git a/plugins/fields/frontend/cells.tsx b/plugins/fields/frontend/cells.tsx new file mode 100644 index 0000000..f43f71c --- /dev/null +++ b/plugins/fields/frontend/cells.tsx @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Checkbox, InputControl, Stack, Text, TextareaControl } from '@alphone/frontend-sdk' + +/** + * Renders one field's input, matched to the kind its definition declares. + * @param props - The field, its current text and the change handler. + * @returns The field input. + */ +export function FieldInput({ + field, + value, + onChange, +}: { + field: { label: string; kind: string } + value: string + onChange: (next: string) => void +}) { + if (field.kind === 'BOOLEAN') { + return ( + + onChange(checked ? 'true' : 'false')} + /> + {field.label} + + ) + } + if (field.kind === 'LONGTEXT') { + return ( + onChange(event.target.value)} + /> + ) + } + return ( + onChange(event.target.value)} + /> + ) +} + +/** + * Returns the HTML input type one field kind is edited with. + * @param kind - The kind the definition declares. + * @returns The input type. + */ +function inputType(kind: string) { + if (kind === 'NUMBER') { + return 'number' + } + if (kind === 'DATE') { + return 'date' + } + return 'text' +} From 4e6cf63ba7444f0a8aa952533f4f0c2f2201b683 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 11:17:10 +0200 Subject: [PATCH 11/48] test(postgres): walk each function's own statement consts --- internal/postgres/pluginpredicate_test.go | 79 +++++++++++++++++------ 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/internal/postgres/pluginpredicate_test.go b/internal/postgres/pluginpredicate_test.go index 0ff1f37..b54a9c0 100644 --- a/internal/postgres/pluginpredicate_test.go +++ b/internal/postgres/pluginpredicate_test.go @@ -6,9 +6,11 @@ import ( "go/ast" "go/parser" "go/token" + "maps" "os" "path/filepath" "regexp" + "slices" "strconv" "strings" "testing" @@ -68,17 +70,30 @@ func answersBeforeTheTenant(statement string) bool { return false } -// sqlLiterals returns every statement a source builds, resolving the consts it names. +// sqlLiterals returns every statement a source builds, resolving each const in the function that declares it. func sqlLiterals(t *testing.T, source string) []string { t.Helper() parsed, err := parser.ParseFile(token.NewFileSet(), source, nil, 0) if err != nil { t.Fatalf("parsing %s: %v", source, err) } - consts := stringConsts(parsed) + shared := stringConsts(parsed, map[string]string{}) var held []string - ast.Inspect(parsed, func(node ast.Node) bool { - if declared, ok := node.(*ast.GenDecl); ok && declared.Tok == token.CONST { + for _, declared := range parsed.Decls { + consts := shared + if function, ok := declared.(*ast.FuncDecl); ok { + consts = stringConsts(function, shared) + } + held = append(held, literalsIn(declared, consts)...) + } + return held +} + +// literalsIn returns every statement one declaration builds from the given consts. +func literalsIn(declared ast.Node, consts map[string]string) []string { + var held []string + ast.Inspect(declared, func(node ast.Node) bool { + if constant, ok := node.(*ast.GenDecl); ok && constant.Tok == token.CONST { return false } joined, ok := joinedString(node, consts) @@ -91,28 +106,34 @@ func sqlLiterals(t *testing.T, source string) []string { return held } -// stringConsts returns the text every const in a source spells. -func stringConsts(parsed *ast.File) map[string]string { - held := map[string]string{} - ast.Inspect(parsed, func(node ast.Node) bool { - declared, ok := node.(*ast.GenDecl) - if !ok || declared.Tok != token.CONST { - return true +// stringConsts returns the text every const one scope spells over the consts it inherits, leaving nested functions out. +func stringConsts(scope ast.Node, inherited map[string]string) map[string]string { + held := maps.Clone(inherited) + ast.Inspect(scope, func(node ast.Node) bool { + if function, ok := node.(*ast.FuncDecl); ok && function != scope { + return false } - for _, spec := range declared.Specs { - valued, ok := spec.(*ast.ValueSpec) - if !ok || len(valued.Names) != 1 || len(valued.Values) != 1 { - continue - } - if text, ok := joinedString(valued.Values[0], held); ok { - held[valued.Names[0].Name] = text - } + if declared, ok := node.(*ast.GenDecl); ok && declared.Tok == token.CONST { + recordConsts(declared, held) } return true }) return held } +// recordConsts adds the text every single-valued const of one declaration spells. +func recordConsts(declared *ast.GenDecl, held map[string]string) { + for _, spec := range declared.Specs { + valued, ok := spec.(*ast.ValueSpec) + if !ok || len(valued.Names) != 1 || len(valued.Values) != 1 { + continue + } + if text, ok := joinedString(valued.Values[0], held); ok { + held[valued.Names[0].Name] = text + } + } +} + // joinedString returns the text one literal, named const or concatenation of them spells. func joinedString(node ast.Node, consts map[string]string) (string, bool) { switch held := node.(type) { @@ -177,6 +198,26 @@ func TestTheWalkerResolvesAStatementBuiltFromAConst(t *testing.T) { t.Errorf("statements = %q, want the const joined with the appended clause", statements) } +func TestTheWalkerReadsEachFunctionsOwnConst(t *testing.T) { + t.Parallel() + + source := filepath.Join(t.TempDir(), "store.go") + held := "package p\n\n" + + "func first() string { const statement = `SELECT id FROM plugin_x.first`\n return statement }\n\n" + + "func second() string { const statement = `SELECT id FROM plugin_x.second`\n return statement }\n" + if err := os.WriteFile(source, []byte(held), 0o600); err != nil { + t.Fatalf("writing the source: %v", err) + } + + statements := sqlLiterals(t, source) + + for _, table := range []string{"plugin_x.first", "plugin_x.second"} { + if !slices.ContainsFunc(statements, func(statement string) bool { return strings.Contains(statement, table) }) { + t.Errorf("statements = %q, want the statement reading %s walked", statements, table) + } + } +} + func TestEveryPluginStatementFiltersItsTableByTenant(t *testing.T) { t.Parallel() From d4b25238f6793ba67f4078c714e010294fa868ea Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 11:19:34 +0200 Subject: [PATCH 12/48] test(postgres): hold each table a statement touches to the caller's tenant --- internal/postgres/pluginpredicate_test.go | 4 +- internal/postgres/tenantpredicate_test.go | 45 ++++++++++++++++------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/internal/postgres/pluginpredicate_test.go b/internal/postgres/pluginpredicate_test.go index b54a9c0..ce7ce85 100644 --- a/internal/postgres/pluginpredicate_test.go +++ b/internal/postgres/pluginpredicate_test.go @@ -228,12 +228,12 @@ func TestEveryPluginStatementFiltersItsTableByTenant(t *testing.T) { continue } for _, statement := range statements { - if tenantSafe(statement) || answersBeforeTheTenant(statement) { + if answersBeforeTheTenant(statement) { continue } for _, table := range held.tables { named := regexp.MustCompile(`\b` + held.schema + `\.` + table + `\b`) - if named.MatchString(statement) { + if named.MatchString(statement) && !tenantSafe(statement, table) { t.Errorf("%s touches %s.%s without filtering by tenant_id: %s", held.source, held.schema, table, statement) } diff --git a/internal/postgres/tenantpredicate_test.go b/internal/postgres/tenantpredicate_test.go index 1ca798e..deddafd 100644 --- a/internal/postgres/tenantpredicate_test.go +++ b/internal/postgres/tenantpredicate_test.go @@ -61,11 +61,11 @@ var tenantParameter = regexp.MustCompile(`tenant_id\s*=\s*(\$\d+|@\w+)`) // conflictArbiter captures the columns an ON CONFLICT clause arbitrates on. var conflictArbiter = regexp.MustCompile(`(?is)ON\s+CONFLICT\s*\(([^)]*)\)`) -// insertedColumns captures the columns an INSERT names for its table. -var insertedColumns = regexp.MustCompile(`(?is)INSERT\s+INTO\s+\w+\.\w+\s*\(([^)]*)\)`) +// insertedColumns captures the table an INSERT writes and the columns it names for it. +var insertedColumns = regexp.MustCompile(`(?is)INSERT\s+INTO\s+\w+\.(\w+)\s*\(([^)]*)\)`) -// tenantSafe reports whether a statement holds its rows to the caller's tenant. -func tenantSafe(statement string) bool { +// tenantSafe reports whether a statement holds one table it touches to the caller's tenant. +func tenantSafe(statement, table string) bool { if arbiter := conflictArbiter.FindStringSubmatch(statement); arbiter != nil && !strings.Contains(arbiter[1], "tenant_id") { return false @@ -73,21 +73,18 @@ func tenantSafe(statement string) bool { if tenantParameter.MatchString(statement) { return true } - if columns := insertedColumns.FindStringSubmatch(statement); columns != nil { - return strings.Contains(columns[1], "tenant_id") + if columns := insertedColumns.FindStringSubmatch(statement); columns != nil && columns[1] == table { + return strings.Contains(columns[2], "tenant_id") } return false } // unguarded returns the guarded tables a query touches without holding them to the caller's tenant. func unguarded(query string, guarded []string) []string { - if tenantSafe(query) { - return nil - } var touched []string for _, table := range guarded { pattern := regexp.MustCompile(`\b(core|plugin_[a-z]+)\.` + table + `\b`) - if pattern.MatchString(query) { + if pattern.MatchString(query) && !tenantSafe(query, table) { touched = append(touched, table) } } @@ -102,7 +99,7 @@ func TestTheGateRefusesATenantIdThatHoldsNothingToTheCaller(t *testing.T) { WHERE m.conversation_id = conv.id AND m.tenant_id = conv.tenant_id) x ON TRUE WHERE conv.contact_id = ANY($1)` - if tenantSafe(joining) { + if tenantSafe(joining, "conversations") { t.Error("tenantSafe() admitted a column to column tenant join, want the caller's tenant demanded") } } @@ -114,7 +111,7 @@ func TestTheGateRefusesAConflictArbiterSpanningTenants(t *testing.T) { VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value` - if tenantSafe(spanning) { + if tenantSafe(spanning, "user_settings") { t.Error("tenantSafe() admitted an arbiter spanning tenants, want tenant_id demanded in it") } } @@ -126,7 +123,7 @@ func TestTheGateAdmitsAConflictArbiterCarryingTheTenant(t *testing.T) { VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tenant_id, channel, identifier) DO NOTHING` - if !tenantSafe(held) { + if !tenantSafe(held, "contact_identities") { t.Error("tenantSafe() refused a tenant composite arbiter, want it admitted") } } @@ -136,11 +133,31 @@ func TestTheGateAdmitsAnInsertStampingItsTenant(t *testing.T) { stamping := `INSERT INTO core.contacts (id, name, created_at, tenant_id) VALUES ($1, $2, $3, $4)` - if !tenantSafe(stamping) { + if !tenantSafe(stamping, "contacts") { t.Error("tenantSafe() refused an insert stamping its tenant, want it admitted") } } +// stampingFromAnUnfilteredRead inserts a row stamped with its tenant from a table it reads without the caller's. +const stampingFromAnUnfilteredRead = `INSERT INTO plugin_fields.contact_values (contact_id, values, tenant_id) + SELECT c.id, '{}', c.tenant_id FROM core.contacts AS c WHERE c.id = $1` + +func TestTheGateRefusesATableAnInsertOnlyReads(t *testing.T) { + t.Parallel() + + if tenantSafe(stampingFromAnUnfilteredRead, "contacts") { + t.Error("tenantSafe() admitted a read the insert columns never filter, want the caller's tenant demanded") + } +} + +func TestTheGateAdmitsTheTableAnInsertStamps(t *testing.T) { + t.Parallel() + + if !tenantSafe(stampingFromAnUnfilteredRead, "contact_values") { + t.Error("tenantSafe() refused the table the insert stamps, want it admitted") + } +} + func TestTheGateNamesAQueryTouchingAGuardedTableWithoutItsTenant(t *testing.T) { t.Parallel() From db31f3770b234b8b9470948450e7ec4d620170c6 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 11:19:34 +0200 Subject: [PATCH 13/48] test(postgres): walk the fields store and seed over the core tables they read --- internal/postgres/pluginpredicate_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/postgres/pluginpredicate_test.go b/internal/postgres/pluginpredicate_test.go index ce7ce85..3960f67 100644 --- a/internal/postgres/pluginpredicate_test.go +++ b/internal/postgres/pluginpredicate_test.go @@ -27,6 +27,16 @@ var pluginStores = []struct { schema: "plugin_fields", tables: []string{"definitions", "contact_values"}, }, + { + source: filepath.Join("..", "..", "plugins", "fields", "store.go"), + schema: "core", + tables: []string{"contacts"}, + }, + { + source: filepath.Join("..", "..", "plugins", "fields", "seed.go"), + schema: "core", + tables: []string{"contacts", "contact_identities"}, + }, { source: filepath.Join("..", "..", "plugins", "importer", "store.go"), schema: "plugin_importer", From a55c9b8ac517f49f2eba220e1a1103a95b01fa48 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 11:28:48 +0200 Subject: [PATCH 14/48] feat(fields): store repeater entries one statement at a time --- plugins/fields/entries_store_internal_test.go | 676 ++++++++++++++++++ plugins/fields/store.go | 108 +++ 2 files changed, 784 insertions(+) create mode 100644 plugins/fields/entries_store_internal_test.go diff --git a/plugins/fields/entries_store_internal_test.go b/plugins/fields/entries_store_internal_test.go new file mode 100644 index 0000000..4175fb2 --- /dev/null +++ b/plugins/fields/entries_store_internal_test.go @@ -0,0 +1,676 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package fields + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/gopherium/alphone/sdk" +) + +// roomy is a limit no store test reaches unless it means to. +const roomy = 10 + +// noted builds the cells of one entry holding only a comment. +func noted(comments ...string) []map[string]any { + entries := make([]map[string]any, len(comments)) + for at, comment := range comments { + entries[at] = map[string]any{"comment": comment} + } + return entries +} + +// added stores entries on a contact's history in the tenant the context serves, returning them with their ids. +func added(t *testing.T, p *Plugin, ctx context.Context, contactID uuid.UUID, comments ...string) []map[string]any { + t.Helper() + stored, err := p.store.addEntry(ctx, contactID, "history", noted(comments...), roomy) + if err != nil { + t.Fatalf("addEntry() error = %v, want nil", err) + } + return stored +} + +// idOf returns the id one stored entry carries. +func idOf(t *testing.T, entry map[string]any) uuid.UUID { + t.Helper() + id, err := uuid.Parse(entry[entryIDKey].(string)) + if err != nil { + t.Fatalf("entry id %v: %v", entry[entryIDKey], err) + } + return id +} + +// historyHeld reads the comments and ids a contact's history holds, in stored order. +func historyHeld(t *testing.T, p *Plugin, ctx context.Context, contactID uuid.UUID) ([]string, []string) { + t.Helper() + listed, _ := storedValues(t, p, ctx, contactID)["history"].([]any) + comments := make([]string, 0, len(listed)) + ids := make([]string, 0, len(listed)) + for _, entry := range listed { + cells := entry.(map[string]any) + comment, _ := cells["comment"].(string) + comments = append(comments, comment) + id, _ := cells[entryIDKey].(string) + ids = append(ids, id) + } + return comments, ids +} + +// wantHistory fails unless a contact's history holds exactly the given comments in order. +func wantHistory(t *testing.T, p *Plugin, ctx context.Context, contactID uuid.UUID, want ...string) { + t.Helper() + if held, _ := historyHeld(t, p, ctx, contactID); !slices.Equal(held, want) { + t.Errorf("history = %q, want %q", held, want) + } +} + +// foreignContact stores a contact in the default tenant and returns a context serving another tenant. +func foreignContact(t *testing.T, p *Plugin) (context.Context, uuid.UUID) { + t.Helper() + return inTenant(t, p), seedContact(t, p, "Maria Perez") +} + +func TestAddEntryStoresTheFirstEntryOnAContactWithNoRow(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + + stored, err := p.store.addEntry(t.Context(), contactID, "history", + []map[string]any{{"date": "2026-09-01", "comment": "First call."}}, roomy) + + if err != nil { + t.Fatalf("addEntry() error = %v, want nil", err) + } + if len(stored) != 1 || idOf(t, stored[0]).Version() != 7 || stored[0]["date"] != "2026-09-01" { + t.Fatalf("addEntry() = %v, want the entry answered under a UUIDv7 id", stored) + } + comments, ids := historyHeld(t, p, t.Context(), contactID) + if !slices.Equal(comments, []string{"First call."}) || ids[0] != stored[0][entryIDKey] { + t.Errorf("history = %q %q, want the answered entry stored", comments, ids) + } +} + +func TestAddEntryPutsTheLastGivenOnTop(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + + stored := added(t, p, t.Context(), contactID, "one", "two", "three") + + wantHistory(t, p, t.Context(), contactID, "three", "two", "one") + if stored[0]["comment"] != "three" { + t.Errorf("addEntry() = %v, want the answer in stored order", stored) + } + _, ids := historyHeld(t, p, t.Context(), contactID) + if len(slices.Compact(slices.Sorted(slices.Values(ids)))) != 3 { + t.Errorf("ids = %q, want each entry under its own id", ids) + } +} + +func TestAddEntryPrependsToAHeldList(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + added(t, p, t.Context(), contactID, "first") + + added(t, p, t.Context(), contactID, "second") + + wantHistory(t, p, t.Context(), contactID, "second", "first") +} + +func TestAddEntryLeavesOtherKeysAlone(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + visits := []any{map[string]any{"id": "kept", "place": "Office"}} + if err := p.store.writeValues(t.Context(), contactID, + map[string]any{"birthDate": "1990-04-17", "visits": visits}); err != nil { + t.Fatalf("writeValues() error = %v, want nil", err) + } + + added(t, p, t.Context(), contactID, "call") + + held := storedValues(t, p, t.Context(), contactID) + if held["birthDate"] != "1990-04-17" || len(held["visits"].([]any)) != 1 { + t.Errorf("values = %v, want the other keys untouched", held) + } +} + +func TestEntriesKeepEachRepeaterApart(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + added(t, p, t.Context(), contactID, "call") + visits, err := p.store.addEntry(t.Context(), contactID, "visits", []map[string]any{{"place": "Office"}}, roomy) + if err != nil { + t.Fatalf("addEntry() to visits error = %v, want nil", err) + } + + if err := p.store.deleteEntry(t.Context(), contactID, "visits", idOf(t, visits[0])); err != nil { + t.Fatalf("deleteEntry() from visits error = %v, want nil", err) + } + + held := storedValues(t, p, t.Context(), contactID) + if _, kept := held["visits"]; kept { + t.Errorf("values = %v, want the emptied visits dropped", held) + } + wantHistory(t, p, t.Context(), contactID, "call") +} + +func TestAddEntryRefusesAContactNoTenantHolds(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + unknown := uuid.Must(uuid.NewV7()) + + _, err := p.store.addEntry(t.Context(), unknown, "history", noted("call"), roomy) + + if !errors.Is(err, errNoContact) { + t.Errorf("addEntry() error = %v, want errNoContact", err) + } +} + +func TestAddEntryRefusesAnotherTenantsContact(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + acme, contactID := foreignContact(t, p) + + _, err := p.store.addEntry(acme, contactID, "history", noted("call"), roomy) + + if !errors.Is(err, errNoContact) { + t.Fatalf("addEntry() error = %v, want errNoContact", err) + } + if held := storedValues(t, p, acme, contactID); held != nil { + t.Errorf("Acme's values = %v, want no row written", held) + } + if held := storedValues(t, p, t.Context(), contactID); held != nil { + t.Errorf("the owner's values = %v, want no row written", held) + } +} + +func TestAddEntryRefusesAForeignContactUnderAStrayRow(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + acme, contactID := foreignContact(t, p) + stray := []any{map[string]any{"id": "stray", "comment": "stray"}} + if err := p.store.writeValues(acme, contactID, map[string]any{"history": stray}); err != nil { + t.Fatalf("writeValues() in Acme error = %v, want nil", err) + } + + _, err := p.store.addEntry(acme, contactID, "history", noted("call"), roomy) + + if !errors.Is(err, errNoContact) { + t.Fatalf("addEntry() error = %v, want errNoContact", err) + } + wantHistory(t, p, acme, contactID, "stray") +} + +func TestAddEntryRefusesAListAtTheLimit(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + if _, err := p.store.addEntry(t.Context(), contactID, "history", noted("one", "two"), 2); err != nil { + t.Fatalf("addEntry() up to the limit error = %v, want nil", err) + } + + _, err := p.store.addEntry(t.Context(), contactID, "history", noted("three"), 2) + + if !errors.Is(err, errEntriesFull) { + t.Fatalf("addEntry() error = %v, want errEntriesFull", err) + } + wantHistory(t, p, t.Context(), contactID, "two", "one") +} + +func TestAddEntryRefusesEntriesThatWouldPassTheLimit(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + added(t, p, t.Context(), contactID, "one") + + _, err := p.store.addEntry(t.Context(), contactID, "history", noted("two", "three"), 2) + + if !errors.Is(err, errEntriesFull) { + t.Fatalf("addEntry() error = %v, want errEntriesFull", err) + } + wantHistory(t, p, t.Context(), contactID, "one") +} + +func TestAddEntryRefusesAFirstBatchOverTheLimit(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + + _, err := p.store.addEntry(t.Context(), contactID, "history", noted("one", "two"), 1) + + if !errors.Is(err, errEntriesFull) { + t.Fatalf("addEntry() error = %v, want errEntriesFull", err) + } + if held := storedValues(t, p, t.Context(), contactID); held != nil { + t.Errorf("values = %v, want no row written", held) + } +} + +func TestAddEntryTakesTheLastPlaceUnderTheLimit(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + added(t, p, t.Context(), contactID, "one") + + if _, err := p.store.addEntry(t.Context(), contactID, "history", noted("two"), 2); err != nil { + t.Fatalf("addEntry() error = %v, want the last place taken", err) + } + + wantHistory(t, p, t.Context(), contactID, "two", "one") +} + +func TestUpdateEntryReplacesOnlyItsCells(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + stored, err := p.store.addEntry(t.Context(), contactID, "history", []map[string]any{ + {"comment": "one"}, {"date": "2026-09-01", "comment": "two"}, {"comment": "three"}, + }, roomy) + if err != nil { + t.Fatalf("addEntry() error = %v, want nil", err) + } + _, before := historyHeld(t, p, t.Context(), contactID) + + err = p.store.updateEntry(t.Context(), contactID, "history", + idOf(t, stored[1]), map[string]any{"comment": "two edited"}) + + if err != nil { + t.Fatalf("updateEntry() error = %v, want nil", err) + } + wantHistory(t, p, t.Context(), contactID, "three", "two edited", "one") + if _, after := historyHeld(t, p, t.Context(), contactID); !slices.Equal(after, before) { + t.Errorf("ids = %q, want %q kept", after, before) + } + if edited := storedValues(t, p, t.Context(), contactID)["history"].([]any)[1]; edited.(map[string]any)["date"] != nil { + t.Errorf("edited entry = %v, want every cell replaced", edited) + } +} + +func TestUpdateEntryRefusesAnIDItCannotFind(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + stored := added(t, p, t.Context(), contactID, "one") + if err := p.store.writeValues(t.Context(), contactID, + map[string]any{"visits": []any{map[string]any{"id": uuid.NewString(), "place": "Office"}}}); err != nil { + t.Fatalf("writeValues() error = %v, want nil", err) + } + bare := seedContact(t, p, "Maria Perez") + cases := map[string]struct { + contactID uuid.UUID + name string + id uuid.UUID + }{ + "an unknown id": {contactID, "history", uuid.Must(uuid.NewV7())}, + "an id under another key": {contactID, "visits", idOf(t, stored[0])}, + "a contact with no list": {bare, "history", idOf(t, stored[0])}, + } + for name, held := range cases { + t.Run(name, func(t *testing.T) { + err := p.store.updateEntry(t.Context(), held.contactID, held.name, held.id, map[string]any{"comment": "x"}) + + if !errors.Is(err, errNoEntry) { + t.Errorf("updateEntry() error = %v, want errNoEntry", err) + } + }) + } + wantHistory(t, p, t.Context(), contactID, "one") +} + +func TestUpdateEntryStaysInsideItsTenant(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + acme, contactID := foreignContact(t, p) + stored := added(t, p, t.Context(), contactID, "one") + + err := p.store.updateEntry(acme, contactID, "history", idOf(t, stored[0]), map[string]any{"comment": "x"}) + + if !errors.Is(err, errNoEntry) { + t.Fatalf("updateEntry() error = %v, want errNoEntry", err) + } + wantHistory(t, p, t.Context(), contactID, "one") +} + +func TestDeleteEntryDropsOnlyItsEntry(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + stored := added(t, p, t.Context(), contactID, "one", "two", "three") + + if err := p.store.deleteEntry(t.Context(), contactID, "history", idOf(t, stored[1])); err != nil { + t.Fatalf("deleteEntry() error = %v, want nil", err) + } + + wantHistory(t, p, t.Context(), contactID, "three", "one") +} + +func TestDeleteEntryDropsTheKeyWithTheLastEntry(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + if err := p.store.writeValues(t.Context(), contactID, map[string]any{"birthDate": "1990-04-17"}); err != nil { + t.Fatalf("writeValues() error = %v, want nil", err) + } + stored := added(t, p, t.Context(), contactID, "one") + + if err := p.store.deleteEntry(t.Context(), contactID, "history", idOf(t, stored[0])); err != nil { + t.Fatalf("deleteEntry() error = %v, want nil", err) + } + + held := storedValues(t, p, t.Context(), contactID) + if _, kept := held["history"]; kept || held["birthDate"] != "1990-04-17" { + t.Errorf("values = %v, want only the emptied history dropped", held) + } +} + +func TestDeleteEntryRefusesAnUnknownID(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + added(t, p, t.Context(), contactID, "one") + + err := p.store.deleteEntry(t.Context(), contactID, "history", uuid.Must(uuid.NewV7())) + + if !errors.Is(err, errNoEntry) { + t.Fatalf("deleteEntry() error = %v, want errNoEntry", err) + } + wantHistory(t, p, t.Context(), contactID, "one") +} + +func TestDeleteEntryStaysInsideItsTenant(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + acme, contactID := foreignContact(t, p) + stored := added(t, p, t.Context(), contactID, "one") + + err := p.store.deleteEntry(acme, contactID, "history", idOf(t, stored[0])) + + if !errors.Is(err, errNoEntry) { + t.Fatalf("deleteEntry() error = %v, want errNoEntry", err) + } + wantHistory(t, p, t.Context(), contactID, "one") +} + +func TestDeleteEntryKeepsEntriesWithoutAnID(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + if err := p.store.writeValues(t.Context(), contactID, map[string]any{ + "history": []any{map[string]any{"comment": "old one"}, map[string]any{"comment": "old two"}}, + }); err != nil { + t.Fatalf("writeValues() error = %v, want nil", err) + } + stored := added(t, p, t.Context(), contactID, "new") + + if err := p.store.deleteEntry(t.Context(), contactID, "history", idOf(t, stored[0])); err != nil { + t.Fatalf("deleteEntry() error = %v, want nil", err) + } + + wantHistory(t, p, t.Context(), contactID, "old one", "old two") +} + +func TestEntriesReportAClosedPool(t *testing.T) { + t.Parallel() + + p := newClosedPlugin(t) + id := uuid.Must(uuid.NewV7()) + + if _, err := p.store.addEntry(t.Context(), id, "history", noted("one"), roomy); err == nil || + errors.Is(err, errNoContact) { + t.Errorf("addEntry() error = %v, want the closed pool reported", err) + } + if err := p.store.updateEntry(t.Context(), id, "history", id, map[string]any{}); err == nil || + errors.Is(err, errNoEntry) { + t.Errorf("updateEntry() error = %v, want the closed pool reported", err) + } + if err := p.store.deleteEntry(t.Context(), id, "history", id); err == nil || errors.Is(err, errNoEntry) { + t.Errorf("deleteEntry() error = %v, want the closed pool reported", err) + } +} + +// awaitLocked waits until the given statement is blocked on a lock in the test's database. +func awaitLocked(t *testing.T, p *Plugin, statement string) { + t.Helper() + const query = `SELECT count(*) FROM pg_stat_activity + WHERE datname = current_database() AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' AND starts_with(query, left($1, ( + SELECT setting::int - 1 FROM pg_settings WHERE name = 'track_activity_query_size')))` + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + var waiting int + if err := p.pool.QueryRow(t.Context(), query, statement).Scan(&waiting); err != nil { + t.Fatalf("reading the waiting statements: %v", err) + } + if waiting > 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("no statement waited on the racing one: %.40s", statement) +} + +// holding runs one statement in a transaction the test commits once another statement waits on it. +func holding(t *testing.T, p *Plugin, statement string, args ...any) pgx.Tx { + t.Helper() + racing, err := p.pool.Begin(t.Context()) + if err != nil { + t.Fatalf("beginning the racing transaction: %v", err) + } + t.Cleanup(func() { _ = racing.Rollback(context.Background()) }) + if _, err := racing.Exec(t.Context(), statement, args...); err != nil { + t.Fatalf("racing statement: %v", err) + } + return racing +} + +// holdingAnAdd runs the add statement for an entry noted racing in a transaction the test commits later. +func holdingAnAdd(t *testing.T, p *Plugin, contactID uuid.UUID) pgx.Tx { + t.Helper() + return holding(t, p, addEntryStatement, + contactID, "history", newestFirst(noted("racing")), sdk.DefaultTenantID, roomy) +} + +// release commits the racing transaction once the waiting statement is blocked, returning what it answered. +func release(t *testing.T, p *Plugin, racing pgx.Tx, waiting string, done <-chan error) error { + t.Helper() + awaitLocked(t, p, waiting) + if err := racing.Commit(t.Context()); err != nil { + t.Fatalf("committing the racing transaction: %v", err) + } + return <-done +} + +// addingInBackground adds one entry off the test goroutine, answering on the returned channel. +func addingInBackground(p *Plugin, contactID uuid.UUID, comment string, limit int) <-chan error { + done := make(chan error, 1) + go func() { + _, err := p.store.addEntry(context.Background(), contactID, "history", noted(comment), limit) + done <- err + }() + return done +} + +func TestAddEntryKeepsARacingAdd(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + added(t, p, t.Context(), contactID, "first") + racing := holdingAnAdd(t, p, contactID) + + done := addingInBackground(p, contactID, "waiting", roomy) + + if err := release(t, p, racing, addEntryStatement, done); err != nil { + t.Fatalf("addEntry() error = %v, want nil", err) + } + wantHistory(t, p, t.Context(), contactID, "waiting", "racing", "first") +} + +func TestAddEntryKeepsARacingFirstAdd(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + racing := holdingAnAdd(t, p, contactID) + + done := addingInBackground(p, contactID, "waiting", roomy) + + if err := release(t, p, racing, addEntryStatement, done); err != nil { + t.Fatalf("addEntry() error = %v, want nil", err) + } + wantHistory(t, p, t.Context(), contactID, "waiting", "racing") +} + +func TestUpdateEntryKeepsARacingAdd(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + target := added(t, p, t.Context(), contactID, "target") + targetID := idOf(t, target[0]) + racing := holdingAnAdd(t, p, contactID) + done := make(chan error, 1) + + go func() { + done <- p.store.updateEntry(context.Background(), contactID, "history", + targetID, map[string]any{"comment": "edited"}) + }() + + if err := release(t, p, racing, updateEntryStatement, done); err != nil { + t.Fatalf("updateEntry() error = %v, want nil", err) + } + wantHistory(t, p, t.Context(), contactID, "racing", "edited") + if _, ids := historyHeld(t, p, t.Context(), contactID); ids[1] != targetID.String() { + t.Errorf("ids = %q, want the edited entry's id kept", ids) + } +} + +func TestDeleteEntryKeepsARacingAdd(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + target := added(t, p, t.Context(), contactID, "target") + targetID := idOf(t, target[0]) + racing := holdingAnAdd(t, p, contactID) + done := make(chan error, 1) + + go func() { done <- p.store.deleteEntry(context.Background(), contactID, "history", targetID) }() + + if err := release(t, p, racing, deleteEntryStatement, done); err != nil { + t.Fatalf("deleteEntry() error = %v, want nil", err) + } + wantHistory(t, p, t.Context(), contactID, "racing") +} + +func TestAddEntryTakesTheRoomARacingDeleteMade(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + stored, err := p.store.addEntry(t.Context(), contactID, "history", noted("one", "two"), 2) + if err != nil { + t.Fatalf("addEntry() error = %v, want nil", err) + } + racing := holding(t, p, deleteEntryStatement, contactID, "history", stored[0][entryIDKey], sdk.DefaultTenantID) + + done := addingInBackground(p, contactID, "three", 2) + + if err := release(t, p, racing, addEntryStatement, done); err != nil { + t.Fatalf("addEntry() error = %v, want the room the delete made taken", err) + } + wantHistory(t, p, t.Context(), contactID, "three", "one") +} + +func TestAddEntryRefusesAContactDeletedWhileItWaits(t *testing.T) { + t.Parallel() + + for name, held := range map[string][]string{"with a stored list": {"first"}, "with no row yet": nil} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + if held != nil { + added(t, p, t.Context(), contactID, held...) + } + racing := holding(t, p, "DELETE FROM core.contacts WHERE id = $1", contactID) + + done := addingInBackground(p, contactID, "waiting", roomy) + + if err := release(t, p, racing, addEntryStatement, done); !errors.Is(err, errNoContact) { + t.Errorf("addEntry() error = %v, want errNoContact", err) + } + }) + } +} + +func TestUpdateEntryRefusesAnEntryARacingDeleteRemoved(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + stored := added(t, p, t.Context(), contactID, "one", "two") + removedID := idOf(t, stored[1]) + racing := holding(t, p, deleteEntryStatement, contactID, "history", removedID.String(), sdk.DefaultTenantID) + done := make(chan error, 1) + + go func() { + done <- p.store.updateEntry(context.Background(), contactID, "history", + removedID, map[string]any{"comment": "edited"}) + }() + + if err := release(t, p, racing, updateEntryStatement, done); !errors.Is(err, errNoEntry) { + t.Fatalf("updateEntry() error = %v, want errNoEntry", err) + } + wantHistory(t, p, t.Context(), contactID, "two") +} + +func TestWriteValuesKeepsARacingAdd(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + contactID := seedContact(t, p, "Maria Perez") + added(t, p, t.Context(), contactID, "first") + racing := holdingAnAdd(t, p, contactID) + done := make(chan error, 1) + + go func() { + done <- p.store.writeValues(context.Background(), contactID, map[string]any{"birthDate": "1990-04-17"}) + }() + + if err := release(t, p, racing, "INSERT INTO plugin_fields.contact_values", done); err != nil { + t.Fatalf("writeValues() error = %v, want nil", err) + } + wantHistory(t, p, t.Context(), contactID, "racing", "first") +} diff --git a/plugins/fields/store.go b/plugins/fields/store.go index 85bdccc..f40fd18 100644 --- a/plugins/fields/store.go +++ b/plugins/fields/store.go @@ -6,9 +6,11 @@ import ( "context" "errors" "fmt" + "maps" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/gopherium/alphone/sdk" @@ -19,6 +21,9 @@ var ( errNameTaken = errors.New("fields: another definition holds that name") errNoDefinition = errors.New("fields: no live definition holds that id") errKindLocked = errors.New("fields: the archived definition of that name holds another kind or other sub fields") + errNoContact = errors.New("fields: the caller's tenant holds no contact of that id") + errEntriesFull = errors.New("fields: the repeater has no room for the entries given") + errNoEntry = errors.New("fields: no entry of that id is in that contact's field") ) // store reads and writes the definition catalogue. @@ -116,6 +121,109 @@ func (s *store) writeValues(ctx context.Context, contactID uuid.UUID, values map return nil } +// contactReference is the constraint tying a value row to the contact it describes. +const contactReference = "contact_values_contact_id_fkey" + +// addEntryStatement prepends entries to a contact's list and says whether the contact and the room were there. +const addEntryStatement = `WITH entry_contact AS ( + SELECT id, tenant_id FROM core.contacts WHERE id = $1 AND tenant_id = $4 + ), added AS ( + INSERT INTO plugin_fields.contact_values (contact_id, values, tenant_id) + SELECT id, jsonb_build_object($2::text, $3::jsonb), tenant_id FROM entry_contact + WHERE jsonb_array_length($3::jsonb) <= $5 + ON CONFLICT (tenant_id, contact_id) DO UPDATE + SET values = plugin_fields.contact_values.values || jsonb_build_object($2::text, + $3::jsonb || coalesce(plugin_fields.contact_values.values -> $2::text, '[]'::jsonb)) + WHERE jsonb_array_length(coalesce(plugin_fields.contact_values.values -> $2::text, '[]'::jsonb)) + + jsonb_array_length($3::jsonb) <= $5 + RETURNING 1 + ) + SELECT EXISTS (SELECT 1 FROM entry_contact), EXISTS (SELECT 1 FROM added)` + +// updateEntryStatement replaces the cells of one entry in place, keeping its id. +const updateEntryStatement = `UPDATE plugin_fields.contact_values + SET values = values || jsonb_build_object($2::text, ( + SELECT jsonb_agg(CASE WHEN listed.entry ->> 'id' = $3::text + THEN $4::jsonb || jsonb_build_object('id', $3::text) ELSE listed.entry END ORDER BY listed.at) + FROM jsonb_array_elements(values -> $2::text) WITH ORDINALITY AS listed (entry, at))) + WHERE tenant_id = $5 AND contact_id = $1 + AND values -> $2::text @> jsonb_build_array(jsonb_build_object('id', $3::text))` + +// deleteEntryStatement drops one entry, and the key with the last one. +const deleteEntryStatement = `UPDATE plugin_fields.contact_values + SET values = (values - $2::text) || coalesce(( + SELECT jsonb_build_object($2::text, jsonb_agg(listed.entry ORDER BY listed.at)) + FROM jsonb_array_elements(values -> $2::text) WITH ORDINALITY AS listed (entry, at) + WHERE listed.entry ->> 'id' IS DISTINCT FROM $3::text + HAVING count(*) > 0), '{}'::jsonb) + WHERE tenant_id = $4 AND contact_id = $1 + AND values -> $2::text @> jsonb_build_array(jsonb_build_object('id', $3::text))` + +// addEntry prepends entries to a contact's repeater list, the last given on top, and returns them with their ids. +func (s *store) addEntry( + ctx context.Context, contactID uuid.UUID, name string, entries []map[string]any, limit int, +) ([]map[string]any, error) { + stored := newestFirst(entries) + var held, added bool + err := s.pool.QueryRow(ctx, addEntryStatement, + contactID, name, stored, sdk.TenantOrDefault(ctx), limit).Scan(&held, &added) + switch { + case isMissingContact(err): + return nil, errNoContact + case err != nil: + return nil, fmt.Errorf("fields: add entries: %w", err) + case !held: + return nil, errNoContact + case !added: + return nil, errEntriesFull + } + return stored, nil +} + +// newestFirst returns the entries each under a fresh id, the last given first. +func newestFirst(entries []map[string]any) []map[string]any { + stored := make([]map[string]any, len(entries)) + for at, cells := range entries { + entry := maps.Clone(cells) + entry[entryIDKey] = uuid.Must(uuid.NewV7()).String() + stored[len(entries)-1-at] = entry + } + return stored +} + +// isMissingContact reports whether err is a value row naming a contact that no longer exists. +func isMissingContact(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.ConstraintName == contactReference +} + +// updateEntry replaces the cells of one entry in a contact's repeater list, keeping its id and its place. +func (s *store) updateEntry( + ctx context.Context, contactID uuid.UUID, name string, id uuid.UUID, cells map[string]any, +) error { + tag, err := s.pool.Exec(ctx, updateEntryStatement, + contactID, name, id.String(), cells, sdk.TenantOrDefault(ctx)) + if err != nil { + return fmt.Errorf("fields: update entry: %w", err) + } + if tag.RowsAffected() == 0 { + return errNoEntry + } + return nil +} + +// deleteEntry drops one entry from a contact's repeater list, dropping the field with its last entry. +func (s *store) deleteEntry(ctx context.Context, contactID uuid.UUID, name string, id uuid.UUID) error { + tag, err := s.pool.Exec(ctx, deleteEntryStatement, contactID, name, id.String(), sdk.TenantOrDefault(ctx)) + if err != nil { + return fmt.Errorf("fields: delete entry: %w", err) + } + if tag.RowsAffected() == 0 { + return errNoEntry + } + return nil +} + // valueRow pairs a contact with the field values it holds. type valueRow struct { contactID uuid.UUID From bfc39e31e05651dee1c89640c5dfb6c206f972bb Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 11:59:05 +0200 Subject: [PATCH 15/48] feat(fields): cap the entries one repeater holds --- .env.example | 3 + plugins/fields/entriescap_internal_test.go | 90 ++++++++++++++++++++++ plugins/fields/fields.go | 46 +++++++++-- 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 plugins/fields/entriescap_internal_test.go diff --git a/.env.example b/.env.example index 29c57ff..d2973ee 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ ALPHONE_DATABASE_URL=postgres://postgres:alphone@localhost:5433/postgres?sslmode # How long a server process keeps a tenant's fields before reading them again, 1m when unset. # ALPHONE_TENANTS_REFRESH=1m +# The most entries one repeater field holds on a contact, 500 when unset. +# ALPHONE_FIELDS_ENTRIES_MAX=500 + # Any non-empty value serves the interactive GraphiQL page on GET /api/graphql. # ALPHONE_DEV_GRAPHIQL=1 diff --git a/plugins/fields/entriescap_internal_test.go b/plugins/fields/entriescap_internal_test.go new file mode 100644 index 0000000..7ab793e --- /dev/null +++ b/plugins/fields/entriescap_internal_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package fields + +import ( + "strings" + "testing" + + "github.com/gopherium/alphone/sdk" +) + +// environment returns a getenv answering only the given variables. +func environment(held map[string]string) func(string) string { + return func(name string) string { return held[name] } +} + +func TestEntriesCapAppliesTheDefaultWhenUnset(t *testing.T) { + t.Parallel() + + held, err := entriesCap("") + + if err != nil || held != defaultEntriesMax { + t.Errorf("entriesCap(\"\") = %d, %v, want %d, nil", held, err, defaultEntriesMax) + } +} + +func TestEntriesCapReadsAPositiveCount(t *testing.T) { + t.Parallel() + + held, err := entriesCap("7") + + if err != nil || held != 7 { + t.Errorf("entriesCap(\"7\") = %d, %v, want 7, nil", held, err) + } +} + +func TestEntriesCapRefusesACountItCannotHold(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "none": "0", + "fewer than none": "-1", + "a count in words": "many", + "past a Postgres int4": "2147483648", + "a fraction": "1.5", + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := entriesCap(raw) + + if err == nil || !strings.Contains(err.Error(), entriesMaxVariable) { + t.Errorf("entriesCap(%q) error = %v, want one naming %s", raw, err, entriesMaxVariable) + } + }) + } +} + +func TestRegisterReadsTheEntriesCap(t *testing.T) { + t.Parallel() + + p := newUnreachablePlugin(t, sdk.Deps{Getenv: environment(map[string]string{entriesMaxVariable: "7"})}) + + if p.entriesMax != 7 { + t.Errorf("entriesMax = %d, want 7", p.entriesMax) + } +} + +func TestRegisterAppliesTheDefaultCapWithoutAnEnvironment(t *testing.T) { + t.Parallel() + + p := newUnreachablePlugin(t, sdk.Deps{}) + + if p.entriesMax != defaultEntriesMax { + t.Errorf("entriesMax = %d, want %d", p.entriesMax, defaultEntriesMax) + } +} + +func TestRegisterRefusesAMalformedEntriesCap(t *testing.T) { + t.Parallel() + + _, err := Register(sdk.Deps{ + DatabaseURL: unreachableURL, Getenv: environment(map[string]string{entriesMaxVariable: "many"}), + }) + + if err == nil || !strings.Contains(err.Error(), entriesMaxVariable) { + t.Errorf("Register() error = %v, want the malformed cap named", err) + } +} diff --git a/plugins/fields/fields.go b/plugins/fields/fields.go index 6889fd1..54276d2 100644 --- a/plugins/fields/fields.go +++ b/plugins/fields/fields.go @@ -9,6 +9,7 @@ import ( "embed" "fmt" "io/fs" + "strconv" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -24,22 +25,55 @@ var migrations embed.FS var migrationSource = mustSub(migrations, "migrations") +// entriesMaxVariable names the setting that caps the entries one repeater holds. +const entriesMaxVariable = "ALPHONE_FIELDS_ENTRIES_MAX" + +// defaultEntriesMax is how many entries one repeater holds when the setting is unset. +const defaultEntriesMax = 500 + // Plugin holds the catalogue of contact fields an operator defines. type Plugin struct { - pool *pgxpool.Pool - store *store - catalog *catalog - batchWait time.Duration + pool *pgxpool.Pool + store *store + catalog *catalog + batchWait time.Duration + entriesMax int } -// Register builds the fields [Plugin] from the host-provided deps. +// entriesCap parses the most entries one repeater holds, applying the default when raw is empty. +func entriesCap(raw string) (int, error) { + if raw == "" { + return defaultEntriesMax, nil + } + parsed, err := strconv.ParseInt(raw, 10, 32) + if err != nil { + return 0, fmt.Errorf("fields: parse %s: %w", entriesMaxVariable, err) + } + if parsed <= 0 { + return 0, fmt.Errorf("fields: %s must be positive", entriesMaxVariable) + } + return int(parsed), nil +} + +// Register builds the fields [Plugin] from the host-provided deps, reading the entries cap from the environment. func Register(deps sdk.Deps) (*Plugin, error) { + getenv := deps.Getenv + if getenv == nil { + getenv = func(string) string { return "" } + } + entriesMax, err := entriesCap(getenv(entriesMaxVariable)) + if err != nil { + return nil, err + } pool, err := pgxpool.New(context.Background(), deps.DatabaseURL) if err != nil { return nil, fmt.Errorf("fields: connect database: %w", err) } store := &store{pool: pool} - return &Plugin{pool: pool, store: store, catalog: newCatalog(store, deps.TenantsHeld, deps.TenantsRefresh)}, nil + return &Plugin{ + pool: pool, store: store, entriesMax: entriesMax, + catalog: newCatalog(store, deps.TenantsHeld, deps.TenantsRefresh), + }, nil } // ID reports the plugin identifier. From f31b95635e4a1215e16252966d180ab97f6d90d2 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 11:59:05 +0200 Subject: [PATCH 16/48] docs(configuration): describe the repeater entries cap --- docs/src/content/docs/self-hosting/configuration.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index 919d9ea..fdf2867 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -25,6 +25,12 @@ database is all an upgrade takes. | `ALPHONE_TRUSTED_PROXIES` | no | unset | Comma-separated CIDR ranges allowed to set `X-Forwarded-For`, e.g. `172.18.0.0/16`. Only addresses in these ranges are trusted when the login rate limiter resolves the client IP. Unset, the direct peer address is used. **Set this whenever AlphOne runs behind a reverse proxy**, or all visitors share one rate-limit bucket. Each entry must be CIDR notation. A bare IP is rejected at startup. | | `ALPHONE_DEV_GRAPHIQL` | no | unset | Any non-empty value serves the interactive GraphiQL page on `GET /api/graphql`. Development only. | +## Fields plugin + +| Variable | Purpose | +| --- | --- | +| `ALPHONE_FIELDS_ENTRIES_MAX` | The most entries one repeater field holds on a contact. Defaults to 500. An add past the cap is refused, and AlphOne will not start if the value is not a whole number above zero. | + ## WhatsApp plugin All optional. Without them the plugin runs inert: screens exist, but no From 800d0a5592fd1ea3befff99c65a40a9e12d433f2 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 12:21:05 +0200 Subject: [PATCH 17/48] feat(fields): add, update and delete repeater entries over the graph --- plugins/fields/entries.go | 107 +++++++ plugins/fields/entries_internal_test.go | 296 +++++++++++++++++++ plugins/fields/frontend/errorTemplates.ts | 4 + plugins/fields/frontend/languages/es-ES.json | 2 +- plugins/fields/graph/schema.graphqls | 5 + plugins/fields/graphql.go | 4 + plugins/fields/graphql_internal_test.go | 17 ++ plugins/fields/languages/alphone-fields.pot | 12 + plugins/fields/languages/es-ES.po | 16 + 9 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 plugins/fields/entries.go create mode 100644 plugins/fields/entries_internal_test.go diff --git a/plugins/fields/entries.go b/plugins/fields/entries.go new file mode 100644 index 0000000..c9274ad --- /dev/null +++ b/plugins/fields/entries.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package fields + +import ( + "context" + "errors" + "fmt" + "maps" + + "github.com/google/uuid" + + "github.com/gopherium/alphone/sdk" +) + +// errNotARepeater reports an entry written to a field that keeps no list of entries. +var errNotARepeater = errors.New("fields: the field is not a repeater") + +// AddContactFieldEntry stores one entry on a contact's repeater and answers it under its new id. +func (m MutationResolvers) AddContactFieldEntry( + ctx context.Context, contactID uuid.UUID, field string, entry any, +) (any, error) { + cells, err := m.plugin.checkedEntry(ctx, field, entry, "") + if err != nil { + return nil, err + } + stored, err := m.plugin.store.addEntry(ctx, contactID, field, []map[string]any{cells}, m.plugin.entriesMax) + if err != nil { + return nil, m.plugin.storeRefusal(err) + } + return stored[0], nil +} + +// UpdateContactFieldEntry replaces the cells of one entry on a contact's repeater and answers the entry. +func (m MutationResolvers) UpdateContactFieldEntry( + ctx context.Context, contactID uuid.UUID, field string, entryID uuid.UUID, entry any, +) (any, error) { + cells, err := m.plugin.checkedEntry(ctx, field, entry, entryID.String()) + if err != nil { + return nil, err + } + if err := m.plugin.store.updateEntry(ctx, contactID, field, entryID, cells); err != nil { + return nil, m.plugin.storeRefusal(err) + } + answered := maps.Clone(cells) + answered[entryIDKey] = entryID.String() + return answered, nil +} + +// DeleteContactFieldEntry drops one entry from a contact's repeater. +func (m MutationResolvers) DeleteContactFieldEntry( + ctx context.Context, contactID uuid.UUID, field string, entryID uuid.UUID, +) (bool, error) { + if _, err := m.plugin.repeaterColumns(ctx, field); err != nil { + return false, err + } + if err := m.plugin.store.deleteEntry(ctx, contactID, field, entryID); err != nil { + return false, m.plugin.storeRefusal(err) + } + return true, nil +} + +// checkedEntry returns the storable cells of one entry of a live repeater of the caller's. +func (p *Plugin) checkedEntry(ctx context.Context, field string, entry any, own string) (map[string]any, error) { + columns, err := p.repeaterColumns(ctx, field) + if err != nil { + return nil, err + } + cells, err := checkEntry(field, columns, entry, own) + if err != nil { + return nil, sdk.GraphError{Code: "VALIDATION", Reason: fieldReason(err), Err: err} + } + return cells, nil +} + +// repeaterColumns returns the sub fields of a live repeater the caller's tenant defines. +func (p *Plugin) repeaterColumns(ctx context.Context, field string) ([]SubField, error) { + read, err := p.catalog.viewFor(ctx) + if err != nil { + return nil, err + } + held, defined := read.kinds[field] + if !defined { + err := fmt.Errorf("%w: %s", errNoField, field) + return nil, sdk.GraphError{Code: "VALIDATION", Reason: fieldReason(err), Err: err} + } + if held != kindRepeater { + err := fmt.Errorf("%w: %s", errNotARepeater, field) + return nil, sdk.GraphError{Code: "VALIDATION", Reason: fieldReason(err), Err: err} + } + return read.columns[field], nil +} + +// storeRefusal returns the graph refusal a store sentinel stands for, or the error as it is. +func (p *Plugin) storeRefusal(err error) error { + switch { + case errors.Is(err, errNoContact): + return sdk.GraphError{Code: "NOT_FOUND", Reason: "contact_not_found", Err: err} + case errors.Is(err, errNoEntry): + return sdk.GraphError{Code: "NOT_FOUND", Reason: fieldReason(err), Err: err} + case errors.Is(err, errEntriesFull): + return sdk.GraphError{ + Code: "CONFLICT", Reason: fieldReason(err), Meta: map[string]any{"max": p.entriesMax}, Err: err, + } + } + return err +} diff --git a/plugins/fields/entries_internal_test.go b/plugins/fields/entries_internal_test.go new file mode 100644 index 0000000..be85e19 --- /dev/null +++ b/plugins/fields/entries_internal_test.go @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package fields + +import ( + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/gopherium/alphone/sdk" +) + +// refusalOf returns the graph refusal err carries, failing the test when it carries none. +func refusalOf(t *testing.T, err error) sdk.GraphError { + t.Helper() + var raised sdk.GraphError + if !errors.As(err, &raised) { + t.Fatalf("error = %v, want a graph refusal", err) + } + return raised +} + +// wantRefusal fails unless err is a graph refusal of the given code and reason. +func wantRefusal(t *testing.T, err error, code, reason string) { + t.Helper() + if raised := refusalOf(t, err); raised.Code != code || raised.Reason != reason { + t.Errorf("refusal = %s %s, want %s %s", raised.Code, raised.Reason, code, reason) + } +} + +// withHistory returns resolvers over a plugin holding a history repeater, and a contact to write on. +func withHistory(t *testing.T) (*Plugin, MutationResolvers, uuid.UUID) { + t.Helper() + p := newMigratedPlugin(t) + define(t, p, historyOf(historyColumns...)) + return p, MutationResolvers{plugin: p}, seedContact(t, p, "Maria Perez") +} + +// answeredID returns the id an answered entry carries. +func answeredID(t *testing.T, answered any) uuid.UUID { + t.Helper() + entry, isEntry := answered.(map[string]any) + if !isEntry { + t.Fatalf("answer = %#v, want an entry", answered) + } + return idOf(t, entry) +} + +func TestAddContactFieldEntryAnswersTheStoredEntry(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + + answered, err := resolvers.AddContactFieldEntry(t.Context(), contactID, "history", + map[string]any{"date": "2026-09-01", "comment": "First call."}) + + if err != nil { + t.Fatalf("AddContactFieldEntry() error = %v, want nil", err) + } + entry := answered.(map[string]any) + if answeredID(t, answered).Version() != 7 || entry["date"] != "2026-09-01" || entry["comment"] != "First call." { + t.Errorf("answer = %#v, want the stored entry under its new id", answered) + } + wantHistory(t, p, t.Context(), contactID, "First call.") +} + +func TestAddContactFieldEntryRefusesAFieldThatIsNotARepeater(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + define(t, p, defined(t, "birthDate", "DATE")) + + _, err := resolvers.AddContactFieldEntry(t.Context(), contactID, "birthDate", map[string]any{"date": "2026-09-01"}) + + wantRefusal(t, err, "VALIDATION", "field_not_a_repeater") +} + +func TestAddContactFieldEntryRefusesAFieldNoLiveDefinitionHolds(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + listed, err := p.store.liveDefinitions(t.Context()) + if err != nil { + t.Fatalf("liveDefinitions() error = %v, want nil", err) + } + if err := p.store.archive(t.Context(), listed[0].ID); err != nil { + t.Fatalf("archive() error = %v, want nil", err) + } + p.catalog.forget(t.Context()) + + for _, name := range []string{"history", "neverDefined"} { + _, err := resolvers.AddContactFieldEntry(t.Context(), contactID, name, map[string]any{"comment": "call"}) + + wantRefusal(t, err, "VALIDATION", "field_unknown") + } +} + +func TestAddContactFieldEntryRefusesAnEntryItCannotCheck(t *testing.T) { + t.Parallel() + + _, resolvers, contactID := withHistory(t) + cases := map[string]struct { + entry any + reason string + }{ + "every cell blank": {map[string]any{"comment": " "}, "field_entry_empty"}, + "a value of a kind": {map[string]any{"date": "not a date"}, "value_kind_mismatch"}, + "an unknown sub field": {map[string]any{"mood": "happy"}, "field_unknown"}, + "a value not an entry": {"a text", "value_kind_mismatch"}, + } + for name, held := range cases { + t.Run(name, func(t *testing.T) { + _, err := resolvers.AddContactFieldEntry(t.Context(), contactID, "history", held.entry) + + wantRefusal(t, err, "VALIDATION", held.reason) + }) + } +} + +func TestAddContactFieldEntryRefusesAContactTheTenantDoesNotHold(t *testing.T) { + t.Parallel() + + _, resolvers, _ := withHistory(t) + + _, err := resolvers.AddContactFieldEntry(t.Context(), uuid.Must(uuid.NewV7()), "history", + map[string]any{"comment": "call"}) + + wantRefusal(t, err, "NOT_FOUND", "contact_not_found") +} + +func TestAddContactFieldEntryRefusesAFullListNamingTheCap(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + p.entriesMax = 1 + if _, err := resolvers.AddContactFieldEntry(t.Context(), contactID, "history", + map[string]any{"comment": "first"}); err != nil { + t.Fatalf("AddContactFieldEntry() error = %v, want the first entry stored", err) + } + + _, err := resolvers.AddContactFieldEntry(t.Context(), contactID, "history", map[string]any{"comment": "second"}) + + wantRefusal(t, err, "CONFLICT", "field_entries_full") + if meta := refusalOf(t, err).Meta; meta["max"] != 1 { + t.Errorf("meta = %v, want the cap named", meta) + } + wantHistory(t, p, t.Context(), contactID, "first") +} + +func TestUpdateContactFieldEntryAnswersTheEditedEntryInItsPlace(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + older := added(t, p, t.Context(), contactID, "older") + added(t, p, t.Context(), contactID, "newer") + olderID := idOf(t, older[0]) + + answered, err := resolvers.UpdateContactFieldEntry(t.Context(), contactID, "history", olderID, + map[string]any{"id": olderID.String(), "comment": "older, edited"}) + + if err != nil { + t.Fatalf("UpdateContactFieldEntry() error = %v, want nil", err) + } + if answeredID(t, answered) != olderID || answered.(map[string]any)["comment"] != "older, edited" { + t.Errorf("answer = %#v, want the edited entry under its own id", answered) + } + wantHistory(t, p, t.Context(), contactID, "newer", "older, edited") +} + +func TestUpdateContactFieldEntryRefusesAnEntryItCannotFind(t *testing.T) { + t.Parallel() + + _, resolvers, contactID := withHistory(t) + + _, err := resolvers.UpdateContactFieldEntry(t.Context(), contactID, "history", uuid.Must(uuid.NewV7()), + map[string]any{"comment": "edited"}) + + wantRefusal(t, err, "NOT_FOUND", "field_entry_not_found") +} + +func TestUpdateContactFieldEntryRefusesABlankEntryAndKeepsTheOld(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + stored := added(t, p, t.Context(), contactID, "kept") + + _, err := resolvers.UpdateContactFieldEntry(t.Context(), contactID, "history", idOf(t, stored[0]), + map[string]any{"comment": ""}) + + wantRefusal(t, err, "VALIDATION", "field_entry_empty") + wantHistory(t, p, t.Context(), contactID, "kept") +} + +func TestDeleteContactFieldEntryRemovesTheEntry(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + stored := added(t, p, t.Context(), contactID, "gone", "kept") + + deleted, err := resolvers.DeleteContactFieldEntry(t.Context(), contactID, "history", idOf(t, stored[1])) + + if err != nil || !deleted { + t.Fatalf("DeleteContactFieldEntry() = %v, %v, want true, nil", deleted, err) + } + wantHistory(t, p, t.Context(), contactID, "kept") +} + +func TestDeleteContactFieldEntryRefusesAnEntryItCannotFind(t *testing.T) { + t.Parallel() + + _, resolvers, contactID := withHistory(t) + + _, err := resolvers.DeleteContactFieldEntry(t.Context(), contactID, "history", uuid.Must(uuid.NewV7())) + + wantRefusal(t, err, "NOT_FOUND", "field_entry_not_found") +} + +func TestUpdateAndDeleteRefuseAFieldThatIsNotARepeater(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + define(t, p, defined(t, "birthDate", "DATE")) + id := uuid.Must(uuid.NewV7()) + + _, updated := resolvers.UpdateContactFieldEntry(t.Context(), contactID, "birthDate", id, + map[string]any{"date": "2026-09-01"}) + _, deleted := resolvers.DeleteContactFieldEntry(t.Context(), contactID, "birthDate", id) + + wantRefusal(t, updated, "VALIDATION", "field_not_a_repeater") + wantRefusal(t, deleted, "VALIDATION", "field_not_a_repeater") +} + +func TestEntryMutationsCheckTheCallersOwnFields(t *testing.T) { + t.Parallel() + + p, resolvers, contactID := withHistory(t) + stored := added(t, p, t.Context(), contactID, "kept") + acme := inTenant(t, p) + + _, adding := resolvers.AddContactFieldEntry(acme, contactID, "history", map[string]any{"comment": "call"}) + wantRefusal(t, adding, "VALIDATION", "field_unknown") + + if err := p.store.define(acme, historyOf(historyColumns...)); err != nil { + t.Fatalf("define() in Acme error = %v, want nil", err) + } + p.catalog.forget(acme) + _, adding = resolvers.AddContactFieldEntry(acme, contactID, "history", map[string]any{"comment": "call"}) + _, updating := resolvers.UpdateContactFieldEntry(acme, contactID, "history", idOf(t, stored[0]), + map[string]any{"comment": "edited"}) + _, deleting := resolvers.DeleteContactFieldEntry(acme, contactID, "history", idOf(t, stored[0])) + + wantRefusal(t, adding, "NOT_FOUND", "contact_not_found") + wantRefusal(t, updating, "NOT_FOUND", "field_entry_not_found") + wantRefusal(t, deleting, "NOT_FOUND", "field_entry_not_found") + wantHistory(t, p, t.Context(), contactID, "kept") +} + +func TestEntryMutationsReportAFailedCatalogueRead(t *testing.T) { + t.Parallel() + + resolvers := MutationResolvers{plugin: newWedgedPlugin(t)} + id := uuid.Must(uuid.NewV7()) + + _, adding := resolvers.AddContactFieldEntry(t.Context(), id, "history", map[string]any{"comment": "call"}) + _, updating := resolvers.UpdateContactFieldEntry(t.Context(), id, "history", id, map[string]any{"comment": "call"}) + _, deleting := resolvers.DeleteContactFieldEntry(t.Context(), id, "history", id) + + for name, err := range map[string]error{"add": adding, "update": updating, "delete": deleting} { + if !errors.Is(err, errCatalogue) { + t.Errorf("%s error = %v, want the catalogue failure", name, err) + } + } +} + +func TestEntryMutationsReportAClosedPool(t *testing.T) { + t.Parallel() + + p := newClosedPlugin(t) + loader := newFakeLoader() + loader.held[sdk.DefaultTenantID] = []Definition{historyOf(historyColumns...)} + p.catalog = newCatalog(loader, 0, 0) + resolvers := MutationResolvers{plugin: p} + id := uuid.Must(uuid.NewV7()) + + _, adding := resolvers.AddContactFieldEntry(t.Context(), id, "history", map[string]any{"comment": "call"}) + _, updating := resolvers.UpdateContactFieldEntry(t.Context(), id, "history", id, map[string]any{"comment": "call"}) + _, deleting := resolvers.DeleteContactFieldEntry(t.Context(), id, "history", id) + + for name, err := range map[string]error{"add": adding, "update": updating, "delete": deleting} { + var raised sdk.GraphError + if err == nil || errors.As(err, &raised) { + t.Errorf("%s error = %v, want the closed pool reported as it is", name, err) + } + } +} diff --git a/plugins/fields/frontend/errorTemplates.ts b/plugins/fields/frontend/errorTemplates.ts index 4672d3b..727ccfe 100644 --- a/plugins/fields/frontend/errorTemplates.ts +++ b/plugins/fields/frontend/errorTemplates.ts @@ -29,5 +29,9 @@ export function errorTemplates(): Record { field_unknown: __('That field is not one this contact holds.', DOMAIN), value_kind_mismatch: __('That value does not match the kind the field declares.', DOMAIN), values_not_an_object: __('Send the values as field names to values.', DOMAIN), + field_not_a_repeater: __('That field does not keep a list of entries.', DOMAIN), + field_entry_empty: __('Fill in at least one part of the entry.', DOMAIN), + field_entry_not_found: __('That entry no longer exists.', DOMAIN), + field_entries_full: __('This list is full. It holds %(max)d entries at most.', DOMAIN), } } diff --git a/plugins/fields/frontend/languages/es-ES.json b/plugins/fields/frontend/languages/es-ES.json index 5b70aae..6721802 100644 --- a/plugins/fields/frontend/languages/es-ES.json +++ b/plugins/fields/frontend/languages/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":""},"%(label)s %(number)d":["%(label)s %(number)d"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Fields":["Campos"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move entry down":["Bajar la entrada"],"Move entry up":["Subir la entrada"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove entry":["Quitar la entrada"],"Remove sub field":["Quitar el subcampo"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} +{"":{"lang":"es","plural-forms":""},"%(label)s %(number)d":["%(label)s %(number)d"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move entry down":["Bajar la entrada"],"Move entry up":["Subir la entrada"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove entry":["Quitar la entrada"],"Remove sub field":["Quitar el subcampo"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} diff --git a/plugins/fields/graph/schema.graphqls b/plugins/fields/graph/schema.graphqls index 1795b4b..f40cd8b 100644 --- a/plugins/fields/graph/schema.graphqls +++ b/plugins/fields/graph/schema.graphqls @@ -42,4 +42,9 @@ extend type Mutation { @scope(area: "fields", write: true) archiveField(id: UUID!): Boolean! @scope(area: "fields", write: true) writeContactFields(contactId: UUID!, values: JSON!): Boolean! @scope(area: "contacts", write: true) + addContactFieldEntry(contactId: UUID!, field: String!, entry: JSON!): JSON! @scope(area: "contacts", write: true) + updateContactFieldEntry(contactId: UUID!, field: String!, entryId: UUID!, entry: JSON!): JSON! + @scope(area: "contacts", write: true) + deleteContactFieldEntry(contactId: UUID!, field: String!, entryId: UUID!): Boolean! + @scope(area: "contacts", write: true) } diff --git a/plugins/fields/graphql.go b/plugins/fields/graphql.go index 8cb6e30..6404fcd 100644 --- a/plugins/fields/graphql.go +++ b/plugins/fields/graphql.go @@ -177,6 +177,10 @@ var fieldReasons = []struct { {errWrongKind, "value_kind_mismatch"}, {errNoField, "field_unknown"}, {errValuesNotAnObject, "values_not_an_object"}, + {errNotARepeater, "field_not_a_repeater"}, + {errEntryEmpty, "field_entry_empty"}, + {errNoEntry, "field_entry_not_found"}, + {errEntriesFull, "field_entries_full"}, } // fieldReason returns the stable reason a fields error answers with. diff --git a/plugins/fields/graphql_internal_test.go b/plugins/fields/graphql_internal_test.go index 149d87d..1fd8a5f 100644 --- a/plugins/fields/graphql_internal_test.go +++ b/plugins/fields/graphql_internal_test.go @@ -93,6 +93,23 @@ func TestEverySubFieldSentinelNamesItsReason(t *testing.T) { } } +func TestEveryEntrySentinelNamesItsReason(t *testing.T) { + t.Parallel() + + want := map[error]string{ + errNotARepeater: "field_not_a_repeater", + errEntryEmpty: "field_entry_empty", + errNoEntry: "field_entry_not_found", + errEntriesFull: "field_entries_full", + } + + for sentinel, reason := range want { + if got := fieldReason(sentinel); got != reason { + t.Errorf("fieldReason(%v) = %q, want %q", sentinel, got, reason) + } + } +} + func TestEveryReasonHasAFrontendMessage(t *testing.T) { t.Parallel() diff --git a/plugins/fields/languages/alphone-fields.pot b/plugins/fields/languages/alphone-fields.pot index 4708363..a909186 100644 --- a/plugins/fields/languages/alphone-fields.pot +++ b/plugins/fields/languages/alphone-fields.pot @@ -58,6 +58,9 @@ msgstr "" msgid "Fields" msgstr "" +msgid "Fill in at least one part of the entry." +msgstr "" + msgid "Kind" msgstr "" @@ -112,6 +115,12 @@ msgstr "" msgid "Sub fields" msgstr "" +msgid "That entry no longer exists." +msgstr "" + +msgid "That field does not keep a list of entries." +msgstr "" + msgid "That field is not one this contact holds." msgstr "" @@ -142,6 +151,9 @@ msgstr "" msgid "The fields could not be saved." msgstr "" +msgid "This list is full. It holds %(max)d entries at most." +msgstr "" + msgid "Two sub fields of this repeater hold the same name." msgstr "" diff --git a/plugins/fields/languages/es-ES.po b/plugins/fields/languages/es-ES.po index a697835..5fb667b 100644 --- a/plugins/fields/languages/es-ES.po +++ b/plugins/fields/languages/es-ES.po @@ -76,6 +76,10 @@ msgstr "Archivar %(label)s" msgid "Fields" msgstr "Campos" +#, fuzzy +msgid "Fill in at least one part of the entry." +msgstr "Rellena al menos una parte de la entrada." + #, fuzzy msgid "Kind" msgstr "Tipo" @@ -148,6 +152,14 @@ msgstr "Subcampo %(number)d" msgid "Sub fields" msgstr "Subcampos" +#, fuzzy +msgid "That entry no longer exists." +msgstr "Esa entrada ya no existe." + +#, fuzzy +msgid "That field does not keep a list of entries." +msgstr "Ese campo no guarda una lista de entradas." + #, fuzzy msgid "That field is not one this contact holds." msgstr "Ese campo no es uno de los que tiene este contacto." @@ -188,6 +200,10 @@ msgstr "No se han podido cargar los campos." msgid "The fields could not be saved." msgstr "No se han podido guardar los campos." +#, fuzzy +msgid "This list is full. It holds %(max)d entries at most." +msgstr "Esta lista está llena. Guarda como mucho %(max)d entradas." + #, fuzzy msgid "Two sub fields of this repeater hold the same name." msgstr "Dos subcampos de este repetidor tienen el mismo nombre." From fd42a4003f507926b0100b388c79d49f147fc77e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 12:21:05 +0200 Subject: [PATCH 18/48] chore(graph): regenerate the schema with repeater entry mutations --- graph/generated.go | 361 +++++++++++++++++++++++++++++++++++++++---- graph/schema.graphql | 3 + 2 files changed, 337 insertions(+), 27 deletions(-) diff --git a/graph/generated.go b/graph/generated.go index f7de832..d7a1546 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -179,33 +179,36 @@ type ComplexityRoot struct { } Mutation struct { - APITokenCreate func(childComplexity int, name string, scopes []string, ttlDays *int) int - APITokenRevoke func(childComplexity int, id uuid.UUID) int - AcceptInvite func(childComplexity int, token string, password string) int - AddContactIdentity func(childComplexity int, contactID uuid.UUID, identity model.ContactIdentityInput) int - ArchiveField func(childComplexity int, id uuid.UUID) int - CreateContact func(childComplexity int, name string, identities []*model.ContactIdentityInput) int - CreateTask func(childComplexity int, input model.CreateTaskInput) int - CreateWebhook func(childComplexity int, url string, events []string) int - DefineField func(childComplexity int, name string, label string, kind model.FieldKind, subFields []*model.FieldSubFieldInput) int - DeleteContactIdentity func(childComplexity int, contactID uuid.UUID, identityID uuid.UUID) int - DeleteWebhook func(childComplexity int, id uuid.UUID) int - ImportCommit func(childComplexity int, id uuid.UUID) int - ImportSetMapping func(childComplexity int, id uuid.UUID, assignments []*model.ImportAssignmentInput) int - ImportUpload func(childComplexity int, file graphql.Upload) int - Invite func(childComplexity int, email string, name string, role *string) int - Login func(childComplexity int, email string, password string) int - Logout func(childComplexity int) int - RenameContact func(childComplexity int, id uuid.UUID, name string) int - RequestPasswordReset func(childComplexity int, email string) int - ResendInvite func(childComplexity int, email string) int - ResetPassword func(childComplexity int, token string, password string) int - SetLocale func(childComplexity int, locale string) int - SetUserDisabled func(childComplexity int, id uuid.UUID, disabled bool) int - SetUserRole func(childComplexity int, id uuid.UUID, role string) int - UpdateTask func(childComplexity int, id uuid.UUID, input model.UpdateTaskInput) int - WhatsAppSendMessage func(childComplexity int, conversationID uuid.UUID, content string) int - WriteContactFields func(childComplexity int, contactID uuid.UUID, values interface{}) int + APITokenCreate func(childComplexity int, name string, scopes []string, ttlDays *int) int + APITokenRevoke func(childComplexity int, id uuid.UUID) int + AcceptInvite func(childComplexity int, token string, password string) int + AddContactFieldEntry func(childComplexity int, contactID uuid.UUID, field string, entry interface{}) int + AddContactIdentity func(childComplexity int, contactID uuid.UUID, identity model.ContactIdentityInput) int + ArchiveField func(childComplexity int, id uuid.UUID) int + CreateContact func(childComplexity int, name string, identities []*model.ContactIdentityInput) int + CreateTask func(childComplexity int, input model.CreateTaskInput) int + CreateWebhook func(childComplexity int, url string, events []string) int + DefineField func(childComplexity int, name string, label string, kind model.FieldKind, subFields []*model.FieldSubFieldInput) int + DeleteContactFieldEntry func(childComplexity int, contactID uuid.UUID, field string, entryID uuid.UUID) int + DeleteContactIdentity func(childComplexity int, contactID uuid.UUID, identityID uuid.UUID) int + DeleteWebhook func(childComplexity int, id uuid.UUID) int + ImportCommit func(childComplexity int, id uuid.UUID) int + ImportSetMapping func(childComplexity int, id uuid.UUID, assignments []*model.ImportAssignmentInput) int + ImportUpload func(childComplexity int, file graphql.Upload) int + Invite func(childComplexity int, email string, name string, role *string) int + Login func(childComplexity int, email string, password string) int + Logout func(childComplexity int) int + RenameContact func(childComplexity int, id uuid.UUID, name string) int + RequestPasswordReset func(childComplexity int, email string) int + ResendInvite func(childComplexity int, email string) int + ResetPassword func(childComplexity int, token string, password string) int + SetLocale func(childComplexity int, locale string) int + SetUserDisabled func(childComplexity int, id uuid.UUID, disabled bool) int + SetUserRole func(childComplexity int, id uuid.UUID, role string) int + UpdateContactFieldEntry func(childComplexity int, contactID uuid.UUID, field string, entryID uuid.UUID, entry interface{}) int + UpdateTask func(childComplexity int, id uuid.UUID, input model.UpdateTaskInput) int + WhatsAppSendMessage func(childComplexity int, conversationID uuid.UUID, content string) int + WriteContactFields func(childComplexity int, contactID uuid.UUID, values interface{}) int } PageInfo struct { @@ -360,6 +363,9 @@ type MutationResolver interface { DefineField(ctx context.Context, name string, label string, kind model.FieldKind, subFields []*model.FieldSubFieldInput) (*model.FieldDefinition, error) ArchiveField(ctx context.Context, id uuid.UUID) (bool, error) WriteContactFields(ctx context.Context, contactID uuid.UUID, values interface{}) (bool, error) + AddContactFieldEntry(ctx context.Context, contactID uuid.UUID, field string, entry interface{}) (interface{}, error) + UpdateContactFieldEntry(ctx context.Context, contactID uuid.UUID, field string, entryID uuid.UUID, entry interface{}) (interface{}, error) + DeleteContactFieldEntry(ctx context.Context, contactID uuid.UUID, field string, entryID uuid.UUID) (bool, error) ImportUpload(ctx context.Context, file graphql.Upload) (*model.ImportJob, error) ImportSetMapping(ctx context.Context, id uuid.UUID, assignments []*model.ImportAssignmentInput) (*model.ImportJob, error) ImportCommit(ctx context.Context, id uuid.UUID) (*model.ImportCommitPayload, error) @@ -939,6 +945,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Mutation.AcceptInvite(childComplexity, args["token"].(string), args["password"].(string)), true + case "Mutation.addContactFieldEntry": + if e.ComplexityRoot.Mutation.AddContactFieldEntry == nil { + break + } + + args, err := ec.field_Mutation_addContactFieldEntry_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.AddContactFieldEntry(childComplexity, args["contactId"].(uuid.UUID), args["field"].(string), args["entry"].(interface{})), true case "Mutation.addContactIdentity": if e.ComplexityRoot.Mutation.AddContactIdentity == nil { break @@ -1005,6 +1022,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Mutation.DefineField(childComplexity, args["name"].(string), args["label"].(string), args["kind"].(model.FieldKind), args["subFields"].([]*model.FieldSubFieldInput)), true + case "Mutation.deleteContactFieldEntry": + if e.ComplexityRoot.Mutation.DeleteContactFieldEntry == nil { + break + } + + args, err := ec.field_Mutation_deleteContactFieldEntry_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.DeleteContactFieldEntry(childComplexity, args["contactId"].(uuid.UUID), args["field"].(string), args["entryId"].(uuid.UUID)), true case "Mutation.deleteContactIdentity": if e.ComplexityRoot.Mutation.DeleteContactIdentity == nil { break @@ -1165,6 +1193,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Mutation.SetUserRole(childComplexity, args["id"].(uuid.UUID), args["role"].(string)), true + case "Mutation.updateContactFieldEntry": + if e.ComplexityRoot.Mutation.UpdateContactFieldEntry == nil { + break + } + + args, err := ec.field_Mutation_updateContactFieldEntry_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateContactFieldEntry(childComplexity, args["contactId"].(uuid.UUID), args["field"].(string), args["entryId"].(uuid.UUID), args["entry"].(interface{})), true case "Mutation.updateTask": if e.ComplexityRoot.Mutation.UpdateTask == nil { break @@ -1884,6 +1923,11 @@ extend type Mutation { @scope(area: "fields", write: true) archiveField(id: UUID!): Boolean! @scope(area: "fields", write: true) writeContactFields(contactId: UUID!, values: JSON!): Boolean! @scope(area: "contacts", write: true) + addContactFieldEntry(contactId: UUID!, field: String!, entry: JSON!): JSON! @scope(area: "contacts", write: true) + updateContactFieldEntry(contactId: UUID!, field: String!, entryId: UUID!, entry: JSON!): JSON! + @scope(area: "contacts", write: true) + deleteContactFieldEntry(contactId: UUID!, field: String!, entryId: UUID!): Boolean! + @scope(area: "contacts", write: true) } `, BuiltIn: false}, {Name: "../plugins/importer/graph/schema.graphqls", Input: `extend type Query { @@ -2644,6 +2688,36 @@ func (ec *executionContext) field_Mutation_acceptInvite_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_addContactFieldEntry_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "contactId", + func(ctx context.Context, v any) (uuid.UUID, error) { + return ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + }) + if err != nil { + return nil, err + } + args["contactId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "field", + func(ctx context.Context, v any) (string, error) { + return ec.unmarshalNString2string(ctx, v) + }) + if err != nil { + return nil, err + } + args["field"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "entry", + func(ctx context.Context, v any) (any, error) { + return ec.unmarshalNJSON2interface(ctx, v) + }) + if err != nil { + return nil, err + } + args["entry"] = arg2 + return args, nil +} + func (ec *executionContext) field_Mutation_addContactIdentity_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -2820,6 +2894,36 @@ func (ec *executionContext) field_Mutation_defineField_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_deleteContactFieldEntry_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "contactId", + func(ctx context.Context, v any) (uuid.UUID, error) { + return ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + }) + if err != nil { + return nil, err + } + args["contactId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "field", + func(ctx context.Context, v any) (string, error) { + return ec.unmarshalNString2string(ctx, v) + }) + if err != nil { + return nil, err + } + args["field"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "entryId", + func(ctx context.Context, v any) (uuid.UUID, error) { + return ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + }) + if err != nil { + return nil, err + } + args["entryId"] = arg2 + return args, nil +} + func (ec *executionContext) field_Mutation_deleteContactIdentity_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -3088,6 +3192,44 @@ func (ec *executionContext) field_Mutation_setUserRole_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_updateContactFieldEntry_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "contactId", + func(ctx context.Context, v any) (uuid.UUID, error) { + return ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + }) + if err != nil { + return nil, err + } + args["contactId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "field", + func(ctx context.Context, v any) (string, error) { + return ec.unmarshalNString2string(ctx, v) + }) + if err != nil { + return nil, err + } + args["field"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "entryId", + func(ctx context.Context, v any) (uuid.UUID, error) { + return ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + }) + if err != nil { + return nil, err + } + args["entryId"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "entry", + func(ctx context.Context, v any) (any, error) { + return ec.unmarshalNJSON2interface(ctx, v) + }) + if err != nil { + return nil, err + } + args["entry"] = arg3 + return args, nil +} + func (ec *executionContext) field_Mutation_updateTask_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -6340,6 +6482,150 @@ func (ec *executionContext) fieldContext_Mutation_writeContactFields(ctx context return fc, nil } +func (ec *executionContext) _Mutation_addContactFieldEntry(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_addContactFieldEntry(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().AddContactFieldEntry(ctx, fc.Args["contactId"].(uuid.UUID), fc.Args["field"].(string), + func() any { + if fc.Args["entry"] == nil { + return nil + } + return fc.Args["entry"].(any) + }()) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v any) graphql.Marshaler { + return ec.marshalNJSON2interface(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_addContactFieldEntry(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type JSON does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_addContactFieldEntry_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_updateContactFieldEntry(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_updateContactFieldEntry(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateContactFieldEntry(ctx, fc.Args["contactId"].(uuid.UUID), fc.Args["field"].(string), fc.Args["entryId"].(uuid.UUID), + func() any { + if fc.Args["entry"] == nil { + return nil + } + return fc.Args["entry"].(any) + }()) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v any) graphql.Marshaler { + return ec.marshalNJSON2interface(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_updateContactFieldEntry(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type JSON does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updateContactFieldEntry_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_deleteContactFieldEntry(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_deleteContactFieldEntry(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().DeleteContactFieldEntry(ctx, fc.Args["contactId"].(uuid.UUID), fc.Args["field"].(string), fc.Args["entryId"].(uuid.UUID)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v bool) graphql.Marshaler { + return ec.marshalNBoolean2bool(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_deleteContactFieldEntry(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deleteContactFieldEntry_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_importUpload(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -11387,6 +11673,27 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "addContactFieldEntry": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_addContactFieldEntry(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "updateContactFieldEntry": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateContactFieldEntry(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deleteContactFieldEntry": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deleteContactFieldEntry(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "importUpload": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_importUpload(ctx, field) diff --git a/graph/schema.graphql b/graph/schema.graphql index b074ab1..61f14f6 100644 --- a/graph/schema.graphql +++ b/graph/schema.graphql @@ -171,6 +171,9 @@ type Mutation { defineField(name: String!, label: String!, kind: FieldKind!, subFields: [FieldSubFieldInput!]): FieldDefinition! @scope(area: "fields", write: true) archiveField(id: UUID!): Boolean! @scope(area: "fields", write: true) writeContactFields(contactId: UUID!, values: JSON!): Boolean! @scope(area: "contacts", write: true) + addContactFieldEntry(contactId: UUID!, field: String!, entry: JSON!): JSON! @scope(area: "contacts", write: true) + updateContactFieldEntry(contactId: UUID!, field: String!, entryId: UUID!, entry: JSON!): JSON! @scope(area: "contacts", write: true) + deleteContactFieldEntry(contactId: UUID!, field: String!, entryId: UUID!): Boolean! @scope(area: "contacts", write: true) importUpload(file: Upload!): ImportJob! @scope(area: "imports", write: true) importSetMapping(id: UUID!, assignments: [ImportAssignmentInput!]!): ImportJob! @scope(area: "imports", write: true) importCommit(id: UUID!): ImportCommitPayload! @scope(area: "imports", write: true) From 662e01568ce9407a6c3f48b5f82a9f9d77abdc94 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 12:21:06 +0200 Subject: [PATCH 19/48] test(fields): round trip repeater entries over the graph --- plugins/fields/entries_graph_test.go | 146 +++++++++++++++++++++++++++ plugins/fields/graphql_test.go | 8 +- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 plugins/fields/entries_graph_test.go diff --git a/plugins/fields/entries_graph_test.go b/plugins/fields/entries_graph_test.go new file mode 100644 index 0000000..a24e68e --- /dev/null +++ b/plugins/fields/entries_graph_test.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package fields_test + +import ( + "reflect" + "strings" + "testing" + + gqlclient "github.com/99designs/gqlgen/client" + "github.com/google/uuid" +) + +// addEntryMutation adds one entry to a contact's repeater through the graph. +const addEntryMutation = `mutation($id: UUID!, $entry: JSON!) { + addContactFieldEntry(contactId: $id, field: "history", entry: $entry) +}` + +// defineHistory declares the history repeater of a date and a comment through the graph. +func defineHistory(t *testing.T, client *gqlclient.Client) { + t.Helper() + var created struct { + DefineField struct { + ID string `json:"id"` + } + } + client.MustPost(`mutation { defineField(name: "history", label: "History", kind: REPEATER, subFields: [ + {name: "date", label: "Date", kind: DATE}, {name: "comment", label: "Comment", kind: LONGTEXT}]) { id } }`, + &created) +} + +// addedEntry adds one entry through the graph and answers it as the graph did. +func addedEntry(t *testing.T, client *gqlclient.Client, contactID uuid.UUID, entry map[string]any) map[string]any { + t.Helper() + var added struct { + AddContactFieldEntry map[string]any + } + client.MustPost(addEntryMutation, &added, + gqlclient.Var("id", contactID.String()), gqlclient.Var("entry", entry)) + return added.AddContactFieldEntry +} + +// historyRead reads a contact's history through the graph. +func historyRead(t *testing.T, client *gqlclient.Client, contactID uuid.UUID) any { + t.Helper() + var read struct { + Contact struct { + Field any `json:"field"` + } + } + client.MustPost(`query($id: UUID!) { contact(id: $id) { field(name: "history") } }`, &read, + gqlclient.Var("id", contactID.String())) + return read.Contact.Field +} + +func TestGraphKeepsRepeaterEntriesOneAtATime(t *testing.T) { + t.Parallel() + + client, contactID := newValuesClient(t) + defineHistory(t, client) + older := addedEntry(t, client, contactID, map[string]any{"date": "2026-09-01", "comment": "First call."}) + newer := addedEntry(t, client, contactID, map[string]any{"comment": "Sent the offer."}) + + var updated struct { + UpdateContactFieldEntry map[string]any + } + client.MustPost(`mutation($id: UUID!, $entryId: UUID!, $entry: JSON!) { + updateContactFieldEntry(contactId: $id, field: "history", entryId: $entryId, entry: $entry) + }`, &updated, gqlclient.Var("id", contactID.String()), gqlclient.Var("entryId", older["id"]), + gqlclient.Var("entry", map[string]any{"date": "2026-09-02", "comment": "First call, moved."})) + var deleted struct { + DeleteContactFieldEntry bool + } + client.MustPost(`mutation($id: UUID!, $entryId: UUID!) { + deleteContactFieldEntry(contactId: $id, field: "history", entryId: $entryId) + }`, &deleted, gqlclient.Var("id", contactID.String()), gqlclient.Var("entryId", newer["id"])) + + want := []any{map[string]any{"id": older["id"], "date": "2026-09-02", "comment": "First call, moved."}} + if held := historyRead(t, client, contactID); !reflect.DeepEqual(held, want) { + t.Errorf("history = %#v, want the edited entry alone under its own id", held) + } + if updated.UpdateContactFieldEntry["id"] != older["id"] || !deleted.DeleteContactFieldEntry { + t.Errorf("update = %#v, delete = %t, want the edit answered and the delete confirmed", + updated.UpdateContactFieldEntry, deleted.DeleteContactFieldEntry) + } +} + +func TestGraphAnswersEntriesNewestFirst(t *testing.T) { + t.Parallel() + + client, contactID := newValuesClient(t) + defineHistory(t, client) + first := addedEntry(t, client, contactID, map[string]any{"comment": "First call."}) + second := addedEntry(t, client, contactID, map[string]any{"comment": "Sent the offer."}) + + held, _ := historyRead(t, client, contactID).([]any) + + if len(held) != 2 || !reflect.DeepEqual(held[0], second) || !reflect.DeepEqual(held[1], first) { + t.Errorf("history = %#v, want the newest entry first, each as its add answered", held) + } +} + +func TestGraphAnswersAFullListAsAConflictNamingTheCap(t *testing.T) { + t.Parallel() + + client := newFieldsClientWith(t, func(name string) string { + if name == "ALPHONE_FIELDS_ENTRIES_MAX" { + return "1" + } + return "" + }) + contactID := seedGraphContact(t, client, "Maria Perez") + defineHistory(t, client) + addedEntry(t, client, contactID, map[string]any{"comment": "First call."}) + + var added struct { + AddContactFieldEntry map[string]any + } + err := client.Post(addEntryMutation, &added, + gqlclient.Var("id", contactID.String()), gqlclient.Var("entry", map[string]any{"comment": "Sent the offer."})) + + for _, held := range []string{`"code":"CONFLICT"`, `"reason":"field_entries_full"`, `"max":1`} { + if err == nil || !strings.Contains(err.Error(), held) { + t.Errorf("error = %v, want it to carry %s", err, held) + } + } +} + +func TestGraphAnswersAnAddToAnUnknownContactAsNotFound(t *testing.T) { + t.Parallel() + + client, _ := newValuesClient(t) + defineHistory(t, client) + + var added struct { + AddContactFieldEntry map[string]any + } + err := client.Post(addEntryMutation, &added, + gqlclient.Var("id", uuid.Must(uuid.NewV7()).String()), gqlclient.Var("entry", map[string]any{"comment": "x"})) + + for _, held := range []string{`"code":"NOT_FOUND"`, `"reason":"contact_not_found"`} { + if err == nil || !strings.Contains(err.Error(), held) { + t.Errorf("error = %v, want it to carry %s", err, held) + } + } +} diff --git a/plugins/fields/graphql_test.go b/plugins/fields/graphql_test.go index e23074b..fbfc4a6 100644 --- a/plugins/fields/graphql_test.go +++ b/plugins/fields/graphql_test.go @@ -28,6 +28,12 @@ const unreachable = "postgres://graph:graph@localhost:1/graph" // newFieldsClient returns a graph client over a migrated fields plugin. func newFieldsClient(t *testing.T) *gqlclient.Client { + t.Helper() + return newFieldsClientWith(t, nil) +} + +// newFieldsClientWith returns a graph client over a migrated fields plugin reading the given environment. +func newFieldsClientWith(t *testing.T, getenv func(string) string) *gqlclient.Client { t.Helper() if testing.Short() { t.Skip("skipping database test in short mode") @@ -38,7 +44,7 @@ func newFieldsClient(t *testing.T) *gqlclient.Client { t.Fatalf("connecting the test pool: %v", err) } t.Cleanup(pool.Close) - plugin, err := fields.Register(sdk.Deps{DatabaseURL: cfg.URL()}) + plugin, err := fields.Register(sdk.Deps{DatabaseURL: cfg.URL(), Getenv: getenv}) if err != nil { t.Fatalf("fields.Register() error = %v, want nil", err) } From 0103e6d79ce89dc4a4d405fafe6fe1a0315e7974 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 12:21:06 +0200 Subject: [PATCH 20/48] docs(reference): describe the repeater entry mutations --- docs/src/content/docs/reference/graphql-api.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index 24971d5..edd377b 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -426,6 +426,10 @@ The stock plugins add their own: | `field_unknown` | | no live definition holds the name | | `value_kind_mismatch` | | the value does not match the declared kind | | `values_not_an_object` | | values arrive as an object of names | +| `field_not_a_repeater` | | the field keeps no list of entries | +| `field_entry_empty` | | every cell of the entry is blank | +| `field_entry_not_found` | | no entry of that id is in that field of the contact | +| `field_entries_full` | `max` | the list already holds the most entries it may | | `message_content_required` | | a message needs text | | `conversation_not_found` | | the id names no conversation | | `upstream_failed` | | the messaging platform did not accept | @@ -515,6 +519,7 @@ cannot drift. Point a client at the endpoint, or read | Contacts | `contacts`, `contact` | `createContact`, `renameContact`, `addContactIdentity`, `deleteContactIdentity` | | Tasks | `tasks`, `task` | `createTask`, `updateTask` | | Webhooks | `webhooks` | `createWebhook`, `deleteWebhook` | +| Fields | `fields`, `Contact.field` | `defineField`, `archiveField`, `writeContactFields`, `addContactFieldEntry`, `updateContactFieldEntry`, `deleteContactFieldEntry` | | Imports | `imports`, `importJob`, `importFields` | `importUpload`, `importSetMapping`, `importCommit` | | WhatsApp | `whatsAppConversations`, `whatsAppConversation` | `whatsAppSendMessage` | | Version | `version` | | From 9669f762f5695e64da19d066c84a8abc117c9d3e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 12:26:49 +0200 Subject: [PATCH 21/48] test(features): run the repeater entry scenarios the graph now serves --- .../features/features/fields-repeater.feature | 19 -- test/features/features/fields-tenants.feature | 2 - test/features/steps_fields_entries_test.go | 297 ++++++++++++++++++ test/features/steps_fields_repeater_test.go | 51 +-- test/features/steps_fields_tenants_test.go | 2 + test/features/steps_fields_test.go | 2 +- test/features/world_test.go | 3 + 7 files changed, 336 insertions(+), 40 deletions(-) create mode 100644 test/features/steps_fields_entries_test.go diff --git a/test/features/features/fields-repeater.feature b/test/features/features/fields-repeater.feature index ca06470..ecb87e1 100644 --- a/test/features/features/fields-repeater.feature +++ b/test/features/features/fields-repeater.feature @@ -147,7 +147,6 @@ Feature: A repeater keeps a list of entries on a contact | id | ID | TEXT | Then the definition is refused with the reason "field_sub_field_name_reserved" - @wip Scenario: Adding an entry stores it with an id Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -161,7 +160,6 @@ Feature: A repeater keeps a list of entries on a contact | date | comment | | 2026-09-01 | First call | - @wip Scenario: Entries answer newest first Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -177,7 +175,6 @@ Feature: A repeater keeps a list of entries on a contact | 2026-09-01 | First call | And every entry keeps the id its add answered - @wip Scenario: An entry naming an unknown sub field is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -188,7 +185,6 @@ Feature: A repeater keeps a list of entries on a contact | 2026-09-01 | happy | Then the write is refused naming "history.mood" as the bad key - @wip Scenario: An entry naming its own id on an add is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -196,7 +192,6 @@ Feature: A repeater keeps a list of entries on a contact When the operator adds the raw entry {"id": "0199a3c4-0000-7000-8000-000000000001", "comment": "First call"} to "history" of the contact Then the write is refused naming "history.id" as the bad key - @wip Scenario: An entry holding a value of the wrong kind is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -207,7 +202,6 @@ Feature: A repeater keeps a list of entries on a contact | not a date | First call | Then the write is refused for a value of the wrong kind - @wip Scenario Outline: An entry with every cell blank is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -222,7 +216,6 @@ Feature: A repeater keeps a list of entries on a contact | {"date": null, "comment": ""} | | {"comment": " "} | - @wip Scenario: Adding to a field that is not a repeater is refused Given the field "birthDate" labelled "Birth date" of kind DATE is defined When the operator adds an entry to "birthDate" of the contact: @@ -230,7 +223,6 @@ Feature: A repeater keeps a list of entries on a contact | 2026-09-01 | Then the change is refused with the reason "field_not_a_repeater" - @wip Scenario: Adding to an archived repeater is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -241,7 +233,6 @@ Feature: A repeater keeps a list of entries on a contact | First call | Then the write is refused naming "history" as the bad key - @wip Scenario: Editing an entry changes only its cells and keeps its id and its place Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -260,7 +251,6 @@ Feature: A repeater keeps a list of entries on a contact | 2026-09-02 | First call, moved out | And every entry keeps the id its add answered - @wip Scenario: Editing an entry after another add still changes that entry Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -279,7 +269,6 @@ Feature: A repeater keeps a list of entries on a contact | Sent the offer | | First call, amended | - @wip Scenario: An edit that sends the entry's own id back is accepted Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -295,7 +284,6 @@ Feature: A repeater keeps a list of entries on a contact | First call, amended | And every entry keeps the id its add answered - @wip Scenario: An edit of the wrong kind is refused and leaves the entry unchanged Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -312,7 +300,6 @@ Feature: A repeater keeps a list of entries on a contact | date | comment | | 2026-09-01 | First call | - @wip Scenario: An edit with every cell blank is refused and leaves the entry unchanged Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -326,7 +313,6 @@ Feature: A repeater keeps a list of entries on a contact | comment | | First call | - @wip Scenario: Editing an entry that was removed is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -340,7 +326,6 @@ Feature: A repeater keeps a list of entries on a contact | First call, amended | Then the change is refused with the reason "field_entry_not_found" - @wip Scenario: Deleting an entry drops only that entry Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -357,7 +342,6 @@ Feature: A repeater keeps a list of entries on a contact | First call | And every entry keeps the id its add answered - @wip Scenario: Deleting the last entry clears the repeater Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -368,7 +352,6 @@ Feature: A repeater keeps a list of entries on a contact When the operator removes the entry "First call" from "history" Then the contact "Maria Perez" answers null for the field "history" - @wip Scenario: Deleting an unknown entry is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -382,7 +365,6 @@ Feature: A repeater keeps a list of entries on a contact | comment | | First call | - @wip Scenario: Entry ids survive archiving the repeater and defining it again Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -399,7 +381,6 @@ Feature: A repeater keeps a list of entries on a contact | First call | And every entry keeps the id its add answered - @wip Scenario: Saving other fields keeps the entries Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | diff --git a/test/features/features/fields-tenants.feature b/test/features/features/fields-tenants.feature index 3a1a63d..a1c2104 100644 --- a/test/features/features/fields-tenants.feature +++ b/test/features/features/fields-tenants.feature @@ -58,7 +58,6 @@ Feature: Each tenant keeps its own contact fields Then the commit answers 1 imported And the contact "Maria Perez" answers "1990-04-17" for the field "birthDate" - @wip Scenario: A tenant cannot add an entry to another tenant's contact Given a contact named "Maria Perez" And the tenant "Acme" defines the repeater "history" labelled "History" with sub fields: @@ -69,7 +68,6 @@ Feature: Each tenant keeps its own contact fields | First call | Then the change is refused with the reason "contact_not_found" - @wip Scenario: A tenant cannot edit or remove another tenant's entry Given a contact named "Maria Perez" And the repeater "history" labelled "History" is defined with sub fields: diff --git a/test/features/steps_fields_entries_test.go b/test/features/steps_fields_entries_test.go new file mode 100644 index 0000000..79cb729 --- /dev/null +++ b/test/features/steps_fields_entries_test.go @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package features_test + +import ( + "context" + "encoding/json" + "fmt" + "slices" + + "github.com/cucumber/godog" + "github.com/google/uuid" +) + +// addEntryMutation adds one entry to a contact's repeater through the graph. +const addEntryMutation = `mutation($contactId: UUID!, $field: String!, $entry: JSON!) { + addContactFieldEntry(contactId: $contactId, field: $field, entry: $entry) +}` + +// updateEntryMutation replaces the cells of one entry through the graph. +const updateEntryMutation = `mutation($contactId: UUID!, $field: String!, $entryId: UUID!, $entry: JSON!) { + updateContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId, entry: $entry) +}` + +// deleteEntryMutation drops one entry through the graph. +const deleteEntryMutation = `mutation($contactId: UUID!, $field: String!, $entryId: UUID!) { + deleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId) +}` + +// entryAnswer is the envelope an entry mutation answers. +type entryAnswer struct { + Data struct { + Added map[string]any `json:"addContactFieldEntry"` + Updated map[string]any `json:"updateContactFieldEntry"` + } `json:"data"` +} + +// secretFor returns the token acting for the named tenant, or the owner's when no tenant is named. +func (w *world) secretFor(tenant string) (string, error) { + if tenant == "" { + return w.secret, nil + } + return w.secretOf(tenant) +} + +// remember keeps an entry's id under every text cell it holds, so later steps name the entry by its text. +func (w *world) remember(entry map[string]any) { + id, _ := entry["id"].(string) + if w.entryIDs == nil { + w.entryIDs = map[string]string{} + } + for key, value := range entry { + if text, isText := value.(string); isText && key != "id" { + w.entryIDs[text] = id + } + } +} + +// entryIDOf returns the id the scenario remembers for the entry holding the given text. +func (w *world) entryIDOf(text string) (string, error) { + id, held := w.entryIDs[text] + if !held { + return "", fmt.Errorf("the scenario added no entry holding %q", text) + } + return id, nil +} + +// addEntry adds one entry to the seeded contact as the given secret, remembering it when the graph accepts it. +func (w *world) addEntry(ctx context.Context, secret, field string, entry any) (bool, error) { + w.entryField = field + if _, err := w.operationAs(ctx, secret, addEntryMutation, + map[string]any{"contactId": w.lastContact.String(), "field": field, "entry": entry}); err != nil { + return false, err + } + var answer entryAnswer + if err := json.Unmarshal(w.answered, &answer); err != nil { + return false, fmt.Errorf("decoding %s: %w", w.answered, err) + } + if answer.Data.Added == nil { + return false, nil + } + w.remember(answer.Data.Added) + id, _ := answer.Data.Added["id"].(string) + w.answeredIDs = append(w.answeredIDs, id) + return true, nil +} + +// editEntry replaces the cells of the entry holding the given text, as the given secret. +func (w *world) editEntry(ctx context.Context, secret, text, field string, entry map[string]any) error { + id, err := w.entryIDOf(text) + if err != nil { + return err + } + if _, err := w.operationAs(ctx, secret, updateEntryMutation, map[string]any{ + "contactId": w.lastContact.String(), "field": field, "entryId": id, "entry": entry, + }); err != nil { + return err + } + var answer entryAnswer + if err := json.Unmarshal(w.answered, &answer); err != nil { + return fmt.Errorf("decoding %s: %w", w.answered, err) + } + if answer.Data.Updated != nil { + w.remember(answer.Data.Updated) + } + return nil +} + +// removeEntry drops the entry of the given id from the seeded contact, as the given secret. +func (w *world) removeEntry(ctx context.Context, secret, id, field string) error { + _, err := w.operationAs(ctx, secret, deleteEntryMutation, + map[string]any{"contactId": w.lastContact.String(), "field": field, "entryId": id}) + return err +} + +// answeredEntries reads a field of the seeded contact and splits its entries into their ids and their cells. +func (w *world) answeredEntries(ctx context.Context, field string) ([]string, []map[string]any, error) { + answered, err := w.readField(ctx, field) + if err != nil { + return nil, nil, err + } + listed, _ := answered.Data.Contact[field].([]any) + ids := make([]string, 0, len(listed)) + cells := make([]map[string]any, 0, len(listed)) + for _, held := range listed { + entry, _ := held.(map[string]any) + id, _ := entry["id"].(string) + ids = append(ids, id) + rest := map[string]any{} + for key, value := range entry { + if key != "id" { + rest[key] = value + } + } + cells = append(cells, rest) + } + return ids, cells, nil +} + +// distinctIDs reports whether every id is a UUID and no two are the same. +func distinctIDs(ids []string) error { + seen := map[string]bool{} + for _, id := range ids { + if _, err := uuid.Parse(id); err != nil || seen[id] { + return fmt.Errorf("ids = %q, want a distinct id on every entry", ids) + } + seen[id] = true + } + return nil +} + +// bindRepeaterEntrySteps binds the steps that add, edit, remove and read repeater entries one at a time. +func bindRepeaterEntrySteps(sc *godog.ScenarioContext) { + bindEntryWriteSteps(sc) + bindEntryReadSteps(sc) +} + +// bindEntryWriteSteps binds the steps that add, edit and remove repeater entries. +func bindEntryWriteSteps(sc *godog.ScenarioContext) { + sc.Step(`^the operator (?:adds|added) the entries to "([^"]*)" of the contact, oldest first:$`, + func(ctx context.Context, field string, table *godog.Table) error { + w := worldFrom(ctx) + for _, entry := range tableRecords(table) { + accepted, err := w.addEntry(ctx, w.secret, field, entry) + if err != nil { + return err + } + if !accepted { + return fmt.Errorf("the graph refused the entry %v, answered %s", entry, w.answered) + } + } + return nil + }) + + sc.Step(`^(?:the operator|the tenant "([^"]*)") adds an entry to "([^"]*)" of the contact:$`, + func(ctx context.Context, tenant, field string, table *godog.Table) error { + w := worldFrom(ctx) + secret, err := w.secretFor(tenant) + if err != nil { + return err + } + _, err = w.addEntry(ctx, secret, field, tableRecords(table)[0]) + return err + }) + + sc.Step(`^the operator adds the raw entry (.+) to "([^"]*)" of the contact$`, + func(ctx context.Context, raw, field string) error { + w := worldFrom(ctx) + var entry any + if err := json.Unmarshal([]byte(raw), &entry); err != nil { + return fmt.Errorf("decoding the raw entry %s: %w", raw, err) + } + _, err := w.addEntry(ctx, w.secret, field, entry) + return err + }) + + sc.Step(`^(?:the operator|the tenant "([^"]*)") edits the entry "([^"]*)" of "([^"]*)" to:$`, + func(ctx context.Context, tenant, text, field string, table *godog.Table) error { + w := worldFrom(ctx) + secret, err := w.secretFor(tenant) + if err != nil { + return err + } + return w.editEntry(ctx, secret, text, field, tableRecords(table)[0]) + }) + + sc.Step(`^the operator edits the entry "([^"]*)" of "([^"]*)" sending its own id with:$`, + func(ctx context.Context, text, field string, table *godog.Table) error { + w := worldFrom(ctx) + id, err := w.entryIDOf(text) + if err != nil { + return err + } + entry := tableRecords(table)[0] + entry["id"] = id + return w.editEntry(ctx, w.secret, text, field, entry) + }) + + sc.Step(`^the operator edits the entry "([^"]*)" of "([^"]*)" to the raw entry (.+)$`, + func(ctx context.Context, text, field, raw string) error { + var entry map[string]any + if err := json.Unmarshal([]byte(raw), &entry); err != nil { + return fmt.Errorf("decoding the raw entry %s: %w", raw, err) + } + w := worldFrom(ctx) + return w.editEntry(ctx, w.secret, text, field, entry) + }) + + sc.Step(`^(?:the operator|the tenant "([^"]*)") removes the entry "([^"]*)" from "([^"]*)"$`, + func(ctx context.Context, tenant, text, field string) error { + w := worldFrom(ctx) + secret, err := w.secretFor(tenant) + if err != nil { + return err + } + id, err := w.entryIDOf(text) + if err != nil { + return err + } + return w.removeEntry(ctx, secret, id, field) + }) + + sc.When(`^the operator removes an unknown entry from "([^"]*)"$`, func(ctx context.Context, field string) error { + w := worldFrom(ctx) + return w.removeEntry(ctx, w.secret, uuid.Must(uuid.NewV7()).String(), field) + }) +} + +// bindEntryReadSteps binds the steps that read the entries a repeater answers. +func bindEntryReadSteps(sc *godog.ScenarioContext) { + sc.Then(`^the add answers the entry with an id$`, func(ctx context.Context) error { + w := worldFrom(ctx) + var answer entryAnswer + if err := json.Unmarshal(w.answered, &answer); err != nil { + return fmt.Errorf("decoding %s: %w", w.answered, err) + } + id, _ := answer.Data.Added["id"].(string) + if _, err := uuid.Parse(id); err != nil { + return fmt.Errorf("the add answered no id, answered %s", w.answered) + } + return nil + }) + + sc.Then(`^the contact's "([^"]*)" answers the entries, newest first:$`, + func(ctx context.Context, field string, table *godog.Table) error { + w := worldFrom(ctx) + ids, cells, err := w.answeredEntries(ctx, field) + if err != nil { + return err + } + same, err := sameJSON(cells, tableRecords(table)) + if err != nil { + return err + } + if !same { + return fmt.Errorf("%s = %v, answered %s", field, cells, w.answered) + } + return distinctIDs(ids) + }) + + sc.Then(`^every entry keeps the id its add answered$`, func(ctx context.Context) error { + w := worldFrom(ctx) + ids, _, err := w.answeredEntries(ctx, w.entryField) + if err != nil { + return err + } + if len(ids) == 0 { + return fmt.Errorf("%s answered no entries, answered %s", w.entryField, w.answered) + } + for _, id := range ids { + if !slices.Contains(w.answeredIDs, id) { + return fmt.Errorf("the entry id %q is not one an add answered, answered %s", id, w.answered) + } + } + return nil + }) +} diff --git a/test/features/steps_fields_repeater_test.go b/test/features/steps_fields_repeater_test.go index b7fa451..48e59a0 100644 --- a/test/features/steps_fields_repeater_test.go +++ b/test/features/steps_fields_repeater_test.go @@ -41,7 +41,14 @@ func tableRecords(table *godog.Table) []map[string]any { func (w *world) defineWithSubFields( ctx context.Context, name, label, kind string, subFields []map[string]any, ) error { - answer, err := w.operation(ctx, defineWithSubFieldsMutation, map[string]any{ + return w.defineWithSubFieldsAs(ctx, w.secret, name, label, kind, subFields) +} + +// defineWithSubFieldsAs declares a field holding the given sub fields as the given secret. +func (w *world) defineWithSubFieldsAs( + ctx context.Context, secret, name, label, kind string, subFields []map[string]any, +) error { + answer, err := w.operationAs(ctx, secret, defineWithSubFieldsMutation, map[string]any{ "name": name, "label": label, "kind": kind, "subFields": subFields, }) if err != nil { @@ -77,13 +84,20 @@ func registerFieldsRepeaterSteps(sc *godog.ScenarioContext, t *testing.T) { bindImportSteps(sc) bindRepeaterDefinitionSteps(sc) bindRepeaterRowSteps(sc) + bindRepeaterEntrySteps(sc) } // bindRepeaterDefinitionSteps binds the steps that define repeaters and read their sub fields. func bindRepeaterDefinitionSteps(sc *godog.ScenarioContext) { - sc.Step(`^(?:the operator defines )?the repeater "([^"]*)" labelled "([^"]*)" (?:is defined )?with sub fields:$`, - func(ctx context.Context, name, label string, table *godog.Table) error { - return worldFrom(ctx).defineWithSubFields(ctx, name, label, "REPEATER", tableRecords(table)) + sc.Step(`^(?:the operator defines |the tenant "([^"]*)" defines )?the repeater "([^"]*)" labelled "([^"]*)" `+ + `(?:is defined )?with sub fields:$`, + func(ctx context.Context, tenant, name, label string, table *godog.Table) error { + w := worldFrom(ctx) + secret, err := w.secretFor(tenant) + if err != nil { + return err + } + return w.defineWithSubFieldsAs(ctx, secret, name, label, "REPEATER", tableRecords(table)) }) sc.When(`^the operator defines the repeater "([^"]*)" labelled "([^"]*)" with no sub fields$`, @@ -116,20 +130,21 @@ func bindRepeaterDefinitionSteps(sc *godog.ScenarioContext) { return fmt.Errorf("the catalogue lists no field named %q, answered %s", name, w.answered) }) - sc.Then(`^the definition is refused with the reason "([^"]*)"$`, func(ctx context.Context, reason string) error { - w := worldFrom(ctx) - var answer graphAnswer - if err := json.Unmarshal(w.answered, &answer); err != nil { - return fmt.Errorf("decoding %s: %w", w.answered, err) - } - if len(answer.Errors) == 0 { - return fmt.Errorf("the graph accepted the definition, answered %s", w.answered) - } - if got := answer.Errors[0].Extensions["reason"]; got != reason { - return fmt.Errorf("reason = %v, want %s, answered %s", got, reason, w.answered) - } - return nil - }) + sc.Then(`^the (?:definition|change) is refused with the reason "([^"]*)"$`, + func(ctx context.Context, reason string) error { + w := worldFrom(ctx) + var answer graphAnswer + if err := json.Unmarshal(w.answered, &answer); err != nil { + return fmt.Errorf("decoding %s: %w", w.answered, err) + } + if len(answer.Errors) == 0 { + return fmt.Errorf("the graph accepted it, answered %s", w.answered) + } + if got := answer.Errors[0].Extensions["reason"]; got != reason { + return fmt.Errorf("reason = %v, want %s, answered %s", got, reason, w.answered) + } + return nil + }) } // bindRepeaterRowSteps binds the steps that write and read a repeater's rows. diff --git a/test/features/steps_fields_tenants_test.go b/test/features/steps_fields_tenants_test.go index d4b9b6a..62cc30c 100644 --- a/test/features/steps_fields_tenants_test.go +++ b/test/features/steps_fields_tenants_test.go @@ -34,6 +34,8 @@ func registerFieldsTenantsSteps(sc *godog.ScenarioContext, t *testing.T) { bindIntrospectionSteps(sc) bindTenantSteps(sc) bindImportSteps(sc) + bindRepeaterDefinitionSteps(sc) + bindRepeaterEntrySteps(sc) sc.Given(`^the tenant "([^"]*)" defines the field "([^"]*)" labelled "([^"]*)" of kind ([A-Z]+)$`, func(ctx context.Context, tenant, name, label, kind string) error { diff --git a/test/features/steps_fields_test.go b/test/features/steps_fields_test.go index 1d5136c..e1acec7 100644 --- a/test/features/steps_fields_test.go +++ b/test/features/steps_fields_test.go @@ -124,7 +124,7 @@ func bindFieldsCatalogSteps(sc *godog.ScenarioContext) { return worldFrom(ctx).defineField(ctx, name, label, kind) }) - sc.When(`^the operator archives the field "([^"]*)"$`, func(ctx context.Context, name string) error { + sc.Step(`^the operator archives the field "([^"]*)"$`, func(ctx context.Context, name string) error { w := worldFrom(ctx) listed, err := w.operation(ctx, fieldsQuery, map[string]any{}) if err != nil { diff --git a/test/features/world_test.go b/test/features/world_test.go index 3c0cbb8..7279851 100644 --- a/test/features/world_test.go +++ b/test/features/world_test.go @@ -87,6 +87,9 @@ type world struct { status int relay *smtpmock.Server invitedValue string + entryIDs map[string]string + answeredIDs []string + entryField string } // newWorld boots an isolated database and a real server for one scenario. From 765faceb19e947282de1e38950080c89936cd01a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 12:34:03 +0200 Subject: [PATCH 22/48] feat(fields): seed the demo history as entries in one statement --- plugins/fields/seed.go | 23 +++++++++++++++++------ plugins/fields/seed_test.go | 32 +++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/plugins/fields/seed.go b/plugins/fields/seed.go index 403648f..c9e26b7 100644 --- a/plugins/fields/seed.go +++ b/plugins/fields/seed.go @@ -18,22 +18,28 @@ import ( // seedContactEmail is the email the demo contact [Plugin.Seed] writes onto holds, for development only. const seedContactEmail = "maria.perez@example.com" +// demoHistoryName is the repeater [Plugin.Seed] adds the demo history to, for development only. +const demoHistoryName = "history" + // demoFields are the definitions [Plugin.Seed] stores, for development only. var demoFields = []Definition{ {Name: "birthDate", Label: "Birth date", Kind: kindDate}, - {Name: "history", Label: "History", Kind: kindRepeater, SubFields: []SubField{ + {Name: demoHistoryName, Label: "History", Kind: kindRepeater, SubFields: []SubField{ {Name: "date", Label: "Date", Kind: kindDate}, {Name: "comment", Label: "Comment", Kind: kindLongText}, }}, } -// demoValues are the values [Plugin.Seed] writes onto the demo contact, for development only. +// demoValues are the plain values [Plugin.Seed] writes onto the demo contact, for development only. var demoValues = map[string]any{ "birthDate": "1990-04-17", - "history": []map[string]any{ - {"date": "2026-09-01", "comment": "First call about the yearly plan."}, - {"date": "2026-09-10", "comment": "Sent the offer and booked a follow-up call."}, - }, +} + +// demoHistory is the history [Plugin.Seed] adds to the demo contact, oldest first, for development only. +var demoHistory = []map[string]any{ + {"date": "2026-09-01", "comment": "First call about the yearly plan."}, + {"date": "2026-09-10", "comment": "Sent the offer and booked a follow-up call."}, + {"date": "2026-09-18", "comment": "Follow-up call.\nAsked for a second quote."}, } // Seed stores the demo fields and their values on the demo contact. @@ -87,5 +93,10 @@ func (p *Plugin) seedValues(ctx context.Context) error { missing[name] = value } } + if _, kept := held[contactID][demoHistoryName]; !kept { + if _, err := p.store.addEntry(ctx, contactID, demoHistoryName, demoHistory, p.entriesMax); err != nil { + return fmt.Errorf("fields: seed history: %w", err) + } + } return p.store.writeValues(ctx, contactID, missing) } diff --git a/plugins/fields/seed_test.go b/plugins/fields/seed_test.go index ba34beb..d53bbda 100644 --- a/plugins/fields/seed_test.go +++ b/plugins/fields/seed_test.go @@ -178,12 +178,38 @@ func TestSeedWritesTheValuesOntoTheDemoContact(t *testing.T) { if held[maria]["birthDate"] != "1990-04-17" { t.Errorf("birthDate = %#v, want 1990-04-17", held[maria]["birthDate"]) } + ids, cells := splitIDs(t, held[maria]["history"]) rows := []any{ - map[string]any{"date": "2026-09-01", "comment": "First call about the yearly plan."}, + map[string]any{"date": "2026-09-18", "comment": "Follow-up call.\nAsked for a second quote."}, map[string]any{"date": "2026-09-10", "comment": "Sent the offer and booked a follow-up call."}, + map[string]any{"date": "2026-09-01", "comment": "First call about the yearly plan."}, + } + if !reflect.DeepEqual(cells, rows) { + t.Errorf("history = %#v, want the three demo entries newest first", cells) + } + if len(slices.Compact(slices.Sorted(slices.Values(ids)))) != 3 { + t.Errorf("ids = %q, want each demo entry under its own id", ids) + } +} + +func TestSeedAddsTheDemoHistoryAllOrNothing(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + maria := seedDemoContact(t, p) + p.entriesMax = 2 + + err := p.Seed(t.Context()) + + if !errors.Is(err, errEntriesFull) { + t.Fatalf("Seed() error = %v, want the history refused whole under a cap of two", err) + } + held, err := p.store.valuesFor(t.Context(), []uuid.UUID{maria}) + if err != nil { + t.Fatalf("valuesFor() error = %v, want nil", err) } - if !reflect.DeepEqual(held[maria]["history"], rows) { - t.Errorf("history = %#v, want the two demo entries in order", held[maria]["history"]) + if _, kept := held[maria]["history"]; kept { + t.Errorf("history = %#v, want no part of the demo history stored", held[maria]["history"]) } } From 16a359020912a5ff8da2968565bf270e78b0725c Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 12:34:04 +0200 Subject: [PATCH 23/48] test(alphone): expect the three demo history entries from the seed binary --- cmd/alphone/main_exec_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/alphone/main_exec_test.go b/cmd/alphone/main_exec_test.go index 00d08b0..e2e4f19 100644 --- a/cmd/alphone/main_exec_test.go +++ b/cmd/alphone/main_exec_test.go @@ -256,8 +256,8 @@ func TestMainBinarySeedFillsTheDemoHistoryOnTheFirstRun(t *testing.T) { if maria["birthDate"] != "1990-04-17" { t.Errorf("birthDate = %#v, want 1990-04-17", maria["birthDate"]) } - if rows, ok := maria["history"].([]any); !ok || len(rows) != 2 { - t.Errorf("history = %#v, want the two demo entries from a single seed run", maria["history"]) + if rows, ok := maria["history"].([]any); !ok || len(rows) != 3 { + t.Errorf("history = %#v, want the three demo entries from a single seed run", maria["history"]) } } From beecdae57370c86c0464aeacb4525a42e9e77e5f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 13:37:16 +0200 Subject: [PATCH 24/48] test(fields): share the contact panel test harness --- plugins/fields/frontend/test/harness.tsx | 94 +++++++++++++++++++++ plugins/fields/frontend/test/panel.test.tsx | 75 +++------------- 2 files changed, 105 insertions(+), 64 deletions(-) create mode 100644 plugins/fields/frontend/test/harness.tsx diff --git a/plugins/fields/frontend/test/harness.tsx b/plugins/fields/frontend/test/harness.tsx new file mode 100644 index 0000000..3af0d94 --- /dev/null +++ b/plugins/fields/frontend/test/harness.tsx @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { GraphProvider } from '@alphone/frontend-sdk' +import { HttpResponse, fakeGraphClient, graphql, server } from '@alphone/frontend-sdk/testing' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render } from '@testing-library/react' +import { vi } from 'vitest' + +import { ContactFieldsPanel } from '../ContactFieldsPanel' + +/** contactID is the contact every panel test renders. */ +export const contactID = '0198c000-0000-7000-8000-000000000401' + +/** jobTitle is a plain text field the panel tests define. */ +export const jobTitle = { + __typename: 'FieldDefinition', + id: '0198c000-0000-7000-8000-000000000504', + name: 'jobTitle', + label: 'Job title', + kind: 'TEXT', + subFields: [], +} + +/** history is a repeater of a date and a comment the panel tests define. */ +export const history = { + __typename: 'FieldDefinition', + id: '0198c000-0000-7000-8000-000000000506', + name: 'history', + label: 'History', + kind: 'REPEATER', + subFields: [ + { __typename: 'FieldSubField', name: 'date', label: 'Date', kind: 'DATE' }, + { __typename: 'FieldSubField', name: 'comment', label: 'Comment', kind: 'LONGTEXT' }, + ], +} + +/** firstCall is the older stored history entry. */ +export const firstCall = { date: '2026-09-01', comment: 'First call about the yearly plan.' } + +/** offerSent is the newer stored history entry. */ +export const offerSent = { date: '2026-09-10', comment: 'Sent the offer and booked a follow-up call.' } + +/** + * Renders the fields panel of the test contact inside its graph and query providers. + * @returns The render result. + */ +export function renderPanel() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { graph } = fakeGraphClient() + return render( + + + + + , + ) +} + +/** + * Answers the catalogue query with the given fields. + * @param fields - The definitions the catalogue holds. + */ +export function serveCatalogue(fields: unknown[]) { + server.use(graphql.query('Fields', () => HttpResponse.json({ data: { fields } }))) +} + +/** + * Answers the values query of the test contact with the given values. + * @param values - The stored values, keyed by field name. + */ +export function serveValues(values: Record) { + server.use( + graphql.query('ContactFieldValues', () => + HttpResponse.json({ + data: { contact: { __typename: 'Contact', id: contactID, ...values } }, + }), + ), + ) +} + +/** + * Accepts every field write, recording the variables each one carries. + * @returns The mock the variables are recorded on. + */ +export function captureWrite() { + const written = vi.fn() + server.use( + graphql.mutation('WriteContactFields', async ({ variables }) => { + written(variables) + return HttpResponse.json({ data: { writeContactFields: true } }) + }), + ) + return written +} diff --git a/plugins/fields/frontend/test/panel.test.tsx b/plugins/fields/frontend/test/panel.test.tsx index e946ea1..280a7f9 100644 --- a/plugins/fields/frontend/test/panel.test.tsx +++ b/plugins/fields/frontend/test/panel.test.tsx @@ -13,8 +13,17 @@ import userEvent from '@testing-library/user-event' import { expect, test, vi } from 'vitest' import { ContactFieldsPanel } from '../ContactFieldsPanel' - -const contactID = '0198c000-0000-7000-8000-000000000401' +import { + captureWrite, + contactID, + firstCall, + history, + jobTitle, + offerSent, + renderPanel, + serveCatalogue, + serveValues, +} from './harness' const birthDate = { __typename: 'FieldDefinition', @@ -25,32 +34,6 @@ const birthDate = { subFields: [], } -function renderPanel() { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const { graph } = fakeGraphClient() - return render( - - - - - , - ) -} - -function serveCatalogue(fields: unknown[]) { - server.use(graphql.query('Fields', () => HttpResponse.json({ data: { fields } }))) -} - -function serveValues(values: Record) { - server.use( - graphql.query('ContactFieldValues', () => - HttpResponse.json({ - data: { contact: { __typename: 'Contact', id: contactID, ...values } }, - }), - ), - ) -} - test('a defined field renders with its stored value', async () => { serveCatalogue([birthDate]) serveValues({ birthDate: '1990-04-17' }) @@ -142,26 +125,6 @@ const subscribed = { subFields: [], } -function captureWrite() { - const written = vi.fn() - server.use( - graphql.mutation('WriteContactFields', async ({ variables }) => { - written(variables) - return HttpResponse.json({ data: { writeContactFields: true } }) - }), - ) - return written -} - -const jobTitle = { - __typename: 'FieldDefinition', - id: '0198c000-0000-7000-8000-000000000504', - name: 'jobTitle', - label: 'Job title', - kind: 'TEXT', - subFields: [], -} - test('a text field sends its text unchanged', async () => { serveCatalogue([jobTitle]) serveValues({ jobTitle: null }) @@ -323,22 +286,6 @@ test('clearing a field sends null', async () => { ) }) -const history = { - __typename: 'FieldDefinition', - id: '0198c000-0000-7000-8000-000000000506', - name: 'history', - label: 'History', - kind: 'REPEATER', - subFields: [ - { __typename: 'FieldSubField', name: 'date', label: 'Date', kind: 'DATE' }, - { __typename: 'FieldSubField', name: 'comment', label: 'Comment', kind: 'LONGTEXT' }, - ], -} - -const firstCall = { date: '2026-09-01', comment: 'First call about the yearly plan.' } - -const offerSent = { date: '2026-09-10', comment: 'Sent the offer and booked a follow-up call.' } - async function entry(number: number) { return within(await screen.findByRole('group', { name: `History ${number}` })) } From 394617eac4f5eb8bbe7e3a02450bb22fba01cf5e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 13:37:16 +0200 Subject: [PATCH 25/48] refactor(fields): name the catalogue and values operations beside their documents --- plugins/fields/frontend/ContactFieldsPanel.tsx | 4 +--- plugins/fields/frontend/FieldsScreen.tsx | 4 +--- plugins/fields/frontend/document.ts | 5 ++++- plugins/fields/frontend/operations.ts | 3 +++ plugins/fields/frontend/test/document.test.ts | 6 +++++- plugins/fields/frontend/test/operations.test.ts | 11 +++++++++++ 6 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 plugins/fields/frontend/test/operations.test.ts diff --git a/plugins/fields/frontend/ContactFieldsPanel.tsx b/plugins/fields/frontend/ContactFieldsPanel.tsx index 4944fae..051442a 100644 --- a/plugins/fields/frontend/ContactFieldsPanel.tsx +++ b/plugins/fields/frontend/ContactFieldsPanel.tsx @@ -20,11 +20,9 @@ import { useId, useMemo, useState } from 'react' import { entryText, textOf, typedValue } from './cellText' import type { EntryText, SubFieldRow } from './cellText' import { FieldInput } from './cells' -import { contactValuesDocument } from './document' +import { contactValuesDocument, valuesOperation } from './document' import { fieldsQuery, writeContactFieldsMutation } from './operations' -const valuesOperation = 'ContactFieldValues' - /** FieldRow is one catalogue entry the panel renders an input for. */ interface FieldRow { id: string diff --git a/plugins/fields/frontend/FieldsScreen.tsx b/plugins/fields/frontend/FieldsScreen.tsx index e5b2d79..49aabce 100644 --- a/plugins/fields/frontend/FieldsScreen.tsx +++ b/plugins/fields/frontend/FieldsScreen.tsx @@ -27,9 +27,7 @@ import { useState } from 'react' import { fieldsIcon } from './icon' import type { FieldKind } from './gql/graphql' import { ENTRY_ID_KEY, kindItems, kindOf, subKindItems } from './kind' -import { archiveFieldMutation, defineFieldMutation, fieldsQuery } from './operations' - -const catalogueOperation = 'Fields' +import { archiveFieldMutation, catalogueOperation, defineFieldMutation, fieldsQuery } from './operations' /** FieldRow is one catalogue entry as the screen renders it. */ interface FieldRow { diff --git a/plugins/fields/frontend/document.ts b/plugins/fields/frontend/document.ts index 20e5d4c..8069fc3 100644 --- a/plugins/fields/frontend/document.ts +++ b/plugins/fields/frontend/document.ts @@ -2,6 +2,9 @@ const selectable = /^[a-z][a-zA-Z0-9]*$/ +/** valuesOperation names the query reading one contact's field values. */ +export const valuesOperation = 'ContactFieldValues' + /** * Returns the query reading the given fields of one contact. * @param names - The field names the catalogue holds. @@ -9,7 +12,7 @@ const selectable = /^[a-z][a-zA-Z0-9]*$/ */ export function contactValuesDocument(names: string[]) { const selections = names.filter((name) => selectable.test(name)).join('\n\t\t\t') - return `query ContactFieldValues($id: UUID!) { + return `query ${valuesOperation}($id: UUID!) { contact(id: $id) { id ${selections} diff --git a/plugins/fields/frontend/operations.ts b/plugins/fields/frontend/operations.ts index 0a8e620..fb3fa92 100644 --- a/plugins/fields/frontend/operations.ts +++ b/plugins/fields/frontend/operations.ts @@ -2,6 +2,9 @@ import { graphql } from './gql' +/** catalogueOperation names the query reading the field catalogue. */ +export const catalogueOperation = 'Fields' + export const fieldsQuery = graphql(` query Fields { fields { diff --git a/plugins/fields/frontend/test/document.test.ts b/plugins/fields/frontend/test/document.test.ts index 946a12b..4d2c0d1 100644 --- a/plugins/fields/frontend/test/document.test.ts +++ b/plugins/fields/frontend/test/document.test.ts @@ -2,7 +2,11 @@ import { expect, test } from 'vitest' -import { contactValuesDocument } from '../document' +import { contactValuesDocument, valuesOperation } from '../document' + +test('names the values query it builds', () => { + expect(contactValuesDocument(['birthDate'])).toContain(`query ${valuesOperation}(`) +}) test('a document selects every defined field by its own name', () => { const document = contactValuesDocument(['birthDate', 'loyaltyPoints']) diff --git a/plugins/fields/frontend/test/operations.test.ts b/plugins/fields/frontend/test/operations.test.ts new file mode 100644 index 0000000..b3b7c7a --- /dev/null +++ b/plugins/fields/frontend/test/operations.test.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { expect, test } from 'vitest' + +import { catalogueOperation, fieldsQuery } from '../operations' + +test('names the catalogue query by the operation it declares', () => { + const [query] = fieldsQuery.definitions + + expect(query.kind === 'OperationDefinition' ? query.name?.value : undefined).toBe(catalogueOperation) +}) From 1ed6bd646a89715198dcc62748fda5f742a0482b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 14:13:11 +0200 Subject: [PATCH 26/48] feat(fields): add the operations that add and remove a repeater entry --- plugins/fields/frontend/gql/gql.ts | 12 ++++++++++++ plugins/fields/frontend/gql/graphql.ts | 22 +++++++++++++++++++++- plugins/fields/frontend/operations.ts | 12 ++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/plugins/fields/frontend/gql/gql.ts b/plugins/fields/frontend/gql/gql.ts index bdcadf1..793711e 100644 --- a/plugins/fields/frontend/gql/gql.ts +++ b/plugins/fields/frontend/gql/gql.ts @@ -18,12 +18,16 @@ type Documents = { "\n\tmutation DefineField(\n\t\t$name: String!\n\t\t$label: String!\n\t\t$kind: FieldKind!\n\t\t$subFields: [FieldSubFieldInput!]\n\t) {\n\t\tdefineField(name: $name, label: $label, kind: $kind, subFields: $subFields) {\n\t\t\tid\n\t\t\tname\n\t\t\tlabel\n\t\t\tkind\n\t\t\tsubFields {\n\t\t\t\tname\n\t\t\t\tlabel\n\t\t\t\tkind\n\t\t\t}\n\t\t}\n\t}\n": typeof types.DefineFieldDocument, "\n\tmutation ArchiveField($id: UUID!) {\n\t\tarchiveField(id: $id)\n\t}\n": typeof types.ArchiveFieldDocument, "\n\tmutation WriteContactFields($contactId: UUID!, $values: JSON!) {\n\t\twriteContactFields(contactId: $contactId, values: $values)\n\t}\n": typeof types.WriteContactFieldsDocument, + "\n\tmutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) {\n\t\taddContactFieldEntry(contactId: $contactId, field: $field, entry: $entry)\n\t}\n": typeof types.AddContactFieldEntryDocument, + "\n\tmutation DeleteContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!) {\n\t\tdeleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId)\n\t}\n": typeof types.DeleteContactFieldEntryDocument, }; const documents: Documents = { "\n\tquery Fields {\n\t\tfields {\n\t\t\tid\n\t\t\tname\n\t\t\tlabel\n\t\t\tkind\n\t\t\tsubFields {\n\t\t\t\tname\n\t\t\t\tlabel\n\t\t\t\tkind\n\t\t\t}\n\t\t}\n\t}\n": types.FieldsDocument, "\n\tmutation DefineField(\n\t\t$name: String!\n\t\t$label: String!\n\t\t$kind: FieldKind!\n\t\t$subFields: [FieldSubFieldInput!]\n\t) {\n\t\tdefineField(name: $name, label: $label, kind: $kind, subFields: $subFields) {\n\t\t\tid\n\t\t\tname\n\t\t\tlabel\n\t\t\tkind\n\t\t\tsubFields {\n\t\t\t\tname\n\t\t\t\tlabel\n\t\t\t\tkind\n\t\t\t}\n\t\t}\n\t}\n": types.DefineFieldDocument, "\n\tmutation ArchiveField($id: UUID!) {\n\t\tarchiveField(id: $id)\n\t}\n": types.ArchiveFieldDocument, "\n\tmutation WriteContactFields($contactId: UUID!, $values: JSON!) {\n\t\twriteContactFields(contactId: $contactId, values: $values)\n\t}\n": types.WriteContactFieldsDocument, + "\n\tmutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) {\n\t\taddContactFieldEntry(contactId: $contactId, field: $field, entry: $entry)\n\t}\n": types.AddContactFieldEntryDocument, + "\n\tmutation DeleteContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!) {\n\t\tdeleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId)\n\t}\n": types.DeleteContactFieldEntryDocument, }; /** @@ -56,6 +60,14 @@ export function graphql(source: "\n\tmutation ArchiveField($id: UUID!) {\n\t\tar * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n\tmutation WriteContactFields($contactId: UUID!, $values: JSON!) {\n\t\twriteContactFields(contactId: $contactId, values: $values)\n\t}\n"): (typeof documents)["\n\tmutation WriteContactFields($contactId: UUID!, $values: JSON!) {\n\t\twriteContactFields(contactId: $contactId, values: $values)\n\t}\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n\tmutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) {\n\t\taddContactFieldEntry(contactId: $contactId, field: $field, entry: $entry)\n\t}\n"): (typeof documents)["\n\tmutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) {\n\t\taddContactFieldEntry(contactId: $contactId, field: $field, entry: $entry)\n\t}\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n\tmutation DeleteContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!) {\n\t\tdeleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId)\n\t}\n"): (typeof documents)["\n\tmutation DeleteContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!) {\n\t\tdeleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId)\n\t}\n"]; export function graphql(source: string) { return (documents as any)[source] ?? {}; diff --git a/plugins/fields/frontend/gql/graphql.ts b/plugins/fields/frontend/gql/graphql.ts index 1779978..357c54f 100644 --- a/plugins/fields/frontend/gql/graphql.ts +++ b/plugins/fields/frontend/gql/graphql.ts @@ -49,8 +49,28 @@ export type WriteContactFieldsMutationVariables = Exact<{ export type WriteContactFieldsMutation = { writeContactFields: boolean }; +export type AddContactFieldEntryMutationVariables = Exact<{ + contactId: string; + field: string; + entry: unknown; +}>; + + +export type AddContactFieldEntryMutation = { addContactFieldEntry: unknown }; + +export type DeleteContactFieldEntryMutationVariables = Exact<{ + contactId: string; + field: string; + entryId: string; +}>; + + +export type DeleteContactFieldEntryMutation = { deleteContactFieldEntry: boolean }; + export const FieldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Fields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"subFields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]} as unknown as DocumentNode; export const DefineFieldDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DefineField"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"label"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"kind"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FieldKind"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"subFields"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FieldSubFieldInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"defineField"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"label"},"value":{"kind":"Variable","name":{"kind":"Name","value":"label"}}},{"kind":"Argument","name":{"kind":"Name","value":"kind"},"value":{"kind":"Variable","name":{"kind":"Name","value":"kind"}}},{"kind":"Argument","name":{"kind":"Name","value":"subFields"},"value":{"kind":"Variable","name":{"kind":"Name","value":"subFields"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"subFields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]} as unknown as DocumentNode; export const ArchiveFieldDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveField"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveField"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; -export const WriteContactFieldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"WriteContactFields"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"values"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"writeContactFields"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"values"},"value":{"kind":"Variable","name":{"kind":"Name","value":"values"}}}]}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const WriteContactFieldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"WriteContactFields"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"values"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"writeContactFields"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"values"},"value":{"kind":"Variable","name":{"kind":"Name","value":"values"}}}]}]}}]} as unknown as DocumentNode; +export const AddContactFieldEntryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddContactFieldEntry"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"field"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entry"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addContactFieldEntry"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"field"},"value":{"kind":"Variable","name":{"kind":"Name","value":"field"}}},{"kind":"Argument","name":{"kind":"Name","value":"entry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entry"}}}]}]}}]} as unknown as DocumentNode; +export const DeleteContactFieldEntryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteContactFieldEntry"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"field"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entryId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteContactFieldEntry"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"field"},"value":{"kind":"Variable","name":{"kind":"Name","value":"field"}}},{"kind":"Argument","name":{"kind":"Name","value":"entryId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entryId"}}}]}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/plugins/fields/frontend/operations.ts b/plugins/fields/frontend/operations.ts index fb3fa92..ebe2bd8 100644 --- a/plugins/fields/frontend/operations.ts +++ b/plugins/fields/frontend/operations.ts @@ -53,3 +53,15 @@ export const writeContactFieldsMutation = graphql(` writeContactFields(contactId: $contactId, values: $values) } `) + +export const addContactFieldEntryMutation = graphql(` + mutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) { + addContactFieldEntry(contactId: $contactId, field: $field, entry: $entry) + } +`) + +export const deleteContactFieldEntryMutation = graphql(` + mutation DeleteContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!) { + deleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId) + } +`) From 095bd1f4d8165b7b06666c61b908e13d2d3b01ab Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 14:13:10 +0200 Subject: [PATCH 27/48] feat(fields): show repeater entries as a list with one add form --- .../fields/frontend/ContactFieldsPanel.tsx | 210 ++--- plugins/fields/frontend/EntryRow.tsx | 114 +++ plugins/fields/frontend/RepeaterEntries.tsx | 151 ++++ plugins/fields/frontend/cells.tsx | 26 + plugins/fields/frontend/entries.ts | 176 ++++ plugins/fields/frontend/entryOutcome.ts | 71 ++ plugins/fields/frontend/focus.ts | 24 + plugins/fields/frontend/languages/es-ES.json | 2 +- plugins/fields/frontend/test/entries.test.tsx | 827 ++++++++++++++++++ plugins/fields/frontend/test/harness.tsx | 141 ++- plugins/fields/frontend/test/panel.test.tsx | 291 +----- plugins/fields/frontend/useEntryActions.ts | 132 +++ plugins/fields/languages/alphone-fields.pot | 37 +- plugins/fields/languages/es-ES.po | 50 +- 14 files changed, 1794 insertions(+), 458 deletions(-) create mode 100644 plugins/fields/frontend/EntryRow.tsx create mode 100644 plugins/fields/frontend/RepeaterEntries.tsx create mode 100644 plugins/fields/frontend/entries.ts create mode 100644 plugins/fields/frontend/entryOutcome.ts create mode 100644 plugins/fields/frontend/focus.ts create mode 100644 plugins/fields/frontend/test/entries.test.tsx create mode 100644 plugins/fields/frontend/useEntryActions.ts diff --git a/plugins/fields/frontend/ContactFieldsPanel.tsx b/plugins/fields/frontend/ContactFieldsPanel.tsx index 051442a..4c376ac 100644 --- a/plugins/fields/frontend/ContactFieldsPanel.tsx +++ b/plugins/fields/frontend/ContactFieldsPanel.tsx @@ -5,23 +5,24 @@ import { Button, ErrorNotice, LoadingRows, - RepeatRows, Stack, Text, graphError, - sprintf, useGraph, useGraphMutation, useGraphQuery, validationMessage, } from '@alphone/frontend-sdk' -import { useId, useMemo, useState } from 'react' +import { useId, useState } from 'react' -import { entryText, textOf, typedValue } from './cellText' -import type { EntryText, SubFieldRow } from './cellText' +import { textOf, typedValue } from './cellText' +import type { SubFieldRow } from './cellText' import { FieldInput } from './cells' import { contactValuesDocument, valuesOperation } from './document' +import { whenFocusStayed } from './focus' import { fieldsQuery, writeContactFieldsMutation } from './operations' +import { RepeaterEntries } from './RepeaterEntries' +import type { GoneHandler } from './useEntryActions' /** FieldRow is one catalogue entry the panel renders an input for. */ interface FieldRow { @@ -48,36 +49,77 @@ export function ContactFieldsPanel({ contactId }: { contactId: string }) { } /** - * Renders the value editor of one contact once its stored values load. + * Renders the fields of one contact once its stored values load, under a heading that takes focus when a repeater goes. * @param props - The contact and the fields it holds values for. - * @returns The value editor, a loading placeholder or the failed read. + * @returns The fields, a loading placeholder or the failed read. */ function FieldValues({ contactId, fields }: { contactId: string; fields: FieldRow[] }) { const [values] = useGraphQuery({ query: contactValuesDocument(fields.map((field) => field.name)), variables: { id: contactId }, }) + const [gone, setGone] = useState('') + const heading = useId() + const onGone: GoneHandler = (message, from) => { + setGone(message) + whenFocusStayed(from, () => (document.getElementById(heading) as HTMLElement).focus()) + } let body = if (values.error) { body = {__('The fields could not be loaded.', 'alphone-fields')} } else if (values.data) { const stored = (values.data.contact ?? {}) as Record - body = + body = } return ( - }> + }> {__('Fields', 'alphone-fields')} + {gone !== '' && {gone}} {body} ) } /** - * Renders the form editing the stored values of one contact. - * @param props - The contact, the fields it holds values for and the values the graph answered. + * Renders the form for the plain fields, then one section per repeater. + * @param props - The contact, its fields, the values the graph answered and the report of a repeater gone. + * @returns The plain fields form and the repeater sections. + */ +function StoredFields({ + contactId, + fields, + stored, + onGone, +}: { + contactId: string + fields: FieldRow[] + stored: Record + onGone: GoneHandler +}) { + const others = fields.filter((field) => field.kind !== 'REPEATER') + const repeaters = fields.filter((field) => field.kind === 'REPEATER') + return ( + <> + {others.length > 0 && } + {repeaters.map((field) => ( + + ))} + + ) +} + +/** + * Renders the form editing the stored values of one contact's plain fields. + * @param props - The contact, the plain fields it holds values for and the values the graph answered. * @returns The value form. */ function FieldsForm({ @@ -90,7 +132,6 @@ function FieldsForm({ stored: Record }) { const [edited, setEdited] = useState>({}) - const [entries, setEntries] = useState>({}) const [written, write] = useGraphMutation(writeContactFieldsMutation) const graph = useGraph() @@ -99,10 +140,9 @@ function FieldsForm({ className="godmin-form" onSubmit={(event) => { event.preventDefault() - void write({ contactId, values: writable(fields, edited, entries) }).then((result) => { + void write({ contactId, values: writable(fields, edited) }).then((result) => { if (!result.error) { setEdited({}) - setEntries({}) graph.refetch([valuesOperation]) } }) @@ -113,24 +153,14 @@ function FieldsForm({ {validationMessage(graphError(written.error), __('The fields could not be saved.', 'alphone-fields'))} ) : null} - {fields.map((field) => - field.kind === 'REPEATER' ? ( - setEntries((held) => ({ ...held, [field.name]: next }))} - /> - ) : ( - setEdited({ ...edited, [field.name]: next })} - /> - ), - )} + {fields.map((field) => ( + setEdited({ ...edited, [field.name]: next })} + /> + ))} @@ -138,123 +168,19 @@ function FieldsForm({ ) } -/** - * Renders the entries of one repeater, each a group of its sub field inputs. - * @param props - The repeater, its stored entries, the edited ones and the change handler. - * @returns The entries editor. - */ -function EntriesInput({ - field, - stored, - entries, - onChange, -}: { - field: FieldRow - stored: unknown - entries: EntryText[] | undefined - onChange: (next: EntryText[]) => void -}) { - const given = useMemo(() => entriesOf(field.subFields, stored), [field.subFields, stored]) - const heading = useId() - - return ( - - } id={heading}> - {field.label} - - entryText(field.subFields, {})} - renderRow={(entry, update) => - field.subFields.map((column) => ( - update({ ...entry, [column.name]: next })} - /> - )) - } - rowLabel={(at) => - sprintf(__('%(label)s %(number)d', 'alphone-fields'), { label: field.label, number: at + 1 }) - } - labels={{ - add: sprintf(__('Add an entry to %(label)s', 'alphone-fields'), { label: field.label }), - empty: __('No entries yet.', 'alphone-fields'), - moveUp: __('Move entry up', 'alphone-fields'), - moveDown: __('Move entry down', 'alphone-fields'), - remove: __('Remove entry', 'alphone-fields'), - }} - /> - - ) -} - -/** - * Returns the text of every stored entry of one repeater. - * @param subFields - The sub fields every entry holds. - * @param stored - The entries the graph answered, absent when none are stored. - * @returns The entries as text, in stored order. - */ -function entriesOf(subFields: SubFieldRow[], stored: unknown) { - const held = (stored ?? []) as Record[] - return held.map((entry) => entryText(subFields, entry)) -} - /** * Returns the edited values in the form the write mutation takes. - * @param fields - The catalogue the panel renders. + * @param fields - The plain fields the form renders. * @param edited - The text the operator typed, by field name. - * @param entries - The entries the operator changed, by repeater name. - * @returns The values keyed by field name. + * @returns The values keyed by field name, the untouched fields left out. */ -function writable( - fields: FieldRow[], - edited: Record, - entries: Record, -) { +function writable(fields: FieldRow[], edited: Record) { const values: Record = {} for (const field of fields) { - const value = writableValue(field, edited, entries) - if (value !== undefined) { - values[field.name] = value + const text = edited[field.name] + if (text !== undefined) { + values[field.name] = typedValue(field.kind, text) } } return values } - -/** - * Returns the value one field sends. - * @param field - The catalogue entry. - * @param edited - The text the operator typed, by field name. - * @param entries - The entries the operator changed, by repeater name. - * @returns The typed value, or undefined when the operator left the field untouched. - */ -function writableValue( - field: FieldRow, - edited: Record, - entries: Record, -) { - if (field.kind === 'REPEATER') { - const changed = entries[field.name] - return changed === undefined ? undefined : typedEntries(field.subFields, changed) - } - const text = edited[field.name] - return text === undefined ? undefined : typedValue(field.kind, text) -} - -/** - * Returns the entries of one repeater in the form the write mutation takes. - * @param subFields - The sub fields every entry holds. - * @param changed - The entries as text, in order. - * @returns The typed entries, or null when none are left. - */ -function typedEntries(subFields: SubFieldRow[], changed: EntryText[]) { - if (changed.length === 0) { - return null - } - return changed.map((entry) => - Object.fromEntries(subFields.map((column) => [column.name, typedValue(column.kind, entry[column.name])])), - ) -} diff --git a/plugins/fields/frontend/EntryRow.tsx b/plugins/fields/frontend/EntryRow.tsx new file mode 100644 index 0000000..9e31e32 --- /dev/null +++ b/plugins/fields/frontend/EntryRow.tsx @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Button, LogItem, LogTime, Stack, Text, __, sprintf } from '@alphone/frontend-sdk' +import { useEffect, useId, useRef } from 'react' +import type { RefObject } from 'react' + +import type { SubFieldRow } from './cellText' +import { entryName, entryParts } from './entries' +import type { StoredEntry } from './entries' +import type { EntryHandlers, RowState } from './useEntryActions' + +/** + * Renders one entry as a log item: its day, its text and a line per other cell, with its actions. + * @param props - The entry, the sub fields it holds, how the row shows and the actions its buttons run. + * @returns The log item. + */ +export function EntryRow({ + entry, + subFields, + row, + on, +}: { + entry: StoredEntry + subFields: SubFieldRow[] + row: RowState + on: EntryHandlers +}) { + const parts = entryParts(subFields, entry.cells) + const name = entryName(parts) + const removeRef = useRef(null) + const keepRef = useRef(null) + + useEffect(() => { + if (row.focus) { + const target = row.focus.on === 'keep' ? keepRef : removeRef + ;(target.current as HTMLButtonElement).focus() + } + }, [row.focus]) + + return ( + {parts.day.text} : undefined} + actions={ + row.mode === 'confirm' ? ( + void on.confirm(entry.id)} + onKeep={() => on.keep(entry.id)} + /> + ) : ( + + ) + } + body={parts.body} + > + {parts.lines.map((line) => ( + }> + {line.text} + + ))} + + ) +} + +/** + * Renders the question that confirms a removal, with its Remove and Keep buttons. + * @param props - How the row shows, the Keep button's ref and the two actions. + * @returns The confirm group. + */ +function ConfirmActions({ + row, + keepRef, + onConfirm, + onKeep, +}: { + row: RowState + keepRef: RefObject + onConfirm: () => void + onKeep: () => void +}) { + const question = useId() + return ( + + + {__('Remove this entry?', 'alphone-fields')} + + + + + ) +} diff --git a/plugins/fields/frontend/RepeaterEntries.tsx b/plugins/fields/frontend/RepeaterEntries.tsx new file mode 100644 index 0000000..2fc758b --- /dev/null +++ b/plugins/fields/frontend/RepeaterEntries.tsx @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + Button, + ErrorNotice, + LogList, + Stack, + Text, + __, + sprintf, + useGraph, + useGraphMutation, +} from '@alphone/frontend-sdk' +import type { GraphFailure } from '@alphone/frontend-sdk' +import { useEffect, useId, useMemo, useRef, useState } from 'react' + +import { EntryCells } from './cells' +import { draftCells, entriesOf, holdsInput, localDay, typedEntry } from './entries' +import type { RepeaterRow } from './entries' +import { entryMessage, outcomeOf, refetchAfter } from './entryOutcome' +import { EntryRow } from './EntryRow' +import { focusFirstControl, whenFocusStayed } from './focus' +import { addContactFieldEntryMutation } from './operations' +import { useEntryActions } from './useEntryActions' +import type { GoneHandler } from './useEntryActions' + +/** + * Renders one repeater of a contact: its heading, one add form and its entries, newest first. + * @param props - The contact, the repeater, its stored value and the report of a repeater gone. + * @returns The repeater section. + */ +export function RepeaterEntries({ + contactId, + field, + stored, + onGone, +}: { + contactId: string + field: RepeaterRow + stored: unknown + onGone: GoneHandler +}) { + const heading = useId() + const entries = useMemo(() => entriesOf(stored), [stored]) + const actions = useEntryActions({ contactId, field, entries, onGone }) + + return ( + + } id={heading}> + {field.label} + + + {actions.failure !== '' && {actions.failure}} + {entries.length === 0 ? ( + {__('No entries yet.', 'alphone-fields')} + ) : ( + + {entries.map((entry) => ( + + ))} + + )} + + ) +} + +/** + * Renders the form adding one entry, its dates starting on today. + * @param props - The contact, the repeater, the count of focus requests, and what runs on an add and a repeater gone. + * @returns The add form. + */ +function AddEntryForm({ + contactId, + field, + focusCount, + onAdded, + onGone, +}: { + contactId: string + field: RepeaterRow + focusCount: number + onAdded: () => void + onGone: GoneHandler +}) { + const [picked, setPicked] = useState>(() => new Map()) + const [add, runAdd] = useGraphMutation(addContactFieldEntryMutation) + const graph = useGraph() + const formRef = useRef(null) + const shown = draftCells(field.subFields, picked, localDay(new Date())) + const label = sprintf(__('Add an entry to %(label)s', 'alphone-fields'), { label: field.label }) + const fallback = __('The entry could not be added.', 'alphone-fields') + + useEffect(() => { + if (focusCount > 0) { + focusFirstControl(formRef.current as HTMLFormElement) + } + }, [focusCount]) + + const submit = async () => { + const from = document.activeElement + const result = await runAdd({ contactId, field: field.name, entry: typedEntry(field.subFields, shown) }) + refetchAfter(graph, result.error) + const outcome = outcomeOf(result.error) + if (outcome === 'done') { + setPicked(new Map()) + whenFocusStayed(from, onAdded) + } else if (outcome === 'field-gone') { + onGone(entryMessage(result.error as GraphFailure, fallback), from) + } + } + + return ( +
{ + event.preventDefault() + void submit() + }} + > + setPicked((held) => new Map(held).set(name, text))} + /> + {add.error && outcomeOf(add.error) !== 'field-gone' ? ( + {entryMessage(add.error, fallback)} + ) : null} + + + ) +} diff --git a/plugins/fields/frontend/cells.tsx b/plugins/fields/frontend/cells.tsx index f43f71c..b3380ca 100644 --- a/plugins/fields/frontend/cells.tsx +++ b/plugins/fields/frontend/cells.tsx @@ -2,6 +2,32 @@ import { Checkbox, InputControl, Stack, Text, TextareaControl } from '@alphone/frontend-sdk' +import type { EntryText, SubFieldRow } from './cellText' + +/** + * Renders one input per sub field of an entry. + * @param props - The sub fields, the text of every cell and the change handler. + * @returns The cell inputs. + */ +export function EntryCells({ + subFields, + draft, + onChange, +}: { + subFields: SubFieldRow[] + draft: EntryText + onChange: (name: string, text: string) => void +}) { + return subFields.map((column) => ( + onChange(column.name, next)} + /> + )) +} + /** * Renders one field's input, matched to the kind its definition declares. * @param props - The field, its current text and the change handler. diff --git a/plugins/fields/frontend/entries.ts b/plugins/fields/frontend/entries.ts new file mode 100644 index 0000000..7b3e363 --- /dev/null +++ b/plugins/fields/frontend/entries.ts @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { __, _x, formatDate, sprintf } from '@alphone/frontend-sdk' + +import { entryText, typedValue } from './cellText' +import type { EntryText, SubFieldRow } from './cellText' +import { ENTRY_ID_KEY } from './kind' + +/** RepeaterRow is one repeater of the catalogue with the sub fields its entries hold. */ +export interface RepeaterRow { + id: string + name: string + label: string + subFields: SubFieldRow[] +} + +/** StoredEntry is one entry the graph answered, under its id. */ +export interface StoredEntry { + id: string + cells: Record +} + +/** EntryParts is what a row shows of one entry. */ +interface EntryParts { + day?: { at: string; text: string } + body?: string + lines: { key: string; text: string }[] +} + +/** DAY_STYLE is how a row shows a calendar day. */ +const DAY_STYLE: Intl.DateTimeFormatOptions = { dateStyle: 'medium' } + +/** BODY_KINDS are the kinds whose cells make up a row's text. */ +const BODY_KINDS = new Set(['TEXT', 'LONGTEXT']) + +/** + * Returns the entries a repeater's stored value holds, each under its id. + * @param stored - The value the graph answered, absent when none are stored. + * @returns The entries, in the order answered. + */ +export function entriesOf(stored: unknown): StoredEntry[] { + return ((stored ?? []) as Record[]).map((cells) => ({ id: String(cells[ENTRY_ID_KEY]), cells })) +} + +/** + * Returns the text an add form shows, the picked cells over today's date and blank text. + * @param subFields - The sub fields an entry holds. + * @param picked - The cells the operator changed. + * @param today - The local calendar day date cells start on. + * @returns The text of every cell. + */ +export function draftCells(subFields: SubFieldRow[], picked: ReadonlyMap, today: string): EntryText { + return Object.fromEntries( + subFields.map((column) => [column.name, picked.get(column.name) ?? (column.kind === 'DATE' ? today : '')]), + ) +} + +/** + * Reports whether an add form holds something beyond the dates it starts with. + * @param subFields - The sub fields an entry holds. + * @param picked - The cells the operator changed. + * @returns True when a changed cell or any other cell holds text. + */ +export function holdsInput(subFields: SubFieldRow[], picked: ReadonlyMap): boolean { + return subFields.some((column) => (picked.get(column.name) ?? '').trim() !== '') +} + +/** + * Returns an entry in the form the entry mutations take. + * @param subFields - The sub fields an entry holds. + * @param text - The text of every cell. + * @returns The typed cells, a blank cell as null. + */ +export function typedEntry(subFields: SubFieldRow[], text: EntryText): Record { + return Object.fromEntries( + subFields.map((column) => { + const cell = text[column.name] + return [column.name, cell.trim() === '' ? null : typedValue(column.kind, cell)] + }), + ) +} + +/** + * Returns what a row shows of one entry: its first date, its text and a line per other cell. + * @param subFields - The sub fields the entry holds. + * @param cells - The stored cells, keyed by sub field name. + * @returns The parts of the row. + */ +export function entryParts(subFields: SubFieldRow[], cells: Record): EntryParts { + const text = entryText(subFields, cells) + const filled = subFields.filter((column) => text[column.name].trim() !== '') + const firstDate = subFields.find((column) => column.kind === 'DATE') + const header = firstDate && filled.includes(firstDate) ? firstDate : undefined + const said = filled.filter((column) => BODY_KINDS.has(column.kind)).map((column) => text[column.name]) + const rest = filled.filter((column) => column !== header && !BODY_KINDS.has(column.kind)) + return { + day: header ? { at: text[header.name], text: dayText(text[header.name]) } : undefined, + body: said.length > 0 ? said.join('\n\n') : undefined, + lines: rest.map((column) => ({ key: column.name, text: detailLine(column, text[column.name]) })), + } +} + +/** + * Returns the name a row is announced by: its day, its first line of text or its first detail line. + * @param parts - The parts of the row. + * @returns The name. + */ +export function entryName(parts: EntryParts): string { + return ( + parts.day?.text ?? parts.body?.split('\n')[0] ?? parts.lines[0]?.text ?? __('Blank entry', 'alphone-fields') + ) +} + +/** + * Returns the entry focus moves to when one leaves, passing over entries that are leaving too. + * @param entries - The entries shown, in order. + * @param id - The entry leaving. + * @param settling - The entries already leaving. + * @returns The next entry's id, else the previous one's, else an empty string. + */ +export function neighbour(entries: readonly StoredEntry[], id: string, settling: readonly string[]): string { + const open = entries.filter((entry) => entry.id === id || !settling.includes(entry.id)) + const at = open.findIndex((entry) => entry.id === id) + return (open[at + 1] ?? open[at - 1])?.id ?? '' +} + +/** + * Returns a moment's local calendar day written YYYY-MM-DD. + * @param at - The moment. + * @returns The local calendar day. + */ +export function localDay(at: Date): string { + const year = String(at.getFullYear()).padStart(4, '0') + const month = String(at.getMonth() + 1).padStart(2, '0') + const day = String(at.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** + * Returns one cell as a label and value line. + * @param column - The sub field the cell belongs to. + * @param text - The cell's text. + * @returns The line. + */ +function detailLine(column: SubFieldRow, text: string): string { + return sprintf(__('%(label)s: %(value)s', 'alphone-fields'), { + label: column.label, + value: cellValue(column.kind, text), + }) +} + +/** + * Returns a cell's text as a reader sees it. + * @param kind - The kind the sub field declares. + * @param text - The cell's text. + * @returns Yes or No for a boolean, a shown day for a date, the text otherwise. + */ +function cellValue(kind: string, text: string): string { + if (kind === 'BOOLEAN') { + return text === 'true' ? _x('Yes', 'entry cell', 'alphone-fields') : _x('No', 'entry cell', 'alphone-fields') + } + if (kind === 'DATE') { + return dayText(text) + } + return text +} + +/** + * Returns a calendar day as a reader sees it, on the day it names in every time zone. + * @param day - The day written YYYY-MM-DD. + * @returns The shown day. + */ +function dayText(day: string): string { + const [year, month, date] = day.split('-').map(Number) + return formatDate(new Date(year, month - 1, date), DAY_STYLE) +} diff --git a/plugins/fields/frontend/entryOutcome.ts b/plugins/fields/frontend/entryOutcome.ts new file mode 100644 index 0000000..e1de63c --- /dev/null +++ b/plugins/fields/frontend/entryOutcome.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { graphError, graphExtensions, validationMessage } from '@alphone/frontend-sdk' +import type { GraphClient, GraphFailure } from '@alphone/frontend-sdk' + +import { valuesOperation } from './document' +import { catalogueOperation } from './operations' + +/** Outcome is what an entry call's answer asks the screen to do. */ +export type Outcome = 'done' | 'refused' | 'entry-gone' | 'field-gone' + +/** SPOKEN are the reasons whose own words show, though their code is not a validation. */ +const SPOKEN = new Set(['field_entry_not_found', 'contact_not_found']) + +/** OUTCOMES maps the reasons that change the screen to what they change. */ +const OUTCOMES = new Map([ + ['field_entry_not_found', 'entry-gone'], + ['field_unknown', 'field-gone'], +]) + +/** REFETCHES maps the reasons that make the screen read again to the queries it reads. */ +const REFETCHES = new Map([ + ['field_entry_not_found', [valuesOperation]], + ['field_entries_full', [valuesOperation]], + ['field_unknown', [catalogueOperation]], +]) + +/** + * Returns the reason a refused entry call answered with. + * @param error - The failure the call answered with. + * @returns The reason, or an empty string. + */ +function reasonOf(error: GraphFailure): string { + const reason = graphExtensions(error).reason + return typeof reason === 'string' ? reason : '' +} + +/** + * Returns what a reader is shown for a refused entry call. + * @param error - The failure the call answered with. + * @param fallback - The words shown when the server said nothing readable. + * @returns The message. + */ +export function entryMessage(error: GraphFailure, fallback: string): string { + const said = graphError(error) as Error + return SPOKEN.has(reasonOf(error)) ? said.message : validationMessage(said, fallback) +} + +/** + * Returns what an entry call's answer asks the screen to do. + * @param error - The failure the call answered with, if any. + * @returns The outcome. + */ +export function outcomeOf(error: GraphFailure | undefined): Outcome { + if (error === undefined) { + return 'done' + } + return OUTCOMES.get(reasonOf(error)) ?? 'refused' +} + +/** + * Reads again what an entry call's answer changed. + * @param graph - The graph client. + * @param error - The failure the call answered with, if any. + */ +export function refetchAfter(graph: GraphClient, error: GraphFailure | undefined): void { + const names = error === undefined ? [valuesOperation] : (REFETCHES.get(reasonOf(error)) ?? []) + if (names.length > 0) { + graph.refetch(names) + } +} diff --git a/plugins/fields/frontend/focus.ts b/plugins/fields/frontend/focus.ts new file mode 100644 index 0000000..558542f --- /dev/null +++ b/plugins/fields/frontend/focus.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** CONTROLS matches the cells a form's first control is found among. */ +const CONTROLS = 'input:not([aria-hidden="true"]), textarea, [role="checkbox"]' + +/** + * Moves focus to a form's first cell. + * @param form - The form, which always holds at least one cell. + */ +export function focusFirstControl(form: HTMLFormElement): void { + ;(form.querySelector(CONTROLS) as HTMLElement).focus() +} + +/** + * Runs a focus move only when focus is still where the action started, or fell to the page. + * @param from - The element focus was on when the action started. + * @param move - The move to run. + */ +export function whenFocusStayed(from: Element | null, move: () => void): void { + const now = document.activeElement + if (now === from || now === document.body) { + move() + } +} diff --git a/plugins/fields/frontend/languages/es-ES.json b/plugins/fields/frontend/languages/es-ES.json index 6721802..6d55919 100644 --- a/plugins/fields/frontend/languages/es-ES.json +++ b/plugins/fields/frontend/languages/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":""},"%(label)s %(number)d":["%(label)s %(number)d"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move entry down":["Bajar la entrada"],"Move entry up":["Subir la entrada"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove entry":["Quitar la entrada"],"Remove sub field":["Quitar el subcampo"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} +{"":{"lang":"es","plural-forms":""},"%(label)s: %(value)s":["%(label)s: %(value)s"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Blank entry":["Entrada en blanco"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Keep":["Conservar"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove":["Quitar"],"Remove entry":["Quitar la entrada"],"Remove entry: %(name)s":["Quitar la entrada: %(name)s"],"Remove sub field":["Quitar el subcampo"],"Remove this entry?":["¿Quitar esta entrada?"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The entry could not be added.":["No se ha podido añadir la entrada."],"The entry could not be removed.":["No se ha podido quitar la entrada."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"entry cell\u0004No":["No"],"entry cell\u0004Yes":["Sí"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} diff --git a/plugins/fields/frontend/test/entries.test.tsx b/plugins/fields/frontend/test/entries.test.tsx new file mode 100644 index 0000000..748eccb --- /dev/null +++ b/plugins/fields/frontend/test/entries.test.tsx @@ -0,0 +1,827 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { configureErrorText } from '@alphone/frontend-sdk' +import { HttpResponse, graphql, server } from '@alphone/frontend-sdk/testing' +import { act, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, expect, test, vi } from 'vitest' + +import { + ID1, + ID2, + ID3, + capture, + captureWrite, + contactID, + firstButton, + firstCall, + followUp, + history, + hold, + jobTitle, + offerSent, + otherContactID, + refusal, + renderPanel, + serveCatalogue, + serveChangingValues, + serveValues, + speakTemplates, + visits, +} from './harness' + +/** added is the body a successful add answers. */ +const added = { data: { addContactFieldEntry: { id: ID3, comment: 'x' } } } + +/** removed is the body a successful removal answers. */ +const removed = { data: { deleteContactFieldEntry: true } } + +/** now is the moment the add form's dates start from. */ +const now = new Date() + +/** today is the local calendar day the add form starts its dates on. */ +const today = [ + String(now.getFullYear()).padStart(4, '0'), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), +].join('-') + +afterEach(() => { + configureErrorText({ templates: () => ({}), fallback: () => '' }) + vi.unstubAllEnvs() +}) + +/** + * Returns the add form of the named repeater. + * @param label - The repeater's label. + * @returns The form, once it shows. + */ +async function addForm(label = 'History') { + return within(await screen.findByRole('form', { name: `Add an entry to ${label}` })) +} + +/** + * Returns the add button of the named repeater. + * @param label - The repeater's label. + * @returns The button. + */ +function addButton(label = 'History') { + return screen.getByRole('button', { name: `Add an entry to ${label}` }) +} + +/** + * Returns the names of the items the named list shows, in order. + * @param label - The repeater's label. + * @returns The item names. + */ +async function itemNames(label = 'History') { + const list = await screen.findByRole('list', { name: label }) + return within(list) + .getAllByRole('listitem') + .map((item) => item.getAttribute('aria-label')) +} + +/** + * Asks to remove the named entry and confirms it. + * @param name - The entry's name. + */ +async function removeEntry(name: string) { + await userEvent.click(await screen.findByRole('button', { name: `Remove entry: ${name}` })) + await userEvent.click(screen.getByRole('button', { name: 'Remove' })) +} + +test('lists entries in the order the graph answers them', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + + renderPanel() + + expect(await itemNames()).toEqual(['Sep 10, 2026', 'Sep 1, 2026']) +}) + +test('shows the first date as a muted time above the text', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + + renderPanel() + + const item = await screen.findByRole('listitem', { name: 'Sep 10, 2026' }) + const time = item.querySelector('time') + expect(time).toHaveAttribute('datetime', '2026-09-10') + expect(time).toHaveTextContent('Sep 10, 2026') + expect(item.querySelector('p.godmin-log-list__body')?.textContent).toBe(offerSent.comment) +}) + +test('keeps the line breaks of its text', async () => { + serveCatalogue([history]) + serveValues({ history: [followUp] }) + + renderPanel() + + const item = await screen.findByRole('listitem', { name: 'Sep 18, 2026' }) + expect(item.querySelector('p.godmin-log-list__body')?.textContent).toBe(followUp.comment) +}) + +test('shows other cells as label and value lines in sub field order', async () => { + serveCatalogue([visits]) + serveValues({ + visits: [ + { + id: ID1, + date: '2026-09-10', + note: 'Paid in cash.', + minutes: 45, + paid: true, + channel: 'Phone', + followUpOn: '2026-10-01', + }, + ], + }) + + renderPanel() + + const item = await screen.findByRole('listitem', { name: 'Sep 10, 2026' }) + expect([...item.querySelectorAll('p')].map((line) => line.textContent)).toEqual([ + 'Paid in cash.', + 'Minutes: 45', + 'Paid: Yes', + 'Channel: Phone', + 'Follow up on: Oct 1, 2026', + ]) +}) + +test('a false yes or no cell shows No', async () => { + serveCatalogue([visits]) + serveValues({ visits: [{ id: ID1, paid: false }] }) + + renderPanel() + + expect(await screen.findByText('Paid: No')).toBeInTheDocument() +}) + +test('leaves out cells that hold no value', async () => { + serveCatalogue([history]) + serveValues({ history: [{ id: ID1, comment: 'Left a message.' }] }) + + renderPanel() + + const item = await screen.findByRole('listitem', { name: 'Left a message.' }) + expect(item.querySelector('time')).toBeNull() + expect(within(item).queryByText(/Date:/)).not.toBeInTheDocument() +}) + +test('a sub field named after a built-in member shows nothing inherited', async () => { + serveCatalogue([ + { + ...history, + subFields: [{ __typename: 'FieldSubField', name: 'constructor', label: 'Builder', kind: 'TEXT' }], + }, + ]) + serveValues({ history: [{ id: ID1 }] }) + + renderPanel() + + const item = await screen.findByRole('listitem', { name: 'Blank entry' }) + expect(item).not.toHaveTextContent('function') + expect((await addForm()).getByLabelText('Builder')).toHaveValue('') +}) + +test('names each row by its day, its first line or its first detail', async () => { + serveCatalogue([history, visits]) + serveValues({ + history: [offerSent, { id: ID3, comment: 'Left a message.\nNo answer.' }], + visits: [{ id: ID1, minutes: 45 }], + }) + + renderPanel() + + expect(await itemNames()).toEqual(['Sep 10, 2026', 'Left a message.']) + expect(await itemNames('Visits')).toEqual(['Minutes: 45']) + expect(screen.getByRole('button', { name: 'Remove entry: Sep 10, 2026' })).toBeInTheDocument() +}) + +test('shows the day an entry names to a reader west of UTC', async () => { + vi.stubEnv('TZ', 'America/Los_Angeles') + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + + renderPanel() + + const item = await screen.findByRole('listitem', { name: 'Sep 10, 2026' }) + expect(item.querySelector('time')).toHaveTextContent('Sep 10, 2026') +}) + +test('no entries invites the first one', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + + renderPanel() + + expect(await screen.findByText('No entries yet.')).toHaveAttribute('role', 'status') + expect(addButton()).toBeInTheDocument() +}) + +test('a contact the graph no longer finds shows no entries', async () => { + serveCatalogue([history]) + server.use(graphql.query('ContactFieldValues', () => HttpResponse.json({ data: { contact: null } }))) + + renderPanel() + + expect(await screen.findByText('No entries yet.')).toBeInTheDocument() +}) + +test('the add form starts its dates on today', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + + renderPanel() + + const form = await addForm() + expect(form.getByLabelText('Date')).toHaveValue(today) + expect(form.getByLabelText('Comment')).toHaveValue('') +}) + +test('Add stays off on an untouched form holding only the dates it starts with', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + + renderPanel() + await addForm() + + expect(addButton()).toHaveAttribute('aria-disabled', 'true') +}) + +test('Add stays off while the typed cells hold only white space', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + + renderPanel() + const comment = (await addForm()).getByLabelText('Comment') + await userEvent.type(comment, ' ') + expect(addButton()).toHaveAttribute('aria-disabled', 'true') + await userEvent.type(comment, 'x') + + expect(addButton()).not.toHaveAttribute('aria-disabled', 'true') +}) + +test('a picked date alone turns Add on', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + + renderPanel() + const date = (await addForm()).getByLabelText('Date') + await userEvent.clear(date) + await userEvent.type(date, '2026-09-20') + + expect(addButton()).not.toHaveAttribute('aria-disabled', 'true') +}) + +test('adding sends only that entry, never the field values', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + const adding = capture('AddContactFieldEntry', added) + const written = captureWrite() + + renderPanel() + await userEvent.type((await addForm()).getByLabelText('Comment'), 'Called back.{Enter}Agreed on a date.') + await userEvent.click(addButton()) + + await waitFor(() => + expect(adding).toHaveBeenCalledWith({ + contactId: contactID, + field: 'history', + entry: { date: today, comment: 'Called back.\nAgreed on a date.' }, + }), + ) + expect(written).not.toHaveBeenCalled() +}) + +test('a picked date is sent in place of today', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + const adding = capture('AddContactFieldEntry', added) + + renderPanel() + const form = await addForm() + await userEvent.clear(form.getByLabelText('Date')) + await userEvent.type(form.getByLabelText('Date'), '2026-09-20') + await userEvent.type(form.getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + + await waitFor(() => expect(adding).toHaveBeenCalledOnce()) + expect(adding.mock.calls[0][0].entry.date).toBe('2026-09-20') +}) + +test('a cleared date is sent as null', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + const adding = capture('AddContactFieldEntry', added) + + renderPanel() + const form = await addForm() + await userEvent.clear(form.getByLabelText('Date')) + await userEvent.type(form.getByLabelText('Comment'), 'Left a message.') + await userEvent.click(addButton()) + + await waitFor(() => expect(adding).toHaveBeenCalledOnce()) + expect(adding.mock.calls[0][0].entry).toEqual({ date: null, comment: 'Left a message.' }) +}) + +test('a cell holding only white space is sent as null', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + const adding = capture('AddContactFieldEntry', added) + + renderPanel() + const form = await addForm() + await userEvent.clear(form.getByLabelText('Date')) + await userEvent.type(form.getByLabelText('Date'), '2026-09-20') + await userEvent.type(form.getByLabelText('Comment'), ' ') + await userEvent.click(addButton()) + + await waitFor(() => expect(adding).toHaveBeenCalledOnce()) + expect(adding.mock.calls[0][0].entry).toEqual({ date: '2026-09-20', comment: null }) +}) + +test('a number cell is sent as a number', async () => { + serveCatalogue([visits]) + serveValues({ visits: null }) + const adding = capture('AddContactFieldEntry', added) + + renderPanel() + await userEvent.type((await addForm('Visits')).getByLabelText('Minutes'), '45') + await userEvent.click(addButton('Visits')) + + await waitFor(() => expect(adding).toHaveBeenCalledOnce()) + expect(adding.mock.calls[0][0].entry).toEqual(expect.objectContaining({ minutes: 45, paid: null })) +}) + +test('a yes or no cell is sent as a boolean', async () => { + serveCatalogue([visits]) + serveValues({ visits: null }) + const adding = capture('AddContactFieldEntry', added) + + renderPanel() + await userEvent.click((await addForm('Visits')).getByRole('checkbox', { name: 'Paid' })) + await userEvent.click(addButton('Visits')) + + await waitFor(() => expect(adding).toHaveBeenCalledOnce()) + expect(adding.mock.calls[0][0].entry).toEqual(expect.objectContaining({ paid: true })) +}) + +test('Add shows it is busy while the add runs', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + const adding = hold('AddContactFieldEntry', added) + + renderPanel() + await userEvent.type((await addForm()).getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + await waitFor(() => expect(adding.called).toHaveBeenCalledOnce()) + + expect(addButton()).toHaveAttribute('aria-disabled', 'true') + adding.release() + await waitFor(() => expect(addButton()).toHaveAttribute('aria-disabled', 'true')) +}) + +test('an added entry resets the form to today and shows on top', async () => { + serveCatalogue([history]) + const change = serveChangingValues({ history: [firstCall] }) + capture('AddContactFieldEntry', added) + + const { graph } = renderPanel() + await screen.findByRole('listitem', { name: 'Sep 1, 2026' }) + change({ history: [followUp, firstCall] }) + const form = await addForm() + await userEvent.clear(form.getByLabelText('Date')) + await userEvent.type(form.getByLabelText('Date'), '2026-09-18') + await userEvent.type(form.getByLabelText('Comment'), 'Follow-up call.') + await userEvent.click(addButton()) + + await waitFor(async () => expect((await itemNames())[0]).toBe('Sep 18, 2026')) + expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) + expect(form.getByLabelText('Date')).toHaveValue(today) + expect(form.getByLabelText('Comment')).toHaveValue('') +}) + +test('an add returns focus to the first cell', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + capture('AddContactFieldEntry', added) + + renderPanel() + const form = await addForm() + await userEvent.type(form.getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + + await waitFor(() => expect(form.getByLabelText('Date')).toHaveFocus()) +}) + +test('an add answered while a removal is confirmed leaves focus on Keep', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + const adding = hold('AddContactFieldEntry', added) + + renderPanel() + const comment = (await addForm()).getByLabelText('Comment') + await userEvent.type(comment, 'x') + await userEvent.click(addButton()) + await waitFor(() => expect(adding.called).toHaveBeenCalledOnce()) + await userEvent.click(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + adding.release() + + await waitFor(() => expect(comment).toHaveValue('')) + expect(screen.getByRole('button', { name: 'Keep' })).toHaveFocus() +}) + +test('a refused add shows the reason and keeps the draft', async () => { + speakTemplates() + serveCatalogue([history]) + serveValues({ history: null }) + capture('AddContactFieldEntry', refusal('VALIDATION', 'field_entry_empty')) + + const { graph } = renderPanel() + const form = await addForm() + await userEvent.type(form.getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + + expect(await screen.findByRole('alert')).toHaveTextContent('Fill in at least one part of the entry.') + expect(form.getByLabelText('Comment')).toHaveValue('x') + expect(graph.refetch).not.toHaveBeenCalled() +}) + +test('a full list names its most entries and reads the list again', async () => { + speakTemplates() + serveCatalogue([history]) + serveValues({ history: null }) + capture('AddContactFieldEntry', refusal('CONFLICT', 'field_entries_full', { max: 500 })) + + const { graph } = renderPanel() + await userEvent.type((await addForm()).getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + + expect(await screen.findByRole('alert')).toHaveTextContent('This list is full. It holds 500 entries at most.') + expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) +}) + +test('an add to a contact that is gone says so', async () => { + speakTemplates() + serveCatalogue([history]) + serveValues({ history: null }) + capture('AddContactFieldEntry', refusal('NOT_FOUND', 'contact_not_found')) + + renderPanel() + await userEvent.type((await addForm()).getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + + expect(await screen.findByRole('alert')).toHaveTextContent('That contact no longer exists.') +}) + +test('an add to a repeater archived elsewhere reads the catalogue again', async () => { + speakTemplates() + serveCatalogue([history, jobTitle]) + serveValues({ history: null, jobTitle: null }) + capture('AddContactFieldEntry', refusal('VALIDATION', 'field_unknown')) + + const { graph } = renderPanel() + const form = await addForm() + serveCatalogue([jobTitle]) + await userEvent.type(form.getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + + await waitFor(() => expect(screen.queryByRole('group', { name: 'History' })).not.toBeInTheDocument()) + expect(graph.refetch).toHaveBeenCalledWith(['Fields']) + expect(screen.getByRole('alert')).toHaveTextContent('That field is not one this contact holds.') + expect(screen.getByRole('heading', { name: 'Fields' })).toHaveFocus() +}) + +test('an add that fails otherwise shows the fallback', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + capture('AddContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + await userEvent.type((await addForm()).getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + + expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be added.') +}) + +test('Enter in the add form adds the entry and never sends the field values', async () => { + serveCatalogue([history, jobTitle]) + serveValues({ history: null, jobTitle: null }) + const adding = capture('AddContactFieldEntry', added) + const written = captureWrite() + + renderPanel() + const form = await addForm() + await userEvent.type(form.getByLabelText('Comment'), 'x') + await userEvent.type(form.getByLabelText('Date'), '{Enter}') + + await waitFor(() => expect(adding).toHaveBeenCalledOnce()) + expect(written).not.toHaveBeenCalled() +}) + +test('Enter in an untouched add form sends nothing', async () => { + serveCatalogue([history]) + serveValues({ history: null }) + const adding = capture('AddContactFieldEntry', added) + + renderPanel() + await userEvent.type((await addForm()).getByLabelText('Date'), '{Enter}') + + await act(async () => {}) + expect(adding).not.toHaveBeenCalled() +}) + +test('Remove asks first and puts focus on Keep', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + const removing = capture('DeleteContactFieldEntry', removed) + + renderPanel() + await userEvent.click(await screen.findByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + + const item = within(screen.getByRole('listitem', { name: 'Sep 1, 2026' })) + expect(item.getByRole('group', { name: 'Remove this entry?' })).toBeInTheDocument() + expect(item.getByRole('button', { name: 'Remove' })).toBeInTheDocument() + expect(item.getByRole('button', { name: 'Keep' })).toHaveFocus() + expect(removing).not.toHaveBeenCalled() +}) + +test('Keep leaves the entry and puts focus back on Remove entry', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + const removing = capture('DeleteContactFieldEntry', removed) + + renderPanel() + await userEvent.click(await screen.findByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + await userEvent.click(screen.getByRole('button', { name: 'Keep' })) + + expect(screen.queryByRole('group', { name: 'Remove this entry?' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })).toHaveFocus() + expect(removing).not.toHaveBeenCalled() +}) + +test('confirming a removal sends the entry id and reads the list again', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + const removing = capture('DeleteContactFieldEntry', removed) + + const { graph } = renderPanel() + await removeEntry('Sep 1, 2026') + + await waitFor(() => + expect(removing).toHaveBeenCalledWith({ contactId: contactID, field: 'history', entryId: ID1 }), + ) + await waitFor(() => expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues'])) +}) + +test('a removal in flight disables its own row and every other row', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + const removing = hold('DeleteContactFieldEntry', removed) + + renderPanel() + await removeEntry('Sep 1, 2026') + await waitFor(() => expect(removing.called).toHaveBeenCalledOnce()) + + expect(screen.getByRole('button', { name: 'Remove' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: 'Keep' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: 'Remove entry: Sep 10, 2026' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + removing.release() +}) + +test('a removed entry stays disabled until the graph stops answering it', async () => { + serveCatalogue([history]) + const change = serveChangingValues({ history: [offerSent, firstCall] }) + capture('DeleteContactFieldEntry', removed) + + const { graph } = renderPanel() + await removeEntry('Sep 1, 2026') + await waitFor(() => expect(graph.refetch).toHaveBeenCalled()) + + const settling = await screen.findByRole('button', { name: 'Remove entry: Sep 1, 2026' }) + expect(settling).toHaveAttribute('aria-disabled', 'true') + change({ history: [offerSent] }) + act(() => graph.refetch(['ContactFieldValues'])) + await waitFor(() => expect(screen.queryByRole('listitem', { name: 'Sep 1, 2026' })).not.toBeInTheDocument()) +}) + +test('a removal moves focus to the next entry', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + capture('DeleteContactFieldEntry', removed) + + renderPanel() + await removeEntry('Sep 10, 2026') + + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: 'Sep 1, 2026' }))).toHaveFocus()) +}) + +test('removing the oldest entry moves focus to the one above it', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + capture('DeleteContactFieldEntry', removed) + + renderPanel() + await removeEntry('Sep 1, 2026') + + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: 'Sep 10, 2026' }))).toHaveFocus()) +}) + +test('a removal passes over an entry that is still leaving', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + capture('DeleteContactFieldEntry', removed) + + renderPanel() + await removeEntry('Sep 1, 2026') + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: 'Sep 10, 2026' }))).toHaveFocus()) + await removeEntry('Sep 10, 2026') + + await waitFor(async () => expect((await addForm()).getByLabelText('Date')).toHaveFocus()) +}) + +test('a removal answered while typing in the add form leaves focus there', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + const removing = hold('DeleteContactFieldEntry', removed) + + renderPanel() + await removeEntry('Sep 1, 2026') + await waitFor(() => expect(removing.called).toHaveBeenCalledOnce()) + const comment = (await addForm()).getByLabelText('Comment') + await userEvent.type(comment, 'x') + removing.release() + + await waitFor(() => expect(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })).toBeInTheDocument()) + expect(comment).toHaveFocus() +}) + +test('removing the last entry moves focus to the add form', async () => { + serveCatalogue([history]) + const change = serveChangingValues({ history: [firstCall] }) + capture('DeleteContactFieldEntry', removed) + + renderPanel() + await screen.findByRole('listitem', { name: 'Sep 1, 2026' }) + change({ history: null }) + await removeEntry('Sep 1, 2026') + + expect(await screen.findByText('No entries yet.')).toBeInTheDocument() + await waitFor(async () => expect((await addForm()).getByLabelText('Date')).toHaveFocus()) +}) + +test('a removal answered as not found says so and reads the list again', async () => { + speakTemplates() + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + capture('DeleteContactFieldEntry', refusal('NOT_FOUND', 'field_entry_not_found')) + + const { graph } = renderPanel() + await removeEntry('Sep 1, 2026') + + expect(await screen.findByRole('alert')).toHaveTextContent('That entry no longer exists.') + expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) + expect(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) +}) + +test('a refused removal keeps the entry usable', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + await removeEntry('Sep 1, 2026') + + expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be removed.') + const kept = screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' }) + expect(kept).not.toHaveAttribute('aria-disabled', 'true') + await waitFor(() => expect(kept).toHaveFocus()) +}) + +test('a second removal clears the last failure while it runs', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + await removeEntry('Sep 1, 2026') + await screen.findByRole('alert') + const removing = hold('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) + await removeEntry('Sep 1, 2026') + await waitFor(() => expect(removing.called).toHaveBeenCalledOnce()) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + removing.release() + expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be removed.') +}) + +test('a removal from a repeater archived elsewhere reads the catalogue again', async () => { + speakTemplates() + serveCatalogue([history, jobTitle]) + serveValues({ history: [firstCall], jobTitle: null }) + capture('DeleteContactFieldEntry', refusal('VALIDATION', 'field_unknown')) + + const { graph } = renderPanel() + await screen.findByRole('listitem', { name: 'Sep 1, 2026' }) + serveCatalogue([jobTitle]) + await removeEntry('Sep 1, 2026') + + await waitFor(() => expect(screen.queryByRole('group', { name: 'History' })).not.toBeInTheDocument()) + expect(graph.refetch).toHaveBeenCalledWith(['Fields']) + expect(screen.getByRole('alert')).toHaveTextContent('That field is not one this contact holds.') + expect(screen.getByRole('heading', { name: 'Fields' })).toHaveFocus() +}) + +test('saving fields never sends a repeater', async () => { + serveCatalogue([history, jobTitle]) + serveValues({ history: [firstCall], jobTitle: null }) + const written = captureWrite() + + renderPanel() + await userEvent.type(await screen.findByLabelText('Job title'), 'Rear Admiral') + const save = screen.getByRole('button', { name: 'Save fields' }) + await userEvent.click(save) + + await waitFor(() => + expect(written).toHaveBeenCalledWith({ contactId: contactID, values: { jobTitle: 'Rear Admiral' } }), + ) + expect(save.closest('form')).not.toContainElement(screen.getByRole('group', { name: 'History' })) +}) + +test('a contact holding only repeaters shows no Save fields', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + + renderPanel() + await screen.findByRole('group', { name: 'History' }) + + expect(screen.queryByRole('button', { name: 'Save fields' })).not.toBeInTheDocument() +}) + +test('a draft stays with the contact it was typed on', async () => { + serveCatalogue([history]) + server.use( + graphql.query('ContactFieldValues', ({ variables }) => + HttpResponse.json({ + data: { + contact: { + __typename: 'Contact', + id: variables.id, + history: variables.id === contactID ? [firstCall] : [offerSent], + }, + }, + }), + ), + ) + + const { showContact } = renderPanel() + await userEvent.type((await addForm()).getByLabelText('Comment'), 'Typed on the first contact.') + showContact(otherContactID) + + await screen.findByRole('listitem', { name: 'Sep 10, 2026' }) + const form = await addForm() + expect(form.getByLabelText('Comment')).toHaveValue('') + expect(form.getByLabelText('Date')).toHaveValue(today) +}) + +test('entries wait for the stored values before they show', async () => { + serveCatalogue([history]) + let release = () => {} + const answered = new Promise((resolve) => { + release = resolve + }) + server.use( + graphql.query('ContactFieldValues', async () => { + await answered + return HttpResponse.json({ + data: { contact: { __typename: 'Contact', id: contactID, history: [firstCall] } }, + }) + }), + ) + + renderPanel() + + expect(await screen.findByRole('status')).toHaveTextContent('Loading fields…') + expect(screen.queryByRole('button', { name: 'Add an entry to History' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save fields' })).not.toBeInTheDocument() + release() + expect(await screen.findByRole('listitem', { name: 'Sep 1, 2026' })).toBeInTheDocument() +}) + +test('an entry holding only a second date is named by that detail line', async () => { + serveCatalogue([visits]) + serveValues({ visits: [{ id: ID2, followUpOn: '2026-10-01' }] }) + + renderPanel() + + expect(await itemNames('Visits')).toEqual(['Follow up on: Oct 1, 2026']) +}) diff --git a/plugins/fields/frontend/test/harness.tsx b/plugins/fields/frontend/test/harness.tsx index 3af0d94..bb20624 100644 --- a/plugins/fields/frontend/test/harness.tsx +++ b/plugins/fields/frontend/test/harness.tsx @@ -1,16 +1,29 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { GraphProvider } from '@alphone/frontend-sdk' +import { GraphProvider, configureErrorText } from '@alphone/frontend-sdk' import { HttpResponse, fakeGraphClient, graphql, server } from '@alphone/frontend-sdk/testing' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render } from '@testing-library/react' +import { render, within } from '@testing-library/react' import { vi } from 'vitest' import { ContactFieldsPanel } from '../ContactFieldsPanel' +import { errorTemplates } from '../errorTemplates' /** contactID is the contact every panel test renders. */ export const contactID = '0198c000-0000-7000-8000-000000000401' +/** otherContactID is a second contact a panel test switches to. */ +export const otherContactID = '0198c000-0000-7000-8000-000000000402' + +/** ID1 is the id of the oldest stored entry. */ +export const ID1 = '0199a3c4-0000-7000-8000-000000000001' + +/** ID2 is the id of the middle stored entry. */ +export const ID2 = '0199a3c4-0000-7000-8000-000000000002' + +/** ID3 is the id of the newest stored entry. */ +export const ID3 = '0199a3c4-0000-7000-8000-000000000003' + /** jobTitle is a plain text field the panel tests define. */ export const jobTitle = { __typename: 'FieldDefinition', @@ -34,26 +47,115 @@ export const history = { ], } -/** firstCall is the older stored history entry. */ -export const firstCall = { date: '2026-09-01', comment: 'First call about the yearly plan.' } +/** visits is a repeater holding one sub field of every kind a row shows differently. */ +export const visits = { + __typename: 'FieldDefinition', + id: '0198c000-0000-7000-8000-000000000507', + name: 'visits', + label: 'Visits', + kind: 'REPEATER', + subFields: [ + { __typename: 'FieldSubField', name: 'date', label: 'Date', kind: 'DATE' }, + { __typename: 'FieldSubField', name: 'note', label: 'Note', kind: 'TEXT' }, + { __typename: 'FieldSubField', name: 'minutes', label: 'Minutes', kind: 'NUMBER' }, + { __typename: 'FieldSubField', name: 'paid', label: 'Paid', kind: 'BOOLEAN' }, + { __typename: 'FieldSubField', name: 'channel', label: 'Channel', kind: 'SELECT' }, + { __typename: 'FieldSubField', name: 'followUpOn', label: 'Follow up on', kind: 'DATE' }, + ], +} + +/** firstCall is the oldest stored history entry. */ +export const firstCall = { id: ID1, date: '2026-09-01', comment: 'First call about the yearly plan.' } + +/** offerSent is the middle stored history entry. */ +export const offerSent = { id: ID2, date: '2026-09-10', comment: 'Sent the offer and booked a follow-up call.' } -/** offerSent is the newer stored history entry. */ -export const offerSent = { date: '2026-09-10', comment: 'Sent the offer and booked a follow-up call.' } +/** followUp is the newest stored history entry, its comment on two lines. */ +export const followUp = { id: ID3, date: '2026-09-18', comment: 'Follow-up call.\nAsked for a second quote.' } /** * Renders the fields panel of the test contact inside its graph and query providers. - * @returns The render result. + * @returns The render result, the graph client and a switch to another contact. */ export function renderPanel() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const { graph } = fakeGraphClient() - return render( + const shown = (id: string) => ( - + - , + + ) + const rendered = render(shown(contactID)) + return { ...rendered, graph, showContact: (id: string) => rendered.rerender(shown(id)) } +} + +/** + * Answers every call of the named mutation with the given body, recording the variables each one carries. + * @param operation - The mutation name. + * @param body - The body every call answers. + * @returns The mock the variables are recorded on. + */ +export function capture(operation: string, body: Record) { + const called = vi.fn() + server.use( + graphql.mutation(operation, ({ variables }) => { + called(variables) + return HttpResponse.json(body) + }), + ) + return called +} + +/** + * Holds every call of the named mutation until the test releases it, then answers with the given body. + * @param operation - The mutation name. + * @param body - The body the released calls answer. + * @returns The mock the variables are recorded on and the release. + */ +export function hold(operation: string, body: Record) { + const called = vi.fn() + let release = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + server.use( + graphql.mutation(operation, async ({ variables }) => { + called(variables) + await released + return HttpResponse.json(body) + }), ) + return { called, release: () => release() } +} + +/** + * Builds the body of a refused answer. + * @param code - The extensions code. + * @param reason - The stable reason. + * @param meta - The named values the reason's message fills in. + * @returns The body carrying the refusal. + */ +export function refusal(code: string, reason: string, meta?: Record) { + return { errors: [{ message: 'refused', extensions: { code, reason, meta } }] } +} + +/** Speaks every refused answer through the plugin's templates and core's contact_not_found. */ +export function speakTemplates() { + configureErrorText({ + templates: () => ({ ...errorTemplates(), contact_not_found: 'That contact no longer exists.' }), + fallback: () => '', + }) +} + +/** + * Returns the first button one list item holds. + * @param item - The list item. + * @returns The button. + */ +export function firstButton(item: HTMLElement) { + return within(item).getAllByRole('button')[0] } /** @@ -78,6 +180,25 @@ export function serveValues(values: Record) { ) } +/** + * Answers the values query with whatever the returned setter last stored, so a test can change it mid flight. + * @param first - The values answered until the setter is called. + * @returns The setter. + */ +export function serveChangingValues(first: Record) { + let held = first + server.use( + graphql.query('ContactFieldValues', ({ variables }) => + HttpResponse.json({ + data: { contact: { __typename: 'Contact', id: variables.id, ...held } }, + }), + ), + ) + return (next: Record) => { + held = next + } +} + /** * Accepts every field write, recording the variables each one carries. * @returns The mock the variables are recorded on. diff --git a/plugins/fields/frontend/test/panel.test.tsx b/plugins/fields/frontend/test/panel.test.tsx index 280a7f9..6120af1 100644 --- a/plugins/fields/frontend/test/panel.test.tsx +++ b/plugins/fields/frontend/test/panel.test.tsx @@ -1,29 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { GraphProvider } from '@alphone/frontend-sdk' -import { - HttpResponse, - fakeGraphClient, - graphql, - server, -} from '@alphone/frontend-sdk/testing' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, waitFor, within } from '@testing-library/react' +import { HttpResponse, graphql, server } from '@alphone/frontend-sdk/testing' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { expect, test, vi } from 'vitest' -import { ContactFieldsPanel } from '../ContactFieldsPanel' -import { - captureWrite, - contactID, - firstCall, - history, - jobTitle, - offerSent, - renderPanel, - serveCatalogue, - serveValues, -} from './harness' +import { captureWrite, contactID, history, jobTitle, renderPanel, serveCatalogue, serveValues } from './harness' const birthDate = { __typename: 'FieldDefinition', @@ -286,257 +268,6 @@ test('clearing a field sends null', async () => { ) }) -async function entry(number: number) { - return within(await screen.findByRole('group', { name: `History ${number}` })) -} - -test('a repeater renders one group per stored entry, long text in a text area', async () => { - serveCatalogue([history]) - serveValues({ history: [firstCall, offerSent] }) - - renderPanel() - - const first = await entry(1) - expect(first.getByLabelText('Date')).toHaveValue('2026-09-01') - expect(first.getByRole('textbox', { name: 'Comment' })).toBeInstanceOf(HTMLTextAreaElement) - expect(first.getByRole('textbox', { name: 'Comment' })).toHaveValue(firstCall.comment) - expect((await entry(2)).getByLabelText('Date')).toHaveValue('2026-09-10') - expect(screen.getByRole('group', { name: 'History' })).toBeInTheDocument() -}) - -test('a repeater holding no entries invites the first one', async () => { - serveCatalogue([history]) - serveValues({ history: null }) - - renderPanel() - - expect(await screen.findByText('No entries yet.')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Add an entry to History' })).toBeInTheDocument() -}) - -test('a stored entry missing a cell renders that cell empty', async () => { - serveCatalogue([history]) - serveValues({ history: [{ comment: firstCall.comment }] }) - - renderPanel() - - expect((await entry(1)).getByLabelText('Date')).toHaveValue('') -}) - -test('adding an entry sends the whole list with the new entry last', async () => { - serveCatalogue([history]) - serveValues({ history: [firstCall] }) - const written = captureWrite() - - renderPanel() - await entry(1) - await userEvent.click(screen.getByRole('button', { name: 'Add an entry to History' })) - const added = await entry(2) - await userEvent.type(added.getByLabelText('Date'), '2026-09-20') - await userEvent.type(added.getByLabelText('Comment'), 'Called back.{Enter}Agreed on a date.') - await userEvent.click(screen.getByRole('button', { name: 'Save fields' })) - - await waitFor(() => - expect(written).toHaveBeenCalledWith({ - contactId: contactID, - values: { - history: [firstCall, { date: '2026-09-20', comment: 'Called back.\nAgreed on a date.' }], - }, - }), - ) - expect(written).toHaveBeenCalledTimes(1) -}) - -test('a blank cell in an entry is sent as null', async () => { - serveCatalogue([history]) - serveValues({ history: null }) - const written = captureWrite() - - renderPanel() - await userEvent.click(await screen.findByRole('button', { name: 'Add an entry to History' })) - await userEvent.type((await entry(1)).getByLabelText('Comment'), 'Left a message.') - await userEvent.click(screen.getByRole('button', { name: 'Save fields' })) - - await waitFor(() => - expect(written).toHaveBeenCalledWith({ - contactId: contactID, - values: { history: [{ date: null, comment: 'Left a message.' }] }, - }), - ) -}) - -test('moving an entry up sends the list in its new order', async () => { - serveCatalogue([history]) - serveValues({ history: [firstCall, offerSent] }) - const written = captureWrite() - - renderPanel() - await userEvent.click((await entry(2)).getByRole('button', { name: 'Move entry up' })) - await userEvent.click(screen.getByRole('button', { name: 'Save fields' })) - - await waitFor(() => - expect(written).toHaveBeenCalledWith({ - contactId: contactID, - values: { history: [offerSent, firstCall] }, - }), - ) -}) - -test('removing the only entry sends null', async () => { - serveCatalogue([history]) - serveValues({ history: [firstCall] }) - const written = captureWrite() - - renderPanel() - await userEvent.click((await entry(1)).getByRole('button', { name: 'Remove entry' })) - await userEvent.click(screen.getByRole('button', { name: 'Save fields' })) - - await waitFor(() => - expect(written).toHaveBeenCalledWith({ contactId: contactID, values: { history: null } }), - ) -}) - -test('an untouched repeater is left out of the write', async () => { - serveCatalogue([history, jobTitle]) - serveValues({ history: [firstCall], jobTitle: null }) - const written = captureWrite() - - renderPanel() - await entry(1) - await userEvent.type(screen.getByLabelText('Job title'), 'Rear Admiral') - await userEvent.click(screen.getByRole('button', { name: 'Save fields' })) - - await waitFor(() => - expect(written).toHaveBeenCalledWith({ - contactId: contactID, - values: { jobTitle: 'Rear Admiral' }, - }), - ) -}) - -test('a number cell in an entry sends a number', async () => { - const visits = { - __typename: 'FieldDefinition', - id: '0198c000-0000-7000-8000-000000000507', - name: 'visits', - label: 'Visits', - kind: 'REPEATER', - subFields: [{ __typename: 'FieldSubField', name: 'minutes', label: 'Minutes', kind: 'NUMBER' }], - } - serveCatalogue([visits]) - serveValues({ visits: null }) - const written = captureWrite() - - renderPanel() - await userEvent.click(await screen.findByRole('button', { name: 'Add an entry to Visits' })) - await userEvent.type( - within(screen.getByRole('group', { name: 'Visits 1' })).getByLabelText('Minutes'), - '45', - ) - await userEvent.click(screen.getByRole('button', { name: 'Save fields' })) - - await waitFor(() => - expect(written).toHaveBeenCalledWith({ - contactId: contactID, - values: { visits: [{ minutes: 45 }] }, - }), - ) -}) - -test('a saved entry list is edited afresh from what the graph answers', async () => { - serveCatalogue([history]) - let stored: unknown = [firstCall] - server.use( - graphql.query('ContactFieldValues', () => - HttpResponse.json({ - data: { contact: { __typename: 'Contact', id: contactID, history: stored } }, - }), - ), - graphql.mutation('WriteContactFields', () => { - stored = [offerSent] - return HttpResponse.json({ data: { writeContactFields: true } }) - }), - ) - - renderPanel() - await entry(1) - await userEvent.click(screen.getByRole('button', { name: 'Add an entry to History' })) - await entry(2) - await userEvent.click(screen.getByRole('button', { name: 'Save fields' })) - - await waitFor(async () => expect((await entry(1)).getByLabelText('Date')).toHaveValue(offerSent.date)) - expect(screen.queryByRole('group', { name: 'History 2' })).not.toBeInTheDocument() -}) - -test('unsaved entries stay with the contact they were typed on', async () => { - serveCatalogue([history]) - const otherID = '0198c000-0000-7000-8000-000000000402' - server.use( - graphql.query('ContactFieldValues', ({ variables }) => - HttpResponse.json({ - data: { - contact: { - __typename: 'Contact', - id: variables.id, - history: variables.id === contactID ? [firstCall] : [offerSent], - }, - }, - }), - ), - ) - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const { graph } = fakeGraphClient() - const shown = (id: string) => ( - - - - - - ) - - const { rerender } = render(shown(contactID)) - await entry(1) - await userEvent.click(screen.getByRole('button', { name: 'Add an entry to History' })) - await entry(2) - rerender(shown(otherID)) - - await waitFor(async () => expect((await entry(1)).getByLabelText('Date')).toHaveValue(offerSent.date)) - expect(screen.queryByRole('group', { name: 'History 2' })).not.toBeInTheDocument() -}) - -test('the editor waits for the stored entries before it opens', async () => { - serveCatalogue([history]) - let release = () => {} - const answered = new Promise((resolve) => { - release = resolve - }) - server.use( - graphql.query('ContactFieldValues', async () => { - await answered - return HttpResponse.json({ - data: { contact: { __typename: 'Contact', id: contactID, history: [firstCall] } }, - }) - }), - ) - - renderPanel() - - expect(await screen.findByRole('status')).toHaveTextContent('Loading fields…') - expect(screen.queryByRole('button', { name: 'Add an entry to History' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Save fields' })).not.toBeInTheDocument() - release() - expect((await entry(1)).getByLabelText('Date')).toHaveValue(firstCall.date) -}) - -test('a contact the graph no longer finds opens with no stored entries', async () => { - serveCatalogue([history]) - server.use(graphql.query('ContactFieldValues', () => HttpResponse.json({ data: { contact: null } }))) - - renderPanel() - - expect(await screen.findByText('No entries yet.')).toBeInTheDocument() -}) - test('a failed value read is reported instead of an empty editor', async () => { serveCatalogue([history]) server.use( @@ -550,22 +281,6 @@ test('a failed value read is reported instead of an empty editor', async () => { expect(screen.queryByRole('button', { name: 'Save fields' })).not.toBeInTheDocument() }) -test('a sub field named after a built-in member starts every entry blank', async () => { - serveCatalogue([ - { - ...history, - subFields: [{ __typename: 'FieldSubField', name: 'constructor', label: 'Builder', kind: 'TEXT' }], - }, - ]) - serveValues({ history: [{}] }) - - renderPanel() - expect((await entry(1)).getByLabelText('Builder')).toHaveValue('') - await userEvent.click(screen.getByRole('button', { name: 'Add an entry to History' })) - - expect((await entry(2)).getByLabelText('Builder')).toHaveValue('') -}) - test('a failed catalogue read leaves the contact screen alone', async () => { server.use( graphql.query('Fields', () => HttpResponse.json({ errors: [{ message: 'boom' }] })), diff --git a/plugins/fields/frontend/useEntryActions.ts b/plugins/fields/frontend/useEntryActions.ts new file mode 100644 index 0000000..baf5a8f --- /dev/null +++ b/plugins/fields/frontend/useEntryActions.ts @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { __, useGraph, useGraphMutation } from '@alphone/frontend-sdk' +import type { GraphFailure } from '@alphone/frontend-sdk' +import { useState } from 'react' + +import { neighbour } from './entries' +import type { RepeaterRow, StoredEntry } from './entries' +import { entryMessage, outcomeOf, refetchAfter } from './entryOutcome' +import { whenFocusStayed } from './focus' +import { deleteContactFieldEntryMutation } from './operations' + +/** RowFocus names the button of one row focus moves to. */ +export interface RowFocus { + id: string + on: 'first' | 'remove' | 'keep' +} + +/** RowState is how one row shows and what it answers to. */ +export interface RowState { + mode: 'show' | 'confirm' + pending: boolean + locked: boolean + focus: RowFocus | undefined +} + +/** EntryHandlers are the actions the buttons of a row run. */ +export interface EntryHandlers { + remove: (id: string) => void + keep: (id: string) => void + confirm: (id: string) => Promise +} + +/** GoneHandler reports a repeater the catalogue no longer holds, and where focus was when the call began. */ +export type GoneHandler = (message: string, from: Element | null) => void + +/** SettleSteps are what a settled call does once it succeeds and once it is plainly refused. */ +interface SettleSteps { + fallback: string + done: () => void + refused: () => void +} + +/** + * Holds the state the rows of one repeater share, and the actions that change it. + * @param props - The contact, the repeater, the entries shown and the report of a repeater gone. + * @returns The failure to show, the add form's focus count, the add hook, the actions and each row's state. + */ +export function useEntryActions({ + contactId, + field, + entries, + onGone, +}: { + contactId: string + field: RepeaterRow + entries: readonly StoredEntry[] + onGone: GoneHandler +}) { + const [open, setOpen] = useState('') + const [pendingID, setPendingID] = useState('') + const [settling, setSettling] = useState([]) + const [failure, setFailure] = useState('') + const [rowFocus, setRowFocus] = useState(null) + const [addFocus, setAddFocus] = useState(0) + const [, runDelete] = useGraphMutation(deleteContactFieldEntryMutation) + const graph = useGraph() + + const added = () => setAddFocus((count) => count + 1) + + const leave = (id: string, from: Element | null) => { + const next = neighbour(entries, id, settling) + setOpen('') + setSettling((held) => [...held, id]) + whenFocusStayed(from, () => (next === '' ? added() : setRowFocus({ id: next, on: 'first' }))) + } + + const settle = (id: string, from: Element | null, error: GraphFailure | undefined, steps: SettleSteps) => { + refetchAfter(graph, error) + const outcome = outcomeOf(error) + if (outcome === 'done') { + steps.done() + return + } + const message = entryMessage(error as GraphFailure, steps.fallback) + if (outcome === 'field-gone') { + onGone(message, from) + return + } + setFailure(message) + if (outcome === 'entry-gone') { + leave(id, from) + return + } + steps.refused() + } + + const on: EntryHandlers = { + remove: (id) => { + setOpen(id) + setRowFocus({ id, on: 'keep' }) + }, + keep: (id) => { + setOpen('') + setRowFocus({ id, on: 'remove' }) + }, + confirm: async (id) => { + const from = document.activeElement + setFailure('') + setPendingID(id) + const result = await runDelete({ contactId, field: field.name, entryId: id }) + setPendingID('') + settle(id, from, result.error, { + fallback: __('The entry could not be removed.', 'alphone-fields'), + done: () => leave(id, from), + refused: () => { + setOpen('') + whenFocusStayed(from, () => setRowFocus({ id, on: 'remove' })) + }, + }) + }, + } + + const rowOf = (id: string): RowState => ({ + mode: open === id ? 'confirm' : 'show', + pending: pendingID === id, + locked: pendingID !== '' || settling.includes(id), + focus: rowFocus?.id === id ? rowFocus : undefined, + }) + + return { failure, addFocus, added, on, rowOf } +} diff --git a/plugins/fields/languages/alphone-fields.pot b/plugins/fields/languages/alphone-fields.pot index a909186..269ff0e 100644 --- a/plugins/fields/languages/alphone-fields.pot +++ b/plugins/fields/languages/alphone-fields.pot @@ -7,7 +7,7 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Domain: alphone-fields\n" -msgid "%(label)s %(number)d" +msgid "%(label)s: %(value)s" msgstr "" msgid "A field name starts lowercase and runs together, like birthDate." @@ -55,12 +55,18 @@ msgstr "" msgid "Archive %(label)s" msgstr "" +msgid "Blank entry" +msgstr "" + msgid "Fields" msgstr "" msgid "Fill in at least one part of the entry." msgstr "" +msgid "Keep" +msgstr "" + msgid "Kind" msgstr "" @@ -70,12 +76,6 @@ msgstr "" msgid "Loading fields…" msgstr "" -msgid "Move entry down" -msgstr "" - -msgid "Move entry up" -msgstr "" - msgid "Move sub field down" msgstr "" @@ -97,12 +97,21 @@ msgstr "" msgid "Only a repeater holds sub fields." msgstr "" +msgid "Remove" +msgstr "" + msgid "Remove entry" msgstr "" +msgid "Remove entry: %(name)s" +msgstr "" + msgid "Remove sub field" msgstr "" +msgid "Remove this entry?" +msgstr "" + msgid "Save fields" msgstr "" @@ -139,6 +148,12 @@ msgstr "" msgid "The contact already holds a detail by that name." msgstr "" +msgid "The entry could not be added." +msgstr "" + +msgid "The entry could not be removed." +msgstr "" + msgid "The field could not be archived." msgstr "" @@ -161,6 +176,14 @@ msgctxt "admin section" msgid "Fields" msgstr "" +msgctxt "entry cell" +msgid "No" +msgstr "" + +msgctxt "entry cell" +msgid "Yes" +msgstr "" + msgctxt "field kind" msgid "Choice" msgstr "" diff --git a/plugins/fields/languages/es-ES.po b/plugins/fields/languages/es-ES.po index 5fb667b..7fa2ed6 100644 --- a/plugins/fields/languages/es-ES.po +++ b/plugins/fields/languages/es-ES.po @@ -9,8 +9,8 @@ msgstr "" "Language: es\n" #, fuzzy -msgid "%(label)s %(number)d" -msgstr "%(label)s %(number)d" +msgid "%(label)s: %(value)s" +msgstr "%(label)s: %(value)s" #, fuzzy msgid "A field name starts lowercase and runs together, like birthDate." @@ -72,6 +72,10 @@ msgstr "Archivar" msgid "Archive %(label)s" msgstr "Archivar %(label)s" +#, fuzzy +msgid "Blank entry" +msgstr "Entrada en blanco" + #, fuzzy msgid "Fields" msgstr "Campos" @@ -80,6 +84,10 @@ msgstr "Campos" msgid "Fill in at least one part of the entry." msgstr "Rellena al menos una parte de la entrada." +#, fuzzy +msgid "Keep" +msgstr "Conservar" + #, fuzzy msgid "Kind" msgstr "Tipo" @@ -92,14 +100,6 @@ msgstr "Etiqueta" msgid "Loading fields…" msgstr "Cargando los campos…" -#, fuzzy -msgid "Move entry down" -msgstr "Bajar la entrada" - -#, fuzzy -msgid "Move entry up" -msgstr "Subir la entrada" - #, fuzzy msgid "Move sub field down" msgstr "Bajar el subcampo" @@ -128,14 +128,26 @@ msgstr "Todavía no hay ningún subcampo." msgid "Only a repeater holds sub fields." msgstr "Solo un repetidor tiene subcampos." +#, fuzzy +msgid "Remove" +msgstr "Quitar" + #, fuzzy msgid "Remove entry" msgstr "Quitar la entrada" +#, fuzzy +msgid "Remove entry: %(name)s" +msgstr "Quitar la entrada: %(name)s" + #, fuzzy msgid "Remove sub field" msgstr "Quitar el subcampo" +#, fuzzy +msgid "Remove this entry?" +msgstr "¿Quitar esta entrada?" + #, fuzzy msgid "Save fields" msgstr "Guardar los campos" @@ -184,6 +196,14 @@ msgstr "Ese valor no encaja con el tipo que declara el campo." msgid "The contact already holds a detail by that name." msgstr "El contacto ya tiene un dato con ese nombre." +#, fuzzy +msgid "The entry could not be added." +msgstr "No se ha podido añadir la entrada." + +#, fuzzy +msgid "The entry could not be removed." +msgstr "No se ha podido quitar la entrada." + #, fuzzy msgid "The field could not be archived." msgstr "No se ha podido archivar el campo." @@ -213,6 +233,16 @@ msgctxt "admin section" msgid "Fields" msgstr "Campos" +#, fuzzy +msgctxt "entry cell" +msgid "No" +msgstr "No" + +#, fuzzy +msgctxt "entry cell" +msgid "Yes" +msgstr "Sí" + #, fuzzy msgctxt "field kind" msgid "Choice" From 7939fe5952ba97fd575e4b912683026cce2e7338 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 14:13:10 +0200 Subject: [PATCH 28/48] test(e2e): keep a contact history through the add form --- test/e2e/tests/fields-repeater.spec.ts | 72 +++++++++++++++++--------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/test/e2e/tests/fields-repeater.spec.ts b/test/e2e/tests/fields-repeater.spec.ts index 5a85b08..e236ce3 100644 --- a/test/e2e/tests/fields-repeater.spec.ts +++ b/test/e2e/tests/fields-repeater.spec.ts @@ -56,31 +56,53 @@ test('defines a repeater and keeps a contact history in order', async ({ page }) const history = page.getByRole('group', { name: label, exact: true }) await expect(history.getByText('No entries yet.')).toBeVisible() - await page.getByRole('button', { name: `Add an entry to ${label}` }).click() - const first = page.getByRole('group', { name: `${label} 1`, exact: true }) - await first.getByLabel('Date', { exact: true }).fill('2026-09-01') - await first.getByLabel('Comment', { exact: true }).fill('First call about the yearly plan.\nAsked for a quote.') - await page.getByRole('button', { name: `Add an entry to ${label}` }).click() - const second = page.getByRole('group', { name: `${label} 2`, exact: true }) - await second.getByLabel('Date', { exact: true }).fill('2026-09-10') - await second.getByLabel('Comment', { exact: true }).fill('Sent the offer.') - await second.getByRole('button', { name: 'Move entry up' }).click() - const saved = page.waitForResponse( - (response) => - response.url().includes('/api/graphql') && - (response.request().postData() ?? '').includes('WriteContactFields'), - ) - await page.getByRole('button', { name: 'Save fields' }).click() - const answer = await saved - expect(await answer.text()).toContain('"writeContactFields":true') + const form = history.getByRole('form', { name: `Add an entry to ${label}` }) + await expect(form.getByRole('textbox', { name: 'Comment', exact: true })).toHaveJSProperty('tagName', 'TEXTAREA') + await addEntry(page, form, '2026-09-01', 'First call about the yearly plan.\nAsked for a quote.') + await addEntry(page, form, '2026-09-10', 'Sent the offer.') + await addEntry(page, form, '2026-09-18', 'Follow-up call.') + const entries = history.getByRole('listitem') + await expect(entries.first()).toHaveAccessibleName('Sep 18, 2026') + + const removed = operationAnswer(page, 'DeleteContactFieldEntry') + await history.getByRole('button', { name: 'Remove entry: Sep 10, 2026' }).click() + await history.getByRole('button', { name: 'Remove', exact: true }).click() + await removed await page.reload() - const kept = page.getByRole('group', { name: `${label} 1`, exact: true }) - await expect(kept.getByLabel('Date', { exact: true })).toHaveValue('2026-09-10') - await expect(kept.getByLabel('Comment', { exact: true })).toHaveValue('Sent the offer.') - const moved = page.getByRole('group', { name: `${label} 2`, exact: true }) - await expect(moved.getByLabel('Date', { exact: true })).toHaveValue('2026-09-01') - const comment = moved.getByRole('textbox', { name: 'Comment', exact: true }) - await expect(comment).toHaveValue('First call about the yearly plan.\nAsked for a quote.') - await expect(comment).toHaveJSProperty('tagName', 'TEXTAREA') + await expect(entries).toHaveCount(2) + await expect(entries.nth(0)).toHaveAccessibleName('Sep 18, 2026') + await expect(entries.nth(1)).toHaveAccessibleName('Sep 1, 2026') + const body = entries.nth(1).locator('p.godmin-log-list__body') + expect(await body.evaluate((node) => (node as HTMLElement).innerText)).toContain('\n') }) + +/** + * Waits for the graph to answer the named operation. + * @param page - The page sending the operation. + * @param operation - The operation name. + * @returns The answer, once it arrives. + */ +function operationAnswer(page: Page, operation: string) { + return page.waitForResponse( + (response) => + response.url().includes('/api/graphql') && (response.request().postData() ?? '').includes(operation), + ) +} + +/** + * Adds one entry through a repeater's add form and waits until the form is ready for the next one. + * @param page - The page showing the contact. + * @param form - The add form. + * @param date - The entry's date. + * @param comment - The entry's comment. + */ +async function addEntry(page: Page, form: Locator, date: string, comment: string) { + const text = form.getByLabel('Comment', { exact: true }) + await form.getByLabel('Date', { exact: true }).fill(date) + await text.fill(comment) + const stored = operationAnswer(page, 'AddContactFieldEntry') + await form.getByRole('button', { name: /^Add an entry to / }).click() + await stored + await expect(text).toHaveValue('') +} From 639ffbdea70da1931b9e587d7a5cdc483fdd4ac8 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 16:30:42 +0200 Subject: [PATCH 29/48] feat(fields): refuse a repeater written through writeContactFields --- plugins/fields/frontend/errorTemplates.ts | 1 + plugins/fields/frontend/languages/es-ES.json | 2 +- plugins/fields/graphql.go | 1 + plugins/fields/graphql_internal_test.go | 2 + plugins/fields/languages/alphone-fields.pot | 3 + plugins/fields/languages/es-ES.po | 4 + plugins/fields/value.go | 48 +------- plugins/fields/value_internal_test.go | 110 ++----------------- plugins/fields/values_graph_test.go | 28 ++++- 9 files changed, 50 insertions(+), 149 deletions(-) diff --git a/plugins/fields/frontend/errorTemplates.ts b/plugins/fields/frontend/errorTemplates.ts index 727ccfe..b8122b1 100644 --- a/plugins/fields/frontend/errorTemplates.ts +++ b/plugins/fields/frontend/errorTemplates.ts @@ -33,5 +33,6 @@ export function errorTemplates(): Record { field_entry_empty: __('Fill in at least one part of the entry.', DOMAIN), field_entry_not_found: __('That entry no longer exists.', DOMAIN), field_entries_full: __('This list is full. It holds %(max)d entries at most.', DOMAIN), + field_repeater_entries_only: __('A repeater takes its entries one at a time.', DOMAIN), } } diff --git a/plugins/fields/frontend/languages/es-ES.json b/plugins/fields/frontend/languages/es-ES.json index 6d55919..6c53457 100644 --- a/plugins/fields/frontend/languages/es-ES.json +++ b/plugins/fields/frontend/languages/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":""},"%(label)s: %(value)s":["%(label)s: %(value)s"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Blank entry":["Entrada en blanco"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Keep":["Conservar"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove":["Quitar"],"Remove entry":["Quitar la entrada"],"Remove entry: %(name)s":["Quitar la entrada: %(name)s"],"Remove sub field":["Quitar el subcampo"],"Remove this entry?":["¿Quitar esta entrada?"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The entry could not be added.":["No se ha podido añadir la entrada."],"The entry could not be removed.":["No se ha podido quitar la entrada."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"entry cell\u0004No":["No"],"entry cell\u0004Yes":["Sí"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} +{"":{"lang":"es","plural-forms":""},"%(label)s: %(value)s":["%(label)s: %(value)s"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A repeater takes its entries one at a time.":["Un repetidor recibe sus entradas de una en una."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Blank entry":["Entrada en blanco"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Keep":["Conservar"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove":["Quitar"],"Remove entry":["Quitar la entrada"],"Remove entry: %(name)s":["Quitar la entrada: %(name)s"],"Remove sub field":["Quitar el subcampo"],"Remove this entry?":["¿Quitar esta entrada?"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The entry could not be added.":["No se ha podido añadir la entrada."],"The entry could not be removed.":["No se ha podido quitar la entrada."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"entry cell\u0004No":["No"],"entry cell\u0004Yes":["Sí"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} diff --git a/plugins/fields/graphql.go b/plugins/fields/graphql.go index 6404fcd..6fc5b60 100644 --- a/plugins/fields/graphql.go +++ b/plugins/fields/graphql.go @@ -181,6 +181,7 @@ var fieldReasons = []struct { {errEntryEmpty, "field_entry_empty"}, {errNoEntry, "field_entry_not_found"}, {errEntriesFull, "field_entries_full"}, + {errRepeaterEntriesOnly, "field_repeater_entries_only"}, } // fieldReason returns the stable reason a fields error answers with. diff --git a/plugins/fields/graphql_internal_test.go b/plugins/fields/graphql_internal_test.go index 1fd8a5f..e048bb9 100644 --- a/plugins/fields/graphql_internal_test.go +++ b/plugins/fields/graphql_internal_test.go @@ -101,6 +101,8 @@ func TestEveryEntrySentinelNamesItsReason(t *testing.T) { errEntryEmpty: "field_entry_empty", errNoEntry: "field_entry_not_found", errEntriesFull: "field_entries_full", + + errRepeaterEntriesOnly: "field_repeater_entries_only", } for sentinel, reason := range want { diff --git a/plugins/fields/languages/alphone-fields.pot b/plugins/fields/languages/alphone-fields.pot index 269ff0e..c96a890 100644 --- a/plugins/fields/languages/alphone-fields.pot +++ b/plugins/fields/languages/alphone-fields.pot @@ -22,6 +22,9 @@ msgstr "" msgid "A repeater needs at least one sub field." msgstr "" +msgid "A repeater takes its entries one at a time." +msgstr "" + msgid "A sub field cannot be named id. Each entry keeps that name for itself." msgstr "" diff --git a/plugins/fields/languages/es-ES.po b/plugins/fields/languages/es-ES.po index 7fa2ed6..592bfc5 100644 --- a/plugins/fields/languages/es-ES.po +++ b/plugins/fields/languages/es-ES.po @@ -28,6 +28,10 @@ msgstr "Un repetidor no puede contener otro repetidor." msgid "A repeater needs at least one sub field." msgstr "Un repetidor necesita al menos un subcampo." +#, fuzzy +msgid "A repeater takes its entries one at a time." +msgstr "Un repetidor recibe sus entradas de una en una." + #, fuzzy msgid "A sub field cannot be named id. Each entry keeps that name for itself." msgstr "Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma." diff --git a/plugins/fields/value.go b/plugins/fields/value.go index e44d274..24c69a4 100644 --- a/plugins/fields/value.go +++ b/plugins/fields/value.go @@ -19,6 +19,8 @@ var ( errNoField = errors.New("fields: no live definition holds that name") errValuesNotAnObject = errors.New("fields: values is an object of field names to values") errEntryEmpty = errors.New("fields: an entry holds at least one filled cell") + + errRepeaterEntriesOnly = errors.New("fields: a repeater takes its entries one at a time") ) // checkEntry returns the storable cells of one repeater entry, refusing unknown keys, wrong kinds and a blank entry. @@ -144,7 +146,7 @@ func coerceDate(given any) (any, error) { return text, nil } -// checkValues returns the storable values the view allows, refusing unknown keys and wrong kinds. +// checkValues returns the storable values the view allows, refusing unknown keys, wrong kinds and repeaters. func checkValues(live *view, given map[string]any) (map[string]any, error) { var unknown []string checked := make(map[string]any, len(given)) @@ -155,13 +157,7 @@ func checkValues(live *view, given map[string]any) (map[string]any, error) { continue } if held == kindRepeater { - rows, stray, err := checkRows(name, live.columns[name], value) - if err != nil { - return nil, err - } - unknown = append(unknown, stray...) - checked[name] = rows - continue + return nil, fmt.Errorf("%w: %s", errRepeaterEntriesOnly, name) } coerced, err := coerce(held, value) if err != nil { @@ -176,40 +172,8 @@ func checkValues(live *view, given map[string]any) (map[string]any, error) { return checked, nil } -// checkRows returns the storable rows of a repeater and the paths of the cells no column holds. -func checkRows(name string, columns []SubField, given any) (any, []string, error) { - if given == nil { - return nil, nil, nil - } - listed, ok := given.([]any) - if !ok { - return nil, nil, fmt.Errorf("%w: %s expects %s", errWrongKind, name, kindRepeater) - } - kindsOf := columnKinds(columns) - var unknown []string - rows := make([]map[string]any, 0, len(listed)) - for at, entry := range listed { - row, stray, err := checkRow(fmt.Sprintf("%s[%d]", name, at), kindsOf, entry) - if err != nil { - return nil, nil, err - } - unknown = append(unknown, stray...) - if len(row) > 0 { - rows = append(rows, row) - } - } - if len(rows) == 0 { - return nil, unknown, nil - } - return rows, unknown, nil -} - -// checkRow returns the storable cells of one row and the paths of the cells no column holds. -func checkRow(path string, kindsOf map[string]kind, given any) (map[string]any, []string, error) { - cells, ok := given.(map[string]any) - if !ok { - return nil, nil, fmt.Errorf("%w: %s expects a row", errWrongKind, path) - } +// checkRow returns the storable cells of one entry and the paths of the cells no column holds. +func checkRow(path string, kindsOf map[string]kind, cells map[string]any) (map[string]any, []string, error) { var unknown []string row := make(map[string]any, len(cells)) for key, value := range cells { diff --git a/plugins/fields/value_internal_test.go b/plugins/fields/value_internal_test.go index 036ce0d..bf24875 100644 --- a/plugins/fields/value_internal_test.go +++ b/plugins/fields/value_internal_test.go @@ -241,91 +241,18 @@ func TestCheckValuesNamesTheKindItRefused(t *testing.T) { } } -func TestCheckValuesKeepsRepeaterRowsInOrder(t *testing.T) { - t.Parallel() - - checked, err := checkValues(historyView(), map[string]any{"history": []any{ - map[string]any{"date": "2026-09-10", "comment": "Sent the offer", "count": float64(2)}, - map[string]any{"date": "2026-09-01", "comment": "First call"}, - }}) - - if err != nil { - t.Fatalf("checkValues() error = %v, want nil", err) - } - want := []map[string]any{ - {"date": "2026-09-10", "comment": "Sent the offer", "count": int64(2)}, - {"date": "2026-09-01", "comment": "First call"}, - } - if !reflect.DeepEqual(checked["history"], want) { - t.Errorf("history = %#v, want the rows in the order written with every cell coerced", checked["history"]) - } -} - -func TestCheckValuesReportsEveryBadRowKeyAtOnce(t *testing.T) { - t.Parallel() - - _, err := checkValues(historyView(), map[string]any{ - "history": []any{ - map[string]any{"date": "2026-09-01", "mood": "happy"}, - map[string]any{"size": "large"}, - }, - "neverDefined": "x", - }) - - if !errors.Is(err, errNoField) { - t.Fatalf("checkValues() error = %v, want errNoField", err) - } - if !strings.HasSuffix(err.Error(), "history[0].mood, history[1].size, neverDefined") { - t.Errorf("error = %v, want every bad key named by its path, sorted", err) - } -} - -func TestCheckValuesNamesTheCellItRefused(t *testing.T) { - t.Parallel() - - _, err := checkValues(historyView(), map[string]any{"history": []any{ - map[string]any{"date": "2026-09-01"}, - map[string]any{"date": "not a date"}, - }}) - - if !errors.Is(err, errWrongKind) || !strings.Contains(err.Error(), "history[1].date expects DATE") { - t.Errorf("checkValues() error = %v, want the cell named with its kind", err) - } -} - -func TestCheckValuesDropsNullCellsAndEmptyRows(t *testing.T) { - t.Parallel() - - checked, err := checkValues(historyView(), map[string]any{"history": []any{ - map[string]any{"date": "2026-09-01", "comment": nil}, - map[string]any{}, - map[string]any{"comment": nil}, - }}) - - if err != nil { - t.Fatalf("checkValues() error = %v, want nil", err) - } - want := []map[string]any{{"date": "2026-09-01"}} - if !reflect.DeepEqual(checked["history"], want) { - t.Errorf("history = %#v, want only the filled cell kept", checked["history"]) - } -} - -func TestCheckValuesClearsARepeaterWrittenWithoutRows(t *testing.T) { +func TestCheckValuesRefusesARepeater(t *testing.T) { t.Parallel() for name, given := range map[string]any{ - "no rows": []any{}, - "null": nil, - "only empty rows": []any{map[string]any{}, map[string]any{"date": nil}}, + "a list of rows": []any{map[string]any{"comment": "First call"}}, + "null": nil, + "a text": "a text", } { - checked, err := checkValues(historyView(), map[string]any{"history": given}) + _, err := checkValues(historyView(), map[string]any{"history": given}) - if err != nil { - t.Fatalf("%s: checkValues() error = %v, want nil", name, err) - } - if held, present := checked["history"]; !present || held != nil { - t.Errorf("%s: history = %#v, want null so the write clears it", name, held) + if !errors.Is(err, errRepeaterEntriesOnly) || !strings.Contains(err.Error(), "history") { + t.Errorf("%s: checkValues() error = %v, want the repeater refused by name", name, err) } } } @@ -469,26 +396,3 @@ func TestCheckEntryRefusesAnotherEntrysID(t *testing.T) { t.Errorf("checkEntry() error = %v, want another entry's id named as unknown", err) } } - -func TestCheckValuesRefusesRowsOfAnotherShape(t *testing.T) { - t.Parallel() - - cases := map[string]struct { - given any - want string - }{ - "a value that is not a list": {"a text", "history expects REPEATER"}, - "a row that is not an object": {[]any{"a text"}, "history[0] expects a row"}, - } - for name, testCase := range cases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - _, err := checkValues(historyView(), map[string]any{"history": testCase.given}) - - if !errors.Is(err, errWrongKind) || !strings.Contains(err.Error(), testCase.want) { - t.Errorf("checkValues() error = %v, want %q", err, testCase.want) - } - }) - } -} diff --git a/plugins/fields/values_graph_test.go b/plugins/fields/values_graph_test.go index 2ba2389..32f8aba 100644 --- a/plugins/fields/values_graph_test.go +++ b/plugins/fields/values_graph_test.go @@ -86,10 +86,13 @@ func TestGraphWritesNumbersWrittenInline(t *testing.T) { `subFields: [{ name: "minutes", label: "Minutes", kind: NUMBER }]) { id } }`, &defined) - var written struct{ WriteContactFields bool } + var written struct { + WriteContactFields bool + AddContactFieldEntry any + } err := client.Post( - `mutation($id: UUID!) { writeContactFields(contactId: $id, `+ - `values: { loyaltyPoints: 420, visits: [{ minutes: 45 }] }) }`, + `mutation($id: UUID!) { writeContactFields(contactId: $id, values: { loyaltyPoints: 420 }) `+ + `addContactFieldEntry(contactId: $id, field: "visits", entry: { minutes: 45 }) }`, &written, gqlclient.Var("id", contactID.String())) if err != nil { @@ -113,6 +116,25 @@ func TestGraphWritesNumbersWrittenInline(t *testing.T) { } } +func TestGraphRefusesARepeaterWrittenThroughTheValues(t *testing.T) { + t.Parallel() + + client, contactID := newValuesClient(t) + defineHistory(t, client) + + var written struct{ WriteContactFields bool } + err := client.Post( + `mutation($id: UUID!, $values: JSON!) { writeContactFields(contactId: $id, values: $values) }`, + &written, gqlclient.Var("id", contactID.String()), + gqlclient.Var("values", map[string]any{"history": []any{map[string]any{"comment": "First call"}}})) + + for _, held := range []string{`"code":"VALIDATION"`, `"reason":"field_repeater_entries_only"`} { + if err == nil || !strings.Contains(err.Error(), held) { + t.Errorf("error = %v, want it to carry %s", err, held) + } + } +} + func TestGraphAnswersNullForAnUnwrittenField(t *testing.T) { t.Parallel() From fd83a39d5a65348a78857b487e8dc46addeefa0e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 16:30:42 +0200 Subject: [PATCH 30/48] test(features): retire the whole-list repeater scenarios and run the refusal --- .../features/features/fields-repeater.feature | 72 ++----------------- test/features/steps_fields_repeater_test.go | 24 +------ 2 files changed, 6 insertions(+), 90 deletions(-) diff --git a/test/features/features/fields-repeater.feature b/test/features/features/fields-repeater.feature index ecb87e1..b39ab90 100644 --- a/test/features/features/fields-repeater.feature +++ b/test/features/features/fields-repeater.feature @@ -1,8 +1,9 @@ Feature: A repeater keeps a list of entries on a contact - A repeater field holds rows of sub fields, such as a history log where each - entry has a date and a comment. The definition names its sub fields, each - with a label and a kind, and every written row is checked cell by cell - against them. + A repeater field holds entries of sub fields, such as a history log where + each entry has a date and a comment. The definition names its sub fields, + each with a label and a kind. Entries are added, edited and removed one at a + time, each checked cell by cell against the sub fields and kept under its + own id, newest first. Background: Given a running AlphOne holding a user with an API token @@ -52,51 +53,6 @@ Feature: A repeater keeps a list of entries on a contact | entries | Entries | REPEATER | Then the definition is refused with the reason "field_sub_field_nested" - Scenario: A repeater answers its rows in the order written - Given the repeater "history" labelled "History" is defined with sub fields: - | name | label | kind | - | date | Date | DATE | - | comment | Comment | LONGTEXT | - When the operator writes the rows into "history" of the contact: - | date | comment | - | 2026-09-10 | Sent the offer | - | 2026-09-01 | First call | - Then querying the contact for "history" answers the rows: - | date | comment | - | 2026-09-10 | Sent the offer | - | 2026-09-01 | First call | - - Scenario: A row naming an unknown sub field is refused - Given the repeater "history" labelled "History" is defined with sub fields: - | name | label | kind | - | date | Date | DATE | - | comment | Comment | LONGTEXT | - When the operator writes the rows into "history" of the contact: - | date | mood | - | 2026-09-01 | happy | - Then the write is refused naming "history[0].mood" as the bad key - - Scenario: A row holding a value of the wrong kind is refused - Given the repeater "history" labelled "History" is defined with sub fields: - | name | label | kind | - | date | Date | DATE | - | comment | Comment | LONGTEXT | - When the operator writes the rows into "history" of the contact: - | date | comment | - | not a date | First call | - Then the write is refused for a value of the wrong kind - - Scenario: Writing no rows clears the repeater - Given the repeater "history" labelled "History" is defined with sub fields: - | name | label | kind | - | date | Date | DATE | - | comment | Comment | LONGTEXT | - And the operator writes the rows into "history" of the contact: - | date | comment | - | 2026-09-01 | First call | - When the operator writes no rows into "history" of the contact - Then the contact "Maria Perez" answers null for the field "history" - Scenario: An archived repeater defined again with other sub fields is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -108,23 +64,6 @@ Feature: A repeater keeps a list of entries on a contact | date | Date | DATE | Then the definition is refused with the reason "field_kind_locked" - Scenario: An archived repeater defined again with the same sub fields answers its rows - Given the repeater "history" labelled "History" is defined with sub fields: - | name | label | kind | - | date | Date | DATE | - | comment | Comment | LONGTEXT | - And the operator writes the rows into "history" of the contact: - | date | comment | - | 2026-09-01 | First call | - When the operator archives the field "history" - And the operator defines the repeater "history" labelled "History" with sub fields: - | name | label | kind | - | date | Date | DATE | - | comment | Comment | LONGTEXT | - Then querying the contact for "history" answers the rows: - | date | comment | - | 2026-09-01 | First call | - Scenario: Introspection lists a repeater typed JSON Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | @@ -395,7 +334,6 @@ Feature: A repeater keeps a list of entries on a contact | First call | And every entry keeps the id its add answered - @wip Scenario: Writing a repeater through the field values is refused Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | diff --git a/test/features/steps_fields_repeater_test.go b/test/features/steps_fields_repeater_test.go index 48e59a0..49cc39a 100644 --- a/test/features/steps_fields_repeater_test.go +++ b/test/features/steps_fields_repeater_test.go @@ -147,32 +147,10 @@ func bindRepeaterDefinitionSteps(sc *godog.ScenarioContext) { }) } -// bindRepeaterRowSteps binds the steps that write and read a repeater's rows. +// bindRepeaterRowSteps binds the step that writes a whole list of rows through the field values. func bindRepeaterRowSteps(sc *godog.ScenarioContext) { sc.Step(`^the operator writes the rows into "([^"]*)" of the contact:$`, func(ctx context.Context, name string, table *godog.Table) error { return worldFrom(ctx).writeValue(ctx, name, tableRecords(table)) }) - - sc.When(`^the operator writes no rows into "([^"]*)" of the contact$`, - func(ctx context.Context, name string) error { - return worldFrom(ctx).writeValue(ctx, name, []map[string]any{}) - }) - - sc.Then(`^querying the contact for "([^"]*)" answers the rows:$`, - func(ctx context.Context, name string, table *godog.Table) error { - w := worldFrom(ctx) - answered, err := w.readField(ctx, name) - if err != nil { - return err - } - if len(answered.Errors) > 0 { - return fmt.Errorf("the graph refused the read, answered %s", w.answered) - } - same, err := sameJSON(answered.Data.Contact[name], tableRecords(table)) - if err != nil || same { - return err - } - return fmt.Errorf("%s = %#v, answered %s", name, answered.Data.Contact[name], w.answered) - }) } From cfee30d0c01d604e4192592f15aef97f0403e743 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 16:51:01 +0200 Subject: [PATCH 31/48] feat(fields): add the operation that updates a repeater entry --- plugins/fields/frontend/gql/gql.ts | 6 ++++++ plugins/fields/frontend/gql/graphql.ts | 11 +++++++++++ plugins/fields/frontend/operations.ts | 6 ++++++ 3 files changed, 23 insertions(+) diff --git a/plugins/fields/frontend/gql/gql.ts b/plugins/fields/frontend/gql/gql.ts index 793711e..a96c1ad 100644 --- a/plugins/fields/frontend/gql/gql.ts +++ b/plugins/fields/frontend/gql/gql.ts @@ -19,6 +19,7 @@ type Documents = { "\n\tmutation ArchiveField($id: UUID!) {\n\t\tarchiveField(id: $id)\n\t}\n": typeof types.ArchiveFieldDocument, "\n\tmutation WriteContactFields($contactId: UUID!, $values: JSON!) {\n\t\twriteContactFields(contactId: $contactId, values: $values)\n\t}\n": typeof types.WriteContactFieldsDocument, "\n\tmutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) {\n\t\taddContactFieldEntry(contactId: $contactId, field: $field, entry: $entry)\n\t}\n": typeof types.AddContactFieldEntryDocument, + "\n\tmutation UpdateContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!, $entry: JSON!) {\n\t\tupdateContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId, entry: $entry)\n\t}\n": typeof types.UpdateContactFieldEntryDocument, "\n\tmutation DeleteContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!) {\n\t\tdeleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId)\n\t}\n": typeof types.DeleteContactFieldEntryDocument, }; const documents: Documents = { @@ -27,6 +28,7 @@ const documents: Documents = { "\n\tmutation ArchiveField($id: UUID!) {\n\t\tarchiveField(id: $id)\n\t}\n": types.ArchiveFieldDocument, "\n\tmutation WriteContactFields($contactId: UUID!, $values: JSON!) {\n\t\twriteContactFields(contactId: $contactId, values: $values)\n\t}\n": types.WriteContactFieldsDocument, "\n\tmutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) {\n\t\taddContactFieldEntry(contactId: $contactId, field: $field, entry: $entry)\n\t}\n": types.AddContactFieldEntryDocument, + "\n\tmutation UpdateContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!, $entry: JSON!) {\n\t\tupdateContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId, entry: $entry)\n\t}\n": types.UpdateContactFieldEntryDocument, "\n\tmutation DeleteContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!) {\n\t\tdeleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId)\n\t}\n": types.DeleteContactFieldEntryDocument, }; @@ -64,6 +66,10 @@ export function graphql(source: "\n\tmutation WriteContactFields($contactId: UUI * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n\tmutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) {\n\t\taddContactFieldEntry(contactId: $contactId, field: $field, entry: $entry)\n\t}\n"): (typeof documents)["\n\tmutation AddContactFieldEntry($contactId: UUID!, $field: String!, $entry: JSON!) {\n\t\taddContactFieldEntry(contactId: $contactId, field: $field, entry: $entry)\n\t}\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n\tmutation UpdateContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!, $entry: JSON!) {\n\t\tupdateContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId, entry: $entry)\n\t}\n"): (typeof documents)["\n\tmutation UpdateContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!, $entry: JSON!) {\n\t\tupdateContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId, entry: $entry)\n\t}\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/plugins/fields/frontend/gql/graphql.ts b/plugins/fields/frontend/gql/graphql.ts index 357c54f..df6d69d 100644 --- a/plugins/fields/frontend/gql/graphql.ts +++ b/plugins/fields/frontend/gql/graphql.ts @@ -58,6 +58,16 @@ export type AddContactFieldEntryMutationVariables = Exact<{ export type AddContactFieldEntryMutation = { addContactFieldEntry: unknown }; +export type UpdateContactFieldEntryMutationVariables = Exact<{ + contactId: string; + field: string; + entryId: string; + entry: unknown; +}>; + + +export type UpdateContactFieldEntryMutation = { updateContactFieldEntry: unknown }; + export type DeleteContactFieldEntryMutationVariables = Exact<{ contactId: string; field: string; @@ -73,4 +83,5 @@ export const DefineFieldDocument = {"kind":"Document","definitions":[{"kind":"Op export const ArchiveFieldDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveField"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveField"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const WriteContactFieldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"WriteContactFields"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"values"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"writeContactFields"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"values"},"value":{"kind":"Variable","name":{"kind":"Name","value":"values"}}}]}]}}]} as unknown as DocumentNode; export const AddContactFieldEntryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddContactFieldEntry"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"field"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entry"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addContactFieldEntry"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"field"},"value":{"kind":"Variable","name":{"kind":"Name","value":"field"}}},{"kind":"Argument","name":{"kind":"Name","value":"entry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entry"}}}]}]}}]} as unknown as DocumentNode; +export const UpdateContactFieldEntryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateContactFieldEntry"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"field"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entryId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entry"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateContactFieldEntry"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"field"},"value":{"kind":"Variable","name":{"kind":"Name","value":"field"}}},{"kind":"Argument","name":{"kind":"Name","value":"entryId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entryId"}}},{"kind":"Argument","name":{"kind":"Name","value":"entry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entry"}}}]}]}}]} as unknown as DocumentNode; export const DeleteContactFieldEntryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteContactFieldEntry"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"field"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entryId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteContactFieldEntry"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"field"},"value":{"kind":"Variable","name":{"kind":"Name","value":"field"}}},{"kind":"Argument","name":{"kind":"Name","value":"entryId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entryId"}}}]}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/plugins/fields/frontend/operations.ts b/plugins/fields/frontend/operations.ts index ebe2bd8..a72b3eb 100644 --- a/plugins/fields/frontend/operations.ts +++ b/plugins/fields/frontend/operations.ts @@ -60,6 +60,12 @@ export const addContactFieldEntryMutation = graphql(` } `) +export const updateContactFieldEntryMutation = graphql(` + mutation UpdateContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!, $entry: JSON!) { + updateContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId, entry: $entry) + } +`) + export const deleteContactFieldEntryMutation = graphql(` mutation DeleteContactFieldEntry($contactId: UUID!, $field: String!, $entryId: UUID!) { deleteContactFieldEntry(contactId: $contactId, field: $field, entryId: $entryId) From 35d486589a012187b242b7d9d72b31b8a53874b6 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 16:51:01 +0200 Subject: [PATCH 32/48] feat(fields): edit a repeater entry in place --- plugins/fields/frontend/EntryRow.tsx | 152 +++++++++- plugins/fields/frontend/entries.ts | 9 + plugins/fields/frontend/languages/es-ES.json | 2 +- plugins/fields/frontend/test/entries.test.tsx | 285 ++++++++++++++++++ plugins/fields/frontend/useEntryActions.ts | 54 +++- plugins/fields/languages/alphone-fields.pot | 15 + plugins/fields/languages/es-ES.po | 20 ++ 7 files changed, 511 insertions(+), 26 deletions(-) diff --git a/plugins/fields/frontend/EntryRow.tsx b/plugins/fields/frontend/EntryRow.tsx index 9e31e32..854a8cc 100644 --- a/plugins/fields/frontend/EntryRow.tsx +++ b/plugins/fields/frontend/EntryRow.tsx @@ -1,16 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { Button, LogItem, LogTime, Stack, Text, __, sprintf } from '@alphone/frontend-sdk' -import { useEffect, useId, useRef } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import type { RefObject } from 'react' -import type { SubFieldRow } from './cellText' -import { entryName, entryParts } from './entries' +import { entryText } from './cellText' +import type { EntryText, SubFieldRow } from './cellText' +import { EntryCells } from './cells' +import { entryName, entryParts, isBlank } from './entries' import type { StoredEntry } from './entries' +import { focusFirstControl } from './focus' import type { EntryHandlers, RowState } from './useEntryActions' /** - * Renders one entry as a log item: its day, its text and a line per other cell, with its actions. + * Renders one entry as a log item: its day, its text and a line per other cell, with its actions, or its editor. * @param props - The entry, the sub fields it holds, how the row shows and the actions its buttons run. * @returns The log item. */ @@ -27,16 +30,32 @@ export function EntryRow({ }) { const parts = entryParts(subFields, entry.cells) const name = entryName(parts) + const editRef = useRef(null) const removeRef = useRef(null) const keepRef = useRef(null) useEffect(() => { if (row.focus) { - const target = row.focus.on === 'keep' ? keepRef : removeRef + const target = { first: editRef, remove: removeRef, keep: keepRef }[row.focus.on] ;(target.current as HTMLButtonElement).focus() } }, [row.focus]) + if (row.mode === 'edit') { + return ( + + void on.save(entry.id, draft)} + onCancel={() => on.cancel(entry.id)} + /> + + ) + } return ( on.keep(entry.id)} /> ) : ( - + on.edit(entry.id)} + onRemove={() => on.remove(entry.id)} + /> ) } body={parts.body} @@ -74,6 +90,54 @@ export function EntryRow({ ) } +/** + * Renders a row's Edit entry and Remove entry buttons, each named after the entry. + * @param props - The entry's name, whether the row is locked, the two buttons' refs and their actions. + * @returns The buttons. + */ +function RowActions({ + name, + locked, + editRef, + removeRef, + onEdit, + onRemove, +}: { + name: string + locked: boolean + editRef: RefObject + removeRef: RefObject + onEdit: () => void + onRemove: () => void +}) { + return ( + <> + + + + ) +} + /** * Renders the question that confirms a removal, with its Remove and Keep buttons. * @param props - How the row shows, the Keep button's ref and the two actions. @@ -112,3 +176,59 @@ function ConfirmActions({ ) } + +/** + * Renders the form editing one entry in place, its cells drafted from the stored entry. + * @param props - The sub fields, the stored cells, the entry's name, the row's state and the two actions. + * @returns The editor form. + */ +function EntryEditor({ + subFields, + cells, + name, + pending, + locked, + onSave, + onCancel, +}: { + subFields: SubFieldRow[] + cells: Record + name: string + pending: boolean + locked: boolean + onSave: (draft: EntryText) => void + onCancel: () => void +}) { + const [draft, setDraft] = useState(() => entryText(subFields, cells)) + const formRef = useRef(null) + + useEffect(() => { + focusFirstControl(formRef.current as HTMLFormElement) + }, []) + + return ( +
{ + event.preventDefault() + onSave(draft) + }} + > + setDraft((held) => ({ ...held, [key]: text }))} + /> + + + + + + ) +} diff --git a/plugins/fields/frontend/entries.ts b/plugins/fields/frontend/entries.ts index 7b3e363..f753a4f 100644 --- a/plugins/fields/frontend/entries.ts +++ b/plugins/fields/frontend/entries.ts @@ -65,6 +65,15 @@ export function holdsInput(subFields: SubFieldRow[], picked: ReadonlyMap (picked.get(column.name) ?? '').trim() !== '') } +/** + * Reports whether every cell of an entry is blank. + * @param text - The text of every cell. + * @returns True when no cell holds more than white space. + */ +export function isBlank(text: EntryText): boolean { + return Object.values(text).every((cell) => cell.trim() === '') +} + /** * Returns an entry in the form the entry mutations take. * @param subFields - The sub fields an entry holds. diff --git a/plugins/fields/frontend/languages/es-ES.json b/plugins/fields/frontend/languages/es-ES.json index 6c53457..76090c9 100644 --- a/plugins/fields/frontend/languages/es-ES.json +++ b/plugins/fields/frontend/languages/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":""},"%(label)s: %(value)s":["%(label)s: %(value)s"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A repeater takes its entries one at a time.":["Un repetidor recibe sus entradas de una en una."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Blank entry":["Entrada en blanco"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Keep":["Conservar"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove":["Quitar"],"Remove entry":["Quitar la entrada"],"Remove entry: %(name)s":["Quitar la entrada: %(name)s"],"Remove sub field":["Quitar el subcampo"],"Remove this entry?":["¿Quitar esta entrada?"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The entry could not be added.":["No se ha podido añadir la entrada."],"The entry could not be removed.":["No se ha podido quitar la entrada."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"entry cell\u0004No":["No"],"entry cell\u0004Yes":["Sí"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} +{"":{"lang":"es","plural-forms":""},"%(label)s: %(value)s":["%(label)s: %(value)s"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A repeater takes its entries one at a time.":["Un repetidor recibe sus entradas de una en una."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Blank entry":["Entrada en blanco"],"Cancel":["Cancelar"],"Edit entry":["Editar la entrada"],"Edit entry: %(name)s":["Editar la entrada: %(name)s"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Keep":["Conservar"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove":["Quitar"],"Remove entry":["Quitar la entrada"],"Remove entry: %(name)s":["Quitar la entrada: %(name)s"],"Remove sub field":["Quitar el subcampo"],"Remove this entry?":["¿Quitar esta entrada?"],"Save entry":["Guardar la entrada"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The entry could not be added.":["No se ha podido añadir la entrada."],"The entry could not be removed.":["No se ha podido quitar la entrada."],"The entry could not be saved.":["No se ha podido guardar la entrada."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"entry cell\u0004No":["No"],"entry cell\u0004Yes":["Sí"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} diff --git a/plugins/fields/frontend/test/entries.test.tsx b/plugins/fields/frontend/test/entries.test.tsx index 748eccb..8e5cc05 100644 --- a/plugins/fields/frontend/test/entries.test.tsx +++ b/plugins/fields/frontend/test/entries.test.tsx @@ -36,6 +36,9 @@ const added = { data: { addContactFieldEntry: { id: ID3, comment: 'x' } } } /** removed is the body a successful removal answers. */ const removed = { data: { deleteContactFieldEntry: true } } +/** updated is the body a successful edit answers. */ +const updated = { data: { updateContactFieldEntry: { id: ID2, comment: 'Edited.' } } } + /** now is the moment the add form's dates start from. */ const now = new Date() @@ -90,6 +93,16 @@ async function removeEntry(name: string) { await userEvent.click(screen.getByRole('button', { name: 'Remove' })) } +/** + * Opens the editor of the named entry. + * @param name - The entry's name. + * @returns The editor form. + */ +async function editEntry(name: string) { + await userEvent.click(await screen.findByRole('button', { name: `Edit entry: ${name}` })) + return within(screen.getByRole('form', { name: `Edit entry: ${name}` })) +} + test('lists entries in the order the graph answers them', async () => { serveCatalogue([history]) serveValues({ history: [offerSent, firstCall] }) @@ -825,3 +838,275 @@ test('an entry holding only a second date is named by that detail line', async ( expect(await itemNames('Visits')).toEqual(['Follow up on: Oct 1, 2026']) }) + +test('Edit opens the entry prefilled in its own row', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + + expect(editor.getByLabelText('Date')).toHaveValue('2026-09-10') + const comment = editor.getByRole('textbox', { name: 'Comment' }) + expect(comment).toBeInstanceOf(HTMLTextAreaElement) + expect(comment).toHaveValue(offerSent.comment) + const item = within(screen.getByRole('listitem', { name: 'Sep 10, 2026' })) + expect(item.queryByRole('button', { name: /^(Edit|Remove) entry/ })).not.toBeInTheDocument() +}) + +test('Edit puts focus on the first cell', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + + await waitFor(() => expect(editor.getByLabelText('Date')).toHaveFocus()) +}) + +test('only one editor opens at a time', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + + renderPanel() + await editEntry('Sep 10, 2026') + await editEntry('Sep 1, 2026') + + const editors = screen.getAllByRole('form', { name: /^Edit entry/ }) + expect(editors).toHaveLength(1) + expect(within(editors[0]).getByLabelText('Date')).toHaveValue('2026-09-01') +}) + +test('Edit closes a removal waiting to be confirmed on another row', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + + renderPanel() + await userEvent.click(await screen.findByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + await editEntry('Sep 10, 2026') + + expect(screen.queryByRole('group', { name: 'Remove this entry?' })).not.toBeInTheDocument() +}) + +test('Cancel sends nothing and puts focus back on Edit entry', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + const updating = capture('UpdateContactFieldEntry', updated) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.type(editor.getByLabelText('Comment'), ' More.') + await userEvent.click(editor.getByRole('button', { name: 'Cancel' })) + + expect(screen.queryByRole('form', { name: /^Edit entry/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit entry: Sep 10, 2026' })).toHaveFocus() + expect(updating).not.toHaveBeenCalled() +}) + +test('Save stays off while every cell of the entry is blank', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + const updating = capture('UpdateContactFieldEntry', updated) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.clear(editor.getByLabelText('Date')) + await userEvent.clear(editor.getByLabelText('Comment')) + await userEvent.type(editor.getByLabelText('Comment'), ' ') + + expect(editor.getByRole('button', { name: 'Save entry' })).toHaveAttribute('aria-disabled', 'true') + await userEvent.type(editor.getByLabelText('Date'), '{Enter}') + await act(async () => {}) + expect(updating).not.toHaveBeenCalled() +}) + +test('saving sends the entry id and every typed cell', async () => { + serveCatalogue([visits]) + serveValues({ visits: [{ id: ID1, note: 'Called.', minutes: 30, paid: false }] }) + const updating = capture('UpdateContactFieldEntry', updated) + + renderPanel() + const editor = await editEntry('Called.') + await userEvent.clear(editor.getByLabelText('Minutes')) + await userEvent.type(editor.getByLabelText('Minutes'), '45') + await userEvent.click(editor.getByRole('checkbox', { name: 'Paid' })) + await userEvent.clear(editor.getByLabelText('Note')) + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + + await waitFor(() => expect(updating).toHaveBeenCalledOnce()) + expect(updating).toHaveBeenCalledWith({ + contactId: contactID, + field: 'visits', + entryId: ID1, + entry: { date: null, note: null, minutes: 45, paid: true, channel: null, followUpOn: null }, + }) +}) + +test('Enter in a cell during a save in flight sends one update', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + const updating = hold('UpdateContactFieldEntry', updated) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + await waitFor(() => expect(updating.called).toHaveBeenCalledOnce()) + await userEvent.type(editor.getByLabelText('Date'), '{Enter}') + updating.release() + + await waitFor(() => expect(screen.queryByRole('form', { name: /^Edit entry/ })).not.toBeInTheDocument()) + expect(updating.called).toHaveBeenCalledOnce() +}) + +test('a save in flight disables Save, Cancel and every other row', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + const updating = hold('UpdateContactFieldEntry', updated) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + await waitFor(() => expect(updating.called).toHaveBeenCalledOnce()) + + expect(editor.getByRole('button', { name: 'Save entry' })).toHaveAttribute('aria-disabled', 'true') + expect(editor.getByRole('button', { name: 'Cancel' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: 'Edit entry: Sep 1, 2026' })).toHaveAttribute('aria-disabled', 'true') + updating.release() +}) + +test('a saved entry closes the editor, reads the list again and puts focus on Edit entry', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + capture('UpdateContactFieldEntry', updated) + + const { graph } = renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + + await waitFor(() => expect(screen.queryByRole('form', { name: /^Edit entry/ })).not.toBeInTheDocument()) + expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) + await waitFor(() => expect(screen.getByRole('button', { name: 'Edit entry: Sep 10, 2026' })).toHaveFocus()) +}) + +test('a refused save keeps the editor and its draft', async () => { + speakTemplates() + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + capture('UpdateContactFieldEntry', refusal('VALIDATION', 'value_kind_mismatch')) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.type(editor.getByLabelText('Comment'), ' More.') + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('That value does not match the kind the field declares.') + expect(editor.getByLabelText('Comment')).toHaveValue(`${offerSent.comment} More.`) +}) + +test('a save answered as not found says so and passes focus on', async () => { + speakTemplates() + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + capture('UpdateContactFieldEntry', refusal('NOT_FOUND', 'field_entry_not_found')) + + const { graph } = renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('That entry no longer exists.') + expect(screen.queryByRole('form', { name: /^Edit entry/ })).not.toBeInTheDocument() + expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) + expect(screen.getByRole('button', { name: 'Edit entry: Sep 10, 2026' })).toHaveAttribute('aria-disabled', 'true') + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: 'Sep 1, 2026' }))).toHaveFocus()) +}) + +test('a save to a repeater archived elsewhere reads the catalogue again', async () => { + speakTemplates() + serveCatalogue([history, jobTitle]) + serveValues({ history: [offerSent], jobTitle: null }) + capture('UpdateContactFieldEntry', refusal('VALIDATION', 'field_unknown')) + + const { graph } = renderPanel() + const editor = await editEntry('Sep 10, 2026') + serveCatalogue([jobTitle]) + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + + await waitFor(() => expect(screen.queryByRole('group', { name: 'History' })).not.toBeInTheDocument()) + expect(graph.refetch).toHaveBeenCalledWith(['Fields']) + expect(screen.getByRole('alert')).toHaveTextContent('That field is not one this contact holds.') + expect(screen.getByRole('heading', { name: 'Fields' })).toHaveFocus() +}) + +test('a save that fails otherwise shows the fallback', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + capture('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be saved.') +}) + +test('a second save clears the last failure while it runs', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + capture('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + await screen.findByRole('alert') + const updating = hold('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + await waitFor(() => expect(updating.called).toHaveBeenCalledOnce()) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + updating.release() + expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be saved.') +}) + +test('the editor of a sub field named after a built-in member starts blank', async () => { + serveCatalogue([ + { + ...history, + subFields: [{ __typename: 'FieldSubField', name: 'constructor', label: 'Builder', kind: 'TEXT' }], + }, + ]) + serveValues({ history: [{ id: ID1 }] }) + + renderPanel() + const editor = await editEntry('Blank entry') + + expect(editor.getByLabelText('Builder')).toHaveValue('') +}) + +test('a removal puts focus on the next entry Edit entry', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + capture('DeleteContactFieldEntry', removed) + + renderPanel() + await removeEntry('Sep 10, 2026') + + await waitFor(() => expect(screen.getByRole('button', { name: 'Edit entry: Sep 1, 2026' })).toHaveFocus()) +}) + +test('an add answered while an editor is open leaves focus in the editor', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + const adding = hold('AddContactFieldEntry', added) + + renderPanel() + const comment = (await addForm()).getByLabelText('Comment') + await userEvent.type(comment, 'x') + await userEvent.click(addButton()) + await waitFor(() => expect(adding.called).toHaveBeenCalledOnce()) + const editor = await editEntry('Sep 10, 2026') + await userEvent.click(editor.getByLabelText('Comment')) + adding.release() + + await waitFor(() => expect(comment).toHaveValue('')) + expect(editor.getByLabelText('Comment')).toHaveFocus() +}) diff --git a/plugins/fields/frontend/useEntryActions.ts b/plugins/fields/frontend/useEntryActions.ts index baf5a8f..1f7c1ae 100644 --- a/plugins/fields/frontend/useEntryActions.ts +++ b/plugins/fields/frontend/useEntryActions.ts @@ -4,11 +4,12 @@ import { __, useGraph, useGraphMutation } from '@alphone/frontend-sdk' import type { GraphFailure } from '@alphone/frontend-sdk' import { useState } from 'react' -import { neighbour } from './entries' +import type { EntryText } from './cellText' +import { neighbour, typedEntry } from './entries' import type { RepeaterRow, StoredEntry } from './entries' import { entryMessage, outcomeOf, refetchAfter } from './entryOutcome' import { whenFocusStayed } from './focus' -import { deleteContactFieldEntryMutation } from './operations' +import { deleteContactFieldEntryMutation, updateContactFieldEntryMutation } from './operations' /** RowFocus names the button of one row focus moves to. */ export interface RowFocus { @@ -16,9 +17,12 @@ export interface RowFocus { on: 'first' | 'remove' | 'keep' } +/** RowMode is how one row shows. */ +type RowMode = 'show' | 'edit' | 'confirm' + /** RowState is how one row shows and what it answers to. */ export interface RowState { - mode: 'show' | 'confirm' + mode: RowMode pending: boolean locked: boolean focus: RowFocus | undefined @@ -26,6 +30,9 @@ export interface RowState { /** EntryHandlers are the actions the buttons of a row run. */ export interface EntryHandlers { + edit: (id: string) => void + cancel: (id: string) => void + save: (id: string, draft: EntryText) => Promise remove: (id: string) => void keep: (id: string) => void confirm: (id: string) => Promise @@ -57,12 +64,13 @@ export function useEntryActions({ entries: readonly StoredEntry[] onGone: GoneHandler }) { - const [open, setOpen] = useState('') + const [open, setOpen] = useState<{ id: string; as: 'edit' | 'confirm' } | null>(null) const [pendingID, setPendingID] = useState('') const [settling, setSettling] = useState([]) const [failure, setFailure] = useState('') const [rowFocus, setRowFocus] = useState(null) const [addFocus, setAddFocus] = useState(0) + const [, runUpdate] = useGraphMutation(updateContactFieldEntryMutation) const [, runDelete] = useGraphMutation(deleteContactFieldEntryMutation) const graph = useGraph() @@ -70,7 +78,7 @@ export function useEntryActions({ const leave = (id: string, from: Element | null) => { const next = neighbour(entries, id, settling) - setOpen('') + setOpen(null) setSettling((held) => [...held, id]) whenFocusStayed(from, () => (next === '' ? added() : setRowFocus({ id: next, on: 'first' }))) } @@ -96,12 +104,40 @@ export function useEntryActions({ } const on: EntryHandlers = { + edit: (id) => { + setOpen({ id, as: 'edit' }) + setFailure('') + }, + cancel: (id) => { + setOpen(null) + setRowFocus({ id, on: 'first' }) + }, + save: async (id, draft) => { + const from = document.activeElement + setFailure('') + setPendingID(id) + const result = await runUpdate({ + contactId, + field: field.name, + entryId: id, + entry: typedEntry(field.subFields, draft), + }) + setPendingID('') + settle(id, from, result.error, { + fallback: __('The entry could not be saved.', 'alphone-fields'), + done: () => { + setOpen(null) + whenFocusStayed(from, () => setRowFocus({ id, on: 'first' })) + }, + refused: () => {}, + }) + }, remove: (id) => { - setOpen(id) + setOpen({ id, as: 'confirm' }) setRowFocus({ id, on: 'keep' }) }, keep: (id) => { - setOpen('') + setOpen(null) setRowFocus({ id, on: 'remove' }) }, confirm: async (id) => { @@ -114,7 +150,7 @@ export function useEntryActions({ fallback: __('The entry could not be removed.', 'alphone-fields'), done: () => leave(id, from), refused: () => { - setOpen('') + setOpen(null) whenFocusStayed(from, () => setRowFocus({ id, on: 'remove' })) }, }) @@ -122,7 +158,7 @@ export function useEntryActions({ } const rowOf = (id: string): RowState => ({ - mode: open === id ? 'confirm' : 'show', + mode: open?.id === id ? open.as : 'show', pending: pendingID === id, locked: pendingID !== '' || settling.includes(id), focus: rowFocus?.id === id ? rowFocus : undefined, diff --git a/plugins/fields/languages/alphone-fields.pot b/plugins/fields/languages/alphone-fields.pot index c96a890..3e089d6 100644 --- a/plugins/fields/languages/alphone-fields.pot +++ b/plugins/fields/languages/alphone-fields.pot @@ -61,6 +61,15 @@ msgstr "" msgid "Blank entry" msgstr "" +msgid "Cancel" +msgstr "" + +msgid "Edit entry" +msgstr "" + +msgid "Edit entry: %(name)s" +msgstr "" + msgid "Fields" msgstr "" @@ -115,6 +124,9 @@ msgstr "" msgid "Remove this entry?" msgstr "" +msgid "Save entry" +msgstr "" + msgid "Save fields" msgstr "" @@ -157,6 +169,9 @@ msgstr "" msgid "The entry could not be removed." msgstr "" +msgid "The entry could not be saved." +msgstr "" + msgid "The field could not be archived." msgstr "" diff --git a/plugins/fields/languages/es-ES.po b/plugins/fields/languages/es-ES.po index 592bfc5..9819a45 100644 --- a/plugins/fields/languages/es-ES.po +++ b/plugins/fields/languages/es-ES.po @@ -80,6 +80,18 @@ msgstr "Archivar %(label)s" msgid "Blank entry" msgstr "Entrada en blanco" +#, fuzzy +msgid "Cancel" +msgstr "Cancelar" + +#, fuzzy +msgid "Edit entry" +msgstr "Editar la entrada" + +#, fuzzy +msgid "Edit entry: %(name)s" +msgstr "Editar la entrada: %(name)s" + #, fuzzy msgid "Fields" msgstr "Campos" @@ -152,6 +164,10 @@ msgstr "Quitar el subcampo" msgid "Remove this entry?" msgstr "¿Quitar esta entrada?" +#, fuzzy +msgid "Save entry" +msgstr "Guardar la entrada" + #, fuzzy msgid "Save fields" msgstr "Guardar los campos" @@ -208,6 +224,10 @@ msgstr "No se ha podido añadir la entrada." msgid "The entry could not be removed." msgstr "No se ha podido quitar la entrada." +#, fuzzy +msgid "The entry could not be saved." +msgstr "No se ha podido guardar la entrada." + #, fuzzy msgid "The field could not be archived." msgstr "No se ha podido archivar el campo." From d12c6d9484bcf87f760690a4d6b4bdaa1699d258 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 16:51:01 +0200 Subject: [PATCH 33/48] test(e2e): edit a contact history entry in place --- test/e2e/tests/fields-repeater.spec.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/e2e/tests/fields-repeater.spec.ts b/test/e2e/tests/fields-repeater.spec.ts index e236ce3..06a25be 100644 --- a/test/e2e/tests/fields-repeater.spec.ts +++ b/test/e2e/tests/fields-repeater.spec.ts @@ -69,12 +69,23 @@ test('defines a repeater and keeps a contact history in order', async ({ page }) await history.getByRole('button', { name: 'Remove', exact: true }).click() await removed + await history.getByRole('button', { name: 'Edit entry: Sep 1, 2026' }).click() + const editor = history.getByRole('form', { name: 'Edit entry: Sep 1, 2026' }) + const edited = editor.getByRole('textbox', { name: 'Comment', exact: true }) + await expect(edited).toHaveJSProperty('tagName', 'TEXTAREA') + await edited.fill('First call about the yearly plan.\nAsked for a second quote.') + const saved = operationAnswer(page, 'UpdateContactFieldEntry') + await editor.getByRole('button', { name: 'Save entry' }).click() + await saved + await page.reload() await expect(entries).toHaveCount(2) await expect(entries.nth(0)).toHaveAccessibleName('Sep 18, 2026') await expect(entries.nth(1)).toHaveAccessibleName('Sep 1, 2026') const body = entries.nth(1).locator('p.godmin-log-list__body') - expect(await body.evaluate((node) => (node as HTMLElement).innerText)).toContain('\n') + expect(await body.evaluate((node) => (node as HTMLElement).innerText)).toBe( + 'First call about the yearly plan.\nAsked for a second quote.', + ) }) /** From 72c06913c736c733960c58f87e23e17ddc85ddde Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:55 +0200 Subject: [PATCH 34/48] test(e2e): keep a contact history one entry at a time --- test/e2e/tests/fields-repeater.spec.ts | 29 +++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/test/e2e/tests/fields-repeater.spec.ts b/test/e2e/tests/fields-repeater.spec.ts index 06a25be..9691c99 100644 --- a/test/e2e/tests/fields-repeater.spec.ts +++ b/test/e2e/tests/fields-repeater.spec.ts @@ -30,10 +30,11 @@ async function addSubField(page: Page, at: number, label: string, kind: string) await chooseKind(page, row.getByRole('combobox', { name: 'Kind' }), kind) } -test('defines a repeater and keeps a contact history in order', async ({ page }) => { +test('defines a repeater and keeps a contact history one entry at a time', async ({ page }) => { const stamp = Date.now() const label = `History ${stamp}` const contact = `Customer ${stamp}` + const sent = operationsSent(page) await page.goto('/') await page.getByRole('link', { name: 'Fields' }).click() @@ -86,8 +87,34 @@ test('defines a repeater and keeps a contact history in order', async ({ page }) expect(await body.evaluate((node) => (node as HTMLElement).innerText)).toBe( 'First call about the yearly plan.\nAsked for a second quote.', ) + const days = history.locator('time') + await expect(days).toHaveCount(2) + expect(await days.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('datetime')))).toEqual([ + '2026-09-18', + '2026-09-01', + ]) + expect(sent).toEqual( + expect.arrayContaining(['AddContactFieldEntry', 'DeleteContactFieldEntry', 'UpdateContactFieldEntry']), + ) + expect(sent).not.toContain('WriteContactFields') }) +/** + * Collects the name of every operation the page sends to the graph. + * @param page - The page sending the operations. + * @returns The operation names, growing as the page sends them. + */ +function operationsSent(page: Page): string[] { + const names: string[] = [] + page.on('request', (request) => { + if (request.method() === 'POST' && request.url().includes('/api/graphql')) { + const body = request.postDataJSON() as { operationName?: string } | null + names.push(body?.operationName ?? '') + } + }) + return names +} + /** * Waits for the graph to answer the named operation. * @param page - The page sending the operation. From 9ceb619aa38a44d78e9c6fe2707efa32166a1822 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:55 +0200 Subject: [PATCH 35/48] docs(fields): document repeater entries kept one at a time --- docs/src/content/docs/guides/fields.md | 120 +++++++++++++----- .../src/content/docs/reference/graphql-api.md | 11 +- .../docs/self-hosting/configuration.md | 2 +- 3 files changed, 92 insertions(+), 41 deletions(-) diff --git a/docs/src/content/docs/guides/fields.md b/docs/src/content/docs/guides/fields.md index 4a0129c..9114742 100644 --- a/docs/src/content/docs/guides/fields.md +++ b/docs/src/content/docs/guides/fields.md @@ -37,8 +37,7 @@ filled in. ## Fill a field in Open a contact. The **Fields** section sits beside the tasks, or under them -on a narrow screen, with one input per field you created. Type, then press -**Save fields**. +on a narrow screen. Type into a field, then press **Save fields**. A Long text field is a box several lines tall. Press Enter to start a new line. The text keeps its line breaks when you save. @@ -66,19 +65,25 @@ the same label get two names, such as `note` and `note2`. A sub field labelled The sub fields cannot be changed once the repeater exists. The reason is the same as for the kind: old entries would no longer fit. -On a contact, a repeater lists its entries one under the other. +On a contact, each repeater shows under its own heading. A form to add an +entry sits on top. The entries follow, the last one added first. Each date in +the form starts on today's date. -- Press **Add an entry to History**, with your repeater's label in place of - History, to add an entry at the end. -- Use the arrows beside an entry to move it up or down. -- Press **Remove entry** to take one away. +- Fill in the form and press **Add an entry to History**, with your + repeater's label in place of History. The button stays off until you fill + in a part yourself. Spaces alone do not count, and neither does the date + the form starts with. +- Press **Edit entry** to change an entry in place, then **Save entry** or + **Cancel**. **Save entry** stays off while every part is blank. +- Press **Remove entry**, and the row asks **Remove this entry?** Press + **Remove** to confirm or **Keep** to leave it. -Then press **Save fields**. Saving stores the whole list, in the order you -see it. An entry you leave completely empty is dropped when you save. +Each add, save and removal is stored at once. **Save fields** saves only the +other fields, and it is hidden when there are none. Each change touches only +its own entry. If two people edit the same entry, the last save wins. -Saving replaces the list that was stored before. If two people change the -same list on two open pages, the second save wins and the first person's -changes are lost. +A list holds at most 500 entries by default. See +[Configuration](/self-hosting/configuration/#fields-plugin) to change the cap. ## Fill a field from a spreadsheet @@ -140,7 +145,7 @@ API tools and AI agents discover it on their own. A field belongs to the workspace that created it. Callers in another workspace never see it, not in the API and not in introspection. -Writing values goes through one mutation: +Writing values goes through `writeContactFields`: ```graphql mutation { @@ -158,28 +163,9 @@ is cleared. A `Number` field holds a whole number between -2147483648 and 2147483647. Anything outside that is refused, because the API answers it as an `Int`. -A repeater reads and writes as a list of entries. Each entry is an object -keyed by sub field name, and the API types the whole list as `JSON`: - -```graphql -mutation { - writeContactFields( - contactId: "0198c000-0000-7000-8000-000000000401" - values: { - history: [ - { date: "2026-09-01", comment: "First call about the yearly plan." } - { date: "2026-09-10", comment: "Sent the offer." } - ] - } - ) -} -``` - -Sending a repeater replaces its whole list. An empty list or `null` clears -it. Every cell is checked against its sub field's kind, and a refusal names -the cell by its place in the list, counting from 0, such as -`history[0].date expects DATE`. A key that no sub field holds is refused the -same way, such as `history[1].mood`. +`writeContactFields` refuses any value for a repeater, even `null`, with the +reason `field_repeater_entries_only`, and stores nothing from that request. A +repeater takes its entries one at a time, as shown below. To define a repeater from the API, pass its sub fields to `defineField`: @@ -206,6 +192,70 @@ The API does not make sub field names for you. Send a camelCase name for each one, unique inside the repeater. `id` is refused, because each entry keeps its own id under that name. +A repeater reads as a list of entries typed `JSON`, the last one added first. +Each entry is an object keyed by sub field name, with the `id` AlphOne gave +it. A repeater with no entries reads `null`. Ask for `history` the same way as +`birthDate`, and the answer looks like this: + +```json +{ + "data": { + "contact": { + "history": [ + { + "id": "0198c000-0000-7000-8000-000000000501", + "date": "2026-09-10", + "comment": "Sent the offer." + } + ] + } + } +} +``` + +`addContactFieldEntry` puts one entry at the top of the list: + +```graphql +mutation { + addContactFieldEntry( + contactId: "0198c000-0000-7000-8000-000000000401" + field: "history" + entry: { date: "2026-09-10", comment: "Sent the offer." } + ) +} +``` + +It answers the stored entry with its new `id`. Every cell is checked against +its sub field's kind, and a refusal names the cell, such as +`history.date expects DATE`. A key that no sub field holds is refused and +named too, such as `history.mood`. So is `id`. An entry whose cells are all +blank is refused with `field_entry_empty`. An add to a full list is refused +with `field_entries_full`, and its `meta.max` names the cap. + +`updateContactFieldEntry` changes the entry that `entryId` names: + +```graphql +mutation { + updateContactFieldEntry( + contactId: "0198c000-0000-7000-8000-000000000401" + field: "history" + entryId: "0198c000-0000-7000-8000-000000000501" + entry: { date: "2026-09-10", comment: "Sent the offer by email." } + ) +} +``` + +The entry you send replaces all its cells, so a cell you leave out is dropped. +The cells get the same checks as an add, but you may send the entry's own `id` +back. The entry keeps its id and its place, and the answer is the entry as +stored. + +`deleteContactFieldEntry` takes the same `contactId`, `field` and `entryId`, +and answers `true`. An update or a removal naming an id the list does not +hold is refused with `field_entry_not_found`. See +[GraphQL API](/reference/graphql-api/#reasons) for where a refusal carries its +reason and `meta`. + ## What stays fixed A contact's **name** is not a field you can archive or rename away. It is what diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index edd377b..41f053d 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -308,7 +308,7 @@ Every error carries a `code` in its `extensions`. | `UNAUTHORIZED` | The caller does not reach the field. `scope required` means the token lacks the scope `scope` names, `admin required` means the account's role holds no capability the field needs | | `VALIDATION` | The input was refused. `message` names the field or rule | | `NOT_FOUND` | The id names nothing | -| `CONFLICT` | An identity is already claimed. `ownerContactId` names the owner | +| `CONFLICT` | The write clashes with what is stored, such as an identity another contact holds or a full list. For an identity, `ownerContactId` names the owner | | `RATE_LIMITED` | Too many attempts. `retryAfter` is in seconds | | `COMPLEXITY_LIMIT_EXCEEDED` | The query asks for too much, see limits below | | `INTERNAL` | AlphOne failed. The message is deliberately bare | @@ -419,17 +419,18 @@ The stock plugins add their own: | `field_sub_field_nested` | | a sub field cannot be a repeater | | `field_sub_field_name_invalid` | | a sub field name is camelCase | | `field_sub_field_name_taken` | | two sub fields of one repeater share a name | -| `field_sub_field_name_reserved` | | a sub field is named `id`, which each entry keeps for itself | +| `field_sub_field_name_reserved` | | a sub field is named `id`, which holds each entry's own id | | `field_name_taken` | | another definition holds the name | | `field_kind_locked` | | an archived definition pins the kind and sub fields | | `field_not_found` | | the id names no live definition | -| `field_unknown` | | no live definition holds the name | +| `field_unknown` | | no live definition or sub field holds the name | | `value_kind_mismatch` | | the value does not match the declared kind | | `values_not_an_object` | | values arrive as an object of names | | `field_not_a_repeater` | | the field keeps no list of entries | | `field_entry_empty` | | every cell of the entry is blank | -| `field_entry_not_found` | | no entry of that id is in that field of the contact | -| `field_entries_full` | `max` | the list already holds the most entries it may | +| `field_entry_not_found` | | the id names no entry in that field of the contact | +| `field_entries_full` | `max` | the list already holds the [most entries](/self-hosting/configuration/#fields-plugin) it may | +| `field_repeater_entries_only` | | a repeater takes its entries one at a time, never through `writeContactFields` | | `message_content_required` | | a message needs text | | `conversation_not_found` | | the id names no conversation | | `upstream_failed` | | the messaging platform did not accept | diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index fdf2867..cf9fe15 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -29,7 +29,7 @@ database is all an upgrade takes. | Variable | Purpose | | --- | --- | -| `ALPHONE_FIELDS_ENTRIES_MAX` | The most entries one repeater field holds on a contact. Defaults to 500. An add past the cap is refused, and AlphOne will not start if the value is not a whole number above zero. | +| `ALPHONE_FIELDS_ENTRIES_MAX` | The most entries one repeater field holds on a contact. Defaults to 500. An add past the cap is refused, and AlphOne will not start if the value is not a whole number between 1 and 2147483647. | ## WhatsApp plugin From 3ce4b8819ff985192f9d73ed7a13edddac01ad6e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:56 +0200 Subject: [PATCH 36/48] fix(fields): refuse a repeater first when writing contact fields --- plugins/fields/value.go | 20 ++++++++++++++++---- plugins/fields/value_internal_test.go | 12 ++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/plugins/fields/value.go b/plugins/fields/value.go index 24c69a4..885619c 100644 --- a/plugins/fields/value.go +++ b/plugins/fields/value.go @@ -146,8 +146,11 @@ func coerceDate(given any) (any, error) { return text, nil } -// checkValues returns the storable values the view allows, refusing unknown keys, wrong kinds and repeaters. +// checkValues returns the storable values the view allows, refusing repeaters, unknown keys and wrong kinds. func checkValues(live *view, given map[string]any) (map[string]any, error) { + if repeaters := repeatersIn(live, given); len(repeaters) > 0 { + return nil, fmt.Errorf("%w: %s", errRepeaterEntriesOnly, strings.Join(repeaters, ", ")) + } var unknown []string checked := make(map[string]any, len(given)) for name, value := range given { @@ -156,9 +159,6 @@ func checkValues(live *view, given map[string]any) (map[string]any, error) { unknown = append(unknown, name) continue } - if held == kindRepeater { - return nil, fmt.Errorf("%w: %s", errRepeaterEntriesOnly, name) - } coerced, err := coerce(held, value) if err != nil { return nil, fmt.Errorf("%w: %s expects %s", errWrongKind, name, held) @@ -172,6 +172,18 @@ func checkValues(live *view, given map[string]any) (map[string]any, error) { return checked, nil } +// repeatersIn returns the given names the view holds as repeaters, sorted. +func repeatersIn(live *view, given map[string]any) []string { + var named []string + for name := range given { + if live.kinds[name] == kindRepeater { + named = append(named, name) + } + } + sort.Strings(named) + return named +} + // checkRow returns the storable cells of one entry and the paths of the cells no column holds. func checkRow(path string, kindsOf map[string]kind, cells map[string]any) (map[string]any, []string, error) { var unknown []string diff --git a/plugins/fields/value_internal_test.go b/plugins/fields/value_internal_test.go index bf24875..87ac889 100644 --- a/plugins/fields/value_internal_test.go +++ b/plugins/fields/value_internal_test.go @@ -257,6 +257,18 @@ func TestCheckValuesRefusesARepeater(t *testing.T) { } } +func TestCheckValuesRefusesARepeaterBeforeAWrongKind(t *testing.T) { + t.Parallel() + + for range 50 { + _, err := checkValues(historyView(), map[string]any{"birthDate": "not a date", "history": nil}) + + if !errors.Is(err, errRepeaterEntriesOnly) { + t.Fatalf("checkValues() error = %v, want the repeater refused whatever the key order", err) + } + } +} + // historyColumnsOf returns the sub fields of the history repeater the value tests hold. func historyColumnsOf() []SubField { return historyView().columns["history"] From 2e4e35fe88ada89b0af0cdb1b5d26e785ea886fa Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:56 +0200 Subject: [PATCH 37/48] fix(fields): give stored entries their ids on Postgres before 18 --- plugins/fields/migrations/00006_add_repeater_entry_ids.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/fields/migrations/00006_add_repeater_entry_ids.sql b/plugins/fields/migrations/00006_add_repeater_entry_ids.sql index 6642ae2..06cd9e0 100644 --- a/plugins/fields/migrations/00006_add_repeater_entry_ids.sql +++ b/plugins/fields/migrations/00006_add_repeater_entry_ids.sql @@ -8,7 +8,7 @@ UPDATE plugin_fields.contact_values AS held SET values = held.values || rebuilt.lists FROM ( SELECT v.tenant_id, v.contact_id, jsonb_object_agg(d.name, ( - SELECT jsonb_agg(listed.entry || jsonb_build_object('id', uuidv7()) ORDER BY listed.at DESC) + SELECT jsonb_agg(listed.entry || jsonb_build_object('id', gen_random_uuid()) ORDER BY listed.at DESC) FROM jsonb_array_elements(v.values -> d.name) WITH ORDINALITY AS listed (entry, at) )) AS lists FROM plugin_fields.contact_values AS v From 970cee4f0c72c7b9aae19c827a3ebd9aada2b66c Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:56 +0200 Subject: [PATCH 38/48] fix(fields): lock an entry's cells while it is stored --- plugins/fields/frontend/EntryRow.tsx | 1 + plugins/fields/frontend/RepeaterEntries.tsx | 1 + plugins/fields/frontend/cells.tsx | 12 ++++++++-- plugins/fields/frontend/test/entries.test.tsx | 22 ++++++++++++++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/plugins/fields/frontend/EntryRow.tsx b/plugins/fields/frontend/EntryRow.tsx index 854a8cc..6f16d00 100644 --- a/plugins/fields/frontend/EntryRow.tsx +++ b/plugins/fields/frontend/EntryRow.tsx @@ -219,6 +219,7 @@ function EntryEditor({ setDraft((held) => ({ ...held, [key]: text }))} /> diff --git a/plugins/fields/frontend/RepeaterEntries.tsx b/plugins/fields/frontend/RepeaterEntries.tsx index 2fc758b..1b88342 100644 --- a/plugins/fields/frontend/RepeaterEntries.tsx +++ b/plugins/fields/frontend/RepeaterEntries.tsx @@ -134,6 +134,7 @@ function AddEntryForm({ setPicked((held) => new Map(held).set(name, text))} /> {add.error && outcomeOf(add.error) !== 'field-gone' ? ( diff --git a/plugins/fields/frontend/cells.tsx b/plugins/fields/frontend/cells.tsx index b3380ca..3aa60ca 100644 --- a/plugins/fields/frontend/cells.tsx +++ b/plugins/fields/frontend/cells.tsx @@ -6,16 +6,18 @@ import type { EntryText, SubFieldRow } from './cellText' /** * Renders one input per sub field of an entry. - * @param props - The sub fields, the text of every cell and the change handler. + * @param props - The sub fields, the text of every cell, whether the cells are locked and the change handler. * @returns The cell inputs. */ export function EntryCells({ subFields, draft, + disabled, onChange, }: { subFields: SubFieldRow[] draft: EntryText + disabled: boolean onChange: (name: string, text: string) => void }) { return subFields.map((column) => ( @@ -23,6 +25,7 @@ export function EntryCells({ key={column.name} field={column} value={draft[column.name]} + disabled={disabled} onChange={(next) => onChange(column.name, next)} /> )) @@ -30,16 +33,18 @@ export function EntryCells({ /** * Renders one field's input, matched to the kind its definition declares. - * @param props - The field, its current text and the change handler. + * @param props - The field, its current text, whether the input is locked and the change handler. * @returns The field input. */ export function FieldInput({ field, value, + disabled, onChange, }: { field: { label: string; kind: string } value: string + disabled?: boolean onChange: (next: string) => void }) { if (field.kind === 'BOOLEAN') { @@ -48,6 +53,7 @@ export function FieldInput({ onChange(checked ? 'true' : 'false')} /> {field.label} @@ -59,6 +65,7 @@ export function FieldInput({ onChange(event.target.value)} /> ) @@ -68,6 +75,7 @@ export function FieldInput({ label={field.label} type={inputType(field.kind)} value={value} + disabled={disabled} onChange={(event) => onChange(event.target.value)} /> ) diff --git a/plugins/fields/frontend/test/entries.test.tsx b/plugins/fields/frontend/test/entries.test.tsx index 8e5cc05..cd01ef4 100644 --- a/plugins/fields/frontend/test/entries.test.tsx +++ b/plugins/fields/frontend/test/entries.test.tsx @@ -397,6 +397,24 @@ test('Add shows it is busy while the add runs', async () => { await waitFor(() => expect(addButton()).toHaveAttribute('aria-disabled', 'true')) }) +test('the add form locks its cells while the add runs', async () => { + serveCatalogue([visits]) + serveValues({ visits: null }) + const adding = hold('AddContactFieldEntry', added) + + renderPanel() + const form = await addForm('Visits') + await userEvent.type(form.getByLabelText('Note'), 'Paid in cash.') + await userEvent.click(addButton('Visits')) + await waitFor(() => expect(adding.called).toHaveBeenCalledOnce()) + + expect(form.getByLabelText('Note')).toBeDisabled() + expect(form.getByLabelText('Minutes')).toBeDisabled() + expect(form.getByRole('checkbox', { name: 'Paid' })).toHaveAttribute('aria-disabled', 'true') + adding.release() + await waitFor(() => expect(form.getByLabelText('Note')).toBeEnabled()) +}) + test('an added entry resets the form to today and shows on top', async () => { serveCatalogue([history]) const change = serveChangingValues({ history: [firstCall] }) @@ -958,7 +976,7 @@ test('Enter in a cell during a save in flight sends one update', async () => { expect(updating.called).toHaveBeenCalledOnce() }) -test('a save in flight disables Save, Cancel and every other row', async () => { +test('a save in flight locks its cells, Save, Cancel and every other row', async () => { serveCatalogue([history]) serveValues({ history: [offerSent, firstCall] }) const updating = hold('UpdateContactFieldEntry', updated) @@ -968,6 +986,8 @@ test('a save in flight disables Save, Cancel and every other row', async () => { await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) await waitFor(() => expect(updating.called).toHaveBeenCalledOnce()) + expect(editor.getByLabelText('Date')).toBeDisabled() + expect(editor.getByLabelText('Comment')).toBeDisabled() expect(editor.getByRole('button', { name: 'Save entry' })).toHaveAttribute('aria-disabled', 'true') expect(editor.getByRole('button', { name: 'Cancel' })).toHaveAttribute('aria-disabled', 'true') expect(screen.getByRole('button', { name: 'Edit entry: Sep 1, 2026' })).toHaveAttribute('aria-disabled', 'true') From 99cf480193f88b8542a1aac5ed4901e7ff7f5361 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:57 +0200 Subject: [PATCH 39/48] fix(fields): clear an entry failure when the next action starts --- plugins/fields/frontend/RepeaterEntries.tsx | 6 +- plugins/fields/frontend/test/entries.test.tsx | 57 +++++++++++++++++++ plugins/fields/frontend/useEntryActions.ts | 11 +++- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/plugins/fields/frontend/RepeaterEntries.tsx b/plugins/fields/frontend/RepeaterEntries.tsx index 1b88342..6c1db72 100644 --- a/plugins/fields/frontend/RepeaterEntries.tsx +++ b/plugins/fields/frontend/RepeaterEntries.tsx @@ -19,7 +19,7 @@ import { draftCells, entriesOf, holdsInput, localDay, typedEntry } from './entri import type { RepeaterRow } from './entries' import { entryMessage, outcomeOf, refetchAfter } from './entryOutcome' import { EntryRow } from './EntryRow' -import { focusFirstControl, whenFocusStayed } from './focus' +import { focusFirstControl } from './focus' import { addContactFieldEntryMutation } from './operations' import { useEntryActions } from './useEntryActions' import type { GoneHandler } from './useEntryActions' @@ -91,7 +91,7 @@ function AddEntryForm({ contactId: string field: RepeaterRow focusCount: number - onAdded: () => void + onAdded: (from: Element | null) => void onGone: GoneHandler }) { const [picked, setPicked] = useState>(() => new Map()) @@ -115,7 +115,7 @@ function AddEntryForm({ const outcome = outcomeOf(result.error) if (outcome === 'done') { setPicked(new Map()) - whenFocusStayed(from, onAdded) + onAdded(from) } else if (outcome === 'field-gone') { onGone(entryMessage(result.error as GraphFailure, fallback), from) } diff --git a/plugins/fields/frontend/test/entries.test.tsx b/plugins/fields/frontend/test/entries.test.tsx index cd01ef4..4679332 100644 --- a/plugins/fields/frontend/test/entries.test.tsx +++ b/plugins/fields/frontend/test/entries.test.tsx @@ -755,6 +755,49 @@ test('a second removal clears the last failure while it runs', async () => { expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be removed.') }) +test('asking to remove an entry clears an earlier failure', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + await removeEntry('Sep 1, 2026') + await screen.findByRole('alert') + await userEvent.click(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) + +test('Edit clears an earlier failure', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + await removeEntry('Sep 1, 2026') + await screen.findByRole('alert') + await editEntry('Sep 1, 2026') + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) + +test('an added entry clears an earlier failure', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) + capture('AddContactFieldEntry', added) + + renderPanel() + await removeEntry('Sep 1, 2026') + await screen.findByRole('alert') + const comment = (await addForm()).getByLabelText('Comment') + await userEvent.type(comment, 'x') + await userEvent.click(addButton()) + + await waitFor(() => expect(comment).toHaveValue('')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) + test('a removal from a repeater archived elsewhere reads the catalogue again', async () => { speakTemplates() serveCatalogue([history, jobTitle]) @@ -1069,6 +1112,20 @@ test('a save that fails otherwise shows the fallback', async () => { expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be saved.') }) +test('Cancel clears the failure of a refused save', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + capture('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + const editor = await editEntry('Sep 10, 2026') + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + await screen.findByRole('alert') + await userEvent.click(editor.getByRole('button', { name: 'Cancel' })) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) + test('a second save clears the last failure while it runs', async () => { serveCatalogue([history]) serveValues({ history: [offerSent] }) diff --git a/plugins/fields/frontend/useEntryActions.ts b/plugins/fields/frontend/useEntryActions.ts index 1f7c1ae..5ccae21 100644 --- a/plugins/fields/frontend/useEntryActions.ts +++ b/plugins/fields/frontend/useEntryActions.ts @@ -74,13 +74,18 @@ export function useEntryActions({ const [, runDelete] = useGraphMutation(deleteContactFieldEntryMutation) const graph = useGraph() - const added = () => setAddFocus((count) => count + 1) + const focusAdd = () => setAddFocus((count) => count + 1) + + const added = (from: Element | null) => { + setFailure('') + whenFocusStayed(from, focusAdd) + } const leave = (id: string, from: Element | null) => { const next = neighbour(entries, id, settling) setOpen(null) setSettling((held) => [...held, id]) - whenFocusStayed(from, () => (next === '' ? added() : setRowFocus({ id: next, on: 'first' }))) + whenFocusStayed(from, () => (next === '' ? focusAdd() : setRowFocus({ id: next, on: 'first' }))) } const settle = (id: string, from: Element | null, error: GraphFailure | undefined, steps: SettleSteps) => { @@ -110,6 +115,7 @@ export function useEntryActions({ }, cancel: (id) => { setOpen(null) + setFailure('') setRowFocus({ id, on: 'first' }) }, save: async (id, draft) => { @@ -134,6 +140,7 @@ export function useEntryActions({ }, remove: (id) => { setOpen({ id, as: 'confirm' }) + setFailure('') setRowFocus({ id, on: 'keep' }) }, keep: (id) => { From 911dfb904e65739cfcfb2e570fd3d7e18f355d7a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:57 +0200 Subject: [PATCH 40/48] fix(fields): name an entry by its first line of words --- plugins/fields/frontend/entries.ts | 10 ++++++++-- plugins/fields/frontend/test/entries.test.tsx | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/plugins/fields/frontend/entries.ts b/plugins/fields/frontend/entries.ts index f753a4f..16da5f9 100644 --- a/plugins/fields/frontend/entries.ts +++ b/plugins/fields/frontend/entries.ts @@ -110,13 +110,19 @@ export function entryParts(subFields: SubFieldRow[], cells: Record line.trim() !== '') + ?.trim() ?? + parts.lines[0]?.text ?? + __('Blank entry', 'alphone-fields') ) } diff --git a/plugins/fields/frontend/test/entries.test.tsx b/plugins/fields/frontend/test/entries.test.tsx index 4679332..c5e8097 100644 --- a/plugins/fields/frontend/test/entries.test.tsx +++ b/plugins/fields/frontend/test/entries.test.tsx @@ -900,6 +900,15 @@ test('an entry holding only a second date is named by that detail line', async ( expect(await itemNames('Visits')).toEqual(['Follow up on: Oct 1, 2026']) }) +test('an entry whose text starts on blank lines is named by its first line of words', async () => { + serveCatalogue([history]) + serveValues({ history: [{ id: ID1, comment: '\n \nCalled back.\nLeft a message.' }] }) + + renderPanel() + + expect(await itemNames()).toEqual(['Called back.']) +}) + test('Edit opens the entry prefilled in its own row', async () => { serveCatalogue([history]) serveValues({ history: [offerSent, firstCall] }) From 9add0410a22cfff206d1ad5c03663059d35713f4 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:58 +0200 Subject: [PATCH 41/48] test(fields): tighten the entry tests and fix their clock --- plugins/fields/frontend/test/entries.test.tsx | 40 ++++++++++++++----- plugins/fields/frontend/test/harness.tsx | 2 +- plugins/fields/frontend/test/panel.test.tsx | 2 +- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/plugins/fields/frontend/test/entries.test.tsx b/plugins/fields/frontend/test/entries.test.tsx index c5e8097..6b4d648 100644 --- a/plugins/fields/frontend/test/entries.test.tsx +++ b/plugins/fields/frontend/test/entries.test.tsx @@ -4,7 +4,7 @@ import { configureErrorText } from '@alphone/frontend-sdk' import { HttpResponse, graphql, server } from '@alphone/frontend-sdk/testing' import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { ID1, @@ -39,17 +39,23 @@ const removed = { data: { deleteContactFieldEntry: true } } /** updated is the body a successful edit answers. */ const updated = { data: { updateContactFieldEntry: { id: ID2, comment: 'Edited.' } } } -/** now is the moment the add form's dates start from. */ -const now = new Date() +/** NOW is the moment every test starts at. */ +const NOW = new Date('2026-09-27T12:00:00Z') /** today is the local calendar day the add form starts its dates on. */ const today = [ - String(now.getFullYear()).padStart(4, '0'), - String(now.getMonth() + 1).padStart(2, '0'), - String(now.getDate()).padStart(2, '0'), + String(NOW.getFullYear()).padStart(4, '0'), + String(NOW.getMonth() + 1).padStart(2, '0'), + String(NOW.getDate()).padStart(2, '0'), ].join('-') +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'], shouldAdvanceTime: true }) + vi.setSystemTime(NOW) +}) + afterEach(() => { + vi.useRealTimers() configureErrorText({ templates: () => ({}), fallback: () => '' }) vi.unstubAllEnvs() }) @@ -393,8 +399,9 @@ test('Add shows it is busy while the add runs', async () => { await waitFor(() => expect(adding.called).toHaveBeenCalledOnce()) expect(addButton()).toHaveAttribute('aria-disabled', 'true') + expect(addButton().className).toMatch(/is-loading/) adding.release() - await waitFor(() => expect(addButton()).toHaveAttribute('aria-disabled', 'true')) + await waitFor(() => expect(addButton().className).not.toMatch(/is-loading/)) }) test('the add form locks its cells while the add runs', async () => { @@ -526,6 +533,20 @@ test('an add to a repeater archived elsewhere reads the catalogue again', async expect(screen.getByRole('heading', { name: 'Fields' })).toHaveFocus() }) +test('an add refused as a repeater gone shows one notice', async () => { + speakTemplates() + serveCatalogue([history, jobTitle]) + serveValues({ history: null, jobTitle: null }) + capture('AddContactFieldEntry', refusal('VALIDATION', 'field_unknown')) + + renderPanel() + await userEvent.type((await addForm()).getByLabelText('Comment'), 'x') + await userEvent.click(addButton()) + + await waitFor(() => expect(screen.getByRole('heading', { name: 'Fields' })).toHaveFocus()) + expect(screen.getAllByRole('alert')).toHaveLength(1) +}) + test('an add that fails otherwise shows the fallback', async () => { serveCatalogue([history]) serveValues({ history: null }) @@ -868,7 +889,7 @@ test('a draft stays with the contact it was typed on', async () => { }) test('entries wait for the stored values before they show', async () => { - serveCatalogue([history]) + serveCatalogue([history, jobTitle]) let release = () => {} const answered = new Promise((resolve) => { release = resolve @@ -877,7 +898,7 @@ test('entries wait for the stored values before they show', async () => { graphql.query('ContactFieldValues', async () => { await answered return HttpResponse.json({ - data: { contact: { __typename: 'Contact', id: contactID, history: [firstCall] } }, + data: { contact: { __typename: 'Contact', id: contactID, history: [firstCall], jobTitle: null } }, }) }), ) @@ -889,6 +910,7 @@ test('entries wait for the stored values before they show', async () => { expect(screen.queryByRole('button', { name: 'Save fields' })).not.toBeInTheDocument() release() expect(await screen.findByRole('listitem', { name: 'Sep 1, 2026' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save fields' })).toBeInTheDocument() }) test('an entry holding only a second date is named by that detail line', async () => { diff --git a/plugins/fields/frontend/test/harness.tsx b/plugins/fields/frontend/test/harness.tsx index bb20624..89dcb66 100644 --- a/plugins/fields/frontend/test/harness.tsx +++ b/plugins/fields/frontend/test/harness.tsx @@ -181,7 +181,7 @@ export function serveValues(values: Record) { } /** - * Answers the values query with whatever the returned setter last stored, so a test can change it mid flight. + * Answers the values query with whatever the returned setter last stored. * @param first - The values answered until the setter is called. * @returns The setter. */ diff --git a/plugins/fields/frontend/test/panel.test.tsx b/plugins/fields/frontend/test/panel.test.tsx index 6120af1..830b026 100644 --- a/plugins/fields/frontend/test/panel.test.tsx +++ b/plugins/fields/frontend/test/panel.test.tsx @@ -269,7 +269,7 @@ test('clearing a field sends null', async () => { }) test('a failed value read is reported instead of an empty editor', async () => { - serveCatalogue([history]) + serveCatalogue([history, jobTitle]) server.use( graphql.query('ContactFieldValues', () => HttpResponse.json({ errors: [{ message: 'boom' }] })), ) From 920e653d6bb72d8964fa344e7d7474298ffde375 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 17:41:58 +0200 Subject: [PATCH 42/48] test(features): keep the remember docblock to what it does --- test/features/steps_fields_entries_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/steps_fields_entries_test.go b/test/features/steps_fields_entries_test.go index 79cb729..aeb8f74 100644 --- a/test/features/steps_fields_entries_test.go +++ b/test/features/steps_fields_entries_test.go @@ -43,7 +43,7 @@ func (w *world) secretFor(tenant string) (string, error) { return w.secretOf(tenant) } -// remember keeps an entry's id under every text cell it holds, so later steps name the entry by its text. +// remember keeps an entry's id under every text cell it holds. func (w *world) remember(entry map[string]any) { id, _ := entry["id"].(string) if w.entryIDs == nil { From b34e216ffaaaefbc7474fd1263322182b5e181e1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 18:43:56 +0200 Subject: [PATCH 43/48] fix(fields): name an entry by its day and its first line --- plugins/fields/frontend/entries.ts | 28 ++- plugins/fields/frontend/languages/es-ES.json | 2 +- plugins/fields/frontend/test/entries.test.tsx | 183 +++++++++++------- plugins/fields/frontend/test/harness.tsx | 9 + plugins/fields/languages/alphone-fields.pot | 4 + plugins/fields/languages/es-ES.po | 5 + 6 files changed, 147 insertions(+), 84 deletions(-) diff --git a/plugins/fields/frontend/entries.ts b/plugins/fields/frontend/entries.ts index 16da5f9..21fb21a 100644 --- a/plugins/fields/frontend/entries.ts +++ b/plugins/fields/frontend/entries.ts @@ -110,20 +110,28 @@ export function entryParts(subFields: SubFieldRow[], cells: Record line.trim() !== '') - ?.trim() ?? - parts.lines[0]?.text ?? - __('Blank entry', 'alphone-fields') - ) + const said = firstWords(parts.body) ?? parts.lines[0]?.text + if (parts.day === undefined || said === undefined) { + return parts.day?.text ?? said ?? __('Blank entry', 'alphone-fields') + } + return sprintf(_x('%(day)s, %(text)s', 'entry name', 'alphone-fields'), { day: parts.day.text, text: said }) +} + +/** + * Returns the first line of a text holding more than white space, trimmed. + * @param body - The text, absent when the row holds none. + * @returns The line, absent when there is no text. + */ +function firstWords(body: string | undefined): string | undefined { + return body + ?.split('\n') + .find((line) => line.trim() !== '') + ?.trim() } /** diff --git a/plugins/fields/frontend/languages/es-ES.json b/plugins/fields/frontend/languages/es-ES.json index 76090c9..6e4ae86 100644 --- a/plugins/fields/frontend/languages/es-ES.json +++ b/plugins/fields/frontend/languages/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":""},"%(label)s: %(value)s":["%(label)s: %(value)s"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A repeater takes its entries one at a time.":["Un repetidor recibe sus entradas de una en una."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Blank entry":["Entrada en blanco"],"Cancel":["Cancelar"],"Edit entry":["Editar la entrada"],"Edit entry: %(name)s":["Editar la entrada: %(name)s"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Keep":["Conservar"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove":["Quitar"],"Remove entry":["Quitar la entrada"],"Remove entry: %(name)s":["Quitar la entrada: %(name)s"],"Remove sub field":["Quitar el subcampo"],"Remove this entry?":["¿Quitar esta entrada?"],"Save entry":["Guardar la entrada"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The entry could not be added.":["No se ha podido añadir la entrada."],"The entry could not be removed.":["No se ha podido quitar la entrada."],"The entry could not be saved.":["No se ha podido guardar la entrada."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"entry cell\u0004No":["No"],"entry cell\u0004Yes":["Sí"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} +{"":{"lang":"es","plural-forms":""},"%(label)s: %(value)s":["%(label)s: %(value)s"],"A field name starts lowercase and runs together, like birthDate.":["El nombre de un campo empieza por minúscula y va todo junto, como birthDate."],"A field needs a label.":["El campo necesita una etiqueta."],"A repeater cannot hold another repeater.":["Un repetidor no puede contener otro repetidor."],"A repeater needs at least one sub field.":["Un repetidor necesita al menos un subcampo."],"A repeater takes its entries one at a time.":["Un repetidor recibe sus entradas de una en una."],"A sub field cannot be named id. Each entry keeps that name for itself.":["Un subcampo no puede llamarse id. Cada entrada guarda ese nombre para sí misma."],"A sub field name starts lowercase and runs together, like followUp.":["El nombre de un subcampo empieza por minúscula y va todo junto, como followUp."],"Add a field":["Añadir un campo"],"Add a field to store more about every contact.":["Añade un campo para guardar más información de cada contacto."],"Add an entry to %(label)s":["Añadir una entrada a %(label)s"],"Add field":["Añadir campo"],"Add sub field":["Añadir subcampo"],"An archived field of that name holds another kind or other sub fields.":["Un campo archivado con ese nombre tiene otro tipo u otros subcampos."],"Another field already holds that name.":["Ya hay otro campo con ese nombre."],"Archive":["Archivar"],"Archive %(label)s":["Archivar %(label)s"],"Blank entry":["Entrada en blanco"],"Cancel":["Cancelar"],"Edit entry":["Editar la entrada"],"Edit entry: %(name)s":["Editar la entrada: %(name)s"],"Fields":["Campos"],"Fill in at least one part of the entry.":["Rellena al menos una parte de la entrada."],"Keep":["Conservar"],"Kind":["Tipo"],"Label":["Etiqueta"],"Loading fields…":["Cargando los campos…"],"Move sub field down":["Bajar el subcampo"],"Move sub field up":["Subir el subcampo"],"Name":["Nombre"],"No entries yet.":["Todavía no hay ninguna entrada."],"No fields yet.":["Todavía no hay ningún campo."],"No sub fields yet.":["Todavía no hay ningún subcampo."],"Only a repeater holds sub fields.":["Solo un repetidor tiene subcampos."],"Remove":["Quitar"],"Remove entry":["Quitar la entrada"],"Remove entry: %(name)s":["Quitar la entrada: %(name)s"],"Remove sub field":["Quitar el subcampo"],"Remove this entry?":["¿Quitar esta entrada?"],"Save entry":["Guardar la entrada"],"Save fields":["Guardar los campos"],"Send the values as field names to values.":["Envía los valores como pares de nombre de campo y valor."],"Sub field %(number)d":["Subcampo %(number)d"],"Sub fields":["Subcampos"],"That entry no longer exists.":["Esa entrada ya no existe."],"That field does not keep a list of entries.":["Ese campo no guarda una lista de entradas."],"That field is not one this contact holds.":["Ese campo no es uno de los que tiene este contacto."],"That field no longer exists.":["Ese campo ya no existe."],"That kind is not one AlphOne offers.":["Ese tipo no es uno de los que ofrece AlphOne."],"That label is too long. Try a shorter one.":["Esa etiqueta es demasiado larga. Prueba con una más corta."],"That value does not match the kind the field declares.":["Ese valor no encaja con el tipo que declara el campo."],"The contact already holds a detail by that name.":["El contacto ya tiene un dato con ese nombre."],"The entry could not be added.":["No se ha podido añadir la entrada."],"The entry could not be removed.":["No se ha podido quitar la entrada."],"The entry could not be saved.":["No se ha podido guardar la entrada."],"The field could not be archived.":["No se ha podido archivar el campo."],"The field could not be defined.":["No se ha podido definir el campo."],"The fields could not be loaded.":["No se han podido cargar los campos."],"The fields could not be saved.":["No se han podido guardar los campos."],"This list is full. It holds %(max)d entries at most.":["Esta lista está llena. Guarda como mucho %(max)d entradas."],"Two sub fields of this repeater hold the same name.":["Dos subcampos de este repetidor tienen el mismo nombre."],"admin section\u0004Fields":["Campos"],"entry cell\u0004No":["No"],"entry cell\u0004Yes":["Sí"],"entry name\u0004%(day)s, %(text)s":["%(day)s, %(text)s"],"field kind\u0004Choice":["Elección"],"field kind\u0004Date":["Fecha"],"field kind\u0004Long text":["Texto largo"],"field kind\u0004Number":["Número"],"field kind\u0004Repeater":["Repetidor"],"field kind\u0004Text":["Texto"],"field kind\u0004Yes or no":["Sí o no"]} diff --git a/plugins/fields/frontend/test/entries.test.tsx b/plugins/fields/frontend/test/entries.test.tsx index 6b4d648..2a0e78a 100644 --- a/plugins/fields/frontend/test/entries.test.tsx +++ b/plugins/fields/frontend/test/entries.test.tsx @@ -15,11 +15,14 @@ import { contactID, firstButton, firstCall, + firstCallName, followUp, + followUpName, history, hold, jobTitle, offerSent, + offerSentName, otherContactID, refusal, renderPanel, @@ -115,7 +118,7 @@ test('lists entries in the order the graph answers them', async () => { renderPanel() - expect(await itemNames()).toEqual(['Sep 10, 2026', 'Sep 1, 2026']) + expect(await itemNames()).toEqual([offerSentName, firstCallName]) }) test('shows the first date as a muted time above the text', async () => { @@ -124,7 +127,7 @@ test('shows the first date as a muted time above the text', async () => { renderPanel() - const item = await screen.findByRole('listitem', { name: 'Sep 10, 2026' }) + const item = await screen.findByRole('listitem', { name: offerSentName }) const time = item.querySelector('time') expect(time).toHaveAttribute('datetime', '2026-09-10') expect(time).toHaveTextContent('Sep 10, 2026') @@ -137,7 +140,7 @@ test('keeps the line breaks of its text', async () => { renderPanel() - const item = await screen.findByRole('listitem', { name: 'Sep 18, 2026' }) + const item = await screen.findByRole('listitem', { name: followUpName }) expect(item.querySelector('p.godmin-log-list__body')?.textContent).toBe(followUp.comment) }) @@ -159,7 +162,7 @@ test('shows other cells as label and value lines in sub field order', async () = renderPanel() - const item = await screen.findByRole('listitem', { name: 'Sep 10, 2026' }) + const item = await screen.findByRole('listitem', { name: 'Sep 10, 2026, Paid in cash.' }) expect([...item.querySelectorAll('p')].map((line) => line.textContent)).toEqual([ 'Paid in cash.', 'Minutes: 45', @@ -205,7 +208,7 @@ test('a sub field named after a built-in member shows nothing inherited', async expect((await addForm()).getByLabelText('Builder')).toHaveValue('') }) -test('names each row by its day, its first line or its first detail', async () => { +test('names each row by its day and its first line, its first line or its first detail', async () => { serveCatalogue([history, visits]) serveValues({ history: [offerSent, { id: ID3, comment: 'Left a message.\nNo answer.' }], @@ -214,9 +217,43 @@ test('names each row by its day, its first line or its first detail', async () = renderPanel() - expect(await itemNames()).toEqual(['Sep 10, 2026', 'Left a message.']) + expect(await itemNames()).toEqual([offerSentName, 'Left a message.']) expect(await itemNames('Visits')).toEqual(['Minutes: 45']) - expect(screen.getByRole('button', { name: 'Remove entry: Sep 10, 2026' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: `Remove entry: ${offerSentName}` })).toBeInTheDocument() +}) + +test('tells apart two entries of one day by their first line', async () => { + serveCatalogue([history]) + serveValues({ + history: [ + { id: ID2, date: '2026-09-27', comment: 'Called back.\nLeft a message.' }, + { id: ID1, date: '2026-09-27', comment: 'Sent the offer.' }, + ], + }) + + renderPanel() + + expect(await itemNames()).toEqual(['Sep 27, 2026, Called back.', 'Sep 27, 2026, Sent the offer.']) + expect(screen.getByRole('button', { name: 'Remove entry: Sep 27, 2026, Called back.' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Remove entry: Sep 27, 2026, Sent the offer.' })).toBeInTheDocument() +}) + +test('names an entry holding a day and only details by the day and its first detail', async () => { + serveCatalogue([visits]) + serveValues({ visits: [{ id: ID1, date: '2026-09-10', minutes: 45, paid: true }] }) + + renderPanel() + + expect(await itemNames('Visits')).toEqual(['Sep 10, 2026, Minutes: 45']) +}) + +test('names an entry holding only a day by that day', async () => { + serveCatalogue([history]) + serveValues({ history: [{ id: ID1, date: '2026-09-10' }] }) + + renderPanel() + + expect(await itemNames()).toEqual(['Sep 10, 2026']) }) test('shows the day an entry names to a reader west of UTC', async () => { @@ -226,7 +263,7 @@ test('shows the day an entry names to a reader west of UTC', async () => { renderPanel() - const item = await screen.findByRole('listitem', { name: 'Sep 10, 2026' }) + const item = await screen.findByRole('listitem', { name: offerSentName }) expect(item.querySelector('time')).toHaveTextContent('Sep 10, 2026') }) @@ -428,7 +465,7 @@ test('an added entry resets the form to today and shows on top', async () => { capture('AddContactFieldEntry', added) const { graph } = renderPanel() - await screen.findByRole('listitem', { name: 'Sep 1, 2026' }) + await screen.findByRole('listitem', { name: firstCallName }) change({ history: [followUp, firstCall] }) const form = await addForm() await userEvent.clear(form.getByLabelText('Date')) @@ -436,7 +473,7 @@ test('an added entry resets the form to today and shows on top', async () => { await userEvent.type(form.getByLabelText('Comment'), 'Follow-up call.') await userEvent.click(addButton()) - await waitFor(async () => expect((await itemNames())[0]).toBe('Sep 18, 2026')) + await waitFor(async () => expect((await itemNames())[0]).toBe(followUpName)) expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) expect(form.getByLabelText('Date')).toHaveValue(today) expect(form.getByLabelText('Comment')).toHaveValue('') @@ -465,7 +502,7 @@ test('an add answered while a removal is confirmed leaves focus on Keep', async await userEvent.type(comment, 'x') await userEvent.click(addButton()) await waitFor(() => expect(adding.called).toHaveBeenCalledOnce()) - await userEvent.click(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + await userEvent.click(screen.getByRole('button', { name: `Remove entry: ${firstCallName}` })) adding.release() await waitFor(() => expect(comment).toHaveValue('')) @@ -592,9 +629,9 @@ test('Remove asks first and puts focus on Keep', async () => { const removing = capture('DeleteContactFieldEntry', removed) renderPanel() - await userEvent.click(await screen.findByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + await userEvent.click(await screen.findByRole('button', { name: `Remove entry: ${firstCallName}` })) - const item = within(screen.getByRole('listitem', { name: 'Sep 1, 2026' })) + const item = within(screen.getByRole('listitem', { name: firstCallName })) expect(item.getByRole('group', { name: 'Remove this entry?' })).toBeInTheDocument() expect(item.getByRole('button', { name: 'Remove' })).toBeInTheDocument() expect(item.getByRole('button', { name: 'Keep' })).toHaveFocus() @@ -607,11 +644,11 @@ test('Keep leaves the entry and puts focus back on Remove entry', async () => { const removing = capture('DeleteContactFieldEntry', removed) renderPanel() - await userEvent.click(await screen.findByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + await userEvent.click(await screen.findByRole('button', { name: `Remove entry: ${firstCallName}` })) await userEvent.click(screen.getByRole('button', { name: 'Keep' })) expect(screen.queryByRole('group', { name: 'Remove this entry?' })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })).toHaveFocus() + expect(screen.getByRole('button', { name: `Remove entry: ${firstCallName}` })).toHaveFocus() expect(removing).not.toHaveBeenCalled() }) @@ -621,7 +658,7 @@ test('confirming a removal sends the entry id and reads the list again', async ( const removing = capture('DeleteContactFieldEntry', removed) const { graph } = renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await waitFor(() => expect(removing).toHaveBeenCalledWith({ contactId: contactID, field: 'history', entryId: ID1 }), @@ -635,12 +672,12 @@ test('a removal in flight disables its own row and every other row', async () => const removing = hold('DeleteContactFieldEntry', removed) renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await waitFor(() => expect(removing.called).toHaveBeenCalledOnce()) expect(screen.getByRole('button', { name: 'Remove' })).toHaveAttribute('aria-disabled', 'true') expect(screen.getByRole('button', { name: 'Keep' })).toHaveAttribute('aria-disabled', 'true') - expect(screen.getByRole('button', { name: 'Remove entry: Sep 10, 2026' })).toHaveAttribute( + expect(screen.getByRole('button', { name: `Remove entry: ${offerSentName}` })).toHaveAttribute( 'aria-disabled', 'true', ) @@ -653,14 +690,14 @@ test('a removed entry stays disabled until the graph stops answering it', async capture('DeleteContactFieldEntry', removed) const { graph } = renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await waitFor(() => expect(graph.refetch).toHaveBeenCalled()) - const settling = await screen.findByRole('button', { name: 'Remove entry: Sep 1, 2026' }) + const settling = await screen.findByRole('button', { name: `Remove entry: ${firstCallName}` }) expect(settling).toHaveAttribute('aria-disabled', 'true') change({ history: [offerSent] }) act(() => graph.refetch(['ContactFieldValues'])) - await waitFor(() => expect(screen.queryByRole('listitem', { name: 'Sep 1, 2026' })).not.toBeInTheDocument()) + await waitFor(() => expect(screen.queryByRole('listitem', { name: firstCallName })).not.toBeInTheDocument()) }) test('a removal moves focus to the next entry', async () => { @@ -669,9 +706,9 @@ test('a removal moves focus to the next entry', async () => { capture('DeleteContactFieldEntry', removed) renderPanel() - await removeEntry('Sep 10, 2026') + await removeEntry(offerSentName) - await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: 'Sep 1, 2026' }))).toHaveFocus()) + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: firstCallName }))).toHaveFocus()) }) test('removing the oldest entry moves focus to the one above it', async () => { @@ -680,9 +717,9 @@ test('removing the oldest entry moves focus to the one above it', async () => { capture('DeleteContactFieldEntry', removed) renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) - await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: 'Sep 10, 2026' }))).toHaveFocus()) + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: offerSentName }))).toHaveFocus()) }) test('a removal passes over an entry that is still leaving', async () => { @@ -691,9 +728,9 @@ test('a removal passes over an entry that is still leaving', async () => { capture('DeleteContactFieldEntry', removed) renderPanel() - await removeEntry('Sep 1, 2026') - await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: 'Sep 10, 2026' }))).toHaveFocus()) - await removeEntry('Sep 10, 2026') + await removeEntry(firstCallName) + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: offerSentName }))).toHaveFocus()) + await removeEntry(offerSentName) await waitFor(async () => expect((await addForm()).getByLabelText('Date')).toHaveFocus()) }) @@ -704,13 +741,13 @@ test('a removal answered while typing in the add form leaves focus there', async const removing = hold('DeleteContactFieldEntry', removed) renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await waitFor(() => expect(removing.called).toHaveBeenCalledOnce()) const comment = (await addForm()).getByLabelText('Comment') await userEvent.type(comment, 'x') removing.release() - await waitFor(() => expect(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })).toBeInTheDocument()) + await waitFor(() => expect(screen.getByRole('button', { name: `Remove entry: ${firstCallName}` })).toBeInTheDocument()) expect(comment).toHaveFocus() }) @@ -720,9 +757,9 @@ test('removing the last entry moves focus to the add form', async () => { capture('DeleteContactFieldEntry', removed) renderPanel() - await screen.findByRole('listitem', { name: 'Sep 1, 2026' }) + await screen.findByRole('listitem', { name: firstCallName }) change({ history: null }) - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) expect(await screen.findByText('No entries yet.')).toBeInTheDocument() await waitFor(async () => expect((await addForm()).getByLabelText('Date')).toHaveFocus()) @@ -735,11 +772,11 @@ test('a removal answered as not found says so and reads the list again', async ( capture('DeleteContactFieldEntry', refusal('NOT_FOUND', 'field_entry_not_found')) const { graph } = renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) expect(await screen.findByRole('alert')).toHaveTextContent('That entry no longer exists.') expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) - expect(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })).toHaveAttribute( + expect(screen.getByRole('button', { name: `Remove entry: ${firstCallName}` })).toHaveAttribute( 'aria-disabled', 'true', ) @@ -751,10 +788,10 @@ test('a refused removal keeps the entry usable', async () => { capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be removed.') - const kept = screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' }) + const kept = screen.getByRole('button', { name: `Remove entry: ${firstCallName}` }) expect(kept).not.toHaveAttribute('aria-disabled', 'true') await waitFor(() => expect(kept).toHaveFocus()) }) @@ -765,10 +802,10 @@ test('a second removal clears the last failure while it runs', async () => { capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await screen.findByRole('alert') const removing = hold('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await waitFor(() => expect(removing.called).toHaveBeenCalledOnce()) expect(screen.queryByRole('alert')).not.toBeInTheDocument() @@ -782,9 +819,9 @@ test('asking to remove an entry clears an earlier failure', async () => { capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await screen.findByRole('alert') - await userEvent.click(screen.getByRole('button', { name: 'Remove entry: Sep 1, 2026' })) + await userEvent.click(screen.getByRole('button', { name: `Remove entry: ${firstCallName}` })) expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) @@ -795,9 +832,9 @@ test('Edit clears an earlier failure', async () => { capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await screen.findByRole('alert') - await editEntry('Sep 1, 2026') + await editEntry(firstCallName) expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) @@ -809,7 +846,7 @@ test('an added entry clears an earlier failure', async () => { capture('AddContactFieldEntry', added) renderPanel() - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await screen.findByRole('alert') const comment = (await addForm()).getByLabelText('Comment') await userEvent.type(comment, 'x') @@ -826,9 +863,9 @@ test('a removal from a repeater archived elsewhere reads the catalogue again', a capture('DeleteContactFieldEntry', refusal('VALIDATION', 'field_unknown')) const { graph } = renderPanel() - await screen.findByRole('listitem', { name: 'Sep 1, 2026' }) + await screen.findByRole('listitem', { name: firstCallName }) serveCatalogue([jobTitle]) - await removeEntry('Sep 1, 2026') + await removeEntry(firstCallName) await waitFor(() => expect(screen.queryByRole('group', { name: 'History' })).not.toBeInTheDocument()) expect(graph.refetch).toHaveBeenCalledWith(['Fields']) @@ -882,7 +919,7 @@ test('a draft stays with the contact it was typed on', async () => { await userEvent.type((await addForm()).getByLabelText('Comment'), 'Typed on the first contact.') showContact(otherContactID) - await screen.findByRole('listitem', { name: 'Sep 10, 2026' }) + await screen.findByRole('listitem', { name: offerSentName }) const form = await addForm() expect(form.getByLabelText('Comment')).toHaveValue('') expect(form.getByLabelText('Date')).toHaveValue(today) @@ -909,7 +946,7 @@ test('entries wait for the stored values before they show', async () => { expect(screen.queryByRole('button', { name: 'Add an entry to History' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Save fields' })).not.toBeInTheDocument() release() - expect(await screen.findByRole('listitem', { name: 'Sep 1, 2026' })).toBeInTheDocument() + expect(await screen.findByRole('listitem', { name: firstCallName })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save fields' })).toBeInTheDocument() }) @@ -936,13 +973,13 @@ test('Edit opens the entry prefilled in its own row', async () => { serveValues({ history: [offerSent, firstCall] }) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) expect(editor.getByLabelText('Date')).toHaveValue('2026-09-10') const comment = editor.getByRole('textbox', { name: 'Comment' }) expect(comment).toBeInstanceOf(HTMLTextAreaElement) expect(comment).toHaveValue(offerSent.comment) - const item = within(screen.getByRole('listitem', { name: 'Sep 10, 2026' })) + const item = within(screen.getByRole('listitem', { name: offerSentName })) expect(item.queryByRole('button', { name: /^(Edit|Remove) entry/ })).not.toBeInTheDocument() }) @@ -951,7 +988,7 @@ test('Edit puts focus on the first cell', async () => { serveValues({ history: [offerSent] }) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await waitFor(() => expect(editor.getByLabelText('Date')).toHaveFocus()) }) @@ -961,8 +998,8 @@ test('only one editor opens at a time', async () => { serveValues({ history: [offerSent, firstCall] }) renderPanel() - await editEntry('Sep 10, 2026') - await editEntry('Sep 1, 2026') + await editEntry(offerSentName) + await editEntry(firstCallName) const editors = screen.getAllByRole('form', { name: /^Edit entry/ }) expect(editors).toHaveLength(1) @@ -974,8 +1011,8 @@ test('Edit closes a removal waiting to be confirmed on another row', async () => serveValues({ history: [offerSent, firstCall] }) renderPanel() - await userEvent.click(await screen.findByRole('button', { name: 'Remove entry: Sep 1, 2026' })) - await editEntry('Sep 10, 2026') + await userEvent.click(await screen.findByRole('button', { name: `Remove entry: ${firstCallName}` })) + await editEntry(offerSentName) expect(screen.queryByRole('group', { name: 'Remove this entry?' })).not.toBeInTheDocument() }) @@ -986,12 +1023,12 @@ test('Cancel sends nothing and puts focus back on Edit entry', async () => { const updating = capture('UpdateContactFieldEntry', updated) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.type(editor.getByLabelText('Comment'), ' More.') await userEvent.click(editor.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('form', { name: /^Edit entry/ })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Edit entry: Sep 10, 2026' })).toHaveFocus() + expect(screen.getByRole('button', { name: `Edit entry: ${offerSentName}` })).toHaveFocus() expect(updating).not.toHaveBeenCalled() }) @@ -1001,7 +1038,7 @@ test('Save stays off while every cell of the entry is blank', async () => { const updating = capture('UpdateContactFieldEntry', updated) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.clear(editor.getByLabelText('Date')) await userEvent.clear(editor.getByLabelText('Comment')) await userEvent.type(editor.getByLabelText('Comment'), ' ') @@ -1040,7 +1077,7 @@ test('Enter in a cell during a save in flight sends one update', async () => { const updating = hold('UpdateContactFieldEntry', updated) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) await waitFor(() => expect(updating.called).toHaveBeenCalledOnce()) await userEvent.type(editor.getByLabelText('Date'), '{Enter}') @@ -1056,7 +1093,7 @@ test('a save in flight locks its cells, Save, Cancel and every other row', async const updating = hold('UpdateContactFieldEntry', updated) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) await waitFor(() => expect(updating.called).toHaveBeenCalledOnce()) @@ -1064,7 +1101,7 @@ test('a save in flight locks its cells, Save, Cancel and every other row', async expect(editor.getByLabelText('Comment')).toBeDisabled() expect(editor.getByRole('button', { name: 'Save entry' })).toHaveAttribute('aria-disabled', 'true') expect(editor.getByRole('button', { name: 'Cancel' })).toHaveAttribute('aria-disabled', 'true') - expect(screen.getByRole('button', { name: 'Edit entry: Sep 1, 2026' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: `Edit entry: ${firstCallName}` })).toHaveAttribute('aria-disabled', 'true') updating.release() }) @@ -1074,12 +1111,12 @@ test('a saved entry closes the editor, reads the list again and puts focus on Ed capture('UpdateContactFieldEntry', updated) const { graph } = renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) await waitFor(() => expect(screen.queryByRole('form', { name: /^Edit entry/ })).not.toBeInTheDocument()) expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) - await waitFor(() => expect(screen.getByRole('button', { name: 'Edit entry: Sep 10, 2026' })).toHaveFocus()) + await waitFor(() => expect(screen.getByRole('button', { name: `Edit entry: ${offerSentName}` })).toHaveFocus()) }) test('a refused save keeps the editor and its draft', async () => { @@ -1089,7 +1126,7 @@ test('a refused save keeps the editor and its draft', async () => { capture('UpdateContactFieldEntry', refusal('VALIDATION', 'value_kind_mismatch')) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.type(editor.getByLabelText('Comment'), ' More.') await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) @@ -1104,14 +1141,14 @@ test('a save answered as not found says so and passes focus on', async () => { capture('UpdateContactFieldEntry', refusal('NOT_FOUND', 'field_entry_not_found')) const { graph } = renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) expect(await screen.findByRole('alert')).toHaveTextContent('That entry no longer exists.') expect(screen.queryByRole('form', { name: /^Edit entry/ })).not.toBeInTheDocument() expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) - expect(screen.getByRole('button', { name: 'Edit entry: Sep 10, 2026' })).toHaveAttribute('aria-disabled', 'true') - await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: 'Sep 1, 2026' }))).toHaveFocus()) + expect(screen.getByRole('button', { name: `Edit entry: ${offerSentName}` })).toHaveAttribute('aria-disabled', 'true') + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: firstCallName }))).toHaveFocus()) }) test('a save to a repeater archived elsewhere reads the catalogue again', async () => { @@ -1121,7 +1158,7 @@ test('a save to a repeater archived elsewhere reads the catalogue again', async capture('UpdateContactFieldEntry', refusal('VALIDATION', 'field_unknown')) const { graph } = renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) serveCatalogue([jobTitle]) await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) @@ -1137,7 +1174,7 @@ test('a save that fails otherwise shows the fallback', async () => { capture('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be saved.') @@ -1149,7 +1186,7 @@ test('Cancel clears the failure of a refused save', async () => { capture('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) await screen.findByRole('alert') await userEvent.click(editor.getByRole('button', { name: 'Cancel' })) @@ -1163,7 +1200,7 @@ test('a second save clears the last failure while it runs', async () => { capture('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) renderPanel() - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) await screen.findByRole('alert') const updating = hold('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) @@ -1196,9 +1233,9 @@ test('a removal puts focus on the next entry Edit entry', async () => { capture('DeleteContactFieldEntry', removed) renderPanel() - await removeEntry('Sep 10, 2026') + await removeEntry(offerSentName) - await waitFor(() => expect(screen.getByRole('button', { name: 'Edit entry: Sep 1, 2026' })).toHaveFocus()) + await waitFor(() => expect(screen.getByRole('button', { name: `Edit entry: ${firstCallName}` })).toHaveFocus()) }) test('an add answered while an editor is open leaves focus in the editor', async () => { @@ -1211,7 +1248,7 @@ test('an add answered while an editor is open leaves focus in the editor', async await userEvent.type(comment, 'x') await userEvent.click(addButton()) await waitFor(() => expect(adding.called).toHaveBeenCalledOnce()) - const editor = await editEntry('Sep 10, 2026') + const editor = await editEntry(offerSentName) await userEvent.click(editor.getByLabelText('Comment')) adding.release() diff --git a/plugins/fields/frontend/test/harness.tsx b/plugins/fields/frontend/test/harness.tsx index 89dcb66..d40b530 100644 --- a/plugins/fields/frontend/test/harness.tsx +++ b/plugins/fields/frontend/test/harness.tsx @@ -73,6 +73,15 @@ export const offerSent = { id: ID2, date: '2026-09-10', comment: 'Sent the offer /** followUp is the newest stored history entry, its comment on two lines. */ export const followUp = { id: ID3, date: '2026-09-18', comment: 'Follow-up call.\nAsked for a second quote.' } +/** firstCallName is the name the row of firstCall is announced by. */ +export const firstCallName = 'Sep 1, 2026, First call about the yearly plan.' + +/** offerSentName is the name the row of offerSent is announced by. */ +export const offerSentName = 'Sep 10, 2026, Sent the offer and booked a follow-up call.' + +/** followUpName is the name the row of followUp is announced by. */ +export const followUpName = 'Sep 18, 2026, Follow-up call.' + /** * Renders the fields panel of the test contact inside its graph and query providers. * @returns The render result, the graph client and a switch to another contact. diff --git a/plugins/fields/languages/alphone-fields.pot b/plugins/fields/languages/alphone-fields.pot index 3e089d6..7d80adc 100644 --- a/plugins/fields/languages/alphone-fields.pot +++ b/plugins/fields/languages/alphone-fields.pot @@ -202,6 +202,10 @@ msgctxt "entry cell" msgid "Yes" msgstr "" +msgctxt "entry name" +msgid "%(day)s, %(text)s" +msgstr "" + msgctxt "field kind" msgid "Choice" msgstr "" diff --git a/plugins/fields/languages/es-ES.po b/plugins/fields/languages/es-ES.po index 9819a45..01402db 100644 --- a/plugins/fields/languages/es-ES.po +++ b/plugins/fields/languages/es-ES.po @@ -267,6 +267,11 @@ msgctxt "entry cell" msgid "Yes" msgstr "Sí" +#, fuzzy +msgctxt "entry name" +msgid "%(day)s, %(text)s" +msgstr "%(day)s, %(text)s" + #, fuzzy msgctxt "field kind" msgid "Choice" From b1faef17e8ec68c85c83cff6d873875ea5c41852 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 18:43:56 +0200 Subject: [PATCH 44/48] test(e2e): find contact history entries by day and first line --- test/e2e/tests/fields-repeater.spec.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/e2e/tests/fields-repeater.spec.ts b/test/e2e/tests/fields-repeater.spec.ts index 9691c99..9492e31 100644 --- a/test/e2e/tests/fields-repeater.spec.ts +++ b/test/e2e/tests/fields-repeater.spec.ts @@ -63,15 +63,16 @@ test('defines a repeater and keeps a contact history one entry at a time', async await addEntry(page, form, '2026-09-10', 'Sent the offer.') await addEntry(page, form, '2026-09-18', 'Follow-up call.') const entries = history.getByRole('listitem') - await expect(entries.first()).toHaveAccessibleName('Sep 18, 2026') + await expect(entries.first()).toHaveAccessibleName('Sep 18, 2026, Follow-up call.') const removed = operationAnswer(page, 'DeleteContactFieldEntry') - await history.getByRole('button', { name: 'Remove entry: Sep 10, 2026' }).click() + await history.getByRole('button', { name: 'Remove entry: Sep 10, 2026, Sent the offer.' }).click() await history.getByRole('button', { name: 'Remove', exact: true }).click() await removed - await history.getByRole('button', { name: 'Edit entry: Sep 1, 2026' }).click() - const editor = history.getByRole('form', { name: 'Edit entry: Sep 1, 2026' }) + const firstCall = 'Sep 1, 2026, First call about the yearly plan.' + await history.getByRole('button', { name: `Edit entry: ${firstCall}` }).click() + const editor = history.getByRole('form', { name: `Edit entry: ${firstCall}` }) const edited = editor.getByRole('textbox', { name: 'Comment', exact: true }) await expect(edited).toHaveJSProperty('tagName', 'TEXTAREA') await edited.fill('First call about the yearly plan.\nAsked for a second quote.') @@ -81,8 +82,8 @@ test('defines a repeater and keeps a contact history one entry at a time', async await page.reload() await expect(entries).toHaveCount(2) - await expect(entries.nth(0)).toHaveAccessibleName('Sep 18, 2026') - await expect(entries.nth(1)).toHaveAccessibleName('Sep 1, 2026') + await expect(entries.nth(0)).toHaveAccessibleName('Sep 18, 2026, Follow-up call.') + await expect(entries.nth(1)).toHaveAccessibleName(firstCall) const body = entries.nth(1).locator('p.godmin-log-list__body') expect(await body.evaluate((node) => (node as HTMLElement).innerText)).toBe( 'First call about the yearly plan.\nAsked for a second quote.', From 24680ae04b46c34839be6ef97d8c8ff2d4b8074e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 19:39:54 +0200 Subject: [PATCH 45/48] chore(deps): update gottext to 0.5.1 --- frontend/package.json | 2 +- pnpm-lock.yaml | 14 +++++++------- sdk/frontend/package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index ac14f83..0752f91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,7 @@ "@alphone/plugin-importer": "workspace:*", "@alphone/plugin-whatsapp": "workspace:*", "@gopherium/godmin": "0.11.0", - "@gopherium/gottext": "0.5.0", + "@gopherium/gottext": "0.5.1", "@gopherium/react-auth": "0.9.0", "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.36", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 104a4d3..0c554c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,8 +159,8 @@ importers: specifier: 0.11.0 version: 0.11.0(dcf93bb2df0535b72c5d2768136b7099) '@gopherium/gottext': - specifier: 0.5.0 - version: 0.5.0(@wordpress/i18n@6.28.0)(gettext-extractor@4.0.6) + specifier: 0.5.1 + version: 0.5.1(@wordpress/i18n@6.28.0)(gettext-extractor@4.0.6) '@gopherium/react-auth': specifier: 0.9.0 version: 0.9.0(0e1302f3c8b195b54f88c3f54ba61f37) @@ -349,8 +349,8 @@ importers: specifier: 0.11.0 version: 0.11.0(dcf93bb2df0535b72c5d2768136b7099) '@gopherium/gottext': - specifier: 0.5.0 - version: 0.5.0(@wordpress/i18n@6.28.0)(gettext-extractor@4.0.6) + specifier: 0.5.1 + version: 0.5.1(@wordpress/i18n@6.28.0)(gettext-extractor@4.0.6) '@gopherium/react-auth': specifier: 0.9.0 version: 0.9.0(1fb7c474b0e30cb304088605b249a6f6) @@ -1049,8 +1049,8 @@ packages: vitest: optional: true - '@gopherium/gottext@0.5.0': - resolution: {integrity: sha512-yJJpL6ot9vQd+AlUPBWiw2LKzIPSIhypvMcJ/z2MUDe/mg5gQZP1FWRvubQapimZsBuX0CHhuEowGyS22ioa4A==} + '@gopherium/gottext@0.5.1': + resolution: {integrity: sha512-8tzbD3HysVa9/XCme7r7+AiKaHqZTix6Fca7s1vezXWe9qcxmwm2R6gbzszsTdA5SaJaV/dXA9u+S3tOIEEePw==} engines: {node: '>=22'} peerDependencies: '@wordpress/i18n': '>=6.26.0 <7.0.0' @@ -5702,7 +5702,7 @@ snapshots: stylelint: 17.15.0(supports-color@10.2.2)(typescript@6.0.3) vitest: 4.1.11(@types/node@26.5.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.5.1)(typescript@6.0.3))(vite@8.2.2(@types/node@26.5.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - '@gopherium/gottext@0.5.0(@wordpress/i18n@6.28.0)(gettext-extractor@4.0.6)': + '@gopherium/gottext@0.5.1(@wordpress/i18n@6.28.0)(gettext-extractor@4.0.6)': dependencies: '@wordpress/i18n': 6.28.0 gettext-parser: 9.1.1 diff --git a/sdk/frontend/package.json b/sdk/frontend/package.json index 3a861e5..1107590 100644 --- a/sdk/frontend/package.json +++ b/sdk/frontend/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@gopherium/godmin": "0.11.0", - "@gopherium/gottext": "0.5.0", + "@gopherium/gottext": "0.5.1", "@gopherium/react-auth": "0.9.0", "@tanstack/react-query": "^5.102.8", "@urql/exchange-graphcache": "9.0.1", From 67b931f91284afe72e14d9011e6fbb0eb37a0612 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 19:39:55 +0200 Subject: [PATCH 46/48] refactor(fields): show entry days through formatDate --- plugins/fields/frontend/entries.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/fields/frontend/entries.ts b/plugins/fields/frontend/entries.ts index 21fb21a..a0dac89 100644 --- a/plugins/fields/frontend/entries.ts +++ b/plugins/fields/frontend/entries.ts @@ -194,6 +194,5 @@ function cellValue(kind: string, text: string): string { * @returns The shown day. */ function dayText(day: string): string { - const [year, month, date] = day.split('-').map(Number) - return formatDate(new Date(year, month - 1, date), DAY_STYLE) + return formatDate(day, DAY_STYLE) } From 8b26f941aa9d5cba09ff7544b057c5dfcb3449f1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 19:59:53 +0200 Subject: [PATCH 47/48] fix(fields): seed the newest demo history the entry cap holds --- plugins/fields/seed.go | 5 +++-- plugins/fields/seed_test.go | 39 ++++++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/plugins/fields/seed.go b/plugins/fields/seed.go index c9e26b7..6769b89 100644 --- a/plugins/fields/seed.go +++ b/plugins/fields/seed.go @@ -71,7 +71,7 @@ func (p *Plugin) seedDefinitions(ctx context.Context) error { return nil } -// seedValues writes the demo values the demo contact does not hold yet, when one exists. +// seedValues writes the demo values the demo contact does not hold yet, the history cut to the cap, when one exists. func (p *Plugin) seedValues(ctx context.Context) error { const query = `SELECT contact_id FROM core.contact_identities WHERE channel = 'email' AND identifier = $1 AND tenant_id = $2 ORDER BY created_at, id LIMIT 1` @@ -94,7 +94,8 @@ func (p *Plugin) seedValues(ctx context.Context) error { } } if _, kept := held[contactID][demoHistoryName]; !kept { - if _, err := p.store.addEntry(ctx, contactID, demoHistoryName, demoHistory, p.entriesMax); err != nil { + history := demoHistory[max(0, len(demoHistory)-p.entriesMax):] + if _, err := p.store.addEntry(ctx, contactID, demoHistoryName, history, p.entriesMax); err != nil { return fmt.Errorf("fields: seed history: %w", err) } } diff --git a/plugins/fields/seed_test.go b/plugins/fields/seed_test.go index d53bbda..2973f91 100644 --- a/plugins/fields/seed_test.go +++ b/plugins/fields/seed_test.go @@ -6,6 +6,7 @@ import ( "errors" "reflect" "slices" + "strings" "testing" "github.com/google/uuid" @@ -192,24 +193,48 @@ func TestSeedWritesTheValuesOntoTheDemoContact(t *testing.T) { } } -func TestSeedAddsTheDemoHistoryAllOrNothing(t *testing.T) { +func TestSeedKeepsTheNewestDemoHistoryACapHolds(t *testing.T) { t.Parallel() p := newMigratedPlugin(t) maria := seedDemoContact(t, p) p.entriesMax = 2 - err := p.Seed(t.Context()) - - if !errors.Is(err, errEntriesFull) { - t.Fatalf("Seed() error = %v, want the history refused whole under a cap of two", err) + if err := p.Seed(t.Context()); err != nil { + t.Fatalf("Seed() error = %v, want the demo history cut to a cap of two", err) } + held, err := p.store.valuesFor(t.Context(), []uuid.UUID{maria}) if err != nil { t.Fatalf("valuesFor() error = %v, want nil", err) } - if _, kept := held[maria]["history"]; kept { - t.Errorf("history = %#v, want no part of the demo history stored", held[maria]["history"]) + _, cells := splitIDs(t, held[maria]["history"]) + rows := []any{ + map[string]any{"date": "2026-09-18", "comment": "Follow-up call.\nAsked for a second quote."}, + map[string]any{"date": "2026-09-10", "comment": "Sent the offer and booked a follow-up call."}, + } + if !reflect.DeepEqual(cells, rows) { + t.Errorf("history = %#v, want the two newest demo entries", cells) + } + if held[maria]["birthDate"] != "1990-04-17" { + t.Errorf("birthDate = %#v, want 1990-04-17", held[maria]["birthDate"]) + } +} + +func TestSeedReportsAHistoryItCannotStore(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + seedDemoContact(t, p) + if _, err := p.pool.Exec(t.Context(), + "ALTER TABLE plugin_fields.contact_values ADD CONSTRAINT no_history CHECK (NOT values ? 'history')"); err != nil { + t.Fatalf("refusing stored histories: %v", err) + } + + err := p.Seed(t.Context()) + + if err == nil || !strings.Contains(err.Error(), "seed history") { + t.Errorf("Seed() error = %v, want the refused history reported", err) } } From cd2bb80b67a06cf4a724a9a42046006f1fe2799e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 27 Sep 2026 19:59:53 +0200 Subject: [PATCH 48/48] test(e2e): wait for a graph answer by its operation name --- test/e2e/tests/fields-repeater.spec.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/test/e2e/tests/fields-repeater.spec.ts b/test/e2e/tests/fields-repeater.spec.ts index 9492e31..6b437f3 100644 --- a/test/e2e/tests/fields-repeater.spec.ts +++ b/test/e2e/tests/fields-repeater.spec.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' +import type { Locator, Page, Request } from '@playwright/test' /** * Chooses one kind from a kind menu and waits for the menu to close. @@ -108,9 +108,9 @@ test('defines a repeater and keeps a contact history one entry at a time', async function operationsSent(page: Page): string[] { const names: string[] = [] page.on('request', (request) => { - if (request.method() === 'POST' && request.url().includes('/api/graphql')) { - const body = request.postDataJSON() as { operationName?: string } | null - names.push(body?.operationName ?? '') + const name = operationOf(request) + if (name !== '') { + names.push(name) } }) return names @@ -123,10 +123,19 @@ function operationsSent(page: Page): string[] { * @returns The answer, once it arrives. */ function operationAnswer(page: Page, operation: string) { - return page.waitForResponse( - (response) => - response.url().includes('/api/graphql') && (response.request().postData() ?? '').includes(operation), - ) + return page.waitForResponse((response) => operationOf(response.request()) === operation) +} + +/** + * Returns the name of the graph operation a request sends. + * @param request - The request. + * @returns The operation name, empty for a request that sends none. + */ +function operationOf(request: Request): string { + if (request.method() !== 'POST' || !request.url().includes('/api/graphql')) { + return '' + } + return (request.postDataJSON() as { operationName?: string } | null)?.operationName ?? '' } /**