Custom Fields: Harden implementation closes #194 #204
31 changed files with 1002 additions and 647 deletions
|
|
@ -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.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
moritz marked this conversation as resolved
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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}` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
defmodule Mv.Membership.PropertyType do
|
||||
defmodule Mv.Membership.CustomField do
|
||||
@moduledoc """
|
||||
Ash resource defining the schema for custom member properties.
|
||||
Ash resource defining the schema for custom member fields.
|
||||
|
||||
## 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.
|
||||
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 property (e.g., "phone_mobile", "birthday")
|
||||
- `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, property values cannot be changed after creation
|
||||
- `required` - If true, all members must have this property (future feature)
|
||||
- `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 (unlimited length)
|
||||
|
|
@ -22,22 +22,22 @@ defmodule Mv.Membership.PropertyType do
|
|||
- `:email` - Validated email addresses
|
||||
|
||||
## Relationships
|
||||
- `has_many :properties` - All property values of this type
|
||||
- `has_many :custom_field_values` - All custom field values of this type
|
||||
|
||||
## Constraints
|
||||
- Name must be unique across all property types
|
||||
- Cannot delete a property type that has existing property values (RESTRICT)
|
||||
- Name must be unique across all custom fields
|
||||
- Cannot delete a custom field that has existing custom field values (RESTRICT)
|
||||
|
||||
## Examples
|
||||
# Create a new property type
|
||||
PropertyType.create!(%{
|
||||
# Create a new custom field
|
||||
CustomField.create!(%{
|
||||
name: "phone_mobile",
|
||||
value_type: :string,
|
||||
description: "Mobile phone number"
|
||||
})
|
||||
|
||||
# Create a required property type
|
||||
PropertyType.create!(%{
|
||||
# Create a required custom field
|
||||
CustomField.create!(%{
|
||||
name: "emergency_contact",
|
||||
value_type: :string,
|
||||
required: true
|
||||
|
|
@ -48,7 +48,7 @@ defmodule Mv.Membership.PropertyType do
|
|||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "property_types"
|
||||
table "custom_fields"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ defmodule Mv.Membership.PropertyType do
|
|||
attribute :value_type, :atom,
|
||||
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
|
||||
allow_nil?: false,
|
||||
description: "Defines the datatype `Property.value` is interpreted as"
|
||||
description: "Defines the datatype `CustomFieldValue.value` is interpreted as"
|
||||
|
||||
attribute :description, :string, allow_nil?: true, public?: true
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ defmodule Mv.Membership.PropertyType do
|
|||
end
|
||||
|
||||
relationships do
|
||||
has_many :properties, Mv.Membership.Property
|
||||
has_many :custom_field_values, Mv.Membership.CustomFieldValue
|
||||
end
|
||||
|
||||
identities do
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
defmodule Mv.Membership.Property do
|
||||
defmodule Mv.Membership.CustomFieldValue do
|
||||
@moduledoc """
|
||||
Ash resource representing a custom property value for a member.
|
||||
Ash resource representing a custom field 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.
|
||||
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:
|
||||
|
|
@ -24,19 +24,19 @@ defmodule Mv.Membership.Property do
|
|||
- `: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
|
||||
- `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 property per property type (unique composite index)
|
||||
- Properties are deleted when the associated member is deleted (CASCADE)
|
||||
- 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)
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "properties"
|
||||
table "custom_field_values"
|
||||
repo Mv.Repo
|
||||
|
||||
references do
|
||||
|
|
@ -46,7 +46,7 @@ defmodule Mv.Membership.Property do
|
|||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
default_accept [:value, :member_id, :property_type_id]
|
||||
default_accept [:value, :member_id, :custom_field_id]
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
|
@ -68,16 +68,16 @@ defmodule Mv.Membership.Property do
|
|||
relationships do
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
belongs_to :property_type, Mv.Membership.PropertyType
|
||||
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 property per property type
|
||||
# For example: A member can have only one "email" property, one "phone" property, etc.
|
||||
# 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_property_per_member, [:member_id, :property_type_id]
|
||||
identity :unique_custom_field_per_member, [:member_id, :custom_field_id]
|
||||
end
|
||||
end
|
||||
|
|
@ -4,7 +4,7 @@ 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
|
||||
|
|
@ -14,12 +14,12 @@ defmodule Mv.Membership.Email do
|
|||
- Automatic trimming of leading/trailing whitespace
|
||||
|
||||
## 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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
88
lib/mv_web/live/custom_field_live/index.ex
Normal file
88
lib/mv_web/live/custom_field_live/index.ex
Normal 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
|
||||
66
lib/mv_web/live/custom_field_live/show.ex
Normal file
66
lib/mv_web/live/custom_field_live/show.ex
Normal 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
|
||||
|
|
@ -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.")}
|
||||
|
moritz marked this conversation as resolved
carla
commented
Maybe without underscore? Maybe without underscore?
|
||||
</: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
|
||||
86
lib/mv_web/live/custom_field_value_live/index.ex
Normal file
86
lib/mv_web/live/custom_field_value_live/index.ex
Normal 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
|
||||
67
lib/mv_web/live/custom_field_value_live/show.ex
Normal file
67
lib/mv_web/live/custom_field_value_live/show.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -36,7 +36,7 @@ for attrs <- [
|
|||
required: true
|
||||
}
|
||||
] do
|
||||
Membership.create_property_type!(
|
||||
Membership.create_custom_field!(
|
||||
attrs,
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_name
|
||||
|
|
@ -182,7 +182,7 @@ end)
|
|||
|
||||
IO.puts("✅ Seeds completed successfully!")
|
||||
IO.puts("📝 Created sample data:")
|
||||
IO.puts(" - Property types: String, Date, Boolean, Email")
|
||||
IO.puts(" - Custom fields: String, Date, Boolean, Email")
|
||||
IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
|
||||
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
106
priv/resource_snapshots/repo/custom_fields/20251113163602.json
Normal file
106
priv/resource_snapshots/repo/custom_fields/20251113163602.json
Normal 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"
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"ash_functions_version": 5,
|
||||
"installed": [
|
||||
"ash-functions",
|
||||
"citext"
|
||||
"citext",
|
||||
"pg_trgm"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue
We would not think about enums here, right? Because in case of that or lists we would have more than one value per custom field...
Enums will just enable a selection of different values. But inside the
custom field valueonly one value will be stored.