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/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"]) } } diff --git a/docs/src/content/docs/guides/fields.md b/docs/src/content/docs/guides/fields.md index 261786e..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. @@ -60,24 +59,31 @@ 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. -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 @@ -139,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 { @@ -157,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`: @@ -202,7 +189,72 @@ 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. + +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 diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index cf13603..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,12 +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 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` | | 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 | @@ -514,6 +520,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` | | diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index 919d9ea..cf9fe15 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 between 1 and 2147483647. | + ## WhatsApp plugin All optional. Without them the plugin runs inert: screens exist, but no diff --git a/frontend/package.json b/frontend/package.json index 7b86c4f..0752f91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,8 +22,8 @@ "@alphone/plugin-fields": "workspace:*", "@alphone/plugin-importer": "workspace:*", "@alphone/plugin-whatsapp": "workspace:*", - "@gopherium/godmin": "0.10.0", - "@gopherium/gottext": "0.5.0", + "@gopherium/godmin": "0.11.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/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) diff --git a/internal/postgres/pluginpredicate_test.go b/internal/postgres/pluginpredicate_test.go index 0ff1f37..3960f67 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" @@ -25,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", @@ -68,17 +80,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 +116,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 +208,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() @@ -187,12 +238,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() 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_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/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/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/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/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/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. diff --git a/plugins/fields/frontend/ContactFieldsPanel.tsx b/plugins/fields/frontend/ContactFieldsPanel.tsx index ff39268..4c376ac 100644 --- a/plugins/fields/frontend/ContactFieldsPanel.tsx +++ b/plugins/fields/frontend/ContactFieldsPanel.tsx @@ -3,27 +3,26 @@ import { __, Button, - Checkbox, ErrorNotice, - InputControl, LoadingRows, - RepeatRows, Stack, Text, - TextareaControl, graphError, - sprintf, useGraph, useGraphMutation, useGraphQuery, validationMessage, } from '@alphone/frontend-sdk' -import { useId, useMemo, useState } from 'react' +import { useId, useState } from 'react' -import { contactValuesDocument } from './document' +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' - -const valuesOperation = 'ContactFieldValues' +import { RepeaterEntries } from './RepeaterEntries' +import type { GoneHandler } from './useEntryActions' /** FieldRow is one catalogue entry the panel renders an input for. */ interface FieldRow { @@ -34,16 +33,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. @@ -60,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({ @@ -102,7 +132,6 @@ function FieldsForm({ stored: Record }) { const [edited, setEdited] = useState>({}) - const [entries, setEntries] = useState>({}) const [written, write] = useGraphMutation(writeContactFieldsMutation) const graph = useGraph() @@ -111,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]) } }) @@ -125,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 })} + /> + ))} @@ -150,234 +168,19 @@ 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. - * @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'), - }} - /> - - ) -} - -/** - * 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. - * @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 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. + * @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])])), - ) -} - -/** - * 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/EntryRow.tsx b/plugins/fields/frontend/EntryRow.tsx new file mode 100644 index 0000000..6f16d00 --- /dev/null +++ b/plugins/fields/frontend/EntryRow.tsx @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Button, LogItem, LogTime, Stack, Text, __, sprintf } from '@alphone/frontend-sdk' +import { useEffect, useId, useRef, useState } from 'react' +import type { RefObject } from 'react' + +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, 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. + */ +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 editRef = useRef(null) + const removeRef = useRef(null) + const keepRef = useRef(null) + + useEffect(() => { + if (row.focus) { + 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 ( + {parts.day.text} : undefined} + actions={ + row.mode === 'confirm' ? ( + void on.confirm(entry.id)} + onKeep={() => on.keep(entry.id)} + /> + ) : ( + on.edit(entry.id)} + onRemove={() => on.remove(entry.id)} + /> + ) + } + body={parts.body} + > + {parts.lines.map((line) => ( + }> + {line.text} + + ))} + + ) +} + +/** + * 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. + * @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')} + + + + + ) +} + +/** + * 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/FieldsScreen.tsx b/plugins/fields/frontend/FieldsScreen.tsx index 338fa64..49aabce 100644 --- a/plugins/fields/frontend/FieldsScreen.tsx +++ b/plugins/fields/frontend/FieldsScreen.tsx @@ -26,10 +26,8 @@ import { useState } from 'react' import { fieldsIcon } from './icon' import type { FieldKind } from './gql/graphql' -import { kindItems, kindOf, subKindItems } from './kind' -import { archiveFieldMutation, defineFieldMutation, fieldsQuery } from './operations' - -const catalogueOperation = 'Fields' +import { ENTRY_ID_KEY, kindItems, kindOf, subKindItems } from './kind' +import { archiveFieldMutation, catalogueOperation, defineFieldMutation, fieldsQuery } from './operations' /** FieldRow is one catalogue entry as the screen renders it. */ interface FieldRow { @@ -215,10 +213,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/RepeaterEntries.tsx b/plugins/fields/frontend/RepeaterEntries.tsx new file mode 100644 index 0000000..6c1db72 --- /dev/null +++ b/plugins/fields/frontend/RepeaterEntries.tsx @@ -0,0 +1,152 @@ +// 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 } 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: (from: Element | null) => 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()) + onAdded(from) + } 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/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..3aa60ca --- /dev/null +++ b/plugins/fields/frontend/cells.tsx @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +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, 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) => ( + onChange(column.name, next)} + /> + )) +} + +/** + * Renders one field's input, matched to the kind its definition declares. + * @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') { + 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' +} 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/entries.ts b/plugins/fields/frontend/entries.ts new file mode 100644 index 0000000..a0dac89 --- /dev/null +++ b/plugins/fields/frontend/entries.ts @@ -0,0 +1,198 @@ +// 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() !== '') +} + +/** + * 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. + * @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 and its first words, else either one alone. + * @param parts - The parts of the row. + * @returns The name. + */ +export function entryName(parts: EntryParts): string { + 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() +} + +/** + * 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 { + return formatDate(day, 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/errorTemplates.ts b/plugins/fields/frontend/errorTemplates.ts index e131510..b8122b1 100644 --- a/plugins/fields/frontend/errorTemplates.ts +++ b/plugins/fields/frontend/errorTemplates.ts @@ -22,11 +22,17 @@ 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), 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), + field_repeater_entries_only: __('A repeater takes its entries one at a time.', DOMAIN), } } 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/gql/gql.ts b/plugins/fields/frontend/gql/gql.ts index bdcadf1..a96c1ad 100644 --- a/plugins/fields/frontend/gql/gql.ts +++ b/plugins/fields/frontend/gql/gql.ts @@ -18,12 +18,18 @@ 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 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 = { "\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 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, }; /** @@ -56,6 +62,18 @@ 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 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. + */ +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..df6d69d 100644 --- a/plugins/fields/frontend/gql/graphql.ts +++ b/plugins/fields/frontend/gql/graphql.ts @@ -49,8 +49,39 @@ 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 UpdateContactFieldEntryMutationVariables = Exact<{ + contactId: string; + field: string; + entryId: string; + entry: unknown; +}>; + + +export type UpdateContactFieldEntryMutation = { updateContactFieldEntry: 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 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/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/languages/es-ES.json b/plugins/fields/frontend/languages/es-ES.json index d50c1d8..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 %(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: %(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/operations.ts b/plugins/fields/frontend/operations.ts index 0a8e620..a72b3eb 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 { @@ -50,3 +53,21 @@ 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 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) + } +`) 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/entries.test.tsx b/plugins/fields/frontend/test/entries.test.tsx new file mode 100644 index 0000000..2a0e78a --- /dev/null +++ b/plugins/fields/frontend/test/entries.test.tsx @@ -0,0 +1,1257 @@ +// 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, beforeEach, expect, test, vi } from 'vitest' + +import { + ID1, + ID2, + ID3, + capture, + captureWrite, + contactID, + firstButton, + firstCall, + firstCallName, + followUp, + followUpName, + history, + hold, + jobTitle, + offerSent, + offerSentName, + 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 } } + +/** updated is the body a successful edit answers. */ +const updated = { data: { updateContactFieldEntry: { id: ID2, comment: 'Edited.' } } } + +/** 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'), +].join('-') + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'], shouldAdvanceTime: true }) + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() + 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' })) +} + +/** + * 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] }) + + renderPanel() + + expect(await itemNames()).toEqual([offerSentName, firstCallName]) +}) + +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: offerSentName }) + 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: followUpName }) + 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, Paid in cash.' }) + 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 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.' }], + visits: [{ id: ID1, minutes: 45 }], + }) + + renderPanel() + + expect(await itemNames()).toEqual([offerSentName, 'Left a message.']) + expect(await itemNames('Visits')).toEqual(['Minutes: 45']) + 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 () => { + vi.stubEnv('TZ', 'America/Los_Angeles') + serveCatalogue([history]) + serveValues({ history: [offerSent] }) + + renderPanel() + + const item = await screen.findByRole('listitem', { name: offerSentName }) + 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') + expect(addButton().className).toMatch(/is-loading/) + adding.release() + await waitFor(() => expect(addButton().className).not.toMatch(/is-loading/)) +}) + +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] }) + capture('AddContactFieldEntry', added) + + const { graph } = renderPanel() + await screen.findByRole('listitem', { name: firstCallName }) + 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(followUpName)) + 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: ${firstCallName}` })) + 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 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 }) + 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: ${firstCallName}` })) + + 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() + 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: ${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: ${firstCallName}` })).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(firstCallName) + + 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(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: ${offerSentName}` })).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(firstCallName) + await waitFor(() => expect(graph.refetch).toHaveBeenCalled()) + + 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: firstCallName })).not.toBeInTheDocument()) +}) + +test('a removal moves focus to the next entry', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + capture('DeleteContactFieldEntry', removed) + + renderPanel() + await removeEntry(offerSentName) + + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: firstCallName }))).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(firstCallName) + + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: offerSentName }))).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(firstCallName) + await waitFor(() => expect(firstButton(screen.getByRole('listitem', { name: offerSentName }))).toHaveFocus()) + await removeEntry(offerSentName) + + 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(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: ${firstCallName}` })).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: firstCallName }) + change({ history: null }) + await removeEntry(firstCallName) + + 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(firstCallName) + + expect(await screen.findByRole('alert')).toHaveTextContent('That entry no longer exists.') + expect(graph.refetch).toHaveBeenCalledWith(['ContactFieldValues']) + expect(screen.getByRole('button', { name: `Remove entry: ${firstCallName}` })).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(firstCallName) + + expect(await screen.findByRole('alert')).toHaveTextContent('The entry could not be removed.') + const kept = screen.getByRole('button', { name: `Remove entry: ${firstCallName}` }) + 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(firstCallName) + await screen.findByRole('alert') + const removing = hold('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) + await removeEntry(firstCallName) + 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('asking to remove an entry clears an earlier failure', async () => { + serveCatalogue([history]) + serveValues({ history: [firstCall] }) + capture('DeleteContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + await removeEntry(firstCallName) + await screen.findByRole('alert') + await userEvent.click(screen.getByRole('button', { name: `Remove entry: ${firstCallName}` })) + + 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(firstCallName) + await screen.findByRole('alert') + await editEntry(firstCallName) + + 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(firstCallName) + 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]) + serveValues({ history: [firstCall], jobTitle: null }) + capture('DeleteContactFieldEntry', refusal('VALIDATION', 'field_unknown')) + + const { graph } = renderPanel() + await screen.findByRole('listitem', { name: firstCallName }) + serveCatalogue([jobTitle]) + await removeEntry(firstCallName) + + 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: offerSentName }) + 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, jobTitle]) + 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], jobTitle: null } }, + }) + }), + ) + + 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: firstCallName })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save fields' })).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']) +}) + +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] }) + + renderPanel() + 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: offerSentName })) + 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(offerSentName) + + 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(offerSentName) + await editEntry(firstCallName) + + 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: ${firstCallName}` })) + await editEntry(offerSentName) + + 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(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: ${offerSentName}` })).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(offerSentName) + 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(offerSentName) + 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 locks its cells, Save, Cancel and every other row', async () => { + serveCatalogue([history]) + serveValues({ history: [offerSent, firstCall] }) + const updating = hold('UpdateContactFieldEntry', updated) + + renderPanel() + const editor = await editEntry(offerSentName) + 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: ${firstCallName}` })).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(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: ${offerSentName}` })).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(offerSentName) + 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(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: ${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 () => { + speakTemplates() + serveCatalogue([history, jobTitle]) + serveValues({ history: [offerSent], jobTitle: null }) + capture('UpdateContactFieldEntry', refusal('VALIDATION', 'field_unknown')) + + const { graph } = renderPanel() + const editor = await editEntry(offerSentName) + 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(offerSentName) + await userEvent.click(editor.getByRole('button', { name: 'Save entry' })) + + 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(offerSentName) + 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] }) + capture('UpdateContactFieldEntry', { errors: [{ message: 'boom' }] }) + + renderPanel() + 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' }] }) + 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(offerSentName) + + 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 () => { + 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(offerSentName) + await userEvent.click(editor.getByLabelText('Comment')) + adding.release() + + await waitFor(() => expect(comment).toHaveValue('')) + expect(editor.getByLabelText('Comment')).toHaveFocus() +}) 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([]) diff --git a/plugins/fields/frontend/test/harness.tsx b/plugins/fields/frontend/test/harness.tsx new file mode 100644 index 0000000..d40b530 --- /dev/null +++ b/plugins/fields/frontend/test/harness.tsx @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +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, 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', + 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' }, + ], +} + +/** 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.' } + +/** 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. + */ +export function renderPanel() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { graph } = fakeGraphClient() + 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] +} + +/** + * 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 } }, + }), + ), + ) +} + +/** + * Answers the values query with whatever the returned setter last stored. + * @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. + */ +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/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) +}) diff --git a/plugins/fields/frontend/test/panel.test.tsx b/plugins/fields/frontend/test/panel.test.tsx index e946ea1..830b026 100644 --- a/plugins/fields/frontend/test/panel.test.tsx +++ b/plugins/fields/frontend/test/panel.test.tsx @@ -1,20 +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' - -const contactID = '0198c000-0000-7000-8000-000000000401' +import { captureWrite, contactID, history, jobTitle, renderPanel, serveCatalogue, serveValues } from './harness' const birthDate = { __typename: 'FieldDefinition', @@ -25,32 +16,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 +107,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,275 +268,8 @@ 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}` })) -} - -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]) + serveCatalogue([history, jobTitle]) server.use( graphql.query('ContactFieldValues', () => HttpResponse.json({ errors: [{ message: 'boom' }] })), ) @@ -603,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..5ccae21 --- /dev/null +++ b/plugins/fields/frontend/useEntryActions.ts @@ -0,0 +1,175 @@ +// 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 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, updateContactFieldEntryMutation } from './operations' + +/** RowFocus names the button of one row focus moves to. */ +export interface RowFocus { + id: string + 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: RowMode + pending: boolean + locked: boolean + focus: RowFocus | undefined +} + +/** 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 +} + +/** 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<{ 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() + + 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 === '' ? focusAdd() : 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 = { + edit: (id) => { + setOpen({ id, as: 'edit' }) + setFailure('') + }, + cancel: (id) => { + setOpen(null) + setFailure('') + 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, as: 'confirm' }) + setFailure('') + setRowFocus({ id, on: 'keep' }) + }, + keep: (id) => { + setOpen(null) + 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(null) + whenFocusStayed(from, () => setRowFocus({ id, on: 'remove' })) + }, + }) + }, + } + + const rowOf = (id: string): RowState => ({ + mode: open?.id === id ? open.as : '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/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 fe5743e..6fc5b60 100644 --- a/plugins/fields/graphql.go +++ b/plugins/fields/graphql.go @@ -170,12 +170,18 @@ 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"}, {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"}, + {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 4bb8cfc..e048bb9 100644 --- a/plugins/fields/graphql_internal_test.go +++ b/plugins/fields/graphql_internal_test.go @@ -78,11 +78,31 @@ 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 { + if got := fieldReason(sentinel); got != reason { + t.Errorf("fieldReason(%v) = %q, want %q", sentinel, got, reason) + } + } +} + +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", + + errRepeaterEntriesOnly: "field_repeater_entries_only", } for sentinel, reason := range want { 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) } diff --git a/plugins/fields/languages/alphone-fields.pot b/plugins/fields/languages/alphone-fields.pot index 766bd8f..7d80adc 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." @@ -22,6 +22,12 @@ 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 "" + msgid "A sub field name starts lowercase and runs together, like followUp." msgstr "" @@ -52,22 +58,34 @@ msgstr "" msgid "Archive %(label)s" msgstr "" +msgid "Blank entry" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "Edit entry" +msgstr "" + +msgid "Edit entry: %(name)s" +msgstr "" + msgid "Fields" msgstr "" -msgid "Kind" +msgid "Fill in at least one part of the entry." msgstr "" -msgid "Label" +msgid "Keep" msgstr "" -msgid "Loading fields…" +msgid "Kind" msgstr "" -msgid "Move entry down" +msgid "Label" msgstr "" -msgid "Move entry up" +msgid "Loading fields…" msgstr "" msgid "Move sub field down" @@ -91,12 +109,24 @@ 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 entry" +msgstr "" + msgid "Save fields" msgstr "" @@ -109,6 +139,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 "" @@ -127,6 +163,15 @@ 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 entry could not be saved." +msgstr "" + msgid "The field could not be archived." msgstr "" @@ -139,6 +184,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 "" @@ -146,6 +194,18 @@ msgctxt "admin section" msgid "Fields" msgstr "" +msgctxt "entry cell" +msgid "No" +msgstr "" + +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 147b84d..01402db 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." @@ -28,6 +28,14 @@ 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." + #, 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." @@ -68,10 +76,34 @@ msgstr "Archivar" msgid "Archive %(label)s" msgstr "Archivar %(label)s" +#, fuzzy +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" +#, fuzzy +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" @@ -84,14 +116,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" @@ -120,14 +144,30 @@ 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 entry" +msgstr "Guardar la entrada" + #, fuzzy msgid "Save fields" msgstr "Guardar los campos" @@ -144,6 +184,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." @@ -168,6 +216,18 @@ 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 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." @@ -184,6 +244,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." @@ -193,6 +257,21 @@ 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 "entry name" +msgid "%(day)s, %(text)s" +msgstr "%(day)s, %(text)s" + #, fuzzy msgctxt "field kind" msgid "Choice" 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..06cd9e0 --- /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', 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 + 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; diff --git a/plugins/fields/seed.go b/plugins/fields/seed.go index 403648f..6769b89 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. @@ -65,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` @@ -87,5 +93,11 @@ func (p *Plugin) seedValues(ctx context.Context) error { missing[name] = value } } + if _, kept := held[contactID][demoHistoryName]; !kept { + 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) + } + } return p.store.writeValues(ctx, contactID, missing) } diff --git a/plugins/fields/seed_test.go b/plugins/fields/seed_test.go index ba34beb..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" @@ -178,12 +179,62 @@ 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-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 TestSeedKeepsTheNewestDemoHistoryACapHolds(t *testing.T) { + t.Parallel() + + p := newMigratedPlugin(t) + maria := seedDemoContact(t, p) + p.entriesMax = 2 + + 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) + } + _, 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(held[maria]["history"], rows) { - t.Errorf("history = %#v, want the two demo entries in order", held[maria]["history"]) + 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) } } 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 diff --git a/plugins/fields/value.go b/plugins/fields/value.go index 9f6b34a..885619c 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,60 @@ 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") + + 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. +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 { @@ -93,8 +146,11 @@ 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 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 { @@ -103,15 +159,6 @@ func checkValues(live *view, given map[string]any) (map[string]any, error) { unknown = append(unknown, name) 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 - } coerced, err := coerce(held, value) if err != nil { return nil, fmt.Errorf("%w: %s expects %s", errWrongKind, name, held) @@ -125,43 +172,20 @@ 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 := make(map[string]kind, len(columns)) - for _, column := range columns { - kindsOf[column.Name] = column.Kind - } - 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 +// 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) } - unknown = append(unknown, stray...) - if len(row) > 0 { - rows = append(rows, row) - } - } - if len(rows) == 0 { - return nil, unknown, nil } - return rows, unknown, nil + sort.Strings(named) + return named } -// 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 cb10763..87ac889 100644 --- a/plugins/fields/value_internal_test.go +++ b/plugins/fields/value_internal_test.go @@ -241,114 +241,170 @@ func TestCheckValuesNamesTheKindItRefused(t *testing.T) { } } -func TestCheckValuesKeepsRepeaterRowsInOrder(t *testing.T) { +func TestCheckValuesRefusesARepeater(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"}, - }}) + for name, given := range map[string]any{ + "a list of rows": []any{map[string]any{"comment": "First call"}}, + "null": nil, + "a text": "a text", + } { + _, err := checkValues(historyView(), map[string]any{"history": given}) + + if !errors.Is(err, errRepeaterEntriesOnly) || !strings.Contains(err.Error(), "history") { + t.Errorf("%s: checkValues() error = %v, want the repeater refused by name", name, err) + } + } +} + +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"] +} + +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("checkValues() error = %v, want nil", err) + t.Fatalf("checkEntry() 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"}, + 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) } - if !reflect.DeepEqual(checked["history"], want) { - t.Errorf("history = %#v, want the rows in the order written with every cell coerced", checked["history"]) +} + +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 TestCheckValuesReportsEveryBadRowKeyAtOnce(t *testing.T) { +func TestCheckEntryNamesTheCellItRefused(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", - }) + _, err := checkEntry("visits", historyColumnsOf(), map[string]any{"date": "not a date"}, "") - if !errors.Is(err, errNoField) { - t.Fatalf("checkValues() error = %v, want errNoField", err) + 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) } - 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 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 TestCheckValuesNamesTheCellItRefused(t *testing.T) { +func TestCheckEntryRefusesAnEntryWithEveryCellBlank(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"}, - }}) + 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, errWrongKind) || !strings.Contains(err.Error(), "history[1].date expects DATE") { - t.Errorf("checkValues() error = %v, want the cell named with its kind", err) + if !errors.Is(err, errEntryEmpty) { + t.Errorf("%s: checkEntry() error = %v, want errEntryEmpty", name, err) + } } } -func TestCheckValuesDropsNullCellsAndEmptyRows(t *testing.T) { +func TestCheckEntryDropsBlankTextBesideAFilledCell(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}, - }}) + checked, err := checkEntry("history", historyColumnsOf(), + map[string]any{"date": "2026-09-01", "comment": " "}, "") if err != nil { - t.Fatalf("checkValues() error = %v, want nil", err) + t.Fatalf("checkEntry() 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"]) + 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 TestCheckValuesClearsARepeaterWrittenWithoutRows(t *testing.T) { +func TestCheckEntryKeepsTextSurroundedBySpaceAsWritten(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}}, - } { - checked, err := checkValues(historyView(), map[string]any{"history": given}) + checked, err := checkEntry("history", historyColumnsOf(), + map[string]any{"comment": " Follow-up call.\nAsked for a second quote. "}, "") - 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 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 TestCheckValuesRefusesRowsOfAnotherShape(t *testing.T) { +func TestCheckEntryRefusesAnIDCellOnAnAdd(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"}, + _, 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) } - for name, testCase := range cases { - t.Run(name, func(t *testing.T) { - t.Parallel() +} - _, err := checkValues(historyView(), map[string]any{"history": testCase.given}) +func TestCheckEntryAcceptsTheEntrysOwnIDBack(t *testing.T) { + t.Parallel() + own := "0199a3c4-0000-7000-8000-000000000001" + given := map[string]any{"id": own, "comment": "First call"} - if !errors.Is(err, errWrongKind) || !strings.Contains(err.Error(), testCase.want) { - t.Errorf("checkValues() error = %v, want %q", err, testCase.want) - } - }) + 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) } } 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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeb37a4..0c554c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,11 +156,11 @@ 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) + 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) @@ -346,11 +346,11 @@ 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) + 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) @@ -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 @@ -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' @@ -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)) @@ -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/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/package.json b/sdk/frontend/package.json index c40d9f5..1107590 100644 --- a/sdk/frontend/package.json +++ b/sdk/frontend/package.json @@ -9,8 +9,8 @@ "./testing": "./testing.tsx" }, "dependencies": { - "@gopherium/godmin": "0.10.0", - "@gopherium/gottext": "0.5.0", + "@gopherium/godmin": "0.11.0", + "@gopherium/gottext": "0.5.1", "@gopherium/react-auth": "0.9.0", "@tanstack/react-query": "^5.102.8", "@urql/exchange-graphcache": "9.0.1", 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() +}) diff --git a/test/e2e/tests/fields-repeater.spec.ts b/test/e2e/tests/fields-repeater.spec.ts index 5a85b08..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. @@ -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() @@ -56,31 +57,100 @@ 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, Follow-up call.') + + const removed = operationAnswer(page, 'DeleteContactFieldEntry') + 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 + + 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.') + const saved = operationAnswer(page, 'UpdateContactFieldEntry') + await editor.getByRole('button', { name: 'Save entry' }).click() + await saved 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, 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.', + ) + 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) => { + const name = operationOf(request) + if (name !== '') { + names.push(name) + } + }) + return names +} + +/** + * 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) => 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 ?? '' +} + +/** + * 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('') +} diff --git a/test/features/features/fields-repeater.feature b/test/features/features/fields-repeater.feature index 67b8b45..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,91 +53,292 @@ 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 + 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 | | date | Date | DATE | | comment | Comment | LONGTEXT | - When the operator writes the rows into "history" of the contact: + 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 | + Then the definition is refused with the reason "field_kind_locked" + + Scenario: Introspection lists a repeater typed JSON + Given the repeater "history" labelled "History" is defined with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + When the Contact type is introspected + Then the introspection lists "history" answering the scalar "JSON" + + Scenario: A repeater is not offered as an import column + When the operator defines the repeater "history" labelled "History" with sub fields: + | name | label | kind | + | date | Date | DATE | + | comment | Comment | LONGTEXT | + Then the catalogue lists "history" with label "History" and kind REPEATER + And the mapping registry does not list "history" + + 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" + + 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 | + + 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-10 | Sent the offer | | 2026-09-01 | First call | - Then querying the contact for "history" answers the rows: + | 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 - Scenario: A row naming an unknown sub field is refused + 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 writes the rows into "history" of the contact: + When the operator adds an entry to "history" of the contact: | date | mood | | 2026-09-01 | happy | - Then the write is refused naming "history[0].mood" as the bad key + Then the write is refused naming "history.mood" as the bad key - Scenario: A row holding a value of the wrong kind is refused + 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 + + 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 writes the rows into "history" of the contact: + 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 - Scenario: Writing no rows clears the repeater + 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 | - 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" + When the operator adds the raw entry to "history" of the contact + Then the change is refused with the reason "field_entry_empty" - Scenario: An archived repeater defined again with other sub fields is refused + Examples: + | entry | + | {} | + | {"date": null, "comment": ""} | + | {"comment": " "} | + + 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" + + 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 + + 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 | - 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 | - Then the definition is refused with the reason "field_kind_locked" + 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 + + 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 | + + 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 - Scenario: An archived repeater defined again with the same sub fields answers its rows + 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 writes the rows into "history" of the contact: + 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 | + + 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 | + + 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" + + 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 + + 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" + + 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 | + + 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 | - | date | Date | DATE | | comment | Comment | LONGTEXT | - Then querying the contact for "history" answers the rows: - | date | comment | - | 2026-09-01 | First call | + Then the contact's "history" answers the entries, newest first: + | comment | + | First call | + And every entry keeps the id its add answered - Scenario: Introspection lists a repeater typed JSON + Scenario: Saving other fields keeps the entries Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | - | date | Date | DATE | | comment | Comment | LONGTEXT | - When the Contact type is introspected - Then the introspection lists "history" answering the scalar "JSON" + 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 - Scenario: A repeater is not offered as an import column - When the operator defines the repeater "history" labelled "History" with sub fields: + Scenario: Writing a repeater through the field values is refused + Given the repeater "history" labelled "History" is defined with sub fields: | name | label | kind | - | date | Date | DATE | | comment | Comment | LONGTEXT | - Then the catalogue lists "history" with label "History" and kind REPEATER - And the mapping registry does not list "history" + 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..a1c2104 100644 --- a/test/features/features/fields-tenants.feature +++ b/test/features/features/fields-tenants.feature @@ -57,3 +57,34 @@ 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" + + 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" + + 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 | diff --git a/test/features/steps_fields_entries_test.go b/test/features/steps_fields_entries_test.go new file mode 100644 index 0000000..aeb8f74 --- /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. +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..49cc39a 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,48 +130,27 @@ 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. +// 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) - }) } 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.