[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 {
|
.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,
|
||||||
|
.outfit-header:has(outfit-rename-field[editing]) .outfit-save-button:disabled {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.outfit-save-form {
|
.outfit-save-form {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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 "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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue