refactor: Rename Property/PropertyType to CustomFieldValue/CustomField
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Complete refactoring of resources, database tables, code references, tests, and documentation for improved naming consistency.
This commit is contained in:
parent
47f18e9ef3
commit
8400e727a7
31 changed files with 1002 additions and 647 deletions
|
|
@ -81,8 +81,8 @@ lib/
|
||||||
├── membership/ # Membership domain
|
├── membership/ # Membership domain
|
||||||
│ ├── membership.ex # Domain definition
|
│ ├── membership.ex # Domain definition
|
||||||
│ ├── member.ex # Member resource
|
│ ├── member.ex # Member resource
|
||||||
│ ├── property.ex # Custom property resource
|
│ ├── custom_field_value.ex # Custom field value resource
|
||||||
│ ├── property_type.ex # Property type resource
|
│ ├── custom_field.ex # CustomFieldValue type resource
|
||||||
│ └── email.ex # Email custom type
|
│ └── email.ex # Email custom type
|
||||||
├── mv/ # Core application modules
|
├── mv/ # Core application modules
|
||||||
│ ├── accounts/ # Domain-specific logic
|
│ ├── accounts/ # Domain-specific logic
|
||||||
|
|
@ -121,8 +121,8 @@ lib/
|
||||||
│ │ │ ├── search_bar_component.ex
|
│ │ │ ├── search_bar_component.ex
|
||||||
│ │ │ └── sort_header_component.ex
|
│ │ │ └── sort_header_component.ex
|
||||||
│ │ ├── member_live/ # Member CRUD LiveViews
|
│ │ ├── member_live/ # Member CRUD LiveViews
|
||||||
│ │ ├── property_live/ # Property CRUD LiveViews
|
│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
|
||||||
│ │ ├── property_type_live/
|
│ │ ├── custom_field_live/
|
||||||
│ │ └── user_live/ # User management LiveViews
|
│ │ └── user_live/ # User management LiveViews
|
||||||
│ ├── auth_overrides.ex # AshAuthentication overrides
|
│ ├── auth_overrides.ex # AshAuthentication overrides
|
||||||
│ ├── endpoint.ex # Phoenix endpoint
|
│ ├── endpoint.ex # Phoenix endpoint
|
||||||
|
|
@ -740,14 +740,14 @@ end
|
||||||
# Good - preload relationships
|
# Good - preload relationships
|
||||||
members =
|
members =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.load(:properties)
|
|> Ash.Query.load(:custom_field_values)
|
||||||
|> Mv.Membership.list_members!()
|
|> Mv.Membership.list_members!()
|
||||||
|
|
||||||
# Avoid - causes N+1 queries
|
# Avoid - causes N+1 queries
|
||||||
members = Mv.Membership.list_members!()
|
members = Mv.Membership.list_members!()
|
||||||
Enum.map(members, fn member ->
|
Enum.map(members, fn member ->
|
||||||
# This triggers a query for each member
|
# This triggers a query for each member
|
||||||
Ash.load!(member, :properties)
|
Ash.load!(member, :custom_field_values)
|
||||||
end)
|
end)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1723,13 +1723,13 @@ end
|
||||||
# Good - preload relationships
|
# Good - preload relationships
|
||||||
members =
|
members =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.load([:properties, :user])
|
|> Ash.Query.load([:custom_field_values, :user])
|
||||||
|> Mv.Membership.list_members!()
|
|> Mv.Membership.list_members!()
|
||||||
|
|
||||||
# Avoid - causes N+1
|
# Avoid - causes N+1
|
||||||
members = Mv.Membership.list_members!()
|
members = Mv.Membership.list_members!()
|
||||||
Enum.map(members, fn member ->
|
Enum.map(members, fn member ->
|
||||||
properties = Ash.load!(member, :properties) # N queries!
|
custom_field_values = Ash.load!(member, :custom_field_values) # N queries!
|
||||||
end)
|
end)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1904,7 +1904,7 @@ defmodule Mv.Membership.Member do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Represents a club member with their personal information and membership status.
|
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.
|
Each member is optionally linked to a user account for self-service access.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
@ -2050,7 +2050,7 @@ open doc/index.html
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Member custom properties feature
|
- Member custom_field_values feature
|
||||||
- Email synchronization between user and member
|
- Email synchronization between user and member
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
@ -2081,14 +2081,14 @@ open doc/index.html
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create feature branch
|
# Create feature branch
|
||||||
git checkout -b feature/member-custom-properties
|
git checkout -b feature/member-custom-custom_field_values
|
||||||
|
|
||||||
# Work on feature
|
# Work on feature
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Add custom properties to members"
|
git commit -m "Add custom_field_values to members"
|
||||||
|
|
||||||
# Push to remote
|
# Push to remote
|
||||||
git push origin feature/member-custom-properties
|
git push origin feature/member-custom-custom_field_values
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.2 Commit Messages
|
### 8.2 Commit Messages
|
||||||
|
|
@ -2127,7 +2127,7 @@ Closes #123
|
||||||
```
|
```
|
||||||
fix: resolve N+1 query in member list
|
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.
|
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
|
- Bidirectional email sync with users
|
||||||
- Flexible address and contact data
|
- Flexible address and contact data
|
||||||
|
|
||||||
#### `properties`
|
#### `custom_field_values`
|
||||||
- **Purpose:** Dynamic custom member attributes
|
- **Purpose:** Dynamic custom member attributes
|
||||||
- **Rows (Estimated):** Variable (N per member)
|
- **Rows (Estimated):** Variable (N per member)
|
||||||
- **Key Features:**
|
- **Key Features:**
|
||||||
- Union type value storage (JSONB)
|
- Union type value storage (JSONB)
|
||||||
- Multiple data types supported
|
- Multiple data types supported
|
||||||
- One property per type per member
|
- One custom field value per custom field per member
|
||||||
|
|
||||||
#### `property_types`
|
#### `custom_fields`
|
||||||
- **Purpose:** Schema definitions for custom properties
|
- **Purpose:** Schema definitions for custom_field_values
|
||||||
- **Rows (Estimated):** Low (admin-defined)
|
- **Rows (Estimated):** Low (admin-defined)
|
||||||
- **Key Features:**
|
- **Key Features:**
|
||||||
- Type definitions
|
- Type definitions
|
||||||
- Immutable and required flags
|
- Immutable and required flags
|
||||||
- Centralized property management
|
- Centralized custom field management
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ User (0..1) ←→ (0..1) Member
|
||||||
|
|
||||||
Member (1) → (N) Properties
|
Member (1) → (N) Properties
|
||||||
↓
|
↓
|
||||||
PropertyType (1)
|
CustomField (1)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Relationship Details
|
### Relationship Details
|
||||||
|
|
@ -90,11 +90,11 @@ Member (1) → (N) Properties
|
||||||
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
|
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
|
||||||
|
|
||||||
2. **Member → Properties (1:N)**
|
2. **Member → Properties (1:N)**
|
||||||
- One member, many properties
|
- One member, many custom_field_values
|
||||||
- `ON DELETE CASCADE` - properties deleted with member
|
- `ON DELETE CASCADE` - custom_field_values deleted with member
|
||||||
- Composite unique constraint (member_id, property_type_id)
|
- Composite unique constraint (member_id, custom_field_id)
|
||||||
|
|
||||||
3. **Property → PropertyType (N:1)**
|
3. **CustomFieldValue → CustomField (N:1)**
|
||||||
- Properties reference type definition
|
- Properties reference type definition
|
||||||
- `ON DELETE RESTRICT` - cannot delete type if in use
|
- `ON DELETE RESTRICT` - cannot delete type if in use
|
||||||
- Type defines data structure
|
- Type defines data structure
|
||||||
|
|
@ -121,8 +121,8 @@ Member (1) → (N) Properties
|
||||||
- Phone: `+?[0-9\- ]{6,20}`
|
- Phone: `+?[0-9\- ]{6,20}`
|
||||||
- Postal code: 5 digits
|
- Postal code: 5 digits
|
||||||
|
|
||||||
### Property System
|
### CustomFieldValue System
|
||||||
- Maximum one property per type per member
|
- Maximum one custom field value per custom field per member
|
||||||
- Value stored as union type in JSONB
|
- Value stored as union type in JSONB
|
||||||
- Supported types: string, integer, boolean, date, email
|
- Supported types: string, integer, boolean, date, email
|
||||||
- Types can be marked as immutable or required
|
- Types can be marked as immutable or required
|
||||||
|
|
@ -144,10 +144,10 @@ Member (1) → (N) Properties
|
||||||
- `join_date` (B-tree) - Date filtering
|
- `join_date` (B-tree) - Date filtering
|
||||||
- `paid` (partial B-tree) - Payment status queries
|
- `paid` (partial B-tree) - Payment status queries
|
||||||
|
|
||||||
**properties:**
|
**custom_field_values:**
|
||||||
- `member_id` - Member property lookups
|
- `member_id` - Member custom field value lookups
|
||||||
- `property_type_id` - Type-based queries
|
- `custom_field_id` - Type-based queries
|
||||||
- Composite `(member_id, property_type_id)` - Uniqueness
|
- Composite `(member_id, custom_field_id)` - Uniqueness
|
||||||
|
|
||||||
**tokens:**
|
**tokens:**
|
||||||
- `subject` - User token lookups
|
- `subject` - User token lookups
|
||||||
|
|
@ -297,8 +297,8 @@ priv/repo/migrations/
|
||||||
| Relationship | On Delete | Rationale |
|
| Relationship | On Delete | Rationale |
|
||||||
|--------------|-----------|-----------|
|
|--------------|-----------|-----------|
|
||||||
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
|
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
|
||||||
| `properties.member_id → members.id` | CASCADE | Delete properties with member |
|
| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member |
|
||||||
| `properties.property_type_id → property_types.id` | RESTRICT | Prevent deletion of types in use |
|
| `custom_field_values.custom_field_id → custom_fields.id` | RESTRICT | Prevent deletion of types in use |
|
||||||
|
|
||||||
### Validation Layers
|
### Validation Layers
|
||||||
|
|
||||||
|
|
@ -327,15 +327,15 @@ priv/repo/migrations/
|
||||||
- Member search (uses GIN index on search_vector)
|
- Member search (uses GIN index on search_vector)
|
||||||
- Member list with filters (uses indexes on join_date, paid)
|
- Member list with filters (uses indexes on join_date, paid)
|
||||||
- User authentication (uses unique index on email/oidc_id)
|
- 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:**
|
**Medium Frequency:**
|
||||||
- Member CRUD operations
|
- Member CRUD operations
|
||||||
- Property updates
|
- CustomFieldValue updates
|
||||||
- Token validation
|
- Token validation
|
||||||
|
|
||||||
**Low Frequency:**
|
**Low Frequency:**
|
||||||
- PropertyType management
|
- CustomField management
|
||||||
- User-Member linking
|
- User-Member linking
|
||||||
- Bulk operations
|
- Bulk operations
|
||||||
|
|
||||||
|
|
@ -396,10 +396,10 @@ Install "DBML Language" extension to view/edit DBML files with:
|
||||||
### Critical Tables (Priority 1)
|
### Critical Tables (Priority 1)
|
||||||
- `members` - Core business data
|
- `members` - Core business data
|
||||||
- `users` - Authentication data
|
- `users` - Authentication data
|
||||||
- `property_types` - Schema definitions
|
- `custom_fields` - Schema definitions
|
||||||
|
|
||||||
### Important Tables (Priority 2)
|
### Important Tables (Priority 2)
|
||||||
- `properties` - Member custom data
|
- `custom_field_values` - Member custom data
|
||||||
- `tokens` - Can be regenerated but good to backup
|
- `tokens` - Can be regenerated but good to backup
|
||||||
|
|
||||||
### Backup Strategy
|
### Backup Strategy
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ Project mila_membership_management {
|
||||||
|
|
||||||
## Key Features:
|
## Key Features:
|
||||||
- User authentication (OIDC + Password with secure account linking)
|
- 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
|
- Bidirectional email synchronization between users and members
|
||||||
- Full-text search capabilities (tsvector)
|
- Full-text search capabilities (tsvector)
|
||||||
- Fuzzy search with trigram matching (pg_trgm)
|
- Fuzzy search with trigram matching (pg_trgm)
|
||||||
|
|
@ -26,7 +26,7 @@ Project mila_membership_management {
|
||||||
|
|
||||||
## Domains:
|
## Domains:
|
||||||
- **Accounts**: User authentication and session management
|
- **Accounts**: User authentication and session management
|
||||||
- **Membership**: Club member data and custom properties
|
- **Membership**: Club member data and custom fields
|
||||||
|
|
||||||
## Required PostgreSQL Extensions:
|
## Required PostgreSQL Extensions:
|
||||||
- uuid-ossp (UUID generation)
|
- uuid-ossp (UUID generation)
|
||||||
|
|
@ -178,7 +178,7 @@ Table members {
|
||||||
|
|
||||||
**Relationships:**
|
**Relationships:**
|
||||||
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
|
- 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:**
|
**Validation Rules:**
|
||||||
- first_name, last_name: min 1 character
|
- 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']
|
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"})']
|
value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})']
|
||||||
member_id uuid [not null, note: 'Link to member']
|
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 {
|
indexes {
|
||||||
(member_id, property_type_id) [unique, name: 'properties_unique_property_per_member_index', note: 'One property per type per member']
|
(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: 'properties_member_id_idx']
|
member_id [name: 'custom_field_values_member_id_idx']
|
||||||
property_type_id [name: 'properties_property_type_id_idx']
|
custom_field_id [name: 'custom_field_values_custom_field_id_idx']
|
||||||
}
|
}
|
||||||
|
|
||||||
Note: '''
|
Note: '''
|
||||||
**Dynamic Custom Member Properties**
|
**Dynamic Custom Member Field Values**
|
||||||
|
|
||||||
Provides flexible, extensible attributes for members beyond the fixed schema.
|
Provides flexible, extensible attributes for members beyond the fixed schema.
|
||||||
|
|
||||||
|
|
@ -221,9 +221,9 @@ Table properties {
|
||||||
- `email`: Validated email addresses
|
- `email`: Validated email addresses
|
||||||
|
|
||||||
**Constraints:**
|
**Constraints:**
|
||||||
- Each member can have only ONE property per property_type
|
- Each member can have only ONE custom field value per custom field
|
||||||
- Properties are deleted when member is deleted (CASCADE)
|
- Custom field values are deleted when member is deleted (CASCADE)
|
||||||
- Property type cannot be deleted if properties exist (RESTRICT)
|
- Custom field cannot be deleted if custom field values exist (RESTRICT)
|
||||||
|
|
||||||
**Use Cases:**
|
**Use Cases:**
|
||||||
- Custom membership numbers
|
- 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']
|
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']
|
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
|
||||||
description text [null, note: 'Human-readable description']
|
description text [null, note: 'Human-readable description']
|
||||||
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
|
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 {
|
indexes {
|
||||||
name [unique, name: 'property_types_unique_name_index']
|
name [unique, name: 'custom_fields_unique_name_index']
|
||||||
}
|
}
|
||||||
|
|
||||||
Note: '''
|
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:**
|
**Attributes:**
|
||||||
- `name`: Unique identifier for the property type
|
- `name`: Unique identifier for the custom field
|
||||||
- `value_type`: Enforces data type consistency
|
- `value_type`: Enforces data type consistency
|
||||||
- `description`: Documentation for users/admins
|
- `description`: Documentation for users/admins
|
||||||
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
|
- `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:**
|
**Constraints:**
|
||||||
- `value_type` must be one of: string, integer, boolean, date, email
|
- `value_type` must be one of: string, integer, boolean, date, email
|
||||||
- `name` must be unique across all property types
|
- `name` must be unique across all custom fields
|
||||||
- Cannot be deleted if properties reference it (ON DELETE RESTRICT)
|
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
- Membership Number (string, immutable, required)
|
- Membership Number (string, immutable, required)
|
||||||
|
|
@ -283,25 +283,25 @@ Table property_types {
|
||||||
Ref: users.member_id - members.id [delete: set null]
|
Ref: users.member_id - members.id [delete: set null]
|
||||||
|
|
||||||
// Member → Properties (1:N)
|
// Member → Properties (1:N)
|
||||||
// - One member can have multiple properties
|
// - One member can have multiple custom_field_values
|
||||||
// - Each property belongs to exactly one member
|
// - Each custom field value belongs to exactly one member
|
||||||
// - ON DELETE CASCADE: Properties deleted when member deleted
|
// - ON DELETE CASCADE: Properties deleted when member deleted
|
||||||
// - UNIQUE constraint: One property per type per member
|
// - UNIQUE constraint: One custom field value per custom field per member
|
||||||
Ref: properties.member_id > members.id [delete: cascade]
|
Ref: custom_field_values.member_id > members.id [delete: cascade]
|
||||||
|
|
||||||
// Property → PropertyType (N:1)
|
// CustomFieldValue → CustomField (N:1)
|
||||||
// - Many properties can reference one property type
|
// - Many custom_field_values can reference one custom field
|
||||||
// - Property type defines the schema/behavior
|
// - CustomFieldValue type defines the schema/behavior
|
||||||
// - ON DELETE RESTRICT: Cannot delete type if properties exist
|
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
|
||||||
Ref: properties.property_type_id > property_types.id [delete: restrict]
|
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ENUMS
|
// ENUMS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Valid data types for property values
|
// Valid data types for custom field values
|
||||||
// Determines how Property.value is interpreted
|
// Determines how CustomFieldValue.value is interpreted
|
||||||
Enum property_value_type {
|
Enum custom_field_value_type {
|
||||||
string [note: 'Text data']
|
string [note: 'Text data']
|
||||||
integer [note: 'Numeric data']
|
integer [note: 'Numeric data']
|
||||||
boolean [note: 'True/False flags']
|
boolean [note: 'True/False flags']
|
||||||
|
|
@ -335,8 +335,8 @@ TableGroup accounts_domain {
|
||||||
|
|
||||||
TableGroup membership_domain {
|
TableGroup membership_domain {
|
||||||
members
|
members
|
||||||
properties
|
custom_field_values
|
||||||
property_types
|
custom_fields
|
||||||
|
|
||||||
Note: '''
|
Note: '''
|
||||||
**Membership Domain**
|
**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**
|
**Sprint 3 - 28.05 - 09.07**
|
||||||
- Member CRUD operations
|
- Member CRUD operations
|
||||||
- Basic property system
|
- Basic custom field system
|
||||||
- Initial UI with Tailwind CSS
|
- Initial UI with Tailwind CSS
|
||||||
|
|
||||||
**Sprint 4 - 09.07 - 30.07**
|
**Sprint 4 - 09.07 - 30.07**
|
||||||
- Property types implementation
|
- CustomFieldValue types implementation
|
||||||
- Data validation
|
- Data validation
|
||||||
- Error handling improvements
|
- 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*
|
**PR #147:** *Add seed data for members*
|
||||||
- Comprehensive seed data
|
- Comprehensive seed data
|
||||||
- Test users and members
|
- Test users and members
|
||||||
- Property type examples
|
- CustomFieldValue type examples
|
||||||
|
|
||||||
#### Phase 3: Search & Navigation (Sprint 6)
|
#### 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.
|
**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
|
**Implementation:** Entity-Attribute-Value pattern with union types
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
# Property Type defines schema
|
# CustomFieldValue Type defines schema
|
||||||
defmodule Mv.Membership.PropertyType do
|
defmodule Mv.Membership.CustomField do
|
||||||
attribute :name, :string # "Membership Number"
|
attribute :name, :string # "Membership Number"
|
||||||
attribute :value_type, :atom # :string, :integer, :boolean, :date, :email
|
attribute :value_type, :atom # :string, :integer, :boolean, :date, :email
|
||||||
attribute :immutable, :boolean # Can't change after creation
|
attribute :immutable, :boolean # Can't change after creation
|
||||||
attribute :required, :boolean # All members must have this
|
attribute :required, :boolean # All members must have this
|
||||||
end
|
end
|
||||||
|
|
||||||
# Property stores values
|
# CustomFieldValue stores values
|
||||||
defmodule Mv.Membership.Property do
|
defmodule Mv.Membership.CustomFieldValue do
|
||||||
attribute :value, :union, # Polymorphic value storage
|
attribute :value, :union, # Polymorphic value storage
|
||||||
constraints: [
|
constraints: [
|
||||||
types: [
|
types: [
|
||||||
|
|
@ -405,7 +405,7 @@ defmodule Mv.Membership.Property do
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
belongs_to :member
|
belongs_to :member
|
||||||
belongs_to :property_type
|
belongs_to :custom_field
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -413,12 +413,12 @@ end
|
||||||
- Clubs need different custom fields
|
- Clubs need different custom fields
|
||||||
- No schema migrations for new fields
|
- No schema migrations for new fields
|
||||||
- Type safety with union types
|
- Type safety with union types
|
||||||
- Centralized property management
|
- Centralized custom field management
|
||||||
|
|
||||||
**Constraints:**
|
**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)
|
- Properties deleted with member (CASCADE)
|
||||||
- Property types protected if in use (RESTRICT)
|
- CustomFieldValue types protected if in use (RESTRICT)
|
||||||
|
|
||||||
#### 5. Authentication Strategy
|
#### 5. Authentication Strategy
|
||||||
|
|
||||||
|
|
@ -593,7 +593,7 @@ end
|
||||||
#### Database Migrations
|
#### Database Migrations
|
||||||
|
|
||||||
**Key migrations in chronological order:**
|
**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
|
2. `20250617090641_member_fields.exs` - Member attributes expansion
|
||||||
3. `20250620110850_add_accounts_domain.exs` - Users & tokens tables
|
3. `20250620110850_add_accounts_domain.exs` - Users & tokens tables
|
||||||
4. `20250912085235_AddSearchVectorToMembers.exs` - Full-text search (tsvector + GIN index)
|
4. `20250912085235_AddSearchVectorToMembers.exs` - Full-text search (tsvector + GIN index)
|
||||||
|
|
@ -772,7 +772,7 @@ end
|
||||||
- Admin user: `admin@mv.local` / `testpassword`
|
- Admin user: `admin@mv.local` / `testpassword`
|
||||||
- Sample members: Hans Müller, Greta Schmidt, Friedrich Wagner
|
- Sample members: Hans Müller, Greta Schmidt, Friedrich Wagner
|
||||||
- Linked accounts: Maria Weber, Thomas Klein
|
- Linked accounts: Maria Weber, Thomas Klein
|
||||||
- Property types: String, Date, Boolean, Email
|
- CustomFieldValue types: String, Date, Boolean, Email
|
||||||
|
|
||||||
**Test Helpers:**
|
**Test Helpers:**
|
||||||
```elixir
|
```elixir
|
||||||
|
|
@ -956,9 +956,9 @@ mix credo --strict
|
||||||
mix credo suggest --format=oneline
|
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:**
|
**Error:**
|
||||||
```
|
```
|
||||||
|
|
@ -966,16 +966,16 @@ mix credo suggest --format=oneline
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
Ensure property value matches property_type.value_type:
|
Ensure custom field value matches custom_field.value_type:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
# Property Type: value_type = :integer
|
# CustomFieldValue Type: value_type = :integer
|
||||||
property_type = get_property_type("age")
|
custom_field = get_custom_field("age")
|
||||||
|
|
||||||
# Property Value: must be integer union type
|
# CustomFieldValue Value: must be integer union type
|
||||||
{:ok, property} = create_property(%{
|
{:ok, custom_field_value} = create_custom_field_value(%{
|
||||||
value: %{type: :integer, value: 25}, # Not "25" as string
|
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:**
|
**Current State:**
|
||||||
- ✅ Property types (string, integer, boolean, date, email)
|
- ✅ CustomFieldValue types (string, integer, boolean, date, email)
|
||||||
- ✅ Property type management
|
- ✅ CustomFieldValue type management
|
||||||
- ✅ Dynamic property assignment to members
|
- ✅ Dynamic custom field value assignment to members
|
||||||
- ✅ Union type storage (JSONB)
|
- ✅ Union type storage (JSONB)
|
||||||
|
|
||||||
**Open Issues:**
|
**Open Issues:**
|
||||||
|
|
@ -217,7 +217,7 @@
|
||||||
- ❌ Global settings management
|
- ❌ Global settings management
|
||||||
- ❌ Club/Organization profile
|
- ❌ Club/Organization profile
|
||||||
- ❌ Email templates configuration
|
- ❌ Email templates configuration
|
||||||
- ❌ Property type management UI (user-facing)
|
- ❌ CustomFieldValue type management UI (user-facing)
|
||||||
- ❌ Role and permission management UI
|
- ❌ Role and permission management UI
|
||||||
- ❌ System health dashboard
|
- ❌ System health dashboard
|
||||||
- ❌ Audit log viewer
|
- ❌ 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 |
|
| Mount | Purpose | Auth | Query Params | Events |
|
||||||
|-------|---------|------|--------------|--------|
|
|-------|---------|------|--------------|--------|
|
||||||
| `/members` | Member list with search/sort | 🔐 | `?search=&sort_by=&sort_dir=` | `search`, `sort`, `delete`, `select` |
|
| `/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` | 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
|
#### 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 |
|
| `save` | Create/update member | `%{"member" => attrs}` | Redirect or show errors |
|
||||||
| `link_user` | Link user to member | `%{"user_id" => id}` | Update member view |
|
| `link_user` | Link user to member | `%{"user_id" => id}` | Update member view |
|
||||||
| `unlink_user` | Unlink user from member | - | 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 |
|
| `add_custom_field_value` | Add custom field value | `%{"custom_field_id" => id, "value" => val}` | Update form |
|
||||||
| `remove_property` | Remove custom property | `%{"property_id" => id}` | Update form |
|
| `remove_custom_field_value` | Remove custom field value | `%{"custom_field_value_id" => id}` | Update form |
|
||||||
|
|
||||||
#### Ash Resource Actions
|
#### 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` | `:fuzzy_search` | Fuzzy text search | 🔐 | `{query, threshold}` | `[%Member{}]` |
|
||||||
| `Member` | `:advanced_search` | Multi-criteria search | 🔐 | `{filters: [{field, op, value}]}` | `[%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` | `: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_delete` | Delete multiple members | 🛡️ | `{ids: [id1, id2, ...]}` | `{:ok, count}` |
|
||||||
| `Member` | `:bulk_update` | Update multiple members | 🛡️ | `{ids, attrs}` | `{:ok, count}` |
|
| `Member` | `:bulk_update` | Update multiple members | 🛡️ | `{ids, attrs}` | `{:ok, count}` |
|
||||||
| `Member` | `:export` | Export to CSV/Excel | 🔐 | `{format, filters}` | File download |
|
| `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
|
#### LiveView Endpoints
|
||||||
|
|
||||||
| Mount | Purpose | Auth | Events |
|
| Mount | Purpose | Auth | Events |
|
||||||
|-------|---------|------|--------|
|
|-------|---------|------|--------|
|
||||||
| `/property-types` | List property types | 🛡️ | `new`, `edit`, `delete` |
|
| `/custom-fields` | List custom fields | 🛡️ | `new`, `edit`, `delete` |
|
||||||
| `/property-types/new` | Create property type | 🛡️ | `save`, `cancel` |
|
| `/custom-fields/new` | Create custom field | 🛡️ | `save`, `cancel` |
|
||||||
| `/property-types/:id/edit` | Edit property type | 🛡️ | `save`, `cancel`, `delete` |
|
| `/custom-fields/:id/edit` | Edit custom field | 🛡️ | `save`, `cancel`, `delete` |
|
||||||
|
|
||||||
#### Ash Resource Actions
|
#### Ash Resource Actions
|
||||||
|
|
||||||
| Resource | Action | Purpose | Auth | Input | Output |
|
| Resource | Action | Purpose | Auth | Input | Output |
|
||||||
|----------|--------|---------|------|-------|--------|
|
|----------|--------|---------|------|-------|--------|
|
||||||
| `PropertyType` | `:create` | Create property type | 🛡️ | `{name, value_type, description, ...}` | `{:ok, property_type}` |
|
| `CustomField` | `:create` | Create custom field | 🛡️ | `{name, value_type, description, ...}` | `{:ok, custom_field}` |
|
||||||
| `PropertyType` | `:read` | List property types | 🔐 | - | `[%PropertyType{}]` |
|
| `CustomField` | `:read` | List custom fields | 🔐 | - | `[%CustomField{}]` |
|
||||||
| `PropertyType` | `:update` | Update property type | 🛡️ | `{id, attrs}` | `{:ok, property_type}` |
|
| `CustomField` | `:update` | Update custom field | 🛡️ | `{id, attrs}` | `{:ok, custom_field}` |
|
||||||
| `PropertyType` | `:destroy` | Delete property type | 🛡️ | `{id}` | `{:ok, property_type}` |
|
| `CustomField` | `:destroy` | Delete custom field | 🛡️ | `{id}` | `{:ok, custom_field}` |
|
||||||
| `Property` | `:create` | Add property to member | 🔐 | `{member_id, property_type_id, value}` | `{:ok, property}` |
|
| `CustomFieldValue` | `:create` | Add custom field value to member | 🔐 | `{member_id, custom_field_id, value}` | `{:ok, custom_field_value}` |
|
||||||
| `Property` | `:update` | Update property value | 🔐 | `{id, value}` | `{:ok, property}` |
|
| `CustomFieldValue` | `:update` | Update custom field value | 🔐 | `{id, value}` | `{:ok, custom_field_value}` |
|
||||||
| `Property` | `:destroy` | Remove property | 🔐 | `{id}` | `{:ok, property}` |
|
| `CustomFieldValue` | `:destroy` | Remove custom field value | 🔐 | `{id}` | `{:ok, custom_field_value}` |
|
||||||
|
|
||||||
#### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153)
|
#### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153)
|
||||||
|
|
||||||
| Resource | Action | Purpose | Auth | Input | Output |
|
| Resource | Action | Purpose | Auth | Input | Output |
|
||||||
|----------|--------|---------|------|-------|--------|
|
|----------|--------|---------|------|-------|--------|
|
||||||
| `PropertyType` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, property_type}` |
|
| `CustomField` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, custom_field}` |
|
||||||
| `PropertyType` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, property_type}` |
|
| `CustomField` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, custom_field}` |
|
||||||
| `PropertyType` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, property_type}` |
|
| `CustomField` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, custom_field}` |
|
||||||
| `PropertyType` | `:create_group` | Create field group | 🛡️ | `{name, property_type_ids}` | `{:ok, group}` |
|
| `CustomField` | `:create_group` | Create field group | 🛡️ | `{name, custom_field_ids}` | `{:ok, group}` |
|
||||||
| `Property` | `:validate_value` | Validate property value | 🔐 | `{property_type_id, value}` | `{:ok, valid}` or `{:error, reason}` |
|
| `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 """
|
@moduledoc """
|
||||||
Ash resource defining the schema for custom member properties.
|
Ash resource defining the schema for custom member fields.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
PropertyTypes define the "schema" for custom fields in the membership system.
|
CustomFields define the "schema" for custom fields in the membership system.
|
||||||
Each PropertyType specifies the name, data type, and behavior of a custom field
|
Each CustomField specifies the name, data type, and behavior of a custom field
|
||||||
that can be attached to members via Property resources.
|
that can be attached to members via CustomFieldValue resources.
|
||||||
|
|
||||||
## Attributes
|
## 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`)
|
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||||
- `description` - Optional human-readable description
|
- `description` - Optional human-readable description
|
||||||
- `immutable` - If true, property values cannot be changed after creation
|
- `immutable` - If true, custom field values cannot be changed after creation
|
||||||
- `required` - If true, all members must have this property (future feature)
|
- `required` - If true, all members must have this custom field (future feature)
|
||||||
|
|
||||||
## Supported Value Types
|
## Supported Value Types
|
||||||
- `:string` - Text data (unlimited length)
|
- `:string` - Text data (unlimited length)
|
||||||
|
|
@ -22,22 +22,22 @@ defmodule Mv.Membership.PropertyType do
|
||||||
- `:email` - Validated email addresses
|
- `:email` - Validated email addresses
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
- `has_many :properties` - All property values of this type
|
- `has_many :custom_field_values` - All custom field values of this type
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
- Name must be unique across all property types
|
- Name must be unique across all custom fields
|
||||||
- Cannot delete a property type that has existing property values (RESTRICT)
|
- Cannot delete a custom field that has existing custom field values (RESTRICT)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
# Create a new property type
|
# Create a new custom field
|
||||||
PropertyType.create!(%{
|
CustomField.create!(%{
|
||||||
name: "phone_mobile",
|
name: "phone_mobile",
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: "Mobile phone number"
|
description: "Mobile phone number"
|
||||||
})
|
})
|
||||||
|
|
||||||
# Create a required property type
|
# Create a required custom field
|
||||||
PropertyType.create!(%{
|
CustomField.create!(%{
|
||||||
name: "emergency_contact",
|
name: "emergency_contact",
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
required: true
|
required: true
|
||||||
|
|
@ -48,7 +48,7 @@ defmodule Mv.Membership.PropertyType do
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "property_types"
|
table "custom_fields"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ defmodule Mv.Membership.PropertyType do
|
||||||
attribute :value_type, :atom,
|
attribute :value_type, :atom,
|
||||||
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
|
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
|
||||||
allow_nil?: false,
|
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
|
attribute :description, :string, allow_nil?: true, public?: true
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ defmodule Mv.Membership.PropertyType do
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
has_many :properties, Mv.Membership.Property
|
has_many :custom_field_values, Mv.Membership.CustomFieldValue
|
||||||
end
|
end
|
||||||
|
|
||||||
identities do
|
identities do
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
defmodule Mv.Membership.Property do
|
defmodule Mv.Membership.CustomFieldValue do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Ash resource representing a custom property value for a member.
|
Ash resource representing a custom field value for a member.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Properties implement the Entity-Attribute-Value (EAV) pattern, allowing
|
CustomFieldValues implement the Entity-Attribute-Value (EAV) pattern, allowing
|
||||||
dynamic custom fields to be attached to members. Each property links a
|
dynamic custom fields to be attached to members. Each custom field value links a
|
||||||
member to a property type and stores the actual value.
|
member to a custom field and stores the actual value.
|
||||||
|
|
||||||
## Value Storage
|
## Value Storage
|
||||||
Values are stored using Ash's union type with JSONB storage format:
|
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)
|
- `:email` - Validated email addresses (custom type)
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
- `belongs_to :member` - The member this property belongs to (CASCADE delete)
|
- `belongs_to :member` - The member this custom field value belongs to (CASCADE delete)
|
||||||
- `belongs_to :property_type` - The property type definition
|
- `belongs_to :custom_field` - The custom field definition
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
- Each member can have only one property per property type (unique composite index)
|
- Each member can have only one custom field value per custom field (unique composite index)
|
||||||
- Properties are deleted when the associated member is deleted (CASCADE)
|
- Custom field values are deleted when the associated member is deleted (CASCADE)
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
domain: Mv.Membership,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "properties"
|
table "custom_field_values"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
||||||
references do
|
references do
|
||||||
|
|
@ -46,7 +46,7 @@ defmodule Mv.Membership.Property do
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
defaults [:create, :read, :update, :destroy]
|
defaults [:create, :read, :update, :destroy]
|
||||||
default_accept [:value, :member_id, :property_type_id]
|
default_accept [:value, :member_id, :custom_field_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
|
|
@ -68,16 +68,16 @@ defmodule Mv.Membership.Property do
|
||||||
relationships do
|
relationships do
|
||||||
belongs_to :member, Mv.Membership.Member
|
belongs_to :member, Mv.Membership.Member
|
||||||
|
|
||||||
belongs_to :property_type, Mv.Membership.PropertyType
|
belongs_to :custom_field, Mv.Membership.CustomField
|
||||||
end
|
end
|
||||||
|
|
||||||
calculations do
|
calculations do
|
||||||
calculate :value_to_string, :string, expr(value[:value] <> "")
|
calculate :value_to_string, :string, expr(value[:value] <> "")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Ensure a member can only have one property per property type
|
# Ensure a member can only have one custom field value per custom field
|
||||||
# For example: A member can have only one "email" property, one "phone" property, etc.
|
# For example: A member can have only one "phone" custom field value, one "email" custom field value, etc.
|
||||||
identities do
|
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
|
||||||
end
|
end
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule Mv.Membership.Email do
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
This type extends `:string` with email-specific validation constraints.
|
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.
|
addresses according to a standard regex pattern.
|
||||||
|
|
||||||
## Validation Rules
|
## Validation Rules
|
||||||
|
|
@ -14,12 +14,12 @@ defmodule Mv.Membership.Email do
|
||||||
- Automatic trimming of leading/trailing whitespace
|
- Automatic trimming of leading/trailing whitespace
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
This type is used in the Property union type for properties with
|
This type is used in the CustomFieldValue union type for custom fields with
|
||||||
`value_type: :email` in PropertyType definitions.
|
`value_type: :email` in CustomField definitions.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
# In a property type definition
|
# In a custom field definition
|
||||||
PropertyType.create!(%{
|
CustomField.create!(%{
|
||||||
name: "work_email",
|
name: "work_email",
|
||||||
value_type: :email
|
value_type: :email
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule Mv.Membership.Member do
|
||||||
can have:
|
can have:
|
||||||
- Personal information (name, email, phone, address)
|
- Personal information (name, email, phone, address)
|
||||||
- Optional link to a User account (1:1 relationship)
|
- 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
|
- Full-text searchable profile
|
||||||
|
|
||||||
## Email Synchronization
|
## Email Synchronization
|
||||||
|
|
@ -16,7 +16,7 @@ defmodule Mv.Membership.Member do
|
||||||
See `Mv.EmailSync` for details.
|
See `Mv.EmailSync` for details.
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
- `has_many :properties` - Dynamic custom fields
|
- `has_many :custom_field_values` - Dynamic custom fields
|
||||||
- `has_one :user` - Optional authentication account link
|
- `has_one :user` - Optional authentication account link
|
||||||
|
|
||||||
## Validations
|
## Validations
|
||||||
|
|
@ -48,8 +48,8 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
create :create_member do
|
create :create_member do
|
||||||
primary? true
|
primary? true
|
||||||
# Properties can be created along with member
|
# Custom field values can be created along with member
|
||||||
argument :properties, {:array, :map}
|
argument :custom_field_values, {:array, :map}
|
||||||
# Allow user to be passed as argument for relationship management
|
# Allow user to be passed as argument for relationship management
|
||||||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||||
argument :user, :map, allow_nil?: true
|
argument :user, :map, allow_nil?: true
|
||||||
|
|
@ -70,7 +70,7 @@ defmodule Mv.Membership.Member do
|
||||||
:postal_code
|
:postal_code
|
||||||
]
|
]
|
||||||
|
|
||||||
change manage_relationship(:properties, type: :create)
|
change manage_relationship(:custom_field_values, type: :create)
|
||||||
|
|
||||||
# Manage the user relationship during member creation
|
# Manage the user relationship during member creation
|
||||||
change manage_relationship(:user, :user,
|
change manage_relationship(:user, :user,
|
||||||
|
|
@ -95,8 +95,8 @@ defmodule Mv.Membership.Member do
|
||||||
primary? true
|
primary? true
|
||||||
# Required because custom validation function cannot be done atomically
|
# Required because custom validation function cannot be done atomically
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
# Properties can be updated or created along with member
|
# Custom field values can be updated or created along with member
|
||||||
argument :properties, {:array, :map}
|
argument :custom_field_values, {:array, :map}
|
||||||
# Allow user to be passed as argument for relationship management
|
# Allow user to be passed as argument for relationship management
|
||||||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||||
argument :user, :map, allow_nil?: true
|
argument :user, :map, allow_nil?: true
|
||||||
|
|
@ -117,7 +117,7 @@ defmodule Mv.Membership.Member do
|
||||||
:postal_code
|
: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
|
# Manage the user relationship during member update
|
||||||
change manage_relationship(:user, :user,
|
change manage_relationship(:user, :user,
|
||||||
|
|
@ -349,7 +349,7 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
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
|
# 1:1 relationship - Member can optionally have one User
|
||||||
# This references the User's member_id attribute
|
# This references the User's member_id attribute
|
||||||
# The relationship is optional (allow_nil? true by default)
|
# The relationship is optional (allow_nil? true by default)
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,15 @@ defmodule Mv.Membership do
|
||||||
Ash Domain for membership management.
|
Ash Domain for membership management.
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
- `Member` - Club members with personal information and custom properties
|
- `Member` - Club members with personal information and custom field values
|
||||||
- `Property` - Dynamic custom field values attached to members
|
- `CustomFieldValue` - Dynamic custom field values attached to members
|
||||||
- `PropertyType` - Schema definitions for custom properties
|
- `CustomField` - Schema definitions for custom fields
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
The domain exposes these main actions:
|
The domain exposes these main actions:
|
||||||
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
|
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
|
||||||
- Property management: `create_property/1`, `list_property/0`, etc.
|
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
|
||||||
- PropertyType management: `create_property_type/1`, `list_property_types/0`, etc.
|
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc.
|
||||||
|
|
||||||
## Admin Interface
|
## Admin Interface
|
||||||
The domain is configured with AshAdmin for management UI.
|
The domain is configured with AshAdmin for management UI.
|
||||||
|
|
@ -31,18 +31,18 @@ defmodule Mv.Membership do
|
||||||
define :destroy_member, action: :destroy
|
define :destroy_member, action: :destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
resource Mv.Membership.Property do
|
resource Mv.Membership.CustomFieldValue do
|
||||||
define :create_property, action: :create
|
define :create_custom_field_value, action: :create
|
||||||
define :list_property, action: :read
|
define :list_custom_field_values, action: :read
|
||||||
define :update_property, action: :update
|
define :update_custom_field_value, action: :update
|
||||||
define :destroy_property, action: :destroy
|
define :destroy_custom_field_value, action: :destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
resource Mv.Membership.PropertyType do
|
resource Mv.Membership.CustomField do
|
||||||
define :create_property_type, action: :create
|
define :create_custom_field, action: :create
|
||||||
define :list_property_types, action: :read
|
define :list_custom_fields, action: :read
|
||||||
define :update_property_type, action: :update
|
define :update_custom_field, action: :update
|
||||||
define :destroy_property_type, action: :destroy
|
define :destroy_custom_field, action: :destroy
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
defmodule MvWeb.PropertyTypeLive.Form do
|
defmodule MvWeb.CustomFieldLive.Form do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
LiveView form for creating and editing property types (admin).
|
LiveView form for creating and editing custom fields (admin).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Create new property type definitions
|
- Create new custom field definitions
|
||||||
- Edit existing property types
|
- Edit existing custom fields
|
||||||
- Select value type from supported types
|
- Select value type from supported types
|
||||||
- Set immutable and required flags
|
- Set immutable and required flags
|
||||||
- Real-time validation
|
- Real-time validation
|
||||||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.PropertyTypeLive.Form do
|
||||||
**Optional:**
|
**Optional:**
|
||||||
- description - Human-readable explanation
|
- description - Human-readable explanation
|
||||||
- immutable - If true, values cannot be changed after creation (default: false)
|
- 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
|
## Value Type Selection
|
||||||
- `:string` - Text data (unlimited length)
|
- `:string` - Text data (unlimited length)
|
||||||
|
|
@ -28,10 +28,10 @@ defmodule MvWeb.PropertyTypeLive.Form do
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `validate` - Real-time form validation
|
- `validate` - Real-time form validation
|
||||||
- `save` - Submit form (create or update property type)
|
- `save` - Submit form (create or update custom field)
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
Property type management is restricted to admin users.
|
Custom field management is restricted to admin users.
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
|
@ -42,18 +42,18 @@ defmodule MvWeb.PropertyTypeLive.Form do
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
<:subtitle>
|
<: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>
|
</:subtitle>
|
||||||
</.header>
|
</.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[:name]} type="text" label={gettext("Name")} />
|
||||||
<.input
|
<.input
|
||||||
field={@form[:value_type]}
|
field={@form[:value_type]}
|
||||||
type="select"
|
type="select"
|
||||||
label={gettext("Value type")}
|
label={gettext("Value type")}
|
||||||
options={
|
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")} />
|
<.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")} />
|
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save Property type")}
|
{gettext("Save Custom field")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button navigate={return_path(@return_to, @property_type)}>{gettext("Cancel")}</.button>
|
<.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -71,19 +71,19 @@ defmodule MvWeb.PropertyTypeLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
property_type =
|
custom_field =
|
||||||
case params["id"] do
|
case params["id"] do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
id -> Ash.get!(Mv.Membership.PropertyType, id)
|
id -> Ash.get!(Mv.Membership.CustomField, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
action = if is_nil(property_type), do: "New", else: "Edit"
|
action = if is_nil(custom_field), do: "New", else: "Edit"
|
||||||
page_title = action <> " " <> "Property type"
|
page_title = action <> " " <> "Custom field"
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(property_type: property_type)
|
|> assign(custom_field: custom_field)
|
||||||
|> assign(:page_title, page_title)
|
|> assign(:page_title, page_title)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
@ -92,15 +92,15 @@ defmodule MvWeb.PropertyTypeLive.Form do
|
||||||
defp return_to(_), do: "index"
|
defp return_to(_), do: "index"
|
||||||
|
|
||||||
@impl true
|
@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,
|
{: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
|
end
|
||||||
|
|
||||||
def handle_event("save", %{"property_type" => property_type_params}, socket) do
|
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do
|
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
|
||||||
{:ok, property_type} ->
|
{:ok, custom_field} ->
|
||||||
notify_parent({:saved, property_type})
|
notify_parent({:saved, custom_field})
|
||||||
|
|
||||||
action =
|
action =
|
||||||
case socket.assigns.form.source.type do
|
case socket.assigns.form.source.type do
|
||||||
|
|
@ -111,8 +111,8 @@ defmodule MvWeb.PropertyTypeLive.Form do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, gettext("Property type %{action} successfully", action: action))
|
|> put_flash(:info, gettext("Custom field %{action} successfully", action: action))
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, property_type))
|
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field))
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
|
|
@ -123,17 +123,17 @@ defmodule MvWeb.PropertyTypeLive.Form do
|
||||||
|
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
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 =
|
form =
|
||||||
if property_type do
|
if custom_field do
|
||||||
AshPhoenix.Form.for_update(property_type, :update, as: "property_type")
|
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
|
||||||
else
|
else
|
||||||
AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type")
|
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
|
||||||
end
|
end
|
||||||
|
|
||||||
assign(socket, form: to_form(form))
|
assign(socket, form: to_form(form))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp return_path("index", _property_type), do: ~p"/property_types"
|
defp return_path("index", _custom_field), do: ~p"/custom_fields"
|
||||||
defp return_path("show", property_type), do: ~p"/property_types/#{property_type.id}"
|
defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}"
|
||||||
end
|
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 """
|
@moduledoc """
|
||||||
LiveView form for creating and editing properties.
|
LiveView form for creating and editing custom field values.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Create new properties with member and type selection
|
- Create new custom field values with member and type selection
|
||||||
- Edit existing property values
|
- Edit existing custom field values
|
||||||
- Value input adapts to property type (string, integer, boolean, date, email)
|
- Value input adapts to custom field type (string, integer, boolean, date, email)
|
||||||
- Real-time validation
|
- Real-time validation
|
||||||
|
|
||||||
## Form Fields
|
## Form Fields
|
||||||
**Required:**
|
**Required:**
|
||||||
- member - Select which member owns this property
|
- member - Select which member owns this custom field value
|
||||||
- property_type - Select the type (defines value type)
|
- custom_field - Select the type (defines value type)
|
||||||
- value - The actual value (input type depends on property type)
|
- value - The actual value (input type depends on custom field type)
|
||||||
|
|
||||||
## Value Types
|
## 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
|
- String: text input
|
||||||
- Integer: number input
|
- Integer: number input
|
||||||
- Boolean: checkbox
|
- Boolean: checkbox
|
||||||
|
|
@ -24,10 +24,10 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `validate` - Real-time form validation
|
- `validate` - Real-time form validation
|
||||||
- `save` - Submit form (create or update property)
|
- `save` - Submit form (create or update custom field value)
|
||||||
|
|
||||||
## Note
|
## 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.
|
not through this standalone form.
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
@ -38,17 +38,19 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
<:subtitle>{gettext("Use this form to manage property records in your database.")}</:subtitle>
|
<:subtitle>
|
||||||
|
{gettext("Use this form to manage custom_field_value records in your database.")}
|
||||||
|
</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.form for={@form} id="property-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="custom_field_value-form" phx-change="validate" phx-submit="save">
|
||||||
<!-- Property Type Selection -->
|
<!-- Custom Field Selection -->
|
||||||
<.input
|
<.input
|
||||||
field={@form[:property_type_id]}
|
field={@form[:custom_field_id]}
|
||||||
type="select"
|
type="select"
|
||||||
label={gettext("Property type")}
|
label={gettext("Custom field")}
|
||||||
options={property_type_options(@property_types)}
|
options={custom_field_options(@custom_fields)}
|
||||||
prompt={gettext("Choose a property type")}
|
prompt={gettext("Choose a custom field")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Member Selection -->
|
<!-- Member Selection -->
|
||||||
|
|
@ -61,18 +63,18 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Value Input - handles Union type -->
|
<!-- Value Input - handles Union type -->
|
||||||
<%= if @selected_property_type do %>
|
<%= if @selected_custom_field do %>
|
||||||
<.union_value_input form={@form} property_type={@selected_property_type} />
|
<.union_value_input form={@form} custom_field={@selected_custom_field} />
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
{gettext("Please select a property type first")}
|
{gettext("Please select a custom field first")}
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save Property")}
|
{gettext("Save Custom field value")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button navigate={return_path(@return_to, @property)}>{gettext("Cancel")}</.button>
|
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -80,8 +82,8 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
|
|
||||||
# Helper function for Union-Value Input
|
# Helper function for Union-Value Input
|
||||||
defp union_value_input(assigns) do
|
defp union_value_input(assigns) do
|
||||||
# Extract the current value from the Property
|
# Extract the current value from the CustomFieldValue
|
||||||
current_value = extract_current_value(assigns.form.data, assigns.property_type.value_type)
|
current_value = extract_current_value(assigns.form.data, assigns.custom_field.value_type)
|
||||||
assigns = assign(assigns, :current_value, current_value)
|
assigns = assign(assigns, :current_value, current_value)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -90,7 +92,7 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
{gettext("Value")}
|
{gettext("Value")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<%= case @property_type.value_type do %>
|
<%= case @custom_field.value_type do %>
|
||||||
<% :string -> %>
|
<% :string -> %>
|
||||||
<.inputs_for :let={value_form} field={@form[:value]}>
|
<.inputs_for :let={value_form} field={@form[:value]}>
|
||||||
<.input field={value_form[:value]} type="text" label="" value={@current_value} />
|
<.input field={value_form[:value]} type="text" label="" value={@current_value} />
|
||||||
|
|
@ -123,16 +125,16 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
</.inputs_for>
|
</.inputs_for>
|
||||||
<% _ -> %>
|
<% _ -> %>
|
||||||
<div class="text-sm text-red-600">
|
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
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(
|
defp extract_current_value(
|
||||||
%Mv.Membership.Property{value: %Ash.Union{value: value}},
|
%Mv.Membership.CustomFieldValue{value: %Ash.Union{value: value}},
|
||||||
_value_type
|
_value_type
|
||||||
) do
|
) do
|
||||||
value
|
value
|
||||||
|
|
@ -160,27 +162,27 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
property =
|
custom_field_value =
|
||||||
case params["id"] do
|
case params["id"] do
|
||||||
nil -> nil
|
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
|
end
|
||||||
|
|
||||||
action = if is_nil(property), do: "New", else: "Edit"
|
action = if is_nil(custom_field_value), do: "New", else: "Edit"
|
||||||
page_title = action <> " " <> "Property"
|
page_title = action <> " " <> "Custom field value"
|
||||||
|
|
||||||
# Load all PropertyTypes and Members for the selection fields
|
# Load all CustomFields and Members for the selection fields
|
||||||
property_types = Ash.read!(Mv.Membership.PropertyType)
|
custom_fields = Ash.read!(Mv.Membership.CustomField)
|
||||||
members = Ash.read!(Mv.Membership.Member)
|
members = Ash.read!(Mv.Membership.Member)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(property: property)
|
|> assign(custom_field_value: custom_field_value)
|
||||||
|> assign(:page_title, page_title)
|
|> assign(:page_title, page_title)
|
||||||
|> assign(:property_types, property_types)
|
|> assign(:custom_fields, custom_fields)
|
||||||
|> assign(:members, members)
|
|> 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()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -188,43 +190,43 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
defp return_to(_), do: "index"
|
defp return_to(_), do: "index"
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("validate", %{"property" => property_params}, socket) do
|
def handle_event("validate", %{"custom_field_value" => custom_field_value_params}, socket) do
|
||||||
# Find the selected PropertyType
|
# Find the selected CustomField
|
||||||
selected_property_type =
|
selected_custom_field =
|
||||||
case property_params["property_type_id"] do
|
case custom_field_value_params["custom_field_id"] do
|
||||||
"" -> nil
|
"" -> nil
|
||||||
nil -> nil
|
nil -> nil
|
||||||
id -> Enum.find(socket.assigns.property_types, &(&1.id == id))
|
id -> Enum.find(socket.assigns.custom_fields, &(&1.id == id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set the Union type based on the selected PropertyType
|
# Set the Union type based on the selected CustomField
|
||||||
updated_params =
|
updated_params =
|
||||||
if selected_property_type do
|
if selected_custom_field do
|
||||||
union_type = to_string(selected_property_type.value_type)
|
union_type = to_string(selected_custom_field.value_type)
|
||||||
put_in(property_params, ["value", "_union_type"], union_type)
|
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
|
||||||
else
|
else
|
||||||
property_params
|
custom_field_value_params
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
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))}
|
|> assign(form: AshPhoenix.Form.validate(socket.assigns.form, updated_params))}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save", %{"property" => property_params}, socket) do
|
def handle_event("save", %{"custom_field_value" => custom_field_value_params}, socket) do
|
||||||
# Set the Union type based on the selected PropertyType
|
# Set the Union type based on the selected CustomField
|
||||||
updated_params =
|
updated_params =
|
||||||
if socket.assigns.selected_property_type do
|
if socket.assigns.selected_custom_field do
|
||||||
union_type = to_string(socket.assigns.selected_property_type.value_type)
|
union_type = to_string(socket.assigns.selected_custom_field.value_type)
|
||||||
put_in(property_params, ["value", "_union_type"], union_type)
|
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
|
||||||
else
|
else
|
||||||
property_params
|
custom_field_value_params
|
||||||
end
|
end
|
||||||
|
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
|
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
|
||||||
{:ok, property} ->
|
{:ok, custom_field_value} ->
|
||||||
notify_parent({:saved, property})
|
notify_parent({:saved, custom_field_value})
|
||||||
|
|
||||||
action =
|
action =
|
||||||
case socket.assigns.form.source.type do
|
case socket.assigns.form.source.type do
|
||||||
|
|
@ -235,8 +237,11 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, gettext("Property %{action} successfully", action: action))
|
|> put_flash(
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, property))
|
:info,
|
||||||
|
gettext("Custom field value %{action} successfully", action: action)
|
||||||
|
)
|
||||||
|
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field_value))
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
|
|
@ -247,11 +252,11 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
|
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
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 =
|
form =
|
||||||
if property do
|
if custom_field_value do
|
||||||
# Determine the Union type based on the property_type
|
# Determine the Union type based on the custom_field
|
||||||
union_type = property.property_type && property.property_type.value_type
|
union_type = custom_field_value.custom_field && custom_field_value.custom_field.value_type
|
||||||
|
|
||||||
params =
|
params =
|
||||||
if union_type do
|
if union_type do
|
||||||
|
|
@ -260,20 +265,27 @@ defmodule MvWeb.PropertyLive.Form do
|
||||||
%{}
|
%{}
|
||||||
end
|
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
|
else
|
||||||
AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property")
|
AshPhoenix.Form.for_create(Mv.Membership.CustomFieldValue, :create,
|
||||||
|
as: "custom_field_value"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
assign(socket, form: to_form(form))
|
assign(socket, form: to_form(form))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp return_path("index", _property), do: ~p"/properties"
|
defp return_path("index", _custom_field_value), do: ~p"/custom_field_values"
|
||||||
defp return_path("show", property), do: ~p"/properties/#{property.id}"
|
|
||||||
|
defp return_path("show", custom_field_value),
|
||||||
|
do: ~p"/custom_field_values/#{custom_field_value.id}"
|
||||||
|
|
||||||
# Helper functions for selection options
|
# Helper functions for selection options
|
||||||
defp property_type_options(property_types) do
|
defp custom_field_options(custom_fields) do
|
||||||
Enum.map(property_types, &{&1.name, &1.id})
|
Enum.map(custom_fields, &{&1.name, &1.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp member_options(members) do
|
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
|
- paid status
|
||||||
- notes
|
- notes
|
||||||
|
|
||||||
## Custom Properties
|
## Custom Field Values
|
||||||
Members can have dynamic custom properties defined by PropertyTypes.
|
Members can have dynamic custom field values defined by CustomFields.
|
||||||
The form dynamically renders inputs based on available PropertyTypes.
|
The form dynamically renders inputs based on available CustomFields.
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `validate` - Real-time form validation
|
- `validate` - Real-time form validation
|
||||||
- `save` - Submit form (create or update member)
|
- `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
|
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[:house_number]} label={gettext("House Number")} />
|
||||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
||||||
|
|
||||||
<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>
|
||||||
<.inputs_for :let={f_property} field={@form[:properties]}>
|
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
|
||||||
<% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %>
|
<% type =
|
||||||
<.inputs_for :let={value_form} field={f_property[:value]}>
|
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 =
|
<% input_type =
|
||||||
cond do
|
cond do
|
||||||
type && type.value_type == :boolean -> "checkbox"
|
type && type.value_type == :boolean -> "checkbox"
|
||||||
|
|
@ -70,8 +71,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
</.inputs_for>
|
</.inputs_for>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name={f_property[:property_type_id].name}
|
name={f_custom_field_value[:custom_field_id].name}
|
||||||
value={f_property[:property_type_id].value}
|
value={f_custom_field_value[:custom_field_id].value}
|
||||||
/>
|
/>
|
||||||
</.inputs_for>
|
</.inputs_for>
|
||||||
|
|
||||||
|
|
@ -86,16 +87,16 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
{:ok, property_types} = Mv.Membership.list_property_types()
|
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
|
||||||
|
|
||||||
initial_properties =
|
initial_custom_field_values =
|
||||||
Enum.map(property_types, fn pt ->
|
Enum.map(custom_fields, fn cf ->
|
||||||
%{
|
%{
|
||||||
"property_type_id" => pt.id,
|
"custom_field_id" => cf.id,
|
||||||
"value" => %{
|
"value" => %{
|
||||||
"type" => pt.value_type,
|
"type" => cf.value_type,
|
||||||
"value" => nil,
|
"value" => nil,
|
||||||
"_union_type" => Atom.to_string(pt.value_type)
|
"_union_type" => Atom.to_string(cf.value_type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
@ -112,8 +113,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(:property_types, property_types)
|
|> assign(:custom_fields, custom_fields)
|
||||||
|> assign(:initial_properties, initial_properties)
|
|> assign(:initial_custom_field_values, initial_custom_field_values)
|
||||||
|> assign(member: member)
|
|> assign(member: member)
|
||||||
|> assign(:page_title, page_title)
|
|> assign(:page_title, page_title)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
|
|
@ -156,25 +157,25 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
defp assign_form(%{assigns: %{member: member}} = socket) do
|
defp assign_form(%{assigns: %{member: member}} = socket) do
|
||||||
form =
|
form =
|
||||||
if member do
|
if member do
|
||||||
{:ok, member} = Ash.load(member, properties: [:property_type])
|
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
|
||||||
|
|
||||||
existing_properties =
|
existing_custom_field_values =
|
||||||
member.properties
|
member.custom_field_values
|
||||||
|> Enum.map(& &1.property_type_id)
|
|> Enum.map(& &1.custom_field_id)
|
||||||
|
|
||||||
is_missing_property = fn i ->
|
is_missing_custom_field_value = fn i ->
|
||||||
not Enum.member?(existing_properties, Map.get(i, "property_type_id"))
|
not Enum.member?(existing_custom_field_values, Map.get(i, "custom_field_id"))
|
||||||
end
|
end
|
||||||
|
|
||||||
params = %{
|
params = %{
|
||||||
"properties" =>
|
"custom_field_values" =>
|
||||||
Enum.map(member.properties, fn prop ->
|
Enum.map(member.custom_field_values, fn cfv ->
|
||||||
%{
|
%{
|
||||||
"property_type_id" => prop.property_type_id,
|
"custom_field_id" => cfv.custom_field_id,
|
||||||
"value" => %{
|
"value" => %{
|
||||||
"_union_type" => Atom.to_string(prop.value.type),
|
"_union_type" => Atom.to_string(cfv.value.type),
|
||||||
"type" => prop.value.type,
|
"type" => cfv.value.type,
|
||||||
"value" => prop.value.value
|
"value" => cfv.value.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
@ -190,12 +191,13 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
forms: [auto?: true]
|
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(
|
Enum.reduce(
|
||||||
missing_properties,
|
missing_custom_field_values,
|
||||||
form,
|
form,
|
||||||
&AshPhoenix.Form.add_form(&2, [:properties], params: &1)
|
&AshPhoenix.Form.add_form(&2, [:custom_field_values], params: &1)
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
AshPhoenix.Form.for_create(
|
AshPhoenix.Form.for_create(
|
||||||
|
|
@ -203,7 +205,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
:create_member,
|
:create_member,
|
||||||
api: Mv.Membership,
|
api: Mv.Membership,
|
||||||
as: "member",
|
as: "member",
|
||||||
params: %{"properties" => socket.assigns[:initial_properties]},
|
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
|
||||||
forms: [auto?: true]
|
forms: [auto?: true]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
## Features
|
## Features
|
||||||
- Display all member information (personal, contact, address)
|
- Display all member information (personal, contact, address)
|
||||||
- Show linked user account (if exists)
|
- Show linked user account (if exists)
|
||||||
- Display custom properties
|
- Display custom field values
|
||||||
- Navigate to edit form
|
- Navigate to edit form
|
||||||
- Return to member list
|
- Return to member list
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
- Address: street, house number, postal code, city
|
- Address: street, house number, postal code, city
|
||||||
- Status: paid flag
|
- Status: paid flag
|
||||||
- Relationships: linked user account
|
- Relationships: linked user account
|
||||||
- Custom: dynamic properties from PropertyTypes
|
- Custom: dynamic custom field values from CustomFields
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
- Back to member list
|
- Back to member list
|
||||||
|
|
@ -75,14 +75,14 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
</:item>
|
</:item>
|
||||||
</.list>
|
</.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={
|
<.generic_list items={
|
||||||
Enum.map(@member.properties, fn p ->
|
Enum.map(@member.custom_field_values, fn cfv ->
|
||||||
{
|
{
|
||||||
# name
|
# name
|
||||||
p.property_type && p.property_type.name,
|
cfv.custom_field && cfv.custom_field.name,
|
||||||
# value
|
# value
|
||||||
case p.value do
|
case cfv.value do
|
||||||
%{value: v} -> v
|
%{value: v} -> v
|
||||||
v -> v
|
v -> v
|
||||||
end
|
end
|
||||||
|
|
@ -103,7 +103,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
query =
|
query =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> filter(id == ^id)
|
|> filter(id == ^id)
|
||||||
|> load([:user, properties: [:property_type]])
|
|> load([:user, custom_field_values: [:custom_field]])
|
||||||
|
|
||||||
member = Ash.read_one!(query)
|
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", MemberLive.Show, :show
|
||||||
live "/members/:id/show/edit", MemberLive.Show, :edit
|
live "/members/:id/show/edit", MemberLive.Show, :edit
|
||||||
|
|
||||||
live "/property_types", PropertyTypeLive.Index, :index
|
live "/custom_fields", CustomFieldLive.Index, :index
|
||||||
live "/property_types/new", PropertyTypeLive.Form, :new
|
live "/custom_fields/new", CustomFieldLive.Form, :new
|
||||||
live "/property_types/:id/edit", PropertyTypeLive.Form, :edit
|
live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit
|
||||||
live "/property_types/:id", PropertyTypeLive.Show, :show
|
live "/custom_fields/:id", CustomFieldLive.Show, :show
|
||||||
live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit
|
live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit
|
||||||
|
|
||||||
live "/properties", PropertyLive.Index, :index
|
live "/custom_field_values", CustomFieldValueLive.Index, :index
|
||||||
live "/properties/new", PropertyLive.Form, :new
|
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
|
||||||
live "/properties/:id/edit", PropertyLive.Form, :edit
|
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
|
||||||
live "/properties/:id", PropertyLive.Show, :show
|
live "/custom_field_values/:id", CustomFieldValueLive.Show, :show
|
||||||
live "/properties/:id/show/edit", PropertyLive.Show, :edit
|
live "/custom_field_values/:id/show/edit", CustomFieldValueLive.Show, :edit
|
||||||
|
|
||||||
live "/users", UserLive.Index, :index
|
live "/users", UserLive.Index, :index
|
||||||
live "/users/new", UserLive.Form, :new
|
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
|
required: true
|
||||||
}
|
}
|
||||||
] do
|
] do
|
||||||
Membership.create_property_type!(
|
Membership.create_custom_field!(
|
||||||
attrs,
|
attrs,
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_name
|
upsert_identity: :unique_name
|
||||||
|
|
@ -182,7 +182,7 @@ end)
|
||||||
|
|
||||||
IO.puts("✅ Seeds completed successfully!")
|
IO.puts("✅ Seeds completed successfully!")
|
||||||
IO.puts("📝 Created sample data:")
|
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(" - Admin user: admin@mv.local (password: testpassword)")
|
||||||
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
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,
|
"ash_functions_version": 5,
|
||||||
"installed": [
|
"installed": [
|
||||||
"ash-functions",
|
"ash-functions",
|
||||||
"citext"
|
"citext",
|
||||||
|
"pg_trgm"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -148,10 +148,10 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
"/",
|
"/",
|
||||||
"/members",
|
"/members",
|
||||||
"/members/new",
|
"/members/new",
|
||||||
"/properties",
|
"/custom_field_values",
|
||||||
"/properties/new",
|
"/custom_field_values/new",
|
||||||
"/property_types",
|
"/custom_fields",
|
||||||
"/property_types/new",
|
"/custom_fields/new",
|
||||||
"/users",
|
"/users",
|
||||||
"/users/new"
|
"/users/new"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ defmodule Mv.SeedsTest do
|
||||||
# Basic smoke test: ensure some data was created
|
# Basic smoke test: ensure some data was created
|
||||||
{:ok, users} = Ash.read(Mv.Accounts.User)
|
{:ok, users} = Ash.read(Mv.Accounts.User)
|
||||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
{: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(users) > 0, "Seeds should create at least one user"
|
||||||
assert length(members) > 0, "Seeds should create at least one member"
|
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
|
end
|
||||||
|
|
||||||
test "can be run multiple times (idempotent)" do
|
test "can be run multiple times (idempotent)" do
|
||||||
|
|
@ -23,7 +23,7 @@ defmodule Mv.SeedsTest do
|
||||||
# Count records
|
# Count records
|
||||||
{:ok, users_count_1} = Ash.read(Mv.Accounts.User)
|
{:ok, users_count_1} = Ash.read(Mv.Accounts.User)
|
||||||
{:ok, members_count_1} = Ash.read(Mv.Membership.Member)
|
{: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
|
# Run seeds second time - should not raise errors
|
||||||
assert Code.eval_file("priv/repo/seeds.exs")
|
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)
|
# Count records again - should be the same (upsert, not duplicate)
|
||||||
{:ok, users_count_2} = Ash.read(Mv.Accounts.User)
|
{:ok, users_count_2} = Ash.read(Mv.Accounts.User)
|
||||||
{:ok, members_count_2} = Ash.read(Mv.Membership.Member)
|
{: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),
|
assert length(users_count_1) == length(users_count_2),
|
||||||
"Users count should remain same after re-running seeds"
|
"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),
|
assert length(members_count_1) == length(members_count_2),
|
||||||
"Members count should remain same after re-running seeds"
|
"Members count should remain same after re-running seeds"
|
||||||
|
|
||||||
assert length(property_types_count_1) == length(property_types_count_2),
|
assert length(custom_fields_count_1) == length(custom_fields_count_2),
|
||||||
"PropertyTypes count should remain same after re-running seeds"
|
"CustomFields count should remain same after re-running seeds"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue