mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-06-03 13:49:02 -04:00
Update docs
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
- Seeking — the type of connection someone is looking for (friendship, relationship, collaboration, etc.).
|
||||
- Living — where someone currently lives, with a selectable search radius.
|
||||
- Age — age range.
|
||||
- Gender — gender identity.
|
||||
- Gender — gender identity (multi-select with extended list hidden behind "Show more").
|
||||
- Orientation — sexual orientation (multi-select with extended list hidden behind "Show more").
|
||||
- Status — current relationship status (e.g., single, in a relationship).
|
||||
- Style — preferred romantic relationship structure (e.g., monogamous, polyamorous).
|
||||
- Has Kids — whether someone currently has children.
|
||||
|
||||
@@ -1,53 +1,297 @@
|
||||
# Adding a new profile field
|
||||
|
||||
A profile field is any variable associated with a user profile, such as age, politics, diet, etc. You may want to add a
|
||||
new profile field if it helps people find better matches.
|
||||
A profile field is any variable associated with a user profile (age, politics, diet, orientation, etc.).
|
||||
|
||||
To do so, you add code here:
|
||||
## Field types
|
||||
|
||||
- common/src/supabase/schema.ts
|
||||
- web/components/filters/choices.tsx (if multi choices)
|
||||
- web/components/optional-profile-form.tsx
|
||||
- web/components/profile-about.tsx
|
||||
- backend/api/src/get-profiles.ts
|
||||
- common/src/api/schema.ts ('get-profiles' props)
|
||||
- common/src/api/zod-types.ts (optionalProfilesSchema)
|
||||
- web/components/filters/filters.tsx
|
||||
- common/src/filters.ts
|
||||
- common/src/filters-format.ts
|
||||
- web/components/filters/use-filters.ts (yourFilters and isYourFilters)
|
||||
- tests/e2e/backend/utils/userInformation.ts (UserAccountInformationForSeeding)
|
||||
- Add translations (see [internationalization.md](internationalization.md))
|
||||
Choose the right storage type before starting:
|
||||
|
||||
Note that you will also need to add a column to the `profiles` table; you
|
||||
can do so via this SQL command (change the type and index if not `TEXT`):
|
||||
| Kind | DB type | Example fields |
|
||||
| -------------------- | --------------------- | ------------------------------------- |
|
||||
| Single-select choice | `TEXT` | `gender`, `mbti`, `education_level` |
|
||||
| Multi-select choices | `TEXT[]` | `diet`, `religion`, `orientation` |
|
||||
| Free text | `TEXT` | `political_details`, `gender_details` |
|
||||
| Numeric | `INTEGER` / `NUMERIC` | `age`, `drinks_per_month` |
|
||||
|
||||
Array fields need a GIN index; scalar choice/text fields use btree.
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step checklist
|
||||
|
||||
### 1. `common/src/choices.ts`
|
||||
|
||||
For choice fields, add a choices constant and its inverse:
|
||||
|
||||
```ts
|
||||
export const MY_CHOICES = {
|
||||
'Label A': 'value_a',
|
||||
'Label B': 'value_b',
|
||||
} as const
|
||||
|
||||
export const INVERTED_MY_CHOICES = invert(MY_CHOICES)
|
||||
export type MyChoiceTuple = {
|
||||
[K in keyof typeof MY_CHOICES]: [K, (typeof MY_CHOICES)[K]]
|
||||
}[keyof typeof MY_CHOICES]
|
||||
```
|
||||
|
||||
**"Show more" pattern** — when the list is long and a few choices cover most cases (such as man/woman for gender),
|
||||
export defaults and extras so the UI can collapse the uncommon ones:
|
||||
|
||||
```ts
|
||||
export const DEFAULT_MY_CHOICES = [MY_CHOICES.ValueA, MY_CHOICES.ValueB]
|
||||
export const EXTRA_MY_CHOICES = Object.values(MY_CHOICES).filter(
|
||||
(v) => !DEFAULT_MY_CHOICES.includes(v as any),
|
||||
)
|
||||
```
|
||||
|
||||
For gender, the defaults live in `common/src/gender.ts` (`DEFAULT_GENDERS`, `EXTRA_GENDERS`).
|
||||
|
||||
### 2. `common/src/supabase/schema.ts`
|
||||
|
||||
Add the column to the `Row`, `Insert`, and `Update` blocks of the `profiles` table (around line 1115):
|
||||
|
||||
```ts
|
||||
// Row:
|
||||
my_field: string | null // scalar
|
||||
my_field: string[] | null // array
|
||||
my_field_details: string | null // free-text companion
|
||||
|
||||
// Insert / Update (same but optional):
|
||||
my_field ? : string | null
|
||||
```
|
||||
|
||||
### 3. Database migration
|
||||
|
||||
Create `backend/supabase/migrations/YYYYMMDD_add_<field>.sql`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN profile_field TEXT;
|
||||
ADD COLUMN IF NOT EXISTS my_field TEXT, -- scalar
|
||||
ADD COLUMN IF NOT EXISTS my_field TEXT[], -- array
|
||||
ADD COLUMN IF NOT EXISTS my_field_details TEXT;
|
||||
-- free-text companion
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_profile_field ON profiles USING btree (mbti);
|
||||
-- Array fields need GIN; scalar choice/text fields use btree:
|
||||
CREATE INDEX IF NOT EXISTS profiles_my_field_gin ON profiles USING GIN (my_field);
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_my_field ON profiles USING btree (my_field);
|
||||
```
|
||||
|
||||
Store it in `add_<profile_field>.sql` in the [migrations](../backend/supabase/migrations) folder, add the line to
|
||||
backend/supabase/migration.sql and
|
||||
run [migrate.sh](../scripts/migrate.sh) from the root folder:
|
||||
|
||||
```bash
|
||||
./scripts/migrate.sh backend/supabase/migrations/add_<profile_field>.sql
|
||||
```
|
||||
|
||||
Optionally, if you use the remote dev DB, run the SQL above in the dev DB and sync the database types from supabase to
|
||||
the local files (which assist Typescript in typing):
|
||||
|
||||
```bash
|
||||
yarn --cwd=backend/api regen-types-dev
|
||||
```
|
||||
|
||||
If you use your local DB, load the new schema with:
|
||||
Run it (user runs this, not Claude):
|
||||
|
||||
```bash
|
||||
yarn test:db:reset
|
||||
```
|
||||
|
||||
That's it!
|
||||
### 4. `common/src/api/zod-types.ts`
|
||||
|
||||
Add to `optionalProfilesSchema`:
|
||||
|
||||
```ts
|
||||
my_field: z.string().optional().nullable(), // scalar
|
||||
my_field
|
||||
:
|
||||
z.array(z.string()).optional().nullable(), // array
|
||||
my_field_details
|
||||
:
|
||||
z.string().optional().nullable(), // free-text
|
||||
```
|
||||
|
||||
### 5. `backend/api/src/get-profiles.ts`
|
||||
|
||||
Add a filter WHERE clause. The column is already selected automatically (see step 2).
|
||||
|
||||
**Scalar choice:**
|
||||
|
||||
```ts
|
||||
my_field?.length && where(`my_field = ANY($(my_field))`, {my_field}),
|
||||
```
|
||||
|
||||
**Array (allow profiles with no value set):**
|
||||
|
||||
```ts
|
||||
my_field?.length &&
|
||||
where(`my_field IS NULL OR my_field = '{}' OR my_field && $(my_field)`, {my_field}),
|
||||
```
|
||||
|
||||
**Keyword search** — add to `textFields` (free text) or `arrayChoiceFields` / `choiceFields` (choices):
|
||||
|
||||
```ts
|
||||
// free text:
|
||||
const textFields = [..., 'my_field_details'
|
||||
]
|
||||
|
||||
// single-select choice:
|
||||
const choiceFields = [..., {field: 'my_field', choices: MY_CHOICES}
|
||||
]
|
||||
|
||||
// multi-select choice:
|
||||
const arrayChoiceFields = [..., {field: 'my_field', choices: MY_CHOICES}
|
||||
]
|
||||
```
|
||||
|
||||
Also add `my_field?: string[]` to `profileQueryType`.
|
||||
|
||||
### 6. `common/src/api/schema.ts`
|
||||
|
||||
Add to the `get-profiles` props:
|
||||
|
||||
```ts
|
||||
my_field: arraybeSchema.optional(), // array
|
||||
my_field
|
||||
:
|
||||
z.string().optional(), // scalar
|
||||
```
|
||||
|
||||
### 7. `common/src/filters.ts`
|
||||
|
||||
Add to the `FilterFields` Pick and set a default in `initialFilters`:
|
||||
|
||||
```ts
|
||||
// in FilterFields Pick:
|
||||
|
|
||||
'my_field'
|
||||
|
||||
// in initialFilters:
|
||||
my_field: undefined,
|
||||
```
|
||||
|
||||
### 8. `common/src/filters-format.ts`
|
||||
|
||||
Add a branch in the array-value formatter so filter chips display human-readable labels:
|
||||
|
||||
```ts
|
||||
} else
|
||||
if (key === 'my_field') {
|
||||
value = value.map(
|
||||
(s) => translate(`profile.my_field.${s}`, INVERTED_MY_CHOICES[s] ?? s),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Also add `my_field: ''` to `filterLabels`.
|
||||
|
||||
### 9. `web/components/filters/my-field-filter.tsx` (new file)
|
||||
|
||||
Follow the pattern of `orientation-filter.tsx`:
|
||||
|
||||
- `MyFieldFilterText` — renders the chip summary ("Any X", specific labels, or "Multiple")
|
||||
- `MyFieldFilter` — renders `<MultiCheckbox>` with optional "show more" button
|
||||
|
||||
**"Show more" pattern:**
|
||||
|
||||
```tsx
|
||||
const [showAll, setShowAll] = useState(
|
||||
selected.some((v) => !DEFAULT_MY_CHOICES.includes(v)) ||
|
||||
EXTRA_MY_CHOICES.includes(profile?.my_field as any),
|
||||
)
|
||||
const visibleChoices = Object.fromEntries(
|
||||
Object.entries(MY_CHOICES).filter(
|
||||
([, v]) => showAll || DEFAULT_MY_CHOICES.includes(v) || selected.includes(v),
|
||||
),
|
||||
)
|
||||
// ...
|
||||
{
|
||||
!showAll && (
|
||||
<button type="button" onClick={() => setShowAll(true)}>
|
||||
{t('filter.my_field.show_more', 'Show more options')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 10. `web/components/filters/filters.tsx`
|
||||
|
||||
Import and add a `<FilterSection>` for the new filter. Keep imports sorted alphabetically.
|
||||
|
||||
### 11. `web/components/optional-profile-form.tsx`
|
||||
|
||||
Add the field to the profile edit form. Free-text companion fields use `<Input placeholder={t(...)} />`.
|
||||
|
||||
**"Show more" in the form** uses the same pattern as the filter (§9) but reads `profile.my_field` to decide whether to
|
||||
expand by default.
|
||||
|
||||
### 12. `web/components/profile-about.tsx`
|
||||
|
||||
Add a display component following existing patterns (e.g., `Religion`, `Orientation`):
|
||||
|
||||
```tsx
|
||||
function MyField(props: {profile: Profile}) {
|
||||
const {profile} = props
|
||||
const t = useT()
|
||||
const values = (profile as any).my_field as string[] | null | undefined
|
||||
const details = (profile as any).my_field_details as string | null | undefined
|
||||
if (!values?.length && !details) return null
|
||||
const text = values
|
||||
?.map((v) => t(`profile.my_field.${v}`, INVERTED_MY_CHOICES[v] ?? v))
|
||||
.join(', ')
|
||||
return (
|
||||
<Col>
|
||||
<span className="font-semibold">{t('profile.my_field_label', 'My Field')}</span>
|
||||
{text && <span>{text}</span>}
|
||||
{details && <span className="text-ink-600 italic">"{details}"</span>}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 13. `web/components/filters/use-filters.ts`
|
||||
|
||||
If the field reflects the viewer's own profile data (like `diet`, `religion`), add it to `yourFilters` and
|
||||
`isYourFilters` so the "My Filters" preset works:
|
||||
|
||||
```ts
|
||||
// yourFilters:
|
||||
my_field: you?.my_field?.length ? you.my_field : undefined,
|
||||
|
||||
// isYourFilters:
|
||||
isEqual(new Set(filters.my_field), new Set(you.my_field)) &&
|
||||
```
|
||||
|
||||
Skip this step if the field isn't part of the viewer's profile (e.g., a filter-only concept).
|
||||
|
||||
### 14. `tests/e2e/backend/utils/userInformation.ts`
|
||||
|
||||
Add the field to `UserAccountInformationForSeeding` with realistic faker values so E2E seeds work.
|
||||
|
||||
### 15. Translations
|
||||
|
||||
Add keys to `common/messages/fr.json` and `common/messages/de.json` (alphabetically sorted). Required key groups for a
|
||||
typical choice field:
|
||||
|
||||
| Key pattern | English fallback | Notes |
|
||||
| -------------------------------------- | --------------------- | -------------------------------- |
|
||||
| `filter.any_my_field` | `'Any X'` | chip when nothing selected |
|
||||
| `filter.my_field.show_more` | `'Show more options'` | show-more button in filter |
|
||||
| `profile.my_field` | `'X'` | section heading in profile-about |
|
||||
| `profile.my_field.<value>` | label | one per choice value |
|
||||
| `profile.my_field.details_placeholder` | `'Details about…'` | form input placeholder |
|
||||
| `profile.my_field.show_more` | `'Show more options'` | show-more button in form |
|
||||
| `profile.optional.my_field` | `'X'` | form section label |
|
||||
|
||||
Translation key naming: values use the DB value (e.g., `gray-asexual`, not `gray_asexual`). The `toKey()` helper in
|
||||
`common/src/parsing.ts` converts spaces to underscores and lowercases — the `translationPrefix` prop on
|
||||
`<MultiCheckbox>` uses this automatically.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference: which files change per field type
|
||||
|
||||
| File | Scalar choice | Array choices | Free text only |
|
||||
| --------------------------- | ------------------- | --------------------- | -------------- |
|
||||
| `choices.ts` | ✓ | ✓ | — |
|
||||
| `schema.ts` | ✓ | ✓ | ✓ |
|
||||
| Migration SQL | ✓ | ✓ | ✓ |
|
||||
| `zod-types.ts` | ✓ | ✓ | ✓ |
|
||||
| `get-profiles.ts` (filter) | ✓ | ✓ | — |
|
||||
| `get-profiles.ts` (search) | ✓ (choiceFields) | ✓ (arrayChoiceFields) | ✓ (textFields) |
|
||||
| `api/schema.ts` | ✓ | ✓ | — |
|
||||
| `filters.ts` | ✓ | ✓ | — |
|
||||
| `filters-format.ts` | ✓ | ✓ | — |
|
||||
| `*-filter.tsx` (new) | ✓ | ✓ | — |
|
||||
| `filters.tsx` | ✓ | ✓ | — |
|
||||
| `optional-profile-form.tsx` | ✓ | ✓ | ✓ |
|
||||
| `profile-about.tsx` | ✓ | ✓ | ✓ |
|
||||
| `use-filters.ts` | if self-referential | if self-referential | — |
|
||||
| `userInformation.ts` | ✓ | ✓ | ✓ |
|
||||
| Translation JSONs | ✓ | ✓ | ✓ |
|
||||
|
||||
Reference in New Issue
Block a user