Skip to content

Better CMS Starter #222

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

clementroche
Copy link
Contributor

This pull request introduces several enhancements to the CMS starter, focusing on improved data modeling, field mapping, and synchronization functionality. Key changes include adding support for collectionReference and multiCollectionReference fields, introducing a more robust ID and slug handling mechanism, and making the FieldMapping component more flexible and user-friendly.

Field mapping enhancements:

  • Updated FieldMapping.tsx to include a style prop for customization and improved handling of missing references in FieldMappingRow. Missing references are now displayed as "Missing Collection" and visually disabled.
  • Replaced stroke-linecap, stroke-linejoin, and stroke-width with their camelCase equivalents (strokeLinecap, strokeLinejoin, strokeWidth) for better React compatibility.

Data synchronization improvements:

  • Added idField and slugField to the DataSource type to standardize ID and slug handling. Introduced a slugify function to ensure slugs are unique and URL-safe.
  • Enhanced the syncCollection function to handle collectionReference and multiCollectionReference fields by mapping them to the correct collection IDs, skipping items without valid IDs, and generating slugs dynamically.

Codebase refactoring:

  • Introduced the ExtendedEditableManagedCollectionField type to extend field capabilities, including support for dataSourceId and isMissingReference.

Comment on lines 254 to 255
existingFields as ManagedCollectionFieldInput[],
slugField as ManagedCollectionFieldInput
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to cast the types here? Casts are dangerous because you are overriding something which might not be true

Copy link
Contributor Author

@clementroche clementroche May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the typecasting for slugField. But I struggle with existingFields, I might need some help since those types are not documented.

collection.getField() returns ManagedCollectionField[] meawnhile collection.setFields expects ManagedCollectionFieldInput[]

Comment on lines 63 to 83
function slugify(text: string) {
text = text.trim()
text = text.slice(0, 60) // limit to 60 characters

if (slugs.has(text)) {
const count = slugs.get(text) ?? 0
slugs.set(text, count + 1)
text = `${text} ${count + 1}`
} else {
slugs.set(text, 0)
}

const slug = text
.replace(/^\s+|\s+$/g, "")
.toLowerCase()
.replace(/[^a-z0-9 -]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/-+/g, "-")
return slug
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A slugify function should not have side effects like making something unique. If someone starts reusing this function for something different it could easily lead to bugs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've fixed it by copying the text variable to another one to make it safer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strings are value types in JS. So they can't be mutated. That's not the issue. The issue is that a slugify function should not add suffixes

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is our implementation btw:

// 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!

// Match everything except for letters, numbers and parentheses.
const nonSlugCharactersRegExp = /[^\p{Letter}\p{Number}()]+/gu
// Match leading/trailing dashes, for trimming purposes.
const trimSlugRegExp = /^-+|-+$/gu

/**
 * Takes a freeform string and removes all characters except letters, numbers,
 * and parentheses. Also makes it lower case, and separates words by dashes.
 * This makes the value URL safe.
 */
export function slugify(value: string): string {
    return value.toLowerCase().replace(nonSlugCharactersRegExp, "-").replace(trimSlugRegExp, "")
}

Copy link
Contributor Author

@clementroche clementroche May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make slugs unique at some point in case users select a field that is not unique. What the solution here, should we rename the function uniqueSlugify ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if you name it uniqueSlugify, it will be very easy to use it wrong

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And that mostly means the function is too magic — as in having unexpected side effects.

Copy link
Contributor Author

@clementroche clementroche May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid point, slugs map should be reset after each batched usage then?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. I would create two functions slufigy and createUniqueSlug. slugify only does one job. And createUniqueSlug takes both the raw string value and a Set with taken slugs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 10 to 22
export function createUniqueSlug(text: string, existingSlugs: Map<string, number>) {
text = text.trim().slice(0, 60)

if (existingSlugs.has(text)) {
const count = existingSlugs.get(text) ?? 0
existingSlugs.set(text, count + 1)
text = `${text} ${count + 1}`
} else {
existingSlugs.set(text, 0)
}

return slugify(text)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can still result in duplicate slugs, because we check the input instead of the generated slug

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, i've made it more robust now. First, input get slugified then function makes it unique.
a3e3b8c#diff-e1867fda29cfd75d20d909c3895b73c2878e32c5c28bc70912a893251891c2a1

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function still has bugs. And I'm wondering if automatically making it unique is better than telling the user about the duplicate slug so they can fix it themselves. Having a duplicate slug is probably a mistake and if we don't warn them you will publish a URL that you will probably need to change once you see the mistake.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bug is that we don't check the final generated slug for uniqueness. So if my data source has the following slugs it will result in duplicates:

  1. my-slug
  2. my-slug-0
  3. my-slug → transformed into my-slug-0 which is a duplicate

@jonastreub
Copy link
Contributor

Why would you want to show the id? This is typically hidden from users

Comment on lines 177 to 215
await syncCollection(collection, dataSource, existingFields, slugField)
await syncCollection(collection, dataSource, existingFields as ManagedCollectionFieldInput[], slugField)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This as ManagedCollectionFieldInput[] cast is a bad habit, so should definitely not be in the starter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required since collection.getField() returns ManagedCollectionField[] meawnhile collection.setFields expects ManagedCollectionFieldInput[]

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Casts are never the solution though. It silences an actual issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course you can use casts in your private code but this is an example where the code quality should be really high

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, the fact that it currently errors is really bad… but adding a cast only hides the actual issue that we should fix

Copy link
Contributor Author

@clementroche clementroche May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed it, no more typescating:

const fields: ManagedCollectionFieldInput[] = []
        for (const field of existingFields) {
            if (field.type === "multiCollectionReference" || field.type === "collectionReference") {
                fields.push({
                    id: field.id,
                    name: field.name,
                    type: field.type,
                    collectionId: field.collectionId,
                })
            } else if (field.type === "enum") {
                fields.push({
                    id: field.id,
                    name: field.name,
                    type: field.type,
                    cases: [],
                })
            } else if (field.type === "file" || field.type === "image") {
                fields.push({
                    id: field.id,
                    name: field.name,
                    type: field.type,
                    allowedFileTypes: [],
                })
            } else {
                fields.push({
                    id: field.id,
                    name: field.name,
                    type: field.type,
                })
            }
        }

@clementroche
Copy link
Contributor Author

clementroche commented May 8, 2025

Why would you want to show the id? This is typically hidden from users

I needed to define the id field to make it different from the slug since it's necessary to keep it consistent for mapping multiCollectionReference and collectionReference correctly, in addition ways of handling ids can change depending which API you're fetching. Currently the cms starter is using not realistic data, that's why it's not obvious to you. All the stuffs i'm bringing here comes from my experience developing CMS Plugins at the same time. To use something like JSONPlaceholder would help to have a more robust starter imo with real use case.

Comment on lines 229 to 236
cases: [],
})
} else if (field.type === "file" || field.type === "image") {
fields.push({
id: field.id,
name: field.name,
type: field.type,
allowedFileTypes: [],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty arrays are incorrect

Copy link
Contributor Author

@clementroche clementroche May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yo @jonastreub, sorry it's a bit hard to solve this since i can't find documentation, but let me know if this commit works

@jonastreub
Copy link
Contributor

ids can change depending which API you're fetching

Can you be more specific?

The ids are supposed to be coming from your external data source. So in theory you would not need to read anything from Framer data

@clementroche
Copy link
Contributor Author

clementroche commented May 13, 2025

Can you be more specific?

Here is an example using Greenhouse Job Board API:

Jobs:

{
    "jobs": [
        {
            "absolute_url": "https://job-boards.greenhouse.io/vaulttec/jobs/260533",
            "data_compliance": [
                {
                    "type": "gdpr",
                    "requires_consent": false,
                    "requires_processing_consent": false,
                    "requires_retention_consent": false,
                    "retention_period": null,
                    "demographic_data_consent_applies": false
                }
            ],
            "education": "education_optional",
            "internal_job_id": 287334,
            "location": {
                "name": "NYC"
            },
            "metadata": null,
            "id": 260533,
            "updated_at": "2025-04-11T16:16:09-04:00",
            "requisition_id": "3",
            "title": "Awkward Cop",
            "company_name": "Vault-Tec Corporation",
            "first_published": null,
            "content": "&lt;p&gt;The Rule Enforcement team is dedicated to keeping all employees in line. &amp;nbsp;Rule enforcers use&amp;nbsp;various tactics such as physical intimidation, awkward pauses, and dramatic coffee sips in order to make sure everyone does what they&#39;re told.&lt;/p&gt;\n&lt;p&gt;As a Good Cop, you&#39;ll work closely with Bad Cops in order to extract information from employees. Don&#39;t let the name of the role fool you however -- you are going to be just as &#39;bad&#39; as Bad Cops, but you&#39;ll preferably have experience playing the part of the kind, compassionate and fair cop.&lt;/p&gt;",
            "departments": [
                {
                    "id": 13580,
                    "name": "Finance",
                    "child_ids": [],
                    "parent_id": null
                }
            ],
            "offices": []
        },
    ],
    "meta": {
    "total" : 5
  }
}

Departments:

{
    "departments": [
        {
            "id": 13580,
            "name": "Finance",
            "parent_id": null,
            "child_ids": [],
            "jobs": [
                {
                    "absolute_url": "https://job-boards.greenhouse.io/vaulttec/jobs/260533",
                    "data_compliance": [
                        {
                            "type": "gdpr",
                            "requires_consent": false,
                            "requires_processing_consent": false,
                            "requires_retention_consent": false,
                            "retention_period": null,
                            "demographic_data_consent_applies": false
                        }
                    ],
                    "education": "education_optional",
                    "internal_job_id": 287334,
                    "location": {
                        "name": "NYC"
                    },
                    "metadata": null,
                    "id": 260533,
                    "updated_at": "2025-04-11T16:16:09-04:00",
                    "requisition_id": "3",
                    "title": "Awkward Cop",
                    "company_name": "Vault-Tec Corporation",
                    "first_published": null
                }
            ]
        },
    ]
}

So in Framer CMS it will be two collections: Jobs & Departements where a Job reference to Departements through multiCollectionReference departments field. To retrieve these reference departements from a job I (as a CMS Starter user) need to pass the id of the departement entry. So at some point I have to define which field will be the id of Departments collection and this will remain.

BTW the end user of the plugin will never have to choose what's the id field. It's a choice that the CMS starter user will do to ensure data coherence depending on the data that needs to be mapped.

You can check my Greenhouse plugin that has been built on top of CMS starter. And more specifically data source folder.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants