[WV2] Outfit renaming as an atomic operation

This commit is contained in:
Emi Matchu 2026-02-05 21:56:23 -08:00
parent 0d4b553162
commit 6fa4e57184
10 changed files with 157 additions and 19 deletions

View 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);

View file

@ -911,15 +911,84 @@ pose-picker-popover {
} }
} }
/* Progressive enhancement: hide rename submit when JS is available */ /* Rename button: hidden by default, shown on hover/focus */
.outfit-name-submit { .outfit-name-submit {
margin-left: 0.5rem; 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) { /* Hide save button when rename is in editing state */
.outfit-name-submit { .outfit-header:has(outfit-rename-field[editing]) .outfit-save-form,
display: none; .outfit-header:has(outfit-rename-field[editing]) .outfit-save-button:disabled {
} display: none;
} }
.outfit-save-form { .outfit-save-form {

View file

@ -133,7 +133,14 @@ class OutfitsController < ApplicationController
def update def update
if @outfit.update(outfit_params) if @outfit.update(outfit_params)
respond_to do |format| 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 } format.json { render json: @outfit }
end end
else else

View file

@ -70,7 +70,7 @@ class WardrobeController < ApplicationController
# Build the outfit # Build the outfit
@outfit = Outfit.new( @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, pet_state: @pet_state,
alt_style: @alt_style, alt_style: @alt_style,
worn_items: items, worn_items: items,

View file

@ -5,7 +5,7 @@ module WardrobeHelper
def outfit_state_params(outfit = @outfit, except: []) def outfit_state_params(outfit = @outfit, except: [])
fields = [] 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(:species, @outfit.species_id) unless except.include?(:species)
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color) 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) fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)

View file

@ -267,7 +267,6 @@ class Outfit < ApplicationRecord
def wardrobe_params def wardrobe_params
params = { params = {
name: name,
color: color_id, color: color_id,
species: species_id, species: species_id,
pose: pose, pose: pose,

View 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

View file

@ -16,6 +16,7 @@
= javascript_include_tag "species-color-picker", async: true = javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "pose-picker", async: true = javascript_include_tag "pose-picker", async: true
= javascript_include_tag "tab-panel", async: true = javascript_include_tag "tab-panel", async: true
= javascript_include_tag "outfit-rename-field", async: true
= javascript_include_tag "wardrobe/show", async: true = javascript_include_tag "wardrobe/show", async: true
= csrf_meta_tags = csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
@ -63,12 +64,10 @@
= render "search_results" = render "search_results"
- else - else
.outfit-header .outfit-header
= form_with url: @wardrobe_path, method: :get, class: "outfit-name-form" do |f| - if @saved_outfit && !@is_owner
= outfit_state_params except: [:name] .outfit-name-static= @outfit.name
= f.text_field :name, value: @outfit.name, - else
class: "outfit-name-input", placeholder: "Untitled outfit", = render "outfit_rename_field"
"aria-label": "Outfit name"
= f.submit "Rename", name: nil, class: "outfit-name-submit"
= render "save_button" = render "save_button"
- if @outfit.worn_items.any? - if @outfit.worn_items.any?
.worn-items .worn-items

View file

@ -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 - 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 - "Save a copy" for non-owners, login prompt for unauthenticated users
- `beforeunload` warning for unsaved changes via MutationObserver - `beforeunload` warning for unsaved changes via MutationObserver
- Outfit name tracked as URL param (`name`) to survive navigation - 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.
- Not yet done: auto-save, renaming (the field is present but isn't consistently tracked)
**Alt Styles Support** (done) **Alt Styles Support** (done)
- `Outfit#visible_layers` handles alt styles - `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 - **Conflict management**: Auto zone conflict resolution, smart item restoration on unwear
- **Pet loading**: "Load my pet" by name, modeling integration - **Pet loading**: "Load my pet" by name, modeling integration
- **Search enhancements**: Inline syntax (`is:nc`, `fits:blue-acara`), advanced filter UI, autocomplete - **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 ### Phase 4: Migration & Rollout

View file

@ -351,11 +351,11 @@ RSpec.describe Outfit do
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end 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) outfit1 = Outfit.new(name: "Outfit A", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Outfit B", 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 end
it "returns false when poses differ" do it "returns false when poses differ" do