unit uNameManager;

interface

uses
  System.SysUtils, JS, Web;

type
  TGetOptionsEvent = reference to function(const AFieldName: string): TJSArray;
  TFocusTriggerEvent = reference to procedure(const ATriggerId: string);
  TAddAssignedNameEvent = reference to procedure(const ARowIndex: Integer; const ANewName: string);
  TRenameAssignedNameEvent = reference to procedure(const AOldName, ANewName: string);
  TDeleteAssignedNameEvent = reference to procedure(const AName: string);

  TNameManager = class
  private
    FCurrentField: string;
    FCurrentRowIndex: Integer;
    FCurrentTriggerId: string;
    FCurrentEditName: string;
    FGetOptions: TGetOptionsEvent;
    FFocusTrigger: TFocusTriggerEvent;
    FAddAssignedName: TAddAssignedNameEvent;
    FRenameAssignedName: TRenameAssignedNameEvent;
    FDeleteAssignedName: TDeleteAssignedNameEvent;
    procedure AddAnotherClick(Event: TJSEvent);
    procedure SaveClick(Event: TJSEvent);
    procedure NameInputKeyDown(Event: TJSEvent);
    procedure OffcanvasHidden(Event: TJSEvent);
    procedure ExistingEditClick(Event: TJSEvent);
    procedure ExistingDeleteClick(Event: TJSEvent);
    function GetOptionsForField(const AFieldName: string): TJSArray;
    procedure ResetInputArea;
    procedure ShowValidation(const AMessage: string);
    procedure RenderExistingList;
    procedure ShowAddArea;
    procedure HandleAddClick;
    procedure HandleHidden;
  public
    constructor Create(
      const AGetOptions: TGetOptionsEvent;
      const AFocusTrigger: TFocusTriggerEvent;
      const AAddAssignedName: TAddAssignedNameEvent;
      const ARenameAssignedName: TRenameAssignedNameEvent;
      const ADeleteAssignedName: TDeleteAssignedNameEvent
    );

    procedure BindControls;
    procedure OpenManager(const AFieldName: string; const ARowIndex: Integer; const ATriggerId: string);
    procedure CloseManager;
  end;

implementation

