🎤 Builder Talk: The Story Behind Lady Gaga’s Digital Experience – Register now

Conditional 'Draft Lock' Input Component

By Paul Welsh

Add the ability to make your document conditionally `readOnly` using an input component, where Sanity React hooks are available

ConditionalReadOnlyDocumentInput.tsx

import { ReadOnlyIcon } from '@sanity/icons';
import { Box, Card, Flex, Stack, Text } from '@sanity/ui';
import { defineQuery } from 'groq';
import { Suspense, useEffect, useState } from 'react';
import { type ObjectInputProps, useClient } from 'sanity';

type MetadataResponse = {
  state: string;
};

export const makeReadOnly = <T extends object | unknown[] | null>(
  value: T,
  keys: string[] = [
    'item',
    'field',
    'fields',
    'members',
    'of',
    'type',
    'array',
    'items',
  ],
  seen = new WeakSet(),
): T => {
  // Early return for primitives, null, undefined, or circular references
  if (value == null || typeof value !== 'object' || seen.has(value as object)) {
    return value;
  }

  seen.add(value as object);

  // Handle arrays with direct return
  if (Array.isArray(value)) {
    return value.map((item) => makeReadOnly(item, keys, seen)) as T;
  }

  // Create result object with readOnly flag
  const result = Object.assign({}, value as object, { readOnly: true });
  const objectKeys = Object.keys(value as object);
  const keysToProcess = objectKeys.filter((key) => keys.includes(key));

  // Process each key that needs to be made readonly
  for (const key of keysToProcess) {
    const val = (value as Record<string, unknown>)[key];
    const shouldMakeReadOnly = val && typeof val === 'object';

    (result as Record<string, unknown>)[key] = shouldMakeReadOnly
      ? makeReadOnly(val, keys, seen)
      : val;
  }

  // Return the result as the original type
  return result as T;
};


const ConditionalReadOnlyDocumentInput = (props: ObjectInputProps) => {
  const client = useClient({ apiVersion: '2024-01-17' });
  const [isReadOnly, setIsReadOnly] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const [readOnlyProps, setReadOnlyProps] = useState<ObjectInputProps | null>(
    null,
  );

  // In this example, we're using a query to fetch the metadata associated with the document. This would be better as a listener but this demonstrates the async functionality enough
  useEffect(() => {
    const fetchMetadata = async () => {
      try {
        setIsLoading(true);

        if (!props.value?._id) {
          setIsReadOnly(false);
          return;
        }

        const query = defineQuery(`*[type == $type && documentId == $id][0] { state }`);
        const params = {
          id: props.value._id,
          type: 'metadata',
        };
        const response = await client.fetch<MetadataResponse>(query, params);
        setIsReadOnly(!!(response?.state === 'approved'));
   
      } catch (error) {
        console.error(error);
      } finally {
        setIsLoading(false);
      }
    };
    fetchMetadata();
  }, [props.value?._id, client]);

  // Update the readOnly props when the props change
  useEffect(() => {
    setReadOnlyProps(makeReadOnly<ObjectInputProps>(props));
  }, [props]);

  if (!isReadOnly || !readOnlyProps) {
    return props.renderDefault(props);
  }

  return (
    <Suspense fallback={<div>Loading...</div>}>
      {!isLoading && (
        <Stack space={5}>
          <Card
            padding={3}
            border
            tone="primary"
            radius={2}
          >
            <Flex
              gap={3}
              align="center"
            >
              <Flex
                style={{ minWidth: 24 }}
                align="center"
                justify="center"
              >
                <ReadOnlyIcon
                  width={24}
                  height={24}
                />
              </Flex>
              <Box>
                <Text size={1}>
                  This document cannot be edited while in an approved state.
                  Either publish, discard changes or move the document back into
                  review.
                </Text>
              </Box>
            </Flex>
          </Card>
          {props.renderDefault(readOnlyProps)}
        </Stack>
      )}
    </Suspense>
  );
};

export default ConditionalReadOnlyDocumentInput;

exampleSchemaType.ts

import { defineType } from 'sanity';

// Import the component from your project
import ConditionalReadOnlyDocumentInput from '../components/ConditionalReadOnlyDocumentInput';

export const exampleSchemaType = defineType({
  // ...all other schema type definitions
  type: 'document',
  components: {
    input: ConditionalReadOnlyDocumentInput,
  },
});

I had the need to disable editing of a document based on the value of another document (essentially a Draft Lock). The `readOnly` resolver on a document schema type has no access to the document store or client, so I resorted to using an input component where react hooks were available.

This could be classed as a hack BUT it solves a need that may be introduced within the core functionality in the future.

Contributor