unit View.TaskItems;

interface

uses
  System.SysUtils, System.Classes,
  JS, Web, WEBLib.Graphics, WEBLib.Controls, WEBLib.Forms, WEBLib.Dialogs,
  WEBLib.ExtCtrls, uNameOffCanvas, uDropdownHelpers,
  XData.Web.Client, XData.Web.Dataset,
  Utils, Data.DB, XData.Web.JsonDataset, Vcl.Controls, Vcl.StdCtrls,
  WEBLib.StdCtrls;

type
  TFTaskItems = class(TWebForm)
    xdwcTasks: TXDataWebClient;
    xdwdsTasks: TXDataWebDataSet;
    xdwdsTaskstaskId: TStringField;
    xdwdsTasksapplication: TStringField;
    xdwdsTasksversion: TStringField;
    xdwdsTaskstaskDate: TStringField;
    xdwdsTasksreportedBy: TStringField;
    xdwdsTasksassignedTo: TStringField;
    xdwdsTasksstatus: TStringField;
    xdwdsTasksstatusDate: TStringField;
    xdwdsTasksformSection: TStringField;
    xdwdsTasksissue: TStringField;
    xdwdsTasksnotes: TStringField;
    btnReload: TWebButton;
    btnAddRow: TWebButton;
    xdwdsTasksitemNum: TIntegerField;
    xdwdsTaskstaskItemId: TIntegerField;
    btnDeleteRow: TWebButton;
    btnInsertRow: TWebButton;
    btnTextColor: TWebButton;
    btnFmtBold: TWebButton;
    btnFmtItalic: TWebButton;
    btnFmtUnderline: TWebButton;
    [async] procedure btnAddRowClick(Sender: TObject);
    procedure btnReloadClick(Sender: TObject);
    procedure WebFormCreate(Sender: TObject);
    [async] procedure btnDeleteRowClick(Sender: TObject);
    [async] procedure btnInsertRowClick(Sender: TObject);
    procedure btnTextColorClick(Sender: TObject);
    procedure btnFmtBoldClick(Sender: TObject);
    procedure btnFmtItalicClick(Sender: TObject);
    procedure btnFmtUnderlineClick(Sender: TObject);
  private
    FTaskId: string;
    FApplicationOptions: TJSArray;
    FReportedByOptions: TJSArray;
    FAssignedToOptions: TJSArray;
    FStatusOptions: TJSArray;
    FPendingFocusTaskItemId: integer;
    FPendingFocusField: string;
    FSelectedTaskItemId: Integer;
    FSelectedTaskId: Integer;
    FPendingScrollTop: Integer;
    FPendingScrollLeft: Integer;
    FPendingFocusItemNum: Integer;
    FPendingFocusTaskField: string;
    FActiveRichEditorId: string;
    FLastSelectionRange: JSValue;
    FNameManager: TNameManager;
    [async] procedure LoadTasks(const ATaskId: string);
    procedure RenderTable;
    procedure BindTableEditors;
    procedure EditorInput(Event: TJSEvent);
    procedure SelectChange(Event: TJSEvent);
    procedure EnableColumnResize;
    procedure EnableAutoGrowTextAreas;
    procedure GotoRowIndex(AIndex: Integer);
    function HtmlEncode(const s: string): string;
    procedure SetTotalRowsLabel(ARowCount: Integer);
    procedure SetTaskLabel(const ATitle: string);
    [async] procedure SaveField(AIndex: Integer; const AFieldName: string);
    procedure EditorBlur(Event: TJSEvent);
    [async] function AddTaskRowAtBottom: Boolean;
    [async] function InsertTaskRowBelowSelection: Boolean;
    [async] function AddTaskRowWithInsertAfter(const insertAfterItemNum, newItemNum: Integer): Boolean;
    [async] function DeleteTaskRow: Boolean;
    procedure DropdownItemClick(Event: TJSEvent);
    procedure DropdownEditClick(Event: TJSEvent);
    [async] procedure MoveTaskRow(AIndex: Integer; const newItemNum: Integer);
    procedure ApplyPendingFocus;
    procedure RowClick(Event: TJSEvent);
    procedure ApplySelectedRowState;
    procedure ApplyPendingDeleteFocus;
    procedure EditorKeyDown(Event: TJSEvent);
    procedure CaptureTableScroll;
    procedure RestoreTableScroll;
    [async] function AddAssignedName(const AName: string): TJSArray;
    [async] function RenameAssignedName(const AOldName, ANewName: string): TJSArray;
    [async] function DeleteAssignedName(const AName: string): TJSArray;
    [async] function AddReportedName(const AName: string): TJSArray;
    [async] function RenameReportedName(const AOldName, ANewName: string): TJSArray;
    [async] function DeleteReportedName(const AName: string): TJSArray;
    [async] function AddApplicationName(const AName: string): TJSArray;
    [async] function RenameApplicationName(const AOldName, ANewName: string): TJSArray;
    [async] function DeleteApplicationName(const AName: string): TJSArray;
    [async] procedure HandleAddManagedName(const AFieldName: string; const ARowIndex: Integer; const ANewName: string);
    [async] procedure HandleRenameManagedName(const AFieldName, AOldName, ANewName: string);
    [async] procedure HandleDeleteManagedName(const AFieldName, AName: string);
    procedure ColorPickerInput(Event: TJSEvent);
    procedure RichEditorFocus(Event: TJSEvent);
    procedure RichEditorSelectionChange(Event: TJSEvent);
    procedure ApplyRichTextCommand(const commandName: string; const commandValue: string = '');
    procedure UpdateToolbarState;
    procedure SetToolbarButtonActive(const buttonId: string; const isActive: Boolean);
  public

  end;

var
  FTaskItems: TFTaskItems;

implementation

uses
  ConnectionModule;

{$R *.dfm}

procedure TFTaskItems.WebFormCreate(Sender: TObject);
begin
  console.log('TFTaskItems.WebFormCreate fired');

  FTaskId := Application.Parameters.Values['task_id'];
  if FTaskId = '' then
    FTaskId := DMConnection.currentTaskId;

  FApplicationOptions := TJSArray.new;
  FReportedByOptions := TJSArray.new;
  FAssignedToOptions := TJSArray.new;
  FStatusOptions := TJSArray.new;
  FSelectedTaskItemId := 0;
  FSelectedTaskId := 0;

  FNameManager := TNameManager.Create(
    function(const AFieldName: string): TJSArray
    begin
      Result := uDropdownHelpers.GetOptionsForField(AFieldName, FApplicationOptions, FReportedByOptions, FAssignedToOptions);
    end,
    procedure(const ATriggerId: string)
    begin
      uDropdownHelpers.FocusTrigger(ATriggerId);
    end,
    procedure(const AFieldName: string; const ARowIndex: Integer; const ANewName: string)
    begin
      HandleAddManagedName(AFieldName, ARowIndex, ANewName);
    end,
    procedure(const AFieldName, AOldName, ANewName: string)
    begin
      HandleRenameManagedName(AFieldName, AOldName, ANewName);
    end,
    procedure(const AFieldName, AName: string)
    begin
      HandleDeleteManagedName(AFieldName, AName);
    end
  );

  FNameManager.BindControls;

  if FTaskId = '' then
  begin
    Utils.ShowErrorModal('Missing task_id. Please reopen from emt3.');
    Exit;
  end;

  btnInsertRow.Enabled := False;
  btnAddRow.Enabled := False;
  btnDeleteRow.Enabled := False;

  if not DMConnection.ApiConnection.Connected then
  begin
    DMConnection.ApiConnection.Open(
      procedure
      begin
        LoadTasks(FTaskId);
      end
    );
  end
  else
    LoadTasks(FTaskId);
end;


function TFTaskItems.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;


procedure TFTaskItems.GotoRowIndex(AIndex: Integer);
var
  i: Integer;
begin
  if (AIndex < 0) or (not xdwdsTasks.Active) then
    Exit;

  xdwdsTasks.First;
  i := 0;
  while (i < AIndex) and (not xdwdsTasks.Eof) do
  begin
    xdwdsTasks.Next;
    Inc(i);
  end;
end;


procedure TFTaskItems.EditorInput(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName: string;
  newVal: string;
begin
  if not xdwdsTasks.Active then
    Exit;

  el := TJSHTMLElement(Event.target);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  GotoRowIndex(idx);
  if xdwdsTasks.Eof then
    Exit;

  if SameText(fieldName, 'issue') or SameText(fieldName, 'notes') then
    newVal := string(el.innerHTML)
  else
    newVal := string(TJSObject(el)['value']);

  console.log('EditorInput: idx=' + IntToStr(idx) + ' field=' + fieldName + ' val=' + newVal);

  xdwdsTasks.Edit;
  if SameText(fieldName, 'itemNum') then
    xdwdsTasks.FieldByName(fieldName).AsInteger := StrToIntDef(newVal, 0)
  else
    xdwdsTasks.FieldByName(fieldName).AsString := newVal;
  xdwdsTasks.Post;

  el.setAttribute('data-unsaved-data', '1');
end;


procedure TFTaskItems.SelectChange(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName: string;
  sel: TJSHTMLSelectElement;
begin
  if not xdwdsTasks.Active then
    Exit;

  el := TJSHTMLElement(Event.target);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  GotoRowIndex(idx);
  if xdwdsTasks.Eof then
    Exit;

  sel := TJSHTMLSelectElement(el);

  console.log('SelectChange: idx=' + IntToStr(idx) + ' field=' + fieldName + ' val=' + string(sel.value));

  xdwdsTasks.Edit;
  xdwdsTasks.FieldByName(fieldName).AsString := string(sel.value);
  xdwdsTasks.Post;

  el.setAttribute('data-unsaved-data', '1');
  SaveField(idx, fieldName);
end;


procedure TFTaskItems.BindTableEditors;
var
  nodes: TJSNodeList;
  i: Integer;
  el: TJSHTMLElement;
begin
  console.log('BindTableEditors: wiring handlers...');

  nodes := document.querySelectorAll('.task-editor');
  console.log('BindTableEditors: task-editor count=' + IntToStr(nodes.length));
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('input', TJSEventHandler(@EditorInput));
    el.addEventListener('blur', TJSEventHandler(@EditorBlur));
    el.addEventListener('keydown', TJSEventHandler(@EditorKeyDown));

    if el.classList.contains('task-rich-editor') then
    begin
      el.addEventListener('focus', TJSEventHandler(@RichEditorFocus));
      el.addEventListener('mouseup', TJSEventHandler(@RichEditorSelectionChange));
      el.addEventListener('keyup', TJSEventHandler(@RichEditorSelectionChange));
    end;
  end;

  nodes := document.querySelectorAll('.task-select');
  console.log('BindTableEditors: task-select count=' + IntToStr(nodes.length));
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('change', TJSEventHandler(@SelectChange));
  end;

  nodes := document.querySelectorAll('.task-dd-item');
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@DropdownItemClick));
  end;

  nodes := document.querySelectorAll('.task-dd-edit-btn');
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@DropdownEditClick));
  end;

  nodes := document.querySelectorAll('.task-row-selectable');
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@RowClick));
  end;

  el := TJSHTMLElement(document.getElementById('issue_notes_text_color'));
  if Assigned(el) then
    el.addEventListener('input', TJSEventHandler(@ColorPickerInput));

  ApplySelectedRowState;
end;


procedure TFTaskItems.EditorKeyDown(Event: TJSEvent);
var
  el: TJSHTMLElement;
  fieldName: string;
  newItemNum: Integer;
  idx: Integer;
begin
  el := TJSHTMLElement(Event.target);
  fieldName := string(el.getAttribute('data-field'));

  if not SameText(fieldName, 'itemNum') then
    Exit;

  if TJSKeyboardEvent(Event).key <> 'Enter' then
    Exit;

  Event.preventDefault;

  idx := StrToIntDef(string(el.getAttribute('data-idx')), -1);
  if idx < 0 then
    Exit;

  newItemNum := StrToIntDef(string(TJSObject(el)['value']), 0);

  el.removeAttribute('data-unsaved-data');

  MoveTaskRow(idx, newItemNum);
end;


[async] procedure TFTaskItems.btnAddRowClick(Sender: TObject);
begin
  Utils.ShowSpinner('spinner');
  try
    if await(AddTaskRowAtBottom) then
    begin
      CaptureTableScroll;
      LoadTasks(FTaskId);
    end;
  finally
    Utils.HideSpinner('spinner');
  end;
end;

[async] procedure TFTaskItems.btnDeleteRowClick(Sender: TObject);
var
  deletedItemNum: Integer;
begin
  if FSelectedTaskItemId <= 0 then
    Exit;

  deletedItemNum := 0;

  if xdwdsTasks.Active then
  begin
    xdwdsTasks.First;
    while not xdwdsTasks.Eof do
    begin
      if xdwdsTaskstaskItemId.AsInteger = FSelectedTaskItemId then
      begin
        deletedItemNum := xdwdsTasksitemNum.AsInteger;
        Break;
      end;
      xdwdsTasks.Next;
    end;
  end;

  Utils.ShowSpinner('spinner');
  try
    if await(DeleteTaskRow) then
    begin
      FSelectedTaskItemId := 0;
      FSelectedTaskId := 0;
      btnInsertRow.Enabled := False;
      btnDeleteRow.Enabled := False;

      FPendingFocusItemNum := deletedItemNum;
      FPendingFocusTaskField := 'application';

      CaptureTableScroll;
      LoadTasks(FTaskId);
    end;
  finally
    Utils.HideSpinner('spinner');
  end;
end;

procedure TFTaskItems.btnFmtBoldClick(Sender: TObject);
begin
  ApplyRichTextCommand('bold');
end;

procedure TFTaskItems.btnFmtItalicClick(Sender: TObject);
begin
  ApplyRichTextCommand('italic');
end;

procedure TFTaskItems.btnFmtUnderlineClick(Sender: TObject);
begin
  ApplyRichTextCommand('underline');
end;

[async] procedure TFTaskItems.btnInsertRowClick(Sender: TObject);
begin
  Utils.ShowSpinner('spinner');
  try
    if await(InsertTaskRowBelowSelection) then
    begin
      CaptureTableScroll;
      LoadTasks(FTaskId);
    end;
  finally
    Utils.HideSpinner('spinner');
  end;
end;

procedure TFTaskItems.RowClick(Event: TJSEvent);
var
  rowEl: TJSHTMLElement;
  taskItemIdStr: string;
  taskIdStr: string;
begin
  rowEl := TJSHTMLElement(Event.currentTarget);
  if not Assigned(rowEl) then
    Exit;

  taskItemIdStr := string(rowEl.getAttribute('data-task-item-id'));
  taskIdStr := string(rowEl.getAttribute('data-task-id'));

  FSelectedTaskItemId := StrToIntDef(taskItemIdStr, 0);
  FSelectedTaskId := StrToIntDef(taskIdStr, 0);

  btnInsertRow.Enabled := FSelectedTaskItemId > 0;
  btnDeleteRow.Enabled := FSelectedTaskItemId > 0;
  ApplySelectedRowState;
end;

procedure TFTaskItems.ApplySelectedRowState;
begin
  asm
    const selectedTaskItemId = this.FSelectedTaskItemId;
    document.querySelectorAll('.task-row-selectable').forEach(function(row){
      const rowTaskItemId = parseInt(row.getAttribute('data-task-item-id') || '0', 10);
      if ((selectedTaskItemId > 0) && (rowTaskItemId === selectedTaskItemId))
        row.classList.add('table-active');
      else
        row.classList.remove('table-active');
    });
  end;
end;


[async] function TFTaskItems.AddTaskRowAtBottom: Boolean;
var
  maxItemNum: Integer;
begin
  maxItemNum := 0;

  if xdwdsTasks.Active then
  begin
    xdwdsTasks.First;
    while not xdwdsTasks.Eof do
    begin
      if xdwdsTasksitemNum.AsInteger > maxItemNum then
        maxItemNum := xdwdsTasksitemNum.AsInteger;

      xdwdsTasks.Next;
    end;
  end;

  Result := await(AddTaskRowWithInsertAfter(0, maxItemNum + 1));
end;

[async] function TFTaskItems.InsertTaskRowBelowSelection: Boolean;
var
  insertAfterItemNum: Integer;
begin
  Result := False;

  if FSelectedTaskItemId <= 0 then
  begin
    Utils.ShowErrorModal('Select a row first.');
    Exit;
  end;

  insertAfterItemNum := 0;

  if xdwdsTasks.Active then
  begin
    xdwdsTasks.First;
    while not xdwdsTasks.Eof do
    begin
      if xdwdsTaskstaskItemId.AsInteger = FSelectedTaskItemId then
      begin
        insertAfterItemNum := xdwdsTasksitemNum.AsInteger;
        Break;
      end;

      xdwdsTasks.Next;
    end;
  end;

  if insertAfterItemNum <= 0 then
  begin
    Utils.ShowErrorModal('Unable to find selected row.');
    Exit;
  end;

  Result := await(AddTaskRowWithInsertAfter(insertAfterItemNum, insertAfterItemNum + 1));
end;

[async] function TFTaskItems.AddTaskRowWithInsertAfter(const insertAfterItemNum, newItemNum: Integer): Boolean;
var
  response: TXDataClientResponse;
begin
  Result := False;

  if FTaskId = '' then
  begin
    Utils.ShowErrorModal('Missing task_id. Please reopen from emt3.');
    Exit;
  end;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.AddTaskRow', [FTaskId, insertAfterItemNum]
    ));
    console.log('AddTaskRow response=' + string(TJSJSON.stringify(response.Result)));

    FPendingFocusItemNum := newItemNum;
    FPendingFocusTaskField := 'application';

    Result := True;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('AddTaskRow ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
    end;
  end;
end;


procedure TFTaskItems.btnReloadClick(Sender: TObject);
begin
  if FTaskId = '' then
  begin
    Utils.ShowErrorModal('Missing Task Id. Update url params or resend from emT3.');
    Exit;
  end;

  LoadTasks(FTaskId);
end;


procedure TFTaskItems.btnTextColorClick(Sender: TObject);
var
  colorEl: TJSHTMLElement;
begin
  colorEl := TJSHTMLElement(document.getElementById('issue_notes_text_color'));
  colorEl.click;
end;

procedure TFTaskItems.EnableAutoGrowTextAreas;
begin
  asm
    (function(){
      const host = document.getElementById('tasks_table_host');
      if(!host) return;

      host.querySelectorAll('.task-rich-editor').forEach(function(editor){
        const fit = function() {
          editor.style.height = 'auto';
          editor.style.height = editor.scrollHeight + 'px';
        };

        fit();
        editor.addEventListener('input', fit);
      });
    })();
  end;
end;


procedure TFTaskItems.SetTotalRowsLabel(ARowCount: Integer);
var
  el: TJSHTMLElement;
begin
  el := TJSHTMLElement(document.getElementById('lbl_total_rows'));
  if Assigned(el) then
    el.innerText := 'Total Rows: ' + IntToStr(ARowCount);
end;


procedure TFTaskItems.SetTaskLabel(const ATitle: string);
var
  el: TJSHTMLElement;
begin
  el := TJSHTMLElement(document.getElementById('lbl_project_name'));
  if Assigned(el) then
    el.innerText := ATitle;
end;


[async] procedure TFTaskItems.LoadTasks(const ATaskId: string);
var
  response: TXDataClientResponse;
  resultObj, taskObj: TJSObject;
  itemsArray: TJSArray;
  titleText: string;
  rowCount: Integer;
begin
  console.log('ITaskItemService.GetTaskItems called with task_id: ' + ATaskId);
  console.log('Load Tasks Fired');
  Utils.ShowSpinner('spinner');
  try
    try
      response := await(xdwcTasks.RawInvokeAsync(
        'ITaskItemService.GetTaskItems', [ATaskId]
      ));
    except
      on E: EXDataClientRequestException do
      begin
        Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
        Exit;
      end;
    end;

    if not Assigned(response.Result) then
      Exit;

    resultObj := TJSObject(response.Result);
    taskObj := TJSObject(resultObj['task']);

    if Assigned(taskObj) then
      titleText := string(taskObj['title'])
    else
      titleText := 'Task - ' + ATaskId;

    SetTaskLabel(titleText);

    rowCount := StrToIntDef(string(resultObj['count']), 0);
    SetTotalRowsLabel(rowCount);

    FApplicationOptions := ExtractOptionNames(TJSArray(resultObj['applicationOptions']));
    FReportedByOptions := ExtractOptionNames(TJSArray(resultObj['reportedByOptions']));
    FAssignedToOptions := ExtractOptionNames(TJSArray(resultObj['assignedToOptions']));
    FStatusOptions := ExtractCodeDescs(TJSArray(resultObj['statusOptions']));

    itemsArray := TJSArray(resultObj['items']);
    if not Assigned(itemsArray) then
      itemsArray := TJSArray.new;

    xdwdsTasks.Close;
    xdwdsTasks.SetJsonData(itemsArray);
    xdwdsTasks.Open;

    btnAddRow.Enabled := True;
    btnInsertRow.Enabled := FSelectedTaskItemId > 0;
    btnDeleteRow.Enabled := FSelectedTaskItemId > 0;

    RenderTable;
  finally
    Utils.HideSpinner('spinner');
  end;
end;


procedure TFTaskItems.RenderTable;
var
  host: TJSHTMLElement;
  html: string;
  rowIdx: Integer;

  function Th(const s: string): string;
  begin
    Result := '<th scope="col">' + s + '</th>';
  end;

  function ThBlank: string;
  begin
    Result := '<th scope="col">&nbsp;</th>';
  end;

  function TdNowrap(const s: string): string;
  begin
    Result := '<td class="align-top nowrap-cell">' + s + '</td>';
  end;

  function TdWrap(const s: string): string;
  begin
    Result := '<td class="align-top wrap-cell">' + s + '</td>';
  end;

  function TextInput(const FieldName, Value: string; const AIdx: Integer): string;
  var
    w: string;
  begin
    w := '';

    Result :=
      '<input class="form-control form-control-sm cell-input task-editor w-100" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'value="' + HtmlEncode(Value) + '"' + w + '>';
  end;

  function ItemNumInput(const Value: Integer; const AIdx: Integer): string;
  begin
    Result :=
      '<input type="number" min="1" class="form-control form-control-sm task-editor text-center px-1" ' +
        'style="width: 30px;" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="itemNum" ' +
        'value="' + IntToStr(Value) + '">';
  end;

  function DateInput(const FieldName, Value: string; const AIdx: Integer): string;
  var
    w: string;
    dateValue: string;
  begin
    w := '';

    dateValue := Utils.NormalizeDateValue(Value);

    Result :=
      '<input type="date" class="form-control form-control-sm cell-input task-editor w-100" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'value="' + HtmlEncode(dateValue) + '"' + w + '>';
  end;

  function SelectList(const FieldName, Current: string; const AIdx: Integer; const Items: TJSArray; const AllowEdit: Boolean): string;
  var
    i: Integer;
    itemText: string;
    triggerId: string;
    buttonClass: string;
  begin
    triggerId := 'task_dd_' + FieldName + '_' + IntToStr(AIdx);

    buttonClass := 'btn btn-sm border w-100 d-flex justify-content-between align-items-center text-start task-dd-toggle ';
    if SameText(FieldName, 'status') then
      buttonClass := buttonClass + GetTaskStatusClass(Current)
    else
      buttonClass := buttonClass + 'btn-light';

    Result :=
      '<div class="dropdown w-100">' +
        '<button id="' + triggerId + '" class="' + buttonClass + '" ' +
          'type="button" data-bs-toggle="dropdown" aria-expanded="false" ' +
          'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '">' +
          '<span class="task-dd-label text-truncate">' + HtmlEncode(Current) + '</span>' +
          '<span class="dropdown-toggle dropdown-toggle-split border-0 ms-2"></span>' +
        '</button>' +
        '<div class="dropdown-menu w-100 p-0 overflow-hidden">';

    Result := Result +
      '<button type="button" class="dropdown-item task-dd-item" ' +
        'data-idx="' + IntToStr(AIdx) + '" ' +
        'data-field="' + FieldName + '" ' +
        'data-value=""></button>';

    if Assigned(Items) then
      for i := 0 to Items.length - 1 do
      begin
        itemText := string(Items[i]);
        Result := Result +
          '<button type="button" class="dropdown-item task-dd-item" ' +
            'data-idx="' + IntToStr(AIdx) + '" ' +
            'data-field="' + FieldName + '" ' +
            'data-value="' + HtmlEncode(itemText) + '" ' +
            'data-trigger-id="' + triggerId + '">' + HtmlEncode(itemText) + '</button>';
      end;

    if AllowEdit then
      Result := Result +
        '<div class="dropdown-divider my-1"></div>' +
        '<div class="px-2 py-1 text-end">' +
          '<button type="button" class="btn btn-link btn-sm p-0 text-body task-dd-edit-btn" ' +
            'data-idx="' + IntToStr(AIdx) + '" ' +
            'data-field="' + FieldName + '" ' +
            'data-trigger-id="' + triggerId + '">' +
            '<i class="fas fa-pencil-alt"></i>' +
          '</button>' +
        '</div>';

    Result := Result +
        '</div>' +
      '</div>';
  end;

  function RichTextEditor(const FieldName, Value: string; const AIdx: Integer): string;
  var
    editorId: string;
  begin
    editorId := 'rtf_' + FieldName + '_' + IntToStr(AIdx);

    Result :=
      '<div id="' + editorId + '" ' +
        'class="form-control form-control-sm task-rich-editor task-editor w-100" ' +
        'contenteditable="true" ' +
        'data-idx="' + IntToStr(AIdx) + '" ' +
        'data-field="' + FieldName + '" ' +
        'style="min-height:31px; overflow:hidden;">' + Value + '</div>';
  end;

begin
  host := TJSHTMLElement(document.getElementById('tasks_table_host'));
  if not Assigned(host) then
    Exit;

  html :=
    '<div class="tasks-vscroll">' +
      '<div class="tasks-hscroll">' +
        '<table class="table table-sm table-bordered align-middle mb-0 tasks-table" style="min-width: 1700px; table-layout: fixed;">' +
          '<colgroup>' +
            '<col style="width:40px">' +   // Item Num
            '<col style="width:200px">' +  // App
            '<col style="width:90px">' +   // Version
            '<col style="width:140px">' +  // Date
            '<col style="width:120px">' +  // Reported
            '<col style="width:120px">' +  // Assigned
            '<col style="width:195px">' +  // Status
            '<col style="width:140px">' +  // Status Date
            '<col style="width:160px">' +  // Form
            '<col style="width:520px">' +  // Issue
            '<col style="width:520px">' +  // Notes
          '</colgroup>' +
          '<thead><tr>' +
            ThBlank +
            Th('App') +
            Th('Version') +
            Th('Date') +
            Th('Reported') +
            Th('Assigned') +
            Th('Status') +
            Th('Status Date') +
            Th('Form') +
            Th('Issue/To-Do') +
            Th('Notes') +
          '</tr></thead><tbody>';

  rowIdx := 0;
  xdwdsTasks.First;
  while not xdwdsTasks.Eof do
  begin
    html := html +
      '<tr class="task-row-selectable" data-task-item-id="' + IntToStr(xdwdsTaskstaskItemId.AsInteger) + '" data-task-id="' + xdwdsTaskstaskId.AsString + '" data-item-num="' + IntToStr(xdwdsTasksitemNum.AsInteger) + '">' +
        TdNowrap(ItemNumInput(xdwdsTasksitemNum.AsInteger, rowIdx)) +
        TdNowrap(SelectList('application', xdwdsTasksapplication.AsString, rowIdx, FApplicationOptions, True)) +
        TdNowrap(TextInput('version', xdwdsTasksversion.AsString, rowIdx)) +
        TdNowrap(DateInput('taskDate', xdwdsTaskstaskDate.AsString, rowIdx)) +
        TdNowrap(SelectList('reportedBy', xdwdsTasksreportedBy.AsString, rowIdx, FReportedByOptions, True)) +
        TdNowrap(SelectList('assignedTo', xdwdsTasksassignedTo.AsString, rowIdx, FAssignedToOptions, True)) +
        TdNowrap(SelectList('status', xdwdsTasksstatus.AsString, rowIdx, FStatusOptions, False)) +
        TdNowrap(DateInput('statusDate', xdwdsTasksstatusDate.AsString, rowIdx)) +
        TdNowrap(TextInput('formSection', xdwdsTasksformSection.AsString, rowIdx)) +
        TdWrap(RichTextEditor('issue', xdwdsTasksissue.AsString, rowIdx)) +
        TdWrap(RichTextEditor('notes', xdwdsTasksnotes.AsString, rowIdx)) +
      '</tr>';

    xdwdsTasks.Next;
    Inc(rowIdx);
  end;

  html := html + '</tbody></table></div></div>';
  host.innerHTML := html;

  BindTableEditors;
  EnableAutoGrowTextAreas;
  EnableColumnResize;
  RestoreTableScroll;
  ApplyPendingFocus;
  ApplyPendingDeleteFocus;
end;


procedure TFTaskItems.EnableColumnResize;
begin
  asm
    (function(){
      const host = document.getElementById('tasks_table_host');
      if(!host) return;

      const table = host.querySelector('table');
      if(!table) return;

      const ths = table.querySelectorAll('thead th');
      const cols = table.querySelectorAll('colgroup col');

      ths.forEach((th, index) => {
        th.classList.add('th-resize');
        if (th.querySelector('.th-resize-handle')) return;

        const handle = document.createElement('div');
        handle.className = 'th-resize-handle';
        th.appendChild(handle);

        handle.addEventListener('mousedown', function(e){
          e.preventDefault();
          e.stopPropagation();

          const startX = e.clientX;
          const startW = th.getBoundingClientRect().width;

          function onMove(ev){
            const w = Math.max(20, startW + (ev.clientX - startX));

            th.style.width = w + 'px';
            th.style.minWidth = '0';

            if (cols && cols[index]) {
              cols[index].style.width = w + 'px';
            }
          }

          function onUp(){
            document.removeEventListener('mousemove', onMove);
            document.removeEventListener('mouseup', onUp);
          }

          document.addEventListener('mousemove', onMove);
          document.addEventListener('mouseup', onUp);
        });
      });
    })();
  end;
end;


procedure TFTaskItems.EditorBlur(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName: string;
  newItemNum: Integer;
begin
  el := TJSHTMLElement(Event.target);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));

  if string(el.getAttribute('data-unsaved-data')) <> '1' then
  begin
    console.log('EditorBlur: skip (not unsaved) idx=' + idxStr + ' field=' + fieldName);
    Exit;
  end;

  el.removeAttribute('data-unsaved-data');

  idx := StrToIntDef(idxStr, -1);
  if idx < 0 then
    Exit;

  if SameText(fieldName, 'itemNum') then
  begin
    newItemNum := StrToIntDef(string(TJSObject(el)['value']), 0);
    console.log('EditorBlur: MOVE idx=' + IntToStr(idx) + ' newItemNum=' + IntToStr(newItemNum));
    MoveTaskRow(idx, newItemNum);
    Exit;
  end;

  console.log('EditorBlur: SAVE idx=' + IntToStr(idx) + ' field=' + fieldName);
  SaveField(idx, fieldName);
end;


[async] procedure TFTaskItems.MoveTaskRow(AIndex: Integer; const newItemNum: Integer);
var
  response: TXDataClientResponse;
  movedTaskItemId: Integer;
begin
  if not xdwdsTasks.Active then
    Exit;

  GotoRowIndex(AIndex);
  if xdwdsTasks.Eof then
    Exit;

  movedTaskItemId := xdwdsTaskstaskItemId.AsInteger;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.MoveTaskRow',
      [
        StrToIntDef(xdwdsTaskstaskId.AsString, 0),
        movedTaskItemId,
        newItemNum
      ]
    ));
    console.log('MoveTaskRow: response=' + string(TJSJSON.stringify(response.Result)));

    FPendingFocusTaskItemId := movedTaskItemId;
    FPendingFocusField := 'application';

    CaptureTableScroll;
    LoadTasks(FTaskId);
  except
    on E: EXDataClientRequestException do
    begin
      console.log('MoveTaskRow ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      LoadTasks(FTaskId);
    end;
  end;
end;


[async] procedure TFTaskItems.SaveField(AIndex: Integer; const AFieldName: string);
const
  ENABLE_SERVER_SAVE = True;
var
  response: TXDataClientResponse;
  payload: TJSObject;
  fieldValue: string;
begin
  if not xdwdsTasks.Active then
    Exit;

  GotoRowIndex(AIndex);
  if xdwdsTasks.Eof then
    Exit;

  fieldValue := '';
  if SameText(AFieldName, 'application') then
    fieldValue := xdwdsTasksapplication.AsString
  else if SameText(AFieldName, 'version') then
    fieldValue := xdwdsTasksversion.AsString
  else if SameText(AFieldName, 'taskDate') then
    fieldValue := xdwdsTaskstaskDate.AsString
  else if SameText(AFieldName, 'reportedBy') then
    fieldValue := xdwdsTasksreportedBy.AsString
  else if SameText(AFieldName, 'assignedTo') then
    fieldValue := xdwdsTasksassignedTo.AsString
  else if SameText(AFieldName, 'status') then
    fieldValue := xdwdsTasksstatus.AsString
  else if SameText(AFieldName, 'statusDate') then
    fieldValue := xdwdsTasksstatusDate.AsString
  else if SameText(AFieldName, 'formSection') then
    fieldValue := xdwdsTasksformSection.AsString
  else if SameText(AFieldName, 'issue') then
    fieldValue := xdwdsTasksissue.AsString
  else if SameText(AFieldName, 'notes') then
    fieldValue := xdwdsTasksnotes.AsString
  else
    Exit;

  payload := TJSObject.new;
  payload['taskItemId'] := xdwdsTaskstaskItemId.AsInteger;
  payload['fieldName'] := AFieldName;
  payload['value'] := fieldValue;

  console.log('SaveField: idx=' + IntToStr(AIndex) + ' field=' + AFieldName + ' value=' + fieldValue);

  if not ENABLE_SERVER_SAVE then
    Exit;

  try
    response := await(xdwcTasks.RawInvokeAsync('ITaskItemService.SaveTaskItemField', [payload]));
    console.log('SaveField: response=' + string(TJSJSON.stringify(response.Result)));
  except
    on E: EXDataClientRequestException do
    begin
      console.log('SaveField ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
    end;
  end;
end;


[async] function TFTaskItems.DeleteTaskRow: Boolean;
var
  response: TXDataClientResponse;
begin
  Result := False;

  if (FSelectedTaskId <= 0) or (FSelectedTaskItemId <= 0) then
    Exit;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.DeleteTaskRow',
      [FSelectedTaskId, FSelectedTaskItemId]
    ));
    console.log('DeleteTaskRow response=' + string(TJSJSON.stringify(response.Result)));
    Result := True;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('DeleteTaskRow ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
    end;
  end;
end;


procedure TFTaskItems.DropdownItemClick(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName, newVal, triggerId: string;
  btn: TJSHTMLElement;
  labelEl: TJSHTMLElement;
begin
  if not xdwdsTasks.Active then
    Exit;

  Event.preventDefault;

  el := TJSHTMLElement(Event.currentTarget);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  newVal := string(el.getAttribute('data-value'));
  triggerId := string(el.getAttribute('data-trigger-id'));

  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  GotoRowIndex(idx);
  if xdwdsTasks.Eof then
    Exit;

  xdwdsTasks.Edit;
  xdwdsTasks.FieldByName(fieldName).AsString := newVal;
  xdwdsTasks.Post;

  if triggerId <> '' then
  begin
    btn := TJSHTMLElement(document.getElementById(triggerId));
    if Assigned(btn) then
    begin
      labelEl := TJSHTMLElement(btn.querySelector('.task-dd-label'));
      if Assigned(labelEl) then
        labelEl.textContent := newVal;

      if SameText(fieldName, 'status') then
        ApplyTaskStatusClass(btn, newVal);
    end;
  end;

  SaveField(idx, fieldName);
end;


procedure TFTaskItems.DropdownEditClick(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName, triggerId: string;
begin
  Event.preventDefault;
  Event.stopPropagation;

  el := TJSHTMLElement(Event.currentTarget);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  triggerId := string(el.getAttribute('data-trigger-id'));

  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  if not (SameText(fieldName, 'assignedTo') or SameText(fieldName, 'application') or SameText(fieldName, 'reportedBy')) then
    Exit;

  FNameManager.OpenManager(fieldName, idx, triggerId);
end;


procedure TFTaskItems.ApplyPendingFocus;
var
  el: TJSHTMLElement;
  selector: string;
begin
  if (FPendingFocusTaskItemId <= 0) or (FPendingFocusField = '') then
    Exit;

  selector :=
    '[data-task-item-id="' + IntToStr(FPendingFocusTaskItemId) + '"] ' +
    '[data-field="' + FPendingFocusField + '"]';

  el := TJSHTMLElement(document.querySelector(selector));
  if Assigned(el) then
  begin
    asm
      el.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' });
      el.focus();
    end;
  end;

  FPendingFocusTaskItemId := 0;
  FPendingFocusField := '';
end;


procedure TFTaskItems.ApplyPendingDeleteFocus;
var
  el: TJSHTMLElement;
  rowEl: TJSHTMLElement;
  selector: string;
  taskItemIdStr: string;
  taskIdStr: string;
begin
  if (FPendingFocusItemNum <= 0) or (FPendingFocusTaskField = '') then
    Exit;

  selector :=
    'tr[data-item-num="' + IntToStr(FPendingFocusItemNum) + '"] ' +
    '[data-field="' + FPendingFocusTaskField + '"]';

  el := TJSHTMLElement(document.querySelector(selector));
  if Assigned(el) then
  begin
    rowEl := TJSHTMLElement(el.closest('tr'));

    if Assigned(rowEl) then
    begin
      taskItemIdStr := string(rowEl.getAttribute('data-task-item-id'));
      taskIdStr := string(rowEl.getAttribute('data-task-id'));

      FSelectedTaskItemId := StrToIntDef(taskItemIdStr, 0);
      FSelectedTaskId := StrToIntDef(taskIdStr, 0);
      btnInsertRow.Enabled := FSelectedTaskItemId > 0;
      btnDeleteRow.Enabled := FSelectedTaskItemId > 0;
      ApplySelectedRowState;

      asm
        rowEl.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' });
      end;
    end;

    asm
      el.focus();
    end;
  end;

  FPendingFocusItemNum := 0;
  FPendingFocusTaskField := '';
end;

procedure TFTaskItems.CaptureTableScroll;
begin
  asm
    const vscroll = document.querySelector('.tasks-vscroll');
    const hscroll = document.querySelector('.tasks-hscroll');

    this.FPendingScrollTop = vscroll ? vscroll.scrollTop : 0;
    this.FPendingScrollLeft = hscroll ? hscroll.scrollLeft : 0;
  end;
end;

procedure TFTaskItems.RestoreTableScroll;
begin
  asm
    const vscroll = document.querySelector('.tasks-vscroll');
    const hscroll = document.querySelector('.tasks-hscroll');

    if (vscroll) vscroll.scrollTop = this.FPendingScrollTop || 0;
    if (hscroll) hscroll.scrollLeft = this.FPendingScrollLeft || 0;
  end;
end;


[async] function TFTaskItems.AddAssignedName(const AName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.AddAssignedName',
      [FTaskId, Trim(AName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractAssignedOptionNames(resultObj);
    FAssignedToOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('AddAssignedName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;

[async] function TFTaskItems.RenameAssignedName(const AOldName, ANewName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.RenameAssignedName',
      [FTaskId, Trim(AOldName), Trim(ANewName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractAssignedOptionNames(resultObj);
    FAssignedToOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('RenameAssignedName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;

[async] function TFTaskItems.DeleteAssignedName(const AName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.DeleteAssignedName',
      [FTaskId, Trim(AName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractAssignedOptionNames(resultObj);
    FAssignedToOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('DeleteAssignedName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;


[async] procedure TFTaskItems.HandleAddManagedName(const AFieldName: string; const ARowIndex: Integer; const ANewName: string);
var
  newOptions: TJSArray;
  resolvedName: string;
begin
  if SameText(AFieldName, 'application') then
    newOptions := await(AddApplicationName(ANewName))
  else if SameText(AFieldName, 'assignedTo') then
    newOptions := await(AddAssignedName(ANewName))
  else if SameText(AFieldName, 'reportedBy') then
    newOptions := await(AddReportedName(ANewName))
  else
    Exit;

  if not Assigned(newOptions) then
    Exit;

  if SameText(AFieldName, 'application') then
    FApplicationOptions := newOptions
  else if SameText(AFieldName, 'reportedBy') then
    FReportedByOptions := newOptions
  else
    FAssignedToOptions := newOptions;

  resolvedName := FindOptionIgnoreCase(newOptions, ANewName);
  if resolvedName = '' then
    resolvedName := Trim(ANewName);

  if not xdwdsTasks.Active then
    Exit;

  GotoRowIndex(ARowIndex);
  if xdwdsTasks.Eof then
    Exit;

  xdwdsTasks.Edit;
  xdwdsTasks.FieldByName(AFieldName).AsString := resolvedName;
  xdwdsTasks.Post;

  RenderTable;
  await(SaveField(ARowIndex, AFieldName));
end;


[async] procedure TFTaskItems.HandleRenameManagedName(const AFieldName, AOldName, ANewName: string);
var
  newOptions: TJSArray;
begin
  if SameText(AFieldName, 'application') then
    newOptions := await(RenameApplicationName(AOldName, ANewName))
  else if SameText(AFieldName, 'assignedTo') then
    newOptions := await(RenameAssignedName(AOldName, ANewName))
  else if SameText(AFieldName, 'reportedBy') then
    newOptions := await(RenameReportedName(AOldName, ANewName))
  else
    Exit;

  if not Assigned(newOptions) then
    Exit;

  if SameText(AFieldName, 'application') then
    FApplicationOptions := newOptions
  else if SameText(AFieldName, 'reportedBy') then
    FReportedByOptions := newOptions
  else
    FAssignedToOptions := newOptions;

  CaptureTableScroll;
  LoadTasks(FTaskId);
end;

[async] procedure TFTaskItems.HandleDeleteManagedName(const AFieldName, AName: string);
var
  newOptions: TJSArray;
begin
  if SameText(AFieldName, 'application') then
    newOptions := await(DeleteApplicationName(AName))
  else if SameText(AFieldName, 'assignedTo') then
    newOptions := await(DeleteAssignedName(AName))
  else if SameText(AFieldName, 'reportedBy') then
    newOptions := await(DeleteReportedName(AName))
  else
    Exit;

  if not Assigned(newOptions) then
    Exit;

  if SameText(AFieldName, 'application') then
    FApplicationOptions := newOptions
  else if SameText(AFieldName, 'reportedBy') then
    FReportedByOptions := newOptions
  else
    FAssignedToOptions := newOptions;

  CaptureTableScroll;
  LoadTasks(FTaskId);
end;

[async] function TFTaskItems.AddApplicationName(const AName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.AddApplicationName',
      [FTaskId, Trim(AName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractApplicationOptionNames(resultObj);
    FApplicationOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('AddApplicationName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;

[async] function TFTaskItems.RenameApplicationName(const AOldName, ANewName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.RenameApplicationName',
      [FTaskId, Trim(AOldName), Trim(ANewName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractApplicationOptionNames(resultObj);
    FApplicationOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('RenameApplicationName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;

[async] function TFTaskItems.DeleteApplicationName(const AName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.DeleteApplicationName',
      [FTaskId, Trim(AName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractApplicationOptionNames(resultObj);
    FApplicationOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('DeleteApplicationName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;


[async] function TFTaskItems.AddReportedName(const AName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.AddReportedName',
      [FTaskId, Trim(AName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractReportedOptionNames(resultObj);
    FReportedByOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('AddReportedName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;

[async] function TFTaskItems.RenameReportedName(const AOldName, ANewName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.RenameReportedName',
      [FTaskId, Trim(AOldName), Trim(ANewName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractReportedOptionNames(resultObj);
    FReportedByOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('RenameReportedName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;

[async] function TFTaskItems.DeleteReportedName(const AName: string): TJSArray;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
begin
  Result := nil;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'ITaskItemService.DeleteReportedName',
      [FTaskId, Trim(AName)]
    ));

    resultObj := TJSObject(response.Result);
    Result := ExtractReportedOptionNames(resultObj);
    FReportedByOptions := Result;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('DeleteReportedName ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      Exit;
    end;
  end;
end;


procedure TFTaskItems.ColorPickerInput(Event: TJSEvent);
var
  pickerEl: TJSHTMLElement;
  colorValue: string;
  swatchEl: TJSHTMLElement;
begin
  pickerEl := TJSHTMLElement(Event.target);
  colorValue := string(TJSObject(pickerEl)['value']);

  swatchEl := TJSHTMLElement(document.getElementById('text_color_swatch'));
  swatchEl.style.setProperty('background', colorValue);

  ApplyRichTextCommand('foreColor', colorValue);
end;


procedure TFTaskItems.RichEditorFocus(Event: TJSEvent);
var
  el: TJSHTMLElement;
begin
  el := TJSHTMLElement(Event.target);
  FActiveRichEditorId := string(el.id);
  UpdateToolbarState;
end;

procedure TFTaskItems.RichEditorSelectionChange(Event: TJSEvent);
begin
  asm
    const sel = window.getSelection();
    if (!sel || sel.rangeCount === 0) return;

    const range = sel.getRangeAt(0);
    const editor = document.getElementById(this.FActiveRichEditorId);
    if (!editor) return;

    const container = range.commonAncestorContainer;
    if (editor.contains(container.nodeType === 1 ? container : container.parentNode)) {
      this.FLastSelectionRange = range.cloneRange();
    }
  end;

  UpdateToolbarState;
end;

procedure TFTaskItems.ApplyRichTextCommand(const commandName: string; const commandValue: string = '');
var
  editorEl: TJSHTMLElement;
begin
  if not xdwdsTasks.Active then
    Exit;

  if FActiveRichEditorId = '' then
    Exit;

  editorEl := TJSHTMLElement(document.getElementById(FActiveRichEditorId));
  if not Assigned(editorEl) then
    Exit;

  asm
    const sel = window.getSelection();
    if (!sel) return;

    editorEl.focus();

    if (this.FLastSelectionRange) {
      sel.removeAllRanges();
      sel.addRange(this.FLastSelectionRange);
    }

    if (commandName === 'foreColor')
      document.execCommand('styleWithCSS', false, true)
    else
      document.execCommand('styleWithCSS', false, false);

    if (commandValue)
      document.execCommand(commandName, false, commandValue);
    else
      document.execCommand(commandName, false, null);

    if (sel.rangeCount > 0) {
      this.FLastSelectionRange = sel.getRangeAt(0).cloneRange();
    }

    editorEl.setAttribute('data-unsaved-data', '1');
    editorEl.dispatchEvent(new Event('input', { bubbles: true }));
  end;
  UpdateToolbarState;
end;


procedure TFTaskItems.SetToolbarButtonActive(const buttonId: string; const isActive: Boolean);
var
  el: TJSHTMLElement;
begin
  el := TJSHTMLElement(document.getElementById(buttonId));
  if isActive then
    el.classList.add('active')
  else
    el.classList.remove('active');

  el.setAttribute('aria-pressed', BoolToStr(isActive, True).ToLower);
end;

procedure TFTaskItems.UpdateToolbarState;
begin
  asm
    this.SetToolbarButtonActive('btn_fmt_bold', document.queryCommandState('bold'));
    this.SetToolbarButtonActive('btn_fmt_italic', document.queryCommandState('italic'));
    this.SetToolbarButtonActive('btn_fmt_underline', document.queryCommandState('underline'));
  end;
end;



end.

