npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

react-admin-contraption-unstable

v0.0.22

Published

A tool that allows one to create django admin / active admin like interfaces in react.

Downloads

24

Readme

Admin Contraption Thing

A tool that allows one to create django admin / active admin like interfaces in react.

https://github.com/1egoman/react-admin-contraption/assets/1704236/d127b193-5c19-44ab-803b-68a5e71afb9f

Docs site: https://bread-1.gitbook.io/react-admin-contraption/

Getting started

There is a next.js app in example. Run npm install && npm run dev, then go to the root web page it serves for some example admin implementations.

Note that you may need to also start a fake backend server locally - cd jsonserver && npx json-server -p 3003 db.json should do that.

Example

The core code currently lives in admin/ (a symlink to example/src/admin). Right now, copy this code into a project's src directory (TBD process for now, some sort of package distribution mechanism needs to be thought through here) to add this to a project.

Data Models

The most important concept in this project is a "data model" - this is how you tell the tool how data should be represented.

Code example

// Example type data to give an idea of this demo schema:
type Person = { id: string, /* ... */ };
export type Vehicle = {
  id: string,
  name: string,
  hasBrakes: boolean,
  type: 'car' | 'truck',
  numberOfWheels: number | null,
  metadata: any, /* some sort of json... */
  driverId: Person['id'],
};

// Then, in a component somewhere:
<DataModel<Vehicle>
  name="vehicle"
  singularDisplayName="vehicle"
  pluralDisplayName="vehicles"

  fetchPageOfData={/* see below for an implementation of this */ }
  fetchItem={/* see below for an implementation of this */ }
  // createItem
  // updateItem
  // deleteItem

  keyGenerator={vehicle => post.id}
  detailLinkGenerator={post => ({ type: 'href' as const, href: `/admin/vehicles/${vehicle.id}` })}
  listLink={{ type: 'href' as const, href: `/admin/vehicles` }}
  createLink={{ type: 'href', href: `/admin/vehicles/new` }}
>
  {/* A field tells the tool how to render an subattribute of the data model */}
  {/* Note that this is a raw field implementation, there are more abstract fields */}
  {/* that in practice you'd use most of the time. */}
  <Field<Vehicle, 'id', string>
    name="id"
    singularDisplayName="Id"
    pluralDisplayName="Ids"
    csvExportColumnName="id"
    columnWidth={100}

    // Each field has a backing state data structure. For this field, it's `string` (third generic)
    // The below props control mapping back and forth between the raw item (`vehicle`) and the state (`state`)
    // This field is read only though, so it's not that interesting.
    getInitialStateFromItem={vehicle => vehicle.id}
    injectAsyncDataIntoInitialStateOnDetailPage={async state => state}
    serializeStateToItem={(state, _vehicle) => state}

    sortable // When set, this field can be clicked on to sort in list views

    // This prop (along with `modifyMarkup`) control how a field presents itself in different
    // contexts. `displayMarkup` is rendered in read only contexts (ie, list views) and
    // `modifyMarkup` is shown in read/write contexts. Note that `modifyMarkup` also has the ability
    // to update the field state (its render prop function signature has more parameters)
    displayMarkup={state => <span>{state}</span>}

    // There are other lifecycle methods one can tap into as well - the goal here is to provide as
    // many extension points as possible to allow this tool to scale better than a django admin /
    // active admin kind of thing.
  />

  {/* A few more simple abstract fields: */}
  <BooleanField<Post, 'hasBrakes'>
    name="hasBrakes"
    singularDisplayName="Has Brakes"
    pluralDisplayName="Has Brakes"
    // ^- Sometimes the plural and singular names are the same...  just do whatever makes gramatical sense in the interface :)
  />
  <ChoiceField<Post, 'type'>
    name="type"
    singularDisplayName="Car Type"
    pluralDisplayName="Car Types"

    getInitialStateWhenCreating={() => 'unset'}
    // ^- What is the default value when in the creation form?

    choices={[
      {id: 'Unset', disabled: true, label: 'Unset'},
      {id: 'car', label: 'Car'},
      {id: 'truck', label: 'Truck'},
    ]}
  />
  <NumberField<Post, 'numberOfWheels', true>
    name="numberOfWheels"
    singularDisplayName="Number of Wheels"
    pluralDisplayName="Number of Wheels"

    // Most of the abstract fields can be made nullable, and when enabled (plus the last generic
    // parameter being set to true), the control will show radio buttons allowing one to pick "null"
    // instead of typing a value
    nullable

    getInitialStateWhenCreating={() => null}
  />
  <JSONField<Post, 'metadata'>
    name="metadata"
    singularDisplayName="Metadata"
    pluralDisplayName="Metadata"
    getInitialStateWhenCreating={() => ({})}
  />

  {/* This tool also supports foreign keys by using SingleForeignKeyField / MultiForeignKeyField: */}
  <SingleForeignKeyField<Post, 'driverId', Person>
    name="driverId"
    singularDisplayName="Driver"
    pluralDisplayName="Drivers"

    // The "name" of another data model that represents the other side of the relation
    relatedName="person"

    // There's a lot of different ways that data can be represented in api responses - to try to
    // give as much flexibility as possible, this field can either accept just an id of the related
    // model ("KEY_ONLY") or a full on embedded object ("FULL"). If `KEY_ONLY` is set, then the
    // field will automatically look up the related item using its associated `fetchItem` function.
    getInitialStateFromItem={vehicle => ({ type: 'KEY_ONLY' as const, key: vehicle.driverId })}

    // There are quite a few lifecycle props one can use to tap into how this field works - the
    // above are the only required ones though.
  />
  {/*
  // The MultiForeignKeyField is very similar - the only difference is `getInitialStateFromItem`
  // returns an array of ids or an array of embedded objects:
  <MultiForeignKeyField<Post, 'passengerIds', Person>
    name="passengerIds"
    singularDisplayName="Passenger"
    pluralDisplayName="Passengers"

    relatedName="person"
    getInitialStateFromItem={vehicle => ({ type: "KEY_ONLY", key: post.passengerIds })}
  />
</DataModel>

fetchPageOfData / fetchItem / etc

These functions must be implemented for each data model and tell it how it can get data from a server somewhere. Each is a very generic interface that can be implemented no matter the underlying technology the project uses - as of april 2023, I've experimented with REST and TRPC, but any data async source should work fine.

I wouldn't be surprised if a set of standardized, bread specific components that use trpc/react server actions/etc were developed that would be largely drop in to standardize the api interface. But, that is for the future!

const fetchPageOfData = useCallback(async (
  page: number,
  filters: Array<[Array<string>, any]>,
  sort: Sort | null,
  searchText: string,
  // NOTE: optionally, this `AbortSignal` can be passed into whatever request making mechanism you
  // are using to auto cancel old requests when a user changes things while the app is loading.
  signal: AbortSignal
) => {
  const qs = new URLSearchParams();

  if (filters || searchText.length > 0) {
    for (const [[name, ..._rest], value] of filters) {
      qs.set(name, value);
    }
  }
  if (searchText.length > 0) {
    qs.set('title', searchText);
  }

  const response = await fetch(`http://localhost:3003/vehicles?${qs.toString()}`, { signal });
  if (!response.ok) {
    throw new Error(`Error fetching vehicles: received ${response.status} ${await response.text()}`)
  }

  const body = await response.json();

  return {
    // This api endpoint isn't paginated, but in the real world, you probably would want it to be
    // (which would mean setting `nextPageAvailable` / `totalCount` accordingly)
    nextPageAvailable: false,
    totalCount: body.length,
    data: body,
  };
}, []);

const fetchItem = useCallback(async (itemKey: string, signal: AbortSignal) => {
  const response = await fetch(`http://localhost:3003/vehicles/${itemKey}`, { signal });
  if (!response.ok) {
    throw new Error(`Error fetching vehicle with id ${itemKey}: received ${response.status} ${await response.text()}`)
  }

  return response.json();
}, []);

// createItem, updateItem, and deleteItem are similar, but handle their respective CRUD actions
// Look at the code / typescript types if you want to understand the exact parameters here, but
// they are relatively straightforward

Including data models in other pages

Data models must be placed in a <DataModels>...</DataModels> component. Here's what I've done to date:

export default function CustomWrapperComponentToBringInDataModels({ children  }) {
  return (
    // Must wrap all the admin stuff - allows configuration of global parameters
    <AdminContextProvider>
      <DataModels>
        {/* Add data model definitions here */}

        {children}
      </DataModels>
    </AdminContextProvider>
  );
}

Then, wrap all subsequent pages in this component.

In a next.js app, there's probably a more elegant way to do this. If so, do that instead.

Remote Data Models

It's likely you won't want to write out custom <DataModel /> definitions for all data models in the app. Instead, you can let the server drive data models by taking advantage of "remote data models". Here is an example:

// This function would make a request to the server and get `definitions`, plus then inject all
// this extra context about how one could query the server to get information about the given datamodels
//
// In the near term, this would probably be some sort of "bread specific custom adapter" to work
// with the bread starter stuff. In the longer term, this could potentially be a set of adapters
// for different stacks if the goal was to make this project more generic.
const fetchRemoteDataModels = useCallback(async (): Promise<RemoteDataModelDefinition> => {
  return {
    fetchPageOfData: (dataModelName) => {
      // A mock implementation of `fetchPageOfData` for `dataModelName`
      // This should actually call out to some dynamic query endpoint thing that can service this
      // request, do the filters / search / sort, etc
      return async (page, filters, /* ... */) => ({
        nextPageAvailable: false,
        totalCount: 0,
        data: [{id: 1, textcolumn: 'foo', foreign: '1'}],
      });
    },
    fetchItem: (dataModelName) => {
      // A mock implementation of `fetchItem` for `dataModelName`
      // This should actually call out to some dynamic query endpoint thing that can service this
      // request, do the filters / search / sort, etc
      return async (key) => ({ id: key, textcolumn: 'foo', foreign: '1' });
    },
    // Similar to the above, these can also be optionally defined here:
    // createItem
    // updateItem
    // deleteItem

    listLink: (dataModelName) => ({ type: 'next-link', href: `/admin/filteroff/${dataModelName}` }),
    detailLinkGenerator: (dataModelName, key) => ({ type: 'next-link', href: `/admin/filteroff/${dataModelName}/${key}` }),
    createLink: dataModelName => ({ type: 'next-link', href: `/admin/filteroff/${dataModelName}/new` }),

    // Somehow generate this from the prisma schema file serverside...
    definitions: {
      dynamicmodel: {
        singularDisplayName: "dynamic model",
        pluralDisplayName: "dynamic models",
        columns: {
          id: { type: 'primaryKey', singularDisplayName: "id", pluralDisplayName: "ids", nullable: false },
          textcolumn: { type: "text", singularDisplayName: "id", pluralDisplayName: "ids", nullable: false},
          foreign: { type: "singleForeignKey", to: "user", singularDisplayName: "id", pluralDisplayName: "ids", nullable: false },
        },
      },
      dynamicmodel2: {
        singularDisplayName: "dynamic model",
        pluralDisplayName: "dynamic models",
        columns: {
          id: { type: 'primaryKey', singularDisplayName: "id", pluralDisplayName: "ids", nullable: false },
          textcolumn: { type: "text", singularDisplayName: "id", pluralDisplayName: "ids", nullable: false},
        },
      },
    },
  };
}, []);

// Then, later on, in something like that `CustomWrapperComponentToBringInDataModels` component I mentioned above:
<AdminContextProvider>
  <DataModels fetchRemoteDataModels={fetchRemoteDataModels}> {/* <-- fetchRemoteDataModels is passed in here */}

    {/* Finally, a new built in component: This will render custom server generated data models: */}
    <RemoteDataModels />
    {/* You could also do this, to exclude certain server generated ones so you can implement your own: */}
    {/* <RemoteDataModels exclude={["dynamicmodel2"]} /> */}
    {/* Or the allowlist version */}
    {/* <RemoteDataModels include={["dynamicmodel"]} /> */}

    {/* Or, it's also possible to define a data model, but rely on fields from the server: */}
    <DataModel
      // ... props here, see above for what these would be ...
    >
      {/* Include all fields associated with `dynamicmodel`: */}
      <RemoteFields name="dynamicmodel" />

      {/* Include all but a single field associated with `dynamicmodel`: */}
      {/* <RemoteFields name="dynamicmodel" excludes={["foreign"]} /> */}

      {/* Include a single field associated with `dynamicmodel`: */}
      {/* <RemoteFields name="dynamicmodel" includes={["textcolumn"]} /> */}
    </DataModel>

    {/* Data model that is a fully custom implementation - this component is custom and */}
    {/* renders a <DataModel></DataModel> inside: */}
    <UserDataModel />

    {children}
  </DataModels>
</AdminContextProvider>

List Page

The list page shows a read only list of all datamodels that are fetched from a server and allows a user to filter, sort, and perform actions on them.

Code example

If in a next.js app, create a src/pages/admin/vehicles/index.tsx file, and return something like the below from a component defined as that file's default export:

