Merge pull request 'Custom Fields: Harden implementation closes #194' (#204) from feature/harden-custom-fields into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #204
Reviewed-by: carla <carla@noreply.git.local-it.org>
This commit is contained in:
carla 2025-11-17 17:01:30 +01:00
commit a273b54c75
39 changed files with 2337 additions and 1269 deletions

View file

@ -81,8 +81,8 @@ lib/
├── membership/ # Membership domain
│ ├── membership.ex # Domain definition
│ ├── member.ex # Member resource
│ ├── property.ex # Custom property resource
│ ├── property_type.ex # Property type resource
│ ├── custom_field_value.ex # Custom field value resource
│ ├── custom_field.ex # CustomFieldValue type resource
│ └── email.ex # Email custom type
├── mv/ # Core application modules
│ ├── accounts/ # Domain-specific logic
@ -121,8 +121,8 @@ lib/
│ │ │ ├── search_bar_component.ex
│ │ │ └── sort_header_component.ex
│ │ ├── member_live/ # Member CRUD LiveViews
│ │ ├── property_live/ # Property CRUD LiveViews
│ │ ├── property_type_live/
│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
│ │ ├── custom_field_live/
│ │ └── user_live/ # User management LiveViews
│ ├── auth_overrides.ex # AshAuthentication overrides
│ ├── endpoint.ex # Phoenix endpoint
@ -740,14 +740,14 @@ end
# Good - preload relationships
members =
Member
|> Ash.Query.load(:properties)
|> Ash.Query.load(:custom_field_values)
|> Mv.Membership.list_members!()
# Avoid - causes N+1 queries
members = Mv.Membership.list_members!()
Enum.map(members, fn member ->
# This triggers a query for each member
Ash.load!(member, :properties)
Ash.load!(member, :custom_field_values)
end)
```
@ -1723,13 +1723,13 @@ end
# Good - preload relationships
members =
Member
|> Ash.Query.load([:properties, :user])
|> Ash.Query.load([:custom_field_values, :user])
|> Mv.Membership.list_members!()
# Avoid - causes N+1
members = Mv.Membership.list_members!()
Enum.map(members, fn member ->
properties = Ash.load!(member, :properties) # N queries!
custom_field_values = Ash.load!(member, :custom_field_values) # N queries!
end)
```
@ -1904,7 +1904,7 @@ defmodule Mv.Membership.Member do
@moduledoc """
Represents a club member with their personal information and membership status.
Members can have custom properties defined by the club administrators.
Members can have custom_field_values defined by the club administrators.
Each member is optionally linked to a user account for self-service access.
## Examples
@ -2050,7 +2050,7 @@ open doc/index.html
## [Unreleased]
### Added
- Member custom properties feature
- Member custom_field_values feature
- Email synchronization between user and member
### Changed
@ -2081,14 +2081,14 @@ open doc/index.html
```bash
# Create feature branch
git checkout -b feature/member-custom-properties
git checkout -b feature/member-custom-custom_field_values
# Work on feature
git add .
git commit -m "Add custom properties to members"
git commit -m "Add custom_field_values to members"
# Push to remote
git push origin feature/member-custom-properties
git push origin feature/member-custom-custom_field_values
```
### 8.2 Commit Messages
@ -2127,7 +2127,7 @@ Closes #123
```
fix: resolve N+1 query in member list
Preload properties relationship when loading members to avoid N+1 queries.
Preload custom_field_values relationship when loading members to avoid N+1 queries.
Performance improvement: reduced query count from 100+ to 2.
```

View file

@ -52,21 +52,21 @@ This document provides a comprehensive overview of the Mila Membership Managemen
- Bidirectional email sync with users
- Flexible address and contact data
#### `properties`
#### `custom_field_values`
- **Purpose:** Dynamic custom member attributes
- **Rows (Estimated):** Variable (N per member)
- **Key Features:**
- Union type value storage (JSONB)
- Multiple data types supported
- One property per type per member
- One custom field value per custom field per member
#### `property_types`
- **Purpose:** Schema definitions for custom properties
#### `custom_fields`
- **Purpose:** Schema definitions for custom_field_values
- **Rows (Estimated):** Low (admin-defined)
- **Key Features:**
- Type definitions
- Immutable and required flags
- Centralized property management
- Centralized custom field management
## Key Relationships
@ -77,7 +77,7 @@ User (0..1) ←→ (0..1) Member
Member (1) → (N) Properties
PropertyType (1)
CustomField (1)
```
### Relationship Details
@ -90,11 +90,11 @@ Member (1) → (N) Properties
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
2. **Member → Properties (1:N)**
- One member, many properties
- `ON DELETE CASCADE` - properties deleted with member
- Composite unique constraint (member_id, property_type_id)
- One member, many custom_field_values
- `ON DELETE CASCADE` - custom_field_values deleted with member
- Composite unique constraint (member_id, custom_field_id)
3. **Property → PropertyType (N:1)**
3. **CustomFieldValue → CustomField (N:1)**
- Properties reference type definition
- `ON DELETE RESTRICT` - cannot delete type if in use
- Type defines data structure
@ -121,8 +121,8 @@ Member (1) → (N) Properties
- Phone: `+?[0-9\- ]{6,20}`
- Postal code: 5 digits
### Property System
- Maximum one property per type per member
### CustomFieldValue System
- Maximum one custom field value per custom field per member
- Value stored as union type in JSONB
- Supported types: string, integer, boolean, date, email
- Types can be marked as immutable or required
@ -144,10 +144,10 @@ Member (1) → (N) Properties
- `join_date` (B-tree) - Date filtering
- `paid` (partial B-tree) - Payment status queries
**properties:**
- `member_id` - Member property lookups
- `property_type_id` - Type-based queries
- Composite `(member_id, property_type_id)` - Uniqueness
**custom_field_values:**
- `member_id` - Member custom field value lookups
- `custom_field_id` - Type-based queries
- Composite `(member_id, custom_field_id)` - Uniqueness
**tokens:**
- `subject` - User token lookups
@ -297,8 +297,8 @@ priv/repo/migrations/
| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
| `properties.member_id → members.id` | CASCADE | Delete properties with member |
| `properties.property_type_id → property_types.id` | RESTRICT | Prevent deletion of types in use |
| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member |
| `custom_field_values.custom_field_id → custom_fields.id` | RESTRICT | Prevent deletion of types in use |
### Validation Layers
@ -327,15 +327,15 @@ priv/repo/migrations/
- Member search (uses GIN index on search_vector)
- Member list with filters (uses indexes on join_date, paid)
- User authentication (uses unique index on email/oidc_id)
- Property lookups by member (uses index on member_id)
- CustomFieldValue lookups by member (uses index on member_id)
**Medium Frequency:**
- Member CRUD operations
- Property updates
- CustomFieldValue updates
- Token validation
**Low Frequency:**
- PropertyType management
- CustomField management
- User-Member linking
- Bulk operations
@ -396,10 +396,10 @@ Install "DBML Language" extension to view/edit DBML files with:
### Critical Tables (Priority 1)
- `members` - Core business data
- `users` - Authentication data
- `property_types` - Schema definitions
- `custom_fields` - Schema definitions
### Important Tables (Priority 2)
- `properties` - Member custom data
- `custom_field_values` - Member custom data
- `tokens` - Can be regenerated but good to backup
### Backup Strategy

View file

@ -18,7 +18,7 @@ Project mila_membership_management {
## Key Features:
- User authentication (OIDC + Password with secure account linking)
- Member management with flexible custom properties
- Member management with flexible custom fields
- Bidirectional email synchronization between users and members
- Full-text search capabilities (tsvector)
- Fuzzy search with trigram matching (pg_trgm)
@ -26,7 +26,7 @@ Project mila_membership_management {
## Domains:
- **Accounts**: User authentication and session management
- **Membership**: Club member data and custom properties
- **Membership**: Club member data and custom fields
## Required PostgreSQL Extensions:
- uuid-ossp (UUID generation)
@ -178,7 +178,7 @@ Table members {
**Relationships:**
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
- 1:N with properties (custom dynamic fields)
- 1:N with custom_field_values (custom dynamic fields)
**Validation Rules:**
- first_name, last_name: min 1 character
@ -191,20 +191,20 @@ Table members {
'''
}
Table properties {
Table custom_field_values {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})']
member_id uuid [not null, note: 'Link to member']
property_type_id uuid [not null, note: 'Link to property type definition']
custom_field_id uuid [not null, note: 'Link to custom field definition']
indexes {
(member_id, property_type_id) [unique, name: 'properties_unique_property_per_member_index', note: 'One property per type per member']
member_id [name: 'properties_member_id_idx']
property_type_id [name: 'properties_property_type_id_idx']
(member_id, custom_field_id) [unique, name: 'custom_field_values_unique_custom_field_per_member_index', note: 'One custom field value per custom field per member']
member_id [name: 'custom_field_values_member_id_idx']
custom_field_id [name: 'custom_field_values_custom_field_id_idx']
}
Note: '''
**Dynamic Custom Member Properties**
**Dynamic Custom Member Field Values**
Provides flexible, extensible attributes for members beyond the fixed schema.
@ -221,9 +221,9 @@ Table properties {
- `email`: Validated email addresses
**Constraints:**
- Each member can have only ONE property per property_type
- Properties are deleted when member is deleted (CASCADE)
- Property type cannot be deleted if properties exist (RESTRICT)
- Each member can have only ONE custom field value per custom field
- Custom field values are deleted when member is deleted (CASCADE)
- Custom field cannot be deleted if custom field values exist (RESTRICT)
**Use Cases:**
- Custom membership numbers
@ -233,34 +233,34 @@ Table properties {
'''
}
Table property_types {
Table custom_fields {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
name text [not null, unique, note: 'Property name/identifier (e.g., "membership_number")']
name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")']
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
description text [null, note: 'Human-readable description']
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
required boolean [not null, default: false, note: 'If true, all members must have this property']
required boolean [not null, default: false, note: 'If true, all members must have this custom field']
indexes {
name [unique, name: 'property_types_unique_name_index']
name [unique, name: 'custom_fields_unique_name_index']
}
Note: '''
**Property Type Definitions**
**CustomFieldValue Type Definitions**
Defines the schema and behavior for custom member properties.
Defines the schema and behavior for custom member custom_field_values.
**Attributes:**
- `name`: Unique identifier for the property type
- `name`: Unique identifier for the custom field
- `value_type`: Enforces data type consistency
- `description`: Documentation for users/admins
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
- `required`: Enforces that all members must have this property
- `required`: Enforces that all members must have this custom field
**Constraints:**
- `value_type` must be one of: string, integer, boolean, date, email
- `name` must be unique across all property types
- Cannot be deleted if properties reference it (ON DELETE RESTRICT)
- `name` must be unique across all custom fields
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
**Examples:**
- Membership Number (string, immutable, required)
@ -283,25 +283,25 @@ Table property_types {
Ref: users.member_id - members.id [delete: set null]
// Member → Properties (1:N)
// - One member can have multiple properties
// - Each property belongs to exactly one member
// - One member can have multiple custom_field_values
// - Each custom field value belongs to exactly one member
// - ON DELETE CASCADE: Properties deleted when member deleted
// - UNIQUE constraint: One property per type per member
Ref: properties.member_id > members.id [delete: cascade]
// - UNIQUE constraint: One custom field value per custom field per member
Ref: custom_field_values.member_id > members.id [delete: cascade]
// Property → PropertyType (N:1)
// - Many properties can reference one property type
// - Property type defines the schema/behavior
// - ON DELETE RESTRICT: Cannot delete type if properties exist
Ref: properties.property_type_id > property_types.id [delete: restrict]
// CustomFieldValue → CustomField (N:1)
// - Many custom_field_values can reference one custom field
// - CustomFieldValue type defines the schema/behavior
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
// ============================================
// ENUMS
// ============================================
// Valid data types for property values
// Determines how Property.value is interpreted
Enum property_value_type {
// Valid data types for custom field values
// Determines how CustomFieldValue.value is interpreted
Enum custom_field_value_type {
string [note: 'Text data']
integer [note: 'Numeric data']
boolean [note: 'True/False flags']
@ -335,8 +335,8 @@ TableGroup accounts_domain {
TableGroup membership_domain {
members
properties
property_types
custom_field_values
custom_fields
Note: '''
**Membership Domain**

View file

@ -131,11 +131,11 @@ Based on closed PRs from https://git.local-it.org/local-it/mitgliederverwaltung/
**Sprint 3 - 28.05 - 09.07**
- Member CRUD operations
- Basic property system
- Basic custom field system
- Initial UI with Tailwind CSS
**Sprint 4 - 09.07 - 30.07**
- Property types implementation
- CustomFieldValue types implementation
- Data validation
- Error handling improvements
@ -154,7 +154,7 @@ Based on closed PRs from https://git.local-it.org/local-it/mitgliederverwaltung/
**PR #147:** *Add seed data for members*
- Comprehensive seed data
- Test users and members
- Property type examples
- CustomFieldValue type examples
#### Phase 3: Search & Navigation (Sprint 6)
@ -379,21 +379,21 @@ end
**Complete documentation:** See [`docs/email-sync.md`](email-sync.md) for decision tree and sync rules.
#### 4. Property System (EAV Pattern)
#### 4. CustomFieldValue System (EAV Pattern)
**Implementation:** Entity-Attribute-Value pattern with union types
```elixir
# Property Type defines schema
defmodule Mv.Membership.PropertyType do
# CustomFieldValue Type defines schema
defmodule Mv.Membership.CustomField do
attribute :name, :string # "Membership Number"
attribute :value_type, :atom # :string, :integer, :boolean, :date, :email
attribute :immutable, :boolean # Can't change after creation
attribute :required, :boolean # All members must have this
end
# Property stores values
defmodule Mv.Membership.Property do
# CustomFieldValue stores values
defmodule Mv.Membership.CustomFieldValue do
attribute :value, :union, # Polymorphic value storage
constraints: [
types: [
@ -405,7 +405,7 @@ defmodule Mv.Membership.Property do
]
]
belongs_to :member
belongs_to :property_type
belongs_to :custom_field
end
```
@ -413,12 +413,12 @@ end
- Clubs need different custom fields
- No schema migrations for new fields
- Type safety with union types
- Centralized property management
- Centralized custom field management
**Constraints:**
- One property per type per member (composite unique index)
- One custom field value per custom field per member (composite unique index)
- Properties deleted with member (CASCADE)
- Property types protected if in use (RESTRICT)
- CustomFieldValue types protected if in use (RESTRICT)
#### 5. Authentication Strategy
@ -593,7 +593,7 @@ end
#### Database Migrations
**Key migrations in chronological order:**
1. `20250528163901_initial_migration.exs` - Core tables (members, properties, property_types)
1. `20250528163901_initial_migration.exs` - Core tables (members, custom_field_values, custom_fields)
2. `20250617090641_member_fields.exs` - Member attributes expansion
3. `20250620110850_add_accounts_domain.exs` - Users & tokens tables
4. `20250912085235_AddSearchVectorToMembers.exs` - Full-text search (tsvector + GIN index)
@ -772,7 +772,7 @@ end
- Admin user: `admin@mv.local` / `testpassword`
- Sample members: Hans Müller, Greta Schmidt, Friedrich Wagner
- Linked accounts: Maria Weber, Thomas Klein
- Property types: String, Date, Boolean, Email
- CustomFieldValue types: String, Date, Boolean, Email
**Test Helpers:**
```elixir
@ -956,9 +956,9 @@ mix credo --strict
mix credo suggest --format=oneline
```
### 8. Property Value Type Mismatch
### 8. CustomFieldValue Value Type Mismatch
**Issue:** Property value doesn't match property_type definition.
**Issue:** CustomFieldValue value doesn't match custom_field definition.
**Error:**
```
@ -966,16 +966,16 @@ mix credo suggest --format=oneline
```
**Solution:**
Ensure property value matches property_type.value_type:
Ensure custom field value matches custom_field.value_type:
```elixir
# Property Type: value_type = :integer
property_type = get_property_type("age")
# CustomFieldValue Type: value_type = :integer
custom_field = get_custom_field("age")
# Property Value: must be integer union type
{:ok, property} = create_property(%{
# CustomFieldValue Value: must be integer union type
{:ok, custom_field_value} = create_custom_field_value(%{
value: %{type: :integer, value: 25}, # Not "25" as string
property_type_id: property_type.id
custom_field_id: custom_field.id
})
```

View file

@ -87,12 +87,12 @@
---
#### 3. **Custom Fields (Property System)** 🔧
#### 3. **Custom Fields (CustomFieldValue System)** 🔧
**Current State:**
- ✅ Property types (string, integer, boolean, date, email)
- ✅ Property type management
- ✅ Dynamic property assignment to members
- ✅ CustomFieldValue types (string, integer, boolean, date, email)
- ✅ CustomFieldValue type management
- ✅ Dynamic custom field value assignment to members
- ✅ Union type storage (JSONB)
**Open Issues:**
@ -217,7 +217,7 @@
- ❌ Global settings management
- ❌ Club/Organization profile
- ❌ Email templates configuration
- ❌ Property type management UI (user-facing)
- ❌ CustomFieldValue type management UI (user-facing)
- ❌ Role and permission management UI
- ❌ System health dashboard
- ❌ Audit log viewer
@ -481,9 +481,9 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
| Mount | Purpose | Auth | Query Params | Events |
|-------|---------|------|--------------|--------|
| `/members` | Member list with search/sort | 🔐 | `?search=&sort_by=&sort_dir=` | `search`, `sort`, `delete`, `select` |
| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_property` |
| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value` |
| `/members/:id` | Member detail view | 🔐 | - | `edit`, `delete`, `link_user` |
| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_property`, `remove_property` |
| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value`, `remove_custom_field_value` |
#### LiveView Event Handlers
@ -495,8 +495,8 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
| `save` | Create/update member | `%{"member" => attrs}` | Redirect or show errors |
| `link_user` | Link user to member | `%{"user_id" => id}` | Update member view |
| `unlink_user` | Unlink user from member | - | Update member view |
| `add_property` | Add custom property | `%{"property_type_id" => id, "value" => val}` | Update form |
| `remove_property` | Remove custom property | `%{"property_id" => id}` | Update form |
| `add_custom_field_value` | Add custom field value | `%{"custom_field_id" => id, "value" => val}` | Update form |
| `remove_custom_field_value` | Remove custom field value | `%{"custom_field_value_id" => id}` | Update form |
#### Ash Resource Actions
@ -517,7 +517,7 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
| `Member` | `:fuzzy_search` | Fuzzy text search | 🔐 | `{query, threshold}` | `[%Member{}]` |
| `Member` | `:advanced_search` | Multi-criteria search | 🔐 | `{filters: [{field, op, value}]}` | `[%Member{}]` |
| `Member` | `:paginate` | Paginated member list | 🔐 | `{page, per_page, filters}` | `{members, total, page_info}` |
| `Member` | `:sort_by_custom_field` | Sort by property | 🔐 | `{property_type_id, direction}` | `[%Member{}]` |
| `Member` | `:sort_by_custom_field` | Sort by custom field | 🔐 | `{custom_field_id, direction}` | `[%Member{}]` |
| `Member` | `:bulk_delete` | Delete multiple members | 🛡️ | `{ids: [id1, id2, ...]}` | `{:ok, count}` |
| `Member` | `:bulk_update` | Update multiple members | 🛡️ | `{ids, attrs}` | `{:ok, count}` |
| `Member` | `:export` | Export to CSV/Excel | 🔐 | `{format, filters}` | File download |
@ -525,37 +525,37 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
---
### 3. Custom Fields (Property System) Endpoints
### 3. Custom Fields (CustomFieldValue System) Endpoints
#### LiveView Endpoints
| Mount | Purpose | Auth | Events |
|-------|---------|------|--------|
| `/property-types` | List property types | 🛡️ | `new`, `edit`, `delete` |
| `/property-types/new` | Create property type | 🛡️ | `save`, `cancel` |
| `/property-types/:id/edit` | Edit property type | 🛡️ | `save`, `cancel`, `delete` |
| `/custom-fields` | List custom fields | 🛡️ | `new`, `edit`, `delete` |
| `/custom-fields/new` | Create custom field | 🛡️ | `save`, `cancel` |
| `/custom-fields/:id/edit` | Edit custom field | 🛡️ | `save`, `cancel`, `delete` |
#### Ash Resource Actions
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `PropertyType` | `:create` | Create property type | 🛡️ | `{name, value_type, description, ...}` | `{:ok, property_type}` |
| `PropertyType` | `:read` | List property types | 🔐 | - | `[%PropertyType{}]` |
| `PropertyType` | `:update` | Update property type | 🛡️ | `{id, attrs}` | `{:ok, property_type}` |
| `PropertyType` | `:destroy` | Delete property type | 🛡️ | `{id}` | `{:ok, property_type}` |
| `Property` | `:create` | Add property to member | 🔐 | `{member_id, property_type_id, value}` | `{:ok, property}` |
| `Property` | `:update` | Update property value | 🔐 | `{id, value}` | `{:ok, property}` |
| `Property` | `:destroy` | Remove property | 🔐 | `{id}` | `{:ok, property}` |
| `CustomField` | `:create` | Create custom field | 🛡️ | `{name, value_type, description, ...}` | `{:ok, custom_field}` |
| `CustomField` | `:read` | List custom fields | 🔐 | - | `[%CustomField{}]` |
| `CustomField` | `:update` | Update custom field | 🛡️ | `{id, attrs}` | `{:ok, custom_field}` |
| `CustomField` | `:destroy` | Delete custom field | 🛡️ | `{id}` | `{:ok, custom_field}` |
| `CustomFieldValue` | `:create` | Add custom field value to member | 🔐 | `{member_id, custom_field_id, value}` | `{:ok, custom_field_value}` |
| `CustomFieldValue` | `:update` | Update custom field value | 🔐 | `{id, value}` | `{:ok, custom_field_value}` |
| `CustomFieldValue` | `:destroy` | Remove custom field value | 🔐 | `{id}` | `{:ok, custom_field_value}` |
#### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153)
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `PropertyType` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, property_type}` |
| `PropertyType` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, property_type}` |
| `PropertyType` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, property_type}` |
| `PropertyType` | `:create_group` | Create field group | 🛡️ | `{name, property_type_ids}` | `{:ok, group}` |
| `Property` | `:validate_value` | Validate property value | 🔐 | `{property_type_id, value}` | `{:ok, valid}` or `{:error, reason}` |
| `CustomField` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, custom_field}` |
| `CustomField` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, custom_field}` |
| `CustomField` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, custom_field}` |
| `CustomField` | `:create_group` | Create field group | 🛡️ | `{name, custom_field_ids}` | `{:ok, group}` |
| `CustomFieldValue` | `:validate_value` | Validate custom field value | 🔐 | `{custom_field_id, value}` | `{:ok, valid}` or `{:error, reason}` |
---

View file

@ -0,0 +1,101 @@
defmodule Mv.Membership.CustomField do
@moduledoc """
Ash resource defining the schema for custom member fields.
## Overview
CustomFields define the "schema" for custom fields in the membership system.
Each CustomField specifies the name, data type, and behavior of a custom field
that can be attached to members via CustomFieldValue resources.
## Attributes
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `description` - Optional human-readable description
- `immutable` - If true, custom field values cannot be changed after creation
- `required` - If true, all members must have this custom field (future feature)
## Supported Value Types
- `:string` - Text data (max 10,000 characters)
- `:integer` - Numeric data (64-bit integers)
- `:boolean` - True/false flags
- `:date` - Date values (no time component)
- `:email` - Validated email addresses (max 254 characters)
## Relationships
- `has_many :custom_field_values` - All custom field values of this type
## Constraints
- Name must be unique across all custom fields
- Name maximum length: 100 characters
- Cannot delete a custom field that has existing custom field values (RESTRICT)
## Examples
# Create a new custom field
CustomField.create!(%{
name: "phone_mobile",
value_type: :string,
description: "Mobile phone number"
})
# Create a required custom field
CustomField.create!(%{
name: "emergency_contact",
value_type: :string,
required: true
})
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "custom_fields"
repo Mv.Repo
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:name, :value_type, :description, :immutable, :required]
end
attributes do
uuid_primary_key :id
attribute :name, :string,
allow_nil?: false,
public?: true,
constraints: [
max_length: 100,
trim?: true
]
attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false,
description: "Defines the datatype `CustomFieldValue.value` is interpreted as"
attribute :description, :string,
allow_nil?: true,
public?: true,
constraints: [
max_length: 500,
trim?: true
]
attribute :immutable, :boolean,
default: false,
allow_nil?: false
attribute :required, :boolean,
default: false,
allow_nil?: false
end
relationships do
has_many :custom_field_values, Mv.Membership.CustomFieldValue
end
identities do
identity :unique_name, [:name]
end
end

View file

@ -0,0 +1,102 @@
defmodule Mv.Membership.CustomFieldValue do
@moduledoc """
Ash resource representing a custom field value for a member.
## Overview
CustomFieldValues implement the Entity-Attribute-Value (EAV) pattern, allowing
dynamic custom fields to be attached to members. Each custom field value links a
member to a custom field and stores the actual value.
## Value Storage
Values are stored using Ash's union type with JSONB storage format:
```json
{
"type": "string",
"value": "example"
}
```
## Supported Types
- `:string` - Text data
- `:integer` - Numeric data
- `:boolean` - True/false flags
- `:date` - Date values
- `:email` - Validated email addresses (custom type)
## Relationships
- `belongs_to :member` - The member this custom field value belongs to (CASCADE delete)
- `belongs_to :custom_field` - The custom field definition
## Constraints
- Each member can have only one custom field value per custom field (unique composite index)
- Custom field values are deleted when the associated member is deleted (CASCADE)
- String values maximum length: 10,000 characters
- Email values maximum length: 254 characters (RFC 5321)
## Future Features
- Type-matching validation (value type must match custom field's value_type) - to be implemented
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "custom_field_values"
repo Mv.Repo
references do
reference :member, on_delete: :delete
end
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:value, :member_id, :custom_field_id]
end
attributes do
uuid_primary_key :id
attribute :value, :union,
constraints: [
storage: :type_and_value,
types: [
boolean: [
type: :boolean
],
date: [
type: :date
],
integer: [
type: :integer
],
string: [
type: :string,
constraints: [
max_length: 10_000,
trim?: true
]
],
email: [
type: Mv.Membership.Email
]
]
]
end
relationships do
belongs_to :member, Mv.Membership.Member
belongs_to :custom_field, Mv.Membership.CustomField
end
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
# Ensure a member can only have one custom field value per custom field
# For example: A member can have only one "phone" custom field value, one "email" custom field value, etc.
identities do
identity :unique_custom_field_per_member, [:member_id, :custom_field_id]
end
end

View file

@ -4,22 +4,23 @@ defmodule Mv.Membership.Email do
## Overview
This type extends `:string` with email-specific validation constraints.
It ensures that email values stored in Property resources are valid email
It ensures that email values stored in CustomFieldValue resources are valid email
addresses according to a standard regex pattern.
## Validation Rules
- Minimum length: 5 characters
- **Optional**: `nil` and empty strings are allowed (custom fields are optional)
- Minimum length: 5 characters (for non-empty values)
- Maximum length: 254 characters (RFC 5321 maximum)
- Pattern: Standard email format (username@domain.tld)
- Automatic trimming of leading/trailing whitespace
- Automatic trimming of leading/trailing whitespace (empty strings become `nil`)
## Usage
This type is used in the Property union type for properties with
`value_type: :email` in PropertyType definitions.
This type is used in the CustomFieldValue union type for custom fields with
`value_type: :email` in CustomField definitions.
## Example
# In a property type definition
PropertyType.create!(%{
# In a custom field definition
CustomField.create!(%{
name: "work_email",
value_type: :email
})
@ -46,11 +47,18 @@ defmodule Mv.Membership.Email do
max_length: @max_length
]
@impl true
def cast_input(nil, _), do: {:ok, nil}
@impl true
def cast_input(value, _) when is_binary(value) do
value = String.trim(value)
cond do
# Empty string after trim becomes nil (optional field)
value == "" ->
{:ok, nil}
String.length(value) < @min_length ->
:error

View file

@ -7,7 +7,7 @@ defmodule Mv.Membership.Member do
can have:
- Personal information (name, email, phone, address)
- Optional link to a User account (1:1 relationship)
- Dynamic custom properties via PropertyType system
- Dynamic custom field values via CustomField system
- Full-text searchable profile
## Email Synchronization
@ -16,7 +16,7 @@ defmodule Mv.Membership.Member do
See `Mv.EmailSync` for details.
## Relationships
- `has_many :properties` - Dynamic custom fields
- `has_many :custom_field_values` - Dynamic custom fields
- `has_one :user` - Optional authentication account link
## Validations
@ -48,8 +48,8 @@ defmodule Mv.Membership.Member do
create :create_member do
primary? true
# Properties can be created along with member
argument :properties, {:array, :map}
# Custom field values can be created along with member
argument :custom_field_values, {:array, :map}
# Allow user to be passed as argument for relationship management
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
@ -70,7 +70,7 @@ defmodule Mv.Membership.Member do
:postal_code
]
change manage_relationship(:properties, type: :create)
change manage_relationship(:custom_field_values, type: :create)
# Manage the user relationship during member creation
change manage_relationship(:user, :user,
@ -95,8 +95,8 @@ defmodule Mv.Membership.Member do
primary? true
# Required because custom validation function cannot be done atomically
require_atomic? false
# Properties can be updated or created along with member
argument :properties, {:array, :map}
# Custom field values can be updated or created along with member
argument :custom_field_values, {:array, :map}
# Allow user to be passed as argument for relationship management
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
@ -117,7 +117,7 @@ defmodule Mv.Membership.Member do
:postal_code
]
change manage_relationship(:properties, on_match: :update, on_no_match: :create)
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
# Manage the user relationship during member update
change manage_relationship(:user, :user,
@ -349,7 +349,7 @@ defmodule Mv.Membership.Member do
end
relationships do
has_many :properties, Mv.Membership.Property
has_many :custom_field_values, Mv.Membership.CustomFieldValue
# 1:1 relationship - Member can optionally have one User
# This references the User's member_id attribute
# The relationship is optional (allow_nil? true by default)

View file

@ -3,15 +3,15 @@ defmodule Mv.Membership do
Ash Domain for membership management.
## Resources
- `Member` - Club members with personal information and custom properties
- `Property` - Dynamic custom field values attached to members
- `PropertyType` - Schema definitions for custom properties
- `Member` - Club members with personal information and custom field values
- `CustomFieldValue` - Dynamic custom field values attached to members
- `CustomField` - Schema definitions for custom fields
## Public API
The domain exposes these main actions:
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
- Property management: `create_property/1`, `list_property/0`, etc.
- PropertyType management: `create_property_type/1`, `list_property_types/0`, etc.
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc.
## Admin Interface
The domain is configured with AshAdmin for management UI.
@ -31,18 +31,18 @@ defmodule Mv.Membership do
define :destroy_member, action: :destroy
end
resource Mv.Membership.Property do
define :create_property, action: :create
define :list_property, action: :read
define :update_property, action: :update
define :destroy_property, action: :destroy
resource Mv.Membership.CustomFieldValue do
define :create_custom_field_value, action: :create
define :list_custom_field_values, action: :read
define :update_custom_field_value, action: :update
define :destroy_custom_field_value, action: :destroy
end
resource Mv.Membership.PropertyType do
define :create_property_type, action: :create
define :list_property_types, action: :read
define :update_property_type, action: :update
define :destroy_property_type, action: :destroy
resource Mv.Membership.CustomField do
define :create_custom_field, action: :create
define :list_custom_fields, action: :read
define :update_custom_field, action: :update
define :destroy_custom_field, action: :destroy
end
end
end

View file

@ -1,83 +0,0 @@
defmodule Mv.Membership.Property do
@moduledoc """
Ash resource representing a custom property value for a member.
## Overview
Properties implement the Entity-Attribute-Value (EAV) pattern, allowing
dynamic custom fields to be attached to members. Each property links a
member to a property type and stores the actual value.
## Value Storage
Values are stored using Ash's union type with JSONB storage format:
```json
{
"type": "string",
"value": "example"
}
```
## Supported Types
- `:string` - Text data
- `:integer` - Numeric data
- `:boolean` - True/false flags
- `:date` - Date values
- `:email` - Validated email addresses (custom type)
## Relationships
- `belongs_to :member` - The member this property belongs to (CASCADE delete)
- `belongs_to :property_type` - The property type definition
## Constraints
- Each member can have only one property per property type (unique composite index)
- Properties are deleted when the associated member is deleted (CASCADE)
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "properties"
repo Mv.Repo
references do
reference :member, on_delete: :delete
end
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:value, :member_id, :property_type_id]
end
attributes do
uuid_primary_key :id
attribute :value, :union,
constraints: [
storage: :type_and_value,
types: [
boolean: [type: :boolean],
date: [type: :date],
integer: [type: :integer],
string: [type: :string],
email: [type: Mv.Membership.Email]
]
]
end
relationships do
belongs_to :member, Mv.Membership.Member
belongs_to :property_type, Mv.Membership.PropertyType
end
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
# Ensure a member can only have one property per property type
# For example: A member can have only one "email" property, one "phone" property, etc.
identities do
identity :unique_property_per_member, [:member_id, :property_type_id]
end
end

