import SDKAdapter from '../core/adapter.js' import { SDKCollection } from '../core/collection.js' import { SDKModel } from '../core/model.js' import { SDKModelSchema } from '../core/schema.js' import { nanoid } from '../core/transport.js' import componentSchema from '../schemas/component.json' assert { type: 'json' } import type { SDK } from '../sdk.js' import * as Style from '../style/index.js' import { Atleast, RequiredKeys } from '../utils/typescript.js' import type { CollectionModel } from './collections.js' import { DataOptionsById, Datasource, DatasourceModel } from './datasources.js' import type { LibraryModel, NanoId } from './libraries.js' import { Version, VersionModel } from './versions.js' /** * Defines the schema for Component. * * @category Component2 * @module Component1 */ export interface Component { /** The identifier of the Library that Component belongs to */ libraryId: NanoId /** The identifier of the Component */ id: NanoId /** The identifier of the Collection */ collectionId: NanoId /** * The name of the Component. * * @minLength 1 * @maxLength 128 */ name: string /** * The description of the Component. * * @minLength 0 * @maxLength 128 */ description?: string /** The status of the Component */ status: 'published' | 'deleted' | 'staged' | 'changed' | 'draft' /** * The number of versions this Component has. * * @minimum 0 * @TJS-type integer */ versionCount?: number /** * The number of versions that are currently staged. * * @minimum 0 * @TJS-type integer */ stagedVersionCount?: number /** * The number of versions that are available to be embedded. * * @minimum 0 * @TJS-type integer */ embeddableVersionCount?: number /** * The identifiers of the datasources for this Component. * * @items.type string */ datasourceIds?: string[] /** The timestamp when the Component was created at */ createdAt?: Date /** * The timestamp when the Component was last staged at. * * @nullable */ stagedAt?: Date /** * The timestamp when the Component was last published at. * * @nullable */ publishedAt?: Date /** The timestamp when the Component was last modified at */ modifiedAt?: Date /** The User who last modified the Component */ modifiedBy?: object } export class Components extends SDKAdapter { fetch(component: Pick): Promise { const { libraryId, collectionId } = component return this.sdk.fetchJSON(`/libraries/${libraryId}/collections/${collectionId}/components`, {}) } get(component: Pick): Promise { const { libraryId, collectionId, id } = component return this.sdk.fetchJSON(`/libraries/${libraryId}/collections/${collectionId}/components/${id}`, {}) } post(component: Atleast): Promise { const { libraryId, collectionId } = component return this.sdk.fetchJSON(`/libraries/${libraryId}/collections/${collectionId}/components`, { method: 'POST', body: JSON.stringify(component) }) } put(component: Atleast): Promise { return this.post(component) } delete(component: Pick): Promise { const { libraryId, collectionId, id } = component return this.sdk.fetchJSON(`/libraries/${libraryId}/collections/${collectionId}/components/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'text/plain' } }) } } /** * This function returns default values for a component object in TypeScript. * * @param {SDK} sdk - Instance of SDK (used for retrieving current user) * @returns The function `getComponentDefaults` is returning an object with default values for a component. These * values will not be required when instantiating new instance of a Component model. */ export function getComponentDefaults(sdk: SDK) { const now = new Date() return { id: nanoid(10), status: 'draft', createdAt: now, modifiedAt: now, versionCount: 0, stagedVersionCount: 0, embeddableVersionCount: 0, datasourceIds: [] as Datasource['id'][], description: '', modifiedBy: sdk.auth.getUser() || {} } } export type ComponentMinimal = RequiredKeys< Component, typeof getComponentDefaults, T > /** * List of properties that component inherits from its collection. These become optional when instnatiating a new * component. */ export interface ComponentImplicit { library?: LibraryModel libraryId: LibraryModel['id'] collectionId: CollectionModel['id'] collection?: CollectionModel } export interface ComponentParams extends Component, ComponentImplicit {} export interface ComponentModel extends ComponentParams {} export class ComponentModel extends SDKModel implements ComponentParams { get adapter() { return this.sdk.Components } builder: SDKModel versions: ReturnType bundles: ReturnType constructVersionCollection() { return SDKCollection.construct(this.sdk.Version, () => ({ collection: this.collection, collectionId: this.collectionId, library: this.library, libraryId: this.libraryId, component: this, componentId: this.id })) } constructVersionBundleCollection() { return SDKCollection.construct(this.sdk.VersionBundle, () => ({ collection: this.collection, collectionId: this.collectionId, library: this.library, libraryId: this.libraryId, component: this, componentId: this.id })) } getThumbnailURL() { return `${this.sdk.cdn}/thumbnails/${this.libraryId}/${this.id}.jpg` } getSourceURL() { return `${this.sdk.cdn}/components/${this.libraryId}/${this.id}/responsive.html` } getBundles() { if (this.bundles.length == 0) { this.bundles.add({ id: 'responsive', name: 'Default bundle', description: '' }) } return this.bundles } defineProperties() { super.defineProperties() this.sdk.utils.defineCollectionAccessor( this, 'versions', this.constructVersionCollection(), {}, this.getHiddenProps() ) this.sdk.utils.defineCollectionAccessor( this, 'bundles', this.constructVersionBundleCollection(), {}, this.getHiddenProps() ) } getDefaults(sdk: SDK) { return getComponentDefaults(sdk) } getHiddenProps() { return ['versions', 'bundles', 'library', 'collection'] } addVersion(values?: Partial) { const neighbours = this.getNeighbourVersions(null) const heads = VersionModel.getOrderedVersions(this.versions) for (var i = 0; i < 100; i++) { var name = 'Name' + (i == 0 ? '' : ' (' + i + ')') if (!heads.find((h) => h.name == name)) break } return this.versions.add({ name, orderIdx: VersionModel.getVersionOrderIdx(neighbours[0], null), ...values }) } /** * Ensures that version with given `id` has given data. Best way to work with versioning, as it takes care about * drafts, changes and versions behind the scenes. */ establishVersion(values?: Partial) { const current = this.findVersion(values.id) if (current) { return current.changeAndNotify(values) } else { return this.addVersion(values) } } async changeVersions(callback: (versions: VersionModel[]) => any) { await this.versions.fetch() callback(this.versions) await this.saveVersions() } findVersion(id: VersionModel['id'], statuses?: VersionModel['status'][], revision?: VersionModel['revision']) { return this.versions.find( (version) => version.id === id && (!statuses || statuses.length === 0 || statuses.includes(version.status)) ) } /** * Returns a version based on container width provided. If more than one versions match width and versionName is * provided, returns version with specific name If more than one versions exist matching above criteria, it returns * the one having less breakpoints If no version matches width, returns the closest one */ getVersionForWidth(width: number, versionName?: string, versions: VersionModel[] = this.versions) { if (!this.versions.length) return null if (!isFinite(width)) width = 9999 const { stylesheet } = this.library || this.collection?.library const breakpoints = Style.Set.filterByType(stylesheet.rules, 'breakpoint') const filteredBreakpointIds = breakpoints .filter((breakpoint) => breakpoint.props.minWidth <= width && breakpoint.props.maxWidth >= width) .map((breakpoint) => breakpoint.details.id) const getVersionScore = (version: VersionModel) => { const breakpointIds = version.getBreakpoints().map((b) => b.details.id) || breakpoints.map((breakpoint) => breakpoint.details.id) return ( // breakpoint Number(Boolean(breakpointIds.some((id) => filteredBreakpointIds.includes(id)))) * 1000 + // name Number(Boolean(version.name === versionName)) * 100 + // distance 10 + ((10000 - Math.min( ...breakpointIds.flatMap((id: string) => { const breakpoint = breakpoints.find((br) => br.details.id === id) return [Math.abs(breakpoint.props.maxWidth - width), Math.abs(breakpoint.props.minWidth - width)] }) )) / 10000) * 10 + // specificity (breakpoints.length - breakpointIds.length) ) } const currentVersions = this.reduceVersionRevisions(versions) .map((snap) => snap.head) .filter((v) => v.deletedAt == null) const sorted = currentVersions.slice().sort((a, b) => getVersionScore(b) - getVersionScore(a)) return sorted[0] } /** * This function returns the previous and next versions of a given version ID in an ordered list of versions. * * @param id - The `id` parameter is of type `VersionModel['id']`, which means it is a specific property (`id`) of * the `VersionModel` type. It is used as an identifier to find the neighbouring versions of a * particular version. * @returns The function `getNeighbourVersions` returns an array with two elements. The first element is either the * version object that comes before the version with the given `id` in the ordered list of versions, or * the last version in the list if the given `id` is not found in the list. The second element is either * the version object that comes after the version with the given `id` */ getNeighbourVersions(id: VersionModel['id']) { const heads = VersionModel.getOrderedVersions(this.versions) const targetIndex = heads.findIndex((version) => version.id === id) if (targetIndex == -1) { return [heads[heads.length - 1], null] } else { return [heads[targetIndex - 1], heads[targetIndex + 1]] } } getNotDeletedVersions() { return VersionModel.getOrderedVersions(this.versions.filter((version) => !version.deletedAt)) } getVersions(status: VersionModel['status'][] | VersionModel['status'] = ['draft', 'saved', 'staged', 'published']) { return VersionModel.getOrderedVersions(this.versions.filter((ver) => [].concat(status).includes(ver.status))) } /** Get versions that currently are staged and usable */ getStagedVersions() { const versions = this.versions.filter((ver) => ver.status === 'staged' /* || ver.status === 'published' */) return VersionModel.getOrderedVersions(versions).filter( (version) => versions.find((v) => v.id === version.id)?.deletedAt === null ) } /** Get versions that currently are published */ getPublishedVersions() { const versions = this.versions.filter((ver) => ver.status === 'published' /* || ver.status === 'published' */) return VersionModel.getOrderedVersions(versions).filter( (version) => versions.find((v) => v.id === version.id)?.deletedAt === null ) } reduceVersionRevisions(versions: VersionModel[] = this.versions) { return VersionModel.reduceVersionRevisions(versions) } /** * This function saves new and edited versions of a component. * * @param [autoSave=true] - The `autoSave` parameter is a boolean value that determines whether or not to include * versions with a status of "saved" in the list of non-draft revisions to be saved. If * `autoSave` is `true`, then "saved" versions will be included. If `autoSave`. Default is * `true` * @returns A Promise that resolves when all the new and updated versions have been saved to the server. */ saveVersions(autoSave = true) { if (!this.versions.snapshotted) { throw new Error('component.versions.snapshot() needs to be called when versions are loaded initially ') } const revisions = this.versions let toBeAdded: VersionModel[] = [], toBeUpdated: VersionModel[] = [] // find new and edited versions const nonDraftRevisions = revisions.filter( (version) => version.status !== 'draft' && (autoSave == true || version.status != 'saved') ) nonDraftRevisions.forEach((currentRevision) => { const previousRevision = this.versions.snapshotted.find( (prev) => prev.id === currentRevision.id && prev.status === currentRevision.status && prev.revision === currentRevision.revision ) if (previousRevision) { const isChanged = currentRevision.isDifferentFrom(previousRevision) if (isChanged) { this.sdk.log(' > Changed version', currentRevision) toBeUpdated.push(currentRevision) } } else if (!currentRevision.isDeleted()) { this.sdk.log(' > New version', currentRevision) toBeAdded.push(currentRevision) } }) this.versions.snapshot() return Promise.all([...toBeAdded.map((version) => version.post()), ...toBeUpdated.map((version) => version.put())]) } /** * This function aggregates version data for a component and returns an updated component object. * * @returns The updated component object after aggregating version data. */ aggregateVersionData(versions = this.versions) { return this.set({ ...ComponentModel.getAggregatedVersionData(versions), embeddableVersionCount: this.countEmbeddableVersions(versions) }) } /** Get settings for all datasoruces used in component */ getDataSettings(): DataOptionsById | null { return DatasourceModel.getSettingsFromDatasources(this.datasourceIds, this.sdk.datasources) } /** Get list of used datasources */ getDatasources() { return this.datasourceIds .map((datasourceId) => this.sdk.datasources.find((d) => d.id == datasourceId)) .filter(Boolean) } // default sorting logic sortCompare(other: this) { return Number(other.modifiedAt) - Number(this.modifiedAt) } matchesSearch(search: string) { return this.name.toLowerCase().includes(search.toLowerCase()) } getPath() { return `/libraries/${this.libraryId}/collections/${this.collectionId}/components/${this.id}` } getBuilderPath() { return `/libraries/${this.libraryId}/components/${this.id}` } static getNameWithoutIndex(name: string) { return name.replace(/ \((\d+)\)/gi, '') } static getIndexFromName(name: string) { return name .match(/\((\d+)\)/gi)?.[0] ?.replace('(', '') ?.replace(')', '') } /** * Get all component versions that can be embedded (and thus need to be uploaded to CDN). * Turn bundles into versions as well. */ getEmbeddableVersions(status: VersionModel['status']) { const versions = this.getBundlableVersions(this.getVersions(status)) return [ ...versions.filter((v) => v.isEmbeddable), ...this.getBundles().map((bundle) => bundle.aggregate(status, versions)) ] } countEmbeddableVersions(versions: VersionModel[] = this.getVersions(['staged', 'published'])) { const bundleableVersions = this.getBundlableVersions(VersionModel.getOrderedVersions(versions)) return [ ...bundleableVersions.filter((v) => v.isEmbeddable && ['staged', 'published'].includes(v.status)), ...this.getBundles() ].length } getBundlableVersions(versions: VersionModel[] = this.getVersions()) { return versions.filter((v) => !v.deletedAt) } static getAggregatedVersionData(allVersions: VersionModel[]) { const uniqueDatasourceIds = new Set() const versions = VersionModel.getOrderedVersions(allVersions) const stagedVersions = VersionModel.getOrderedVersions(allVersions.filter((v) => v.status == 'staged')).filter( (v) => !v.deletedAt ) const update = {} as Pick< Component, | 'status' | 'publishedAt' | 'versionCount' | 'datasourceIds' | 'modifiedAt' | 'modifiedBy' | 'stagedAt' | 'stagedVersionCount' | 'embeddableVersionCount' > for (const version of versions) { // Ignore deleted versions if (version.status == 'saved' && version.deletedAt != null) { continue } const { datasourceIds: datasourceIds, modifiedAt: modifiedAt, modifiedBy: modifiedBy } = version datasourceIds.map((id) => uniqueDatasourceIds.add(id)) if (update.modifiedAt == null || modifiedAt > update.modifiedAt) { const { view, ...d } = version update.modifiedAt = modifiedAt update.modifiedBy = modifiedBy } if (version.status === 'staged' && (!update.stagedAt || version.modifiedAt > update.stagedAt)) { update.stagedAt = version.modifiedAt } if (version.status === 'published' && (!update.publishedAt || version.modifiedAt > update.publishedAt)) { update.publishedAt = version.modifiedAt } } if (!stagedVersions.length) update.stagedAt = null update.stagedVersionCount = stagedVersions.length const publishedVersions = versions.filter((v) => v.status === 'published') if (publishedVersions.length && publishedVersions.every((v) => v.deletedAt)) update.publishedAt = null update.versionCount = versions.length update.datasourceIds = Array.from(uniqueDatasourceIds).sort() if (versions.every((v) => v.status == 'published')) { update.status = 'published' } else if (versions.every((v) => v.status == 'published' || v.status == 'staged')) { update.status = 'staged' } else if (versions.length) { update.status = 'changed' } else { update.status = 'draft' } return update } static getMaxIndex(component: ComponentModel, components: ComponentModel[]) { let max = 0 components.map((c) => { const sameName = ComponentModel.getNameWithoutIndex(c.name) === ComponentModel.getNameWithoutIndex(component.name) if (sameName) { const index = ComponentModel.getIndexFromName(c.name) if (parseInt(index) > max) { max = parseInt(index) } } }) return max } /** * Creates a duplicate of a component with an incremented index in the name. Additionally ensures that all instanced * elements get unique id to avoid clashes. For that to work, it expects that versions are already fetched. * * @param component - The component to duplicate. * @param components - An array of existing components. * @returns A new component with the duplicated name and updated versions. */ duplicate(component: ComponentModel, components: ComponentModel[]) { const name = `${ComponentModel.getNameWithoutIndex(component.name)} (${ ComponentModel.getMaxIndex(component, components) + 1 })` const map: Record = {} const clone = this.clone({ id: nanoid(10), name }) clone.versions.map((version) => version.set({ view: VersionModel.getUniqueHTML(version.view, map) }) ) return clone } static get schema(): SDKModelSchema { return new SDKModelSchema(ComponentModel, componentSchema) } /** Copy component to a different collection (possibly from another library). */ async copyToCollection(collection: CollectionModel, options = { versions: true, bundles: true }) { const component = await collection.components.upsertItem(this.export()).save() if (component.versions.length == 0) await component.versions.fetch() if (this.versions.length == 0) await this.versions.fetch() for (const sourceVersion of this.versions) { if (this.getVersions().find((v) => v.id == this.id)?.deletedAt) { continue } component.establishVersion(sourceVersion) } await component.saveVersions() await component.aggregateVersionData().save() return component } serialize() { return { ...super.serialize(), versions: this.versions.map((version) => version.serialize()), bundles: this.bundles.map((bundle) => bundle.serialize()) } } }