Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5804124
test(features): pin the names a field cannot take
SirLouen Sep 27, 2026
7862825
fix(fields): refuse a field name every JavaScript object holds
SirLouen Sep 28, 2026
806ac02
test(features): run the scenario refusing inherited field names
SirLouen Sep 28, 2026
c42a682
fix(fields): move a stored field off a name every object holds
SirLouen Sep 28, 2026
45098e5
fix(fields): answer the stored id when a define brings a field back
SirLouen Sep 28, 2026
008d0ec
test(features): run the scenario reviving an archived field
SirLouen Sep 28, 2026
a652f9b
feat(fields): answer the names a field cannot take
SirLouen Sep 28, 2026
4d78b03
chore(graph): regenerate the schema with the reserved field names
SirLouen Sep 28, 2026
0035249
test(features): run the reserved names scenario the graph now serves
SirLouen Sep 28, 2026
bf92da5
test(fields): reserve every field the Contact type holds
SirLouen Sep 28, 2026
75a4802
test(e2e): let the Fields screen name new fields
SirLouen Sep 28, 2026
db3db51
feat(fields): read archived fields and reserved names for the Fields …
SirLouen Sep 28, 2026
d0f6b5d
feat(fields): make a new field's name from its label
SirLouen Sep 28, 2026
4309ac4
feat(fields): head the name column API name
SirLouen Sep 28, 2026
ab0e766
feat(fields): fetch the fields again when another one takes the name
SirLouen Sep 28, 2026
4ade4cf
test(frontend): expect the field list message in the merged templates
SirLouen Sep 28, 2026
de26f49
feat(fields): refuse a label a live field already has
SirLouen Sep 28, 2026
efb6ab1
docs(fields): explain field names and revival to API callers
SirLouen Sep 28, 2026
5d1f513
docs(fields): say AlphOne makes a field's name from its label
SirLouen Sep 28, 2026
5f74dcb
fix(fields): rewrite only the contact rows that hold an inherited name
SirLouen Sep 28, 2026
645500c
test(fields): move two values of one contact off inherited names
SirLouen Sep 28, 2026
61aa33d
fix(fields): keep the add form when a define never reaches the server
SirLouen Sep 28, 2026
566e7b5
fix(fields): keep an earlier define failure hidden once a label is re…
SirLouen Sep 28, 2026
6bbd711
test(fields): pin which refused names the add form numbers
SirLouen Sep 28, 2026
0656df2
test(fields): document the Fields screen test helpers
SirLouen Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 54 additions & 21 deletions docs/src/content/docs/guides/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,9 @@ running AlphOne, without a restart or a new version.

## Add a field

Open **Fields** and fill in three things.
Open **Fields** and fill in two things.

**Label** is the text people see on screen, such as `Birth date`. Change it
whenever you like.

**Name** is what the API calls the field, such as `birthDate`. It starts with
a lowercase letter and holds only letters and digits. Pick it carefully,
because it cannot be changed later.
**Label** is the text people see on screen, such as `Birth date`.

**Kind** says what the field holds. Seven kinds are available.

Expand All @@ -31,8 +26,25 @@ because it cannot be changed later.
| Choice | A short line, kept apart from Text so a later release can add a fixed option list |
| Repeater | A list of entries that share the same parts, such as a contact history |

Save, and the field exists. Open any contact and it is there, waiting to be
filled in.
Press **Add field**, and the field exists. Open any contact and it is there,
waiting to be filled in.

AlphOne makes the field's name from its label, so `Birth date` becomes
`birthDate`. The API uses that name, and the field list shows it under
**API name**. If another field already has that name, even an archived one,
the new name gets the next free number, such as `birthDate2`. The number goes
straight after the name, so `Address 2` becomes `address22` when `address2`
is taken. The name cannot be changed later.

If a field in the list already has the label, the screen says so and adds
nothing. Case and spaces at either end do not matter, so `birth date` counts
as `Birth date`.

