Drag and Drop Reordering with RTK Query and PHP

Today a few things took place.

  • Functionality to add new items
  • Features to add new items
  • Added a drag indicator
  • Separated “Drag” and “Drop” into separate components
  • Added context menu to move items
  • Refactor API endpoints to follow REST API standards.
  • Changed fetch requests to use GET
  • Added PHP page to handing directional movements
  • Write backtrace in serialized responses with error messages
  • Typescript const
  • Got refresh working with cached api slice
Microsoft Designer: Image Creator Prompt

Today’s work focused on implementing drag and drop functionality, improving error handling, and refining API endpoints. Key features include a new item creation dialog, item reordering, and enhanced error reporting with stack traces. Backend changes involved refactoring API methods, handling server-side item movement, and using appropriate HTTP status codes. Additionally, the frontend was updated with a custom dialog component, improved error message display, and optimized RTK Query caching.

Add Type

A button now appears to add a new button to the navigation. A request is made to the server for “Features” to request what actions I am permitted to perform.

In addition, a fairly generic dialog is available to prompt the user to enter a name. It’s also capable of showing the busy status and errors.

Dialog Input to add a new type

Eventually I need to do something about the theme colors with the Material UI Components. I can hardly make out what the button text says.

The Dialog Input component is a reusable component that’s handy for when you only need one piece of data from the end user. In this case, I only need a name. Just about everything is customizable such as the title, content, label, default value, input type, busy status, ok label, cancel label, and an error.

I worked out how to process different types of errors so that error messages returned from the API are displayed, as well as fetch query errors and serialized errors. The error message needs some kind of indication that it’s an error. For now, it works well enough.

Error message on Dialog Input

What I don’t have is any kind of validation other than the built-in types for HTML input (date, email, number, time, phone, etc.)

DialogInput.tsx
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import { FC, FormEvent, HTMLInputTypeAttribute } from 'react';
import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { SerializedError } from '@reduxjs/toolkit';

interface DialogInputProps {
  title?: string,
  content?: string,
  label?: string,
  defaultValue?: string,
  type?: HTMLInputTypeAttribute,
  open: boolean,
  isBusy?: boolean,
  onCancel: () => void,
  onSubmit: (value: string) => void,
  onUpload?: (value: File) => void,
  okLabel?: string,
  cancelLabel?: string,
  error?: FetchBaseQueryError | SerializedError | undefined
};

export const DialogInput: FC<DialogInputProps> = ({
  title = 'Input',
  content = 'Please enter a value.',
  label = 'Value',
  type = 'text',
  defaultValue = '',
  open = false,
  onCancel = () => { },
  onSubmit = () => { },
  onUpload = () => { },
  okLabel = "OK",
  cancelLabel = "Cancel",
  isBusy = false,
  error = undefined
}) => {

    const handleClose = () => onCancel();

    const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      const data = new FormData(event.currentTarget);
      const currentValue = data.get('value')!;
      if (typeof currentValue === 'string') {
        onSubmit(currentValue);
      } else {
        onUpload(currentValue);
      }
    }
    return (
      <Dialog
        onClose={handleClose}
        open={open}
        PaperProps={{ component: 'form', onSubmit: handleSubmit }}
      >
        <DialogTitle>{title}</DialogTitle>
        <DialogContent>{content}</DialogContent>
        <TextField
          label={label}
          name="value"
          type={type}
          defaultValue={defaultValue}
          autoFocus
          required
          margin="dense"
          fullWidth
          variant='standard' />
        {isBusy ? <CircularProgress size={30} /> : null}
        <Error error={error} />
        <DialogActions>
          <Button onClick={handleClose}>{cancelLabel}</Button>
          <Button type="submit">{okLabel}</Button>
        </DialogActions>
      </Dialog>
    )
  }

const Error: FC<{
  error?: FetchBaseQueryError | SerializedError | undefined
}> = ({
  error
}) => {
    if (!error) return;
    if ('status' in error) {
      if (isDataError(error.data)) {
        return <DialogContent>{error.data.error}</DialogContent>
      }
      return <DialogContent>Fetch Error: {error.status}</DialogContent>
    }
    const { name, code, message } = error;
    return <DialogContent>
      {`${name ?? 'Error'}${code ? `[${code}]` : ''}:`}
      {message}
    </DialogContent>
  }
