sanity-io / sanity

Sanity Studio – Rapidly configure content workspaces powered by structured content
https://www.sanity.io
MIT License
5.15k stars 415 forks source link

Add extension points to core Document Actions #2041

Open tomsseisums opened 3 years ago

tomsseisums commented 3 years ago

Is your feature request related to a problem? Please describe. Somewhat related to this: https://github.com/sanity-io/sanity/issues/1932

For such a simple and, I think, frequent task of "updating a value on publish", the effort currently necessary to achieve it is crazy.

The "Update a value then publish", besides lacking as per #1932, also skips a lot of design nuances that have went into core Publish Action.

For a scenario, where all we actually want is to auto-update a value, our option now is to reimplement the whole implementation. That opens our extensions up to maintenance issues, as an update to Sanity core could change something that our extensions are not aware of.

All in all, I think the example displays a major oversight on your API design.

Describe the solution you'd like A simple addition of extension points would really provide for a much better DX!

One solution would be to provide an optional prop, i.e. prePublish, to Publish Action. That is then called before publish.execute here: https://github.com/sanity-io/sanity/blob/2513e0eb112a5994feaba4dc6ac5322f2e865c25/packages/%40sanity/desk-tool/src/actions/PublishAction.tsx#L39-L42

Like:

const doPublish = useCallback(() => {
  props.prePublish?.()
  publish.execute()
  setPublishState('publishing')
}, [publish, props.prePublish])

Providing us with a really easy to use extension point:

import { PublishAction as SanityPublishAction } from "part:@sanity/base/document-actions";

export default EnhancedPublishAction(props) {
  const { patch } = useDocumentOperation(props.id, props.type);
  const prePublish = () => patch.execute(patchSet);
  return SanityPublishAction({ ...props, prePublish });
}

And add other frequent use & happy-path extension points for other Actions that could be extended.

This seems like a really elegant extension to your already curious use of Hooks for Document Actions.

One more thing that comes to mind is then to somehow cache studio hooks per component, because this usage will result in two useDocumentOperation calls and I don't know the performance implications of these yet.

Describe alternatives you've considered Other option would be to refactor native actions to export factories, then, your default action could derive from it:

// sanity/packages/@sanity/desk-tool/src/actions/PublishAction.tsx

export function publishActionFactory(options) {
  return (props) => {
    /* PublishAction tuned via options */
  }
}

export const PublishAction = publishActionFactory(defaultOptions)

And our extensions too:

import { publishActionFactory} from "part:@sanity/base/document-actions";

const EnhancedPublishAction = publishActionFactory({ prePublish: ({ patch }) => patch.execute(patchSet) })
export default EnhancedPublishAction

But this quickly becomes cumbersome, because the extension points have to provide dependencies. And in that scenario it becomes a game of prediction about what the extension developer would really need... As can be seen in the example, for instance, to get patch I don't have the option anymore to use studio hooks. (And let's not get started on props.draft or props.published which for illustration purposes already would become a hassle)

Additional context I have already extended PublishAction as it is now, using the same idea. But the hoops I had to jump to provide a slightly tweaked onHandle! I ended up introducing a maaaaasssive boilerplate and code duplication (of core action) with it:

import { PublishAction as SanityPublishAction } from "part:@sanity/base/document-actions";
import { useState, useMemo, useCallback, useEffect } from "react";
import {
  useDocumentOperation,
  useSyncState,
  useValidationStatus,
} from "@sanity/react-hooks";

export default function EnhancedPublishAction(props) {
  const publishAction = SanityPublishAction(props);
  const { patch } = useDocumentOperation(props.id, props.type); # significant part for my action
  const validationStatus = useValidationStatus(props.id, props.type);
  const syncState = useSyncState(props.id, props.type);

  const patchSet = /* some patch preparation logic */ # significant part for my action

  const hasValidationErrors = useMemo(
    () => validationStatus.markers.some((marker) => marker.level === "error"),
    [validationStatus]
  );
  const [publishScheduled, setPublishScheduled] = useState(false);

  const onHandle = useCallback(() => {
    if (syncState.isSyncing || validationStatus.isValidating) {
      setPublishScheduled(true);
    } else {
      patch.execute(patchSet) # significant part for my action
      publishAction.onHandle();
    }
  }, [syncState.isSyncing, validationStatus.isValidating]);

  useEffect(() => {
    if (
      publishScheduled &&
      !syncState.isSyncing &&
      !validationStatus.isValidating
    ) {
      if (!hasValidationErrors) {
        patch.execute(patchSet) # significant part for my action
        publishAction.onHandle();
      }
      setPublishScheduled(false);
    }
  }, [!syncState.isSyncing && !validationStatus.isValidating]);

  return {
    ...publishAction,
    onHandle,
  };
}

Otherwise, I think the core idea, as it already is for Document Actions, is an ingenious use of React Hooks. 🤯 Did you find this pattern in the wild or came up with it for yourselves?

RobEasthope commented 3 years ago

+1 for this. In a similar-ish use case I've created a custom action to generate page slugs based on document data. I'm also using the intl-input plugin handle i18n data.

While they both work well on their own any custom publish action knocks out the intl-input publish actions code which a deal breaker for my app.

Expanding @tomsseisums great idea for a pre-publish action, some functionality to run multiple actions before publishing the doc would be a great help and prevent clashes between different plugins.

devinhalladay commented 2 years ago

+1 on adding a prePublish prop to PublishAction! This would unlock some really powerful functionality to introduce time-saving features for editors. I am building a course platform in which Courses have many referenced Lessons, and each Lesson document has a computed field which uses groq to pull the "parent" course into a read-only field. Right now I have a custom document action labeled Compile Lessons which patches the referenced lessons to update that field, but I'd love to do it all in one go on document publish, including the nice UX affordances of the official PublishAction.

A hook would be cool but if I could do something as simple as this, that would be great too:

// Default document actions
import defaultResolve, {
  PublishAction,
} from 'part:@sanity/base/document-actions';

// Function which takes a document and patches all referenced lessons
import patchUpstreamLessons from './patchUpstreamLessons';

export default function resolveDocumentActions(props) {
  return [
    // Start with Sanity's default actions
    ...defaultResolve(props)
      // Filter out actions by document type
      .filter((action) => {
        /// ...

        return true;
      })
      .map((action) => {
        // Override the PublishAction on courses to add a prePublish callback
        if (props.type === 'course' && action === PublishAction) {
          return <PublishAction prePublishAction={patchUpstreamLessons} />;
        }

        return action;
      }),
    // Add our own custom actions
    shopifyLink,
  ];
}
surjithctly commented 2 years ago

+1 for this. I've ran in to same issues and thought why is it too complex, I was trying to auto-update slug and I have to redo a whole big document and that makes the UI buggy and I don't know how to fix those.

devinhalladay commented 2 years ago

FWIW I think this opens up really interesting design opportunities for the Sanity team. Mainly I am thinking about computed field values across referenced documents, but being able to push to an external API, compute a field value on publish, etc sounds like the beginning of really cool workflow design capabilities.