Sitelet https://github.com/limitless-angular/limitless-angular/pull/84/files
Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ export class HeroPostComponent {
>
More Stories
</h2>
<blog-more-stories [moreStories]="posts()" />
@defer (on immediate; hydrate on viewport) {
<blog-more-stories [moreStories]="posts()" />
} @placeholder {
<div class="min-h-64" aria-hidden="true"></div>
}
</aside>
}
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/sanity-presentation-e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const storageStatePath =
? undefined
: existingStorageStatePath(requestedStorageStatePath);
const browserChannel =
(isAuthSetup ? optionalEnv('SANITY_E2E_BROWSER_CHANNEL') : undefined) ??
optionalEnv('SANITY_E2E_BROWSER_CHANNEL') ??
(isAuthSetup && !cdpEndpoint && !process.env['CI'] ? 'chrome' : undefined);
const browserProjectName = cdpEndpoint ? 'cdp' : (browserChannel ?? 'chromium');
const desktopChromeUse = {
Expand Down
3 changes: 2 additions & 1 deletion packages/sanity/image-loader/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export function sanityImageLoader(config?: SanityConfig | null) {
url.searchParams.set('auto', 'format');
url.searchParams.set(
'fit',
url.searchParams.get('fit') || url.searchParams.has('h') ? 'min' : 'max',
url.searchParams.get('fit') ||
(url.searchParams.has('h') ? 'min' : 'max'),
);
if (width && url.searchParams.has('h') && url.searchParams.has('w')) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Expand Down
37 changes: 37 additions & 0 deletions packages/sanity/image-loader/src/sanity-image.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { IMAGE_LOADER, type ImageLoaderConfig } from '@angular/common';
import { Component, signal, viewChild } from '@angular/core';
import { render } from '@testing-library/angular';
import { vi } from 'vitest';

import { SANITY_CONFIG } from '@limitless-angular/sanity/shared';
import { SanityImage } from './sanity-image.directive';
Expand Down Expand Up @@ -112,6 +114,40 @@ describe('SanityImage', () => {
);
});

it('uses the Sanity loader when no custom image loader is provided', async () => {
const { fixture } = await render(SanityImageHost, {
providers: [{ provide: SANITY_CONFIG, useValue: sanityConfig }],
});

await fixture.whenStable();

const image = fixture.nativeElement.querySelector(
'img',
) as HTMLImageElement;
expect(new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Flimitless-angular%2Flimitless-angular%2Fpull%2F84%2Fimage.src).searchParams.get('auto')).toBe('format');
expect(new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Flimitless-angular%2Flimitless-angular%2Fpull%2F84%2Fimage.src).searchParams.get('fit')).toBe('min');
});

it('preserves a custom image loader provided by the consumer', async () => {
const customLoader = vi.fn(
({ src }: ImageLoaderConfig) => `${src}&custom=1`,
);
const { fixture } = await render(SanityImageHost, {
providers: [
{ provide: SANITY_CONFIG, useValue: sanityConfig },
{ provide: IMAGE_LOADER, useValue: customLoader },
],
});

await fixture.whenStable();

const image = fixture.nativeElement.querySelector(
'img',
) as HTMLImageElement;
expect(customLoader).toHaveBeenCalled();
expect(new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Flimitless-angular%2Flimitless-angular%2Fpull%2F84%2Fimage.src).searchParams.get('custom')).toBe('1');
});

it('updates ngSrc when the bound Sanity image changes', async () => {
const { fixture } = await render(SanityImageHost, {
providers: [{ provide: SANITY_CONFIG, useValue: sanityConfig }],
Expand Down Expand Up @@ -143,6 +179,7 @@ describe('SanityImage', () => {

expect(directive.loaderParams).toEqual({ fit: 'crop' });
expect(url.searchParams.get('fit')).toBe('crop');
expect(renderedImageSearchParams(fixture).get('fit')).toBe('crop');
});

it('updates ngSrc when quality changes', async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/sanity/image-loader/src/sanity-image.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const imageUrlInputs = [

const staticNgOptimizedImageInputs = ['loaderParams', 'width', 'height'];

// Angular does not expose its default loader publicly. Keep this identity check
// until custom loader precedence has an explicit public contract.
function getNoopImageLoader() {
return (
IMAGE_LOADER.ɵprov as {
Expand Down
17 changes: 12 additions & 5 deletions packages/sanity/portabletext/src/components/children.component.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
import {
ChangeDetectionStrategy,
Component,
inject,
TemplateRef,
viewChild,
ViewEncapsulation,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { TypedObject } from '@portabletext/types';
import { trackBy } from '../utils';
import { RenderNode } from '../directives/render-node.directive';
import { PORTABLE_TEXT_RENDERER_CONTEXT } from '../tokens';

@Component({
imports: [RenderNode],
imports: [NgTemplateOutlet],
template: `<ng-template let-children="children" let-isInline="isInline">
@for (
child of children;
track trackBy(child._key, $index);
let index = $index
) {
<ng-container
[renderNode]="child"
[isInline]="child.isInline ?? isInline ?? true"
[index]="child.index ?? index"
[ngTemplateOutlet]="renderNode()"
[ngTemplateOutletContext]="{
$implicit: child,
isInline: child.isInline ?? isInline ?? true,
index: child.index ?? index,
}"
/>
}
</ng-template>`,
Expand All @@ -34,5 +39,7 @@ export class ChildrenComponent {
isInline?: boolean;
}>
>(TemplateRef);
protected readonly renderNode = inject(PORTABLE_TEXT_RENDERER_CONTEXT)
.renderNode;
protected readonly trackBy = trackBy;
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,11 @@ import { PORTABLE_TEXT_RENDERER_CONTEXT } from '../tokens';
/>

<ng-template #renderNode let-node let-index="index" let-isInline="isInline">
@if (resolveNode(node, index, isInline); as nodeRendering) {
<ng-container
[ngTemplateOutlet]="nodeRendering.template"
[ngTemplateOutletContext]="nodeRendering.context"
/>
}
@let nodeRendering = resolveNode(node, index, isInline);
<ng-container
[ngTemplateOutlet]="nodeRendering?.template"
[ngTemplateOutletContext]="nodeRendering?.context"
/>
</ng-template>

<ng-template
Expand Down Expand Up @@ -114,7 +113,7 @@ export class PortableTextComponent<
* Custom components to override the default rendering.
* @see PortableTextComponents
*/
componentOverrides = input<PortableTextComponents<B> | undefined>(
componentOverrides = input<PortableTextComponents<B>>(
undefined,
// eslint-disable-next-line @angular-eslint/no-input-rename
{ alias: 'components' },
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,12 +1,62 @@
import { expect, test, describe, vi } from 'vitest';
import { ChangeDetectorRef, Component, inject } from '@angular/core';
import { aliasedInput, render, screen } from '@testing-library/angular';
import { PortableTextComponent } from '../components/portable-text.component';
import { PortableTextTypeComponent } from '../directives/portable-text-directives';
import * as fixtures from './fixtures';
import { HighlightComponent } from './test-components/HighlightComponent';
import { CodeComponent } from './test-components/CodeComponent';
import { assertHTML } from './helpers';

@Component({ template: '<span data-testid="mutable-block">{{ label }}</span>' })
class MutableBlock extends PortableTextTypeComponent {
static latest: MutableBlock;
label = 'before';
readonly changeDetectorRef = inject(ChangeDetectorRef);

constructor() {
super();
MutableBlock.latest = this;
}
}

describe('PortableText Dynamic Updates', () => {
test('updates a consumer component when it notifies change detection', async () => {
const { fixture } = await render(PortableTextComponent, {
inputs: {
value: fixtures.customBlockType.input,
...aliasedInput('components', { types: { code: MutableBlock } }),
},
});

expect(fixture.nativeElement.textContent).toContain('before');
MutableBlock.latest.label = 'after';
MutableBlock.latest.changeDetectorRef.markForCheck();
await fixture.whenStable();

expect(fixture.nativeElement.textContent).toContain('after');
});

test('preserves a keyed custom component when its node changes', async () => {
const { rerender } = await render(PortableTextComponent, {
inputs: {
value: fixtures.customBlockType.input,
...aliasedInput('components', { types: { code: MutableBlock } }),
},
});

const component = MutableBlock.latest;
await rerender({
inputs: {
value: [{ ...fixtures.customBlockType.input[0], code: 'updated' }],
},
partialUpdate: true,
});

expect(MutableBlock.latest).toBe(component);
expect(MutableBlock.latest.value().code).toBe('updated');
});

test('updates rendered content when input changes', async () => {
// Render with initial content
const { rerender, container } = await render(PortableTextComponent, {
Expand Down
78 changes: 78 additions & 0 deletions packages/sanity/preview-kit/src/create-live-data.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';

import { createLiveData } from './create-live-data';
import { LivePreviewService } from './live-preview.service';

describe('createLiveData', () => {
it('starts with initial data, streams updates, and switches queries', () => {
const initial = signal('first initial');
const query = signal('first');
const first = new Subject<string>();
const second = new Subject<string>();
const streams = { first, second };
const service = {
listenLiveQuery: (_initial: string, key: string) =>
streams[key as keyof typeof streams],
};
TestBed.configureTestingModule({
providers: [{ provide: LivePreviewService, useValue: service }],
});

const liveData = TestBed.runInInjectionContext(() =>
createLiveData(
() => initial(),
() => ({ query: query() }),
),
);

expect(liveData()).toBe('first initial');
TestBed.tick();
first.next('live first');
expect(liveData()).toBe('live first');

initial.set('second initial');
query.set('second');
TestBed.tick();
expect(liveData()).toBe('second initial');

first.next('stale update');
expect(liveData()).toBe('second initial');
second.next('live second');
expect(liveData()).toBe('live second');
});

it('combines multiple query streams after each has emitted', () => {
const post = new Subject<string>();
const settings = new Subject<string>();
const streams = { post, settings };
const service = {
listenLiveQuery: (_initial: string, key: string) =>
streams[key as keyof typeof streams],
};
TestBed.configureTestingModule({
providers: [{ provide: LivePreviewService, useValue: service }],
});

const liveData = TestBed.runInInjectionContext(() =>
createLiveData(
() => ({ post: 'initial post', settings: 'initial settings' }),
() => ({ post: { query: 'post' }, settings: { query: 'settings' } }),
),
);

expect(liveData()).toEqual({
post: 'initial post',
settings: 'initial settings',
});
TestBed.tick();
post.next('live post');
expect(liveData().post).toBe('initial post');
settings.next('live settings');
expect(liveData()).toEqual({
post: 'live post',
settings: 'live settings',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';

import { LivePreviewService } from './live-preview.service';
import { LiveQueryProviderComponent } from './live-query-provider.component';

describe('LiveQueryProviderComponent', () => {
it('initializes once and updates perspective when its input changes', async () => {
const service = {
isInitialized: false,
initialize: vi.fn(function (this: typeof service) {
this.isInitialized = true;
}),
setPerspective: vi.fn(),
};
const logger = { log: vi.fn() };

TestBed.configureTestingModule({ imports: [LiveQueryProviderComponent] });
TestBed.overrideComponent(LiveQueryProviderComponent, {
set: { providers: [{ provide: LivePreviewService, useValue: service }] },
});

const fixture = TestBed.createComponent(LiveQueryProviderComponent);
fixture.componentRef.setInput('token', 'test-token');
fixture.componentRef.setInput('logger', logger);
fixture.componentRef.setInput('perspective', 'drafts');
await fixture.whenStable();

expect(service.initialize).toHaveBeenCalledExactlyOnceWith('test-token');
expect(service.setPerspective).toHaveBeenCalledExactlyOnceWith('drafts');
expect(logger.log).toHaveBeenCalledOnce();

fixture.componentRef.setInput('perspective', 'published');
await fixture.whenStable();

expect(service.initialize).toHaveBeenCalledTimes(1);
expect(service.setPerspective).toHaveBeenLastCalledWith('published');
fixture.destroy();
});
});
Loading
Loading