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
|
- name: Build for test
|
||||||
run: pnpm -r build: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
|
- name: Test
|
||||||
run: pnpm ci:test
|
run: pnpm ci:test
|
||||||
|
|
||||||
|
|
|
@ -34,8 +34,8 @@
|
||||||
"dev": "tsup --watch --no-splitting",
|
"dev": "tsup --watch --no-splitting",
|
||||||
"lint": "eslint --ext .ts src",
|
"lint": "eslint --ext .ts src",
|
||||||
"lint:report": "pnpm lint --format json --output-file report.json",
|
"lint:report": "pnpm lint --format json --output-file report.json",
|
||||||
"test": "echo \"No tests yet.\"",
|
"test": "web-test-runner",
|
||||||
"test:ci": "pnpm run test --silent --coverage"
|
"test:ci": "pnpm run test --coverage"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.9.0"
|
"node": "^20.9.0"
|
||||||
|
@ -54,11 +54,16 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lit/localize-tools": "^0.7.2",
|
"@lit/localize-tools": "^0.7.2",
|
||||||
"@logto/schemas": "workspace:^1.20.0",
|
"@logto/schemas": "workspace:^1.20.0",
|
||||||
|
"@open-wc/testing": "^4.0.0",
|
||||||
|
"@playwright/test": "^1.48.1",
|
||||||
"@silverhand/eslint-config": "6.0.1",
|
"@silverhand/eslint-config": "6.0.1",
|
||||||
"@silverhand/ts-config": "6.0.0",
|
"@silverhand/ts-config": "6.0.0",
|
||||||
|
"@types/mocha": "^10.0.9",
|
||||||
"@types/node": "^20.9.5",
|
"@types/node": "^20.9.5",
|
||||||
"@web/dev-server": "^0.4.6",
|
"@web/dev-server": "^0.4.6",
|
||||||
"@web/dev-server-esbuild": "^1.0.2",
|
"@web/dev-server-esbuild": "^1.0.2",
|
||||||
|
"@web/test-runner": "^0.19.0",
|
||||||
|
"@web/test-runner-playwright": "^0.11.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"lint-staged": "^15.0.0",
|
"lint-staged": "^15.0.0",
|
||||||
"prettier": "^3.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 { createContext, provide } from '@lit/context';
|
||||||
import { customElement } from 'lit/decorators.js';
|
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';
|
const tagName = 'logto-account-provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LogtoAccountProvider
|
||||||
|
*
|
||||||
|
* Provides necessary account information to child components.
|
||||||
|
*/
|
||||||
@customElement(tagName)
|
@customElement(tagName)
|
||||||
export class LogtoAccountProvider extends LitElement {
|
export class LogtoAccountProvider extends LitElement {
|
||||||
static tagName = tagName;
|
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() {
|
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>`;
|
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 {
|
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