function HtmlEncode(const s: string): string;
begin
  Result := s;
  Result := StringReplace(Result, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [rfReplaceAll]);
  Result := StringReplace(Result, '"', '&quot;', [rfReplaceAll]);
  Result := StringReplace(Result, '''', '&#39;', [rfReplaceAll]);
end;

function GetElement(const AId: string): TJSHTMLElement;
begin
  Result := TJSHTMLElement(document.getElementById(AId));
end;

constructor TNameManager.Create(
  const AGetOptions: TGetOptionsEvent;
  const AFocusTrigger: TFocusTriggerEvent;
  const AAddAssignedName: TAddAssignedNameEvent;
  const ARenameAssignedName: TRenameAssignedNameEvent;
  const ADeleteAssignedName: TDeleteAssignedNameEvent
);
begin
  inherited Create;
  FGetOptions := AGetOptions;
  FFocusTrigger := AFocusTrigger;
  FAddAssignedName := AAddAssignedName;
  FRenameAssignedName := ARenameAssignedName;
  FDeleteAssignedName := ADeleteAssignedName;
  FCurrentField := '';
  FCurrentRowIndex := -1;
  FCurrentTriggerId := '';
  FCurrentEditName := '';
end;

function TNameManager.GetOptionsForField(const AFieldName: string): TJSArray;
begin
  if Assigned(FGetOptions) then
    Result := FGetOptions(AFieldName)
  else
    Result := nil;
end;

procedure TNameManager.ResetInputArea;
var
  addWrap: TJSHTMLElement;
  addButton: TJSHTMLButtonElement;
  inputEl: TJSHTMLInputElement;
  invalidEl: TJSHTMLElement;
begin
  addWrap := GetElement('nm_add_wrap');
  addButton := TJSHTMLButtonElement(document.getElementById('btn_nm_add_another'));
  inputEl := TJSHTMLInputElement(document.getElementById('nm_name_input'));
  invalidEl := GetElement('nm_name_invalid');

  FCurrentEditName := '';

  if Assigned(addWrap) then
    addWrap.classList.add('d-none');

  if Assigned(addButton) then
  begin
    addButton.disabled := False;
    addButton.classList.remove('disabled');
  end;

  if Assigned(inputEl) then
  begin
    inputEl.value := '';
    inputEl.classList.remove('is-invalid');
  end;

  if Assigned(invalidEl) then
  begin
    invalidEl.innerHTML := '';
    invalidEl.classList.add('d-none');
  end;
end;

procedure TNameManager.ShowValidation(const AMessage: string);
var
  inputEl: TJSHTMLInputElement;
  invalidEl: TJSHTMLElement;
begin
  inputEl := TJSHTMLInputElement(document.getElementById('nm_name_input'));
  invalidEl := GetElement('nm_name_invalid');

  if Assigned(inputEl) then
  begin
    inputEl.classList.add('is-invalid');
    inputEl.focus;
  end;

  if Assigned(invalidEl) then
  begin
    invalidEl.innerHTML := HtmlEncode(AMessage);
    invalidEl.classList.remove('d-none');
  end;
end;

procedure TNameManager.RenderExistingList;
var
  listEl: TJSHTMLElement;
  items: TJSArray;
  i: Integer;
  itemText: string;
  html: string;
  editButtons: TJSNodeList;
  deleteButtons: TJSNodeList;
  btnEl: TJSHTMLElement;
begin
  listEl := GetElement('nm_existing_list');
  if not Assigned(listEl) then
    Exit;

  items := GetOptionsForField(FCurrentField);

  html := '';
  if Assigned(items) then
  begin
    for i := 0 to items.length - 1 do
    begin
      itemText := string(items[i]);
      html := html +
        '<div class="list-group-item d-flex justify-content-between align-items-center">' +
          '<span>' + HtmlEncode(itemText) + '</span>' +
          '<span class="d-flex gap-2">' +
            '<button type="button" class="btn btn-link btn-sm p-0 text-body nm-edit-existing" data-name="' + HtmlEncode(itemText) + '">' +
              '<i class="fas fa-pencil-alt"></i>' +
            '</button>' +
            '<button type="button" class="btn btn-link btn-sm p-0 text-danger nm-delete-existing" data-name="' + HtmlEncode(itemText) + '">' +
              '<i class="fas fa-trash-alt"></i>' +
            '</button>' +
          '</span>' +
        '</div>';
    end;
  end;

  listEl.innerHTML := html;

  editButtons := listEl.querySelectorAll('.nm-edit-existing');
  for i := 0 to editButtons.length - 1 do
  begin
    btnEl := TJSHTMLElement(editButtons.item(i));
    btnEl.addEventListener('click', TJSEventHandler(@ExistingEditClick));
  end;

  deleteButtons := listEl.querySelectorAll('.nm-delete-existing');
  for i := 0 to deleteButtons.length - 1 do
  begin
    btnEl := TJSHTMLElement(deleteButtons.item(i));
    btnEl.addEventListener('click', TJSEventHandler(@ExistingDeleteClick));
  end;
end;

procedure TNameManager.ShowAddArea;
var
  addWrap: TJSHTMLElement;
  addButton: TJSHTMLButtonElement;
  inputEl: TJSHTMLInputElement;
begin
  addWrap := GetElement('nm_add_wrap');
  addButton := TJSHTMLButtonElement(document.getElementById('btn_nm_add_another'));
  inputEl := TJSHTMLInputElement(document.getElementById('nm_name_input'));

  if Assigned(addWrap) then
    addWrap.classList.remove('d-none');

  if Assigned(addButton) then
  begin
    addButton.disabled := True;
    addButton.classList.add('disabled');
  end;

  if Assigned(inputEl) then
    inputEl.focus;
end;

procedure TNameManager.HandleAddClick;
var
  inputEl: TJSHTMLInputElement;
  invalidEl: TJSHTMLElement;
  newName: string;
begin
  inputEl := TJSHTMLInputElement(document.getElementById('nm_name_input'));
  invalidEl := GetElement('nm_name_invalid');

  if not Assigned(inputEl) then
    Exit;

  newName := Trim(string(inputEl.value));

  inputEl.classList.remove('is-invalid');
  if Assigned(invalidEl) then
  begin
    invalidEl.innerHTML := '';
    invalidEl.classList.add('d-none');
  end;

  if newName = '' then
  begin
    ShowValidation('Enter a name.');
    Exit;
  end;

  if not SameText(FCurrentField, 'assignedTo') then
    Exit;

  if FCurrentEditName <> '' then
  begin
    if Assigned(FRenameAssignedName) then
      FRenameAssignedName(FCurrentEditName, newName);
  end
  else
  begin
    if Assigned(FAddAssignedName) then
      FAddAssignedName(FCurrentRowIndex, newName);
  end;

  CloseManager;
end;

procedure TNameManager.BindControls;
var
  el: TJSHTMLElement;
begin
  el := GetElement('btn_nm_add_another');
  if Assigned(el) then
  begin
    if string(el.getAttribute('data-nm-bound-add-another')) <> '1' then
    begin
      el.addEventListener('click', TJSEventHandler(@AddAnotherClick));
      el.setAttribute('data-nm-bound-add-another', '1');
    end;
  end;

  el := GetElement('btn_nm_save');
  if Assigned(el) then
  begin
    if string(el.getAttribute('data-nm-bound-save')) <> '1' then
    begin
      el.addEventListener('click', TJSEventHandler(@SaveClick));
      el.setAttribute('data-nm-bound-save', '1');
    end;
  end;

  el := GetElement('nm_name_input');
  if Assigned(el) then
  begin
    if string(el.getAttribute('data-nm-bound-keydown')) <> '1' then
    begin
      el.addEventListener('keydown', TJSEventHandler(@NameInputKeyDown));
      el.setAttribute('data-nm-bound-keydown', '1');
    end;
  end;

  el := GetElement('offcanvasNameManager');
  if Assigned(el) then
  begin
    if string(el.getAttribute('data-nm-bound-hidden')) <> '1' then
    begin
      el.addEventListener('hidden.bs.offcanvas', TJSEventHandler(@OffcanvasHidden));
      el.setAttribute('data-nm-bound-hidden', '1');
    end;
  end;
end;

procedure TNameManager.AddAnotherClick(Event: TJSEvent);
begin
  Event.preventDefault;
  ShowAddArea;
end;

procedure TNameManager.SaveClick(Event: TJSEvent);
begin
  Event.preventDefault;
  HandleAddClick;
end;

procedure TNameManager.NameInputKeyDown(Event: TJSEvent);
var
  keyEvent: TJSKeyboardEvent;
begin
  keyEvent := TJSKeyboardEvent(Event);
  if keyEvent.key = 'Enter' then
  begin
    keyEvent.preventDefault;
    HandleAddClick;
  end;
end;

procedure TNameManager.OffcanvasHidden(Event: TJSEvent);
begin
  HandleHidden;
end;

procedure TNameManager.ExistingEditClick(Event: TJSEvent);
var
  el: TJSHTMLElement;
  inputEl: TJSHTMLInputElement;
begin
  Event.preventDefault;
  Event.stopPropagation;

  if not SameText(FCurrentField, 'assignedTo') then
    Exit;

  el := TJSHTMLElement(Event.currentTarget);
  FCurrentEditName := string(el.getAttribute('data-name'));

  ShowAddArea;

  inputEl := TJSHTMLInputElement(document.getElementById('nm_name_input'));
  if Assigned(inputEl) then
  begin
    inputEl.value := FCurrentEditName;
    inputEl.focus;
    asm
      inputEl.select();
    end;
  end;
end;

procedure TNameManager.ExistingDeleteClick(Event: TJSEvent);
var
  el: TJSHTMLElement;
  nameToDelete: string;
begin
  Event.preventDefault;
  Event.stopPropagation;

  if not SameText(FCurrentField, 'assignedTo') then
    Exit;

  el := TJSHTMLElement(Event.currentTarget);
  nameToDelete := string(el.getAttribute('data-name'));
  if nameToDelete = '' then
    Exit;

  if Assigned(FDeleteAssignedName) then
    FDeleteAssignedName(nameToDelete);

  CloseManager;
end;

procedure TNameManager.OpenManager(const AFieldName: string; const ARowIndex: Integer; const ATriggerId: string);
var
  titleEl: TJSHTMLElement;
begin
  if not SameText(AFieldName, 'assignedTo') then
    Exit;

  FCurrentField := AFieldName;
  FCurrentRowIndex := ARowIndex;
  FCurrentTriggerId := ATriggerId;
  FCurrentEditName := '';

  titleEl := GetElement('nm_title');
  if Assigned(titleEl) then
    titleEl.innerHTML := 'Manage Assigned To';

  ResetInputArea;
  RenderExistingList;

  asm
    var el = document.getElementById('offcanvasNameManager');
    if (el) {
      var oc = bootstrap.Offcanvas.getOrCreateInstance(el);
      oc.show();
    }
  end;
end;

procedure TNameManager.CloseManager;
begin
  asm
    var el = document.getElementById('offcanvasNameManager');
    if (el) {
      var oc = bootstrap.Offcanvas.getOrCreateInstance(el);
      oc.hide();
    }
  end;
end;

procedure TNameManager.HandleHidden;
begin
  ResetInputArea;

  if Assigned(FFocusTrigger) and (FCurrentTriggerId <> '') then
    FFocusTrigger(FCurrentTriggerId);

  FCurrentField := '';
  FCurrentRowIndex := -1;
  FCurrentTriggerId := '';
  FCurrentEditName := '';
end;

end.
