Skip to content

Commit

Permalink
Feature/app wrappers registry (#249)
Browse files Browse the repository at this point in the history
* feat: App Wrappers support for Registry API & UI

* chore: seeds adjustment for new demo page with App Wrappers

* chore: E2E tests updated to new Demo apps

* chore: E2E tests for home page & appWrapper
  • Loading branch information
StyleT authored Jan 28, 2021
1 parent 36d8a65 commit c6e0714
Show file tree
Hide file tree
Showing 21 changed files with 301 additions and 45 deletions.
2 changes: 2 additions & 0 deletions e2e/codecept.presettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const appPorts = {
fetchWithCache: 8238,
news: 8239,
system: 8240,
wrapper: 8234,
};

const resources = [
Expand All @@ -22,6 +23,7 @@ const resources = [
`http-get://127.0.0.1:${appPorts.fetchWithCache}`,
`http-get://127.0.0.1:${appPorts.ilc}/ping`,
`http-get://127.0.0.1:${appPorts.registry}/ping`,
`http-get://127.0.0.1:${appPorts.wrapper}/client-entry.js`,
];

let childProcess;
Expand Down
44 changes: 44 additions & 0 deletions e2e/spec/appWrapper.spec.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
Feature('App Wrapper');

Scenario('Renders App Wrapper (SSR) & mounts target app on click', (I) => {
I.amOnPage('/wrapper/');
I.waitForText('Hello from wrapper!', 10, '#body');

I.click('Mount actual app', '#body');
I.waitForText('Welcome to target app', 10, '#body');
I.see('propFromWrapper', '#body')
I.see('fromClick', '#body')
});

Scenario('Renders App Wrapper (CSR) & mounts target app on click', (I) => {
I.amOnPage('/');
I.click(`a[href="/wrapper/"]`);
I.waitForText('Hello from wrapper!', 10, '#body');

I.click('Mount actual app', '#body');
I.waitForText('Welcome to target app', 10, '#body');
I.see('propFromWrapper', '#body')
I.see('fromClick', '#body')
});

Scenario('Renders Target App (SSR)', (I) => {
I.amOnPage('/wrapper/?showApp=1');
I.waitForText('Welcome to target app', 10, '#body');
I.see('propFromWrapper', '#body')
I.see('fromLocation', '#body')
});

Scenario('Renders Target App (CSR)', (I) => {
I.amOnPage('/wrapper/');
I.waitForText('Hello from wrapper!', 10, '#body');
I.click('Mount actual app', '#body');

I.waitForText('Welcome to target app', 10, '#body');
I.click(`a[href="/nosuchpath"]`);
I.waitForText('404', 10, '#body');
I.executeScript('window.history.back();');

I.waitForText('Welcome to target app', 10, '#body');
I.see('propFromWrapper', '#body')
I.see('fromLocation', '#body')
});
6 changes: 6 additions & 0 deletions e2e/spec/home.spec.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Feature('Home page');

Scenario('Renders home page', (I) => {
I.amOnPage('/');
I.waitForText('Hi! Welcome to our Demo website', 10, '#body');
});
8 changes: 4 additions & 4 deletions e2e/spec/i18n.spec.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@ Feature('I18n');

Scenario('Renders (SSR) default language', (I) => {
I.amOnPage('/news/');
I.see('People', '#navbar .primary-navigation-link');
I.see('No SSR', '#navbar .primary-navigation-link');
});

Scenario('Renders (SSR) UA language', (I) => {
I.amOnPage('/ua/news/');
I.see('Люди', '#navbar .primary-navigation-link');
I.see('Без SSR', '#navbar .primary-navigation-link');
});

Scenario('Switches language there and backwards', (I) => {
I.amOnPage('/news/');

I.click('UA', '#navbar');
I.see('Люди', '#navbar .primary-navigation-link');
I.see('Без SSR', '#navbar .primary-navigation-link');
I.seeInCurrentUrl('/ua/news/');

I.click('EN', '#navbar');
I.see('People', '#navbar .primary-navigation-link');
I.see('No SSR', '#navbar .primary-navigation-link');
I.seeInCurrentUrl('/news/');
});

Expand Down
12 changes: 0 additions & 12 deletions ilc/server/registry/Registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,6 @@ module.exports = class Registry {
responseType: 'json',
});

//FIXME: remove TEMP code:
// res.data.apps['@portal/wrapper1'] = {
// spaBundle: 'http://0.0.0.0:8234/client-entry.js',
// //cssBundle: 'http://localhost:8239/dist/common.277f5696f6ae7ecdbe2d.css',
// ssr: { src: 'http://localhost:8234/fragment', timeout: 5000 },
// props: {},
// kind: 'wrapper'
// };
// res.data.apps['@portal/news'].wrappedWith = '@portal/wrapper1';
// console.log(res.data.apps);
//FIXME: remove TEMP code ^^^

this.#cacheHeated.config = true;

return res.data;
Expand Down
13 changes: 8 additions & 5 deletions registry/client/src/appRoutes/Edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const requiredSpecial = (value, allValues, props) => {
return required()(value, allValues);
};

const allowedAppKinds = [
{ id: 'primary', name: 'Primary' },
{ id: 'essential', name: 'Essential' },
{ id: 'regular', name: 'Regular' },
];

const InputForm = ({mode = 'edit', ...props}) => {
return (
<TabbedForm {...props}>
Expand Down Expand Up @@ -57,15 +63,12 @@ const InputForm = ({mode = 'edit', ...props}) => {
<SimpleFormIterator>
<TextInput source="key" label="Slot name" validate={[required()]} fullWidth />
<ReferenceInput reference="app"
filter={{kind: allowedAppKinds.map(v => v.id)}}
source="appName"
label="App name">
<AutocompleteInput optionValue="name" validate={[required()]} />
</ReferenceInput>
<SelectInput resettable source="kind" label="App type" choices={[
{ id: 'primary', name: 'Primary' },
{ id: 'essential', name: 'Essential' },
{ id: 'regular', name: 'Regular' },
]} />
<SelectInput resettable source="kind" label="App type" choices={allowedAppKinds} />
<JsonField source="props" label="Properties that will be passed to application at current route"/>
</SimpleFormIterator>
</ArrayInput>
Expand Down
9 changes: 9 additions & 0 deletions registry/client/src/apps/Edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
NumberInput,
TextField,
ReferenceArrayInput,
ReferenceInput,
AutocompleteArrayInput,
} from 'react-admin'; // eslint-disable-line import/no-unresolved

Expand Down Expand Up @@ -67,10 +68,18 @@ const InputForm = ({mode = 'edit', ...props}) => {
{id: 'primary', name: 'Primary'},
{id: 'essential', name: 'Essential'},
{id: 'regular', name: 'Regular'},
{id: 'wrapper', name: 'Wrapper'},
]} validate={validators.required} />
<ReferenceArrayInput reference="shared_props" source="configSelector" label="Shared props selector">
<AutocompleteArrayInput />
</ReferenceArrayInput>
<FormDataConsumer>
{({ formData, ...rest }) => formData.kind !== 'wrapper' &&
<ReferenceInput reference="app" source="wrappedWith" label="Wrapped with" filter={{kind: 'wrapper'}} allowEmpty {...rest}>
<SelectInput optionText="name" />
</ReferenceInput>
}
</FormDataConsumer>
</FormTab>
<FormTab label="Assets">
<FormDataConsumer>
Expand Down
13 changes: 12 additions & 1 deletion registry/server/appRoutes/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
import {
appNameSchema,
} from '../../apps/interfaces';
import db from "../../db";
import {getJoiErr} from "../../util/helpers";

const Joi = JoiDefault.defaults(schema => {
return schema.empty(null)
Expand All @@ -32,7 +34,16 @@ export default interface AppRouteSlot {

const commonAppRouteSlot = {
name: Joi.string().trim().min(1).max(255),
appName: appNameSchema,
appName: appNameSchema.external(async value => {
const wrapperApp = await db('apps').first('kind').where({ name: value });
if (!wrapperApp) {
throw getJoiErr('appName', `Non-existing app name "${value}" specified.`);
} else if (wrapperApp.kind === 'wrapper') {
throw getJoiErr('appName', 'It\'s forbidden to use wrappers in routes.');
}

return value;
}),
props: Joi.object().default({}),
kind: Joi.string().valid('primary', 'essential', 'regular', null),
};
Expand Down
21 changes: 20 additions & 1 deletion registry/server/apps/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import JoiDefault from 'joi';
import db from "../../db";
import {getJoiErr} from "../../util/helpers";

const Joi = JoiDefault.defaults(schema => {
return schema.empty(null)
Expand All @@ -13,6 +15,7 @@ export default interface App {
props?: string, // JSON({ [propName: string]: any })
configSelector?: string,
ssr: string, // JSON({ src: string, timeout: number })
wrappedWith?: string,
};

export const appNameSchema = Joi.string().trim().min(1);
Expand All @@ -28,7 +31,23 @@ const commonApp = {
src: Joi.string().trim().uri(),
timeout: Joi.number(),
}).and('src', 'timeout').empty({}).default(null),
kind: Joi.string().valid('primary', 'essential', 'regular'),
kind: Joi.string().valid('primary', 'essential', 'regular', 'wrapper'),
wrappedWith: Joi.when('kind', {
is: 'wrapper',
then: Joi.any().custom(() => null),
otherwise: Joi.string().trim().default(null).external(async (value) => {
if (value === null) {
return null;
}

const wrapperApp = await db('apps').first('kind').where({ name: value });
if (!wrapperApp || wrapperApp.kind !== 'wrapper') {
throw getJoiErr('wrappedWith', 'Specified wrapper app is not a wrapper.');
}

return value;
}),
}),
};

export const partialAppSchema = Joi.object({
Expand Down
8 changes: 8 additions & 0 deletions registry/server/apps/routes/getApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ const getApps = async (req: Request, res: Response): Promise<void> => {
if (filters.id || filters.name) {
query.whereIn('name', [...filters.id || filters.name]);
}
if (typeof filters.kind === 'string') {
query.where('kind', filters.kind);
} else if (Array.isArray(filters.kind)) {
query.whereIn('kind', filters.kind);
}
if (filters.q) {
query.where('name', 'like', `%${filters.q}%`);
}

const apps = await query.range(req.query.range as string | undefined);

Expand Down
8 changes: 7 additions & 1 deletion registry/server/common/services/validateRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ const validateRequestFactory = (validationConfig: ValidationConfig[]) => async (
));
next();
} catch (e) {
res.status(422).send(preProcessErrorResponse(e));
res.status(422);
if (e instanceof Joi.ValidationError) {
res.send(preProcessErrorResponse(e));
} else {
console.error(e);
res.send('Unexpected validation error');
}
}
};

Expand Down
67 changes: 67 additions & 0 deletions registry/server/migrations/20210125185210_app_wrapper_kind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as Knex from 'knex';

export async function up(knex: Knex): Promise<void> {
if (isMySQL(knex)) {
return knex.raw("ALTER TABLE `apps` " +
"MODIFY COLUMN `kind` " +
"enum('primary', 'essential','essential','wrapper') " +
"NOT NULL DEFAULT 'regular';");
} else {
await alterSqliteTable(knex, 'apps', `
name varchar(50) not null primary key,
spaBundle varchar(255) not null,
cssBundle varchar(255),
dependencies json,
ssr json,
props json,
assetsDiscoveryUrl varchar(255),
assetsDiscoveryUpdatedAt integer,
kind text default 'regular',
configSelector varchar(255),
check (\`kind\` in ('primary', 'essential', 'regular', 'wrapper'))
`);
}
}


export async function down(knex: Knex): Promise<void> {
if (isMySQL(knex)) {
return knex.raw("ALTER TABLE `apps` " +
"MODIFY COLUMN `kind` " +
"enum('primary', 'essential','essential') " +
"NOT NULL DEFAULT 'regular';");
} else {
await alterSqliteTable(knex,'apps', `
name varchar(50) not null primary key,
spaBundle varchar(255) not null,
cssBundle varchar(255),
dependencies json,
ssr json,
props json,
assetsDiscoveryUrl varchar(255),
assetsDiscoveryUpdatedAt integer,
kind text default 'regular',
configSelector varchar(255),
check (\`kind\` in ('primary', 'essential', 'regular'))
`);
}
}

async function alterSqliteTable(knex: Knex, tableName: string, columnsSql: string):Promise<void> {
try {
await knex.schema.raw(`PRAGMA foreign_keys=off;`);
await knex.transaction(async trx => {
await trx.raw(`CREATE TABLE tbl_tmp (${columnsSql});`);
await trx.raw(`INSERT INTO tbl_tmp SELECT * FROM \`${tableName}\`;`);
await trx.raw(`DROP TABLE \`${tableName}\`;`);
await trx.raw(`ALTER TABLE tbl_tmp RENAME TO \`${tableName}\`;`);
});
} finally {
await knex.schema.raw(`PRAGMA foreign_keys=on;`);
}

}

function isMySQL(knex: Knex) {
return ["mysql", "mariasql", "mariadb"].indexOf(knex.client.dialect) > -1;
}
16 changes: 16 additions & 0 deletions registry/server/migrations/20210125185211_apps_wrappedWith.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Knex from "knex";


export async function up(knex: Knex): Promise<any> {
return knex.schema.table('apps', table => {
table.string('wrappedWith', 50).nullable().references('apps.name');
});
}


export async function down(knex: Knex): Promise<any> {
return knex.schema.table('apps', table => {
table.dropColumn('wrappedWith');
});
}

2 changes: 1 addition & 1 deletion registry/server/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ router.get('/', async (req, res) => {
}

v = _.omitBy(v, v => v === null || (typeof v === 'object' && Object.keys(v).length === 0));
acc[v.name] = _.pick(v, ['kind', 'ssr', 'dependencies', 'props', 'spaBundle', 'cssBundle']);
acc[v.name] = _.pick(v, ['kind', 'ssr', 'dependencies', 'props', 'spaBundle', 'cssBundle', 'wrappedWith']);

return acc;
}, {});
Expand Down
21 changes: 21 additions & 0 deletions registry/server/seeds/01_apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,33 @@ export async function seed(knex: Knex): Promise<any> {
dependencies: '{}',
props: '{}',
kind: 'primary',
}, {
name: '@portal/systemWithWrapper',
spaBundle: `http://${publicHost}:8240/index.js`,
ssr: JSON.stringify({
src: "http://127.0.0.1:8240/fragment",
timeout: 1000,
}),
dependencies: '{}',
props: '{}',
kind: 'primary',
wrappedWith: '@portal/wrapper',
}, {
name: '@portal/fetchWithCache',
spaBundle: `http://${publicHost}:8238/fetchWithCache.js`,
dependencies: '{}',
props: '{}',
kind: 'essential',
}, {
name: '@portal/wrapper',
spaBundle: `http://${publicHost}:8234/client-entry.js`,
ssr: JSON.stringify({
src: "http://127.0.0.1:8234/fragment",
timeout: 2000,
}),
dependencies: '{}',
props: '{}',
kind: 'wrapper',
},
]);
}
Loading

0 comments on commit c6e0714

Please sign in to comment.