diff --git a/frontend/SettingsForm.js b/frontend/SettingsForm.js
new file mode 100644
index 0000000..8885d20
--- /dev/null
+++ b/frontend/SettingsForm.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React, {Fragment} from 'react';
+import {Field, FieldType, Table} from '@airtable/blocks/models';
+import {
+ Box,
+ Button,
+ FieldPickerSynced,
+ FormField,
+ Heading,
+ SelectButtonsSynced,
+ TablePickerSynced,
+} from '@airtable/blocks/ui';
+
+import {ConfigKeys, IsEnforced} from './settings';
+
+function SettingsForm({setIsSettingsVisible, settings}) {
+ return (
+
+
+ Settings
+
+
+
+ {settings.table && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+SettingsForm.propTypes = {
+ setIsSettingsVisible: PropTypes.func.isRequired,
+ settings: PropTypes.shape({
+ isEnforced: PropTypes.bool,
+ table: PropTypes.instanceOf(Table),
+ urlField: PropTypes.instanceOf(Field),
+ }).isRequired,
+};
+
+export default SettingsForm;
diff --git a/frontend/index.js b/frontend/index.js
index 0e78650..143849e 100644
--- a/frontend/index.js
+++ b/frontend/index.js
@@ -6,23 +6,58 @@ import {
useBase,
useRecordById,
useLoadable,
+ useSettingsButton,
+ useViewport,
useWatchable,
Box,
- Text,
- TextButton,
Dialog,
- Link,
Heading,
+ Link,
+ Text,
+ TextButton,
} from '@airtable/blocks/ui';
+import {useSettings} from './settings';
+import SettingsForm from './SettingsForm';
+
// How this block chooses a preview to show:
//
-// - The user selects a row in grid view.
-// - The block looks in the selected field for a supported URL
+// Without a specified Table & Field:
+// - The user selects a row in grid view.
+// - The block looks in the Selected Field for a supported URL
// (e.g. https://www.youtube.com/watch?v=KYz2wyBy3kc)
-// - The block uses this URL to construct an embed URL and inserts this URL into an iframe.
-
+// - The block uses this URL to construct an embed URL and inserts this URL into an iframe.
+//
+// With a Specified Table & Specified Field:
+//
+// - The user may use "Settings" to set a Specified Table and Specified Field for URL previews.
+// - The user may use "Settings" to toggle the Specified Table and Specified Field constraint.
+// - The user selects a row in grid view.
+// - If the Selected Field in the Active Table match the Specified Field & Specified Table, then:
+// - The block looks in the Selected Field for a supported URL
+// (e.g. https://www.youtube.com/watch?v=KYz2wyBy3kc)
+// - If the block supports this URL, then:
+// - The block uses this URL to construct an embed URL and inserts this URL into an iframe.
+// - Else,
+// - Display: "Select a cell to see a preview, View supported URLs"
+// - Else,
+// - If the Active Table does not match the Specified Table, then:
+// - Display: "Switch to the “[Specified Table]” table to see previews."
+// - If the Selected Field does match the Specified Field, then:
+// - Display: "Switch to the “[Specified Field]” field to see previews."
+//
+//
function UrlPreviewBlock() {
+ const viewport = useViewport();
+ const [isSettingsVisible, setIsSettingsVisible] = useState(false);
+ useSettingsButton(() => {
+ if (!isSettingsVisible) {
+ viewport.enterFullscreenIfPossible();
+ }
+ setIsSettingsVisible(!isSettingsVisible);
+ });
+ const settingsValidationResult = useSettings();
+
// Caches the currently selected record and field in state. If the user
// selects a record and a preview appears, and then the user de-selects the
// record (but does not select another), the preview will remain. This is
@@ -65,32 +100,77 @@ function UrlPreviewBlock() {
});
const base = useBase();
- const table = base.getTableByIdIfExists(cursor.activeTableId);
+ const activeTable = base.getTableByIdIfExists(cursor.activeTableId);
- // table is briefly null when switching to a newly created table.
- if (!table) {
+ // activeTable is briefly null when switching to a newly created activeTable.
+ if (!activeTable) {
return null;
}
return (
-
+
+ {isSettingsVisible ? (
+
+ ) : (
+
+ )}
+
);
}
// Shows a preview, or a message about what the user should do to see a preview.
-function RecordPreview({table, selectedRecordId, selectedFieldId}) {
+function RecordPreview({activeTable, settingsValidationResult, selectedRecordId, selectedFieldId}) {
+ const {settings, isValid, message} = settingsValidationResult;
+ const {isEnforced, urlField} = settings;
const [isDialogOpen, setIsDialogOpen] = useState(false);
+ let table = activeTable;
+ let content;
+
+ if (!isValid) {
+ content = (
+
+ {message}
+
+ );
+ }
+
+ // If the creator has specified a Table and Field for URL previews...
+ if (isEnforced && settings.table) {
+ table = settings.table;
+
+ if (!content) {
+ if (cursor.activeTableId !== table.id) {
+ content = (
+
+ Switch to the “{table.name}” table to see previews.
+
+ );
+ } else {
+ if (urlField.id !== selectedFieldId) {
+ content = (
+
+ Switch to the “{urlField.name}” field to see previews.
+
+ );
+ }
+ }
+ }
+ }
+
// We use getFieldByIdIfExists because the field might be deleted.
const selectedField = selectedFieldId ? table.getFieldByIdIfExists(selectedFieldId) : null;
-
- // Triggers a re-render if the record changes. Preview URL cell value
- // might have changed, or record might have been deleted.
const selectedRecord = useRecordById(table, selectedRecordId ? selectedRecordId : '', {
- fields: [selectedField],
+ // When an explicit urlField exists, limit lookup to that field,
+ // otherwise, use the selectedField
+ fields: [(isEnforced && urlField) || selectedField],
});
// Triggers a re-render if the user switches table or view.
@@ -104,10 +184,10 @@ function RecordPreview({table, selectedRecordId, selectedFieldId}) {
);
- let content;
if (
- cursor.activeViewId === null || // activeViewId is briefly null when switching views
- table.getViewById(cursor.activeViewId).type !== ViewType.GRID
+ !content &&
+ (cursor.activeViewId === null || // activeViewId is briefly null when switching views
+ table.getViewById(cursor.activeViewId).type !== ViewType.GRID)
) {
content = (
@@ -128,42 +208,45 @@ function RecordPreview({table, selectedRecordId, selectedFieldId}) {
);
} else {
- // Using getCellValueAsString guarantees we get a string back. If
- // we use getCellValue, we might get back numbers, booleans, or
- // arrays depending on the field type.
- const previewUrl = getPreviewUrlForCellValue(
- selectedRecord.getCellValueAsString(selectedField),
- );
-
- // In this case, the FIELD_NAME field of the currently selected
- // record either contains no URL, or contains a URL that cannot be
- // resolved to a supported preview.
- if (!previewUrl) {
- content = (
-
- No preview
- {viewSupportedURLsButton}
-
- );
- } else {
- content = (
-
-
-
+ // content may have been set previously
+ if (!content) {
+ // Using getCellValueAsString guarantees we get a string back. If
+ // we use getCellValue, we might get back numbers, booleans, or
+ // arrays depending on the field type.
+ const previewUrl = getPreviewUrlForCellValue(
+ selectedRecord.getCellValueAsString(selectedField),
);
+
+ // In this case, the FIELD_NAME field of the currently selected
+ // record either contains no URL, or contains a URL that cannot be
+ // resolved to a supported preview.
+ if (!previewUrl) {
+ content = (
+
+ No preview
+ {viewSupportedURLsButton}
+
+ );
+ } else {
+ content = (
+
+
+
+ );
+ }
}
}
@@ -223,124 +306,98 @@ function getPreviewUrlForCellValue(url) {
// Try to extract the preview URL from the URL using regular expression
// based helper functions for each service we support.
- const airtablePreviewUrl = getAirtablePreviewUrl(url);
- if (airtablePreviewUrl) {
- return airtablePreviewUrl;
- }
-
- const youtubePreviewUrl = getYoutubePreviewUrl(url);
- if (youtubePreviewUrl) {
- return youtubePreviewUrl;
- }
-
- const vimeoPreviewUrl = getVimeoPreviewUrl(url);
- if (vimeoPreviewUrl) {
- return vimeoPreviewUrl;
- }
-
- const spotifyPreviewUrl = getSpotifyPreviewUrl(url);
- if (spotifyPreviewUrl) {
- return spotifyPreviewUrl;
- }
-
- const soundcloudPreviewUrl = getSoundcloudPreviewUrl(url);
- if (soundcloudPreviewUrl) {
- return soundcloudPreviewUrl;
- }
-
- const figmaPreviewUrl = getFigmaPreviewUrl(url);
- if (figmaPreviewUrl) {
- return figmaPreviewUrl;
- }
-
- // URL didn't match any supported services, so return null
- return null;
-}
-
-function getAirtablePreviewUrl(url) {
- const match = url.match(/airtable\.com(\/embed)?\/(shr[A-Za-z0-9]{14}.*)/);
- if (match) {
- return `https://airtable.com/embed/${match[2]}`;
+ //
+ for (const converter of converters) {
+ const previewUrl = converter(url);
+ if (previewUrl) {
+ return previewUrl;
+ }
}
-
- // URL isn't for an Airtable share
+ // If no converter is found, return null.
return null;
}
-function getYoutubePreviewUrl(url) {
- // Standard youtube urls, e.g. https://www.youtube.com/watch?v=KYz2wyBy3kc
- let match = url.match(/youtube\.com\/.*v=([\w-]+)(&|$)/);
-
- if (match) {
- return `https://www.youtube.com/embed/${match[1]}`;
- }
-
- // Shortened youtube urls, e.g. https://youtu.be/KYz2wyBy3kc
- match = url.match(/youtu\.be\/([\w-]+)(\?|$)/);
- if (match) {
- return `https://www.youtube.com/embed/${match[1]}`;
- }
-
- // Youtube playlist urls, e.g. youtube.com/playlist?list=KYz2wyBy3kc
- match = url.match(/youtube\.com\/playlist\?.*list=([\w-]+)(&|$)/);
- if (match) {
- return `https://www.youtube.com/embed/videoseries?list=${match[1]}`;
- }
+const converters = [
+ url => {
+ const match = url.match(/airtable\.com(\/embed)?\/(shr[A-Za-z0-9]{14}.*)/);
+ if (match) {
+ return `https://airtable.com/embed/${match[2]}`;
+ }
- // URL isn't for a youtube video
- return null;
-}
+ // URL isn't for an Airtable share
+ return null;
+ },
+ url => {
+ // Standard youtube urls, e.g. https://www.youtube.com/watch?v=KYz2wyBy3kc
+ let match = url.match(/youtube\.com\/.*v=([\w-]+)(&|$)/);
-function getVimeoPreviewUrl(url) {
- const match = url.match(/vimeo\.com\/([\w-]+)(\?|$)/);
- if (match) {
- return `https://player.vimeo.com/video/${match[1]}`;
- }
+ if (match) {
+ return `https://www.youtube.com/embed/${match[1]}`;
+ }
- // URL isn't for a Vimeo video
- return null;
-}
+ // Shortened youtube urls, e.g. https://youtu.be/KYz2wyBy3kc
+ match = url.match(/youtu\.be\/([\w-]+)(\?|$)/);
+ if (match) {
+ return `https://www.youtube.com/embed/${match[1]}`;
+ }
-function getSpotifyPreviewUrl(url) {
- // Spotify URLs for song, album, artist, playlist all have similar formats
- let match = url.match(/spotify\.com\/(track|album|artist|playlist)\/([\w-]+)(\?|$)/);
- if (match) {
- return `https://open.spotify.com/embed/${match[1]}/${match[2]}`;
- }
+ // Youtube playlist urls, e.g. youtube.com/playlist?list=KYz2wyBy3kc
+ match = url.match(/youtube\.com\/playlist\?.*list=([\w-]+)(&|$)/);
+ if (match) {
+ return `https://www.youtube.com/embed/videoseries?list=${match[1]}`;
+ }
- // Spotify URLs for podcasts and episodes have a different format
- match = url.match(/spotify\.com\/(show|episode)\/([\w-]+)(\?|$)/);
- if (match) {
- return `https://open.spotify.com/embed-podcast/${match[1]}/${match[2]}`;
- }
+ // URL isn't for a youtube video
+ return null;
+ },
+ url => {
+ const match = url.match(/vimeo\.com\/([\w-]+)(\?|$)/);
+ if (match) {
+ return `https://player.vimeo.com/video/${match[1]}`;
+ }
- // URL isn't for Spotify
- return null;
-}
+ // URL isn't for a Vimeo video
+ return null;
+ },
+ url => {
+ // Spotify URLs for song, album, artist, playlist all have similar formats
+ let match = url.match(/spotify\.com\/(track|album|artist|playlist)\/([\w-]+)(\?|$)/);
+ if (match) {
+ return `https://open.spotify.com/embed/${match[1]}/${match[2]}`;
+ }
-function getSoundcloudPreviewUrl(url) {
- // Soundcloud url's don't have a clear format, so just check if they are from soundcloud and try
- // to embed them.
- if (url.match(/soundcloud\.com/)) {
- return `https://w.soundcloud.com/player/?url=${url}&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true`;
- }
+ // Spotify URLs for podcasts and episodes have a different format
+ match = url.match(/spotify\.com\/(show|episode)\/([\w-]+)(\?|$)/);
+ if (match) {
+ return `https://open.spotify.com/embed-podcast/${match[1]}/${match[2]}`;
+ }
- // URL isn't for Soundcloud
- return null;
-}
+ // URL isn't for Spotify
+ return null;
+ },
+ url => {
+ // Soundcloud url's don't have a clear format, so just check if they are from soundcloud and try
+ // to embed them.
+ if (url.match(/soundcloud\.com/)) {
+ return `https://w.soundcloud.com/player/?url=${url}&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true`;
+ }
-function getFigmaPreviewUrl(url) {
- // Figma has a regex they recommend matching against
- if (
- url.match(
- /(https:\/\/([\w.-]+\.)?)?figma.com\/(file|proto)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/,
- )
- ) {
- return `https://www.figma.com/embed?embed_host=astra&url=${url}`;
- }
+ // URL isn't for Soundcloud
+ return null;
+ },
+ url => {
+ // Figma has a regex they recommend matching against
+ if (
+ url.match(
+ /(https:\/\/([\w.-]+\.)?)?figma.com\/(file|proto)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/,
+ )
+ ) {
+ return `https://www.figma.com/embed?embed_host=astra&url=${url}`;
+ }
- // URL isn't for Figma
- return null;
-}
+ // URL isn't for Figma
+ return null;
+ },
+];
initializeBlock(() => );
diff --git a/frontend/settings.js b/frontend/settings.js
new file mode 100644
index 0000000..4e4a005
--- /dev/null
+++ b/frontend/settings.js
@@ -0,0 +1,87 @@
+import {useBase, useGlobalConfig} from '@airtable/blocks/ui';
+
+export const ConfigKeys = {
+ IS_ENFORCED: 'isEnforced',
+ TABLE_ID: 'tableId',
+ URL_FIELD_ID: 'urlFieldId',
+};
+
+export const IsEnforced = Object.freeze({
+ YES: true,
+ NO: false,
+});
+
+const defaults = Object.freeze({
+ [ConfigKeys.IS_ENFORCED]: IsEnforced.YES,
+});
+
+const hasOwn = (O, p) => Object.prototype.hasOwnProperty.call(O, p);
+/**
+ * Reads the values stored in GlobalConfig and inserts defaults for missing values
+ * @param {GlobalConfig} globalConfig
+ * @returns {{
+ * isEnforced?: true,
+ * table: Table | null,
+ * urlField: Field | null,
+ * }}
+ */
+function getSettingsWithResolvedDefaults(globalConfig) {
+ return Object.values(ConfigKeys).reduce((accum, configKey) => {
+ const value = globalConfig.get(configKey);
+ accum[configKey] =
+ value === undefined && hasOwn(defaults, configKey) ? defaults[configKey] : value;
+ return accum;
+ }, {});
+}
+/**
+ * Return settings from GlobalConfig with defaults, and converts them to Airtable objects.
+ * @param {object} settings
+ * @param {Base} base - The base being used by the block in order to convert id's to objects
+ * @returns {{
+ * isEnforced: true | false,
+ * table: Table | null,
+ * urlField: Field | null,
+ * }}
+ */
+function getSettings(settings, base) {
+ const {isEnforced, tableId, urlFieldId} = settings;
+ const table = base.getTableByIdIfExists(tableId);
+ const urlField = table ? table.getFieldByIdIfExists(urlFieldId) : null;
+ return {
+ isEnforced,
+ table,
+ urlField,
+ };
+}
+
+/**
+ * Wraps the settings with validation information
+ * @param {object} settings - The object returned by getSettings
+ * @returns {{settings: *, isValid: boolean}|{settings: *, isValid: boolean, message: string}}
+ */
+function getSettingsValidationResult(settings) {
+ const {isEnforced, table, urlField} = settings;
+ // If a table was selected and the enforcement option is set to "Yes", but
+ // but no field and the option to
+ if (table && isEnforced && !urlField) {
+ return {
+ isValid: false,
+ message: 'Please select a URL Field for previews',
+ settings,
+ };
+ }
+ return {
+ isValid: true,
+ settings,
+ };
+}
+
+/**
+ * A React hook to validate and access settings configured in SettingsForm.
+ * @returns {{settings: *, isValid: boolean, message: string}|{settings: *, isValid: boolean}}
+ */
+export function useSettings() {
+ return getSettingsValidationResult(
+ getSettings(getSettingsWithResolvedDefaults(useGlobalConfig()), useBase()),
+ );
+}