Skip to main content

schema.Collection

Collections are entities but for Arrays or Values.

This makes them well suited at handling mutations. You can add to Array Collections with .push or .unshift and Values Collections with .assign.

RestEndpoint provides .push, .unshift, .assign and .getPage/ .paginated() extenders when using Collections

Usage

export class Todo extends Entity {
  id = '';
  userId = 0;
  title = '';
  completed = false;

  pk() {
    return this.id;
  }
  static key = 'Todo';
}

export const getTodos = new RestEndpoint({
  path: '/todos',
  schema: new schema.Collection([Todo]),
});
import { Todo } from './Todo';

export class User extends Entity {
  id = '';
  name = '';
  username = '';
  email = '';
  todos: Todo[] = [];

  pk() {
    return this.id;
  }
  static key = 'User';
  static schema = {
    todos: new schema.Collection([Todo], {
      nestKey: (parent, key) => ({
        userId: parent.id,
      }),
    }),
  };
}

export const getUsers = new RestEndpoint({
  path: '/users',
  schema: new schema.Collection([User]),
});
import { getTodos } from './api/Todo';

export default function NewTodo({ userId }: { userId?: string }) {
  const ctrl = useController();
  const [unshift, setUnshift] = React.useState(false);

  const handlePress = async e => {
    if (e.key === 'Enter') {
      const createTodo = unshift ? getTodos.unshift : getTodos.push;
      ctrl.fetch(createTodo, {
        title: e.currentTarget.value,
        userId,
      });
      e.currentTarget.value = '';
    }
  };

  return (
    <div>
      <input type="text" onKeyDown={handlePress} />
      <label>
        <input
          type="checkbox"
          checked={unshift}
          onChange={e => setUnshift(e.currentTarget.checked)}
        />{' '}
        unshift
      </label>
    </div>
  );
}
import { type Todo } from './api/Todo';
import NewTodo from './NewTodo';

export default function TodoList({
  todos,
  userId,
}: {
  todos: Todo[];
  userId: string;
}) {
  return (
    <div>
      {todos.map(todo => (
        <div key={todo.pk()}>{todo.title}</div>
      ))}
      <NewTodo userId={userId} />
    </div>
  );
}
import { getUsers } from './api/User';
import TodoList from './TodoList';

function UserList() {
  const users = useSuspense(getUsers);
  return (
    <div>
      {users.map(user => (
        <section key={user.pk()}>
          <h3>{user.name}</h3>
          <TodoList todos={user.todos} userId={user.id} />
        </section>
      ))}
    </div>
  );
}
render(<UserList />);
🔴 Live Preview
Store

Options

argsKey(...args): Object

Returns a serializable Object whose members uniquely define this collection based on Endpoint arguments.

import { schema, RestEndpoint } from '@data-client/rest';

const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new schema.Collection([Todo], {
argsKey: (urlParams: { userId?: string }) => ({
...urlParams,
}),
}),
});

nestKey(parent, key): Object

Returns a serializable Object whose members uniquely define this collection based on the parent it is nested inside.

import { schema, Entity } from '@data-client/rest';

class Todo extends Entity {
id = '';
userId = '';
title = '';
completed = false;

pk() {
return this.id;
}
static key = 'Todo';
}

class User extends Entity {
id = '';
name = '';
username = '';
email = '';
todos: Todo[] = [];

pk() {
return this.id;
}
static key = 'User';
static schema = {
todos: new schema.Collection([Todo], {
nestKey: (parent, key) => ({
userId: parent.id,
}),
}),
};
}

nonFilterArgumentKeys?

nonFilterArgumentKeys defines a test to determine which argument keys are not used for filtering the results. For instance, if your API uses 'orderBy' to choose a sort - this argument would not influence which entities are included in the response.

const getPosts = new RestEndpoint({
path: '/:group/posts',
searchParams: {} as { orderBy?: string; author?: string },
schema: new schema.Collection([Post], {
nonFilterArgumentKeys(key) {
return key === 'orderBy';
},
}),
});

For convenience you can also use a RegExp or list of strings:

const getPosts = new RestEndpoint({
path: '/:group/posts',
searchParams: {} as { orderBy?: string; author?: string },
schema: new schema.Collection([Post], {
nonFilterArgumentKeys: /orderBy/,
}),
});
const getPosts = new RestEndpoint({
path: '/:group/posts',
searchParams: {} as { orderBy?: string; author?: string },
schema: new schema.Collection([Post], {
nonFilterArgumentKeys: ['orderBy'],
}),
});

In this case, author and group are considered 'filter' argument keys, which means they will influence whether a newly created should be added to those lists. On the other hand, orderBy does not need to match when push is called.

import { Entity, RestEndpoint } from '@data-client/rest';

class Post extends Entity {
  id = '';
  title = '';
  group = '';
  author = '';

  pk() {
    return this.id;
  }
}
export const getPosts = new RestEndpoint({
  path: '/:group/posts',
  searchParams: {} as { orderBy?: string; author?: string },
  schema: new schema.Query(
    new schema.Collection([Post], {
      nonFilterArgumentKeys: /orderBy/,
    }),
    (posts, { orderBy } = {}) => {
      if (orderBy) {
        return [...posts].sort((a, b) => a[orderBy].localeCompare(b[orderBy]));
      }
      return posts;
    },
  )
});
import { useLoading } from '@data-client/hooks';

export default function PostListLayout({
  postsByBob,
  postsSorted,
  addPost,
}) {
  const [handleSubmit, loading] = useLoading(addPost);
  return (
    <div>
      <h4>&#123;group: 'react', author: 'bob'&#125;</h4>
      <ul>
        {postsByBob.map(post => (
          <li key={post.pk()}>
            {post.title} by {post.author}
          </li>
        ))}
      </ul>
      <h4>&#123;group: 'react', orderBy: 'title'&#125;</h4>
      <ul>
        {postsSorted.map(post => (
          <li key={post.pk()}>
            {post.title} by {post.author}
          </li>
        ))}
      </ul>
      <form onSubmit={handleSubmit}>
        <div>Group: React</div>
        Author: 
        <label>
          <input type="radio" value="bob" name="author" defaultChecked />
          Bob
        </label>
        <label>
          <input type="radio" value="clara" name="author" />
          Clara
        </label>
        <input type="text" defaultValue="New Post" name="title" />
        <button type="submit">{loading ? 'loading...' : 'Push'}</button>
      </form>
    </div>
  );
}
import { useSuspense, useController } from '@data-client/react';
import { getPosts } from './getPosts';
import PostListLayout from './PostListLayout';

function PostList() {
  const postsByBob = useSuspense(getPosts, {
    group: 'react',
    author: 'bob',
  });
  const postsSorted = useSuspense(getPosts, {
    group: 'react',
    orderBy: 'title',
  });

  const ctrl = useController();

  const addPost = (e) => {
    e.preventDefault();
    return ctrl.fetch(
      getPosts.push,
      { group: 'react' },
      new FormData(e.currentTarget),
    );
  }
  return (
    <PostListLayout
      postsByBob={postsByBob}
      postsSorted={postsSorted}
      addPost={addPost}
    />
  );
}
render(<PostList />);
🔴 Live Preview
Store

createCollectionFilter?

Sets a default createCollectionFilter for addWith(), push, unshift, and assign.

This is used by these creation schemas to determine which collections to add to.

Default:

const defaultFilter =
(urlParams: Record<string, any>, body?: Record<string, any>) =>
(collectionKey: Record<string, string>) =>
Object.entries(collectionKey).every(
([key, value]) =>
key.startsWith('order') ||
// double equals lets us compare non-strings and strings
urlParams[key] == value ||
body?.[key] == value,
);

Methods

push

A creation schema that places at the end of this collection

unshift

A creation schema that places at the start of this collection

assign

A creation schema that assigns its members to the Collection.

addWith(merge, createCollectionFilter): CreationSchema

Constructs a custom creation schema for this collection. This is used by push, unshift, assign and paginate

merge(collection, creation)

This merges the value with the existing collection

createCollectionFilter

This function is used to determine which collections to add to. It uses the Object returned from argsKey or nestKey to determine if that collection should get the newly created values from this schema.

Because arguments may be serializable types like number, we recommend using == comparisons, e.g., '10' == 10

(...args) =>
collectionKey =>
boolean;

Lifecycle Methods

static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean

static shouldReorder(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return incomingMeta.fetchedAt < existingMeta.fetchedAt;
}

true return value will reorder incoming vs in-store entity argument order in merge. With the default merge, this will cause the fields of existing entities to override those of incoming, rather than the other way around.

static merge(existing, incoming): mergedValue

static merge(existing: any, incoming: any) {
return incoming;
}

static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue

static mergeWithStore(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
): any;

mergeWithStore() is called during normalization when a processed entity is already found in the store.

pk: (parent?, key?, args?): pk?

pk() calls argsKey or nestKey depending on which are specified, and then serializes the result for the pk string.

pk(value: any, parent: any, key: string, args: readonly any[]) {
const obj = this.argsKey
? this.argsKey(...args)
: this.nestKey(parent, key);
for (const key in obj) {
if (typeof obj[key] !== 'string') obj[key] = `${obj[key]}`;
}
return JSON.stringify(obj);
}