A few names are reserved, such as `name`, `tasks` and `constructor`. A label
that makes one of them gets a number too, so `Name` becomes `name2`. A label
with no Latin letters or digits, such as `???` or one written in Cyrillic, is
named after the word `field`. That name is reserved too, so it becomes
`field2`.

## Fill a field in

Expand All @@ -57,10 +69,10 @@ a label and a kind. For a history, that is `Date` as a Date and `Comment` as a
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`. A sub field labelled
`ID` becomes `id2`, because each entry keeps its own id under `id`.
repeater. AlphOne names a sub field from its label too, so
`Follow-up comment` becomes `followUpComment`. Two sub fields with 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.
Expand Down Expand Up @@ -91,6 +103,10 @@ You do not have to type every value in by hand. When you import a CSV or an
Excel file, your fields sit in the mapping dropdown beside Name, Email and
Phone. Point a column at one and the values arrive with the contacts.

A field named `email` or `phone` stays out of the dropdown, because Email and
Phone already use those names. The labels `Email` and `Phone` make exactly
those names, so choose a longer label, such as `Email consent`.

The kind is checked before anything is stored. A row whose cell does not fit
its field fails, the reason names the field and its kind, and no contact is
created for that row. Fix the spreadsheet and import it again.
Expand Down Expand Up @@ -120,15 +136,16 @@ leave old values that no longer fit.
Press **Archive** beside a field. It disappears from the contact screen and
from the API straight away.

Archiving does not delete anything. The values stay in the database. If you
create the field again later, with the same name and the same kind, the old
values come back. A repeater also needs the same sub fields, in the same
order and with the same names and kinds, or AlphOne refuses it.
Archiving does not delete anything. The values stay in the database, and the
archived field keeps its name. So a new field with the same label gets a
numbered name and starts empty. Only the API can bring the old field back with
its values. See [Using your fields from the API](#using-your-fields-from-the-api).

## Using your fields from the API

A field you create becomes a real field on `Contact` in the GraphQL API, under
the name you chose. So after adding `birthDate` you can ask for it directly:
its API name. So after adding `Birth date` you can ask for `birthDate`
directly:

```graphql
query {
Expand Down Expand Up @@ -188,9 +205,25 @@ mutation {
}
```

The API does not make sub field names for you. Send a camelCase name for each
one, unique inside the repeater. `id` is refused, because each entry keeps its
own id under that name.
The API does not make names for you. `defineField` takes a `name` such as
`birthDate`. It starts with a lowercase letter and uses only a to z, A to Z
and 0 to 9. Each sub field needs such a name too, unique inside its repeater.
`id` is refused for a sub field, because each entry keeps its own id under
that name.

`reservedFieldNames` lists the names no field can take. They are the fields
`Contact` is built with, such as `name` and `tasks`, and the names every
JavaScript object has, such as `constructor` and `toString`. Such a name is
refused with `field_name_reserved`. Upgrading AlphOne moves a field stored
under one of the JavaScript names to the next free name, such as
`constructor2`, with its values.

`fields(includeArchived: true)` also lists archived fields, each with its
`archivedAt`, so you can find the name to send. Calling `defineField` with an
archived field's name brings that field back with its values and the label
you send. The kind must match, and for a repeater so must the sub field names
and kinds, in the same order. Otherwise the call is refused with
`field_kind_locked`. The answer carries the field's old id.

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
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/graphql-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ The stock plugins add their own:
| `field_label_required` | | a field needs a label |
| `field_label_too_long` | | the label is past the cap |
| `field_kind_unknown` | | the kind is not one the plugin knows |
| `field_name_reserved` | | the name is already a column of the type |
| `field_name_reserved` | | the name is one `reservedFieldNames` lists |
| `field_sub_fields_required` | | a repeater needs at least one sub field |
| `field_sub_fields_unexpected` | | only a repeater holds sub fields |
| `field_sub_field_nested` | | a sub field cannot be a repeater |
Expand Down Expand Up @@ -520,7 +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` |
| Fields | `fields`, `reservedFieldNames`, `Contact.field` | `defineField`, `archiveField`, `writeContactFields`, `addContactFieldEntry`, `updateContactFieldEntry`, `deleteContactFieldEntry` |
| Imports | `imports`, `importJob`, `importFields` | `importUpload`, `importSetMapping`, `importCommit` |
| WhatsApp | `whatsAppConversations`, `whatsAppConversation` | `whatsAppSendMessage` |
| Version | `version` | |
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/test/locale-boot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ test('renders core and plugin reasons from one merged map', () => {
const merged = appErrorTemplates()

expect(merged.contact_name_required).toBe('A contact needs a name.')
expect(merged.field_name_taken).toBe('Another field already holds that name.')
expect(merged.field_name_taken).toBe('The field list just changed. Press Add field again.')
expect(merged.import_not_found).toBe('That import no longer exists.')
expect(merged.message_content_required).toBe('Write something to send.')
})
Expand Down
54 changes: 54 additions & 0 deletions graph/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions graph/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ type Query {
apiTokens: [ApiToken!]! @scope(area: "tokens", write: false)
webhooks: [Webhook!]! @scope(area: "webhooks", write: false)
fields(includeArchived: Boolean): [FieldDefinition!]! @scope(area: "fields", write: false)
reservedFieldNames: [String!]! @scope(area: "fields", write: false)
imports: [ImportJob!]! @scope(area: "imports", write: false)
importJob(id: UUID!): ImportJob @scope(area: "imports", write: false)
importFields: [ImportField!]! @scope(area: "imports", write: false)
Expand Down
2 changes: 1 addition & 1 deletion plugins/fields/entries_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ func TestEntryMutationsCheckTheCallersOwnFields(t *testing.T) {
_, 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 {
if _, err := p.store.define(acme, historyOf(historyColumns...)); err != nil {
t.Fatalf("define() in Acme error = %v, want nil", err)
}
p.catalog.forget(acme)
Expand Down
10 changes: 8 additions & 2 deletions plugins/fields/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ var (
errUnknownKind = errors.New("fields: unknown kind")
errBlankLabel = errors.New("fields: a label carries text")
errLabelTooLong = fmt.Errorf("fields: a label runs to %d characters", labelMax)
errReservedName = errors.New("fields: the name is already a field of the type")
errReservedName = errors.New("fields: the name is reserved")

errSubFieldsRequired = errors.New("fields: a repeater holds at least one sub field")
errSubFieldsUnexpected = errors.New("fields: only a repeater holds sub fields")
Expand Down Expand Up @@ -64,6 +64,12 @@ var kinds = map[kind]string{
// namePattern matches the camelCase names a definition accepts.
var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)

// inheritedNames lists the camelCase members every JavaScript object inherits.
var inheritedNames = map[string]bool{
"constructor": true, "hasOwnProperty": true, "isPrototypeOf": true, "propertyIsEnumerable": true,
"toLocaleString": true, "toString": true, "valueOf": true,
}

// scalar reports the GraphQL scalar the kind answers with.
func (k kind) scalar() string {
return kinds[k]
Expand Down Expand Up @@ -94,7 +100,7 @@ func newDefinition(
if !namePattern.MatchString(name) {
return Definition{}, errMalformedName
}
if reserved[name] {
if reserved[name] || inheritedNames[name] {
return Definition{}, errReservedName
}
held := kind(declared)
Expand Down
15 changes: 15 additions & 0 deletions plugins/fields/field_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,21 @@ func TestNewDefinitionRefusesAReservedName(t *testing.T) {
}
}

func TestNewDefinitionRefusesANameEveryObjectInherits(t *testing.T) {
t.Parallel()

for _, name := range []string{
"constructor", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable",
"toLocaleString", "toString", "valueOf",
} {
_, err := newDefinition(name, "Points", "NUMBER", nil)

if !errors.Is(err, errReservedName) {
t.Errorf("newDefinition(%q) error = %v, want errReservedName", name, err)
}
}
}

func TestNewDefinitionRefusesALabelBeyondTheCap(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading