0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(elements): introduce basic logto-account-provider (#6685)

This commit is contained in:
Xiao Yijun 2024-10-22 15:43:27 +08:00 committed by GitHub
parent 98bd9bb4e0
commit 27e44c287f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 811 additions and 77 deletions

View file

@ -69,6 +69,10 @@ jobs:
- name: Build for test
run: pnpm -r build:test
- name: Install playwright for element tests
working-directory: ./packages/elements
run: pnpm exec playwright install --with-deps
- name: Test
run: pnpm ci:test

View file

@ -34,8 +34,8 @@
"dev": "tsup --watch --no-splitting",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"test": "echo \"No tests yet.\"",
"test:ci": "pnpm run test --silent --coverage"
"test": "web-test-runner",
"test:ci": "pnpm run test --coverage"
},
"engines": {
"node": "^20.9.0"
@ -54,11 +54,16 @@
"devDependencies": {
"@lit/localize-tools": "^0.7.2",
"@logto/schemas": "workspace:^1.20.0",
"@open-wc/testing": "^4.0.0",
"@playwright/test": "^1.48.1",
"@silverhand/eslint-config": "6.0.1",
"@silverhand/ts-config": "6.0.0",
"@types/mocha": "^10.0.9",
"@types/node": "^20.9.5",
"@web/dev-server": "^0.4.6",
"@web/dev-server-esbuild": "^1.0.2",
"@web/test-runner": "^0.19.0",
"@web/test-runner-playwright": "^0.11.0",
"eslint": "^8.56.0",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0",

View file

@ -0,0 +1,18 @@
import { LogtoAccountApi } from '../api/index.js';
import { type UserProfile } from '../types.js';
type CreteMockAccountApiOptions = {
fetchUserProfile: () => Promise<UserProfile>;
};
export const createMockAccountApi = ({
fetchUserProfile,
}: CreteMockAccountApiOptions): LogtoAccountApi => {
class MockAccountApi extends LogtoAccountApi {
async fetchUserProfile(): Promise<UserProfile> {
return fetchUserProfile();
}
}
return new MockAccountApi('https://mock.logto.app', async () => 'dummy_access_token');
};

View file

@ -0,0 +1,108 @@
import { consume } from '@lit/context';
import { assert, fixture, html, nextFrame, waitUntil } from '@open-wc/testing';
import { type Optional } from '@silverhand/essentials';
import { LitElement, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createMockAccountApi } from '../__mocks__/account-api.js';
import {
logtoAccountContext,
type LogtoAccountContextType,
LogtoAccountProvider,
} from './logto-account-provider.js';
@customElement('test-logto-account-provider-consumer')
export class TestLogtoAccountProviderConsumer extends LitElement {
@consume({ context: logtoAccountContext, subscribe: true })
@property({ attribute: false })
accountContext?: LogtoAccountContextType;
render() {
if (!this.accountContext) {
return nothing;
}
return html`<div id="user-id">${this.accountContext.userProfile.id}</div>`;
}
}
suite('logto-account-provider', () => {
test('is defined', () => {
const element = document.createElement('logto-account-provider');
assert.instanceOf(element, LogtoAccountProvider);
});
test('should render not initialized content when account api is not provided', async () => {
const provider = await fixture<LogtoAccountProvider>(
html`<logto-account-provider></logto-account-provider>`
);
await provider.updateComplete;
assert.equal(
provider.shadowRoot?.textContent,
`${LogtoAccountProvider.tagName} not initialized.`
);
});
test('should correctly consume logto account provider context', async () => {
const testUserId = '123';
const mockAccountApi = createMockAccountApi({
fetchUserProfile: async () => ({
id: testUserId,
}),
});
const provider = await fixture<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<test-logto-account-provider-consumer></test-logto-account-provider-consumer>
</logto-account-provider>`
);
await provider.updateComplete;
const consumer = provider.querySelector<TestLogtoAccountProviderConsumer>(
'test-logto-account-provider-consumer'
)!;
await waitUntil(
() => consumer.shadowRoot?.querySelector('#user-id')?.textContent === testUserId,
'Unable to get user data from account context'
);
});
test('should render error content and dispatch error event when initialize failed', async () => {
const errorMessage = 'Failed to fetch user profile';
// eslint-disable-next-line @silverhand/fp/no-let
let dispatchedErrorEvent: Optional<ErrorEvent>;
const mockAccountApi = createMockAccountApi({
fetchUserProfile: async () => {
// Simulate network delay
await nextFrame();
throw new Error(errorMessage);
},
});
const provider = await fixture<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<test-logto-account-provider-consumer></test-logto-account-provider-consumer>
</logto-account-provider>`
);
provider.addEventListener('error', (event) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
dispatchedErrorEvent = event;
});
await provider.updateComplete;
await waitUntil(
() => provider.shadowRoot?.textContent === `${LogtoAccountProvider.tagName}: ${errorMessage}`
);
assert.equal(dispatchedErrorEvent?.error.message, errorMessage);
});
});

View file

@ -1,14 +1,78 @@
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import { createContext, provide } from '@lit/context';
import { html, LitElement, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { type LogtoAccountApi } from '../api/index.js';
import { type UserProfile } from '../types.js';
export type LogtoAccountContextType = {
userProfile: UserProfile;
};
export const logtoAccountContext = createContext<LogtoAccountContextType>('logto-account-context');
const tagName = 'logto-account-provider';
/**
* LogtoAccountProvider
*
* Provides necessary account information to child components.
*/
@customElement(tagName)
export class LogtoAccountProvider extends LitElement {
static tagName = tagName;
/**
* The API client for the Logto account elements
*
* Used to interact with Account-related backend APIs, including the Profile API.
*/
@property({ attribute: false })
accountApi?: LogtoAccountApi;
@state()
@provide({ context: logtoAccountContext })
private accountContext?: LogtoAccountContextType;
@state()
private error?: Error = undefined;
render() {
if (this.error) {
return html`<span>${tagName}: ${this.error.message}</span>`;
}
if (!this.accountContext) {
return html`<span>${tagName} not initialized.</span>`;
}
return html`<slot></slot>`;
}
updated(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('accountApi') && this.accountApi) {
void this.initProvider();
}
}
private async initProvider() {
if (!this.accountApi) {
return;
}
try {
const userProfile = await this.accountApi.fetchUserProfile();
this.accountContext = {
userProfile,
};
} catch (error) {
const errorObject =
error instanceof Error ? error : new Error(`Unknown error: ${String(error)}`);
this.error = errorObject;
this.dispatchEvent(new ErrorEvent('error', { error: errorObject }));
}
}
}
declare global {

View file

@ -0,0 +1,41 @@
import { fileURLToPath } from 'node:url';
import { esbuildPlugin } from '@web/dev-server-esbuild';
import { playwrightLauncher } from '@web/test-runner-playwright';
const config = {
files: ['src/**/*.test.ts'],
nodeResolve: {
exportConditions: ['development'],
},
plugins: [
esbuildPlugin({
ts: true,
tsconfig: fileURLToPath(new URL('tsconfig.json', import.meta.url)),
}),
// Transform SVG files into Lit templates
{
name: 'transform-svg',
transform(context) {
if (context.path.endsWith('.svg')) {
return {
body: `import { html } from 'lit';\nexport default html\`${context.body}\`;`,
headers: { 'content-type': 'application/javascript' },
};
}
},
},
],
browsers: [playwrightLauncher({ product: 'chromium' })],
testFramework: {
config: {
ui: 'tdd',
timeout: 5000,
},
},
coverageConfig: {
include: ['src/**/*.ts'],
},
};
export default config;

File diff suppressed because it is too large Load diff