View file

@ -1,88 +0,0 @@
defmodule Mv.Membership.PropertyType do
@moduledoc """
Ash resource defining the schema for custom member properties.
## Overview
PropertyTypes define the "schema" for custom fields in the membership system.
Each PropertyType specifies the name, data type, and behavior of a custom field
that can be attached to members via Property resources.
## Attributes
- `name` - Unique identifier for the property (e.g., "phone_mobile", "birthday")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `description` - Optional human-readable description
- `immutable` - If true, property values cannot be changed after creation
- `required` - If true, all members must have this property (future feature)
## Supported Value Types
- `:string` - Text data (unlimited length)
- `:integer` - Numeric data (64-bit integers)
- `:boolean` - True/false flags
- `:date` - Date values (no time component)
- `:email` - Validated email addresses
## Relationships
- `has_many :properties` - All property values of this type
## Constraints
- Name must be unique across all property types
- Cannot delete a property type that has existing property values (RESTRICT)
## Examples
# Create a new property type
PropertyType.create!(%{
name: "phone_mobile",
value_type: :string,
description: "Mobile phone number"
})
# Create a required property type
PropertyType.create!(%{
name: "emergency_contact",
value_type: :string,
required: true
})
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
postgres do
table "property_types"
repo Mv.Repo
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:name, :value_type, :description, :immutable, :required]
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false,
description: "Defines the datatype `Property.value` is interpreted as"
attribute :description, :string, allow_nil?: true, public?: true
attribute :immutable, :boolean,
default: false,
allow_nil?: false
attribute :required, :boolean,
default: false,
allow_nil?: false
end
relationships do
has_many :properties, Mv.Membership.Property
end
identities do
identity :unique_name, [:name]
end
end

View file

@ -17,6 +17,7 @@ defmodule MvWeb.Layouts.Navbar do
<a class="btn btn-ghost text-xl">Mitgliederverwaltung</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
</ul>
</div>

View file

@ -1,10 +1,10 @@
defmodule MvWeb.PropertyTypeLive.Form do
defmodule MvWeb.CustomFieldLive.Form do
@moduledoc """
LiveView form for creating and editing property types (admin).
LiveView form for creating and editing custom fields (admin).
## Features
- Create new property type definitions
- Edit existing property types
- Create new custom field definitions
- Edit existing custom fields
- Select value type from supported types
- Set immutable and required flags
- Real-time validation
@ -17,7 +17,7 @@ defmodule MvWeb.PropertyTypeLive.Form do
**Optional:**
- description - Human-readable explanation
- immutable - If true, values cannot be changed after creation (default: false)
- required - If true, all members must have this property (default: false)
- required - If true, all members must have this custom field (default: false)
## Value Type Selection
- `:string` - Text data (unlimited length)
@ -28,10 +28,10 @@ defmodule MvWeb.PropertyTypeLive.Form do
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update property type)
- `save` - Submit form (create or update custom field)
## Security
Property type management is restricted to admin users.
Custom field management is restricted to admin users.
"""
use MvWeb, :live_view
@ -42,18 +42,18 @@ defmodule MvWeb.PropertyTypeLive.Form do
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage property_type records in your database.")}
{gettext("Use this form to manage custom_field records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="property_type-form" phx-change="validate" phx-submit="save">
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.PropertyType, :value_type).constraints[:one_of]
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
@ -61,9 +61,9 @@ defmodule MvWeb.PropertyTypeLive.Form do
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Property type")}
{gettext("Save Custom field")}
</.button>
<.button navigate={return_path(@return_to, @property_type)}>{gettext("Cancel")}</.button>
<.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
@ -71,19 +71,19 @@ defmodule MvWeb.PropertyTypeLive.Form do
@impl true
def mount(params, _session, socket) do
property_type =
custom_field =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.PropertyType, id)
id -> Ash.get!(Mv.Membership.CustomField, id)
end
action = if is_nil(property_type), do: "New", else: "Edit"
page_title = action <> " " <> "Property type"
action = if is_nil(custom_field), do: "New", else: "Edit"
page_title = action <> " " <> "Custom field"
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(property_type: property_type)
|> assign(custom_field: custom_field)
|> assign(:page_title, page_title)
|> assign_form()}
end
@ -92,15 +92,15 @@ defmodule MvWeb.PropertyTypeLive.Form do
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"property_type" => property_type_params}, socket) do
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_type_params))}
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
end
def handle_event("save", %{"property_type" => property_type_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do
{:ok, property_type} ->
notify_parent({:saved, property_type})
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
{:ok, custom_field} ->
notify_parent({:saved, custom_field})
action =
case socket.assigns.form.source.type do
@ -111,8 +111,8 @@ defmodule MvWeb.PropertyTypeLive.Form do
socket =
socket
|> put_flash(:info, gettext("Property type %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, property_type))
|> put_flash(:info, gettext("Custom field %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field))
{:noreply, socket}
@ -123,17 +123,17 @@ defmodule MvWeb.PropertyTypeLive.Form do
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{property_type: property_type}} = socket) do
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form =
if property_type do
AshPhoenix.Form.for_update(property_type, :update, as: "property_type")
if custom_field do
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
else
AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type")
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
end
assign(socket, form: to_form(form))
end
defp return_path("index", _property_type), do: ~p"/property_types"
defp return_path("show", property_type), do: ~p"/property_types/#{property_type.id}"
defp return_path("index", _custom_field), do: ~p"/custom_fields"
defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}"
end

View file

@ -0,0 +1,88 @@
defmodule MvWeb.CustomFieldLive.Index do
@moduledoc """
LiveView for managing custom field definitions (admin).
## Features
- List all custom fields
- Display type information (name, value type, description)
- Show immutable and required flags
- Create new custom fields
- Edit existing custom fields
- Delete custom fields (if no custom field values use them)
## Displayed Information
- Name: Unique identifier for the custom field
- Value type: Data type constraint (string, integer, boolean, date, email)
- Description: Human-readable explanation
- Immutable: Whether custom field values can be changed after creation
- Required: Whether all members must have this custom field (future feature)
## Events
- `delete` - Remove a custom field (only if no custom field values exist)
## Security
Custom field management is restricted to admin users.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Custom fields
<:actions>
<.button variant="primary" navigate={~p"/custom_fields/new"}>
<.icon name="hero-plus" /> New Custom field
</.button>
</:actions>
</.header>
<.table
id="custom_fields"
rows={@streams.custom_fields}
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end}
>
<:col :let={{_id, custom_field}} label="Id">{custom_field.id}</:col>
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
<:action :let={{_id, custom_field}}>
<div class="sr-only">
<.link navigate={~p"/custom_fields/#{custom_field}"}>Show</.link>
</div>
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
</:action>
<:action :let={{id, custom_field}}>
<.link
phx-click={JS.push("delete", value: %{id: custom_field.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Custom fields")
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id)
Ash.destroy!(custom_field)
{:noreply, stream_delete(socket, :custom_fields, custom_field)}
end
end

View file

@ -0,0 +1,66 @@
defmodule MvWeb.CustomFieldLive.Show do
@moduledoc """
LiveView for displaying a single custom field's details (admin).
## Features
- Display custom field definition
- Show all attributes (name, value type, description, flags)
- Navigate to edit form
- Return to custom field list
## Displayed Information
- Name: Unique identifier
- Value type: Data type constraint
- Description: Optional explanation
- Immutable flag: Whether values can be changed
- Required flag: Whether all members need this custom field
## Navigation
- Back to custom field list
- Edit custom field
## Security
Custom field details are restricted to admin users.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Custom field {@custom_field.id}
<:subtitle>This is a custom_field record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/custom_fields"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Custom field
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@custom_field.id}</:item>
<:item title="Name">{@custom_field.name}</:item>
<:item title="Description">{@custom_field.description}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Custom field")
|> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))}
end
end

View file

@ -1,21 +1,21 @@
defmodule MvWeb.PropertyLive.Form do
defmodule MvWeb.CustomFieldValueLive.Form do
@moduledoc """
LiveView form for creating and editing properties.
LiveView form for creating and editing custom field values.
## Features
- Create new properties with member and type selection
- Edit existing property values
- Value input adapts to property type (string, integer, boolean, date, email)
- Create new custom field values with member and type selection
- Edit existing custom field values
- Value input adapts to custom field type (string, integer, boolean, date, email)
- Real-time validation
## Form Fields
**Required:**
- member - Select which member owns this property
- property_type - Select the type (defines value type)
- value - The actual value (input type depends on property type)
- member - Select which member owns this custom field value
- custom_field - Select the type (defines value type)
- value - The actual value (input type depends on custom field type)
## Value Types
The form dynamically renders appropriate inputs based on property type:
The form dynamically renders appropriate inputs based on custom field type:
- String: text input
- Integer: number input
- Boolean: checkbox
@ -24,10 +24,10 @@ defmodule MvWeb.PropertyLive.Form do
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update property)
- `save` - Submit form (create or update custom field value)
## Note
Properties are typically managed through the member edit form,
Custom field values are typically managed through the member edit form,
not through this standalone form.
"""
use MvWeb, :live_view
@ -38,17 +38,19 @@ defmodule MvWeb.PropertyLive.Form do
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage property records in your database.")}</:subtitle>
<:subtitle>
{gettext("Use this form to manage custom_field_value records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="property-form" phx-change="validate" phx-submit="save">
<!-- Property Type Selection -->
<.form for={@form} id="custom_field_value-form" phx-change="validate" phx-submit="save">
<!-- Custom Field Selection -->
<.input
field={@form[:property_type_id]}
field={@form[:custom_field_id]}
type="select"
label={gettext("Property type")}
options={property_type_options(@property_types)}
prompt={gettext("Choose a property type")}
label={gettext("Custom field")}
options={custom_field_options(@custom_fields)}
prompt={gettext("Choose a custom field")}
/>
<!-- Member Selection -->
@ -61,18 +63,18 @@ defmodule MvWeb.PropertyLive.Form do
/>
<!-- Value Input - handles Union type -->
<%= if @selected_property_type do %>
<.union_value_input form={@form} property_type={@selected_property_type} />
<%= if @selected_custom_field do %>
<.union_value_input form={@form} custom_field={@selected_custom_field} />
<% else %>
<div class="text-sm text-gray-600">
{gettext("Please select a property type first")}
{gettext("Please select a custom field first")}
</div>
<% end %>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Property")}
{gettext("Save Custom field value")}
</.button>
<.button navigate={return_path(@return_to, @property)}>{gettext("Cancel")}</.button>
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
@ -80,8 +82,8 @@ defmodule MvWeb.PropertyLive.Form do
# Helper function for Union-Value Input
defp union_value_input(assigns) do
# Extract the current value from the Property
current_value = extract_current_value(assigns.form.data, assigns.property_type.value_type)
# Extract the current value from the CustomFieldValue
current_value = extract_current_value(assigns.form.data, assigns.custom_field.value_type)
assigns = assign(assigns, :current_value, current_value)
~H"""
@ -90,7 +92,7 @@ defmodule MvWeb.PropertyLive.Form do
{gettext("Value")}
</label>
<%= case @property_type.value_type do %>
<%= case @custom_field.value_type do %>
<% :string -> %>
<.inputs_for :let={value_form} field={@form[:value]}>
<.input field={value_form[:value]} type="text" label="" value={@current_value} />
@ -123,16 +125,16 @@ defmodule MvWeb.PropertyLive.Form do
</.inputs_for>
<% _ -> %>
<div class="text-sm text-red-600">
{gettext("Unsupported value type: %{type}", type: @property_type.value_type)}
{gettext("Unsupported value type: %{type}", type: @custom_field.value_type)}
</div>
<% end %>
</div>
"""
end
# Helper function to extract the current value from the Property
# Helper function to extract the current value from the CustomFieldValue
defp extract_current_value(
%Mv.Membership.Property{value: %Ash.Union{value: value}},
%Mv.Membership.CustomFieldValue{value: %Ash.Union{value: value}},
_value_type
) do
value
@ -160,27 +162,27 @@ defmodule MvWeb.PropertyLive.Form do
@impl true
def mount(params, _session, socket) do
property =
custom_field_value =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.Property, id) |> Ash.load!([:property_type])
id -> Ash.get!(Mv.Membership.CustomFieldValue, id) |> Ash.load!([:custom_field])
end
action = if is_nil(property), do: "New", else: "Edit"
page_title = action <> " " <> "Property"
action = if is_nil(custom_field_value), do: "New", else: "Edit"
page_title = action <> " " <> "Custom field value"
# Load all PropertyTypes and Members for the selection fields
property_types = Ash.read!(Mv.Membership.PropertyType)
# Load all CustomFields and Members for the selection fields
custom_fields = Ash.read!(Mv.Membership.CustomField)
members = Ash.read!(Mv.Membership.Member)
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(property: property)
|> assign(custom_field_value: custom_field_value)
|> assign(:page_title, page_title)
|> assign(:property_types, property_types)
|> assign(:custom_fields, custom_fields)
|> assign(:members, members)
|> assign(:selected_property_type, property && property.property_type)
|> assign(:selected_custom_field, custom_field_value && custom_field_value.custom_field)
|> assign_form()}
end
@ -188,43 +190,43 @@ defmodule MvWeb.PropertyLive.Form do
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"property" => property_params}, socket) do
# Find the selected PropertyType
selected_property_type =
case property_params["property_type_id"] do
def handle_event("validate", %{"custom_field_value" => custom_field_value_params}, socket) do
# Find the selected CustomField
selected_custom_field =
case custom_field_value_params["custom_field_id"] do
"" -> nil
nil -> nil
id -> Enum.find(socket.assigns.property_types, &(&1.id == id))
id -> Enum.find(socket.assigns.custom_fields, &(&1.id == id))
end
# Set the Union type based on the selected PropertyType
# Set the Union type based on the selected CustomField
updated_params =
if selected_property_type do
union_type = to_string(selected_property_type.value_type)
put_in(property_params, ["value", "_union_type"], union_type)
if selected_custom_field do
union_type = to_string(selected_custom_field.value_type)
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
else
property_params
custom_field_value_params
end
{:noreply,
socket
|> assign(:selected_property_type, selected_property_type)
|> assign(:selected_custom_field, selected_custom_field)
|> assign(form: AshPhoenix.Form.validate(socket.assigns.form, updated_params))}
end
def handle_event("save", %{"property" => property_params}, socket) do
# Set the Union type based on the selected PropertyType
def handle_event("save", %{"custom_field_value" => custom_field_value_params}, socket) do
# Set the Union type based on the selected CustomField
updated_params =
if socket.assigns.selected_property_type do
union_type = to_string(socket.assigns.selected_property_type.value_type)
put_in(property_params, ["value", "_union_type"], union_type)
if socket.assigns.selected_custom_field do
union_type = to_string(socket.assigns.selected_custom_field.value_type)
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
else
property_params
custom_field_value_params
end
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
{:ok, property} ->
notify_parent({:saved, property})
{:ok, custom_field_value} ->
notify_parent({:saved, custom_field_value})
action =
case socket.assigns.form.source.type do
@ -235,8 +237,11 @@ defmodule MvWeb.PropertyLive.Form do
socket =
socket
|> put_flash(:info, gettext("Property %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, property))
|> put_flash(
:info,
gettext("Custom field value %{action} successfully", action: action)
)
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field_value))
{:noreply, socket}
@ -247,11 +252,11 @@ defmodule MvWeb.PropertyLive.Form do
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{property: property}} = socket) do
defp assign_form(%{assigns: %{custom_field_value: custom_field_value}} = socket) do
form =
if property do
# Determine the Union type based on the property_type
union_type = property.property_type && property.property_type.value_type
if custom_field_value do
# Determine the Union type based on the custom_field
union_type = custom_field_value.custom_field && custom_field_value.custom_field.value_type
params =
if union_type do
@ -260,20 +265,27 @@ defmodule MvWeb.PropertyLive.Form do
%{}
end
AshPhoenix.Form.for_update(property, :update, as: "property", params: params)
AshPhoenix.Form.for_update(custom_field_value, :update,
as: "custom_field_value",
params: params
)
else
AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property")
AshPhoenix.Form.for_create(Mv.Membership.CustomFieldValue, :create,
as: "custom_field_value"
)
end
assign(socket, form: to_form(form))
end
defp return_path("index", _property), do: ~p"/properties"
defp return_path("show", property), do: ~p"/properties/#{property.id}"
defp return_path("index", _custom_field_value), do: ~p"/custom_field_values"
defp return_path("show", custom_field_value),
do: ~p"/custom_field_values/#{custom_field_value.id}"
# Helper functions for selection options
defp property_type_options(property_types) do
Enum.map(property_types, &{&1.name, &1.id})
defp custom_field_options(custom_fields) do
Enum.map(custom_fields, &{&1.name, &1.id})
end
defp member_options(members) do

View file

@ -0,0 +1,86 @@
defmodule MvWeb.CustomFieldValueLive.Index do
@moduledoc """
LiveView for displaying and managing custom field values.
## Features
- List all custom field values with their values and types
- Show which member each custom field value belongs to
- Display custom field information
- Navigate to custom field value details and edit forms
- Delete custom field values
## Relationships
Each custom field value is linked to:
- A member (the custom field value owner)
- A custom field (defining value type and behavior)
## Events
- `delete` - Remove a custom field value from the database
## Note
Custom field values are typically managed through the member edit form.
This view provides a global overview of all custom field values.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Custom field values
<:actions>
<.button variant="primary" navigate={~p"/custom_field_values/new"}>
<.icon name="hero-plus" /> New Custom field value
</.button>
</:actions>
</.header>
<.table
id="custom_field_values"
rows={@streams.custom_field_values}
row_click={
fn {_id, custom_field_value} ->
JS.navigate(~p"/custom_field_values/#{custom_field_value}")
end
}
>
<:col :let={{_id, custom_field_value}} label="Id">{custom_field_value.id}</:col>
<:action :let={{_id, custom_field_value}}>
<div class="sr-only">
<.link navigate={~p"/custom_field_values/#{custom_field_value}"}>Show</.link>
</div>
<.link navigate={~p"/custom_field_values/#{custom_field_value}/edit"}>Edit</.link>
</:action>
<:action :let={{id, custom_field_value}}>
<.link
phx-click={JS.push("delete", value: %{id: custom_field_value.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id)
Ash.destroy!(custom_field_value)
{:noreply, stream_delete(socket, :custom_field_values, custom_field_value)}
end
end

View file

@ -0,0 +1,67 @@
defmodule MvWeb.CustomFieldValueLive.Show do
@moduledoc """
LiveView for displaying a single custom field value's details.
## Features
- Display custom field value and type
- Show linked member
- Show custom field definition
- Navigate to edit form
- Return to custom field value list
## Displayed Information
- Custom field value (formatted based on type)
- Custom field name and description
- Member information (who owns this custom field value)
- Custom field value metadata (ID, timestamps if added)
## Navigation
- Back to custom field value list
- Edit custom field value
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Custom field value {@custom_field_value.id}
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/custom_field_values"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/custom_field_values/#{@custom_field_value}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Custom field value
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@custom_field_value.id}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
end
defp page_title(:show), do: "Show Custom field value"
defp page_title(:edit), do: "Edit Custom field value"
end

View file

@ -19,14 +19,14 @@ defmodule MvWeb.MemberLive.Form do
- paid status
- notes
## Custom Properties
Members can have dynamic custom properties defined by PropertyTypes.
The form dynamically renders inputs based on available PropertyTypes.
## Custom Field Values
Members can have dynamic custom field values defined by CustomFields.
The form dynamically renders inputs based on available CustomFields.
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update member)
- Property management events for adding/removing custom fields
- Custom field value management events for adding/removing custom fields
"""
use MvWeb, :live_view
@ -56,10 +56,11 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:house_number]} label={gettext("House Number")} />
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
<.inputs_for :let={f_property} field={@form[:properties]}>
<% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %>
<.inputs_for :let={value_form} field={f_property[:value]}>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
<% type =
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
<% input_type =
cond do
type && type.value_type == :boolean -> "checkbox"
@ -70,8 +71,8 @@ defmodule MvWeb.MemberLive.Form do
</.inputs_for>
<input
type="hidden"
name={f_property[:property_type_id].name}
value={f_property[:property_type_id].value}
name={f_custom_field_value[:custom_field_id].name}
value={f_custom_field_value[:custom_field_id].value}
/>
</.inputs_for>
@ -86,16 +87,16 @@ defmodule MvWeb.MemberLive.Form do
@impl true
def mount(params, _session, socket) do
{:ok, property_types} = Mv.Membership.list_property_types()
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
initial_properties =
Enum.map(property_types, fn pt ->
initial_custom_field_values =
Enum.map(custom_fields, fn cf ->
%{
"property_type_id" => pt.id,
"custom_field_id" => cf.id,
"value" => %{
"type" => pt.value_type,
"type" => cf.value_type,
"value" => nil,
"_union_type" => Atom.to_string(pt.value_type)
"_union_type" => Atom.to_string(cf.value_type)
}
}
end)
@ -112,8 +113,8 @@ defmodule MvWeb.MemberLive.Form do
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:property_types, property_types)
|> assign(:initial_properties, initial_properties)
|> assign(:custom_fields, custom_fields)
|> assign(:initial_custom_field_values, initial_custom_field_values)
|> assign(member: member)
|> assign(:page_title, page_title)
|> assign_form()}
@ -156,25 +157,25 @@ defmodule MvWeb.MemberLive.Form do
defp assign_form(%{assigns: %{member: member}} = socket) do
form =
if member do
{:ok, member} = Ash.load(member, properties: [:property_type])
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
existing_properties =
member.properties
|> Enum.map(& &1.property_type_id)
existing_custom_field_values =
member.custom_field_values
|> Enum.map(& &1.custom_field_id)
is_missing_property = fn i ->
not Enum.member?(existing_properties, Map.get(i, "property_type_id"))
is_missing_custom_field_value = fn i ->
not Enum.member?(existing_custom_field_values, Map.get(i, "custom_field_id"))
end
params = %{
"properties" =>
Enum.map(member.properties, fn prop ->
"custom_field_values" =>
Enum.map(member.custom_field_values, fn cfv ->
%{
"property_type_id" => prop.property_type_id,
"custom_field_id" => cfv.custom_field_id,
"value" => %{
"_union_type" => Atom.to_string(prop.value.type),
"type" => prop.value.type,
"value" => prop.value.value
"_union_type" => Atom.to_string(cfv.value.type),
"type" => cfv.value.type,
"value" => cfv.value.value
}
}
end)
@ -190,12 +191,13 @@ defmodule MvWeb.MemberLive.Form do
forms: [auto?: true]
)
missing_properties = Enum.filter(socket.assigns[:initial_properties], is_missing_property)
missing_custom_field_values =
Enum.filter(socket.assigns[:initial_custom_field_values], is_missing_custom_field_value)
Enum.reduce(
missing_properties,
missing_custom_field_values,
form,
&AshPhoenix.Form.add_form(&2, [:properties], params: &1)
&AshPhoenix.Form.add_form(&2, [:custom_field_values], params: &1)
)
else
AshPhoenix.Form.for_create(
@ -203,7 +205,7 @@ defmodule MvWeb.MemberLive.Form do
:create_member,
api: Mv.Membership,
as: "member",
params: %{"properties" => socket.assigns[:initial_properties]},
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
forms: [auto?: true]
)
end

View file

@ -5,7 +5,7 @@ defmodule MvWeb.MemberLive.Show do
## Features
- Display all member information (personal, contact, address)
- Show linked user account (if exists)
- Display custom properties
- Display custom field values
- Navigate to edit form
- Return to member list
@ -15,7 +15,7 @@ defmodule MvWeb.MemberLive.Show do
- Address: street, house number, postal code, city
- Status: paid flag
- Relationships: linked user account
- Custom: dynamic properties from PropertyTypes
- Custom: dynamic custom field values from CustomFields
## Navigation
- Back to member list
@ -75,14 +75,14 @@ defmodule MvWeb.MemberLive.Show do
</:item>
</.list>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
<.generic_list items={
Enum.map(@member.properties, fn p ->
Enum.map(@member.custom_field_values, fn cfv ->
{
# name
p.property_type && p.property_type.name,
cfv.custom_field && cfv.custom_field.name,
# value
case p.value do
case cfv.value do
%{value: v} -> v
v -> v
end
@ -103,7 +103,7 @@ defmodule MvWeb.MemberLive.Show do
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load([:user, properties: [:property_type]])
|> load([:user, custom_field_values: [:custom_field]])
member = Ash.read_one!(query)

View file

@ -1,82 +0,0 @@
defmodule MvWeb.PropertyLive.Index do
@moduledoc """
LiveView for displaying and managing properties.
## Features
- List all properties with their values and types
- Show which member each property belongs to
- Display property type information
- Navigate to property details and edit forms
- Delete properties
## Relationships
Each property is linked to:
- A member (the property owner)
- A property type (defining value type and behavior)
## Events
- `delete` - Remove a property from the database
## Note
Properties are typically managed through the member edit form.
This view provides a global overview of all properties.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Properties
<:actions>
<.button variant="primary" navigate={~p"/properties/new"}>
<.icon name="hero-plus" /> New Property
</.button>
</:actions>
</.header>
<.table
id="properties"
rows={@streams.properties}
row_click={fn {_id, property} -> JS.navigate(~p"/properties/#{property}") end}
>
<:col :let={{_id, property}} label="Id">{property.id}</:col>
<:action :let={{_id, property}}>
<div class="sr-only">
<.link navigate={~p"/properties/#{property}"}>Show</.link>
</div>
<.link navigate={~p"/properties/#{property}/edit"}>Edit</.link>
</:action>
<:action :let={{id, property}}>
<.link
phx-click={JS.push("delete", value: %{id: property.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Properties")
|> stream(:properties, Ash.read!(Mv.Membership.Property))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
property = Ash.get!(Mv.Membership.Property, id)
Ash.destroy!(property)
{:noreply, stream_delete(socket, :properties, property)}
end
end

View file

@ -1,64 +0,0 @@
defmodule MvWeb.PropertyLive.Show do
@moduledoc """
LiveView for displaying a single property's details.
## Features
- Display property value and type
- Show linked member
- Show property type definition
- Navigate to edit form
- Return to property list
## Displayed Information
- Property value (formatted based on type)
- Property type name and description
- Member information (who owns this property)
- Property metadata (ID, timestamps if added)
## Navigation
- Back to property list
- Edit property
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Property {@property.id}
<:subtitle>This is a property record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/properties"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/properties/#{@property}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit Property
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@property.id}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:property, Ash.get!(Mv.Membership.Property, id))}
end
defp page_title(:show), do: "Show Property"
defp page_title(:edit), do: "Edit Property"
end

View file

@ -1,88 +0,0 @@
defmodule MvWeb.PropertyTypeLive.Index do
@moduledoc """
LiveView for managing property type definitions (admin).
## Features
- List all property types
- Display type information (name, value type, description)
- Show immutable and required flags
- Create new property types
- Edit existing property types
- Delete property types (if no properties use them)
## Displayed Information
- Name: Unique identifier for the property type
- Value type: Data type constraint (string, integer, boolean, date, email)
- Description: Human-readable explanation
- Immutable: Whether property values can be changed after creation
- Required: Whether all members must have this property (future feature)
## Events
- `delete` - Remove a property type (only if no properties exist)
## Security
Property type management is restricted to admin users.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Property types
<:actions>
<.button variant="primary" navigate={~p"/property_types/new"}>
<.icon name="hero-plus" /> New Property type
</.button>
</:actions>
</.header>
<.table
id="property_types"
rows={@streams.property_types}
row_click={fn {_id, property_type} -> JS.navigate(~p"/property_types/#{property_type}") end}
>
<:col :let={{_id, property_type}} label="Id">{property_type.id}</:col>
<:col :let={{_id, property_type}} label="Name">{property_type.name}</:col>
<:col :let={{_id, property_type}} label="Description">{property_type.description}</:col>
<:action :let={{_id, property_type}}>
<div class="sr-only">
<.link navigate={~p"/property_types/#{property_type}"}>Show</.link>
</div>
<.link navigate={~p"/property_types/#{property_type}/edit"}>Edit</.link>
</:action>
<:action :let={{id, property_type}}>
<.link
phx-click={JS.push("delete", value: %{id: property_type.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Property types")
|> stream(:property_types, Ash.read!(Mv.Membership.PropertyType))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
property_type = Ash.get!(Mv.Membership.PropertyType, id)
Ash.destroy!(property_type)
{:noreply, stream_delete(socket, :property_types, property_type)}
end
end

View file

@ -1,66 +0,0 @@
defmodule MvWeb.PropertyTypeLive.Show do
@moduledoc """
LiveView for displaying a single property type's details (admin).
## Features
- Display property type definition
- Show all attributes (name, value type, description, flags)
- Navigate to edit form
- Return to property type list
## Displayed Information
- Name: Unique identifier
- Value type: Data type constraint
- Description: Optional explanation
- Immutable flag: Whether values can be changed
- Required flag: Whether all members need this property
## Navigation
- Back to property type list
- Edit property type
## Security
Property type details are restricted to admin users.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Property type {@property_type.id}
<:subtitle>This is a property_type record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/property_types"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/property_types/#{@property_type}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Property type
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@property_type.id}</:item>
<:item title="Name">{@property_type.name}</:item>
<:item title="Description">{@property_type.description}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Property type")
|> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))}
end
end