const isDataError = (data: unknown): data is { error: string } =>
  !!data &&
  typeof data === 'object' &&
  ('error' in data) &&
  typeof data.error === 'string'

Drag Handle

We now have a drag handle to the left of each button. Rather than dragging from anywhere on the button, you now need to grab the drag handle to move it around. I’ve also added a menu to move the buttons manually up, down, to the top, and to the bottom. The animations look smooth.

Context menu for drag handles

With the movement, a new endpoint was created in the Drag & Drop slice. Although the items are not technically being dragged, it depends on the same logic and data.

Dispatched actions mixing Drag & Drop with API calls

On the PHP side of things, I needed to work with manipulating arrays and guarding against unnecessary moves. Many of the methods in PHP are similar to JavaScript, except they are worded a bit different. Still, operations such as splice, unshift, and push still work in a similar manor mutating the array.

move_item.php
function move_item($rows, $id, $direction)
{
    $ids = array_column($rows, 'id');
    $count = count($rows);
    $index = array_search($id, $ids);
    $item = $rows[$index];
    switch ($direction) {
        case 'up':
            if ($index === 0) {
                break;
            }
            array_splice($rows, $index, 1);
            if ($index === 1) {
                array_unshift($rows, $item);
            } else {
                array_splice($rows, $index - 1, 0, [$item]);
            }
            break;
        case 'down':
            if ($index === $count - 1) {
                break;
            }
            array_splice($rows, $index, 1);
            if ($index === $count - 2) {
                array_push($rows, $item);
            } else {
                array_splice($rows, $index + 1, 0, [$item]);
            }
            break;
        case 'top':
            if ($index === 0) {
                break;
            }
            array_splice($rows, $index, 1);
            array_unshift($rows, $item);
            break;
        case 'bottom':
            if ($index === $count - 1) {
                break;
            }
            array_splice($rows, $index, 1);
            array_push($rows, $item);
            break;
    }
    return $rows;
}

API Refactor

Quite a few things happened here today. As I’m getting familiar with how createApi works, I’ve setup all queries that read data to use GET methods instead of POST, and send the parameters over as query string parameters. I need to send the parameters as part of the URL rather than query string parameters, but its going to take a bit to work that into the .htaccess file to support it and map it back to the existing file.

All mutation calls now use POST, PUT, PATCH, and DELETE to conform with REST API’s (Representational State Transfer). These methods are specific to certain actions.

  • POST – Add a new item
  • PUT – Update an item
  • PATCH – Update a portion of an item
  • DELETE – Delete an item

The patch requests are used for moving items around, as only their order or parentId fields are updated.

Previously, I just threw an error any time I ran into a situation that was unfavorable, such as a duplicate item with the same name. The default error code is 500, which indicates a server error. These issues were actually client errors, and should be in the 4xx range.

Here is an example of a duplicate item check that uses the correct error code.

verify_not_dupe.php
function verify_not_dupe($db, $id, $parentId, $name)
{
    $sql = "SELECT COUNT(0)
    FROM Types
    WHERE
      id != ?
      AND name = ?
      AND COALESCE(parentId, 0) = ?";
    $total = $db->selectScalar($sql, 'isi', $id, $name, $parentId);
    if ($total === false) {
        throw $db->get_last_exception();
    }
    if ($total > 0) {
        Show::error('Duplicate name', HTTP_STATUS_CONFLICT);
        exit;
    }
}

TypeScript Const

While reviewing example code for RTK Query tags, I saw const being used on the tags for caching. I didn’t quite understand what it was, and why the errors were occurring in TypeScript without them. I found a way to get my Emoji icons to show up when hovering over the tags by casting them as themselves. I was so close to stumbling on what was going on, but I just wasn’t looking. Well, it seems that const is shorthand for what I was doing. It just means that what ever object that you throw it on, it becomes a read-only constant value. Rather than adding the duplication of string values everywhere, I could have just said as const at the end.

While I was updating the emoji library to use const, I also updated the demo site so that instead of appearing alphabetically, the emoji are now grouped and sub grouped. It’s a bit messy at the moment, but it’s a bit easier to browse the items grouped together, rather than having to select the group filter first.

Emoji Website

PHP Backtrace

I’m using PHP on the backend. I have error handling to catch errors and report them as JSON responses. Exceptions come with stack traces. In PHP, errors are not the same as exceptions, so I don’t have an object with a stack trace. PHP still has a way to capture this information with debug_backtrace(). Here is how I’m converting it to an array of strings.

Show.php
<?php
require_once 'HTTP_STATUS.php';

class Show
{
    private static $NO_CACHE =
        'Cache-Control: no-cache, no-store, must-revalidate';

    public static function message(
        $message,
        $code = HTTP_STATUS_OK
    ) {
        self::data(['message' => $message], $code);
    }
    public static function error(
        $error,
        $code = HTTP_STATUS_INTERNAL_SERVER_ERROR
    ) {
        if (class_exists('JsonStreamer')) {
            JsonStreamer::fail($error);
            return;
        }

        if ($error instanceof Exception) {
            self::data([
                'error' => $error->getMessage(),
                'stack' => $error->getTraceAsString(),
            ], $code);
        } else {

            $backtrace = debug_backtrace();
            $backtrace_frames = array_map(
                function ($frame) {
                    return sprintf(
                        "%s:%d  %s()",
                        $frame['file'],
                        $frame['line'],
                        $frame['function']
                    );
                }, $backtrace);

            self::data([
                'error' => $error,
                'stack' => $backtrace_frames,
            ], $code);
        }
        exit;
    }
    public static function status($code)
    {
        $message = http_status_message($code);
        $json = json_encode(
            ['message' => $message],
            JSON_PRETTY_PRINT
        );
        header("HTTP/1.1 $code $message");
        header(self::$NO_CACHE);
        header('Content-Type: application/json');
        header('Content-Length: ' . strlen($json));
        echo $json;
    }
    public static function data(
        $data,
        $code = HTTP_STATUS_OK,
        $secondsCached = -1
    ) {
        $json = json_encode($data, JSON_PRETTY_PRINT);

        http_response_code($code);
        header('Content-Type: application/json');
        if ($secondsCached > 0) {
            header("Cache-Control: max-age=$secondsCached");
        } else if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
            header(self::$NO_CACHE);
        }
        header('Content-Length: ' . strlen($json));
        echo $json;
    }
}

API Slice Cache

Finally, I worked with RTK Query a bit today to try and solve what was going on with cached data. As I added new data, I wasn’t seeing any calls to fetch an updated list.

With RTK Query, you’ve got queries and mutations. For me, I was treating everything as a query, since my modifications were returning the updated data. That’s not exactly how things are supposed to work. In addition, I changed the way I was saving lists. I was providing a separate tag for lists, since each list had a unique parent id – but it tends to get complex because they are subsets and support searching the list. Well, for now I decided to ignore all of that and just create an id for all lists, and invalidate that tag once I start mutating objects.

For now it works, but when I get into the sub-list for each item, I’m sure I’m going to run into the problem again and bring back the list tags.

The API’s have been changed so that they no longer return data for the item that was just created or modified. Instead, they just return a success message. The endpoint to fetch items doesn’t fetch data anymore either. It only returns an array of ID’s, and each item has its own request. This ended up with a situation where the list was being fetched, but the items were not – because they were cached and didn’t change. Fetching a list no longer provides tags for the items returned – only the id of “list”.

I also modified the base query for my fetch. My API’s use an authorization token once the user logs into the system, and pass it along as a header. This token is stored in the authSlice. In order to grab it, I needed to access the state and the auth selector. It’s similar to how you work with the RTK listeners.

apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import emoji from '@lewismoten/emoji';

