diff --git a/plugins/ashby/package.json b/plugins/ashby/package.json index af75e9772..5e69275c3 100644 --- a/plugins/ashby/package.json +++ b/plugins/ashby/package.json @@ -10,7 +10,8 @@ "check-eslint": "run g:check-eslint", "preview": "run g:preview", "pack": "npx framer-plugin-tools@latest pack", - "check-typescript": "run g:check-typescript" + "check-typescript": "run g:check-typescript", + "check-vitest": "run g:check-vitest" }, "dependencies": { "framer-plugin": "^3.6.0", @@ -20,6 +21,7 @@ }, "devDependencies": { "@types/react": "^18.3.24", - "@types/react-dom": "^18.3.7" + "@types/react-dom": "^18.3.7", + "vitest": "^3.2.4" } } diff --git a/plugins/ashby/src/App.css b/plugins/ashby/src/App.css index 0b00ecb77..a48f2258a 100644 --- a/plugins/ashby/src/App.css +++ b/plugins/ashby/src/App.css @@ -77,7 +77,8 @@ form { pointer-events: none; } -.setup .field { +.setup .field, +.setup label { display: flex; flex-direction: row; align-items: center; diff --git a/plugins/ashby/src/App.tsx b/plugins/ashby/src/App.tsx index d276e3ea7..ec37508c3 100644 --- a/plugins/ashby/src/App.tsx +++ b/plugins/ashby/src/App.tsx @@ -25,7 +25,7 @@ export function App({ collection, previousDataSourceId, previousSlugFieldId, pre void framer.showUI({ width: hasDataSourceSelected ? 400 : 320, - height: hasDataSourceSelected ? 427 : 285, + height: hasDataSourceSelected ? 427 : 325, minHeight: hasDataSourceSelected ? 427 : undefined, minWidth: hasDataSourceSelected ? 400 : undefined, resizable: hasDataSourceSelected, diff --git a/plugins/ashby/src/api-types.ts b/plugins/ashby/src/api-types.ts index 8df0d87c4..6f90e1084 100644 --- a/plugins/ashby/src/api-types.ts +++ b/plugins/ashby/src/api-types.ts @@ -1,5 +1,11 @@ import * as v from "valibot" +/** Base interface for all data items that can be synced */ +export interface DataItem { + id: string + [key: string]: unknown +} + const AddressSchema = v.object({ addressLocality: v.optional(v.string()), addressRegion: v.optional(v.string()), @@ -28,7 +34,7 @@ export const JobAddressSchema = v.object({ postalAddress: AddressSchema, }) -const SecondaryLocationSchema = v.object({ +export const SecondaryLocationSchema = v.object({ location: v.string(), address: JobAddressSchema, }) @@ -65,6 +71,19 @@ export type Job = v.InferOutput export type Address = v.InferOutput export type CompensationComponent = v.InferOutput export type CompensationTiers = v.InferOutput +export type SecondaryLocation = v.InferOutput +export type JobAddress = v.InferOutput + +export const LocationSchema = v.object({ + id: v.string(), + name: v.string(), + locality: v.string(), + region: v.string(), + country: v.string(), + fullAddress: v.string(), +}) + +export type Location = v.InferOutput export function hasOwnProperty( object: T, diff --git a/plugins/ashby/src/components/SelectDataSource.tsx b/plugins/ashby/src/components/SelectDataSource.tsx index e5062feea..55816f8ea 100644 --- a/plugins/ashby/src/components/SelectDataSource.tsx +++ b/plugins/ashby/src/components/SelectDataSource.tsx @@ -18,7 +18,9 @@ export function SelectDataSource({ onSelectDataSource, }: SelectDataSourceProps) { const [jobBoardName, setJobBoardName] = useState(previousJobBoardName ?? "") - const [selectedDataSourceId] = useState(previousDataSourceId ?? dataSources[0]?.id ?? "") + const [selectedDataSourceId, setSelectedDataSourceId] = useState( + previousDataSourceId ?? dataSources[0]?.id ?? "" + ) const [isLoading, setIsLoading] = useState(false) const isAllowedToManage = useIsAllowedTo("ManagedCollection.setFields", ...syncMethods) @@ -72,19 +74,40 @@ export function SelectDataSource({ Ashby Hero
-
+
+ +
diff --git a/plugins/ashby/src/data.ts b/plugins/ashby/src/data.ts index 525424c0f..9fde93cd2 100644 --- a/plugins/ashby/src/data.ts +++ b/plugins/ashby/src/data.ts @@ -96,7 +96,6 @@ export function mergeFieldsWithExistingFields( } const StringifiableSchema = v.union([v.string(), v.number(), v.boolean()]) -const ArrayWithIdsSchema = v.array(v.object({ id: v.number() })) async function getItems( dataSource: AshbyDataSource, @@ -159,16 +158,20 @@ async function getItems( } const fieldData: FieldDataInput = {} - for (const [fieldName, rawValue] of Object.entries(item) as [string, unknown][]) { - const isFieldIgnored = !fieldsToSync.find(field => field.id === fieldName) + for (const [fieldName, rawValue] of Object.entries(item)) { const fields = fieldLookup.get(fieldName) ?? [] - if (fields.length === 0 || isFieldIgnored) { + if (fields.length === 0) { continue } for (const field of fields) { - const value = field.getValue ? field.getValue(rawValue) : rawValue + const isFieldIgnored = !fieldsToSync.find(f => f.id === field.id) + if (isFieldIgnored) { + continue + } + + const value = !isCollectionReference(field) && field.getValue ? field.getValue(rawValue) : rawValue switch (field.type) { case "string": @@ -198,26 +201,29 @@ async function getItems( } break case "multiCollectionReference": { - const ids: string[] = [] - if (v.is(ArrayWithIdsSchema, value)) { - ids.push(...value.map(item => String(item.id))) + const targetDataSource = dataSources.find(ds => ds.id === field.dataSourceId) + if (!targetDataSource?.getItemId || !Array.isArray(rawValue)) { + fieldData[field.id] = { value: [], type: "multiCollectionReference" } + break } - fieldData[field.id] = { - value: ids, - type: "multiCollectionReference", + const ids: string[] = [] + for (const entry of rawValue) { + const id = targetDataSource.getItemId(entry) + if (id) ids.push(id) } + + fieldData[field.id] = { value: ids, type: "multiCollectionReference" } break } case "collectionReference": { - if (typeof value !== "object" || value == null || !("id" in value)) { - continue - } + const targetDataSource = dataSources.find(ds => ds.id === field.dataSourceId) + if (!targetDataSource?.getItemId) continue - fieldData[field.id] = { - value: String(value.id), - type: "collectionReference", - } + const id = targetDataSource.getItemId(rawValue) + if (!id) continue + + fieldData[field.id] = { value: id, type: "collectionReference" } break } case "image": @@ -292,7 +298,6 @@ export async function syncExistingCollection( framer.closePlugin("You are not allowed to sync this collection.", { variant: "error", }) - return { didSync: false } } try { @@ -301,7 +306,7 @@ export async function syncExistingCollection( const slugField = dataSource.fields.find(field => field.id === previousSlugFieldId) if (!slugField) { - framer.notify(`No field matches the slug field id “${previousSlugFieldId}”. Sync will not be performed.`, { + framer.notify(`No field matches the slug field id "${previousSlugFieldId}". Sync will not be performed.`, { variant: "error", }) return { didSync: false } @@ -311,7 +316,7 @@ export async function syncExistingCollection( return { didSync: true } } catch (error) { console.error(error) - framer.notify(`Failed to sync collection “${previousDataSourceId}”. Check browser console for more details.`, { + framer.notify(`Failed to sync collection "${previousDataSourceId}". Check browser console for more details.`, { variant: "error", }) return { didSync: false } diff --git a/plugins/ashby/src/dataSources.test.ts b/plugins/ashby/src/dataSources.test.ts new file mode 100644 index 000000000..355577ccb --- /dev/null +++ b/plugins/ashby/src/dataSources.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest" +import { extractLocation } from "./dataSources" + +describe("extractLocation", () => { + it("extracts location with full address", () => { + const result = extractLocation("San Francisco", { + postalAddress: { + addressLocality: "San Francisco", + addressRegion: "CA", + addressCountry: "USA", + }, + }) + + expect(result).toEqual({ + id: "san-francisco", + name: "San Francisco", + locality: "San Francisco", + region: "CA", + country: "USA", + fullAddress: "San Francisco, CA, USA", + }) + }) + + it("handles null address", () => { + const result = extractLocation("Remote", null) + + expect(result).toEqual({ + id: "remote", + name: "Remote", + locality: "", + region: "", + country: "", + fullAddress: "", + }) + }) + + it("handles partial address", () => { + const result = extractLocation("Berlin", { + postalAddress: { + addressCountry: "Germany", + }, + }) + + expect(result).toEqual({ + id: "berlin", + name: "Berlin", + locality: "", + region: "", + country: "Germany", + fullAddress: "Germany", + }) + }) + + it("deduplicates address parts", () => { + const result = extractLocation("California", { + postalAddress: { + addressLocality: "CA", + addressRegion: "CA", + addressCountry: "USA", + }, + }) + + expect(result.fullAddress).toBe("CA, USA") + }) + + it("trims whitespace from address parts", () => { + const result = extractLocation("New York", { + postalAddress: { + addressLocality: " New York ", + addressRegion: " NY ", + addressCountry: " USA ", + }, + }) + + expect(result.locality).toBe("New York") + expect(result.region).toBe("NY") + expect(result.country).toBe("USA") + }) + + it("handles non-ASCII location names", () => { + const result = extractLocation("東京", { + postalAddress: { + addressCountry: "Japan", + }, + }) + + expect(result.id).toBe("東京") + expect(result.name).toBe("東京") + }) +}) diff --git a/plugins/ashby/src/dataSources.ts b/plugins/ashby/src/dataSources.ts index 0dea085c0..dcc9d3c60 100644 --- a/plugins/ashby/src/dataSources.ts +++ b/plugins/ashby/src/dataSources.ts @@ -1,8 +1,18 @@ import type { ManagedCollectionFieldInput } from "framer-plugin" import * as v from "valibot" -import { type Job, JobAddressSchema, JobSchema } from "./api-types" +import { + type DataItem, + type Job, + type JobAddress, + JobAddressSchema, + JobSchema, + type Location, + SecondaryLocationSchema, +} from "./api-types" +import { slugify } from "./slugify" +import { isCollectionReference } from "./utils" -export interface AshbyDataSource { +export interface AshbyDataSource { id: string name: string /** @@ -12,7 +22,9 @@ export interface AshbyDataSource { * The rest of the fields are the fields of the data source. */ fields: readonly AshbyField[] - fetch: (jobBoardName: string) => Promise + fetch: (jobBoardName: string) => Promise + /** Extracts the ID from a data entry. Required when other data sources reference this one. */ + getItemId?: (entry: unknown) => string | null } async function fetchAshbyData(url: string): Promise { @@ -39,7 +51,6 @@ export type AshbyField = ManagedCollectionFieldInput & | { key?: string type: "collectionReference" | "multiCollectionReference" - getValue?: never dataSourceId: string supportedCollections?: { id: string; name: string }[] } @@ -47,6 +58,79 @@ export type AshbyField = ManagedCollectionFieldInput & const JobApiResponseSchema = v.object({ jobs: v.array(JobSchema) }) +const locationsDataSourceName = "Locations" + +/** Extracts the location ID from a location entry. Used both for creating Location items and for references. */ +function getLocationId(entry: unknown): string | null { + if (typeof entry === "string") { + return slugify(entry) + } + if (v.is(SecondaryLocationSchema, entry)) { + return slugify(entry.location) + } + return null +} + +export function extractLocation(locationName: string, address: JobAddress | null): Location { + const postalAddress = address?.postalAddress + const parts = [ + postalAddress?.addressLocality?.trim(), + postalAddress?.addressRegion?.trim(), + postalAddress?.addressCountry?.trim(), + ].filter(Boolean) + + return { + id: getLocationId(locationName) ?? "", + name: locationName, + locality: postalAddress?.addressLocality?.trim() ?? "", + region: postalAddress?.addressRegion?.trim() ?? "", + country: postalAddress?.addressCountry?.trim() ?? "", + fullAddress: [...new Set(parts)].join(", "), + } +} + +const locationsDataSource: AshbyDataSource = { + id: locationsDataSourceName, + name: locationsDataSourceName, + getItemId: getLocationId, + fields: [ + { id: "name", name: "Name", type: "string", canBeUsedAsSlug: true }, + { id: "locality", name: "Locality", type: "string" }, + { id: "region", name: "Region", type: "string" }, + { id: "country", name: "Country", type: "string" }, + { id: "fullAddress", name: "Full Address", type: "string" }, + ], + fetch: async (jobBoardName: string): Promise => { + const url = `https://api.ashbyhq.com/posting-api/job-board/${jobBoardName}?includeCompensation=true` + const data = v.safeParse(JobApiResponseSchema, await fetchAshbyData(url)) + + if (!data.success) { + console.log("Error parsing Ashby data:", data.issues) + throw new Error("Error parsing Ashby data") + } + + const locationMap = new Map() + + for (const job of data.output.jobs) { + // Primary location + const primary = extractLocation(job.location, job.address) + if (primary.id && !locationMap.has(primary.id)) { + locationMap.set(primary.id, primary) + } + + // Secondary locations + for (const secondary of job.secondaryLocations) { + const loc = extractLocation(secondary.location, secondary.address) + if (loc.id && !locationMap.has(loc.id)) { + locationMap.set(loc.id, loc) + } + } + } + + return Array.from(locationMap.values()) + }, +} + const jobsDataSource = createDataSource( { name: "Jobs", @@ -157,24 +241,42 @@ const jobsDataSource = createDataSource( return address.addressLocality?.trim() ?? "" }, }, + { + id: "locationRef", + key: "location", + name: "Location Reference", + type: "collectionReference", + dataSourceId: locationsDataSourceName, + collectionId: "", + }, + { + id: "secondaryLocations", + name: "Secondary Locations", + type: "multiCollectionReference", + dataSourceId: locationsDataSourceName, + collectionId: "", + }, ] ) -export const dataSources = [jobsDataSource] satisfies AshbyDataSource[] +export const dataSources: AshbyDataSource[] = [jobsDataSource, locationsDataSource] -function createDataSource( +function createDataSource( { name, fetch, + getItemId, }: { name: string - fetch: (jobBoardName: string) => Promise + fetch: (jobBoardName: string) => Promise + getItemId?: (entry: unknown) => string | null }, [idField, slugField, ...fields]: [AshbyField, AshbyField, ...AshbyField[]] -): AshbyDataSource { +): AshbyDataSource { return { id: name, name, + getItemId, fields: [idField, slugField, ...fields], fetch, } @@ -188,8 +290,9 @@ function createDataSource( */ export function removeAshbyKeys(fields: AshbyField[]): ManagedCollectionFieldInput[] { return fields.map(originalField => { - const { getValue, ...field } = originalField + if (isCollectionReference(originalField)) return originalField + const { getValue, ...field } = originalField return field }) } diff --git a/plugins/ashby/src/slugify.test.ts b/plugins/ashby/src/slugify.test.ts new file mode 100644 index 000000000..ba0ed9558 --- /dev/null +++ b/plugins/ashby/src/slugify.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" +import { slugify } from "./slugify" + +describe("slugify", () => { + it("converts to lowercase", () => { + expect(slugify("New York")).toBe("new-york") + }) + + it("replaces spaces with hyphens", () => { + expect(slugify("san francisco")).toBe("san-francisco") + }) + + it("removes special characters", () => { + expect(slugify("New York, NY")).toBe("new-york-ny") + }) + + it("trims whitespace", () => { + expect(slugify(" Berlin ")).toBe("berlin") + }) + + it("preserves non-ASCII letters", () => { + expect(slugify("São Paulo")).toBe("são-paulo") + expect(slugify("München")).toBe("münchen") + expect(slugify("東京")).toBe("東京") + expect(slugify("Zürich")).toBe("zürich") + }) + + it("handles mixed ASCII and non-ASCII", () => { + expect(slugify("Köln, Germany")).toBe("köln-germany") + }) + + it("returns empty string for empty input", () => { + expect(slugify("")).toBe("") + }) + + it("handles numbers", () => { + expect(slugify("Area 51")).toBe("area-51") + }) +}) diff --git a/plugins/ashby/src/slugify.ts b/plugins/ashby/src/slugify.ts new file mode 100644 index 000000000..47d3639a7 --- /dev/null +++ b/plugins/ashby/src/slugify.ts @@ -0,0 +1,32 @@ +// Note: We don't use the "slugify" package here because we want a very specific +// behavior for our CMS and web pages. Make sure to pick the right slugify +// function for your use case! +// Characters that are problematic in URLs and should be replaced with dashes: +// - Whitespace (\s) +// - Underscore (_) - Google specifically recommends using `-` instead https://developers.google.com/search/docs/crawling-indexing/url-structure#:~:text=example.com/%F0%9F%A6%99%E2%9C%A8-,Use%20hyphens%20to%20separate%20words,-We%20recommend%20separating +// - URL reserved delimiters: ? # [ ] @ +// - Characters with special meaning: ! $ & ' * + , ; : = " < > % { } | \ ^ ` / +// - : denotes paths on MacOS / reserved for drive letters on Windows +// All other characters (including unicode letters, numbers, symbols) are kept. +const unsafeSlugCharactersRegExp = /[\s_?#[\]@!$&'*+,;:="<>%{}|\\^`/]+/gu +/** + * Trim leading and trailing dashes from a string. + * We can't use a regexp, because matching e.g. /-+$/gu leads to polynomial backtracking with e.g. '-'.repeat(54773) + '\x00-' + */ +export function trimDashes(str: string): string { + let start = 0 + let end = str.length + while (start < end && str[start] === "-") start++ + while (end > start && str[end - 1] === "-") end-- + return str.slice(start, end) +} +/** + * Takes a freeform string and converts it to a URL-safe slug. + * Replaces problematic URL characters and combinations with dashes while preserving: + * - Unicode letters and numbers + * - Unicode symbols (±, §, etc.) + * - URL-safe punctuation: - . ~ ( ) + */ +export function slugify(value: string): string { + return trimDashes(value.trim().toLowerCase().replace(unsafeSlugCharactersRegExp, "-")) +} diff --git a/yarn.lock b/yarn.lock index f261d270e..6a6bef9b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3249,6 +3249,7 @@ __metadata: react: "npm:^18.3.1" react-dom: "npm:^18.3.1" valibot: "npm:^1.2.0" + vitest: "npm:^3.2.4" languageName: unknown linkType: soft