View file

@ -55,17 +55,17 @@ defmodule MvWeb.Router do
live "/members/:id", MemberLive.Show, :show
live "/members/:id/show/edit", MemberLive.Show, :edit
live "/property_types", PropertyTypeLive.Index, :index
live "/property_types/new", PropertyTypeLive.Form, :new
live "/property_types/:id/edit", PropertyTypeLive.Form, :edit
live "/property_types/:id", PropertyTypeLive.Show, :show
live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit
live "/custom_fields", CustomFieldLive.Index, :index
live "/custom_fields/new", CustomFieldLive.Form, :new
live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit
live "/custom_fields/:id", CustomFieldLive.Show, :show
live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit
live "/properties", PropertyLive.Index, :index
live "/properties/new", PropertyLive.Form, :new
live "/properties/:id/edit", PropertyLive.Form, :edit
live "/properties/:id", PropertyLive.Show, :show
live "/properties/:id/show/edit", PropertyLive.Show, :edit
live "/custom_field_values", CustomFieldValueLive.Index, :index
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
live "/custom_field_values/:id", CustomFieldValueLive.Show, :show
live "/custom_field_values/:id/show/edit", CustomFieldValueLive.Show, :edit
live "/users", UserLive.Index, :index
live "/users/new", UserLive.Form, :new

