Merge pull request 'Custom Fields: Add slugs closes #195' (#205) from feature/custom-field-slug into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #205
This commit is contained in:
commit
21ec86839a
13 changed files with 788 additions and 40 deletions
|
|
@ -6,7 +6,7 @@
|
|||
// - https://dbdocs.io
|
||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||
//
|
||||
// Version: 1.1
|
||||
// Version: 1.2
|
||||
// Last Updated: 2025-11-13
|
||||
|
||||
Project mila_membership_management {
|
||||
|
|
@ -236,6 +236,7 @@ Table custom_field_values {
|
|||
Table custom_fields {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")']
|
||||
slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.']
|
||||
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
|
||||
description text [null, note: 'Human-readable description']
|
||||
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
|
||||
|
|
@ -243,6 +244,7 @@ Table custom_fields {
|
|||
|
||||
indexes {
|
||||
name [unique, name: 'custom_fields_unique_name_index']
|
||||
slug [unique, name: 'custom_fields_unique_slug_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
|
|
@ -252,21 +254,32 @@ Table custom_fields {
|
|||
|
||||
**Attributes:**
|
||||
- `name`: Unique identifier for the custom field
|
||||
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
|
||||
- `value_type`: Enforces data type consistency
|
||||
- `description`: Documentation for users/admins
|
||||
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
|
||||
- `required`: Enforces that all members must have this custom field
|
||||
|
||||
**Slug Generation:**
|
||||
- Automatically generated from `name` on creation
|
||||
- Immutable after creation (does not change when name is updated)
|
||||
- Lowercase, spaces replaced with hyphens, special characters removed
|
||||
- UTF-8 support (ä → a, ß → ss, etc.)
|
||||
- Used for human-readable identifiers (CSV export/import, API, etc.)
|
||||
- Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller"
|
||||
|
||||
**Constraints:**
|
||||
- `value_type` must be one of: string, integer, boolean, date, email
|
||||
- `name` must be unique across all custom fields
|
||||
- `slug` must be unique across all custom fields
|
||||
- `slug` cannot be empty (validated on creation)
|
||||
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
|
||||
|
||||
**Examples:**
|
||||
- Membership Number (string, immutable, required)
|
||||
- Emergency Contact (string, mutable, optional)
|
||||
- Certified Trainer (boolean, mutable, optional)
|
||||
- Certification Date (date, immutable, optional)
|
||||
- Membership Number (string, immutable, required) → slug: "membership-number"
|
||||
- Emergency Contact (string, mutable, optional) → slug: "emergency-contact"
|
||||
- Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer"
|
||||
- Certification Date (date, immutable, optional) → slug: "certification-date"
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule Mv.Membership.CustomField do
|
|||
|
||||
## Attributes
|
||||
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
|
||||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||
- `description` - Optional human-readable description
|
||||
- `immutable` - If true, custom field values cannot be changed after creation
|
||||
|
|
@ -54,8 +55,14 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
defaults [:read, :update, :destroy]
|
||||
default_accept [:name, :value_type, :description, :immutable, :required]
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :immutable, :required]
|
||||
change Mv.Membership.CustomField.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
|
@ -69,6 +76,15 @@ defmodule Mv.Membership.CustomField do
|
|||
trim?: true
|
||||
]
|
||||
|
||||
attribute :slug, :string,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
writable?: false,
|
||||
constraints: [
|
||||
max_length: 100,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :value_type, :atom,
|
||||
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
|
||||
allow_nil?: false,
|
||||
|
|
@ -97,5 +113,6 @@ defmodule Mv.Membership.CustomField do
|
|||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
identity :unique_slug, [:slug]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
118
lib/membership/custom_field/changes/generate_slug.ex
Normal file
118
lib/membership/custom_field/changes/generate_slug.ex
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||
@moduledoc """
|
||||
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **On Create**: Generates a slug from the name attribute using slugify
|
||||
- **On Update**: Slug remains unchanged (immutable after creation)
|
||||
- **Slug Generation**: Uses the `slugify` library to convert name to slug
|
||||
- Converts to lowercase
|
||||
- Replaces spaces with hyphens
|
||||
- Removes special characters
|
||||
- Handles UTF-8 characters (e.g., ä → a, ß → ss)
|
||||
- Trims leading/trailing hyphens
|
||||
- Truncates to max 100 characters
|
||||
|
||||
## Examples
|
||||
|
||||
# Create with automatic slug generation
|
||||
CustomField.create!(%{name: "Mobile Phone"})
|
||||
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
|
||||
|
||||
# German umlauts are converted
|
||||
CustomField.create!(%{name: "Café Müller"})
|
||||
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
|
||||
|
||||
# Slug is immutable on update
|
||||
custom_field = CustomField.create!(%{name: "Original"})
|
||||
CustomField.update!(custom_field, %{name: "New Name"})
|
||||
# => %CustomField{name: "New Name", slug: "original"} # slug unchanged!
|
||||
|
||||
## Implementation Note
|
||||
|
||||
This change only runs on `:create` actions. The slug is immutable by design,
|
||||
as changing slugs would break external references (e.g., CSV imports/exports).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@doc """
|
||||
Generates a slug from the changeset's `name` attribute.
|
||||
|
||||
Only runs on create actions. Returns the changeset unchanged if:
|
||||
- The action is not :create
|
||||
- The name is not being changed
|
||||
- The name is nil or empty
|
||||
|
||||
## Parameters
|
||||
|
||||
- `changeset` - The Ash changeset
|
||||
|
||||
## Returns
|
||||
|
||||
The changeset with the `:slug` attribute set to the generated slug.
|
||||
"""
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only generate slug on create, not on update (immutability)
|
||||
if changeset.action_type == :create do
|
||||
case Ash.Changeset.get_attribute(changeset, :name) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
name when is_binary(name) ->
|
||||
slug = generate_slug(name)
|
||||
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
||||
end
|
||||
else
|
||||
# On update, don't touch the slug (immutable)
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a URL-friendly slug from a given string.
|
||||
|
||||
Uses the `slugify` library to create a clean, lowercase slug with:
|
||||
- Spaces replaced by hyphens
|
||||
- Special characters removed
|
||||
- UTF-8 characters transliterated (ä → a, ß → ss, etc.)
|
||||
- Multiple consecutive hyphens reduced to single hyphen
|
||||
- Leading/trailing hyphens removed
|
||||
- Maximum length of 100 characters
|
||||
|
||||
## Examples
|
||||
|
||||
iex> generate_slug("Mobile Phone")
|
||||
"mobile-phone"
|
||||
|
||||
iex> generate_slug("Café Müller")
|
||||
"cafe-muller"
|
||||
|
||||
iex> generate_slug("TEST NAME")
|
||||
"test-name"
|
||||
|
||||
iex> generate_slug("E-Mail & Address!")
|
||||
"e-mail-address"
|
||||
|
||||
iex> generate_slug("Multiple Spaces")
|
||||
"multiple-spaces"
|
||||
|
||||
iex> generate_slug("-Test-")
|
||||
"test"
|
||||
|
||||
iex> generate_slug("Straße")
|
||||
"strasse"
|
||||
|
||||
"""
|
||||
def generate_slug(name) when is_binary(name) do
|
||||
slug = Slug.slugify(name)
|
||||
|
||||
case slug do
|
||||
nil -> ""
|
||||
"" -> ""
|
||||
slug when is_binary(slug) -> String.slice(slug, 0, 100)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_slug(_), do: ""
|
||||
end
|
||||
|
|
@ -48,6 +48,7 @@ defmodule MvWeb.CustomFieldLive.Form do
|
|||
|
||||
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
|
|
|
|||
|
|
@ -43,8 +43,6 @@ defmodule MvWeb.CustomFieldLive.Index do
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ defmodule MvWeb.CustomFieldLive.Show do
|
|||
- Return to custom field list
|
||||
|
||||
## Displayed Information
|
||||
- ID: Internal UUID identifier
|
||||
- Slug: URL-friendly identifier (auto-generated, immutable)
|
||||
- Name: Unique identifier
|
||||
- Value type: Data type constraint
|
||||
- Description: Optional explanation
|
||||
|
|
@ -29,7 +31,7 @@ defmodule MvWeb.CustomFieldLive.Show do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Custom field {@custom_field.id}
|
||||
Custom field {@custom_field.slug}
|
||||
<:subtitle>This is a custom_field record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
|
|
@ -48,6 +50,13 @@ defmodule MvWeb.CustomFieldLive.Show do
|
|||
<.list>
|
||||
<:item title="Id">{@custom_field.id}</:item>
|
||||
|
||||
<:item title="Slug">
|
||||
{@custom_field.slug}
|
||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
{gettext("Auto-generated identifier (immutable)")}
|
||||
</p>
|
||||
</:item>
|
||||
|
||||
<:item title="Name">{@custom_field.name}</:item>
|
||||
|
||||
<:item title="Description">{@custom_field.description}</:item>
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -75,7 +75,8 @@ defmodule Mv.MixProject do
|
|||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:ecto_commons, "~> 0.3"}
|
||||
{:ecto_commons, "~> 0.3"},
|
||||
{:slugify, "~> 1.3"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ msgstr "Postleitzahl"
|
|||
msgid "Save Member"
|
||||
msgstr "Mitglied speichern"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:74
|
||||
#: lib/mv_web/live/member_live/form.ex:79
|
||||
#: lib/mv_web/live/user_live/form.ex:124
|
||||
|
|
@ -203,14 +203,14 @@ msgstr "Dies ist ein Mitglied aus deiner Datenbank."
|
|||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:107
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
||||
#: lib/mv_web/live/member_live/form.ex:138
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "create"
|
||||
msgstr "erstellt"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
||||
#: lib/mv_web/live/member_live/form.ex:139
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -252,7 +252,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt"
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:66
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
||||
#: lib/mv_web/live/member_live/form.ex:82
|
||||
#: lib/mv_web/live/user_live/form.ex:127
|
||||
|
|
@ -265,7 +265,7 @@ msgstr "Abbrechen"
|
|||
msgid "Choose a member"
|
||||
msgstr "Mitglied auswählen"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:59
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr "Beschreibung"
|
||||
|
|
@ -285,7 +285,7 @@ msgstr "Aktiviert"
|
|||
msgid "ID"
|
||||
msgstr "ID"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Immutable"
|
||||
msgstr "Unveränderlich"
|
||||
|
|
@ -355,7 +355,7 @@ msgstr "Passwort-Authentifizierung"
|
|||
msgid "Profil"
|
||||
msgstr "Profil"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Required"
|
||||
msgstr "Erforderlich"
|
||||
|
|
@ -411,7 +411,7 @@ msgstr "Benutzer*in"
|
|||
msgid "Value"
|
||||
msgstr "Wert"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:54
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type"
|
||||
msgstr "Wertetyp"
|
||||
|
|
@ -616,7 +616,7 @@ msgstr "Benutzerdefinierte Feldwerte"
|
|||
msgid "Custom field"
|
||||
msgstr "Benutzerdefiniertes Feld"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:114
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field %{action} successfully"
|
||||
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
|
||||
|
|
@ -631,7 +631,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
|
|||
msgid "Please select a custom field first"
|
||||
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Custom field"
|
||||
msgstr "Benutzerdefiniertes Feld speichern"
|
||||
|
|
@ -655,3 +655,8 @@ msgstr "Benutzerdefinierte Felder"
|
|||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage Custom Field Value records in your database."
|
||||
msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Auto-generated identifier (immutable)"
|
||||
msgstr "Automatisch generierter Identifier"
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ msgstr ""
|
|||
msgid "Save Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:74
|
||||
#: lib/mv_web/live/member_live/form.ex:79
|
||||
#: lib/mv_web/live/user_live/form.ex:124
|
||||
|
|
@ -204,14 +204,14 @@ msgstr ""
|
|||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:107
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
||||
#: lib/mv_web/live/member_live/form.ex:138
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "create"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
||||
#: lib/mv_web/live/member_live/form.ex:139
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -253,7 +253,7 @@ msgstr ""
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:66
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
||||
#: lib/mv_web/live/member_live/form.ex:82
|
||||
#: lib/mv_web/live/user_live/form.ex:127
|
||||
|
|
@ -266,7 +266,7 @@ msgstr ""
|
|||
msgid "Choose a member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:59
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
|
@ -286,7 +286,7 @@ msgstr ""
|
|||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Immutable"
|
||||
msgstr ""
|
||||
|
|
@ -356,7 +356,7 @@ msgstr ""
|
|||
msgid "Profil"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Required"
|
||||
msgstr ""
|
||||
|
|
@ -412,7 +412,7 @@ msgstr ""
|
|||
msgid "Value"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:54
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type"
|
||||
msgstr ""
|
||||
|
|
@ -617,7 +617,7 @@ msgstr ""
|
|||
msgid "Custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:114
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field %{action} successfully"
|
||||
msgstr ""
|
||||
|
|
@ -632,7 +632,7 @@ msgstr ""
|
|||
msgid "Please select a custom field first"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Custom field"
|
||||
msgstr ""
|
||||
|
|
@ -656,3 +656,8 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage Custom Field Value records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Auto-generated identifier (immutable)"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ msgstr ""
|
|||
msgid "Save Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:63
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:74
|
||||
#: lib/mv_web/live/member_live/form.ex:79
|
||||
#: lib/mv_web/live/user_live/form.ex:124
|
||||
|
|
@ -204,14 +204,14 @@ msgstr ""
|
|||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:107
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:233
|
||||
#: lib/mv_web/live/member_live/form.ex:138
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "create"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:108
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:109
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:234
|
||||
#: lib/mv_web/live/member_live/form.ex:139
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -253,7 +253,7 @@ msgstr ""
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:66
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:67
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex:77
|
||||
#: lib/mv_web/live/member_live/form.ex:82
|
||||
#: lib/mv_web/live/user_live/form.ex:127
|
||||
|
|
@ -266,7 +266,7 @@ msgstr ""
|
|||
msgid "Choose a member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:59
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
|
@ -286,7 +286,7 @@ msgstr ""
|
|||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:60
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Immutable"
|
||||
msgstr ""
|
||||
|
|
@ -356,7 +356,7 @@ msgstr ""
|
|||
msgid "Profil"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:61
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Required"
|
||||
msgstr ""
|
||||
|
|
@ -412,7 +412,7 @@ msgstr ""
|
|||
msgid "Value"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:54
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type"
|
||||
msgstr ""
|
||||
|
|
@ -617,7 +617,7 @@ msgstr ""
|
|||
msgid "Custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:114
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:115
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom field %{action} successfully"
|
||||
msgstr ""
|
||||
|
|
@ -632,7 +632,7 @@ msgstr ""
|
|||
msgid "Please select a custom field first"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:64
|
||||
#: lib/mv_web/live/custom_field_live/form.ex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Custom field"
|
||||
msgstr ""
|
||||
|
|
@ -656,3 +656,8 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage Custom Field Value records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Auto-generated identifier (immutable)"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
defmodule Mv.Repo.Migrations.AddSlugToCustomFields 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
|
||||
# Step 1: Add slug column as nullable first
|
||||
alter table(:custom_fields) do
|
||||
add :slug, :text, null: true
|
||||
end
|
||||
|
||||
# Step 2: Generate slugs for existing custom fields
|
||||
execute("""
|
||||
UPDATE custom_fields
|
||||
SET slug = lower(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(name, '[^a-zA-Z0-9\\s-]', '', 'g'),
|
||||
'\\s+', '-', 'g'
|
||||
),
|
||||
'-+', '-', 'g'
|
||||
)
|
||||
)
|
||||
WHERE slug IS NULL
|
||||
""")
|
||||
|
||||
# Step 3: Make slug NOT NULL
|
||||
alter table(:custom_fields) do
|
||||
modify :slug, :text, null: false
|
||||
end
|
||||
|
||||
# Step 4: Create unique index
|
||||
create unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index")
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index")
|
||||
|
||||
alter table(:custom_fields) do
|
||||
remove :slug
|
||||
end
|
||||
end
|
||||
end
|
||||
132
priv/resource_snapshots/repo/custom_fields/20251113180429.json
Normal file
132
priv/resource_snapshots/repo/custom_fields/20251113180429.json
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
{
|
||||
"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": "slug",
|
||||
"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": "DB1D3D9F2F76F518CAEEA2CC855996CCD87FC4C8FDD3A37345CEF2980674D8F3",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_slug_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "slug"
|
||||
}
|
||||
],
|
||||
"name": "unique_slug",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "custom_fields"
|
||||
}
|
||||
397
test/membership/custom_field_slug_test.exs
Normal file
397
test/membership/custom_field_slug_test.exs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
defmodule Mv.Membership.CustomFieldSlugTest do
|
||||
@moduledoc """
|
||||
Tests for automatic slug generation on CustomField resource.
|
||||
|
||||
This test suite verifies:
|
||||
1. Slugs are automatically generated from the name attribute
|
||||
2. Slugs are unique (cannot have duplicates)
|
||||
3. Slugs are immutable (don't change when name changes)
|
||||
4. Slugs handle various edge cases (unicode, special chars, etc.)
|
||||
5. Slugs can be used for lookups
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
describe "automatic slug generation on create" do
|
||||
test "generates slug from name with simple ASCII text" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Mobile Phone",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "mobile-phone"
|
||||
end
|
||||
|
||||
test "generates slug from name with German umlauts" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Café Müller",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "cafe-muller"
|
||||
end
|
||||
|
||||
test "generates slug with lowercase conversion" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "TEST NAME",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "generates slug by removing special characters" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "E-Mail & Address!",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "e-mail-address"
|
||||
end
|
||||
|
||||
test "generates slug by replacing multiple spaces with single hyphen" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Multiple Spaces",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "multiple-spaces"
|
||||
end
|
||||
|
||||
test "trims leading and trailing hyphens" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "-Test-",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "test"
|
||||
end
|
||||
|
||||
test "handles unicode characters properly (ß becomes ss)" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Straße",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "strasse"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug uniqueness" do
|
||||
test "prevents creating custom field with duplicate slug" do
|
||||
# Create first custom field
|
||||
{:ok, _custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Attempt to create second custom field with same slug (different case in name)
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test",
|
||||
value_type: :integer
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert Exception.message(error) =~ "has already been taken"
|
||||
end
|
||||
|
||||
test "allows custom fields with different slugs" do
|
||||
{:ok, custom_field1} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test One",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, custom_field2} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Two",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field1.slug == "test-one"
|
||||
assert custom_field2.slug == "test-two"
|
||||
assert custom_field1.slug != custom_field2.slug
|
||||
end
|
||||
|
||||
test "prevents duplicate slugs when names differ only in special characters" do
|
||||
{:ok, custom_field1} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test!!!",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field1.slug == "test"
|
||||
|
||||
# Second custom field with name that generates the same slug should fail
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test???",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Should fail with uniqueness constraint error
|
||||
assert Exception.message(error) =~ "has already been taken"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug immutability" do
|
||||
test "slug cannot be manually set on create" do
|
||||
# Attempting to set slug manually should fail because slug is not writable
|
||||
result =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string,
|
||||
slug: "custom-slug"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Should fail because slug is not an accepted input
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
assert Exception.message(elem(result, 1)) =~ "No such input"
|
||||
end
|
||||
|
||||
test "slug does not change when name is updated" do
|
||||
# Create custom field
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Original Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
original_slug = custom_field.slug
|
||||
assert original_slug == "original-name"
|
||||
|
||||
# Update the name
|
||||
{:ok, updated_custom_field} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{
|
||||
name: "New Different Name"
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Slug should remain unchanged
|
||||
assert updated_custom_field.slug == original_slug
|
||||
assert updated_custom_field.slug == "original-name"
|
||||
assert updated_custom_field.name == "New Different Name"
|
||||
end
|
||||
|
||||
test "slug cannot be manually updated" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
original_slug = custom_field.slug
|
||||
assert original_slug == "test"
|
||||
|
||||
# Attempt to manually update slug should fail because slug is not writable
|
||||
result =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{
|
||||
slug: "new-slug"
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Should fail because slug is not an accepted input
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
assert Exception.message(elem(result, 1)) =~ "No such input"
|
||||
|
||||
# Reload to verify slug hasn't changed
|
||||
reloaded = Ash.get!(CustomField, custom_field.id)
|
||||
assert reloaded.slug == "test"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug edge cases" do
|
||||
test "handles very long names by truncating slug" do
|
||||
# Create a name at the maximum length (100 chars)
|
||||
long_name = String.duplicate("abcdefghij", 10)
|
||||
# 100 characters exactly
|
||||
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: long_name,
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Slug should be truncated to maximum 100 characters
|
||||
assert String.length(custom_field.slug) <= 100
|
||||
# Should be the full slugified version since name is exactly 100 chars
|
||||
assert custom_field.slug == long_name
|
||||
end
|
||||
|
||||
test "rejects name with only special characters" do
|
||||
# When name contains only special characters, slug would be empty
|
||||
# This should fail validation
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "!!!",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Should fail because slug would be empty
|
||||
error_message = Exception.message(error)
|
||||
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
|
||||
end
|
||||
|
||||
test "handles mixed special characters and text" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test@#$%Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# slugify keeps the hyphen between words
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "handles numbers in name" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Field 123 Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.slug == "field-123-test"
|
||||
end
|
||||
|
||||
test "handles consecutive hyphens in name" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test---Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Should reduce multiple hyphens to single hyphen
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "handles name with dots and underscores" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test.field_name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Dots and underscores should be handled (either kept or converted)
|
||||
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug in queries and responses" do
|
||||
test "slug is included in struct after create" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Slug should be present in the struct
|
||||
assert Map.has_key?(custom_field, :slug)
|
||||
assert custom_field.slug != nil
|
||||
end
|
||||
|
||||
test "can load custom field and slug is present" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Load it back
|
||||
loaded_custom_field = Ash.get!(CustomField, custom_field.id)
|
||||
|
||||
assert loaded_custom_field.slug == "test"
|
||||
end
|
||||
|
||||
test "slug is returned in list queries" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
custom_fields = Ash.read!(CustomField)
|
||||
|
||||
found = Enum.find(custom_fields, &(&1.id == custom_field.id))
|
||||
assert found.slug == "test"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug-based lookup (future feature)" do
|
||||
@tag :skip
|
||||
test "can find custom field by slug" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Field",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# This test is for future implementation
|
||||
# We might add a custom action like :by_slug
|
||||
found = Ash.get!(CustomField, custom_field.slug, load: [:slug])
|
||||
assert found.id == custom_field.id
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue