mirror of
https://github.com/directus/directus.git
synced 2026-01-23 04:58:00 -05:00
Add support for a package extension bundle type (#15672)
* Add bundle type to constants and types * Add support for API bundle extensions * Rename generateExtensionsEntry to generateExtensionsEntrypoint * Add support for App bundle extensions * Refactor App extension registration * Replace extensions inject with useExtensions() * Replace getInterfaces() with useExtensions() * Replace getDisplays() with useExtensions() * Replace getLayouts() with useExtensions() * Replace getModules() with useExtensions() * Replace getPanels() with useExtensions() * Replace getOperations() with useExtensions() * Add useExtension() composable * Replace useExtensions() with useExtension() where applicable * Remove interface getters * Remove display getters * Remove layout getters * Remove module getter * Remove panel getters * Remove operation getters * Rename extension register.ts files to index.ts * Perform module pre register check in parallel * Remove Refs from AppExtensionConfigs type * Remove old extension shims * Ensure registration of modules is awaited when hydrating * Add support for scaffolding package extensions * Add support for building bundle extensions * Add JsonValue type * Use json for complex command line flags * Load internal extensions if custom ones are not available * Fix extension manifest validation for pack extensions * Fix tests in shared * Add SplitEntrypoint type * Move command specific utils to helpers * Add SDK version getter * Move extension dev deps generation to helpers * Move template path to getter util * Move template copying to a helper * Only rename copied template files * Add directus-extension add command * Convert provided extension source path to url * Replace deprecated import.meta.globEager * Mock URL.createObjectURL to make App unit tests pass * Update rollup-plugin-typescript2 * indentation * sort vite glob imported modules * fix unintentional wrong commit * Simplify app extension import logic * reinstall @rollup/plugin-virtual * add test for getInterfaces() expected sort order Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com> Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0859102a61
commit
7bf90efa62
@@ -35,27 +35,20 @@ router.get(
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:type/index.js',
|
||||
'/sources/index.js',
|
||||
asyncHandler(async (req, res) => {
|
||||
const type = depluralize(req.params.type as Plural<string>);
|
||||
|
||||
if (!isIn(type, APP_OR_HYBRID_EXTENSION_TYPES)) {
|
||||
throw new RouteNotFoundException(req.path);
|
||||
}
|
||||
|
||||
const extensionManager = getExtensionManager();
|
||||
|
||||
const extensionSource = extensionManager.getAppExtensions(type);
|
||||
if (extensionSource === undefined) {
|
||||
const extensionSource = extensionManager.getAppExtensions();
|
||||
if (extensionSource === null) {
|
||||
throw new RouteNotFoundException(req.path);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
|
||||
if (env.EXTENSIONS_CACHE_TTL) {
|
||||
res.setHeader('Cache-Control', getCacheControlHeader(req, ms(env.EXTENSIONS_CACHE_TTL as string)));
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
env.EXTENSIONS_CACHE_TTL ? getCacheControlHeader(req, ms(env.EXTENSIONS_CACHE_TTL as string)) : 'no-store'
|
||||
);
|
||||
res.setHeader('Vary', 'Origin, Cache-Control');
|
||||
res.end(extensionSource);
|
||||
})
|
||||
|
||||
@@ -3,21 +3,20 @@ import path from 'path';
|
||||
import {
|
||||
ActionHandler,
|
||||
ApiExtension,
|
||||
AppExtensionType,
|
||||
BundleExtension,
|
||||
EndpointConfig,
|
||||
Extension,
|
||||
ExtensionType,
|
||||
FilterHandler,
|
||||
HookConfig,
|
||||
HybridExtension,
|
||||
HybridExtensionType,
|
||||
InitHandler,
|
||||
OperationApiConfig,
|
||||
ScheduleHandler,
|
||||
} from '@directus/shared/types';
|
||||
import {
|
||||
ensureExtensionDirs,
|
||||
generateExtensionsEntry,
|
||||
generateExtensionsEntrypoint,
|
||||
getLocalExtensions,
|
||||
getPackageExtensions,
|
||||
pathToRelativeUrl,
|
||||
@@ -26,12 +25,10 @@ import {
|
||||
import {
|
||||
API_OR_HYBRID_EXTENSION_PACKAGE_TYPES,
|
||||
API_OR_HYBRID_EXTENSION_TYPES,
|
||||
APP_OR_HYBRID_EXTENSION_TYPES,
|
||||
APP_SHARED_DEPS,
|
||||
EXTENSION_PACKAGE_TYPES,
|
||||
EXTENSION_TYPES,
|
||||
HYBRID_EXTENSION_TYPES,
|
||||
PACKAGE_EXTENSION_TYPES,
|
||||
} from '@directus/shared/constants';
|
||||
import getDatabase from './database';
|
||||
import emitter, { Emitter } from './emitter';
|
||||
@@ -69,13 +66,15 @@ export function getExtensionManager(): ExtensionManager {
|
||||
return extensionManager;
|
||||
}
|
||||
|
||||
type AppExtensions = Partial<Record<AppExtensionType | HybridExtensionType, string>>;
|
||||
type ApiExtensions = {
|
||||
hooks: { path: string; events: EventHandler[] }[];
|
||||
endpoints: { path: string }[];
|
||||
operations: { path: string }[];
|
||||
type BundleConfig = {
|
||||
endpoints: { name: string; config: EndpointConfig }[];
|
||||
hooks: { name: string; config: HookConfig }[];
|
||||
operations: { name: string; config: OperationApiConfig }[];
|
||||
};
|
||||
|
||||
type AppExtensions = string | null;
|
||||
type ApiExtensions = { path: string }[];
|
||||
|
||||
type Options = {
|
||||
schedule: boolean;
|
||||
watch: boolean;
|
||||
@@ -92,10 +91,11 @@ class ExtensionManager {
|
||||
|
||||
private extensions: Extension[] = [];
|
||||
|
||||
private appExtensions: AppExtensions = {};
|
||||
private apiExtensions: ApiExtensions = { hooks: [], endpoints: [], operations: [] };
|
||||
private appExtensions: AppExtensions = null;
|
||||
private apiExtensions: ApiExtensions = [];
|
||||
|
||||
private apiEmitter: Emitter;
|
||||
private hookEvents: EventHandler[] = [];
|
||||
private endpointRouter: Router;
|
||||
|
||||
private reloadQueue: JobQueue;
|
||||
@@ -179,8 +179,8 @@ class ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
public getAppExtensions(type: AppExtensionType | HybridExtensionType): string | undefined {
|
||||
return this.appExtensions[type];
|
||||
public getAppExtensions(): string | null {
|
||||
return this.appExtensions;
|
||||
}
|
||||
|
||||
public getEndpointRouter(): Router {
|
||||
@@ -198,25 +198,24 @@ class ExtensionManager {
|
||||
}
|
||||
|
||||
await this.registerHooks();
|
||||
this.registerEndpoints();
|
||||
await this.registerEndpoints();
|
||||
await this.registerOperations();
|
||||
await this.registerBundles();
|
||||
|
||||
if (env.SERVE_APP) {
|
||||
this.appExtensions = await this.generateExtensionBundles();
|
||||
this.appExtensions = await this.generateExtensionBundle();
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
}
|
||||
|
||||
private async unload(): Promise<void> {
|
||||
this.unregisterHooks();
|
||||
this.unregisterEndpoints();
|
||||
this.unregisterOperations();
|
||||
this.unregisterApiExtensions();
|
||||
|
||||
this.apiEmitter.offAll();
|
||||
|
||||
if (env.SERVE_APP) {
|
||||
this.appExtensions = {};
|
||||
this.appExtensions = null;
|
||||
}
|
||||
|
||||
this.isLoaded = false;
|
||||
@@ -257,9 +256,9 @@ class ExtensionManager {
|
||||
extensions
|
||||
.filter((extension) => !extension.local)
|
||||
.flatMap((extension) =>
|
||||
isTypeIn(extension, PACKAGE_EXTENSION_TYPES)
|
||||
extension.type === 'pack'
|
||||
? path.resolve(extension.path, 'package.json')
|
||||
: isTypeIn(extension, HYBRID_EXTENSION_TYPES)
|
||||
: isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
|
||||
? [
|
||||
path.resolve(extension.path, extension.entrypoint.app),
|
||||
path.resolve(extension.path, extension.entrypoint.api),
|
||||
@@ -288,40 +287,36 @@ class ExtensionManager {
|
||||
return [...packageExtensions, ...localExtensions];
|
||||
}
|
||||
|
||||
private async generateExtensionBundles() {
|
||||
private async generateExtensionBundle(): Promise<string | null> {
|
||||
const sharedDepsMapping = await this.getSharedDepsMapping(APP_SHARED_DEPS);
|
||||
const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({
|
||||
find: name,
|
||||
replacement: path,
|
||||
}));
|
||||
|
||||
const bundles: Partial<Record<AppExtensionType | HybridExtensionType, string>> = {};
|
||||
const entrypoint = generateExtensionsEntrypoint(this.extensions);
|
||||
|
||||
for (const extensionType of APP_OR_HYBRID_EXTENSION_TYPES) {
|
||||
const entry = generateExtensionsEntry(extensionType, this.extensions);
|
||||
try {
|
||||
const bundle = await rollup({
|
||||
input: 'entry',
|
||||
external: Object.values(sharedDepsMapping),
|
||||
makeAbsoluteExternalsRelative: false,
|
||||
plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports })],
|
||||
});
|
||||
const { output } = await bundle.generate({ format: 'es', compact: true });
|
||||
|
||||
try {
|
||||
const bundle = await rollup({
|
||||
input: 'entry',
|
||||
external: Object.values(sharedDepsMapping),
|
||||
makeAbsoluteExternalsRelative: false,
|
||||
plugins: [virtual({ entry }), alias({ entries: internalImports })],
|
||||
});
|
||||
const { output } = await bundle.generate({ format: 'es', compact: true });
|
||||
await bundle.close();
|
||||
|
||||
bundles[extensionType] = output[0].code;
|
||||
|
||||
await bundle.close();
|
||||
} catch (error: any) {
|
||||
logger.warn(`Couldn't bundle App extensions`);
|
||||
logger.warn(error);
|
||||
}
|
||||
return output[0].code;
|
||||
} catch (error: any) {
|
||||
logger.warn(`Couldn't bundle App extensions`);
|
||||
logger.warn(error);
|
||||
}
|
||||
|
||||
return bundles;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getSharedDepsMapping(deps: string[]) {
|
||||
private async getSharedDepsMapping(deps: string[]): Promise<Record<string, string>> {
|
||||
const appDir = await fse.readdir(path.join(resolvePackage('@directus/app', __dirname), 'dist', 'assets'));
|
||||
|
||||
const depsMapping: Record<string, string> = {};
|
||||
@@ -350,7 +345,9 @@ class ExtensionManager {
|
||||
|
||||
const config = getModuleDefault(hookInstance);
|
||||
|
||||
this.registerHook(config, hookPath);
|
||||
this.registerHook(config);
|
||||
|
||||
this.apiExtensions.push({ path: hookPath });
|
||||
} catch (error: any) {
|
||||
logger.warn(`Couldn't register hook "${hook.name}"`);
|
||||
logger.warn(error);
|
||||
@@ -358,7 +355,7 @@ class ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private registerEndpoints(): void {
|
||||
private async registerEndpoints(): Promise<void> {
|
||||
const endpoints = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'endpoint');
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
@@ -368,7 +365,9 @@ class ExtensionManager {
|
||||
|
||||
const config = getModuleDefault(endpointInstance);
|
||||
|
||||
this.registerEndpoint(config, endpointPath, endpoint.name, this.endpointRouter);
|
||||
this.registerEndpoint(config, endpoint.name);
|
||||
|
||||
this.apiExtensions.push({ path: endpointPath });
|
||||
} catch (error: any) {
|
||||
logger.warn(`Couldn't register endpoint "${endpoint.name}"`);
|
||||
logger.warn(error);
|
||||
@@ -400,7 +399,9 @@ class ExtensionManager {
|
||||
|
||||
const config = getModuleDefault(operationInstance);
|
||||
|
||||
this.registerOperation(config, operationPath);
|
||||
this.registerOperation(config);
|
||||
|
||||
this.apiExtensions.push({ path: operationPath });
|
||||
} catch (error: any) {
|
||||
logger.warn(`Couldn't register operation "${operation.name}"`);
|
||||
logger.warn(error);
|
||||
@@ -408,17 +409,42 @@ class ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private registerHook(register: HookConfig, path: string) {
|
||||
const hookHandler: { path: string; events: EventHandler[] } = {
|
||||
path,
|
||||
events: [],
|
||||
};
|
||||
private async registerBundles(): Promise<void> {
|
||||
const bundles = this.extensions.filter((extension): extension is BundleExtension => extension.type === 'bundle');
|
||||
|
||||
for (const bundle of bundles) {
|
||||
try {
|
||||
const bundlePath = path.resolve(bundle.path, bundle.entrypoint.api);
|
||||
const bundleInstances: BundleConfig | { default: BundleConfig } = require(bundlePath);
|
||||
|
||||
const configs = getModuleDefault(bundleInstances);
|
||||
|
||||
for (const { config } of configs.hooks) {
|
||||
this.registerHook(config);
|
||||
}
|
||||
|
||||
for (const { config, name } of configs.endpoints) {
|
||||
this.registerEndpoint(config, name);
|
||||
}
|
||||
|
||||
for (const { config } of configs.operations) {
|
||||
this.registerOperation(config);
|
||||
}
|
||||
|
||||
this.apiExtensions.push({ path: bundlePath });
|
||||
} catch (error: any) {
|
||||
logger.warn(`Couldn't register bundle "${bundle.name}"`);
|
||||
logger.warn(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerHook(register: HookConfig): void {
|
||||
const registerFunctions = {
|
||||
filter: (event: string, handler: FilterHandler) => {
|
||||
emitter.onFilter(event, handler);
|
||||
|
||||
hookHandler.events.push({
|
||||
this.hookEvents.push({
|
||||
type: 'filter',
|
||||
name: event,
|
||||
handler,
|
||||
@@ -427,7 +453,7 @@ class ExtensionManager {
|
||||
action: (event: string, handler: ActionHandler) => {
|
||||
emitter.onAction(event, handler);
|
||||
|
||||
hookHandler.events.push({
|
||||
this.hookEvents.push({
|
||||
type: 'action',
|
||||
name: event,
|
||||
handler,
|
||||
@@ -436,7 +462,7 @@ class ExtensionManager {
|
||||
init: (event: string, handler: InitHandler) => {
|
||||
emitter.onInit(event, handler);
|
||||
|
||||
hookHandler.events.push({
|
||||
this.hookEvents.push({
|
||||
type: 'init',
|
||||
name: event,
|
||||
handler,
|
||||
@@ -454,7 +480,7 @@ class ExtensionManager {
|
||||
}
|
||||
});
|
||||
|
||||
hookHandler.events.push({
|
||||
this.hookEvents.push({
|
||||
type: 'schedule',
|
||||
task,
|
||||
});
|
||||
@@ -473,16 +499,14 @@ class ExtensionManager {
|
||||
logger,
|
||||
getSchema,
|
||||
});
|
||||
|
||||
this.apiExtensions.hooks.push(hookHandler);
|
||||
}
|
||||
|
||||
private registerEndpoint(config: EndpointConfig, path: string, name: string, router: Router) {
|
||||
private registerEndpoint(config: EndpointConfig, name: string): void {
|
||||
const register = typeof config === 'function' ? config : config.handler;
|
||||
const routeName = typeof config === 'function' ? name : config.id;
|
||||
|
||||
const scopedRouter = express.Router();
|
||||
router.use(`/${routeName}`, scopedRouter);
|
||||
this.endpointRouter.use(`/${routeName}`, scopedRouter);
|
||||
|
||||
register(scopedRouter, {
|
||||
services,
|
||||
@@ -493,66 +517,44 @@ class ExtensionManager {
|
||||
logger,
|
||||
getSchema,
|
||||
});
|
||||
|
||||
this.apiExtensions.endpoints.push({
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
private registerOperation(config: OperationApiConfig, path: string) {
|
||||
private registerOperation(config: OperationApiConfig): void {
|
||||
const flowManager = getFlowManager();
|
||||
|
||||
flowManager.addOperation(config.id, config.handler);
|
||||
|
||||
this.apiExtensions.operations.push({
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
private unregisterHooks(): void {
|
||||
for (const hook of this.apiExtensions.hooks) {
|
||||
for (const event of hook.events) {
|
||||
switch (event.type) {
|
||||
case 'filter':
|
||||
emitter.offFilter(event.name, event.handler);
|
||||
break;
|
||||
case 'action':
|
||||
emitter.offAction(event.name, event.handler);
|
||||
break;
|
||||
case 'init':
|
||||
emitter.offInit(event.name, event.handler);
|
||||
break;
|
||||
case 'schedule':
|
||||
event.task.stop();
|
||||
break;
|
||||
}
|
||||
private unregisterApiExtensions(): void {
|
||||
for (const event of this.hookEvents) {
|
||||
switch (event.type) {
|
||||
case 'filter':
|
||||
emitter.offFilter(event.name, event.handler);
|
||||
break;
|
||||
case 'action':
|
||||
emitter.offAction(event.name, event.handler);
|
||||
break;
|
||||
case 'init':
|
||||
emitter.offInit(event.name, event.handler);
|
||||
break;
|
||||
case 'schedule':
|
||||
event.task.stop();
|
||||
break;
|
||||
}
|
||||
|
||||
delete require.cache[require.resolve(hook.path)];
|
||||
}
|
||||
|
||||
this.apiExtensions.hooks = [];
|
||||
}
|
||||
|
||||
private unregisterEndpoints(): void {
|
||||
for (const endpoint of this.apiExtensions.endpoints) {
|
||||
delete require.cache[require.resolve(endpoint.path)];
|
||||
}
|
||||
this.hookEvents = [];
|
||||
|
||||
this.endpointRouter.stack = [];
|
||||
|
||||
this.apiExtensions.endpoints = [];
|
||||
}
|
||||
|
||||
private unregisterOperations(): void {
|
||||
for (const operation of this.apiExtensions.operations) {
|
||||
delete require.cache[require.resolve(operation.path)];
|
||||
}
|
||||
|
||||
const flowManager = getFlowManager();
|
||||
|
||||
flowManager.clearOperations();
|
||||
|
||||
this.apiExtensions.operations = [];
|
||||
for (const apiExtension of this.apiExtensions) {
|
||||
delete require.cache[require.resolve(apiExtension.path)];
|
||||
}
|
||||
|
||||
this.apiExtensions = [];
|
||||
}
|
||||
}
|
||||
|
||||
21
app/src/__setup__/mock-globals.ts
Normal file
21
app/src/__setup__/mock-globals.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
const originals = new Map<string, any>();
|
||||
|
||||
originals.set('createObjectURL', globalThis.URL.createObjectURL);
|
||||
|
||||
Object.defineProperty(globalThis.URL, 'createObjectURL', {
|
||||
value: () => 'blob:http://localhost/0',
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis.URL, 'createObjectURL', {
|
||||
value: originals.get('createObjectURL'),
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
@@ -48,8 +48,8 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
interface Props {
|
||||
field: Field;
|
||||
@@ -82,7 +82,12 @@ defineEmits(['update:modelValue', 'setFieldValue']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const interfaceExists = computed(() => !!getInterface(props.field?.meta?.interface || 'input'));
|
||||
const inter = useExtension(
|
||||
'interface',
|
||||
computed(() => props.field?.meta?.interface ?? 'input')
|
||||
);
|
||||
|
||||
const interfaceExists = computed(() => !!inter.value);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
19
app/src/composables/use-extension.ts
Normal file
19
app/src/composables/use-extension.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Ref, computed, unref } from 'vue';
|
||||
import { AppExtensionConfigs, AppExtensionType, HybridExtensionType, Plural } from '@directus/shared/types';
|
||||
import { useExtensions } from '@/extensions';
|
||||
import { pluralize } from '@directus/shared/utils';
|
||||
|
||||
export function useExtension<T extends AppExtensionType | HybridExtensionType>(
|
||||
type: T,
|
||||
name: string | Ref<string | null>
|
||||
): Ref<AppExtensionConfigs[Plural<T>][number] | null> {
|
||||
const extensions = useExtensions();
|
||||
|
||||
return computed(() => {
|
||||
const nameRaw = unref(name);
|
||||
|
||||
if (nameRaw === null) return null;
|
||||
|
||||
return (extensions[pluralize(type)].value as any[]).find(({ id }) => id === nameRaw) ?? null;
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { FormField } from '@/components/v-form/types';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
|
||||
import { cloneDeep, orderBy } from 'lodash';
|
||||
import { computed, ComputedRef, Ref } from 'vue';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { useExtension } from './use-extension';
|
||||
|
||||
export function useFormFields(fields: Ref<Field[]>): { formFields: ComputedRef<Field[]> } {
|
||||
const formFields = computed(() => {
|
||||
@@ -24,10 +24,10 @@ export function useFormFields(fields: Ref<Field[]>): { formFields: ComputedRef<F
|
||||
formFields = formFields.map((field, index) => {
|
||||
if (!field.meta) return field;
|
||||
|
||||
let interfaceUsed = getInterface(field.meta.interface);
|
||||
if (interfaceUsed === undefined) {
|
||||
let interfaceUsed = field.meta.interface ? useExtension('interface', field.meta.interface).value : null;
|
||||
if (interfaceUsed === null) {
|
||||
field.meta.interface = getDefaultInterfaceForType(field.type);
|
||||
interfaceUsed = getInterface(field.meta.interface);
|
||||
interfaceUsed = useExtension('interface', field.meta.interface).value;
|
||||
}
|
||||
|
||||
if (interfaceUsed?.hideLabel === true) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { provide } from 'vue';
|
||||
import api from '@/api';
|
||||
import { API_INJECT, EXTENSIONS_INJECT, STORES_INJECT } from '@directus/shared/constants';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { getDisplays } from '@/displays';
|
||||
import { getLayouts } from '@/layouts';
|
||||
import { getModules } from '@/modules';
|
||||
import { getPanels } from '@/panels';
|
||||
import { getOperations } from '@/operations';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
@@ -21,6 +15,7 @@ import { useServerStore } from '@/stores/server';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useFlowsStore } from '@/stores/flows';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
export function useSystem(): void {
|
||||
provide(STORES_INJECT, {
|
||||
@@ -42,12 +37,5 @@ export function useSystem(): void {
|
||||
|
||||
provide(API_INJECT, api);
|
||||
|
||||
provide(EXTENSIONS_INJECT, {
|
||||
interfaces: getInterfaces().interfaces,
|
||||
displays: getDisplays().displays,
|
||||
layouts: getLayouts().layouts,
|
||||
modules: getModules().modules,
|
||||
panels: getPanels().panels,
|
||||
operations: getOperations().operations,
|
||||
});
|
||||
provide(EXTENSIONS_INJECT, useExtensions());
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { shallowRef, Ref } from 'vue';
|
||||
import { App } from 'vue';
|
||||
import { DisplayConfig } from '@directus/shared/types';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
const displaysRaw: Ref<DisplayConfig[]> = shallowRef([]);
|
||||
const displays: Ref<DisplayConfig[]> = shallowRef([]);
|
||||
export function getInternalDisplays(): DisplayConfig[] {
|
||||
const displays = import.meta.glob<DisplayConfig>('./*/index.ts', { import: 'default', eager: true });
|
||||
|
||||
export function getDisplays(): { displays: Ref<DisplayConfig[]>; displaysRaw: Ref<DisplayConfig[]> } {
|
||||
return { displays, displaysRaw };
|
||||
return sortBy(Object.values(displays), 'id');
|
||||
}
|
||||
|
||||
export function getDisplay(name?: string | null): DisplayConfig | undefined {
|
||||
return !name ? undefined : displays.value.find(({ id }) => id === name);
|
||||
export function registerDisplays(displays: DisplayConfig[], app: App): void {
|
||||
for (const display of displays) {
|
||||
app.component(`display-${display.id}`, display.component);
|
||||
|
||||
if (typeof display.options !== 'function' && Array.isArray(display.options) === false && display.options !== null) {
|
||||
app.component(`display-options-${display.id}`, display.options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import { App } from 'vue';
|
||||
import { getDisplays } from './index';
|
||||
import { DisplayConfig } from '@directus/shared/types';
|
||||
|
||||
const { displaysRaw } = getDisplays();
|
||||
|
||||
export async function registerDisplays(app: App): Promise<void> {
|
||||
const displayModules = import.meta.globEager('./*/**/index.ts');
|
||||
|
||||
const displays: DisplayConfig[] = Object.values(displayModules).map((module) => module.default);
|
||||
try {
|
||||
const customDisplays: { default: DisplayConfig[] } = import.meta.env.DEV
|
||||
? await import('@directus-extensions-display')
|
||||
: await import(/* @vite-ignore */ `${getRootPath()}extensions/displays/index.js`);
|
||||
|
||||
displays.push(...customDisplays.default);
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Couldn't load custom displays`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
displaysRaw.value = displays;
|
||||
|
||||
displaysRaw.value.forEach((display: DisplayConfig) => {
|
||||
app.component(`display-${display.id}`, display.component);
|
||||
|
||||
if (typeof display.options !== 'function' && Array.isArray(display.options) === false && display.options !== null) {
|
||||
app.component(`display-options-${display.id}`, display.options);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { getFieldsFromTemplate } from '@directus/shared/utils';
|
||||
import { getRelatedCollection } from '@/utils/get-related-collection';
|
||||
import DisplayRelatedValues from './related-values.vue';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { getDisplay } from '@/displays';
|
||||
import { get, set } from 'lodash';
|
||||
import { renderPlainStringTemplate } from '@/utils/render-string-template';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
type Options = {
|
||||
template: string;
|
||||
@@ -79,10 +79,10 @@ export default defineDisplay({
|
||||
continue;
|
||||
}
|
||||
|
||||
const display = getDisplay(field.meta.display);
|
||||
const display = useExtension('display', field.meta.display);
|
||||
|
||||
const stringValue = display?.handler
|
||||
? display.handler(fieldValue, field?.meta?.display_options ?? {}, {
|
||||
const stringValue = display.value?.handler
|
||||
? display.value.handler(fieldValue, field?.meta?.display_options ?? {}, {
|
||||
interfaceOptions: field?.meta?.options ?? {},
|
||||
field: field ?? undefined,
|
||||
collection: collection,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getDisplay } from '@/displays';
|
||||
import { i18n } from '@/lang';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { useRelationsStore } from '@/stores/relations';
|
||||
@@ -7,6 +6,7 @@ import { renderPlainStringTemplate } from '@/utils/render-string-template';
|
||||
import { defineDisplay, getFieldsFromTemplate } from '@directus/shared/utils';
|
||||
import { get, set } from 'lodash';
|
||||
import DisplayTranslations from './translations.vue';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineDisplay({
|
||||
id: 'translations',
|
||||
@@ -79,10 +79,10 @@ export default defineDisplay({
|
||||
continue;
|
||||
}
|
||||
|
||||
const display = getDisplay(field.meta.display);
|
||||
const display = useExtension('display', field.meta.display);
|
||||
|
||||
const stringValue = display?.handler
|
||||
? display.handler(fieldValue, field?.meta?.display_options ?? {}, {
|
||||
const stringValue = display.value?.handler
|
||||
? display.value.handler(fieldValue, field?.meta?.display_options ?? {}, {
|
||||
interfaceOptions: field?.meta?.options ?? {},
|
||||
field: field ?? undefined,
|
||||
collection: collection,
|
||||
|
||||
99
app/src/extensions.ts
Normal file
99
app/src/extensions.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { AppExtensionConfigs, RefRecord } from '@directus/shared/types';
|
||||
import { App, shallowRef, watch } from 'vue';
|
||||
import { getInternalDisplays, registerDisplays } from './displays';
|
||||
import { getInternalInterfaces, registerInterfaces } from './interfaces';
|
||||
import { i18n } from './lang';
|
||||
import { getInternalLayouts, registerLayouts } from './layouts';
|
||||
import { getInternalModules, registerModules } from './modules';
|
||||
import { getInternalOperations, registerOperations } from './operations';
|
||||
import { getInternalPanels, registerPanels } from './panels';
|
||||
import { getRootPath } from './utils/get-root-path';
|
||||
import { translate } from './utils/translate-object-values';
|
||||
|
||||
let customExtensions: AppExtensionConfigs | null = null;
|
||||
|
||||
const extensions: RefRecord<AppExtensionConfigs> = {
|
||||
interfaces: shallowRef([]),
|
||||
displays: shallowRef([]),
|
||||
layouts: shallowRef([]),
|
||||
modules: shallowRef([]),
|
||||
panels: shallowRef([]),
|
||||
operations: shallowRef([]),
|
||||
};
|
||||
|
||||
const onHydrateCallbacks: (() => Promise<void>)[] = [];
|
||||
const onDehydrateCallbacks: (() => Promise<void>)[] = [];
|
||||
|
||||
export async function loadExtensions(): Promise<void> {
|
||||
try {
|
||||
customExtensions = import.meta.env.DEV
|
||||
? await import('@directus-extensions')
|
||||
: await import(/* @vite-ignore */ `${getRootPath()}extensions/sources/index.js`);
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Couldn't load extensions`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerExtensions(app: App): void {
|
||||
const interfaces = getInternalInterfaces();
|
||||
const displays = getInternalDisplays();
|
||||
const layouts = getInternalLayouts();
|
||||
const modules = getInternalModules();
|
||||
const panels = getInternalPanels();
|
||||
const operations = getInternalOperations();
|
||||
|
||||
if (customExtensions !== null) {
|
||||
interfaces.push(...customExtensions.interfaces);
|
||||
displays.push(...customExtensions.displays);
|
||||
layouts.push(...customExtensions.layouts);
|
||||
modules.push(...customExtensions.modules);
|
||||
panels.push(...customExtensions.panels);
|
||||
operations.push(...customExtensions.operations);
|
||||
}
|
||||
|
||||
registerInterfaces(interfaces, app);
|
||||
registerDisplays(displays, app);
|
||||
registerLayouts(layouts, app);
|
||||
registerPanels(panels, app);
|
||||
registerOperations(operations, app);
|
||||
|
||||
watch(
|
||||
i18n.global.locale,
|
||||
() => {
|
||||
extensions.interfaces.value = translate(interfaces);
|
||||
extensions.displays.value = translate(displays);
|
||||
extensions.layouts.value = translate(layouts);
|
||||
extensions.panels.value = translate(panels);
|
||||
extensions.operations.value = translate(operations);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const { registeredModules, onHydrateModules, onDehydrateModules } = registerModules(modules);
|
||||
|
||||
watch(
|
||||
[i18n.global.locale, registeredModules],
|
||||
() => {
|
||||
extensions.modules.value = translate(registeredModules.value);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onHydrateCallbacks.push(onHydrateModules);
|
||||
onDehydrateCallbacks.push(onDehydrateModules);
|
||||
}
|
||||
|
||||
export async function onHydrateExtensions() {
|
||||
await Promise.all(onHydrateCallbacks.map((onHydrate) => onHydrate()));
|
||||
}
|
||||
|
||||
export async function onDehydrateExtensions() {
|
||||
await Promise.all(onDehydrateCallbacks.map((onDehydrate) => onDehydrate()));
|
||||
}
|
||||
|
||||
export function useExtensions(): RefRecord<AppExtensionConfigs> {
|
||||
return extensions;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { setLanguage } from '@/lang/set-language';
|
||||
import { register as registerModules, unregister as unregisterModules } from '@/modules/register';
|
||||
import { getBasemapSources } from '@/utils/geometry/basemap';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
@@ -16,6 +15,7 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useNotificationsStore } from '@/stores/notifications';
|
||||
import { useTranslationStrings } from '@/composables/use-translation-strings';
|
||||
import { onDehydrateExtensions, onHydrateExtensions } from './extensions';
|
||||
|
||||
type GenericStore = {
|
||||
$id: string;
|
||||
@@ -76,7 +76,7 @@ export async function hydrate(): Promise<void> {
|
||||
const hydratedStores = ['userStore', 'permissionsStore'];
|
||||
|
||||
await Promise.all(stores.filter(({ $id }) => !hydratedStores.includes($id)).map((store) => store.hydrate?.()));
|
||||
await registerModules();
|
||||
await onHydrateExtensions();
|
||||
await hydrateTranslationStrings();
|
||||
|
||||
if (userStore.currentUser?.language) lang = userStore.currentUser?.language;
|
||||
@@ -103,7 +103,7 @@ export async function dehydrate(stores = useStores()): Promise<void> {
|
||||
await store.dehydrate?.();
|
||||
}
|
||||
|
||||
unregisterModules();
|
||||
await onDehydrateExtensions();
|
||||
|
||||
appStore.hydrated = false;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed, inject, ref } from 'vue';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -65,15 +65,8 @@ export default defineComponent({
|
||||
|
||||
const values = inject('values', ref<Record<string, any>>({}));
|
||||
|
||||
const selectedInterface = computed(() => {
|
||||
if (props.interface) {
|
||||
return getInterface(props.interface);
|
||||
}
|
||||
|
||||
if (!values.value[props.interfaceField]) return;
|
||||
|
||||
return getInterface(values.value[props.interfaceField]);
|
||||
});
|
||||
const selectedInterfaceId = computed(() => props.interface ?? values.value[props.interfaceField] ?? null);
|
||||
const selectedInterface = useExtension('interface', selectedInterfaceId);
|
||||
|
||||
const usesCustomComponent = computed(() => {
|
||||
if (!selectedInterface.value) return false;
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed, inject, ref, watch } from 'vue';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { InterfaceConfig } from '@directus/shared/types';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -32,7 +32,7 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { interfaces } = getInterfaces();
|
||||
const { interfaces } = useExtensions();
|
||||
|
||||
const values = inject('values', ref<Record<string, any>>({}));
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, ref } from 'vue';
|
||||
import { getModules } from '@/modules';
|
||||
import { Settings, SettingsModuleBarModule, SettingsModuleBarLink } from '@directus/shared/types';
|
||||
import { hideDragImage } from '@/utils/hide-drag-image';
|
||||
import Draggable from 'vuedraggable';
|
||||
@@ -69,6 +68,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Field, DeepPartial } from '@directus/shared/types';
|
||||
import { MODULE_BAR_DEFAULT } from '@/constants';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
type PreviewExtra = {
|
||||
to: string;
|
||||
@@ -130,7 +130,7 @@ export default defineComponent({
|
||||
const values = ref<SettingsModuleBarLink | null>();
|
||||
const initialValues = ref<SettingsModuleBarLink | null>();
|
||||
|
||||
const { modules: registeredModules } = getModules();
|
||||
const { modules: registeredModules } = useExtensions();
|
||||
|
||||
const availableModulesAsBarModule = computed<SettingsModuleBarModule[]>(() => {
|
||||
return registeredModules.value
|
||||
|
||||
37
app/src/interfaces/index.test.ts
Normal file
37
app/src/interfaces/index.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test, describe } from 'vitest';
|
||||
import { getInternalInterfaces } from './index';
|
||||
|
||||
/**
|
||||
* Vite v3 reworked the glob import with it's own internal sorting,
|
||||
* so this is to ensure any changes to vite's internal sorting to not affect the expected sort outcome.
|
||||
*
|
||||
* @see {@link https://github.com/directus/directus/pull/15672#issuecomment-1289975933}
|
||||
*/
|
||||
|
||||
const expectedInterfacesSortOrder = [
|
||||
'boolean',
|
||||
'input',
|
||||
'input-autocomplete-api',
|
||||
'input-code',
|
||||
'input-hash',
|
||||
'input-multiline',
|
||||
'input-rich-text-html',
|
||||
'input-rich-text-md',
|
||||
'system-collection',
|
||||
];
|
||||
|
||||
describe('interfaces', () => {
|
||||
test('getInterfaces() should return the expected sorted order of interfaces', () => {
|
||||
const interfaces = getInternalInterfaces();
|
||||
const interfaceIds = interfaces.map((inter) => inter.id);
|
||||
const interfacesToTest = interfaceIds.filter((inter) => expectedInterfacesSortOrder.includes(inter));
|
||||
|
||||
// test all expected sort order
|
||||
expect(interfacesToTest).toEqual(expectedInterfacesSortOrder);
|
||||
// test whether input interface is the first one among all the input related interfaces
|
||||
expect(interfacesToTest.filter((inter) => inter.includes('input')).findIndex((inter) => inter === 'input')).toBe(0);
|
||||
// system-collection should be not be at the start after sorting. Currently it is within the folder called "_system",
|
||||
// so it will be the first item by default without our added sort logic in getInterface().
|
||||
expect(interfacesToTest.findIndex((inter) => inter === 'system-collection')).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,22 @@
|
||||
import { shallowRef, Ref } from 'vue';
|
||||
import { App } from 'vue';
|
||||
import { InterfaceConfig } from '@directus/shared/types';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
const interfacesRaw: Ref<InterfaceConfig[]> = shallowRef([]);
|
||||
const interfaces: Ref<InterfaceConfig[]> = shallowRef([]);
|
||||
export function getInternalInterfaces(): InterfaceConfig[] {
|
||||
const interfaces = import.meta.glob<InterfaceConfig>(['./*/index.ts', './_system/*/index.ts'], {
|
||||
import: 'default',
|
||||
eager: true,
|
||||
});
|
||||
|
||||
export function getInterfaces(): { interfaces: Ref<InterfaceConfig[]>; interfacesRaw: Ref<InterfaceConfig[]> } {
|
||||
return { interfaces, interfacesRaw };
|
||||
return sortBy(Object.values(interfaces), 'id');
|
||||
}
|
||||
|
||||
export function getInterface(name?: string | null): InterfaceConfig | undefined {
|
||||
return !name ? undefined : interfaces.value.find(({ id }) => id === name);
|
||||
export function registerInterfaces(interfaces: InterfaceConfig[], app: App): void {
|
||||
for (const inter of interfaces) {
|
||||
app.component(`interface-${inter.id}`, inter.component);
|
||||
|
||||
if (typeof inter.options !== 'function' && Array.isArray(inter.options) === false && inter.options !== null) {
|
||||
app.component(`interface-options-${inter.id}`, inter.options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import { App } from 'vue';
|
||||
import { getInterfaces } from './index';
|
||||
import { InterfaceConfig } from '@directus/shared/types';
|
||||
|
||||
const { interfacesRaw } = getInterfaces();
|
||||
|
||||
export async function registerInterfaces(app: App): Promise<void> {
|
||||
const interfaceModules = import.meta.globEager('./*/**/index.ts');
|
||||
|
||||
const interfaces: InterfaceConfig[] = Object.values(interfaceModules).map((module) => module.default);
|
||||
|
||||
try {
|
||||
const customInterfaces: { default: InterfaceConfig[] } = import.meta.env.DEV
|
||||
? await import('@directus-extensions-interface')
|
||||
: await import(/* @vite-ignore */ `${getRootPath()}extensions/interfaces/index.js`);
|
||||
|
||||
interfaces.push(...customInterfaces.default);
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Couldn't load custom interfaces`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
interfacesRaw.value = interfaces;
|
||||
|
||||
interfacesRaw.value.forEach((inter: InterfaceConfig) => {
|
||||
app.component(`interface-${inter.id}`, inter.component);
|
||||
|
||||
if (typeof inter.options !== 'function' && Array.isArray(inter.options) === false && inter.options !== null) {
|
||||
app.component(`interface-options-${inter.id}`, inter.options);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,21 +1,8 @@
|
||||
import { getDisplays } from '@/displays';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { getPanels } from '@/panels';
|
||||
import { getLayouts } from '@/layouts';
|
||||
import { getModules } from '@/modules';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import availableLanguages from './available-languages.yaml';
|
||||
import { i18n, Language, loadedLanguages } from './index';
|
||||
import { getOperations } from '@/operations';
|
||||
import { useTranslationStrings } from '@/composables/use-translation-strings';
|
||||
const { modules, modulesRaw } = getModules();
|
||||
const { layouts, layoutsRaw } = getLayouts();
|
||||
const { interfaces, interfacesRaw } = getInterfaces();
|
||||
const { panels, panelsRaw } = getPanels();
|
||||
const { displays, displaysRaw } = getDisplays();
|
||||
const { operations, operationsRaw } = getOperations();
|
||||
import { loadDateFNSLocale } from '@/utils/get-date-fns-locale';
|
||||
|
||||
export async function setLanguage(lang: Language): Promise<boolean> {
|
||||
@@ -43,13 +30,6 @@ export async function setLanguage(lang: Language): Promise<boolean> {
|
||||
(document.querySelector('html') as HTMLElement).setAttribute('lang', lang);
|
||||
}
|
||||
|
||||
modules.value = translate(modulesRaw.value);
|
||||
layouts.value = translate(layoutsRaw.value);
|
||||
interfaces.value = translate(interfacesRaw.value);
|
||||
panels.value = translate(panelsRaw.value);
|
||||
displays.value = translate(displaysRaw.value);
|
||||
operations.value = translate(operationsRaw.value);
|
||||
|
||||
collectionsStore.translateCollections();
|
||||
fieldsStore.translateFields();
|
||||
mergeTranslationStringsForLanguage(lang);
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { shallowRef, Ref } from 'vue';
|
||||
import { App } from 'vue';
|
||||
import { LayoutConfig } from '@directus/shared/types';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
const layoutsRaw: Ref<LayoutConfig[]> = shallowRef([]);
|
||||
const layouts: Ref<LayoutConfig[]> = shallowRef([]);
|
||||
export function getInternalLayouts(): LayoutConfig[] {
|
||||
const layouts = import.meta.glob<LayoutConfig>('./*/index.ts', { import: 'default', eager: true });
|
||||
|
||||
export function getLayouts(): { layouts: Ref<LayoutConfig[]>; layoutsRaw: Ref<LayoutConfig[]> } {
|
||||
return { layouts, layoutsRaw };
|
||||
return sortBy(Object.values(layouts), 'id');
|
||||
}
|
||||
|
||||
export function getLayout(name?: string | null): LayoutConfig | undefined {
|
||||
return !name ? undefined : layouts.value.find(({ id }) => id === name);
|
||||
export function registerLayouts(layouts: LayoutConfig[], app: App): void {
|
||||
for (const layout of layouts) {
|
||||
app.component(`layout-${layout.id}`, layout.component);
|
||||
app.component(`layout-options-${layout.id}`, layout.slots.options);
|
||||
app.component(`layout-sidebar-${layout.id}`, layout.slots.sidebar);
|
||||
app.component(`layout-actions-${layout.id}`, layout.slots.actions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import { App } from 'vue';
|
||||
import { getLayouts } from './index';
|
||||
import { LayoutConfig } from '@directus/shared/types';
|
||||
|
||||
const { layoutsRaw } = getLayouts();
|
||||
|
||||
export async function registerLayouts(app: App): Promise<void> {
|
||||
const layoutModules = import.meta.globEager('./*/**/index.ts');
|
||||
|
||||
const layouts: LayoutConfig[] = Object.values(layoutModules).map((module) => module.default);
|
||||
|
||||
try {
|
||||
const customLayouts: { default: LayoutConfig[] } = import.meta.env.DEV
|
||||
? await import('@directus-extensions-layout')
|
||||
: await import(/* @vite-ignore */ `${getRootPath()}extensions/layouts/index.js`);
|
||||
|
||||
layouts.push(...customLayouts.default);
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Couldn't load custom layouts`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
layoutsRaw.value = layouts;
|
||||
|
||||
layoutsRaw.value.forEach((layout) => {
|
||||
app.component(`layout-${layout.id}`, layout.component);
|
||||
app.component(`layout-options-${layout.id}`, layout.slots.options);
|
||||
app.component(`layout-sidebar-${layout.id}`, layout.slots.sidebar);
|
||||
app.component(`layout-actions-${layout.id}`, layout.slots.actions);
|
||||
});
|
||||
}
|
||||
@@ -7,16 +7,11 @@ import App from './app.vue';
|
||||
import { registerComponents } from './components/register';
|
||||
import { DIRECTUS_LOGO } from './constants';
|
||||
import { registerDirectives } from './directives/register';
|
||||
import { registerPanels } from './panels/register';
|
||||
import { registerDisplays } from './displays/register';
|
||||
import { registerInterfaces } from './interfaces/register';
|
||||
import { i18n } from './lang/';
|
||||
import { registerLayouts } from './layouts/register';
|
||||
import { loadModules } from './modules/register';
|
||||
import { router } from './router';
|
||||
import './styles/main.scss';
|
||||
import { registerViews } from './views/register';
|
||||
import { registerOperations } from './operations/register';
|
||||
import { loadExtensions, registerExtensions } from './extensions';
|
||||
|
||||
init();
|
||||
|
||||
@@ -44,14 +39,9 @@ async function init() {
|
||||
registerComponents(app);
|
||||
registerViews(app);
|
||||
|
||||
await Promise.all([
|
||||
registerInterfaces(app),
|
||||
registerPanels(app),
|
||||
registerDisplays(app),
|
||||
registerLayouts(app),
|
||||
registerOperations(app),
|
||||
loadModules(),
|
||||
]);
|
||||
await loadExtensions();
|
||||
|
||||
registerExtensions(app);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
|
||||
@@ -295,9 +295,9 @@ import { usePermissionsStore } from '@/stores/permissions';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import DrawerBatch from '@/views/private/components/drawer-batch.vue';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { getLayouts } from '@/layouts';
|
||||
import { mergeFilters } from '@directus/shared/utils';
|
||||
import { Filter } from '@directus/shared/types';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
type Item = {
|
||||
[field: string]: any;
|
||||
@@ -336,7 +336,6 @@ export default defineComponent({
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { layouts } = getLayouts();
|
||||
const userStore = useUserStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
const layoutRef = ref();
|
||||
@@ -382,7 +381,7 @@ export default defineComponent({
|
||||
|
||||
const { bookmarkDialogActive, creatingBookmark, createBookmark } = useBookmarks();
|
||||
|
||||
const currentLayout = computed(() => layouts.value.find((l) => l.id === layout.value));
|
||||
const currentLayout = useExtension('layout', layout);
|
||||
|
||||
watch(
|
||||
collection,
|
||||
|
||||
@@ -1,9 +1,61 @@
|
||||
import { shallowRef, Ref } from 'vue';
|
||||
import { router } from '@/router';
|
||||
import { usePermissionsStore } from '@/stores/permissions';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import RouterPass from '@/utils/router-passthrough';
|
||||
import { ModuleConfig } from '@directus/shared/types';
|
||||
import { ShallowRef, shallowRef } from 'vue';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
const modulesRaw: Ref<ModuleConfig[]> = shallowRef([]);
|
||||
const modules: Ref<ModuleConfig[]> = shallowRef([]);
|
||||
export function getInternalModules(): ModuleConfig[] {
|
||||
const modules = import.meta.glob<ModuleConfig>('./*/index.ts', { import: 'default', eager: true });
|
||||
|
||||
export function getModules(): { modules: Ref<ModuleConfig[]>; modulesRaw: Ref<ModuleConfig[]> } {
|
||||
return { modules, modulesRaw };
|
||||
return sortBy(Object.values(modules), 'id');
|
||||
}
|
||||
|
||||
export function registerModules(modules: ModuleConfig[]): {
|
||||
registeredModules: ShallowRef<ModuleConfig[]>;
|
||||
onHydrateModules: () => Promise<void>;
|
||||
onDehydrateModules: () => Promise<void>;
|
||||
} {
|
||||
const registeredModules = shallowRef<ModuleConfig[]>([]);
|
||||
|
||||
const onHydrateModules = async () => {
|
||||
const userStore = useUserStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
|
||||
if (!userStore.currentUser) return;
|
||||
|
||||
registeredModules.value = (
|
||||
await Promise.all(
|
||||
modules.map(async (module) => {
|
||||
if (!module.preRegisterCheck) return module;
|
||||
|
||||
const allowed = await module.preRegisterCheck(userStore.currentUser, permissionsStore.permissions);
|
||||
|
||||
if (allowed) return module;
|
||||
|
||||
return null;
|
||||
})
|
||||
)
|
||||
).filter((module): module is ModuleConfig => module !== null);
|
||||
|
||||
for (const module of registeredModules.value) {
|
||||
router.addRoute({
|
||||
name: module.id,
|
||||
path: `/${module.id}`,
|
||||
component: RouterPass,
|
||||
children: module.routes,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDehydrateModules = async () => {
|
||||
for (const module of modules) {
|
||||
router.removeRoute(module.id);
|
||||
}
|
||||
|
||||
registeredModules.value = [];
|
||||
};
|
||||
|
||||
return { registeredModules, onHydrateModules, onDehydrateModules };
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
import { AppTile } from '@/components/v-workspace-tile.vue';
|
||||
import { useEditsGuard } from '@/composables/use-edits-guard';
|
||||
import { useShortcut } from '@/composables/use-shortcut';
|
||||
import { getPanels } from '@/panels';
|
||||
import { useExtensions } from '@/extensions';
|
||||
import { router } from '@/router';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useInsightsStore } from '@/stores/insights';
|
||||
@@ -217,7 +217,7 @@ const props = withDefaults(defineProps<Props>(), { panelKey: null });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { panels: panelsInfo } = getPanels();
|
||||
const { panels: panelsInfo } = useExtensions();
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDialogRoute } from '@/composables/use-dialog-route';
|
||||
import { getPanel, getPanels } from '@/panels';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
import { useExtensions } from '@/extensions';
|
||||
import { useInsightsStore } from '@/stores/insights';
|
||||
import { CreatePanel } from '@/stores/insights';
|
||||
import { Panel } from '@directus/shared/types';
|
||||
@@ -135,7 +136,7 @@ const edits = reactive<Partial<Panel>>({
|
||||
const insightsStore = useInsightsStore();
|
||||
|
||||
const { panels } = storeToRefs(insightsStore);
|
||||
const { panels: panelTypes } = getPanels();
|
||||
const { panels: panelTypes } = useExtensions();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -158,9 +159,10 @@ const selectItems = computed<FancySelectItem[]>(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const currentTypeInfo = computed(() => {
|
||||
return unref(panel).type ? getPanel(unref(panel).type) : null;
|
||||
});
|
||||
const currentTypeInfo = useExtension(
|
||||
'panel',
|
||||
computed(() => panel.value.type ?? null)
|
||||
);
|
||||
|
||||
const customOptionsFields = computed(() => {
|
||||
if (typeof currentTypeInfo.value?.options === 'function') {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { router } from '@/router';
|
||||
import { usePermissionsStore } from '@/stores/permissions';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import RouterPass from '@/utils/router-passthrough';
|
||||
import { getModules } from './index';
|
||||
import { ModuleConfig } from '@directus/shared/types';
|
||||
|
||||
const { modulesRaw } = getModules();
|
||||
|
||||
let queuedModules: ModuleConfig[] = [];
|
||||
|
||||
export async function loadModules(): Promise<void> {
|
||||
const moduleModules = import.meta.globEager('./*/index.ts');
|
||||
|
||||
const modules: ModuleConfig[] = Object.values(moduleModules).map((module) => module.default);
|
||||
|
||||
try {
|
||||
const customModules: { default: ModuleConfig[] } = import.meta.env.DEV
|
||||
? await import('@directus-extensions-module')
|
||||
: await import(/* @vite-ignore */ `${getRootPath()}extensions/modules/index.js`);
|
||||
|
||||
modules.push(...customModules.default);
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Couldn't load custom modules`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
queuedModules = modules;
|
||||
}
|
||||
|
||||
export async function register(): Promise<void> {
|
||||
const userStore = useUserStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
|
||||
const registeredModules = [];
|
||||
|
||||
for (const mod of queuedModules) {
|
||||
if (!userStore.currentUser) continue;
|
||||
|
||||
if (mod.preRegisterCheck) {
|
||||
const allowed = await mod.preRegisterCheck(userStore.currentUser, permissionsStore.permissions);
|
||||
if (allowed) registeredModules.push(mod);
|
||||
} else {
|
||||
registeredModules.push(mod);
|
||||
}
|
||||
}
|
||||
|
||||
for (const module of registeredModules) {
|
||||
router.addRoute({
|
||||
name: module.id,
|
||||
path: `/${module.id}`,
|
||||
component: RouterPass,
|
||||
children: module.routes,
|
||||
});
|
||||
}
|
||||
|
||||
modulesRaw.value = registeredModules;
|
||||
}
|
||||
|
||||
export function unregister(): void {
|
||||
for (const module of modulesRaw.value) {
|
||||
router.removeRoute(module.id);
|
||||
}
|
||||
|
||||
modulesRaw.value = [];
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Field, DeepPartial } from '@directus/shared/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -19,7 +19,7 @@ const fieldDetailStore = useFieldDetailStore();
|
||||
const { field, collection } = storeToRefs(fieldDetailStore);
|
||||
|
||||
const conditions = syncFieldDetailStoreProperty('field.meta.conditions');
|
||||
const interfaceID = computed(() => field.value.meta?.interface);
|
||||
const interfaceId = computed(() => field.value.meta?.interface ?? null);
|
||||
|
||||
const repeaterFields = computed<DeepPartial<Field>[]>(() => [
|
||||
{
|
||||
@@ -90,23 +90,24 @@ const repeaterFields = computed<DeepPartial<Field>[]>(() => [
|
||||
meta: {
|
||||
interface: 'system-interface-options',
|
||||
options: {
|
||||
interface: interfaceID.value,
|
||||
interface: interfaceId.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedInterface = useExtension('interface', interfaceId);
|
||||
|
||||
const optionDefaults = computed(() => {
|
||||
const selectedInterface = getInterface(interfaceID.value);
|
||||
if (!selectedInterface || !selectedInterface.options) return [];
|
||||
if (!selectedInterface.value || !selectedInterface.value.options) return [];
|
||||
|
||||
// Indicates a custom vue component is used for the interface options
|
||||
if ('render' in selectedInterface.options) return [];
|
||||
if ('render' in selectedInterface.value.options) return [];
|
||||
|
||||
let optionsObjectOrArray;
|
||||
|
||||
if (typeof selectedInterface.options === 'function') {
|
||||
optionsObjectOrArray = selectedInterface.options({
|
||||
if (typeof selectedInterface.value.options === 'function') {
|
||||
optionsObjectOrArray = selectedInterface.value.options({
|
||||
field: {
|
||||
type: 'unknown',
|
||||
},
|
||||
@@ -133,7 +134,7 @@ const optionDefaults = computed(() => {
|
||||
saving: false,
|
||||
});
|
||||
} else {
|
||||
optionsObjectOrArray = selectedInterface.options;
|
||||
optionsObjectOrArray = selectedInterface.value.options;
|
||||
}
|
||||
|
||||
const optionsArray = Array.isArray(optionsObjectOrArray)
|
||||
|
||||
@@ -21,12 +21,11 @@
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { getDisplay } from '@/displays';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { clone } from 'lodash';
|
||||
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ExtensionOptions from '../shared/extension-options.vue';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ExtensionOptions },
|
||||
@@ -37,11 +36,11 @@ export default defineComponent({
|
||||
|
||||
const { field, displaysForType } = storeToRefs(fieldDetailStore);
|
||||
|
||||
const interfaceID = computed(() => field.value.meta?.interface);
|
||||
const interfaceId = computed(() => field.value.meta?.interface ?? null);
|
||||
const display = syncFieldDetailStoreProperty('field.meta.display');
|
||||
|
||||
const selectedInterface = computed(() => getInterface(interfaceID.value));
|
||||
const selectedDisplay = computed(() => getDisplay(display.value));
|
||||
const selectedInterface = useExtension('interface', interfaceId);
|
||||
const selectedDisplay = useExtension('display', display);
|
||||
|
||||
const selectItems = computed(() => {
|
||||
let recommended = clone(selectedInterface.value?.recommendedDisplays) || [];
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-fancy-select v-model="interfaceID" class="select" :items="selectItems" />
|
||||
<v-fancy-select v-model="interfaceId" class="select" :items="selectItems" />
|
||||
|
||||
<v-notice v-if="interfaceID && !selectedInterface" class="not-found" type="danger">
|
||||
{{ t('interface_not_found', { interface: interfaceID }) }}
|
||||
<v-notice v-if="interfaceId && !selectedInterface" class="not-found" type="danger">
|
||||
{{ t('interface_not_found', { interface: interfaceId }) }}
|
||||
<div class="spacer" />
|
||||
<button @click="interfaceID = null">{{ t('reset_interface') }}</button>
|
||||
<button @click="interfaceId = null">{{ t('reset_interface') }}</button>
|
||||
</v-notice>
|
||||
|
||||
<extension-options
|
||||
v-if="interfaceID && selectedInterface"
|
||||
v-if="interfaceId && selectedInterface"
|
||||
v-model="options"
|
||||
type="interface"
|
||||
:options="customOptionsFields"
|
||||
:extension="interfaceID"
|
||||
:extension="interfaceId"
|
||||
show-advanced
|
||||
/>
|
||||
</div>
|
||||
@@ -22,10 +22,10 @@
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store/';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ExtensionOptions from '../shared/extension-options.vue';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ExtensionOptions },
|
||||
@@ -34,7 +34,7 @@ export default defineComponent({
|
||||
|
||||
const fieldDetailStore = useFieldDetailStore();
|
||||
|
||||
const interfaceID = syncFieldDetailStoreProperty('field.meta.interface');
|
||||
const interfaceId = syncFieldDetailStoreProperty('field.meta.interface');
|
||||
|
||||
const { field, interfacesForType } = storeToRefs(fieldDetailStore);
|
||||
const type = computed(() => field.value.type);
|
||||
@@ -93,15 +93,11 @@ export default defineComponent({
|
||||
return recommendedItems;
|
||||
});
|
||||
|
||||
const selectedInterface = computed(() => getInterface(interfaceID.value));
|
||||
|
||||
const extensionInfo = computed(() => {
|
||||
return getInterface(interfaceID.value);
|
||||
});
|
||||
const selectedInterface = useExtension('interface', interfaceId);
|
||||
|
||||
const customOptionsFields = computed(() => {
|
||||
if (typeof extensionInfo.value?.options === 'function') {
|
||||
return extensionInfo.value?.options(fieldDetailStore);
|
||||
if (typeof selectedInterface.value?.options === 'function') {
|
||||
return selectedInterface.value?.options(fieldDetailStore);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -121,7 +117,7 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
return { t, selectItems, selectedInterface, interfaceID, customOptionsFields, options };
|
||||
return { t, selectItems, selectedInterface, interfaceId, customOptionsFields, options };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -63,12 +63,13 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, watch } from 'vue';
|
||||
import { getInterface, getInterfaces } from '@/interfaces';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store/';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ExtensionOptions from '../shared/extension-options.vue';
|
||||
import RelationshipConfiguration from './relationship-configuration.vue';
|
||||
import { useExtensions } from '@/extensions';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -94,7 +95,7 @@ export default defineComponent({
|
||||
const required = syncFieldDetailStoreProperty('field.meta.required', false);
|
||||
const note = syncFieldDetailStoreProperty('field.meta.note');
|
||||
|
||||
const chosenInterfaceConfig = computed(() => getInterface(chosenInterface.value));
|
||||
const chosenInterfaceConfig = useExtension('interface', chosenInterface);
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
if (!chosenInterfaceConfig.value) return [];
|
||||
@@ -107,7 +108,7 @@ export default defineComponent({
|
||||
|
||||
const typeDisabled = computed(() => typeOptions.value.length === 1 || localType.value !== 'standard');
|
||||
|
||||
const { interfaces } = getInterfaces();
|
||||
const { interfaces } = useExtensions();
|
||||
|
||||
const interfaceIdsWithHiddenLabel = computed(() =>
|
||||
interfaces.value.filter((inter) => inter.hideLabel === true).map((inter) => inter.id)
|
||||
|
||||
@@ -42,11 +42,11 @@
|
||||
import { defineComponent, PropType, computed, toRefs, watch } from 'vue';
|
||||
import { Collection } from '@directus/shared/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { orderBy } from 'lodash';
|
||||
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store/';
|
||||
import { syncRefProperty } from '@/utils/sync-ref-property';
|
||||
import FieldConfiguration from './field-configuration.vue';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
export default defineComponent({
|
||||
components: { FieldConfiguration },
|
||||
@@ -69,7 +69,7 @@ export default defineComponent({
|
||||
const fieldDetail = useFieldDetailStore();
|
||||
watch(collection, () => fieldDetail.update({ collection: collection.value.collection }), { immediate: true });
|
||||
|
||||
const { interfaces } = getInterfaces();
|
||||
const { interfaces } = useExtensions();
|
||||
|
||||
const interfacesSorted = computed(() => {
|
||||
return orderBy(
|
||||
|
||||
@@ -26,13 +26,10 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { getOperation } from '@/operations';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { getDisplay } from '@/displays';
|
||||
import { getPanel } from '@/panels';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useFieldDetailStore } from '../store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -73,20 +70,7 @@ export default defineComponent({
|
||||
|
||||
const { collection, field } = storeToRefs(fieldDetailStore);
|
||||
|
||||
const extensionInfo = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'interface':
|
||||
return getInterface(props.extension);
|
||||
case 'display':
|
||||
return getDisplay(props.extension);
|
||||
case 'panel':
|
||||
return getPanel(props.extension);
|
||||
case 'operation':
|
||||
return getOperation(props.extension);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const extensionInfo = useExtension(props.type, props.extension);
|
||||
|
||||
const usesCustomComponent = computed(() => {
|
||||
if (!extensionInfo.value) return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
import { set } from 'lodash';
|
||||
import { State, StateUpdates } from '../types';
|
||||
import { getInterface } from '@/interfaces';
|
||||
|
||||
/**
|
||||
* In case a relational field removed the schema object, we'll have to make sure it's re-added
|
||||
@@ -20,11 +20,11 @@ export function resetSchema(updates: StateUpdates, state: State) {
|
||||
export function setLocalTypeForInterface(updates: StateUpdates) {
|
||||
if (!updates.field?.meta?.interface) return;
|
||||
|
||||
const chosenInterface = getInterface(updates.field.meta.interface);
|
||||
const chosenInterface = useExtension('interface', updates.field.meta.interface);
|
||||
|
||||
if (!chosenInterface) return;
|
||||
if (!chosenInterface.value) return;
|
||||
|
||||
const localType = chosenInterface?.localTypes?.[0] ?? 'standard';
|
||||
const localType = chosenInterface.value?.localTypes?.[0] ?? 'standard';
|
||||
set(updates, 'localType', localType);
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ export function setLocalTypeForInterface(updates: StateUpdates) {
|
||||
export function setTypeForInterface(updates: StateUpdates, state: State) {
|
||||
if (!updates.field?.meta?.interface) return;
|
||||
|
||||
const chosenInterface = getInterface(updates.field.meta.interface);
|
||||
const chosenInterface = useExtension('interface', updates.field.meta.interface);
|
||||
|
||||
if (!chosenInterface) return updates;
|
||||
if (!chosenInterface.value) return updates;
|
||||
|
||||
if (state.field.type && chosenInterface.types.includes(state.field.type)) return;
|
||||
if (state.field.type && chosenInterface.value.types.includes(state.field.type)) return;
|
||||
|
||||
const defaultType = chosenInterface?.types[0];
|
||||
const defaultType = chosenInterface.value?.types[0];
|
||||
set(updates, 'field.type', defaultType);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HelperFunctions, State, StateUpdates } from '../types';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { set } from 'lodash';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
import { getSpecialForType } from '@/utils/get-special-for-type';
|
||||
|
||||
export function applyChanges(updates: StateUpdates, _state: State, helperFn: HelperFunctions) {
|
||||
@@ -20,9 +20,10 @@ function setSpecialForType(updates: StateUpdates) {
|
||||
}
|
||||
|
||||
function updateInterface(updates: StateUpdates, fn: HelperFunctions) {
|
||||
const interface_ = getInterface(fn.getCurrent('field.meta.interface'));
|
||||
const inter = useExtension('interface', fn.getCurrent('field.meta.interface'));
|
||||
|
||||
const type = updates.field?.type;
|
||||
if (type && !interface_?.types.includes(type)) {
|
||||
if (type && !inter.value?.types.includes(type)) {
|
||||
set(updates, 'field.meta.interface', undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { getDisplays } from '@/displays';
|
||||
import { has, isEmpty, orderBy, cloneDeep } from 'lodash';
|
||||
import {
|
||||
InterfaceConfig,
|
||||
@@ -22,6 +20,7 @@ import { useRelationsStore } from '@/stores/relations';
|
||||
import * as alterations from './alterations';
|
||||
import { getLocalTypeForField } from '@/utils/get-local-type';
|
||||
import api from '@/api';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
export function syncFieldDetailStoreProperty(path: string, defaultValue?: any) {
|
||||
const fieldDetailStore = useFieldDetailStore();
|
||||
@@ -259,7 +258,7 @@ export const useFieldDetailStore = defineStore({
|
||||
return missing.length === 0;
|
||||
},
|
||||
interfacesForType(): InterfaceConfig[] {
|
||||
const { interfaces } = getInterfaces();
|
||||
const { interfaces } = useExtensions();
|
||||
|
||||
return orderBy(
|
||||
interfaces.value.filter((inter: InterfaceConfig) => {
|
||||
@@ -275,7 +274,7 @@ export const useFieldDetailStore = defineStore({
|
||||
);
|
||||
},
|
||||
displaysForType(): DisplayConfig[] {
|
||||
const { displays } = getDisplays();
|
||||
const { displays } = useExtensions();
|
||||
|
||||
return orderBy(
|
||||
displays.value.filter((inter: DisplayConfig) => {
|
||||
|
||||
@@ -148,7 +148,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, PropType, ref, computed } from 'vue';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { getLocalTypeForField } from '@/utils/get-local-type';
|
||||
@@ -160,6 +159,7 @@ import FieldSelectMenu from './field-select-menu.vue';
|
||||
import { hideDragImage } from '@/utils/hide-drag-image';
|
||||
import Draggable from 'vuedraggable';
|
||||
import formatTitle from '@directus/format-title';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FieldSelect',
|
||||
@@ -192,7 +192,11 @@ export default defineComponent({
|
||||
const { deleteActive, deleting, deleteField } = useDeleteField();
|
||||
const { duplicateActive, duplicateName, collections, duplicateTo, saveDuplicate, duplicating } = useDuplicate();
|
||||
|
||||
const interfaceName = computed(() => getInterface(props.field.meta?.interface)?.name);
|
||||
const inter = useExtension(
|
||||
'interface',
|
||||
computed(() => props.field.meta?.interface ?? null)
|
||||
);
|
||||
const interfaceName = computed(() => inter.value?.name ?? null);
|
||||
|
||||
const hidden = computed(() => props.field.meta?.hidden === true);
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRevisions } from '@/composables/use-revisions';
|
||||
import { getOperations } from '@/operations';
|
||||
import { useExtensions } from '@/extensions';
|
||||
import { Action, FlowRaw } from '@directus/shared/types';
|
||||
import { computed, ref, toRefs, unref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -96,7 +96,7 @@ const props = defineProps<Props>();
|
||||
const { flow } = toRefs(props);
|
||||
|
||||
const { triggers } = getTriggers();
|
||||
const { operations } = getOperations();
|
||||
const { operations } = useExtensions();
|
||||
|
||||
const usedTrigger = computed(() => {
|
||||
return triggers.find((trigger) => trigger.id === unref(flow).trigger);
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<v-notice v-if="operationType && !selectedOperation" class="not-found" type="danger">
|
||||
{{ t('operation_not_found', { operation: operationType }) }}
|
||||
<div class="spacer" />
|
||||
<button @click="operationType = undefined">{{ t('reset_interface') }}</button>
|
||||
<button @click="operationType = null">{{ t('reset_interface') }}</button>
|
||||
</v-notice>
|
||||
|
||||
<extension-options
|
||||
@@ -68,12 +68,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useDialogRoute } from '@/composables/use-dialog-route';
|
||||
import ExtensionOptions from '@/modules/settings/routes/data-model/field-detail/shared/extension-options.vue';
|
||||
import { getOperation, getOperations } from '@/operations';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { FlowRaw } from '@directus/shared/types';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useExtensions } from '@/extensions';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
import { customAlphabet } from 'nanoid/non-secure';
|
||||
|
||||
const generateSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 5);
|
||||
@@ -98,7 +99,7 @@ const isOpen = useDialogRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const options = ref<Record<string, any>>(props.operation?.options ?? {});
|
||||
const operationType = ref<string | undefined>(props.operation?.type);
|
||||
const operationType = ref<string | null>(props.operation?.type ?? null);
|
||||
const operationKey = ref<string | null>(props.operation?.key ?? null);
|
||||
const operationName = ref<string | null>(props.operation?.name ?? null);
|
||||
|
||||
@@ -149,7 +150,7 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const selectedOperation = computed(() => getOperation(operationType.value));
|
||||
const selectedOperation = useExtension('operation', operationType);
|
||||
|
||||
const generatedName = computed(() => (selectedOperation.value ? selectedOperation.value?.name : t('operation_name')));
|
||||
|
||||
@@ -159,7 +160,7 @@ const generatedKey = computed(() =>
|
||||
: t('operation_key')
|
||||
);
|
||||
|
||||
const { operations } = getOperations();
|
||||
const { operations } = useExtensions();
|
||||
|
||||
const displayOperations = computed(() => {
|
||||
return operations.value.map((operation) => ({
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useClipboard } from '@/composables/use-clipboard';
|
||||
import { getOperations } from '@/operations';
|
||||
import { useExtensions } from '@/extensions';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { Vector2 } from '@/utils/vector2';
|
||||
import { FlowRaw } from '@directus/shared/types';
|
||||
@@ -174,7 +174,7 @@ const props = withDefaults(
|
||||
|
||||
const { panelsToBeDeleted } = toRefs(props);
|
||||
|
||||
const { operations } = getOperations();
|
||||
const { operations } = useExtensions();
|
||||
const { triggers } = getTriggers();
|
||||
|
||||
const emit = defineEmits([
|
||||
|
||||
@@ -201,7 +201,7 @@ import { Vector2 } from '@/utils/vector2';
|
||||
import FlowDrawer from './flow-drawer.vue';
|
||||
|
||||
import LogsSidebarDetail from './components/logs-sidebar-detail.vue';
|
||||
import { getOperations } from '@/operations';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
// Maps the x and y coordinates of attachments of panels to their id
|
||||
export type Attachments = Record<number, Record<number, string>>;
|
||||
@@ -263,7 +263,7 @@ async function deleteFlow() {
|
||||
|
||||
// ------------- Manage Panels ------------- //
|
||||
|
||||
const { operations } = getOperations();
|
||||
const { operations } = useExtensions();
|
||||
|
||||
const triggerDetailOpen = ref(false);
|
||||
const stagedPanels = ref<Partial<OperationRaw & { borderRadius: [boolean, boolean, boolean, boolean] }>[]>([]);
|
||||
|
||||
@@ -155,8 +155,8 @@ import { usePermissionsStore } from '@/stores/permissions';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { usePresetsStore } from '@/stores/presets';
|
||||
import DrawerBatch from '@/views/private/components/drawer-batch.vue';
|
||||
import { getLayouts } from '@/layouts';
|
||||
import { usePreset } from '@/composables/use-preset';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ContentCollection',
|
||||
@@ -175,7 +175,6 @@ export default defineComponent({
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { layouts } = getLayouts();
|
||||
const userStore = useUserStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
const layoutRef = ref();
|
||||
@@ -187,7 +186,7 @@ export default defineComponent({
|
||||
|
||||
const { confirmDelete, deleting, batchDelete, error: deleteError, batchEditActive } = useBatch();
|
||||
|
||||
const currentLayout = computed(() => layouts.value.find((l) => l.id === layout.value));
|
||||
const currentLayout = useExtension('layout', layout);
|
||||
|
||||
const { batchEditAllowed, batchDeleteAllowed, createAllowed } = usePermissions();
|
||||
|
||||
|
||||
@@ -138,13 +138,13 @@ import { Preset, Filter } from '@directus/shared/types';
|
||||
import api from '@/api';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
import { usePresetsStore } from '@/stores/presets';
|
||||
import { getLayouts } from '@/layouts';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { useLayout } from '@directus/shared/composables';
|
||||
import { useShortcut } from '@/composables/use-shortcut';
|
||||
import { useEditsGuard } from '@/composables/use-edits-guard';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
type FormattedPreset = {
|
||||
id: number;
|
||||
@@ -174,7 +174,7 @@ const router = useRouter();
|
||||
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const presetsStore = usePresetsStore();
|
||||
const { layouts } = getLayouts();
|
||||
const { layouts } = useExtensions();
|
||||
|
||||
const isNew = computed(() => props.id === '+');
|
||||
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { shallowRef, Ref } from 'vue';
|
||||
import { App } from 'vue';
|
||||
import { OperationAppConfig } from '@directus/shared/types';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
const operationsRaw: Ref<OperationAppConfig[]> = shallowRef([]);
|
||||
const operations: Ref<OperationAppConfig[]> = shallowRef([]);
|
||||
export function getInternalOperations(): OperationAppConfig[] {
|
||||
const operations = import.meta.glob<OperationAppConfig>('./*/index.ts', { import: 'default', eager: true });
|
||||
|
||||
export function getOperations(): { operations: Ref<OperationAppConfig[]>; operationsRaw: Ref<OperationAppConfig[]> } {
|
||||
return { operations, operationsRaw };
|
||||
return sortBy(Object.values(operations), 'id');
|
||||
}
|
||||
|
||||
export function getOperation(name?: string | null): OperationAppConfig | undefined {
|
||||
return !name ? undefined : operations.value.find(({ id }) => id === name);
|
||||
export function registerOperations(operations: OperationAppConfig[], app: App): void {
|
||||
for (const operation of operations) {
|
||||
if (
|
||||
typeof operation.overview !== 'function' &&
|
||||
Array.isArray(operation.overview) === false &&
|
||||
operation.overview !== null
|
||||
) {
|
||||
app.component(`operation-overview-${operation.id}`, operation.overview);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof operation.options !== 'function' &&
|
||||
Array.isArray(operation.options) === false &&
|
||||
operation.options !== null
|
||||
) {
|
||||
app.component(`operation-options-${operation.id}`, operation.options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import { App } from 'vue';
|
||||
import { getOperations } from './index';
|
||||
import { OperationAppConfig } from '@directus/shared/types';
|
||||
|
||||
const { operationsRaw } = getOperations();
|
||||
|
||||
export async function registerOperations(app: App): Promise<void> {
|
||||
const operationModules = import.meta.globEager('./*/**/index.ts');
|
||||
|
||||
const operations: OperationAppConfig[] = Object.values(operationModules).map((module) => module.default);
|
||||
|
||||
try {
|
||||
const customOperations: { default: OperationAppConfig[] } = import.meta.env.DEV
|
||||
? await import('@directus-extensions-operation')
|
||||
: await import(/* @vite-ignore */ `${getRootPath()}extensions/operations/index.js`);
|
||||
|
||||
operations.push(...customOperations.default);
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Couldn't load custom operations`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
operationsRaw.value = operations;
|
||||
|
||||
operationsRaw.value.forEach((operation: OperationAppConfig) => {
|
||||
if (
|
||||
typeof operation.overview !== 'function' &&
|
||||
Array.isArray(operation.overview) === false &&
|
||||
operation.overview !== null
|
||||
) {
|
||||
app.component(`operation-overview-${operation.id}`, operation.overview);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof operation.options !== 'function' &&
|
||||
Array.isArray(operation.options) === false &&
|
||||
operation.options !== null
|
||||
) {
|
||||
app.component(`operation-options-${operation.id}`, operation.options);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { shallowRef, Ref } from 'vue';
|
||||
import { App } from 'vue';
|
||||
import { PanelConfig } from '@directus/shared/types';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
const panelsRaw: Ref<PanelConfig[]> = shallowRef([]);
|
||||
const panels: Ref<PanelConfig[]> = shallowRef([]);
|
||||
export function getInternalPanels(): PanelConfig[] {
|
||||
const panels = import.meta.glob<PanelConfig>('./*/index.ts', { import: 'default', eager: true });
|
||||
|
||||
export function getPanels(): { panels: Ref<PanelConfig[]>; panelsRaw: Ref<PanelConfig[]> } {
|
||||
return { panels, panelsRaw };
|
||||
return sortBy(Object.values(panels), 'id');
|
||||
}
|
||||
|
||||
export function getPanel(name?: string | null): PanelConfig | undefined {
|
||||
return !name ? undefined : panels.value.find(({ id }) => id === name);
|
||||
export function registerPanels(panels: PanelConfig[], app: App): void {
|
||||
for (const panel of panels) {
|
||||
app.component(`panel-${panel.id}`, panel.component);
|
||||
|
||||
if (typeof panel.options !== 'function' && Array.isArray(panel.options) === false && panel.options !== null) {
|
||||
app.component(`panel-options-${panel.id}`, panel.options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import { App } from 'vue';
|
||||
import { getPanels } from './index';
|
||||
import { PanelConfig } from '@directus/shared/types';
|
||||
|
||||
const { panelsRaw } = getPanels();
|
||||
|
||||
export async function registerPanels(app: App): Promise<void> {
|
||||
const panelModules = import.meta.globEager('./*/**/index.ts');
|
||||
|
||||
const panels: PanelConfig[] = Object.values(panelModules).map((module) => module.default);
|
||||
|
||||
try {
|
||||
const customPanels: { default: PanelConfig[] } = import.meta.env.DEV
|
||||
? await import('@directus-extensions-panel')
|
||||
: await import(/* @vite-ignore */ `${getRootPath()}extensions/panels/index.js`);
|
||||
|
||||
panels.push(...customPanels.default);
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Couldn't load custom panels`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
panelsRaw.value = panels;
|
||||
|
||||
panelsRaw.value.forEach((panel: PanelConfig) => {
|
||||
app.component(`panel-${panel.id}`, panel.component);
|
||||
|
||||
if (typeof panel.options !== 'function' && Array.isArray(panel.options) === false && panel.options !== null) {
|
||||
app.component(`panel-options-${panel.id}`, panel.options);
|
||||
}
|
||||
});
|
||||
}
|
||||
36
app/src/shims.d.ts
vendored
36
app/src/shims.d.ts
vendored
@@ -33,38 +33,4 @@ declare module 'frappe-charts/src/js/charts/AxisChart' {
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@directus-extensions-operation' {
|
||||
import { OperationAppConfig } from '@directus/shared/types';
|
||||
const operations: OperationAppConfig[];
|
||||
export default operations;
|
||||
}
|
||||
|
||||
declare module '@directus-extensions-interface' {
|
||||
import { InterfaceConfig } from '@directus/shared/types';
|
||||
const interfaces: InterfaceConfig[];
|
||||
export default interfaces;
|
||||
}
|
||||
|
||||
declare module '@directus-extensions-display' {
|
||||
import { DisplayConfig } from '@directus/shared/types';
|
||||
const displays: DisplayConfig[];
|
||||
export default displays;
|
||||
}
|
||||
|
||||
declare module '@directus-extensions-layout' {
|
||||
import { LayoutConfig } from '@directus/shared/types';
|
||||
const layouts: LayoutConfig[];
|
||||
export default layouts;
|
||||
}
|
||||
|
||||
declare module '@directus-extensions-panel' {
|
||||
import { PanelConfig } from '@directus/shared/types';
|
||||
const panel: PanelConfig[];
|
||||
export default panel;
|
||||
}
|
||||
|
||||
declare module '@directus-extensions-module' {
|
||||
import { ModuleConfig } from '@directus/shared/types';
|
||||
const modules: ModuleConfig[];
|
||||
export default modules;
|
||||
}
|
||||
declare module '@directus-extensions' {}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import api from '@/api';
|
||||
import { getPanels } from '@/panels';
|
||||
import { usePermissionsStore } from '@/stores/permissions';
|
||||
import { queryToGqlString } from '@/utils/query-to-gql-string';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
@@ -12,6 +11,7 @@ import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
import { computed, reactive, ref, unref } from 'vue';
|
||||
import { Dashboard } from '../types';
|
||||
import escapeStringRegexp from 'escape-string-regexp';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
export type CreatePanel = Partial<Panel> &
|
||||
Pick<Panel, 'id' | 'width' | 'height' | 'position_x' | 'position_y' | 'type' | 'options'>;
|
||||
@@ -72,7 +72,7 @@ export const useInsightsStore = defineStore('insightsStore', () => {
|
||||
];
|
||||
});
|
||||
|
||||
const { panels: panelTypes } = getPanels();
|
||||
const { panels: panelTypes } = useExtensions();
|
||||
|
||||
return {
|
||||
dashboards,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getDisplay } from '@/displays';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
export function adjustFieldsForDisplays(fields: readonly string[], parentCollection: string): string[] {
|
||||
const fieldsStore = useFieldsStore();
|
||||
@@ -12,19 +13,22 @@ export function adjustFieldsForDisplays(fields: readonly string[], parentCollect
|
||||
if (!field) return fieldKey;
|
||||
if (field.meta?.display === null) return fieldKey;
|
||||
|
||||
const display = getDisplay(field.meta?.display);
|
||||
const display = useExtension(
|
||||
'display',
|
||||
computed(() => field.meta?.display ?? null)
|
||||
);
|
||||
|
||||
if (!display) return fieldKey;
|
||||
if (!display?.fields) return fieldKey;
|
||||
if (!display.value?.fields) return fieldKey;
|
||||
|
||||
let fieldKeys: string[] | null = null;
|
||||
|
||||
if (Array.isArray(display.fields)) {
|
||||
fieldKeys = display.fields.map((relatedFieldKey: string) => `${fieldKey}.${relatedFieldKey}`);
|
||||
if (Array.isArray(display.value.fields)) {
|
||||
fieldKeys = display.value.fields.map((relatedFieldKey: string) => `${fieldKey}.${relatedFieldKey}`);
|
||||
}
|
||||
|
||||
if (typeof display.fields === 'function') {
|
||||
fieldKeys = display
|
||||
if (typeof display.value.fields === 'function') {
|
||||
fieldKeys = display.value
|
||||
.fields(field.meta?.display_options, {
|
||||
collection: field.collection,
|
||||
field: field.field,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAliasFields } from '@/composables/use-alias-fields';
|
||||
import { getDisplay } from '@/displays';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { DisplayConfig, Field } from '@directus/shared/types';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import { getFieldsFromTemplate } from '@directus/shared/utils';
|
||||
import { render, renderFn } from 'micromustache';
|
||||
import { computed, ComputedRef, Ref, ref, unref } from 'vue';
|
||||
import { get, set } from 'lodash';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
type StringTemplate = {
|
||||
fieldsInTemplate: ComputedRef<string[]>;
|
||||
@@ -77,18 +77,17 @@ export function renderDisplayStringTemplate(
|
||||
? get(item, key)
|
||||
: get(item, aliasFields.value[key].fullAlias);
|
||||
|
||||
let display: DisplayConfig | undefined;
|
||||
|
||||
if (fieldsUsed[key]?.meta?.display) {
|
||||
display = getDisplay(fieldsUsed[key]!.meta!.display);
|
||||
}
|
||||
const display = useExtension(
|
||||
'display',
|
||||
computed(() => fieldsUsed[key]?.meta?.display ?? null)
|
||||
);
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
set(
|
||||
parsedItem,
|
||||
key,
|
||||
display?.handler
|
||||
? display.handler(value, fieldsUsed[key]?.meta?.display_options ?? {}, {
|
||||
display.value?.handler
|
||||
? display.value.handler(value, fieldsUsed[key]?.meta?.display_options ?? {}, {
|
||||
interfaceOptions: fieldsUsed[key]?.meta?.options ?? {},
|
||||
field: fieldsUsed[key] ?? undefined,
|
||||
collection: collection,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAliasFields } from '@/composables/use-alias-fields';
|
||||
import { getDisplay } from '@/displays';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { get } from '@directus/shared/utils';
|
||||
import { DisplayConfig, Field, Item } from '@directus/shared/types';
|
||||
import { Field, Item } from '@directus/shared/types';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { parse } from 'json2csv';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
/**
|
||||
* Saves the given collection + items combination as a CSV file
|
||||
@@ -48,15 +48,14 @@ export async function saveAsCSV(collection: string, fields: string[], items: Ite
|
||||
? get(item, key)
|
||||
: get(item, aliasFields.value[key].fullAlias);
|
||||
|
||||
let display: DisplayConfig | undefined;
|
||||
|
||||
if (fieldsUsed[key]?.meta?.display) {
|
||||
display = getDisplay(fieldsUsed[key]!.meta!.display);
|
||||
}
|
||||
const display = useExtension(
|
||||
'display',
|
||||
computed(() => fieldsUsed[key]?.meta?.display ?? null)
|
||||
);
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
parsedItem[name] = display?.handler
|
||||
? await display.handler(value, fieldsUsed[key]?.meta?.display_options ?? {}, {
|
||||
parsedItem[name] = display.value?.handler
|
||||
? await display.value.handler(value, fieldsUsed[key]?.meta?.display_options ?? {}, {
|
||||
interfaceOptions: fieldsUsed[key]?.meta?.options ?? {},
|
||||
field: fieldsUsed[key] ?? undefined,
|
||||
collection: collection,
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { getLayouts, getLayout } from '@/layouts';
|
||||
import { useSync } from '@directus/shared/composables';
|
||||
import { useExtensions } from '@/extensions';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -32,9 +33,11 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { layouts } = getLayouts();
|
||||
const { layouts } = useExtensions();
|
||||
|
||||
const currentLayout = computed(() => getLayout(props.modelValue) ?? getLayout('tabular'));
|
||||
const selectedLayout = useExtension('layout', props.modelValue);
|
||||
const fallbackLayout = useExtension('layout', 'tabular');
|
||||
const currentLayout = computed(() => selectedLayout.value ?? fallbackLayout.value);
|
||||
|
||||
const layout = useSync(props, 'modelValue', emit);
|
||||
|
||||
|
||||
@@ -30,13 +30,13 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { getModules } from '@/modules/';
|
||||
import ModuleBarLogo from './module-bar-logo.vue';
|
||||
import ModuleBarAvatar from './module-bar-avatar.vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { MODULE_BAR_DEFAULT } from '@/constants';
|
||||
import { omit } from 'lodash';
|
||||
import { useExtensions } from '@/extensions';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -45,7 +45,7 @@ export default defineComponent({
|
||||
},
|
||||
setup() {
|
||||
const settingsStore = useSettingsStore();
|
||||
const { modules: registeredModules } = getModules();
|
||||
const { modules: registeredModules } = useExtensions();
|
||||
|
||||
const registeredModuleIDs = computed(() => registeredModules.value.map((module) => module.id));
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { getDisplay } from '@/displays';
|
||||
import { defineComponent } from 'vue';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -55,7 +55,8 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const displayInfo = computed(() => getDisplay(props.display));
|
||||
const displayInfo = useExtension('display', props.display);
|
||||
|
||||
return { displayInfo, translate };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,9 +25,9 @@ import { computed, ref } from 'vue';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { get } from 'lodash';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import { getDisplay } from '@/displays';
|
||||
import { getDefaultDisplayForType } from '@/utils/get-default-display-for-type';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { useExtension } from '@/composables/use-extension';
|
||||
|
||||
interface Props {
|
||||
template: string;
|
||||
@@ -79,12 +79,15 @@ function handleArray(fieldKeyBefore: string, fieldKeyAfter: string) {
|
||||
|
||||
if (!field) return value;
|
||||
|
||||
const displayInfo = getDisplay(field.meta?.display);
|
||||
const displayInfo = useExtension(
|
||||
'display',
|
||||
computed(() => field.meta?.display ?? null)
|
||||
);
|
||||
|
||||
let component = field.meta?.display;
|
||||
let options = field.meta?.display_options;
|
||||
|
||||
if (!displayInfo) {
|
||||
if (!displayInfo.value) {
|
||||
component = 'related-values';
|
||||
options = { template: `{{${fieldKeyAfter}}}` };
|
||||
}
|
||||
@@ -131,10 +134,13 @@ function handleObject(fieldKey: string) {
|
||||
// No need to render the empty display overhead in this case
|
||||
if (display === 'raw') return value;
|
||||
|
||||
const displayInfo = getDisplay(field.meta?.display);
|
||||
const displayInfo = useExtension(
|
||||
'display',
|
||||
computed(() => field.meta?.display ?? null)
|
||||
);
|
||||
|
||||
// If used display doesn't exist in the current project, return raw value
|
||||
if (!displayInfo) return value;
|
||||
if (!displayInfo.value) return value;
|
||||
|
||||
return {
|
||||
component: field.meta?.display,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@directus/shared/constants';
|
||||
import {
|
||||
ensureExtensionDirs,
|
||||
generateExtensionsEntry,
|
||||
generateExtensionsEntrypoint,
|
||||
getLocalExtensions,
|
||||
getPackageExtensions,
|
||||
} from '@directus/shared/utils/node';
|
||||
@@ -156,6 +156,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['src/__setup__/mock-globals.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,10 +171,9 @@ function getExtensionsRealPaths() {
|
||||
}
|
||||
|
||||
function directusExtensions() {
|
||||
const prefix = '@directus-extensions-';
|
||||
const virtualIds = APP_OR_HYBRID_EXTENSION_TYPES.map((type) => `${prefix}${type}`);
|
||||
const virtualExtensionsId = '@directus-extensions';
|
||||
|
||||
let extensionEntrypoints = {};
|
||||
let extensionsEntrypoint = null;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -188,15 +188,13 @@ function directusExtensions() {
|
||||
await loadExtensions();
|
||||
},
|
||||
resolveId(id) {
|
||||
if (virtualIds.includes(id)) {
|
||||
if (id === virtualExtensionsId) {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (virtualIds.includes(id)) {
|
||||
const extensionType = id.substring(prefix.length);
|
||||
|
||||
return extensionEntrypoints[extensionType];
|
||||
if (id === virtualExtensionsId) {
|
||||
return extensionsEntrypoint;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -213,7 +211,7 @@ function directusExtensions() {
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].[hash].entry.js',
|
||||
},
|
||||
external: virtualIds,
|
||||
external: [virtualExtensionsId],
|
||||
preserveEntrySignatures: 'exports-only',
|
||||
},
|
||||
},
|
||||
@@ -228,8 +226,6 @@ function directusExtensions() {
|
||||
|
||||
const extensions = [...packageExtensions, ...localExtensions];
|
||||
|
||||
for (const extensionType of APP_OR_HYBRID_EXTENSION_TYPES) {
|
||||
extensionEntrypoints[extensionType] = generateExtensionsEntry(extensionType, extensions);
|
||||
}
|
||||
extensionsEntrypoint = generateExtensionsEntrypoint(extensions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
'use strict';
|
||||
|
||||
const inquirer = require('inquirer');
|
||||
const { EXTENSION_TYPES, EXTENSION_LANGUAGES } = require('@directus/shared/constants');
|
||||
const { EXTENSION_LANGUAGES, EXTENSION_PACKAGE_TYPES, EXTENSION_TYPES } = require('@directus/shared/constants');
|
||||
const { create } = require('@directus/extensions-sdk/cli');
|
||||
|
||||
run();
|
||||
@@ -16,7 +16,7 @@ async function run() {
|
||||
type: 'list',
|
||||
name: 'type',
|
||||
message: 'Choose the extension type',
|
||||
choices: EXTENSION_TYPES,
|
||||
choices: EXTENSION_PACKAGE_TYPES,
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
@@ -28,6 +28,7 @@ async function run() {
|
||||
name: 'language',
|
||||
message: 'Choose the language to use',
|
||||
choices: EXTENSION_LANGUAGES,
|
||||
when: ({ type }) => EXTENSION_TYPES.includes(type),
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -54,11 +54,13 @@
|
||||
"@rollup/plugin-node-resolve": "15.0.0",
|
||||
"@rollup/plugin-replace": "5.0.0",
|
||||
"@rollup/plugin-terser": "0.1.0",
|
||||
"@rollup/plugin-virtual": "2.1.0",
|
||||
"@vue/compiler-sfc": "3.2.41",
|
||||
"chalk": "4.1.1",
|
||||
"commander": "9.4.1",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "10.1.0",
|
||||
"inquirer": "^8.2.4",
|
||||
"ora": "5.4.0",
|
||||
"rollup": "3.2.3",
|
||||
"rollup-plugin-styles": "4.0.0",
|
||||
@@ -67,6 +69,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "9.0.13",
|
||||
"@types/inquirer": "8.2.1",
|
||||
"npm-run-all": "4.1.5",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "4.8.4"
|
||||
|
||||
304
packages/extensions-sdk/src/cli/commands/add.ts
Normal file
304
packages/extensions-sdk/src/cli/commands/add.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import fse from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import { log } from '../utils/logger';
|
||||
import {
|
||||
ExtensionManifestRaw,
|
||||
ExtensionOptions,
|
||||
ExtensionOptionsBundleEntry,
|
||||
ExtensionType,
|
||||
} from '@directus/shared/types';
|
||||
import { isIn, isTypeIn, validateExtensionManifest } from '@directus/shared/utils';
|
||||
import { pathToRelativeUrl } from '@directus/shared/utils/node';
|
||||
import {
|
||||
EXTENSION_LANGUAGES,
|
||||
EXTENSION_NAME_REGEX,
|
||||
EXTENSION_PKG_KEY,
|
||||
EXTENSION_TYPES,
|
||||
HYBRID_EXTENSION_TYPES,
|
||||
} from '@directus/shared/constants';
|
||||
import { getLanguageFromPath, isLanguage, languageToShort } from '../utils/languages';
|
||||
import { Language } from '../types';
|
||||
import getExtensionDevDeps from './helpers/get-extension-dev-deps';
|
||||
import execa from 'execa';
|
||||
import ora from 'ora';
|
||||
import copyTemplate from './helpers/copy-template';
|
||||
import detectJsonIndent from '../utils/detect-json-indent';
|
||||
|
||||
export default async function add(): Promise<void> {
|
||||
const extensionPath = process.cwd();
|
||||
const packagePath = path.resolve('package.json');
|
||||
|
||||
if (!(await fse.pathExists(packagePath))) {
|
||||
log(`Current directory is not a valid package.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const extensionManifestFile = await fse.readFile(packagePath, 'utf8');
|
||||
const extensionManifest: ExtensionManifestRaw = JSON.parse(extensionManifestFile);
|
||||
|
||||
const indent = detectJsonIndent(extensionManifestFile);
|
||||
|
||||
if (!validateExtensionManifest(extensionManifest)) {
|
||||
log(`Current directory is not a valid Directus extension.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
|
||||
|
||||
if (extensionOptions.type === 'pack') {
|
||||
log(`Adding entries to extensions with type ${chalk.bold('pack')} is not currently supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sourceExists = await fse.pathExists(path.resolve('src'));
|
||||
|
||||
if (extensionOptions.type === 'bundle') {
|
||||
const { type, name, language, alternativeSource } = await inquirer.prompt<{
|
||||
type: ExtensionType;
|
||||
name: string;
|
||||
language: Language;
|
||||
alternativeSource?: string;
|
||||
}>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'type',
|
||||
message: 'Choose the extension type',
|
||||
choices: EXTENSION_TYPES,
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Choose a name for the entry',
|
||||
validate: (name: string) => (name.length === 0 ? 'Entry name can not be empty.' : true),
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'language',
|
||||
message: 'Choose the language to use',
|
||||
choices: EXTENSION_LANGUAGES,
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'alternativeSource',
|
||||
message: 'Specify the path to the extension source',
|
||||
when: !sourceExists && extensionOptions.entries.length > 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const spinner = ora(chalk.bold('Modifying Directus extension...')).start();
|
||||
|
||||
const source = alternativeSource ?? 'src';
|
||||
|
||||
const sourcePath = path.resolve(source, name);
|
||||
|
||||
await fse.ensureDir(sourcePath);
|
||||
await copyTemplate(type, extensionPath, sourcePath, language);
|
||||
|
||||
const newEntries: ExtensionOptionsBundleEntry[] = [
|
||||
...extensionOptions.entries,
|
||||
isIn(type, HYBRID_EXTENSION_TYPES)
|
||||
? {
|
||||
type,
|
||||
name,
|
||||
source: {
|
||||
app: `${pathToRelativeUrl(source)}/${name}/app.${languageToShort(language)}`,
|
||||
api: `${pathToRelativeUrl(source)}/${name}/api.${languageToShort(language)}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type,
|
||||
name,
|
||||
source: `${pathToRelativeUrl(source)}/${name}/index.${languageToShort(language)}`,
|
||||
},
|
||||
];
|
||||
|
||||
const newExtensionOptions: ExtensionOptions = { ...extensionOptions, entries: newEntries };
|
||||
const newExtensionManifest = {
|
||||
...extensionManifest,
|
||||
[EXTENSION_PKG_KEY]: newExtensionOptions,
|
||||
devDependencies: await getExtensionDevDeps(
|
||||
newEntries.map((entry) => entry.type),
|
||||
getLanguageFromEntries(newEntries)
|
||||
),
|
||||
};
|
||||
|
||||
await fse.writeJSON(packagePath, newExtensionManifest, { spaces: indent ?? '\t' });
|
||||
|
||||
await execa('npm', ['install'], { cwd: extensionPath });
|
||||
|
||||
spinner.succeed(chalk.bold('Done'));
|
||||
} else {
|
||||
const { proceed } = await inquirer.prompt<{ proceed: boolean }>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'proceed',
|
||||
message: 'This will convert your extension to a bundle. Do you want to proceed?',
|
||||
},
|
||||
]);
|
||||
|
||||
if (!proceed) {
|
||||
log(`Extension has not been modified.`, 'info');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const oldName = extensionManifest.name.match(EXTENSION_NAME_REGEX)?.[1] ?? extensionManifest.name;
|
||||
|
||||
const { type, name, language, convertName, extensionName, alternativeSource } = await inquirer.prompt<{
|
||||
type: ExtensionType;
|
||||
name: string;
|
||||
language: Language;
|
||||
convertName: string;
|
||||
extensionName: string;
|
||||
alternativeSource?: string;
|
||||
}>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'type',
|
||||
message: 'Choose the extension type',
|
||||
choices: EXTENSION_TYPES,
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Choose a name for the entry',
|
||||
validate: (name: string) => (name.length === 0 ? 'Entry name can not be empty.' : true),
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'language',
|
||||
message: 'Choose the language to use',
|
||||
choices: EXTENSION_LANGUAGES,
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'convertName',
|
||||
message: 'Choose a name for the extension that is converted to an entry',
|
||||
default: oldName,
|
||||
validate: (name: string) => (name.length === 0 ? 'Entry name can not be empty.' : true),
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'extensionName',
|
||||
message: 'Choose a name for the extension',
|
||||
default: ({ convertName }: { convertName: string }) => (convertName !== oldName ? oldName : null),
|
||||
validate: (name: string) => (name.length === 0 ? 'Extension name can not be empty.' : true),
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'alternativeSource',
|
||||
message: 'Specify the path to the extension source',
|
||||
when: !sourceExists,
|
||||
},
|
||||
]);
|
||||
|
||||
const spinner = ora(chalk.bold('Modifying Directus extension...')).start();
|
||||
|
||||
const source = alternativeSource ?? 'src';
|
||||
|
||||
const convertSourcePath = path.resolve(source, convertName);
|
||||
const entrySourcePath = path.resolve(source, name);
|
||||
|
||||
const convertFiles = await fse.readdir(source);
|
||||
|
||||
await Promise.all(
|
||||
convertFiles.map((file) => fse.move(path.resolve(source, file), path.join(convertSourcePath, file)))
|
||||
);
|
||||
|
||||
await fse.ensureDir(entrySourcePath);
|
||||
await copyTemplate(type, extensionPath, entrySourcePath, language);
|
||||
|
||||
const toConvertSourceUrl = (entrypoint: string) =>
|
||||
path.posix.join(pathToRelativeUrl(source), convertName, path.posix.relative(source, entrypoint));
|
||||
|
||||
const entries: ExtensionOptionsBundleEntry[] = [
|
||||
isTypeIn(extensionOptions, HYBRID_EXTENSION_TYPES)
|
||||
? {
|
||||
type: extensionOptions.type,
|
||||
name: convertName,
|
||||
source: {
|
||||
app: toConvertSourceUrl(extensionOptions.source.app),
|
||||
api: toConvertSourceUrl(extensionOptions.source.api),
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: extensionOptions.type,
|
||||
name: convertName,
|
||||
source: toConvertSourceUrl(extensionOptions.source),
|
||||
},
|
||||
isIn(type, HYBRID_EXTENSION_TYPES)
|
||||
? {
|
||||
type,
|
||||
name,
|
||||
source: {
|
||||
app: `${pathToRelativeUrl(source)}/${name}/app.${languageToShort(language)}`,
|
||||
api: `${pathToRelativeUrl(source)}/${name}/api.${languageToShort(language)}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type,
|
||||
name,
|
||||
source: `${pathToRelativeUrl(source)}/${name}/index.${languageToShort(language)}`,
|
||||
},
|
||||
];
|
||||
|
||||
const newExtensionOptions: ExtensionOptions = {
|
||||
type: 'bundle',
|
||||
path: { app: 'dist/app.js', api: 'dist/api.js' },
|
||||
entries,
|
||||
host: extensionOptions.host,
|
||||
hidden: extensionOptions.hidden,
|
||||
};
|
||||
const newExtensionManifest = {
|
||||
...extensionManifest,
|
||||
name: EXTENSION_NAME_REGEX.test(extensionName) ? extensionName : `directus-extension-${extensionName}`,
|
||||
keywords: ['directus', 'directus-extension', `directus-custom-bundle`],
|
||||
[EXTENSION_PKG_KEY]: newExtensionOptions,
|
||||
devDependencies: await getExtensionDevDeps(
|
||||
entries.map((entry) => entry.type),
|
||||
getLanguageFromEntries(entries)
|
||||
),
|
||||
};
|
||||
|
||||
await fse.writeJSON(packagePath, newExtensionManifest, { spaces: indent ?? '\t' });
|
||||
|
||||
await execa('npm', ['install'], { cwd: extensionPath });
|
||||
|
||||
spinner.succeed(chalk.bold('Done'));
|
||||
}
|
||||
}
|
||||
|
||||
function getLanguageFromEntries(entries: ExtensionOptionsBundleEntry[]): Language[] {
|
||||
const languages = new Set<Language>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (isTypeIn(entry, HYBRID_EXTENSION_TYPES)) {
|
||||
const languageApp = getLanguageFromPath(entry.source.app);
|
||||
const languageApi = getLanguageFromPath(entry.source.api);
|
||||
|
||||
if (!isLanguage(languageApp)) {
|
||||
log(`App language ${chalk.bold(languageApp)} is not supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!isLanguage(languageApi)) {
|
||||
log(`API language ${chalk.bold(languageApi)} is not supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
languages.add(languageApp);
|
||||
languages.add(languageApi);
|
||||
} else {
|
||||
const language = getLanguageFromPath(entry.source);
|
||||
|
||||
if (!isLanguage(language)) {
|
||||
log(`Language ${chalk.bold(language)} is not supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
languages.add(language);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(languages);
|
||||
}
|
||||
@@ -2,17 +2,23 @@ import {
|
||||
API_SHARED_DEPS,
|
||||
APP_EXTENSION_TYPES,
|
||||
APP_SHARED_DEPS,
|
||||
EXTENSION_PACKAGE_TYPES,
|
||||
EXTENSION_PKG_KEY,
|
||||
EXTENSION_TYPES,
|
||||
HYBRID_EXTENSION_TYPES,
|
||||
} from '@directus/shared/constants';
|
||||
import { ApiExtensionType, AppExtensionType, ExtensionManifestRaw } from '@directus/shared/types';
|
||||
import {
|
||||
ApiExtensionType,
|
||||
AppExtensionType,
|
||||
ExtensionManifestRaw,
|
||||
ExtensionOptionsBundleEntry,
|
||||
} from '@directus/shared/types';
|
||||
import { isIn, isTypeIn, validateExtensionManifest } from '@directus/shared/utils';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import json from '@rollup/plugin-json';
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import virtual from '@rollup/plugin-virtual';
|
||||
import chalk from 'chalk';
|
||||
import fse from 'fs-extra';
|
||||
import ora from 'ora';
|
||||
@@ -30,9 +36,11 @@ import typescript from 'rollup-plugin-typescript2';
|
||||
import vue from 'rollup-plugin-vue';
|
||||
import { Language, RollupConfig, RollupMode } from '../types';
|
||||
import { getLanguageFromPath, isLanguage } from '../utils/languages';
|
||||
import loadConfig from '../utils/load-config';
|
||||
import { clear, log } from '../utils/logger';
|
||||
import toObject from '../utils/to-object';
|
||||
import tryParseJson from '../utils/try-parse-json';
|
||||
import generateBundleEntrypoint from './helpers/generate-bundle-entrypoint';
|
||||
import loadConfig from './helpers/load-config';
|
||||
import { validateBundleEntriesOption, validateSplitEntrypointOption } from './helpers/validate-cli-options';
|
||||
|
||||
type BuildOptions = {
|
||||
type?: string;
|
||||
@@ -50,35 +58,36 @@ export default async function build(options: BuildOptions): Promise<void> {
|
||||
|
||||
if (!options.type && !options.input && !options.output) {
|
||||
const packagePath = path.resolve('package.json');
|
||||
let extensionManifest: ExtensionManifestRaw = {};
|
||||
|
||||
if (!(await fse.pathExists(packagePath))) {
|
||||
log(`Current directory is not a valid package.`, 'error');
|
||||
process.exit(1);
|
||||
} else {
|
||||
extensionManifest = await fse.readJSON(packagePath);
|
||||
}
|
||||
|
||||
if (!validateExtensionManifest(extensionManifest)) {
|
||||
log(`Current directory is not a valid Directus extension.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
const extensionManifest: ExtensionManifestRaw = await fse.readJSON(packagePath);
|
||||
|
||||
if (!validateExtensionManifest(extensionManifest)) {
|
||||
log(`Current directory is not a valid Directus extension.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
|
||||
|
||||
if (!isTypeIn(extensionOptions, EXTENSION_TYPES)) {
|
||||
log(
|
||||
`Extension type ${chalk.bold(
|
||||
extensionOptions.type
|
||||
)} is not supported. Available extension types: ${EXTENSION_TYPES.map((t) => chalk.bold.magenta(t)).join(
|
||||
', '
|
||||
)}.`,
|
||||
'error'
|
||||
);
|
||||
if (extensionOptions.type === 'pack') {
|
||||
log(`Building extension type ${chalk.bold('pack')} is not currently supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isTypeIn(extensionOptions, HYBRID_EXTENSION_TYPES)) {
|
||||
if (extensionOptions.type === 'bundle') {
|
||||
await buildBundleExtension({
|
||||
entries: extensionOptions.entries,
|
||||
outputApp: extensionOptions.path.app,
|
||||
outputApi: extensionOptions.path.api,
|
||||
watch,
|
||||
sourcemap,
|
||||
minify,
|
||||
});
|
||||
} else if (isTypeIn(extensionOptions, HYBRID_EXTENSION_TYPES)) {
|
||||
await buildHybridExtension({
|
||||
inputApp: extensionOptions.source.app,
|
||||
inputApi: extensionOptions.source.api,
|
||||
@@ -106,16 +115,23 @@ export default async function build(options: BuildOptions): Promise<void> {
|
||||
if (!type) {
|
||||
log(`Extension type has to be specified using the ${chalk.blue('[-t, --type <type>]')} option.`, 'error');
|
||||
process.exit(1);
|
||||
} else if (!isIn(type, EXTENSION_TYPES)) {
|
||||
}
|
||||
|
||||
if (!isIn(type, EXTENSION_PACKAGE_TYPES)) {
|
||||
log(
|
||||
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_TYPES.map((t) =>
|
||||
chalk.bold.magenta(t)
|
||||
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_PACKAGE_TYPES.map(
|
||||
(t) => chalk.bold.magenta(t)
|
||||
).join(', ')}.`,
|
||||
'error'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (type === 'pack') {
|
||||
log(`Building extension type ${chalk.bold('pack')} is not currently supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
log(`Extension entrypoint has to be specified using the ${chalk.blue('[-i, --input <file>]')} option.`, 'error');
|
||||
process.exit(1);
|
||||
@@ -128,30 +144,65 @@ export default async function build(options: BuildOptions): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isIn(type, HYBRID_EXTENSION_TYPES)) {
|
||||
const inputObject = toObject(input);
|
||||
const outputObject = toObject(output);
|
||||
if (type === 'bundle') {
|
||||
const entries = tryParseJson(input);
|
||||
const splitOutput = tryParseJson(output);
|
||||
|
||||
if (!inputObject || !inputObject.app || !inputObject.api) {
|
||||
if (!validateBundleEntriesOption(entries)) {
|
||||
log(
|
||||
`Input option needs to be of the format ${chalk.blue('[-i app:<app-entrypoint>,api:<api-entrypoint>]')}.`,
|
||||
`Input option needs to be of the format ${chalk.blue(
|
||||
`[-i '[{"type":"<extension-type>","name":"<extension-name>","source":<entrypoint>}]']`
|
||||
)}.`,
|
||||
'error'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!outputObject || !outputObject.app || !outputObject.api) {
|
||||
if (!validateSplitEntrypointOption(splitOutput)) {
|
||||
log(
|
||||
`Output option needs to be of the format ${chalk.blue('[-o app:<app-output-file>,api:<api-output-file>]')}.`,
|
||||
`Output option needs to be of the format ${chalk.blue(
|
||||
`[-o '{"app":"<app-entrypoint>","api":"<api-entrypoint>"}']`
|
||||
)}.`,
|
||||
'error'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await buildBundleExtension({
|
||||
entries,
|
||||
outputApp: splitOutput.app,
|
||||
outputApi: splitOutput.api,
|
||||
watch,
|
||||
sourcemap,
|
||||
minify,
|
||||
});
|
||||
} else if (isIn(type, HYBRID_EXTENSION_TYPES)) {
|
||||
const splitInput = tryParseJson(input);
|
||||
const splitOutput = tryParseJson(output);
|
||||
|
||||
if (!validateSplitEntrypointOption(splitInput)) {
|
||||
log(
|
||||
`Input option needs to be of the format ${chalk.blue(
|
||||
`[-i '{"app":"<app-entrypoint>","api":"<api-entrypoint>"}']`
|
||||
)}.`,
|
||||
'error'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!validateSplitEntrypointOption(splitOutput)) {
|
||||
log(
|
||||
`Output option needs to be of the format ${chalk.blue(
|
||||
`[-o '{"app":"<app-entrypoint>","api":"<api-entrypoint>"}']`
|
||||
)}.`,
|
||||
'error'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await buildHybridExtension({
|
||||
inputApp: inputObject.app,
|
||||
inputApi: inputObject.api,
|
||||
outputApp: outputObject.app,
|
||||
outputApi: outputObject.api,
|
||||
inputApp: splitInput.app,
|
||||
inputApi: splitInput.api,
|
||||
outputApp: splitOutput.app,
|
||||
outputApi: splitOutput.api,
|
||||
watch,
|
||||
sourcemap,
|
||||
minify,
|
||||
@@ -259,7 +310,7 @@ async function buildHybridExtension({
|
||||
process.exit(1);
|
||||
}
|
||||
if (!isLanguage(languageApi)) {
|
||||
log(`API language ${chalk.bold(languageApp)} is not supported.`, 'error');
|
||||
log(`API language ${chalk.bold(languageApi)} is not supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -297,6 +348,121 @@ async function buildHybridExtension({
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBundleExtension({
|
||||
entries,
|
||||
outputApp,
|
||||
outputApi,
|
||||
watch,
|
||||
sourcemap,
|
||||
minify,
|
||||
}: {
|
||||
entries: ExtensionOptionsBundleEntry[];
|
||||
outputApp: string;
|
||||
outputApi: string;
|
||||
watch: boolean;
|
||||
sourcemap: boolean;
|
||||
minify: boolean;
|
||||
}) {
|
||||
if (outputApp.length === 0) {
|
||||
log(`App output file can not be empty.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
if (outputApi.length === 0) {
|
||||
log(`API output file can not be empty.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const languagesApp = new Set<Language>();
|
||||
const languagesApi = new Set<Language>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (isTypeIn(entry, HYBRID_EXTENSION_TYPES)) {
|
||||
const inputApp = entry.source.app;
|
||||
const inputApi = entry.source.api;
|
||||
|
||||
if (!(await fse.pathExists(inputApp)) || !(await fse.stat(inputApp)).isFile()) {
|
||||
log(`App entrypoint ${chalk.bold(inputApp)} does not exist.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!(await fse.pathExists(inputApi)) || !(await fse.stat(inputApi)).isFile()) {
|
||||
log(`API entrypoint ${chalk.bold(inputApi)} does not exist.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const languageApp = getLanguageFromPath(inputApp);
|
||||
const languageApi = getLanguageFromPath(inputApi);
|
||||
|
||||
if (!isLanguage(languageApp)) {
|
||||
log(`App language ${chalk.bold(languageApp)} is not supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!isLanguage(languageApi)) {
|
||||
log(`API language ${chalk.bold(languageApi)} is not supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
languagesApp.add(languageApp);
|
||||
languagesApi.add(languageApi);
|
||||
} else {
|
||||
const input = entry.source;
|
||||
|
||||
if (!(await fse.pathExists(input)) || !(await fse.stat(input)).isFile()) {
|
||||
log(`Entrypoint ${chalk.bold(input)} does not exist.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const language = getLanguageFromPath(input);
|
||||
|
||||
if (!isLanguage(language)) {
|
||||
log(`Language ${chalk.bold(language)} is not supported.`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isIn(entry.type, APP_EXTENSION_TYPES)) {
|
||||
languagesApp.add(language);
|
||||
} else {
|
||||
languagesApi.add(language);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = await loadConfig();
|
||||
const plugins = config.plugins ?? [];
|
||||
|
||||
const entrypointApp = generateBundleEntrypoint('app', entries);
|
||||
const entrypointApi = generateBundleEntrypoint('api', entries);
|
||||
|
||||
const rollupOptionsApp = getRollupOptions({
|
||||
mode: 'browser',
|
||||
input: { entry: entrypointApp },
|
||||
language: Array.from(languagesApp),
|
||||
sourcemap,
|
||||
minify,
|
||||
plugins,
|
||||
});
|
||||
const rollupOptionsApi = getRollupOptions({
|
||||
mode: 'node',
|
||||
input: { entry: entrypointApi },
|
||||
language: Array.from(languagesApi),
|
||||
sourcemap,
|
||||
minify,
|
||||
plugins,
|
||||
});
|
||||
const rollupOutputOptionsApp = getRollupOutputOptions({ mode: 'browser', output: outputApp, sourcemap });
|
||||
const rollupOutputOptionsApi = getRollupOutputOptions({ mode: 'node', output: outputApi, sourcemap });
|
||||
|
||||
const rollupOptionsAll = [
|
||||
{ rollupOptions: rollupOptionsApp, rollupOutputOptions: rollupOutputOptionsApp },
|
||||
{ rollupOptions: rollupOptionsApi, rollupOutputOptions: rollupOutputOptionsApi },
|
||||
];
|
||||
|
||||
if (watch) {
|
||||
await watchExtension(rollupOptionsAll);
|
||||
} else {
|
||||
await buildExtension(rollupOptionsAll);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildExtension(config: RollupConfig | RollupConfig[]) {
|
||||
const configs = Array.isArray(config) ? config : [config];
|
||||
|
||||
@@ -388,19 +554,22 @@ function getRollupOptions({
|
||||
plugins,
|
||||
}: {
|
||||
mode: RollupMode;
|
||||
input: string;
|
||||
language: Language;
|
||||
input: string | Record<string, string>;
|
||||
language: Language | Language[];
|
||||
sourcemap: boolean;
|
||||
minify: boolean;
|
||||
plugins: Plugin[];
|
||||
}): RollupOptions {
|
||||
const languages = Array.isArray(language) ? language : [language];
|
||||
|
||||
if (mode === 'browser') {
|
||||
return {
|
||||
input,
|
||||
input: typeof input !== 'string' ? 'entry' : input,
|
||||
external: APP_SHARED_DEPS,
|
||||
plugins: [
|
||||
typeof input !== 'string' ? virtual(input) : null,
|
||||
vue({ preprocessStyles: true }) as Plugin,
|
||||
language === 'typescript' ? typescript({ check: false }) : null,
|
||||
languages.includes('typescript') ? typescript({ check: false }) : null,
|
||||
styles(),
|
||||
...plugins,
|
||||
nodeResolve({ browser: true }),
|
||||
@@ -417,10 +586,11 @@ function getRollupOptions({
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
input,
|
||||
input: typeof input !== 'string' ? 'entry' : input,
|
||||
external: API_SHARED_DEPS,
|
||||
plugins: [
|
||||
language === 'typescript' ? typescript({ check: false }) : null,
|
||||
typeof input !== 'string' ? virtual(input) : null,
|
||||
languages.includes('typescript') ? typescript({ check: false }) : null,
|
||||
...plugins,
|
||||
nodeResolve(),
|
||||
commonjs({ sourceMap: sourcemap }),
|
||||
@@ -457,7 +627,7 @@ function getRollupOutputOptions({
|
||||
return {
|
||||
file: output,
|
||||
format: 'cjs',
|
||||
exports: 'default',
|
||||
exports: 'auto',
|
||||
inlineDynamicImports: true,
|
||||
sourcemap,
|
||||
};
|
||||
|
||||
@@ -4,36 +4,31 @@ import fse from 'fs-extra';
|
||||
import execa from 'execa';
|
||||
import ora from 'ora';
|
||||
import {
|
||||
EXTENSION_TYPES,
|
||||
EXTENSION_PKG_KEY,
|
||||
EXTENSION_LANGUAGES,
|
||||
HYBRID_EXTENSION_TYPES,
|
||||
API_OR_HYBRID_EXTENSION_TYPES,
|
||||
APP_OR_HYBRID_EXTENSION_TYPES,
|
||||
EXTENSION_NAME_REGEX,
|
||||
EXTENSION_PACKAGE_TYPES,
|
||||
PACKAGE_EXTENSION_TYPES,
|
||||
} from '@directus/shared/constants';
|
||||
import { isIn } from '@directus/shared/utils';
|
||||
import { ExtensionType } from '@directus/shared/types';
|
||||
import { ExtensionOptions, ExtensionPackageType, ExtensionType, PackageExtensionType } from '@directus/shared/types';
|
||||
import { log } from '../utils/logger';
|
||||
import { isLanguage, languageToShort } from '../utils/languages';
|
||||
import renameMap from '../utils/rename-map';
|
||||
import { Language } from '../types';
|
||||
import getPackageVersion from '../utils/get-package-version';
|
||||
import getSdkVersion from '../utils/get-sdk-version';
|
||||
import getExtensionDevDeps from './helpers/get-extension-dev-deps';
|
||||
import copyTemplate from './helpers/copy-template';
|
||||
|
||||
const pkg = require('../../../../package.json');
|
||||
|
||||
const TEMPLATE_PATH = path.resolve(__dirname, '../../../../templates');
|
||||
|
||||
type CreateOptions = { language: string };
|
||||
type CreateOptions = { language?: string };
|
||||
|
||||
export default async function create(type: string, name: string, options: CreateOptions): Promise<void> {
|
||||
const targetDir = name.substring(name.lastIndexOf('/') + 1);
|
||||
const targetPath = path.resolve(targetDir);
|
||||
|
||||
if (!isIn(type, EXTENSION_TYPES)) {
|
||||
if (!isIn(type, EXTENSION_PACKAGE_TYPES)) {
|
||||
log(
|
||||
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_TYPES.map((t) =>
|
||||
chalk.bold.magenta(t)
|
||||
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_PACKAGE_TYPES.map(
|
||||
(t) => chalk.bold.magenta(t)
|
||||
).join(', ')}.`,
|
||||
'error'
|
||||
);
|
||||
@@ -61,9 +56,61 @@ export default async function create(type: string, name: string, options: Create
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLanguage(options.language)) {
|
||||
if (isIn(type, PACKAGE_EXTENSION_TYPES)) {
|
||||
await createPackageExtension({ type, name, targetDir, targetPath });
|
||||
} else {
|
||||
const language = options.language ?? 'javascript';
|
||||
|
||||
await createLocalExtension({ type, name, targetDir, targetPath, language });
|
||||
}
|
||||
}
|
||||
|
||||
async function createPackageExtension({
|
||||
type,
|
||||
name,
|
||||
targetDir,
|
||||
targetPath,
|
||||
}: {
|
||||
type: PackageExtensionType;
|
||||
name: string;
|
||||
targetDir: string;
|
||||
targetPath: string;
|
||||
}) {
|
||||
const spinner = ora(chalk.bold('Scaffolding Directus extension...')).start();
|
||||
|
||||
await fse.ensureDir(targetPath);
|
||||
await copyTemplate(type, targetPath);
|
||||
|
||||
const host = `^${getSdkVersion()}`;
|
||||
const options: ExtensionOptions =
|
||||
type === 'bundle' ? { type, path: { app: 'dist/app.js', api: 'dist/api.js' }, entries: [], host } : { type, host };
|
||||
const packageManifest = getPackageManifest(name, options, await getExtensionDevDeps(type));
|
||||
|
||||
await fse.writeJSON(path.join(targetPath, 'package.json'), packageManifest, { spaces: '\t' });
|
||||
|
||||
await execa('npm', ['install'], { cwd: targetPath });
|
||||
|
||||
spinner.succeed(chalk.bold('Done'));
|
||||
|
||||
log(getDoneMessage(type, targetDir, targetPath));
|
||||
}
|
||||
|
||||
async function createLocalExtension({
|
||||
type,
|
||||
name,
|
||||
targetDir,
|
||||
targetPath,
|
||||
language,
|
||||
}: {
|
||||
type: ExtensionType;
|
||||
name: string;
|
||||
targetDir: string;
|
||||
targetPath: string;
|
||||
language: string;
|
||||
}) {
|
||||
if (!isLanguage(language)) {
|
||||
log(
|
||||
`Language ${chalk.bold(options.language)} is not supported. Available languages: ${EXTENSION_LANGUAGES.map((t) =>
|
||||
`Language ${chalk.bold(language)} is not supported. Available languages: ${EXTENSION_LANGUAGES.map((t) =>
|
||||
chalk.bold.magenta(t)
|
||||
).join(', ')}.`,
|
||||
'error'
|
||||
@@ -74,32 +121,23 @@ export default async function create(type: string, name: string, options: Create
|
||||
const spinner = ora(chalk.bold('Scaffolding Directus extension...')).start();
|
||||
|
||||
await fse.ensureDir(targetPath);
|
||||
await copyTemplate(type, targetPath, 'src', language);
|
||||
|
||||
await fse.copy(path.join(TEMPLATE_PATH, 'common', options.language), targetPath);
|
||||
await fse.copy(path.join(TEMPLATE_PATH, type, options.language), targetPath);
|
||||
await renameMap(targetPath, (name) => (name.startsWith('_') ? `.${name.substring(1)}` : null));
|
||||
|
||||
const entryPath = isIn(type, HYBRID_EXTENSION_TYPES) ? { app: 'dist/app.js', api: 'dist/api.js' } : 'dist/index.js';
|
||||
const sourcePath = isIn(type, HYBRID_EXTENSION_TYPES)
|
||||
? { app: `src/app.${languageToShort(options.language)}`, api: `src/api.${languageToShort(options.language)}` }
|
||||
: `src/index.${languageToShort(options.language)}`;
|
||||
|
||||
const packageManifest = {
|
||||
name: EXTENSION_NAME_REGEX.test(name) ? name : `directus-extension-${name}`,
|
||||
version: '1.0.0',
|
||||
keywords: ['directus', 'directus-extension', `directus-custom-${type}`],
|
||||
[EXTENSION_PKG_KEY]: {
|
||||
type,
|
||||
path: entryPath,
|
||||
source: sourcePath,
|
||||
host: `^${pkg.version}`,
|
||||
},
|
||||
scripts: {
|
||||
build: 'directus-extension build',
|
||||
dev: 'directus-extension build -w --no-minify',
|
||||
},
|
||||
devDependencies: await getPackageDeps(type, options.language),
|
||||
};
|
||||
const host = `^${getSdkVersion()}`;
|
||||
const options: ExtensionOptions = isIn(type, HYBRID_EXTENSION_TYPES)
|
||||
? {
|
||||
type,
|
||||
path: { app: 'dist/app.js', api: 'dist/api.js' },
|
||||
source: { app: `src/app.${languageToShort(language)}`, api: `src/api.${languageToShort(language)}` },
|
||||
host,
|
||||
}
|
||||
: {
|
||||
type,
|
||||
path: 'dist/index.js',
|
||||
source: `src/index.${languageToShort(language)}`,
|
||||
host,
|
||||
};
|
||||
const packageManifest = getPackageManifest(name, options, await getExtensionDevDeps(type, language));
|
||||
|
||||
await fse.writeJSON(path.join(targetPath, 'package.json'), packageManifest, { spaces: '\t' });
|
||||
|
||||
@@ -107,29 +145,32 @@ export default async function create(type: string, name: string, options: Create
|
||||
|
||||
spinner.succeed(chalk.bold('Done'));
|
||||
|
||||
log(`
|
||||
log(getDoneMessage(type, targetDir, targetPath));
|
||||
}
|
||||
|
||||
function getPackageManifest(name: string, options: ExtensionOptions, deps: Record<string, string>) {
|
||||
return {
|
||||
name: EXTENSION_NAME_REGEX.test(name) ? name : `directus-extension-${name}`,
|
||||
version: '1.0.0',
|
||||
keywords: ['directus', 'directus-extension', `directus-custom-${options.type}`],
|
||||
[EXTENSION_PKG_KEY]: options,
|
||||
scripts: {
|
||||
build: 'directus-extension build',
|
||||
dev: 'directus-extension build -w --no-minify',
|
||||
},
|
||||
devDependencies: deps,
|
||||
};
|
||||
}
|
||||
|
||||
function getDoneMessage(type: ExtensionPackageType, targetDir: string, targetPath: string) {
|
||||
return `
|
||||
Your ${type} extension has been created at ${chalk.green(targetPath)}
|
||||
|
||||
To start developing, run:
|
||||
${chalk.blue('cd')} ${targetDir}
|
||||
${chalk.blue('npm run')} dev
|
||||
${chalk.blue('cd')} ${targetDir}
|
||||
${chalk.blue('npm run')} dev
|
||||
|
||||
and then to build for production, run:
|
||||
${chalk.blue('npm run')} build
|
||||
`);
|
||||
}
|
||||
|
||||
async function getPackageDeps(type: ExtensionType, language: Language) {
|
||||
return {
|
||||
'@directus/extensions-sdk': pkg.version,
|
||||
...(language === 'typescript'
|
||||
? {
|
||||
...(isIn(type, API_OR_HYBRID_EXTENSION_TYPES)
|
||||
? { '@types/node': `^${await getPackageVersion('@types/node')}` }
|
||||
: {}),
|
||||
typescript: `^${await getPackageVersion('typescript')}`,
|
||||
}
|
||||
: {}),
|
||||
...(isIn(type, APP_OR_HYBRID_EXTENSION_TYPES) ? { vue: `^${await getPackageVersion('vue')}` } : {}),
|
||||
};
|
||||
${chalk.blue('npm run')} build
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import path from 'path';
|
||||
import fse from 'fs-extra';
|
||||
import getTemplatePath from '../../utils/get-template-path';
|
||||
import { ExtensionPackageType } from '@directus/shared/types';
|
||||
import { Language } from '../../types';
|
||||
|
||||
type TemplateFile = { type: 'config' | 'source'; path: string };
|
||||
|
||||
async function copyTemplateFile(templateFile: TemplateFile, extensionPath: string, sourcePath?: string) {
|
||||
if (sourcePath !== undefined) {
|
||||
const oldName = path.basename(templateFile.path);
|
||||
const newName = oldName.startsWith('_') ? `.${oldName.substring(1)}` : oldName;
|
||||
|
||||
const targetPath =
|
||||
templateFile.type === 'config'
|
||||
? path.join(extensionPath, newName)
|
||||
: path.resolve(extensionPath, sourcePath, newName);
|
||||
|
||||
await fse.copy(templateFile.path, targetPath, { overwrite: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesInDir(templatePath: string): Promise<string[]> {
|
||||
if (!(await fse.pathExists(templatePath))) return [];
|
||||
|
||||
const files = await fse.readdir(templatePath);
|
||||
|
||||
return files.map((file) => path.join(templatePath, file));
|
||||
}
|
||||
|
||||
async function getLanguageTemplateFiles(templateLanguagePath: string): Promise<TemplateFile[]> {
|
||||
const [configFiles, sourceFiles] = await Promise.all([
|
||||
getFilesInDir(path.join(templateLanguagePath, 'config')),
|
||||
getFilesInDir(path.join(templateLanguagePath, 'source')),
|
||||
]);
|
||||
|
||||
const configTemplateFiles: TemplateFile[] = configFiles.map((file) => ({ type: 'config', path: file }));
|
||||
const sourceTemplateFiles: TemplateFile[] = sourceFiles.map((file) => ({ type: 'source', path: file }));
|
||||
|
||||
return [...configTemplateFiles, ...sourceTemplateFiles];
|
||||
}
|
||||
|
||||
async function getTypeTemplateFiles(templateTypePath: string, language?: Language): Promise<TemplateFile[]> {
|
||||
const [commonTemplateFiles, languageTemplateFiles] = await Promise.all([
|
||||
getLanguageTemplateFiles(path.join(templateTypePath, 'common')),
|
||||
language ? getLanguageTemplateFiles(path.join(templateTypePath, language)) : null,
|
||||
]);
|
||||
|
||||
return [...commonTemplateFiles, ...(languageTemplateFiles ? languageTemplateFiles : [])];
|
||||
}
|
||||
|
||||
async function getTemplateFiles(type: ExtensionPackageType, language?: Language): Promise<TemplateFile[]> {
|
||||
const templatePath = getTemplatePath();
|
||||
|
||||
const [commonTemplateFiles, typeTemplateFiles] = await Promise.all([
|
||||
getTypeTemplateFiles(path.join(templatePath, 'common'), language),
|
||||
getTypeTemplateFiles(path.join(templatePath, type), language),
|
||||
]);
|
||||
|
||||
return [...commonTemplateFiles, ...typeTemplateFiles];
|
||||
}
|
||||
|
||||
export default async function copyTemplate(
|
||||
type: ExtensionPackageType,
|
||||
extensionPath: string,
|
||||
sourcePath?: string,
|
||||
language?: Language
|
||||
): Promise<void> {
|
||||
const templateFiles = await getTemplateFiles(type, language);
|
||||
|
||||
await Promise.all(templateFiles.map((templateFile) => copyTemplateFile(templateFile, extensionPath, sourcePath)));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import path from 'path';
|
||||
import {
|
||||
API_OR_HYBRID_EXTENSION_TYPES,
|
||||
APP_OR_HYBRID_EXTENSION_TYPES,
|
||||
HYBRID_EXTENSION_TYPES,
|
||||
} from '@directus/shared/constants';
|
||||
import { ExtensionOptionsBundleEntry } from '@directus/shared/types';
|
||||
import { isIn, isTypeIn, pluralize } from '@directus/shared/utils';
|
||||
import { pathToRelativeUrl } from '@directus/shared/utils/node';
|
||||
|
||||
export default function generateBundleEntrypoint(mode: 'app' | 'api', entries: ExtensionOptionsBundleEntry[]): string {
|
||||
const types = mode === 'app' ? APP_OR_HYBRID_EXTENSION_TYPES : API_OR_HYBRID_EXTENSION_TYPES;
|
||||
|
||||
const entriesForTypes = entries.filter((entry) => isIn(entry.type, types));
|
||||
|
||||
const imports = entriesForTypes.map(
|
||||
(entry, i) =>
|
||||
`import e${i} from './${pathToRelativeUrl(
|
||||
path.resolve(
|
||||
isTypeIn(entry, HYBRID_EXTENSION_TYPES)
|
||||
? mode === 'app'
|
||||
? entry.source.app
|
||||
: entry.source.api
|
||||
: entry.source
|
||||
)
|
||||
)}';`
|
||||
);
|
||||
|
||||
const exports = types.map(
|
||||
(type) =>
|
||||
`export const ${pluralize(type)} = [${entriesForTypes
|
||||
.map((entry, i) =>
|
||||
entry.type === type ? (mode === 'app' ? `e${i}` : `{name:'${entry.name}',config:e${i}}`) : null
|
||||
)
|
||||
.filter((e): e is string => e !== null)
|
||||
.join(',')}];`
|
||||
);
|
||||
|
||||
return `${imports.join('')}${exports.join('')}`;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { API_OR_HYBRID_EXTENSION_TYPES, APP_OR_HYBRID_EXTENSION_TYPES } from '@directus/shared/constants';
|
||||
import { ExtensionPackageType } from '@directus/shared/types';
|
||||
import { isIn } from '@directus/shared/utils';
|
||||
import { Language } from '../../types';
|
||||
import getPackageVersion from '../../utils/get-package-version';
|
||||
import getSdkVersion from '../../utils/get-sdk-version';
|
||||
|
||||
export default async function getExtensionDevDeps(
|
||||
type: ExtensionPackageType | ExtensionPackageType[],
|
||||
language: Language | Language[] = []
|
||||
): Promise<Record<string, string>> {
|
||||
const types = Array.isArray(type) ? type : [type];
|
||||
const languages = Array.isArray(language) ? language : [language];
|
||||
|
||||
const deps: Record<string, string> = {
|
||||
'@directus/extensions-sdk': getSdkVersion(),
|
||||
};
|
||||
|
||||
if (languages.includes('typescript')) {
|
||||
if (types.some((type) => isIn(type, API_OR_HYBRID_EXTENSION_TYPES))) {
|
||||
deps['@types/node'] = `^${await getPackageVersion('@types/node')}`;
|
||||
}
|
||||
|
||||
deps['typescript'] = `^${await getPackageVersion('typescript')}`;
|
||||
}
|
||||
|
||||
if (types.some((type) => isIn(type, APP_OR_HYBRID_EXTENSION_TYPES))) {
|
||||
deps['vue'] = `^${await getPackageVersion('vue')}`;
|
||||
}
|
||||
|
||||
return deps;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path';
|
||||
import fse from 'fs-extra';
|
||||
import { Config } from '../types';
|
||||
import { Config } from '../../types';
|
||||
import { pathToRelativeUrl } from '@directus/shared/utils/node';
|
||||
|
||||
const CONFIG_FILE_NAMES = ['extension.config.js', 'extension.config.mjs', 'extension.config.cjs'];
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ExtensionOptionsBundleEntry, JsonValue, SplitEntrypoint } from '@directus/shared/types';
|
||||
import { validateExtensionOptionsBundleEntry } from '@directus/shared/utils';
|
||||
|
||||
function validateNonPrimitive(value: JsonValue | undefined): value is JsonValue[] | { [key: string]: JsonValue } {
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateSplitEntrypointOption(option: JsonValue | undefined): option is SplitEntrypoint {
|
||||
if (!validateNonPrimitive(option) || Array.isArray(option)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!option.app || !option.api) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateBundleEntriesOption(option: JsonValue | undefined): option is ExtensionOptionsBundleEntry[] {
|
||||
if (!validateNonPrimitive(option) || !Array.isArray(option)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!option.every((entry) => {
|
||||
if (!validateNonPrimitive(entry) || Array.isArray(entry)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return validateExtensionOptionsBundleEntry(entry);
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from 'commander';
|
||||
import create from './commands/create';
|
||||
import add from './commands/add';
|
||||
import build from './commands/build';
|
||||
|
||||
const pkg = require('../../../package.json');
|
||||
@@ -13,9 +14,11 @@ program
|
||||
.command('create')
|
||||
.arguments('<type> <name>')
|
||||
.description('Scaffold a new Directus extension')
|
||||
.option('-l, --language <language>', 'specify the language to use', 'javascript')
|
||||
.option('-l, --language <language>', 'specify the language to use')
|
||||
.action(create);
|
||||
|
||||
program.command('add').description('Add entries to an existing Directus extension').action(add);
|
||||
|
||||
program
|
||||
.command('build')
|
||||
.description('Bundle a Directus extension to a single entrypoint')
|
||||
|
||||
13
packages/extensions-sdk/src/cli/utils/detect-json-indent.ts
Normal file
13
packages/extensions-sdk/src/cli/utils/detect-json-indent.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function detectJsonIndent(json: string) {
|
||||
const lines = json.split(/\r?\n/);
|
||||
|
||||
const braceLine = lines.findIndex((line) => /^(?:\{|\[)/.test(line));
|
||||
|
||||
if (braceLine === -1 || braceLine + 1 > lines.length - 1) return null;
|
||||
|
||||
const indent = lines[braceLine + 1]!.match(/[ \t]+/)?.[0];
|
||||
|
||||
if (indent === undefined) return null;
|
||||
|
||||
return indent;
|
||||
}
|
||||
5
packages/extensions-sdk/src/cli/utils/get-sdk-version.ts
Normal file
5
packages/extensions-sdk/src/cli/utils/get-sdk-version.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default function getSdkVersion(): string {
|
||||
const pkg = require('../../../../package.json');
|
||||
|
||||
return pkg.version;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import path from 'path';
|
||||
|
||||
export default function getTemplatePath(): string {
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', 'templates');
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import path from 'path';
|
||||
import fse from 'fs-extra';
|
||||
|
||||
export default async function renameMap(file: string, map: (name: string) => string | null): Promise<void> {
|
||||
const info = await fse.stat(file);
|
||||
|
||||
if (info.isFile()) {
|
||||
const newName = map(path.basename(file));
|
||||
|
||||
if (newName !== null) {
|
||||
fse.rename(file, path.join(path.dirname(file), newName));
|
||||
}
|
||||
} else {
|
||||
const subFiles = await fse.readdir(file);
|
||||
|
||||
for (const subFile of subFiles) {
|
||||
await renameMap(path.join(file, subFile), map);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export default function toObject(val: string): Record<string, string> | null {
|
||||
const arr = val.split(',');
|
||||
|
||||
const obj: Record<string, string> = {};
|
||||
for (const v of arr) {
|
||||
const sub = v.match(/^([^:]+):(.+)$/);
|
||||
|
||||
if (sub) {
|
||||
obj[sub[1]!] = sub[2]!;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
9
packages/extensions-sdk/src/cli/utils/try-parse-json.ts
Normal file
9
packages/extensions-sdk/src/cli/utils/try-parse-json.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { JsonValue } from '@directus/shared/types';
|
||||
|
||||
export default function tryParseJson(str: string): JsonValue | undefined {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user