View file

@ -27,9 +27,9 @@ msgstr "Bist du sicher?"
msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
@ -41,43 +41,43 @@ msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr "Bearbeite"
#: lib/mv_web/live/member_live/show.ex:19
#: lib/mv_web/live/member_live/show.ex:95
#: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:50
#, elixir-autogen, elixir-format
msgid "Email"
msgstr "E-Mail"
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/form.ex:45
#: lib/mv_web/live/member_live/show.ex:48
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/member_live/form.ex:46
#: lib/mv_web/live/member_live/show.ex:49
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr "Nachname"
@ -108,117 +108,111 @@ msgstr "Keine Internetverbindung gefunden"
msgid "close"
msgstr "schließen"
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr "Geburtsdatum"
#: lib/mv_web/live/member_live/form.ex:30
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr "Eigene Eigenschaften"
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "Hausnummer"
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr "Notizen"
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/show.ex:40
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
msgstr "Postleitzahl"
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/form.ex:80
#, elixir-autogen, elixir-format
msgid "Save Member"
msgstr "Mitglied speichern"
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/property_live/form.ex:41
#: lib/mv_web/live/property_type_live/form.ex:29
#: lib/mv_web/live/user_live/form.ex:92
#: lib/mv_web/live/custom_field_live/form.ex:63
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
msgstr "Straße"
#: lib/mv_web/live/member_live/form.ex:11
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format
msgid "Id"
msgstr "ID"
#: lib/mv_web/live/member_live/show.ex:31
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
msgstr "Nein"
#: lib/mv_web/live/member_live/show.ex:94
#: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr "Mitglied anzeigen"
#: lib/mv_web/live/member_live/show.ex:11
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/show.ex:31
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr "Ja"
#: lib/mv_web/live/member_live/form.ex:108
#: lib/mv_web/live/property_live/form.ex:200
#: lib/mv_web/live/property_type_live/form.ex:73
#: lib/mv_web/live/custom_field_live/form.ex:107
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr "erstellt"
#: lib/mv_web/live/member_live/form.ex:109
#: lib/mv_web/live/property_live/form.ex:201
#: lib/mv_web/live/property_type_live/form.ex:74
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
msgid "update"
msgstr "aktualisiert"
@ -228,7 +222,7 @@ msgstr "aktualisiert"
msgid "Incorrect email or password"
msgstr "Falsche E-Mail oder Passwort"
#: lib/mv_web/live/member_live/form.ex:115
#: lib/mv_web/live/member_live/form.ex:145
#, elixir-autogen, elixir-format
msgid "Member %{action} successfully"
msgstr "Mitglied %{action} erfolgreich"
@ -258,73 +252,68 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt"
msgid "Your password has successfully been reset"
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/property_live/form.ex:44
#: lib/mv_web/live/property_type_live/form.ex:32
#: lib/mv_web/live/user_live/form.ex:95
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr "Abbrechen"
#: lib/mv_web/live/property_live/form.ex:29
#: lib/mv_web/live/custom_field_value_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Choose a member"
msgstr "Mitglied auswählen"
#: lib/mv_web/live/property_live/form.ex:20
#, elixir-autogen, elixir-format
msgid "Choose a property type"
msgstr "Eigenschaftstyp auswählen"
#: lib/mv_web/live/property_type_live/form.ex:25
#: lib/mv_web/live/custom_field_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Description"
msgstr "Beschreibung"
#: lib/mv_web/live/user_live/show.ex:18
#: lib/mv_web/live/user_live/show.ex:43
#, elixir-autogen, elixir-format
msgid "Edit User"
msgstr "Benutzer*in bearbeiten"
#: lib/mv_web/live/user_live/show.ex:28
#: lib/mv_web/live/user_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr "Aktiviert"
#: lib/mv_web/live/user_live/show.ex:24
#: lib/mv_web/live/user_live/show.ex:49
#, elixir-autogen, elixir-format
msgid "ID"
msgstr "ID"
#: lib/mv_web/live/property_type_live/form.ex:26
#: lib/mv_web/live/custom_field_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr "Unveränderlich"
#: lib/mv_web/components/layouts/navbar.ex:93
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr "Abmelden"
#: lib/mv_web/live/user_live/index.ex:12
#: lib/mv_web/live/user_live/index.ex:33
#: lib/mv_web/live/user_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr "Benutzer*innen auflisten"
#: lib/mv_web/live/property_live/form.ex:27
#: lib/mv_web/live/custom_field_value_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:10
#: lib/mv_web/live/member_live/index.ex:39
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr "Mitglieder"
#: lib/mv_web/live/property_type_live/form.ex:16
#: lib/mv_web/live/custom_field_live/form.ex:50
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
@ -334,73 +323,43 @@ msgstr "Name"
msgid "New User"
msgstr "Neue*r Benutzer*in"
#: lib/mv_web/live/user_live/show.ex:28
#: lib/mv_web/live/user_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr "Nicht aktiviert"
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr "Nicht gesetzt"
#: lib/mv_web/live/user_live/form.ex:75
#: lib/mv_web/live/user_live/form.ex:83
#: lib/mv_web/live/user_live/form.ex:107
#: lib/mv_web/live/user_live/form.ex:115
#, elixir-autogen, elixir-format
msgid "Note"
msgstr "Hinweis"
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr "OIDC ID"
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr "Passwort-Authentifizierung"
#: lib/mv_web/live/property_live/form.ex:37
#, elixir-autogen, elixir-format
msgid "Please select a property type first"
msgstr "Bitte wählen Sie zuerst einen Eigenschaftstyp"
#: lib/mv_web/components/layouts/navbar.ex:88
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr "Profil"
#: lib/mv_web/live/property_live/form.ex:207
#, elixir-autogen, elixir-format
msgid "Property %{action} successfully"
msgstr "Eigenschaft %{action} erfolgreich"
#: lib/mv_web/live/property_live/form.ex:18
#, elixir-autogen, elixir-format
msgid "Property type"
msgstr "Eigenschaftstyp"
#: lib/mv_web/live/property_type_live/form.ex:80
#, elixir-autogen, elixir-format
msgid "Property type %{action} successfully"
msgstr "Eigenschaftstyp %{action} erfolgreich"
#: lib/mv_web/live/property_type_live/form.ex:27
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Required"
msgstr "Erforderlich"
#: lib/mv_web/live/property_live/form.ex:42
#, elixir-autogen, elixir-format
msgid "Save Property"
msgstr "Eigenschaft speichern"
#: lib/mv_web/live/property_type_live/form.ex:30
#, elixir-autogen, elixir-format
msgid "Save Property type"
msgstr "Eigenschaftstyp speichern"
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
@ -411,58 +370,48 @@ msgstr "Alle Mitglieder auswählen"
msgid "Select member"
msgstr "Mitglied auswählen"
#: lib/mv_web/components/layouts/navbar.ex:91
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr "Einstellungen"
#: lib/mv_web/live/user_live/form.ex:93
#: lib/mv_web/live/user_live/form.ex:125
#, elixir-autogen, elixir-format
msgid "Save User"
msgstr "Benutzer*in speichern"
#: lib/mv_web/live/user_live/show.ex:54
#: lib/mv_web/live/user_live/show.ex:79
#, elixir-autogen, elixir-format
msgid "Show User"
msgstr "Benutzer*in anzeigen"
#: lib/mv_web/live/user_live/show.ex:10
#: lib/mv_web/live/user_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
#: lib/mv_web/live/property_live/form.ex:95
#: lib/mv_web/live/custom_field_value_live/form.ex:128
#, elixir-autogen, elixir-format
msgid "Unsupported value type: %{type}"
msgstr "Nicht unterstützter Wertetyp: %{type}"
#: lib/mv_web/live/property_live/form.ex:10
#, elixir-autogen, elixir-format
msgid "Use this form to manage property records in your database."
msgstr "Dieses Formular dient zur Verwaltung von Eigenschaften in der Datenbank."
#: lib/mv_web/live/property_type_live/form.ex:11
#, elixir-autogen, elixir-format
msgid "Use this form to manage property_type records in your database."
msgstr "Dieses Formular dient zur Verwaltung von Eigenschaftstypen in der Datenbank."
#: lib/mv_web/live/user_live/form.ex:10
#: lib/mv_web/live/user_live/form.ex:42
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
#: lib/mv_web/live/user_live/form.ex:110
#: lib/mv_web/live/user_live/show.ex:9
#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
msgstr "Benutzer*in"
#: lib/mv_web/live/property_live/form.ex:59
#: lib/mv_web/live/custom_field_value_live/form.ex:92
#, elixir-autogen, elixir-format
msgid "Value"
msgstr "Wert"
#: lib/mv_web/live/property_type_live/form.ex:20
#: lib/mv_web/live/custom_field_live/form.ex:54
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr "Wertetyp"
@ -479,57 +428,57 @@ msgstr "aufsteigend"
msgid "descending"
msgstr "absteigend"
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/form.ex:141
#, elixir-autogen, elixir-format
msgid "New"
msgstr "Neue*r"
#: lib/mv_web/live/user_live/form.ex:64
#: lib/mv_web/live/user_live/form.ex:96
#, elixir-autogen, elixir-format
msgid "Admin Note"
msgstr "Administrator*innen-Hinweis"
#: lib/mv_web/live/user_live/form.ex:64
#: lib/mv_web/live/user_live/form.ex:96
#, elixir-autogen, elixir-format
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
msgstr "Als Administrator*in können Sie direkt ein neues Passwort für diese*n Benutzer*in setzen, wobei das gleiche sichere Ash Authentication System verwendet wird."
#: lib/mv_web/live/user_live/form.ex:55
#: lib/mv_web/live/user_live/form.ex:87
#, elixir-autogen, elixir-format
msgid "At least 8 characters"
msgstr "Mindestens 8 Zeichen"
#: lib/mv_web/live/user_live/form.ex:27
#: lib/mv_web/live/user_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Change Password"
msgstr "Passwort ändern"
#: lib/mv_web/live/user_live/form.ex:75
#: lib/mv_web/live/user_live/form.ex:107
#, elixir-autogen, elixir-format
msgid "Check 'Change Password' above to set a new password for this user."
msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen."
#: lib/mv_web/live/user_live/form.ex:45
#: lib/mv_web/live/user_live/form.ex:77
#, elixir-autogen, elixir-format
msgid "Confirm Password"
msgstr "Passwort bestätigen"
#: lib/mv_web/live/user_live/form.ex:57
#: lib/mv_web/live/user_live/form.ex:89
#, elixir-autogen, elixir-format
msgid "Consider using special characters"
msgstr "Sonderzeichen empfohlen"
#: lib/mv_web/live/user_live/form.ex:56
#: lib/mv_web/live/user_live/form.ex:88
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
msgstr "Buchstaben und Zahlen verwenden"
#: lib/mv_web/live/user_live/form.ex:35
#: lib/mv_web/live/user_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Password"
msgstr "Passwort"
#: lib/mv_web/live/user_live/form.ex:53
#: lib/mv_web/live/user_live/form.ex:85
#, elixir-autogen, elixir-format
msgid "Password requirements"
msgstr "Passwort-Anforderungen"
@ -544,56 +493,56 @@ msgstr "Alle Benutzer*innen auswählen"
msgid "Select user"
msgstr "Benutzer*in auswählen"
#: lib/mv_web/live/user_live/form.ex:27
#: lib/mv_web/live/user_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Set Password"
msgstr "Passwort setzen"
#: lib/mv_web/live/user_live/form.ex:83
#: lib/mv_web/live/user_live/form.ex:115
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen."
#: lib/mv_web/live/user_live/show.ex:30
#: lib/mv_web/live/user_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Linked Member"
msgstr "Verknüpftes Mitglied"
#: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:63
#, elixir-autogen, elixir-format
msgid "Linked User"
msgstr "Verknüpfte*r Benutzer*in"
#: lib/mv_web/live/user_live/show.ex:40
#: lib/mv_web/live/user_live/show.ex:65
#, elixir-autogen, elixir-format
msgid "No member linked"
msgstr "Kein Mitglied verknüpft"
#: lib/mv_web/live/member_live/show.ex:51
#: lib/mv_web/live/member_live/show.ex:73
#, elixir-autogen, elixir-format
msgid "No user linked"
msgstr "Keine*r Benutzer*in verknüpft"
#: lib/mv_web/live/member_live/show.ex:14
#: lib/mv_web/live/member_live/show.ex:16
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "Back to members list"
msgstr "Zurück zur Mitgliederliste"
#: lib/mv_web/live/user_live/show.ex:13
#: lib/mv_web/live/user_live/show.ex:15
#: lib/mv_web/live/user_live/show.ex:38
#: lib/mv_web/live/user_live/show.ex:40
#, elixir-autogen, elixir-format
msgid "Back to users list"
msgstr "Zurück zur Benutzer*innen-Liste"
#: lib/mv_web/components/layouts/navbar.ex:26
#: lib/mv_web/components/layouts/navbar.ex:32
#: lib/mv_web/components/layouts/navbar.ex:27
#: lib/mv_web/components/layouts/navbar.ex:33
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"
#: lib/mv_web/components/layouts/navbar.ex:39
#: lib/mv_web/components/layouts/navbar.ex:59
#: lib/mv_web/components/layouts/navbar.ex:40
#: lib/mv_web/components/layouts/navbar.ex:60
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten"
@ -604,7 +553,7 @@ msgstr "Dunklen Modus umschalten"
msgid "Search..."
msgstr "Suchen..."
#: lib/mv_web/components/layouts/navbar.ex:20
#: lib/mv_web/components/layouts/navbar.ex:21
#, elixir-autogen, elixir-format
msgid "Users"
msgstr "Benutzer*innen"
@ -650,3 +599,59 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits
#, elixir-autogen, elixir-format
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden."
#: lib/mv_web/live/custom_field_value_live/form.ex:53
#, elixir-autogen, elixir-format
msgid "Choose a custom field"
msgstr "Wähle ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/member_live/form.ex:59
#: lib/mv_web/live/member_live/show.ex:78
#, elixir-autogen, elixir-format
msgid "Custom Field Values"
msgstr "Benutzerdefinierte Feldwerte"
#: lib/mv_web/live/custom_field_value_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr "Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form.ex:114
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
#: lib/mv_web/live/custom_field_value_live/form.ex:242
#, elixir-autogen, elixir-format
msgid "Custom field value %{action} successfully"
msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
#: lib/mv_web/live/custom_field_value_live/form.ex:70
#, elixir-autogen, elixir-format
msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/custom_field_value_live/form.ex:75
#, elixir-autogen, elixir-format
msgid "Save Custom field value"
msgstr "Benutzerdefinierten Feldwert speichern"
#: lib/mv_web/live/custom_field_live/form.ex:45
#, elixir-autogen, elixir-format
msgid "Use this form to manage custom_field records in your database."
msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."
#: lib/mv_web/live/custom_field_value_live/form.ex:42
#, elixir-autogen, elixir-format
msgid "Use this form to manage custom_field_value records in your database."
msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
#: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format
msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder"

