Airtable Dropdown

/* eslint-disable */

enableDebugMode();

/**
 * Questmate Custom Component - Airtable Dropdown (Questscript)
 *
 * Permissions:
 * @UseApp {AIRTABLE}
 *
 * Changelog:
 * v0.1 Initial release
 * v0.2 Added support for Airtable views
 * v0.3 Adding sorting for dropdown options
 * v0.4 Improved sorting for dropdown options (numbers, case insensitive)
 * v1.0 Use new Custom Item API
 */

return defineCustomItem((Questmate) => {
  Questmate.registerView("ITEM_RUN_VIEW", ({ useRunData, useConfigData }) => {
    const [baseId] = useConfigData("baseId");
    const [tableId] = useConfigData("tableId");
    const [viewId] = useConfigData("viewId");
    const [columnId] = useConfigData("columnId");
    const [selectedRowId, setSelectedRowId] = useRunData("rowId");

    return {
      components: [
        {
          id: "row",
          type: "dropdown",
          title: "Member",
          icon: "person",
          disabled: false,
          optionNoun: "Member",
          optionPluralNoun: "Members",
          value: selectedRowId,
          getOptions: async () => {
            return (
              await rowsDataSource.retrieve(baseId, tableId, viewId, columnId)
            )
              .map((row) => ({
                label: row.fields[columnId],
                value: row.id,
              }))
              .sort((a, b) =>
                a.label.localeCompare(b.label, undefined, {
                  numeric: true,
                  sensitivity: "base",
                })
              );
          },
          onSelect: setSelectedRowId,
        },
      ],
    };
  });

  Questmate.registerView("ITEM_CONFIG_VIEW", async ({ useConfigData }) => {
    const viewComponents = [];
    const [selectedBaseId, setSelectedBaseId] = useConfigData("baseId", null);
    const [selectedTableId, setSelectedTableId] = useConfigData(
      "tableId",
      null
    );
    const [selectedViewId, setSelectedViewId] = useConfigData("viewId", null);
    const [selectedColumnId, setSelectedColumnId] = useConfigData(
      "columnId",
      null
    );

    async function getSelectedTable() {
      const tables = await tablesDataSource.retrieve(selectedBaseId);
      return tables.find(
        (table) => !!selectedTableId && table.id === selectedTableId
      );
    }

    viewComponents.push({
      id: "base",
      title: "Airtable Base",
      type: "dropdown",
      optionNoun: "Base",
      optionPluralNoun: "Bases",
      value: selectedBaseId,
      getOptions: async () =>
        (await basesDataSource.retrieve()).map((base) => ({
          label: base.name,
          value: base.id,
        })),
      onSelect: async (newBaseId) => {
        const changed = newBaseId !== selectedBaseId;
        if (changed) {
          const isValidBase =
            newBaseId &&
            (await basesDataSource.retrieve()).some(
              (base) => base.id === newBaseId
            );

          setSelectedBaseId(isValidBase ? newBaseId : null);
          setSelectedTableId(null);
          setSelectedColumnId(null);
          setSelectedViewId(null);
        }
      },
    });

    if (selectedBaseId) {
      viewComponents.push({
        id: "table",
        title: "Table",
        type: "dropdown",
        optionNoun: "Table",
        optionPluralNoun: "Tables",
        value: selectedTableId,
        getOptions: async () =>
          (await tablesDataSource.retrieve(selectedBaseId)).map((table) => ({
            label: table.name,
            value: table.id,
          })),
        onSelect: async (newTableId) => {
          const changed = newTableId !== selectedTableId;
          if (changed) {
            const isValidTable =
              newTableId &&
              (await tablesDataSource.retrieve(selectedBaseId)).some(
                (table) => table.id === newTableId
              );
            setSelectedTableId(isValidTable ? newTableId : null);
            setSelectedViewId(null);
            setSelectedColumnId(null);
          }
        },
      });
    }

    if (selectedTableId) {
      viewComponents.push({
        id: "view",
        title: "View",
        type: "dropdown",
        optionNoun: "View",
        optionPluralNoun: "Views",
        value: selectedViewId,
        getOptions: async () => {
          const selectedTable = await getSelectedTable();
          return selectedTable.views.map((view) => ({
            label: view.name,
            value: view.id,
          }));
        },
        onSelect: async (newViewId) => {
          const changed = newViewId !== selectedViewId;
          if (changed) {
            const isValidView =
              newViewId &&
              (await getSelectedTable()).views.some(
                (view) => view.id === newViewId
              );
            setSelectedViewId(isValidView ? newViewId : null);
            setSelectedColumnId(null);
          }
        },
      });
    }

    if (selectedViewId) {
      viewComponents.push({
        id: "column",
        title: "Column",
        type: "dropdown",
        optionNoun: "Column",
        optionPluralNoun: "Columns",
        value: selectedColumnId,
        getOptions: async () => {
          const selectedTable = await getSelectedTable();
          return selectedTable.fields.map((field) => ({
            label: field.name,
            value: field.id,
          }));
        },
        onSelect: async (newColumnId) => {
          const changed = newColumnId !== selectedColumnId;
          if (changed) {
            const isValidColumn =
              newColumnId &&
              (await getSelectedTable()).fields.some(
                (field) => field.id === newColumnId
              );

            setSelectedColumnId(isValidColumn ? newColumnId : null);
          }
        },
      });
    }

    return {
      components: viewComponents,
    };
  });

  const basesDataSource = Questmate.registerDataSource({
    id: "bases",
    initialData: [],
    refreshInterval: 15,
    fetcher: () => async () => {
      const basesData = await fetch("https://api.airtable.com/v0/meta/bases");
      const { bases } = await basesData.json();
      if (!bases) {
        return {
          error: {
            message: "Failed to retrieve bases",
            details: { response: basesData },
          },
        };
      }

      return { data: bases };
    },
  });

  const tablesDataSource = Questmate.registerDataSource({
    id: "tables",
    initialData: [],
    refreshInterval: 15,
    fetcher: () => async (baseId) => {
      const tablesData = await fetch(
        `https://api.airtable.com/v0/meta/bases/${baseId}/tables`
      );
      const { tables } = await tablesData.json();

      if (!tables) {
        return {
          error: {
            message: "Failed to retrieve tables",
            details: { baseId, response: tablesData },
          },
        };
      }

      return { data: tables };
    },
  });

  const rowsDataSource = Questmate.registerDataSource({
    id: "rows",
    initialData: [],
    refreshInterval: 60,
    aggregator: ({ results, staleResults }) => {
      // combine stale pages with fresh pages to provide most up-to-date information
      return (
        [...results, ...staleResults]
          .flatMap((result) => result.data)
          // filter out duplicate row data
          .filter(
            (row, index, self) =>
              self.findIndex(({ id }) => id === row.id) === index
          )
      );
    },
    fetcher:
      ({ nextPageMarker }) =>
      async (baseId, tableId, viewId, columnId) => {
        const response = await fetch(
          `https://api.airtable.com/v0/${baseId}/${tableId}?maxRecords=2000&returnFieldsByFieldId=true&fields%5B%5D=${columnId}${
            nextPageMarker ? `&offset=${nextPageMarker}` : ""
          }&view=${viewId}`
        );
        if (response.status === 429) {
          return {
            error: {
              message: "Rate limit reached. Retrying soon.",
              retryAfter: 30,
            },
          };
        }

        const body = await response.json();

        if (!body.records || body.records.length === 0) {
          return {
            error: {
              message: "Failed to retrieve rows",
              details: {
                response,
              },
            },
          };
        }

        return {
          nextPageMarker: body.offset,
          data: body.records
            // filter out rows without labels (and ids, which shouldn't happen)
            .filter(
              ({ id, fields }) => Boolean(id) && Boolean(fields[columnId])
            ),
        };
      },
  });
});

Copyright © Questmate Pty Ltd.