<CustomWrapperComponentToBringInDataModels>
  <List<Vehicle>
    name="vehicle" // Points to a data model, see the earlier section
    checkable
  >
    <ListFilterBar searchable>
      {/* Filter definitions handle rendering filters. `StringFilterDefinition` is a more abstract */}
      {/* filter but fully customizable filter rendering is possible by using the more abstract */}
      {/* `FilterDefinition` implementation. */}

      {/* Simple one parameter filter: */}
      <StringFilterDefinition name={["id"]} />

      {/* `name` can be used to define an arbitrary filter path, allowing for some very nuanced and */}
      {/* complicated filtering behavior not available in many tools. Some examples: */}
      <StringFilterDefinition name={["name", "equals"]} />
      <StringFilterDefinition name={["name", "contains"]} />
      <StringFilterDefinition name={["numberOfWheels", "less than"]} />
      <StringFilterDefinition name={["numberOfWheels", "greater than"]} />

      {/* Here is an example of a more abstract filter to give an idea of the kind of stuff that */}
      {/* is pretty easily possible: */}
      <FilterDefinition<[string, string]>
        name={["numberOfWheels", "is in range"]}
        getInitialState={() => ['', '']}

        // If a filter is not yet valid, it will be highlighted as invalid (often like a red border
        // on an input box or something like that). See this component's `children` for an example.
        onIsValid={([start, end]) => !isNaN(parseInt(start)) && !isNaN(parseInt(end))}

        // If a filter is valid, then it may also be complete. A complete filter is included in the
        // request to get a list of data to show in the list view.
        //
        // In _most_ cases, this function is the same as `onIsValid`.
        onIsComplete={([start, end]) => !isNaN(parseInt(start)) && !isNaN(parseInt(end))}

        // Serializing and deserializing the filter allows it to be represented in the query string
        serialize={state => JSON.stringify(state)}
        deserialize={raw => JSON.parse(raw)}
      >
        {(state, setState, filter, onBlur) => (
          <div style={{ display: 'flex', gap: 8 }}>
            <input
              type="text"
              placeholder="Start"
              value={start}
              onChange={e => setState([e.currentTarget.value, state[1]])}
              onBlur={onBlur}
              style={{ border: !filter.isValid ? '1px solid red' : undefined }}
            />
            <input
              type="text"
              placeholder="End"
              value={end}
              onChange={e => setState([state[0], e.currentTarget.value])}
              onBlur={onBlur}
              style={{ border: !filter.isValid ? '1px solid red' : undefined }}
            />
          </div>
        )}
      </FilterDefinition>
      
      {/* In a real app, you'd probably auto generate filter definitions somehow and then maybe */}
      {/* add a few custom app specific ones. This is also something that some bread specific */}
      {/* api integration stuff on top could help facilutate long term. */}
    </ListFilterBar>
    <ListActionBar<User>>
      {checkedItems => (
        <Fragment>
          {/* Expose bulk actions that a user can select when a set of items are checked */}
          <button
            onClick={() => alert(checkedItems.map(i => i.id).join(','))}
          >Export</button>
        </Fragment>
      )}
    </ListActionBar>

    {/* Because this is react, you can just put whatever you want intermixed with these */}
    {/* admin-related components and it will render like you expect: */}
    <div>My cool markup</div>

    <ListTable />
  </List>
<CustomWrapperComponentToBringInDataModels>

Detail Page

The detail page shows a writable version of a given datamodel allowing one to create and update instances on ones data.

Code example

If in a next.js app, create a src/pages/admin/vehicles/[id].tsx file, and return something like the below from a component defined as that file's default export:

<CustomWrapperComponentToBringInDataModels>
  <Detail<Vehicle>
    name="vehicle" // Points to a data model, see the earlier shction
    itemKey={id === 'new' ? undefined : id} // `id` should be the id from the url. If unset, this renders a creation form.
    title={vehicle => vehicle.name}
    actions={vehicle => (
      // Single-control specific actions can be put here.
      <Fragment>
        <button onClick={() => alert(`Click ${vehicle.id}!`)}>Blink headlights</button>
      </Fragment>
    )}
  >
    <DetailFields />
  </Detail>
</CustomWrapperComponentToBringInDataModels>
}

Autorendering List / Detail Pages

It's likely that if you are taking advantage of remote data models, you wouldn't want to have to scaffold out a list and detail page for each remote data model, given there is no way to know for sure which models the server will return.

Luckily, there is a "fallback" available - create a file like src/pages/admin/[...path].tsx and put this inside:

import { useRouter } from 'next/router';
import { ListDetailRenderer } from '@/admin';
import CustomWrapperComponentToBringInDataModels from '...';

export default function Page() {
  const router = useRouter();
  const path = router.query.path ? router.query.path as Array<string> : null;
  if (!path) {
    return null;
  }

  return (
    <CustomWrapperComponentToBringInDataModels>

      {/* This component will render fallback default versions of list and detail pages */}
      {/* for any models which don't have a pre-existing set of pages defined. */}
      <ListDetailRenderer
        basePath="/admin"
        name={path[0]}
        view={path.length > 1 ? 'detail' : 'list'}
        itemKey={path[1] === 'new' ? undefined : path[1]}
      />
      {/* ^ Note that as of early may 2024, this component doesn't generate filters properly */}

    </CustomWrapperComponentToBringInDataModels>
  );
}