View file

@ -28,9 +28,9 @@ msgstr ""
msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
@ -42,43 +42,43 @@ msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:19
#: lib/mv_web/live/member_live/show.ex:95
#: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:50
#, elixir-autogen, elixir-format
msgid "Email"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/form.ex:45
#: lib/mv_web/live/member_live/show.ex:48
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/member_live/form.ex:46
#: lib/mv_web/live/member_live/show.ex:49
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr ""
@ -109,117 +109,111 @@ msgstr ""
msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:30
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/show.ex:40
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/form.ex:80
#, elixir-autogen, elixir-format
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/property_live/form.ex:41
#: lib/mv_web/live/property_type_live/form.ex:29
#: lib/mv_web/live/user_live/form.ex:92
#: lib/mv_web/live/custom_field_live/form.ex:63
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:11
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:31
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:94
#: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:11
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:31
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:108
#: lib/mv_web/live/property_live/form.ex:200
#: lib/mv_web/live/property_type_live/form.ex:73
#: lib/mv_web/live/custom_field_live/form.ex:107
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:109
#: lib/mv_web/live/property_live/form.ex:201
#: lib/mv_web/live/property_type_live/form.ex:74
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
msgid "update"
msgstr ""
@ -229,7 +223,7 @@ msgstr ""
msgid "Incorrect email or password"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:115
#: lib/mv_web/live/member_live/form.ex:145
#, elixir-autogen, elixir-format
msgid "Member %{action} successfully"
msgstr ""
@ -259,73 +253,68 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/property_live/form.ex:44
#: lib/mv_web/live/property_type_live/form.ex:32
#: lib/mv_web/live/user_live/form.ex:95
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:29
#: lib/mv_web/live/custom_field_value_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:20
#, elixir-autogen, elixir-format
msgid "Choose a property type"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:25
#: lib/mv_web/live/custom_field_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:18
#: lib/mv_web/live/user_live/show.ex:43
#, elixir-autogen, elixir-format
msgid "Edit User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:28
#: lib/mv_web/live/user_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:24
#: lib/mv_web/live/user_live/show.ex:49
#, elixir-autogen, elixir-format
msgid "ID"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:26
#: lib/mv_web/live/custom_field_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:93
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr ""
#: lib/mv_web/live/user_live/index.ex:12
#: lib/mv_web/live/user_live/index.ex:33
#: lib/mv_web/live/user_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:27
#: lib/mv_web/live/custom_field_value_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:10
#: lib/mv_web/live/member_live/index.ex:39
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:16
#: lib/mv_web/live/custom_field_live/form.ex:50
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -335,73 +324,43 @@ msgstr ""
msgid "New User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:28
#: lib/mv_web/live/user_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:75
#: lib/mv_web/live/user_live/form.ex:83
#: lib/mv_web/live/user_live/form.ex:107
#: lib/mv_web/live/user_live/form.ex:115
#, elixir-autogen, elixir-format
msgid "Note"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:37
#, elixir-autogen, elixir-format
msgid "Please select a property type first"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:88
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:207
#, elixir-autogen, elixir-format
msgid "Property %{action} successfully"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:18
#, elixir-autogen, elixir-format
msgid "Property type"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:80
#, elixir-autogen, elixir-format
msgid "Property type %{action} successfully"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:27
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:42
#, elixir-autogen, elixir-format
msgid "Save Property"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:30
#, elixir-autogen, elixir-format
msgid "Save Property type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
@ -412,58 +371,48 @@ msgstr ""
msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:91
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:93
#: lib/mv_web/live/user_live/form.ex:125
#, elixir-autogen, elixir-format
msgid "Save User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:54
#: lib/mv_web/live/user_live/show.ex:79
#, elixir-autogen, elixir-format
msgid "Show User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:10
#: lib/mv_web/live/user_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
msgstr ""
#: lib/mv_web/live/property_live/form.ex:95
#: lib/mv_web/live/custom_field_value_live/form.ex:128
#, elixir-autogen, elixir-format
msgid "Unsupported value type: %{type}"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:10
#, elixir-autogen, elixir-format
msgid "Use this form to manage property records in your database."
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:11
#, elixir-autogen, elixir-format
msgid "Use this form to manage property_type records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:10
#: lib/mv_web/live/user_live/form.ex:42
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:110
#: lib/mv_web/live/user_live/show.ex:9
#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:59
#: lib/mv_web/live/custom_field_value_live/form.ex:92
#, elixir-autogen, elixir-format
msgid "Value"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:20
#: lib/mv_web/live/custom_field_live/form.ex:54
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -480,57 +429,57 @@ msgstr ""
msgid "descending"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/form.ex:141
#, elixir-autogen, elixir-format
msgid "New"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:64
#: lib/mv_web/live/user_live/form.ex:96
#, elixir-autogen, elixir-format
msgid "Admin Note"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:64
#: lib/mv_web/live/user_live/form.ex:96
#, elixir-autogen, elixir-format
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:55
#: lib/mv_web/live/user_live/form.ex:87
#, elixir-autogen, elixir-format
msgid "At least 8 characters"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:27
#: lib/mv_web/live/user_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Change Password"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:75
#: lib/mv_web/live/user_live/form.ex:107
#, elixir-autogen, elixir-format
msgid "Check 'Change Password' above to set a new password for this user."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:45
#: lib/mv_web/live/user_live/form.ex:77
#, elixir-autogen, elixir-format
msgid "Confirm Password"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:57
#: lib/mv_web/live/user_live/form.ex:89
#, elixir-autogen, elixir-format
msgid "Consider using special characters"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:56
#: lib/mv_web/live/user_live/form.ex:88
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:35
#: lib/mv_web/live/user_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Password"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:53
#: lib/mv_web/live/user_live/form.ex:85
#, elixir-autogen, elixir-format
msgid "Password requirements"
msgstr ""
@ -545,56 +494,56 @@ msgstr ""
msgid "Select user"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:27
#: lib/mv_web/live/user_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Set Password"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:83
#: lib/mv_web/live/user_live/form.ex:115
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr ""
#: lib/mv_web/live/user_live/show.ex:30
#: lib/mv_web/live/user_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Linked Member"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:63
#, elixir-autogen, elixir-format
msgid "Linked User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:40
#: lib/mv_web/live/user_live/show.ex:65
#, elixir-autogen, elixir-format
msgid "No member linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:51
#: lib/mv_web/live/member_live/show.ex:73
#, elixir-autogen, elixir-format
msgid "No user linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:14
#: lib/mv_web/live/member_live/show.ex:16
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "Back to members list"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:13
#: lib/mv_web/live/user_live/show.ex:15
#: lib/mv_web/live/user_live/show.ex:38
#: lib/mv_web/live/user_live/show.ex:40
#, elixir-autogen, elixir-format
msgid "Back to users list"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:26
#: lib/mv_web/components/layouts/navbar.ex:32
#: lib/mv_web/components/layouts/navbar.ex:27
#: lib/mv_web/components/layouts/navbar.ex:33
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:39
#: lib/mv_web/components/layouts/navbar.ex:59
#: lib/mv_web/components/layouts/navbar.ex:40
#: lib/mv_web/components/layouts/navbar.ex:60
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr ""
@ -605,7 +554,7 @@ msgstr ""
msgid "Search..."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20
#: lib/mv_web/components/layouts/navbar.ex:21
#, elixir-autogen, elixir-format
msgid "Users"
msgstr ""
@ -651,3 +600,59 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:53
#, elixir-autogen, elixir-format
msgid "Choose a custom field"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:59
#: lib/mv_web/live/member_live/show.ex:78
#, elixir-autogen, elixir-format
msgid "Custom Field Values"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:114
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:242
#, elixir-autogen, elixir-format
msgid "Custom field value %{action} successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:70
#, elixir-autogen, elixir-format
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:75
#, elixir-autogen, elixir-format
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:45
#, elixir-autogen, elixir-format
msgid "Use this form to manage custom_field records in your database."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:42
#, elixir-autogen, elixir-format
msgid "Use this form to manage custom_field_value records in your database."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format
msgid "Custom Fields"
msgstr ""

View file

@ -28,9 +28,9 @@ msgstr ""
msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
@ -42,43 +42,43 @@ msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:19
#: lib/mv_web/live/member_live/show.ex:95
#: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:50
#, elixir-autogen, elixir-format
msgid "Email"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/form.ex:45
#: lib/mv_web/live/member_live/show.ex:48
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/member_live/form.ex:46
#: lib/mv_web/live/member_live/show.ex:49
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr ""
@ -109,117 +109,111 @@ msgstr ""
msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:30
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/show.ex:40
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/form.ex:80
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/property_live/form.ex:41
#: lib/mv_web/live/property_type_live/form.ex:29
#: lib/mv_web/live/user_live/form.ex:92
#: lib/mv_web/live/custom_field_live/form.ex:63
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:11
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:31
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:94
#: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:11
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:31
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:108
#: lib/mv_web/live/property_live/form.ex:200
#: lib/mv_web/live/property_type_live/form.ex:73
#: lib/mv_web/live/custom_field_live/form.ex:107
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:109
#: lib/mv_web/live/property_live/form.ex:201
#: lib/mv_web/live/property_type_live/form.ex:74
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
msgid "update"
msgstr ""
@ -229,7 +223,7 @@ msgstr ""
msgid "Incorrect email or password"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:115
#: lib/mv_web/live/member_live/form.ex:145
#, elixir-autogen, elixir-format
msgid "Member %{action} successfully"
msgstr ""
@ -259,73 +253,68 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/property_live/form.ex:44
#: lib/mv_web/live/property_type_live/form.ex:32
#: lib/mv_web/live/user_live/form.ex:95
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:29
#: lib/mv_web/live/custom_field_value_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:20
#, elixir-autogen, elixir-format
msgid "Choose a property type"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:25
#: lib/mv_web/live/custom_field_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:18
#: lib/mv_web/live/user_live/show.ex:43
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:28
#: lib/mv_web/live/user_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:24
#: lib/mv_web/live/user_live/show.ex:49
#, elixir-autogen, elixir-format
msgid "ID"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:26
#: lib/mv_web/live/custom_field_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:93
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr ""
#: lib/mv_web/live/user_live/index.ex:12
#: lib/mv_web/live/user_live/index.ex:33
#: lib/mv_web/live/user_live/index.html.heex:3
#, elixir-autogen, elixir-format, fuzzy
msgid "Listing Users"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:27
#: lib/mv_web/live/custom_field_value_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:10
#: lib/mv_web/live/member_live/index.ex:39
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:16
#: lib/mv_web/live/custom_field_live/form.ex:50
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -335,73 +324,43 @@ msgstr ""
msgid "New User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:28
#: lib/mv_web/live/user_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:51
#, elixir-autogen, elixir-format, fuzzy
msgid "Not set"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:75
#: lib/mv_web/live/user_live/form.ex:83
#: lib/mv_web/live/user_live/form.ex:107
#: lib/mv_web/live/user_live/form.ex:115
#, elixir-autogen, elixir-format, fuzzy
msgid "Note"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:37
#, elixir-autogen, elixir-format
msgid "Please select a property type first"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:88
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:207
#, elixir-autogen, elixir-format, fuzzy
msgid "Property %{action} successfully"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:18
#, elixir-autogen, elixir-format
msgid "Property type"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:80
#, elixir-autogen, elixir-format
msgid "Property type %{action} successfully"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:27
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:42
#, elixir-autogen, elixir-format
msgid "Save Property"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:30
#, elixir-autogen, elixir-format
msgid "Save Property type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
@ -412,58 +371,48 @@ msgstr ""
msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:91
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:93
#: lib/mv_web/live/user_live/form.ex:125
#, elixir-autogen, elixir-format, fuzzy
msgid "Save User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:54
#: lib/mv_web/live/user_live/show.ex:79
#, elixir-autogen, elixir-format, fuzzy
msgid "Show User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:10
#: lib/mv_web/live/user_live/show.ex:35
#, elixir-autogen, elixir-format, fuzzy
msgid "This is a user record from your database."
msgstr ""
#: lib/mv_web/live/property_live/form.ex:95
#: lib/mv_web/live/custom_field_value_live/form.ex:128
#, elixir-autogen, elixir-format
msgid "Unsupported value type: %{type}"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:10
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage property records in your database."
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:11
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage property_type records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:10
#: lib/mv_web/live/user_live/form.ex:42
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage user records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:110
#: lib/mv_web/live/user_live/show.ex:9
#: lib/mv_web/live/user_live/form.ex:142
#: lib/mv_web/live/user_live/show.ex:34
#, elixir-autogen, elixir-format
msgid "User"
msgstr ""
#: lib/mv_web/live/property_live/form.ex:59
#: lib/mv_web/live/custom_field_value_live/form.ex:92
#, elixir-autogen, elixir-format
msgid "Value"
msgstr ""
#: lib/mv_web/live/property_type_live/form.ex:20
#: lib/mv_web/live/custom_field_live/form.ex:54
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -480,57 +429,57 @@ msgstr ""
msgid "descending"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/form.ex:141
#, elixir-autogen, elixir-format
msgid "New"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:64
#: lib/mv_web/live/user_live/form.ex:96
#, elixir-autogen, elixir-format
msgid "Admin Note"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:64
#: lib/mv_web/live/user_live/form.ex:96
#, elixir-autogen, elixir-format
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
msgstr "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
#: lib/mv_web/live/user_live/form.ex:55
#: lib/mv_web/live/user_live/form.ex:87
#, elixir-autogen, elixir-format
msgid "At least 8 characters"
msgstr "At least 8 characters"
#: lib/mv_web/live/user_live/form.ex:27
#: lib/mv_web/live/user_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Change Password"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:75
#: lib/mv_web/live/user_live/form.ex:107
#, elixir-autogen, elixir-format
msgid "Check 'Change Password' above to set a new password for this user."
msgstr "Check 'Change Password' above to set a new password for this user."
#: lib/mv_web/live/user_live/form.ex:45
#: lib/mv_web/live/user_live/form.ex:77
#, elixir-autogen, elixir-format
msgid "Confirm Password"
msgstr "Confirm Password"
#: lib/mv_web/live/user_live/form.ex:57
#: lib/mv_web/live/user_live/form.ex:89
#, elixir-autogen, elixir-format
msgid "Consider using special characters"
msgstr "Consider using special characters"
#: lib/mv_web/live/user_live/form.ex:56
#: lib/mv_web/live/user_live/form.ex:88
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
msgstr "Include both letters and numbers"
#: lib/mv_web/live/user_live/form.ex:35
#: lib/mv_web/live/user_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Password"
msgstr "Password"
#: lib/mv_web/live/user_live/form.ex:53
#: lib/mv_web/live/user_live/form.ex:85
#, elixir-autogen, elixir-format
msgid "Password requirements"
msgstr "Password requirements"
@ -545,56 +494,56 @@ msgstr ""
msgid "Select user"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:27
#: lib/mv_web/live/user_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Set Password"
msgstr "Set Password"
#: lib/mv_web/live/user_live/form.ex:83
#: lib/mv_web/live/user_live/form.ex:115
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "User will be created without a password. Check 'Set Password' to add one."
#: lib/mv_web/live/user_live/show.ex:30
#: lib/mv_web/live/user_live/show.ex:55
#, elixir-autogen, elixir-format, fuzzy
msgid "Linked Member"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:63
#, elixir-autogen, elixir-format
msgid "Linked User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:40
#: lib/mv_web/live/user_live/show.ex:65
#, elixir-autogen, elixir-format
msgid "No member linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:51
#: lib/mv_web/live/member_live/show.ex:73
#, elixir-autogen, elixir-format
msgid "No user linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:14
#: lib/mv_web/live/member_live/show.ex:16
#: lib/mv_web/live/member_live/show.ex:36
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "Back to members list"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:13
#: lib/mv_web/live/user_live/show.ex:15
#: lib/mv_web/live/user_live/show.ex:38
#: lib/mv_web/live/user_live/show.ex:40
#, elixir-autogen, elixir-format
msgid "Back to users list"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:26
#: lib/mv_web/components/layouts/navbar.ex:32
#: lib/mv_web/components/layouts/navbar.ex:27
#: lib/mv_web/components/layouts/navbar.ex:33
#, elixir-autogen, elixir-format, fuzzy
msgid "Select language"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:39
#: lib/mv_web/components/layouts/navbar.ex:59
#: lib/mv_web/components/layouts/navbar.ex:40
#: lib/mv_web/components/layouts/navbar.ex:60
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr ""
@ -605,7 +554,7 @@ msgstr ""
msgid "Search..."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20
#: lib/mv_web/components/layouts/navbar.ex:21
#, elixir-autogen, elixir-format, fuzzy
msgid "Users"
msgstr ""
@ -651,3 +600,59 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:53
#, elixir-autogen, elixir-format
msgid "Choose a custom field"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:59
#: lib/mv_web/live/member_live/show.ex:78
#, elixir-autogen, elixir-format
msgid "Custom Field Values"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:114
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:242
#, elixir-autogen, elixir-format
msgid "Custom field value %{action} successfully"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:70
#, elixir-autogen, elixir-format
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:75
#, elixir-autogen, elixir-format
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:45
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage custom_field records in your database."
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex:42
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage custom_field_value records in your database."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom Fields"
msgstr ""

View file

@ -0,0 +1,19 @@
defmodule Mv.Repo.Migrations.RenamePropertiesToCustomFieldsExtensions1 do
@moduledoc """
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
execute("CREATE EXTENSION IF NOT EXISTS \"pg_trgm\"")
end
def down do
# Uncomment this if you actually want to uninstall the extensions
# when this migration is rolled back:
# execute("DROP EXTENSION IF EXISTS \"pg_trgm\"")
end
end

View file

@ -0,0 +1,84 @@
defmodule Mv.Repo.Migrations.RenamePropertiesToCustomFields do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
# Rename tables
rename table("property_types"), to: table("custom_fields")
rename table("properties"), to: table("custom_field_values")
# Rename the foreign key column
rename table("custom_field_values"), :property_type_id, to: :custom_field_id
# Drop old foreign key constraints
drop constraint(:custom_field_values, "properties_member_id_fkey")
drop constraint(:custom_field_values, "properties_property_type_id_fkey")
# Add new foreign key constraints with correct names and on_delete behavior
alter table(:custom_field_values) do
modify :member_id,
references(:members,
column: :id,
name: "custom_field_values_member_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
modify :custom_field_id,
references(:custom_fields,
column: :id,
name: "custom_field_values_custom_field_id_fkey",
type: :uuid,
prefix: "public"
)
end
# Rename indexes
execute "ALTER INDEX IF EXISTS property_types_unique_name_index RENAME TO custom_fields_unique_name_index"
execute "ALTER INDEX IF EXISTS properties_unique_property_per_member_index RENAME TO custom_field_values_unique_custom_field_per_member_index"
end
def down do
# Rename indexes back
execute "ALTER INDEX IF EXISTS custom_fields_unique_name_index RENAME TO property_types_unique_name_index"
execute "ALTER INDEX IF EXISTS custom_field_values_unique_custom_field_per_member_index RENAME TO properties_unique_property_per_member_index"
# Drop new foreign key constraints
drop constraint(:custom_field_values, "custom_field_values_member_id_fkey")
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
# Add back old foreign key constraints
alter table(:custom_field_values) do
modify :member_id,
references(:members,
column: :id,
name: "properties_member_id_fkey",
type: :uuid,
prefix: "public"
)
modify :custom_field_id,
references(:custom_fields,
column: :id,
name: "properties_property_type_id_fkey",
type: :uuid,
prefix: "public"
)
end
# Rename the foreign key column back
rename table("custom_field_values"), :custom_field_id, to: :property_type_id
# Rename tables back
rename table("custom_fields"), to: table("property_types")
rename table("custom_field_values"), to: table("properties")
end
end

View file

@ -7,36 +7,94 @@ alias Mv.Membership
alias Mv.Accounts
for attrs <- [
# Basic example fields (for testing)
%{
name: "String Field",
value_type: :string,
description: "Example for a field of type string",
immutable: true,
required: true
required: false
},
%{
name: "Date Field",
value_type: :date,
description: "Example for a field of type date",
immutable: true,
required: true
required: false
},
%{
name: "Boolean Field",
value_type: :boolean,
description: "Example for a field of type boolean",
immutable: true,
required: true
required: false
},
%{
name: "Email Field",
value_type: :email,
description: "Example for a field of type email",
immutable: true,
required: true
required: false
},
# Realistic custom fields
%{
name: "Membership Number",
value_type: :string,
description: "Unique membership identification number",
immutable: false,
required: false
},
%{
name: "Emergency Contact",
value_type: :string,
description: "Emergency contact person name and phone",
immutable: false,
required: false
},
%{
name: "T-Shirt Size",
value_type: :string,
description: "T-Shirt size for events (XS, S, M, L, XL, XXL)",
immutable: false,
required: false
},
%{
name: "Newsletter Subscription",
value_type: :boolean,
description: "Whether member wants to receive newsletter",
immutable: false,
required: false
},
%{
name: "Date of Last Medical Check",
value_type: :date,
description: "Date of last medical examination",
immutable: false,
required: false
},
%{
name: "Secondary Email",
value_type: :email,
description: "Alternative email address",
immutable: false,
required: false
},
%{
name: "Membership Type",
value_type: :string,
description: "Type of membership (e.g., Regular, Student, Senior)",
immutable: false,
required: false
},
%{
name: "Parking Permit",
value_type: :boolean,
description: "Whether member has parking permit",
immutable: false,
required: false
}
] do
Membership.create_property_type!(
Membership.create_custom_field!(
attrs,
upsert?: true,
upsert_identity: :unique_name
@ -180,9 +238,94 @@ Enum.each(linked_members, fn member_attrs ->
end
end)
# Create sample custom field values for some members
all_members = Ash.read!(Membership.Member)
all_custom_fields = Ash.read!(Membership.CustomField)
# Helper function to find custom field by name
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
# Add custom field values for Hans Müller
if hans = find_member.("hans.mueller@example.de") do
[
{find_field.("Membership Number"),
%{"_union_type" => "string", "_union_value" => "M-2023-001"}},
{find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "L"}},
{find_field.("Newsletter Subscription"),
%{"_union_type" => "boolean", "_union_value" => true}},
{find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Regular"}},
{find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => true}},
{find_field.("Secondary Email"),
%{"_union_type" => "email", "_union_value" => "hans.m@private.de"}}
]
|> Enum.each(fn {field, value} ->
if field do
Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: hans.id,
custom_field_id: field.id,
value: value
})
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
end
end)
end
# Add custom field values for Greta Schmidt
if greta = find_member.("greta.schmidt@example.de") do
[
{find_field.("Membership Number"),
%{"_union_type" => "string", "_union_value" => "M-2023-015"}},
{find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "M"}},
{find_field.("Newsletter Subscription"),
%{"_union_type" => "boolean", "_union_value" => true}},
{find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Student"}},
{find_field.("Emergency Contact"),
%{"_union_type" => "string", "_union_value" => "Anna Schmidt, +49301234567"}}
]
|> Enum.each(fn {field, value} ->
if field do
Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: greta.id,
custom_field_id: field.id,
value: value
})
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
end
end)
end
# Add custom field values for Friedrich Wagner
if friedrich = find_member.("friedrich.wagner@example.de") do
[
{find_field.("Membership Number"),
%{"_union_type" => "string", "_union_value" => "M-2022-042"}},
{find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "XL"}},
{find_field.("Newsletter Subscription"),
%{"_union_type" => "boolean", "_union_value" => false}},
{find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Senior"}},
{find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => false}},
{find_field.("Date of Last Medical Check"),
%{"_union_type" => "date", "_union_value" => ~D[2024-03-15]}}
]
|> Enum.each(fn {field, value} ->
if field do
Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: friedrich.id,
custom_field_id: field.id,
value: value
})
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
end
end)
end
IO.puts("✅ Seeds completed successfully!")
IO.puts("📝 Created sample data:")
IO.puts(" - Property types: String, Date, Boolean, Email")
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
IO.puts(" - Sample members: Hans, Greta, Friedrich")
@ -194,4 +337,8 @@ IO.puts(
" - Linked members: Maria Weber ↔ maria.weber@example.de, Thomas Klein ↔ thomas.klein@example.de"
)
IO.puts("🔗 Visit the application to see user-member relationships in action!")
IO.puts(
" - Custom field values: Sample data for Hans (6 fields), Greta (5 fields), Friedrich (6 fields)"
)
IO.puts("🔗 Visit the application to see user-member relationships and custom fields in action!")

View file

@ -0,0 +1,124 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value",
"type": "map"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "custom_field_values_member_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "custom_field_values_custom_field_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "custom_fields"
},
"scale": null,
"size": null,
"source": "custom_field_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "DFA12C7D80B09C2EE5125469A1EDEF0412C7B2A7E44A9FD97A1387C52C8D7753",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_field_values_unique_custom_field_per_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "custom_field_id"
}
],
"name": "unique_custom_field_per_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_field_values"
}

View file

@ -0,0 +1,106 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "immutable",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "required",
"type": "boolean"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "B98535258034AE3C37FCB7AF054B97D7CCADE3CA7015B1B93C64CDE1250807EE",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_fields"
}

View file

@ -2,6 +2,7 @@
"ash_functions_version": 5,
"installed": [
"ash-functions",
"citext"
"citext",
"pg_trgm"
]
}

View file

@ -0,0 +1,205 @@
defmodule Mv.Membership.CustomFieldValidationTest do
@moduledoc """
Tests for CustomField validation constraints.
Tests cover:
- Name length validation (max 100 characters)
- Name trimming
- Description length validation (max 500 characters)
- Description trimming
- Required vs optional fields
"""
use Mv.DataCase, async: true
alias Mv.Membership.CustomField
describe "name validation" do
test "accepts name with exactly 100 characters" do
name = String.duplicate("a", 100)
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: name,
value_type: :string
})
|> Ash.create()
assert custom_field.name == name
assert String.length(custom_field.name) == 100
end
test "rejects name with 101 characters" do
name = String.duplicate("a", 101)
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: name,
value_type: :string
})
|> Ash.create()
assert [%{field: :name, message: message}] = changeset.errors
assert message =~ "max" or message =~ "length" or message =~ "100"
end
test "trims whitespace from name" do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: " test_field ",
value_type: :string
})
|> Ash.create()
assert custom_field.name == "test_field"
end
test "rejects empty name" do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "",
value_type: :string
})
|> Ash.create()
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
test "rejects nil name" do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
value_type: :string
})
|> Ash.create()
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
end
describe "description validation" do
test "accepts description with exactly 500 characters" do
description = String.duplicate("a", 500)
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string,
description: description
})
|> Ash.create()
assert custom_field.description == description
assert String.length(custom_field.description) == 500
end
test "rejects description with 501 characters" do
description = String.duplicate("a", 501)
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string,
description: description
})
|> Ash.create()
assert [%{field: :description, message: message}] = changeset.errors
assert message =~ "max" or message =~ "length" or message =~ "500"
end
test "trims whitespace from description" do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string,
description: " A nice description "
})
|> Ash.create()
assert custom_field.description == "A nice description"
end
test "accepts nil description (optional field)" do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string
})
|> Ash.create()
assert custom_field.description == nil
end
test "accepts empty description after trimming" do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string,
description: " "
})
|> Ash.create()
# After trimming whitespace, becomes nil (empty strings are converted to nil)
assert custom_field.description == nil
end
end
describe "name uniqueness" do
test "rejects duplicate names" do
assert {:ok, _} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "unique_field",
value_type: :string
})
|> Ash.create()
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "unique_field",
value_type: :integer
})
|> Ash.create()
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
end
describe "value_type validation" do
test "accepts all valid value types" do
for value_type <- [:string, :integer, :boolean, :date, :email] do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "field_#{value_type}",
value_type: value_type
})
|> Ash.create()
assert custom_field.value_type == value_type
end
end
test "rejects invalid value type" do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "invalid_field",
value_type: :invalid_type
})
|> Ash.create()
assert [%{field: :value_type}] = changeset.errors
end
end
end

View file

@ -0,0 +1,305 @@
defmodule Mv.Membership.CustomFieldValueValidationTest do
@moduledoc """
Tests for CustomFieldValue validation constraints.
Tests cover:
- String value length validation (max 10,000 characters)
- String value trimming
- Email value validation (via Email type)
- Optional values (nil allowed)
"""
use Mv.DataCase, async: true
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create a test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test.validation@example.com"
})
|> Ash.create()
# Create custom fields for different types
{:ok, string_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "string_field",
value_type: :string
})
|> Ash.create()
{:ok, integer_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "integer_field",
value_type: :integer
})
|> Ash.create()
{:ok, email_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "email_field",
value_type: :email
})
|> Ash.create()
%{
member: member,
string_field: string_field,
integer_field: integer_field,
email_field: email_field
}
end
describe "string value length validation" do
test "accepts string value with exactly 10,000 characters", %{
member: member,
string_field: string_field
} do
value_string = String.duplicate("a", 10_000)
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{
"_union_type" => "string",
"_union_value" => value_string
}
})
|> Ash.create()
assert custom_field_value.value.value == value_string
assert String.length(custom_field_value.value.value) == 10_000
end
test "rejects string value with 10,001 characters", %{
member: member,
string_field: string_field
} do
value_string = String.duplicate("a", 10_001)
assert {:error, changeset} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => value_string}
})
|> Ash.create()
assert Enum.any?(changeset.errors, fn error ->
error.field == :value and (error.message =~ "max" or error.message =~ "length")
end)
end
test "trims whitespace from string value", %{member: member, string_field: string_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => " test value "}
})
|> Ash.create()
assert custom_field_value.value.value == "test value"
end
test "accepts empty string value", %{member: member, string_field: string_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => ""}
})
|> Ash.create()
# Empty strings after trimming become nil
assert custom_field_value.value.value == nil
end
test "accepts string with special characters", %{member: member, string_field: string_field} do
special_string = "Hello 世界! 🎉 @#$%^&*()"
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => special_string}
})
|> Ash.create()
assert custom_field_value.value.value == special_string
end
end
describe "integer value validation" do
test "accepts valid integer value", %{member: member, integer_field: integer_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 42}
})
|> Ash.create()
assert custom_field_value.value.value == 42
end
test "accepts negative integer", %{member: member, integer_field: integer_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => -100}
})
|> Ash.create()
assert custom_field_value.value.value == -100
end
test "accepts zero", %{member: member, integer_field: integer_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 0}
})
|> Ash.create()
assert custom_field_value.value.value == 0
end
end
describe "email value validation" do
test "accepts nil value (optional field)", %{member: member, email_field: email_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => nil}
})
|> Ash.create()
assert custom_field_value.value.value == nil
end
test "accepts empty string (becomes nil after trim)", %{
member: member,
email_field: email_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => ""}
})
|> Ash.create()
# Empty string after trim should become nil
assert custom_field_value.value.value == nil
end
test "accepts valid email", %{member: member, email_field: email_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "test@example.com"}
})
|> Ash.create()
assert custom_field_value.value.value == "test@example.com"
end
test "rejects invalid email format", %{member: member, email_field: email_field} do
assert {:error, changeset} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "not-an-email"}
})
|> Ash.create()
assert Enum.any?(changeset.errors, fn error -> error.field == :value end)
end
test "rejects email longer than 254 characters", %{member: member, email_field: email_field} do
# Create an email with >254 chars (243 + 12 = 255)
long_email = String.duplicate("a", 243) <> "@example.com"
assert {:error, changeset} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => long_email}
})
|> Ash.create()
assert Enum.any?(changeset.errors, fn error -> error.field == :value end)
end
test "trims whitespace from email", %{member: member, email_field: email_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => " test@example.com "}
})
|> Ash.create()
assert custom_field_value.value.value == "test@example.com"
end
end
describe "uniqueness constraint" do
test "rejects duplicate custom_field_id per member", %{
member: member,
string_field: string_field
} do
# Create first custom field value
assert {:ok, _} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "first value"}
})
|> Ash.create()
# Try to create second custom field value with same custom_field_id for same member
assert {:error, changeset} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "second value"}
})
|> Ash.create()
# Should have uniqueness error
assert Enum.any?(changeset.errors, fn error ->
error.message =~ "unique" or error.message =~ "already exists" or
error.message =~ "has already been taken"
end)
end
end
end

View file

@ -148,10 +148,10 @@ defmodule MvWeb.ProfileNavigationTest do
"/",
"/members",
"/members/new",
"/properties",
"/properties/new",
"/property_types",
"/property_types/new",
"/custom_field_values",
"/custom_field_values/new",
"/custom_fields",
"/custom_fields/new",
"/users",
"/users/new"
]

View file

@ -9,11 +9,11 @@ defmodule Mv.SeedsTest do
# Basic smoke test: ensure some data was created
{:ok, users} = Ash.read(Mv.Accounts.User)
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, property_types} = Ash.read(Mv.Membership.PropertyType)
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField)
assert length(users) > 0, "Seeds should create at least one user"
assert length(members) > 0, "Seeds should create at least one member"
assert length(property_types) > 0, "Seeds should create at least one property type"
assert length(custom_fields) > 0, "Seeds should create at least one custom field"
end
test "can be run multiple times (idempotent)" do
@ -23,7 +23,7 @@ defmodule Mv.SeedsTest do
# Count records
{:ok, users_count_1} = Ash.read(Mv.Accounts.User)
{:ok, members_count_1} = Ash.read(Mv.Membership.Member)
{:ok, property_types_count_1} = Ash.read(Mv.Membership.PropertyType)
{:ok, custom_fields_count_1} = Ash.read(Mv.Membership.CustomField)
# Run seeds second time - should not raise errors
assert Code.eval_file("priv/repo/seeds.exs")
@ -31,7 +31,7 @@ defmodule Mv.SeedsTest do
# Count records again - should be the same (upsert, not duplicate)
{:ok, users_count_2} = Ash.read(Mv.Accounts.User)
{:ok, members_count_2} = Ash.read(Mv.Membership.Member)
{:ok, property_types_count_2} = Ash.read(Mv.Membership.PropertyType)
{:ok, custom_fields_count_2} = Ash.read(Mv.Membership.CustomField)
assert length(users_count_1) == length(users_count_2),
"Users count should remain same after re-running seeds"
@ -39,8 +39,8 @@ defmodule Mv.SeedsTest do
assert length(members_count_1) == length(members_count_2),
"Members count should remain same after re-running seeds"
assert length(property_types_count_1) == length(property_types_count_2),
"PropertyTypes count should remain same after re-running seeds"
assert length(custom_fields_count_1) == length(custom_fields_count_2),
"CustomFields count should remain same after re-running seeds"
end
end
end