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:
parent
98bd9bb4e0
commit
27e44c287f
7 changed files with 811 additions and 77 deletions
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
18
packages/elements/src/account/__mocks__/account-api.ts
Normal file
18
packages/elements/src/account/__mocks__/account-api.ts
Normal 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');
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 {
|
||||
|
|
41
packages/elements/web-test-runner.config.js
Normal file
41
packages/elements/web-test-runner.config.js
Normal 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;
|
640
pnpm-lock.yaml
640
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue