Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable cell to take functions for a query #505

Merged
merged 8 commits into from
May 21, 2020

Conversation

eurobob
Copy link
Contributor

@eurobob eurobob commented May 3, 2020

Description

In order to be able to write dynamic queries in a cell, we need to be able to optionally pass the component props.

Motivation

I have a project with 39 tables in the database, and the files required for listing and editing that many different entities with the cell system is a little overwhelming. Since so much of that was repetitive code I figured an abstraction would be worthwhile, and could provide basis for a more cohesive built-in admin interface

Proposed usage

I managed to get this working quite well in my project. Whether or not it is aligned with the vision of Redwood is another matter. This is my first sincere attempt at OSS contribution so go easy on me please 😅
I was going to go ahead and do the work, and I'm very willing to help out with implementing this, but wanted to get the green light. All this magic can be enabled by this very small addition to the codebase and I think it will make users lives much easier!

Routes.js

Routes take the model variable in plural form routes.entities({ model: 'posts' })

import { Router, Route } from '@redwoodjs/router'

const Routes = () => {
  return (
    <Router>
      <Route path="/admin" page={AdminPage} name="admin" />
      <Route path="/admin/{model}" page={AdminEntitiesPage} name="entities" />

      {/* NOTE: I managed to make the edit page work for editing and creating, but stopped short of reducing it down to a single route */}

      <Route
        path="/admin/{model}/new"
        page={AdminEditEntityPage}
        name="editEntity"
      />
      <Route
        path="/admin/{model}/{id:Int}"
        page={AdminEditEntityPage}
        name="editEntity"
      />
      <Route path="/about" page={AboutPage} name="about" />
      <Route path="/" page={HomePage} name="home" />
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

pages/Admin/EntitiesPage/EntitiesPage.js

Admin layout for my use is a Material-UI wrapper and irrelevant for this demonstration

import pluralize from 'pluralize'
import AdminLayout from 'src/layouts/AdminLayout'
import EntitiesCell from 'src/components/admin/EntitiesCell'

const EntitiesPage = ({ model }) => {
  const singular = pluralize.singular(model)
  return (
    <AdminLayout>
      <EntitiesCell singular={singular} model={model} />
    </AdminLayout>
  )
}

export default EntitiesPage

components/admin/EntitiesCell/EntitiesCell.js

✨✨✨Where the magic happens✨✨✨

import Entities from 'src/components/admin/Entities'
// Ok, some of the magic comes from here
import * as queries from 'src/queries'

import { capitalize } from 'src/functions/helpers'

export const QUERY = ({ model, singular }) => {
  const { EntityList } = queries[model]

  return gql`
    query {
      entities: ${model} {
        ...${`${capitalize(singular)}List`}
      }
    }
    ${EntityList}
  `
}

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }) => <div>Error: {error.message}</div>

export const Success = ({ model, entities, singular }) => {
  return <Entities singular={singular} model={model} entities={entities} />
}

src/queries

Just an index file to keep things organized

import * as posts from './posts

export {
  posts,
}

src/queries/posts.js

✨✨✨gql fragment magic✨✨✨
This how where we create uniquely named fragments that get passed into the cell.
For each entity we create we can optionally view minimal table columns (some of my tables are big too!)
Additionally, we can specify relations that we want to know about when creating/editing entities

export const EntityList = gql`
  fragment PostList on Post {
    id
    title
  }
`

export const Entity = gql`
  fragment PostEntity on Post {
    id
    title
    body
  }
`

export const Relations = gql`
  authors {
    id
    name
  }
`

src/components/admin/Entities/Entities.js

And finally list them out automatically

import { useMutation } from '@redwoodjs/web'
import { Link, routes } from '@redwoodjs/router'
import { capitalize } from 'src/functions/helpers'
import * as queries from 'api/src/queries'

const MAX_STRING_LENGTH = 150

const truncate = (text) => {
  let output = text
  if (text.length > MAX_STRING_LENGTH) {
    output = output.substring(0, MAX_STRING_LENGTH) + '...'
  }
  return output
}

const timeTag = (datetime) => {
  return (
    <time dateTime={datetime} title={datetime}>
      {new Date(datetime).toUTCString()}
    </time>
  )
}

const EntitiesList = ({ model, entities, singular }) => {
  const singularCapital = capitalize(singular)
  const [deleteEntity] = useMutation(DELETE_ENTITY_MUTATION)

  const DELETE_ENTITY_MUTATION = gql`
    mutation DeleteEntityMutation($id: Int!) {
      delete${singularCapital}(id: $id) {
        ...${`${singularCapital}List`}
      }
    }
    ${queries[model].EntityList}
  `

  const onDeleteClick = (id) => {
    if (confirm(`Are you sure you want to delete ${singular} ${id}?`)) {
      // NOTE: also didn't get around to playing with refetchQueries yet
      deleteEntity({ variables: { id }, refetchQueries: ['POSTS'] })
    }
  }

  return (
    <div className="bg-white text-gray-900 border rounded-lg overflow-x-scroll">
      <table className="table-auto w-full min-w-3xl text-sm">
        <thead>
          <tr className="bg-gray-300 text-gray-700">
            {Object.keys(entities[0]).map(key => (
              <th key={key} className="font-semibold text-left p-3">{key}</th>
            ))}
            <th className="font-semibold text-left p-3">&nbsp;</th>
          </tr>
        </thead>
        <tbody>
          {entities.map((entity) => (
            <tr
              key={entity.id}
              className="odd:bg-gray-100 even:bg-white border-t"
            >
              {Object.values(entity).map((value) => (
                <td className="p-3">{truncate(value)}</td>
              ))}
              <td className="p-3 pr-4 text-right whitespace-no-wrap">
                <nav>
                  <ul>
                    <li className="inline-block">
                      <Link
                        to={routes.entity({ id: entity.id })}
                        title={'Show entity ' + entity.id + ' detail'}
                        className="text-xs bg-gray-100 text-gray-600 hover:bg-gray-600 hover:text-white rounded-sm px-2 py-1 uppercase font-semibold tracking-wide"
                      >
                        Show
                      </Link>
                    </li>
                    <li className="inline-block">
                      <Link
                        to={routes.editEntity({ id: entity.id })}
                        title={'Edit entity ' + entity.id}
                        className="text-xs bg-gray-100 text-blue-600 hover:bg-blue-600 hover:text-white rounded-sm px-2 py-1 uppercase font-semibold tracking-wide"
                      >
                        Edit
                      </Link>
                    </li>
                    <li className="inline-block">
                      <a
                        href="#"
                        title={'Delete entity ' + entity.id}
                        className="text-xs bg-gray-100 text-red-600 hover:bg-red-600 hover:text-white rounded-sm px-2 py-1 uppercase font-semibold tracking-wide"
                        onClick={() => onDeleteClick(entity.id)}
                      >
                        Delete
                      </a>
                    </li>
                  </ul>
                </nav>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

export default EntitiesList

src/components/admin/EditEntityCell/EditEntityCell.js

Ok, so things get a little messy in this file, particularly in the Success method, but this is how I was able to only use one file for editing and creating. Maybe this is over-engineering and I welcome any feedback on how this is best approached.
I've included a relations example, so that when creating a post you could choose an author from a select input
The EntityForm component is a custom behemoth outside the remit of this PR. I'd like to author one that uses the built in redwood form tools, but my own project has more custom requirements. Note that by passing all Success props to it, we can access the relation queries for select inputs inside the form

import { useMutation } from '@redwoodjs/web'
import { navigate, routes } from '@redwoodjs/router'
import * as queries from 'api/src/queries'
import EntityForm from 'src/components/admin/EntityForm'

import { parseIds, capitalize } from 'src/functions/helpers'

export const QUERY = ({ id, model, singular }) => {
  if (!id) {
    return gql`
      query {
        ${queries[model].Relations}
      }
    `
  }
  return gql`
    query FIND_ENTITY_BY_ID($id: Int!) {
      entity: ${singular}(id: $id) {
        ...${`${capitalize(singular)}Entity`}
      }
      ${queries[model].Relations}
    }
    ${queries[model].Entity}
  `
}

export const Loading = () => <div>Loading...</div>

export const Success = (props) => {
  const { entity, model, singular } = props
  const singularCapital = capitalize(singular)
  const type = entity ? 'update' : 'create'
  const typeCapital = capitalize(type)
  const ENTITY_MUTATION = entity
    ? gql`
    mutation EntityMutation($id: Int!, $input: ${`${typeCapital}${singularCapital}Input`}!) {
      ${`${type}${singularCapital}`}(id: $id, input: $input) {
        id
      }
    }
  `
    : gql`
  mutation EntityMutation($input: ${`${typeCapital}${singularCapital}Input`}!) {
    ${`${type}${singularCapital}`}(input: $input) {
      id
    }
  }
`

  const [upsertEntity, { loading, error }] = useMutation(ENTITY_MUTATION, {
    onCompleted: () => {
      navigate(routes.entities({ model }))
    },
  })

  const onSave = (input, id) => {
    upsertEntity({ variables: { id, input: parseIds(input) } })
  }

  return (
    <div className="bg-white border rounded-lg overflow-hidden">
      <div className="bg-gray-100 p-4">
        <EntityForm
          {...props}
          onSave={onSave}
          error={error}
          loading={loading}
        />
      </div>
    </div>
  )
}

whew...thanks for reading. Let me know if it makes sense or doesn't!

In order to be able to write dynamic queries in a cell, we need to be able to optionally pass the component props
(Almost got away with editing in the github browser 😛)
@peterp
Copy link
Contributor

peterp commented May 4, 2020

Hey @eurobob,

Thanks for this, we actually have two functions that surround the execution of the query:

beforeQuery and afterQuery can be exported in a cell to modify the props that are passed into the Query, and afterQuery for the modification of the props.

https://github.com/redwoodjs/redwoodjs.com/blob/33fe1fc9dc2df5a8f25fae8d1b948e190540ba53/TUTORIAL.md#cells

I don't know if that gives you the same functionality as this PR?

@eurobob
Copy link
Contributor Author

eurobob commented May 5, 2020

@peterp

It seems not, beforeQuery passes props onto the <Query> Component, but only variables are accessible to the query parameters within the gql object. Unless I'm missing something in the Apollo Docs it's not possible to use those variables anywhere else in the template literal

@peterp peterp self-requested a review May 5, 2020 18:30
@peterp
Copy link
Contributor

peterp commented May 6, 2020

@eurobob Sorry for not grasping the intent of your PR initially. I think allowing a function for QUERY is awesome!

packages/web/src/graphql/withCell.js Outdated Show resolved Hide resolved
packages/web/src/graphql/withCell.js Outdated Show resolved Hide resolved
@peterp peterp force-pushed the master branch 6 times, most recently from 711c520 to 2341368 Compare May 10, 2020 10:07
@peterp
Copy link
Contributor

peterp commented May 11, 2020

I broke linting here. :/

@eurobob
Copy link
Contributor Author

eurobob commented May 12, 2020

Changes make a lot of sense 👍🏻

@peterp peterp added this to the next release milestone May 15, 2020
@thedavidprice thedavidprice removed this from the next release milestone May 19, 2020
@peterp peterp added this to the next release milestone May 21, 2020
@peterp peterp merged commit 76834d5 into redwoodjs:master May 21, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants