[WV2] Outfit renaming as an atomic operation
This commit is contained in:
parent
0d4b553162
commit
6fa4e57184
10 changed files with 157 additions and 19 deletions
42
app/assets/javascripts/outfit-rename-field.js
Normal file
42
app/assets/javascripts/outfit-rename-field.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* OutfitRenameField web component
|
||||
*
|
||||
* Progressive enhancement for the outfit name field:
|
||||
* - Shows a static text header with a pencil icon button
|
||||
* - Pencil appears on hover/focus of the container
|
||||
* - Clicking pencil switches to the editable form
|
||||
* - Enter submits, Escape or Cancel reverts to static display
|
||||
*
|
||||
* State is managed via the `editing` attribute, which CSS uses to toggle
|
||||
* visibility. Turbo morphs naturally reset this attribute (since it's not in
|
||||
* the server HTML), so no morph-specific handling is needed.
|
||||
*/
|
||||
class OutfitRenameField extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const pencil = this.querySelector(".outfit-rename-pencil");
|
||||
const cancel = this.querySelector(".outfit-rename-cancel");
|
||||
const input = this.querySelector("input[type=text]");
|
||||
if (!pencil || !cancel || !input) return;
|
||||
|
||||
pencil.addEventListener("click", () => {
|
||||
this.dataset.originalValue = input.value;
|
||||
this.setAttribute("editing", "");
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
|
||||
cancel.addEventListener("click", () => {
|
||||
input.value = this.dataset.originalValue ?? input.value;
|
||||
this.removeAttribute("editing");
|
||||
});
|
||||
|
||||
this.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.hasAttribute("editing")) {
|
||||
e.preventDefault();
|
||||
cancel.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("outfit-rename-field", OutfitRenameField);
|
||||
|
|
@ -911,16 +911,85 @@ pose-picker-popover {
|
|||
}
|
||||
}
|
||||
|
||||
/* Progressive enhancement: hide rename submit when JS is available */
|
||||
/* Rename button: hidden by default, shown on hover/focus */
|
||||
.outfit-name-submit {
|
||||
margin-left: 0.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.outfit-name-form:focus-within .outfit-name-submit,
|
||||
.outfit-name-form:hover .outfit-name-submit {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Static name display for non-owners */
|
||||
.outfit-name-static {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #448844;
|
||||
padding: 0.25rem 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Web component: static display with pencil icon */
|
||||
outfit-rename-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
outfit-rename-field .outfit-name-form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
outfit-rename-field[editing] .outfit-rename-static-display {
|
||||
display: none;
|
||||
}
|
||||
|
||||
outfit-rename-field[editing] .outfit-name-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.outfit-rename-static-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.outfit-rename-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #448844;
|
||||
padding: 0.25rem 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outfit-rename-pencil {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.outfit-rename-static-display:hover .outfit-rename-pencil,
|
||||
.outfit-rename-pencil:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.outfit-rename-cancel {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (scripting: enabled) {
|
||||
.outfit-name-submit {
|
||||
/* Hide save button when rename is in editing state */
|
||||
.outfit-header:has(outfit-rename-field[editing]) .outfit-save-form,
|
||||
.outfit-header:has(outfit-rename-field[editing]) .outfit-save-button:disabled {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.outfit-save-form {
|
||||
display: inline;
|
||||
|
|
|
|||
|
|
@ -133,7 +133,14 @@ class OutfitsController < ApplicationController
|
|||
def update
|
||||
if @outfit.update(outfit_params)
|
||||
respond_to do |format|
|
||||
format.html { redirect_to wardrobe_v2_outfit_path(@outfit) }
|
||||
format.html do
|
||||
return_to = params[:return_to]
|
||||
if return_to.present? && return_to.start_with?("/") && !return_to.start_with?("//")
|
||||
redirect_to return_to
|
||||
else
|
||||
redirect_to wardrobe_v2_outfit_path(@outfit)
|
||||
end
|
||||
end
|
||||
format.json { render json: @outfit }
|
||||
end
|
||||
else
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class WardrobeController < ApplicationController
|
|||
|
||||
# Build the outfit
|
||||
@outfit = Outfit.new(
|
||||
name: params[:name].presence || @saved_outfit&.name || "Untitled outfit",
|
||||
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
|
||||
pet_state: @pet_state,
|
||||
alt_style: @alt_style,
|
||||
worn_items: items,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ module WardrobeHelper
|
|||
def outfit_state_params(outfit = @outfit, except: [])
|
||||
fields = []
|
||||
|
||||
fields << hidden_field_tag(:name, @outfit.name) if @outfit.name.present? && !except.include?(:name)
|
||||
fields << hidden_field_tag(:name, @outfit.name) if !@saved_outfit && @outfit.name.present? && !except.include?(:name)
|
||||
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
||||
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
|
||||
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
|
||||
|
|
|
|||
|
|
@ -267,7 +267,6 @@ class Outfit < ApplicationRecord
|
|||
|
||||
def wardrobe_params
|
||||
params = {
|
||||
name: name,
|
||||
color: color_id,
|
||||
species: species_id,
|
||||
pose: pose,
|
||||
|
|
|
|||
22
app/views/wardrobe/_outfit_rename_field.html.haml
Normal file
22
app/views/wardrobe/_outfit_rename_field.html.haml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
- if @saved_outfit
|
||||
- form_url = outfit_path(@saved_outfit)
|
||||
- form_method = :patch
|
||||
- field_name = "outfit[name]"
|
||||
- else
|
||||
- form_url = @wardrobe_path
|
||||
- form_method = :get
|
||||
- field_name = :name
|
||||
|
||||
%outfit-rename-field
|
||||
.outfit-rename-static-display
|
||||
%span.outfit-rename-name= @outfit.name.presence || "Untitled outfit"
|
||||
%button.outfit-rename-pencil{type: "button", "aria-label": "Rename outfit"} ✏️
|
||||
= form_with url: form_url, method: form_method, class: "outfit-name-form" do |f|
|
||||
= hidden_field_tag :return_to, request.fullpath
|
||||
- unless @saved_outfit
|
||||
= outfit_state_params except: [:name]
|
||||
= f.text_field field_name, value: @outfit.name,
|
||||
class: "outfit-name-input", placeholder: "Untitled outfit",
|
||||
"aria-label": "Outfit name"
|
||||
= f.submit "Rename", name: nil, class: "outfit-name-submit"
|
||||
%button.outfit-rename-cancel{type: "button"} Cancel
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
= javascript_include_tag "species-color-picker", async: true
|
||||
= javascript_include_tag "pose-picker", async: true
|
||||
= javascript_include_tag "tab-panel", async: true
|
||||
= javascript_include_tag "outfit-rename-field", async: true
|
||||
= javascript_include_tag "wardrobe/show", async: true
|
||||
= csrf_meta_tags
|
||||
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
||||
|
|
@ -63,12 +64,10 @@
|
|||
= render "search_results"
|
||||
- else
|
||||
.outfit-header
|
||||
= form_with url: @wardrobe_path, method: :get, class: "outfit-name-form" do |f|
|
||||
= outfit_state_params except: [:name]
|
||||
= f.text_field :name, value: @outfit.name,
|
||||
class: "outfit-name-input", placeholder: "Untitled outfit",
|
||||
"aria-label": "Outfit name"
|
||||
= f.submit "Rename", name: nil, class: "outfit-name-submit"
|
||||
- if @saved_outfit && !@is_owner
|
||||
.outfit-name-static= @outfit.name
|
||||
- else
|
||||
= render "outfit_rename_field"
|
||||
= render "save_button"
|
||||
- if @outfit.worn_items.any?
|
||||
.worn-items
|
||||
|
|
|
|||
|
|
@ -50,8 +50,7 @@ The goal is a basic usable wardrobe. Species/color/pose selection, item search,
|
|||
- Route to load saved outfits (`GET /outfits/:id/v2`) with redirect-based state initialization
|
||||
- "Save a copy" for non-owners, login prompt for unauthenticated users
|
||||
- `beforeunload` warning for unsaved changes via MutationObserver
|
||||
- Outfit name tracked as URL param (`name`) to survive navigation
|
||||
- Not yet done: auto-save, renaming (the field is present but isn't consistently tracked)
|
||||
- Outfit name: For saved outfits, rename is a standalone PATCH operation (not a URL param). For unsaved outfits, name is tracked as a URL param. Progressive enhancement shows static text + pencil icon for renaming.
|
||||
|
||||
**Alt Styles Support** (done)
|
||||
- `Outfit#visible_layers` handles alt styles
|
||||
|
|
@ -93,6 +92,7 @@ Feature parity with Wardrobe 2020 where valuable.
|
|||
- **Conflict management**: Auto zone conflict resolution, smart item restoration on unwear
|
||||
- **Pet loading**: "Load my pet" by name, modeling integration
|
||||
- **Search enhancements**: Inline syntax (`is:nc`, `fits:blue-acara`), advanced filter UI, autocomplete
|
||||
- **Outfit auto-saving**: Save outfit changes automatically over time, rather than requiring clicking Save
|
||||
|
||||
### Phase 4: Migration & Rollout
|
||||
|
||||
|
|
|
|||
|
|
@ -351,11 +351,11 @@ RSpec.describe Outfit do
|
|||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||
end
|
||||
|
||||
it "returns false when names differ" do
|
||||
it "returns true even when names differ (name is not part of wardrobe state)" do
|
||||
outfit1 = Outfit.new(name: "Outfit A", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Outfit B", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||
end
|
||||
|
||||
it "returns false when poses differ" do
|
||||
|
|
|
|||
Loading…
Reference in a new issue