import { authSlice, AuthState } from '@/features/auth/authSlice';
import { MultiItemResponse } from "@/types/MultiItemResponse";
import { Subset } from "@/types/Subset";
import { Hierarchical } from "@/types/Hierarchical";
import { Search } from "@/types/Search";
import { Ordered } from "@/types/Ordered";
import { Identified } from "@/types/Identified";
import { Named } from "@/types/Named";
import { toQueryParams } from "@/utils/toQueryParams";

export const TAG_TYPE = `type ${emoji.fox}` as const;
export const TAG_TYPE_FEATURE =
  `${TAG_TYPE} feature ${emoji.fairy}` as const;

interface Movement {
  id: number,
  parentId: number | null,
  direction: 'up' | 'down' | 'top' | 'bottom'
};
type Item = Identified & Named & Ordered & Hierarchical;
type NewItem = Named & Partial<Ordered> & Hierarchical;

export const apiSlice = createApi({
  reducerPath: `api ${emoji.wireless}` as const,
  tagTypes: [TAG_TYPE, TAG_TYPE_FEATURE],
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://localhost/api',
    prepareHeaders(headers, api) {
      const state = api.getState() as { [authSlice.reducerPath]: AuthState };
      const token = authSlice.selectors.selectToken(state);
      if (token && token !== '') {
        headers.append("Authorization", `Bearer ${token}`);
      }
      return headers;
    },
  }),
  endpoints: (
    builder
  ) => ({
    findTypes: builder.query<
      MultiItemResponse<number>,
      Partial<Subset & Hierarchical & Search>
    >({
      query: params => `/types/find?${toQueryParams(params)}`,
      providesTags: () => [tagType('list')]
    }),
    getTypeFeatures: builder.query<string[], number | null>({
      query: id => `/types/features?${toQueryParams({ id })}`,
      providesTags: (data, _e, id) =>
        data ? [tagTypeFeature(id ?? 0)] : []
    }),
    getType: builder.query<Item, number>({
      query: id => `/types/get?id=${id}`,
      providesTags: (_d, _e, id) => [tagType(id)]
    }),
    modifyType: builder.mutation<void, Item>({
      query: item => ({
        url: '/types/modify',
        method: 'PUT',
        body: item
      }),
      invalidatesTags: (_d, _e, { id }) =>
        [tagType('list'), tagType(id)]
    }),
    createType: builder.mutation<number, NewItem>({
      query: item => ({
        url: '/types/add',
        method: 'POST',
        body: item
      }),
      invalidatesTags: id => id ? [tagType('list')] : []
    }),
    deleteType: builder.mutation<void, number>({
      query: id => ({
        url: '/types/delete',
        method: 'DELETE',
        body: id
      }),
      invalidatesTags: (_d, _e, id) => [tagType('list'), tagType(id)]
    }),
    reorderTypes: builder.mutation<void, Ordered[]>({
      query: orders => ({
        url: '/types/reorder',
        method: 'PATCH',
        body: orders
      }),
      invalidatesTags: (_d, _e, orders) =>
        [tagType('list'), ...orders.map(tagType)]
    }),
    moveType: builder.mutation<
      void,
      Movement
    >({
      query: movement => ({
        url: '/types/move',
        method: 'PATCH',
        body: movement
      }),
      invalidatesTags: () => [tagType('list')]
    })
  })
});

const tagType = (value: number | string | { id: number | string }) => {
  switch (typeof value) {
    case 'number':
    case 'string':
      return { id: value, type: TAG_TYPE };
    case 'object': return ({ id: value.id, type: TAG_TYPE })
  }
}

const tagTypeFeature = (value: number | string | { id: number | string }) => {
  switch (typeof value) {
    case 'number':
    case 'string':
      return { id: value, type: TAG_TYPE_FEATURE };
    case 'object': return ({ id: value.id, type: TAG_TYPE_FEATURE })
  }
}

Looking at the site, it doesn’t seem like much. The majority of the work these past few weeks was setting up the underlying framework and learning a few new libraries, removing a few old ones, and learning about new changes to React Redux since the last time I had dived into their ecosystem.

Drag & Drop

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading